Блокировка дубликатов Symfony Сommand

    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

      0

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


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


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

      М?

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

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


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


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

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


        Если команда сломалась и ваш лок-файл остался, то команда больше никогда не будет вызвана.
        Например, если процесс будет убит из консоли. Или выключится электричество. Или произойдет какой-то сбой на сервере.
          0
          Да, в этом случае команда не запустится. Это стоит учитывать.
          В моем случае есть сборщик мусора, который проверяет «залежавшиеся» лок-файлы и сообщает о том, что это подозрительно. Как ни крути — при солидном кол-ве серверов такое бывает частенько, чем-то жертвовать приходится.
          Подумаю над Вашим замечанием, может быть придет идея, спасибо.
            +2
            Тут другая проблема: предположим, что два скрипта запустились почти одновременно. Оба проверили, что файла нет. Оба вернули true.

            Операция проверки и запирания обязана быть атомарной.
              –3
              Я давненько не встречал таких девайсов, которым нужно так много времени между проверкой и созданием, чтобы другой «вклинился».
              Но, в принципе, это можно поправить.
                +1
                Скорость проверки значения не имеет. Race condition все равно есть. По ссылке пример вообще в памяти, которая куда быстрее «таких девайсов»
                  0
                  Поправил на чуть более удачный вариант.
              0

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


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

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

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

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


                      /**
                       * SingletonCommand constructor.
                       * @param LockService $lockService
                       * @throws \Exception
                       */
                      public function __construct(LockService $lockService, $name = null)
                      {
                          $this->lockService = $lockService;
                          parent::__construct($name);
                      }
                    0
                    Поправил, спасибо
                0

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


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

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

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

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

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


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

                          0
                          Да, в моем случае отличных готовых решений хоть отбавляй, если бы была возможность, но чтобы не городить что-то еще запутаннее чем есть в текущих условиях — решил найти самое короткое решение.
                          Как мне показалось — один файл (без учета интерфейса), довольно «изящно», решил, может есть такие же как я, застрявшие в 20 веке и им тоже пригодится :)
                  +1
                  Лично я сейчас на проекте использую возможности MySQL для блокировок конкурентных запусков команд, а именно функции GET_LOCK(), IS_FREE_LOCK(), IS_USED_LOCK() и RELEASE_LOCK() которые били доданы где то в MySQL 4.1.

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

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

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

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

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

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

                        0

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

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

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое