image

Сегодня хочу предложить вашему вниманию частный случай для решения «неудобств», связанных с периодичным запуском процессов в том случае, если предыдущий еще не завершился. Иначе говоря — блокировка запущенных процессов в symfony/console. Но все было бы слишком банально, если бы не необходимость блокировки среди группы серверов, а не на отдельно взятом.

Дано: Один и тот же процесс, который запускается на N серверов.
Задача: Сделать так, чтобы в единицу времени был запущен только один.

Наиболее популярные решения, которые можно встретить на «просторах»:

  1. блокировка через базу данных;
  2. сторонние приложения;
  3. нативное использование lock-файла

Основные минусы каждого из них:

База данных

  • требует подключение к базе в каждом запускаемом скрипте;
  • нужна таблица;
  • нужен код, обслуживающий запись/удаление;
  • сложности при «падении» скрипта с тем, как снять lock, нужен watchDog;
  • сложности при «падении» самой базы

Сторонние приложения (к примеру, run-one для Ubuntu)

  • не для всех платформ есть одинаковые приложения с одинаково предсказуемым поведением;
  • не всегда есть возможность установить что-то дополнительное;
  • не все умеют блокировать «в сети»

Нативные lock-файлы

  • каждая команда должна сопровождаться созданием файла;
  • сколько команд — столько строк с путем и именем lock-файла

Наиболее распространенный, конечно же, — 3й вариант, но он создает очень много неудобств при наличии большого кол-ва серверов и процессов. Поэтому я решил поделиться идеей написания singleton-команды на базе symfony/console. Но идею можно использовать и в любом другом фреймворке.

Итак, первое же, от чего пришлось отказаться — flock, который используется, к примеру, в LockHandler от symfony. Он не дает возможность блокировки среди нескольких серверов.

Вместо этого будем создавать lock-файл в расшаренной между серверами директории, с помощью маленького сервиса, это практически аналог LockHandler, но с «выпиленным» flock.

Следующее, от чего нужно избавиться — необходимость в каждой команде проверять вручную блокировку, и, самое главное — снимать ее, ведь не всегда скрипт завершается там, где мы предполагаем.

Для этого предлагаю применить нечто, похожее на Mediator — реализовать и финализировать стандартный метод execute(), который будет запущен при старте команды и навязать использование нового метода lockExecute().

Для чего это нужно:

  • весь код команды будет содержаться в методе lockExecute();
  • вызываемый при запуске метод execute() будет создавать блокировку, регистрировать снятие блокировки при падении/завершении скрипта и только потом — выполнять lockExecute()

В итоге, стандартная команда symfony:

class CreateUserCommand extends Command
{
    protected function configure()
    {
        // ...
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        // ...
    }
}

будет выглядеть так:

class CreateUserCommand extends SingletonCommand implements SingletonCommandInterface
{
    protected function configure()
    {
        // ...
    }

    public function lockExecute(InputInterface $input, OutputInterface $output)
    {
        // ...
    }
}

Писать значительно больше кода не придется и при этом она будет гарантированно запущена только 1 раз, сколько бы серверов не попытались это сделать. Единственное условие — общая директория для lock-файлов.

Уже готовое решение и больше деталей можно посмотреть на гитхаб: singleton-command

UPD: как справедливо было замечено — в случае «жестких» падений скриптов, возможно сохранение lock-файлов. Поэтому, желательно организовать демона, который будет «наблюдать» за «залежавшимися» lock-файлами.

Спасибо за внимание!
Поделиться с друзьями
-->

Комментарии (28)


  1. SerafimArts
    09.12.2016 16:58

    Кейсы с блокировкой требуются в крон-задачах и почти никогда при обычных запусках. Если учитывать это — думаю лучшим вариантом было бы просто написать простенький адаптер под уже существующее несколько лет решение, которое использует симфонийский консольный компонент: https://github.com/illuminate/console


    Оно уже покрывает все проблемы, озвученные в статье.


    $sheduler->command('some')->everyFiveMinute()->withoutOverlap();

    М?


    1. jced
      09.12.2016 17:07

      Безусловно, но это решение значительно больше, чем 2 коротких файла.
      Да и в моем случае это не крон.


      1. SerafimArts
        09.12.2016 17:21

        Я подозреваю, что вполне сопоставимо будет. Все команды уже отнаследованы от симфони, всё что потребуется — это написать бандл, который их будет регать в сервисах с нужным тегом.


        С другой стороны — согласен, нужен ресёрч, я сходу не могу представить как добавить сервис в "зафризенный" симфонёвый контейнер, кроме как выполнить ещё раз его билд. Да и возможно могут возникнуть проблемы с контейнером, в симфони у него нет функционала двойной диспатчеризации и получения сервиса по интерфейсу, только сервислокация и автовайринг.


        Короче, да, согласен, надо смотреть. В качестве быстрого решения на коленке — ваш вариант оправдан более чем.+


  1. dmkuznetsov
    09.12.2016 17:16

        public function lock($name)
        {
            $file = $this->getFilePath($name);
            if ($this->fileSystem->exists($file)) {
                return false;
            }
            $this->fileSystem->touch($file);
            return true;
        }
    


    Если команда сломалась и ваш лок-файл остался, то команда больше никогда не будет вызвана.
    Например, если процесс будет убит из консоли. Или выключится электричество. Или произойдет какой-то сбой на сервере.


    1. jced
      09.12.2016 17:28

      Да, в этом случае команда не запустится. Это стоит учитывать.
      В моем случае есть сборщик мусора, который проверяет «залежавшиеся» лок-файлы и сообщает о том, что это подозрительно. Как ни крути — при солидном кол-ве серверов такое бывает частенько, чем-то жертвовать приходится.
      Подумаю над Вашим замечанием, может быть придет идея, спасибо.


    1. OnYourLips
      09.12.2016 19:37
      +2

      Тут другая проблема: предположим, что два скрипта запустились почти одновременно. Оба проверили, что файла нет. Оба вернули true.

      Операция проверки и запирания обязана быть атомарной.


      1. jced
        09.12.2016 19:55
        -3

        Я давненько не встречал таких девайсов, которым нужно так много времени между проверкой и созданием, чтобы другой «вклинился».
        Но, в принципе, это можно поправить.


        1. maximw
          10.12.2016 00:38
          +1

          Скорость проверки значения не имеет. Race condition все равно есть. По ссылке пример вообще в памяти, которая куда быстрее «таких девайсов»


        1. jced
          13.12.2016 12:53

          Поправил на чуть более удачный вариант.


  1. BoShurik
    09.12.2016 17:27

    А зачем в абстрактном классе


        /** @var string */
        protected $name = null;

    ?
    Можно же использовать \Symfony\Component\Console\Command\Command::getName


    1. jced
      09.12.2016 17:34

      Действительно, как-то я не заметил :) Осталось после «причесывания». Поправлю, спасибо.


      1. BoShurik
        09.12.2016 17:36

        Я бы еще конструктор сделал идентичный базовому:


            /**
             * SingletonCommand constructor.
             * @param LockService $lockService
             * @throws \Exception
             */
            public function __construct(LockService $lockService, $name = null)
            {
                $this->lockService = $lockService;
                parent::__construct($name);
            }


        1. jced
          09.12.2016 17:41

          Поправил, спасибо


  1. garex
    09.12.2016 18:24

    Я стесняюсь спросить, а что это за юс-кейсы такие странные? Один процесс на N серверов?


    Что это таким путём надо делать? Не проще ли это порешать очередью, где можно сколько угодно консумеров запускать, но отрабатывать они могут по одному друг за другом?


    1. jced
      09.12.2016 18:35

      Завидую Вашему опыту :) Видимо, Вы еще никогда не слышали отказов типа: «заказчик пока не видит смысла уходить с php 5.3», «мы пока не можем поставить gearman, еtс», «этот модуль поставить нельзя, у нас один php-билд для всех проектов», «да, эта штука мертва уж 5 лет, но у нас есть приоритетнее задачи» и тому подобного.
      Безусловно, в стартап-ах и молодых проектах есть возможность не задумываться о таком, но мои статьи в основном связаны с проектами-тинейджерами, где все не так просто.


      1. garex
        09.12.2016 18:50

        @jced и всё-таки что за юс-кейсы то такие?


        1. jced
          09.12.2016 18:56

          Невозможность установки серверов очередей. Архитектура такова, что есть сервера с шаренной директорией, туда «сбрасываются» на лету сгенерированные скрипты, которые нужно выполнять. На каждом сервере — по демону, которые «рахватывают» эти скрипты-джобы и выполняют. Как-то так, в общих чертах.


          1. garex
            09.12.2016 19:00

            Невозможность установки серверов очередей.

            Наше вам сочувствие.


            Я писал микросервис для лицо-распознавания на питонах и вместо скучного REST'а сделал AMQP-консумера. Это оказалось просто и эффективно. Особенно помогло в горизонтальном масштабировании — можно было запустить кучу консумеров где угодно и задачи отрабатывались быстрее.


            1. jced
              09.12.2016 19:05

              Да, в моем случае отличных готовых решений хоть отбавляй, если бы была возможность, но чтобы не городить что-то еще запутаннее чем есть в текущих условиях — решил найти самое короткое решение.
              Как мне показалось — один файл (без учета интерфейса), довольно «изящно», решил, может есть такие же как я, застрявшие в 20 веке и им тоже пригодится :)


  1. 5hadow
    09.12.2016 23:21
    +1

    Лично я сейчас на проекте использую возможности MySQL для блокировок конкурентных запусков команд, а именно функции GET_LOCK(), IS_FREE_LOCK(), IS_USED_LOCK() и RELEASE_LOCK() которые били доданы где то в MySQL 4.1.

    Плюсы: Простота, не нужно подчищать lock файлы, так как в случае падения/завершения команды блокировка автоматически удалится. Также есть возможность ожидания освобождение блокировки.
    Минусы: не дружит с репликацией.


    1. jced
      09.12.2016 23:21

      И второй минус — нужен MySQL :)


      1. oxidmod
        09.12.2016 23:29
        -1

        Он даже на фри хостингах есть


        1. Icewild
          10.12.2016 23:45

          на фри хостингах нету проблемы создания блокирующихся тасков. А там где и когда эта проблема может возникнуть явно уже не фри хостинг.


          1. oxidmod
            11.12.2016 17:10

            Значит и проблем, которые решает автор не должно там быть


  1. lowadka
    12.12.2016 23:09

    Можно было бы сделать console command listener, завязавшись на console.command, тогда бы не пришлось переписывать текущие команды


  1. maxru
    13.12.2016 18:04

    pgrep %cmd% || %cmd%
    не пробовали?


    1. garex
      13.12.2016 19:01

      Это типа на нескольких серверах, а не на одном.


    1. jced
      13.12.2016 19:06

      Это для разных серверов и, как я написал, я пытался уйти от реализации блокировки там, где вызывается команда.