Сегодня хочу предложить вашему вниманию частный случай для решения «неудобств», связанных с периодичным запуском процессов в том случае, если предыдущий еще не завершился. Иначе говоря — блокировка запущенных процессов в symfony/console. Но все было бы слишком банально, если бы не необходимость блокировки среди группы серверов, а не на отдельно взятом.
Дано: Один и тот же процесс, который запускается на N серверов.
Задача: Сделать так, чтобы в единицу времени был запущен только один.
Наиболее популярные решения, которые можно встретить на «просторах»:
- блокировка через базу данных;
- сторонние приложения;
- нативное использование 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)
dmkuznetsov
09.12.2016 17:16public function lock($name) { $file = $this->getFilePath($name); if ($this->fileSystem->exists($file)) { return false; } $this->fileSystem->touch($file); return true; }
Если команда сломалась и ваш лок-файл остался, то команда больше никогда не будет вызвана.
Например, если процесс будет убит из консоли. Или выключится электричество. Или произойдет какой-то сбой на сервере.jced
09.12.2016 17:28Да, в этом случае команда не запустится. Это стоит учитывать.
В моем случае есть сборщик мусора, который проверяет «залежавшиеся» лок-файлы и сообщает о том, что это подозрительно. Как ни крути — при солидном кол-ве серверов такое бывает частенько, чем-то жертвовать приходится.
Подумаю над Вашим замечанием, может быть придет идея, спасибо.
OnYourLips
09.12.2016 19:37+2Тут другая проблема: предположим, что два скрипта запустились почти одновременно. Оба проверили, что файла нет. Оба вернули true.
Операция проверки и запирания обязана быть атомарной.jced
09.12.2016 19:55-3Я давненько не встречал таких девайсов, которым нужно так много времени между проверкой и созданием, чтобы другой «вклинился».
Но, в принципе, это можно поправить.maximw
10.12.2016 00:38+1Скорость проверки значения не имеет. Race condition все равно есть. По ссылке пример вообще в памяти, которая куда быстрее «таких девайсов»
BoShurik
09.12.2016 17:27А зачем в абстрактном классе
/** @var string */ protected $name = null;
?
Можно же использовать \Symfony\Component\Console\Command\Command::getNamejced
09.12.2016 17:34Действительно, как-то я не заметил :) Осталось после «причесывания». Поправлю, спасибо.
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); }
garex
09.12.2016 18:24Я стесняюсь спросить, а что это за юс-кейсы такие странные? Один процесс на N серверов?
Что это таким путём надо делать? Не проще ли это порешать очередью, где можно сколько угодно консумеров запускать, но отрабатывать они могут по одному друг за другом?
jced
09.12.2016 18:35Завидую Вашему опыту :) Видимо, Вы еще никогда не слышали отказов типа: «заказчик пока не видит смысла уходить с php 5.3», «мы пока не можем поставить gearman, еtс», «этот модуль поставить нельзя, у нас один php-билд для всех проектов», «да, эта штука мертва уж 5 лет, но у нас есть приоритетнее задачи» и тому подобного.
Безусловно, в стартап-ах и молодых проектах есть возможность не задумываться о таком, но мои статьи в основном связаны с проектами-тинейджерами, где все не так просто.garex
09.12.2016 18:50@jced и всё-таки что за юс-кейсы то такие?
jced
09.12.2016 18:56Невозможность установки серверов очередей. Архитектура такова, что есть сервера с шаренной директорией, туда «сбрасываются» на лету сгенерированные скрипты, которые нужно выполнять. На каждом сервере — по демону, которые «рахватывают» эти скрипты-джобы и выполняют. Как-то так, в общих чертах.
garex
09.12.2016 19:00Невозможность установки серверов очередей.
Наше вам сочувствие.
Я писал микросервис для лицо-распознавания на питонах и вместо скучного REST'а сделал AMQP-консумера. Это оказалось просто и эффективно. Особенно помогло в горизонтальном масштабировании — можно было запустить кучу консумеров где угодно и задачи отрабатывались быстрее.
jced
09.12.2016 19:05Да, в моем случае отличных готовых решений хоть отбавляй, если бы была возможность, но чтобы не городить что-то еще запутаннее чем есть в текущих условиях — решил найти самое короткое решение.
Как мне показалось — один файл (без учета интерфейса), довольно «изящно», решил, может есть такие же как я, застрявшие в 20 веке и им тоже пригодится :)
5hadow
09.12.2016 23:21+1Лично я сейчас на проекте использую возможности MySQL для блокировок конкурентных запусков команд, а именно функции GET_LOCK(), IS_FREE_LOCK(), IS_USED_LOCK() и RELEASE_LOCK() которые били доданы где то в MySQL 4.1.
Плюсы: Простота, не нужно подчищать lock файлы, так как в случае падения/завершения команды блокировка автоматически удалится. Также есть возможность ожидания освобождение блокировки.
Минусы: не дружит с репликацией.
lowadka
12.12.2016 23:09Можно было бы сделать console command listener, завязавшись на console.command, тогда бы не пришлось переписывать текущие команды
SerafimArts
Кейсы с блокировкой требуются в крон-задачах и почти никогда при обычных запусках. Если учитывать это — думаю лучшим вариантом было бы просто написать простенький адаптер под уже существующее несколько лет решение, которое использует симфонийский консольный компонент: https://github.com/illuminate/console
Оно уже покрывает все проблемы, озвученные в статье.
М?
jced
Безусловно, но это решение значительно больше, чем 2 коротких файла.
Да и в моем случае это не крон.
SerafimArts
Я подозреваю, что вполне сопоставимо будет. Все команды уже отнаследованы от симфони, всё что потребуется — это написать бандл, который их будет регать в сервисах с нужным тегом.
С другой стороны — согласен, нужен ресёрч, я сходу не могу представить как добавить сервис в "зафризенный" симфонёвый контейнер, кроме как выполнить ещё раз его билд. Да и возможно могут возникнуть проблемы с контейнером, в симфони у него нет функционала двойной диспатчеризации и получения сервиса по интерфейсу, только сервислокация и автовайринг.
Короче, да, согласен, надо смотреть. В качестве быстрого решения на коленке — ваш вариант оправдан более чем.+