Перейти к содержимому
Поиск
Разработка

Symfony Lock: параллельные задачи в PHP — мастерская и 8 реальных опытов

Восемь опытов с настоящими PHP-процессами: конкуренция, ожидание, ключи, исключения, завершение владельца и Redis TTL. Мастерская, исходники и Linux/VPS.

Александр Некрасов
Symfony Lock: параллельные задачи в PHP — мастерская и 8 реальных опытов — обложка

Symfony Lock без фреймворка: параллельные PHP-задачи, Flock, Redis, TTL и refresh. Восемь реальных опытов, интерактивная временная шкала и исходники Windows/Linux.

Symfony Lock: когда два процесса берутся за одну работу

Ночью планировщик запускает выгрузку. Она занимает семь минут, а следующий запуск приходит уже через пять. В другой ситуации два сотрудника одновременно нажимают «Сформировать отчёт», два worker получают одинаковую задачу или webhook повторно запускает пересчёт. Каждый PHP-процесс по отдельности выглядит правильным, но вместе они пишут один файл, отправляют два письма или мешают друг другу обновлять общий ресурс. Symfony Lock помогает договориться, кому сейчас разрешено выполнять защищённую часть работы.

В этой статье мы используем компонент отдельно от полного Symfony. Сначала разберём локальные файловые блокировки, затем подключим Redis как общее хранилище для нескольких процессов. Будут немедленный отказ и ожидание, независимые ключи, исключения, завершение владельца, истечение TTL и продление аренды. Для каждого случая покажем практическую задачу, реальный результат и границы гарантии.

Мини-мастерская позволяет проиграть восемь опытов по событиям: увидеть попытку получения, владельца, конкурента и освобождение. Записи получены от настоящих дочерних PHP-процессов, а исходники доступны для скачивания. Дальше есть команды Windows/Linux, инструкция для VPS, пример cron, systemd timer и объяснение, почему один lock ещё не означает exactly-once.

Где применять: отчёты, импорты, кеш и фоновые операции

Полезный первый пример — генерация большого отчёта для клиента. Веб-запрос создаёт задачу, worker берёт ключ с номером клиента и строит файл. Повторное нажатие не должно запускать второй тяжёлый расчёт рядом с первым. Интерфейс может показать «Отчёт уже формируется» и предложить дождаться готового результата. Это экономит ресурсы и объясняет пользователю, что произошло, вместо загадочной ошибки сервера.

Другой сценарий — импорт внешнего каталога. Пока один процесс обновляет набор записей, второй запуск того же импорта лучше пропустить или поставить в очередь. Аналогично можно координировать пересборку кеша, создание архива, обработку одной выгрузки, периодическую синхронизацию и обслуживание общего локального файла. Lock особенно полезен, когда одна операция доступна несколькими путями: из cron, ручной CLI-команды и фонового worker.

Выбирайте конкретный защищаемый ресурс. «Заблокировать приложение» обычно слишком широко: независимые клиенты начинают ждать друг друга. «Заблокировать генерацию отчёта клиента 42 за ноябрь» уже описывает понятную границу. После этого решите, что должен делать конкурент: быстро завершиться, ждать ограниченное время, вернуть существующий ID задачи или запланировать повтор. Именно это поведение станет видимой частью вашего сервиса.

Что защищает lock, а что остаётся задачей приложения

Блокировка координирует участников, которые используют одинаковое хранилище и одинаковое имя ресурса. Если один обработчик проверяет Lock, а соседний напрямую меняет файл, он не становится дисциплинированным автоматически. Перед внедрением найдите все пути изменения данных: веб-контроллеры, старые cron, очереди, административные команды и интеграции. Общая договорённость должна выполняться в каждом из них.

Lock также не хранит факт успешного выполнения навсегда. После освобождения следующий процесс может законно получить тот же ключ. Если задача уже отправила письмо, повторный запуск способен отправить его снова. Для такой задачи отдельно нужен идентификатор операции и проверка результата. Уникальное ограничение БД, идемпотентный обработчик и бизнес-статус решают вопросы, которые не покрывает временное право на работу.

Поэтому в примерах мы различаем конкуренцию и повтор. Конкуренция — две операции происходят сейчас одновременно. Повтор — операция пришла ещё раз позже, возможно после сбоя или потери ответа. Компонент Lock помогает с первой задачей; полноценная обработка второй строится вместе с хранением результата и правилами вашего API. Это особенно важно для денег, выдачи доступа и других необратимых действий.

Версии, Composer и запуск без полного фреймворка

В приложенном проекте используются Symfony Lock 7.4.23 и Predis 2.4.1. Это конкретные версии из composer.lock; PHP требуется не ниже 8.2. Ветка Symfony 7.4 выбрана для самостоятельного проекта с таким диапазоном PHP. Установка всего Symfony-приложения и настройка контейнера сервисов здесь не нужны: мы явно создаём Store и передаём его в LockFactory.

Composer устанавливает зависимости и создаёт автозагрузчик. В уже скачанном проекте выполняйте install, чтобы получить версии из lock. Команда update предназначена для осознанного обновления зависимостей и должна сопровождаться повторением конкурентных тестов. Не заменяйте install на update в каждом production deploy: иначе разные выкладки могут получить различающееся поведение.

На сайте есть подробное руководство по Composer: команды, зависимости и рабочий проект. Оно пригодится, если автозагрузка и lock пока непривычны. Здесь основное внимание уделено самой конкуренции. Полный список зависимостей и лицензий входит в архив; наличие новой версии библиотеки само по себе не делает непроверенное обновление безопасным.

composer require "symfony/lock:^7.4" "predis/predis:^2.4"

# Для скачанного проекта:
composer install --no-interaction --no-plugins --prefer-dist
composer validate --strict
composer audit

Мини-мастерская: восемь реальных опытов и временная шкала

Начните с первого опыта и нажимайте «Следующее событие». Два процесса проходят общий стартовый барьер, один получает ресурс, второй получает отказ. Затем выберите ожидание: у B появляется попытка acquire, но успешное владение наступает после освобождения A. В третьем опыте ключи разные, поэтому работа двух процессов пересекается. Сравнивайте смысл событий, а не только цвет индикаторов.

В мастерской есть воспроизведение, ползунок позиции, карточки процессов и полный список событий. Можно скачать JSON опыта, посмотреть PHP-исходники и собрать начало собственной команды. Генератор фрагментов не меняет запись: он помогает выбрать локальный Flock, ключ конкретного клиента или Redis с refresh. Настоящее исполнение своего кода выполняется после скачивания архива.

Механизм обозначен прямо: это интерактивный просмотр записей настоящего PHP-выполнения. Браузер не запускает PHP или Redis и не изображает отдельным JavaScript-алгоритмом работу блокировки. Запись содержит версии и хеши кода. Восемь опытов сформировали 71 событие; время измерено на одном хосте и не используется как сравнительный benchmark.

Store, factory, resource и экземпляр Lock

Store отвечает за место, где поддерживается состояние блокировки: в нашем проекте это локальный FlockStore или RedisStore. LockFactory создаёт объекты для конкретных ресурсов. Resource — строка, по которой участники понимают, какую работу они координируют. Экземпляр Lock представляет владение в вызывающем коде. Эти роли стоит разделить сразу, чтобы не смешивать конфигурацию инфраструктуры и бизнес-имена задач.

Вместо неявного выбора хранилища мы создаём его явно. Для Flock передаём отдельный каталог, для Redis — клиент с адресом и таймаутами. Так проще проверить, что два worker действительно смотрят в одно место. Сам объект factory можно использовать для разных ключей; отдельное действие получает свой экземпляр Lock. Нельзя создать новый локальный каталог для каждого процесса и ожидать общего ограничения.

В примере ниже каталог создаётся при развёртывании с подходящими правами. В приложенном классе Stores есть проверка создания учебного каталога. В production удобно иметь стабильный путь вне web-root, владельца служебной учётной записи и одинаковую настройку у ручного запуска и планировщика.

use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\FlockStore;

$factory = new LockFactory(new FlockStore('/var/lib/myapp/locks'));
$lock = $factory->createLock('myapp:prod:daily-report');

if (!$lock->acquire()) {
    return; // Эту работу уже выполняет другой участник.
}
try {
    // Здесь только защищённая операция.
} finally {
    $lock->release();
}

Опыт 1. Два cron и немедленный отказ конкуренту

Первый опыт отвечает на повседневный вопрос: что произойдёт, если планировщик запустит одну команду дважды? Контроллер создаёт два дочерних PHP-процесса. Каждый сообщает о готовности, после чего открывается общий барьер. Оба обращаются к одинаковому Flock-каталогу и одинаковому ключу. Один получает блокировку и некоторое время удерживает её, второй получает false и завершает свою ветку.

В этом опыте победителем может оказаться A или B. Мы проверяем правило «один владелец», а не обещаем преимущество процесса с определённой буквой. Порядок зависит от планировщика ОС. Для практической задачи важнее, что одновременно не начались две защищённые операции и отказ обработан как ожидаемый исход.

Если повторный cron безопасно пропустить, выход 0 может означать «другой экземпляр уже работает». Но обязательно оставьте диагностическое событие skipped. Иначе зависшая задача и нормальный пропуск будут выглядеть одинаково. Для интерфейса пользователя лучше вернуть отдельный статус «уже выполняется» и существующий идентификатор задачи, если приложение его хранит.

if (!$lock->acquire()) {
    fwrite(STDOUT, "SKIPPED: report is already running\n");
    exit(0);
}
try {
    // Сгенерировать отчёт один раз в этой критической секции.
} finally {
    $lock->release();
}

Опыт 2. acquire(true): дождаться своей очереди

Во втором опыте процесс A сначала подтверждает получение блокировки. После этого B вызывает acquire(true) для того же ресурса. Пока A работает, B ожидает. Когда A освобождает ресурс, B получает его и выполняет свою короткую операцию. На временной шкале между попыткой B и успешным acquire виден промежуток ожидания.

Такой режим подходит для небольшой последовательной операции внутри worker, где ожидание допустимо и управляется внешним временем жизни задачи. Для обычного HTTP-запроса длительное ожидание может занять PHP-FPM-процесс, ухудшить отзывчивость сервиса и привести к таймауту. Иногда полезнее немедленно сообщить о занятости и предложить повтор через очередь.

Не считайте acquire(true) обещанием справедливой очереди или готовым deadline. Если нужен предел ожидания, задайте его явно в своей политике: ограниченный цикл неблокирующих попыток с паузой, отменой и конечным временем либо ограничение жизни worker. Поддержка ожидания и её устройство зависят от store; перенос на другой backend требует повторного теста.

// Допустимо для короткой работы с управляемым временем жизни worker.
$lock->acquire(true);
try {
    // Короткая критическая секция.
} finally {
    $lock->release();
}

Ограниченное ожидание: дать задаче закончиться предсказуемо

Представьте экспорт в административной панели. Пользователь ожидает ответ несколько секунд, но уже запущенный расчёт может идти минуту. Бесконечное ожидание нового запроса в такой ситуации не улучшает интерфейс. Приложение должно иметь явный бюджет: сколько ждать, когда вернуть «занято» и каким способом пользователь узнает о завершении.

Неблокирующий acquire позволяет построить ограниченный цикл. Таймер здесь относится к ожиданию получения, а не к сроку действия уже полученной аренды. Это два разных параметра. После получения ресурса работу всё равно нужно ограничивать, а для Redis — продлевать TTL вовремя. При отмене HTTP-запроса серверная работа не всегда прекращается автоматически.

Пример ниже нужен для небольшого управляемого worker. В большом количестве процессов добавляйте разброс задержки, чтобы все конкуренты не просыпались одновременно. При истечении бюджета возвращайте понятный результат и наблюдайте количество таких отказов. Если ожидание постоянно растёт, возможно, защищаемая операция слишком велика или ключ объединяет слишком много независимых ресурсов.

$deadline = hrtime(true) + 2_000_000_000; // 2 секунды на ожидание
$acquired = false;
do {
    if ($lock->acquire()) {
        $acquired = true;
        break;
    }
    usleep(random_int(40_000, 80_000));
} while (hrtime(true) < $deadline);

if (!$acquired) {
    throw new RuntimeException('Ресурс пока занят');
}
try {
    // Выполнить короткую работу.
} finally {
    $lock->release();
}

Опыт 3. Ключ на ресурс: независимые клиенты не мешают друг другу

В третьем опыте A работает с отчётом 42, а B — с отчётом 77. Хранилище у них общее, но ключи различаются. Оба получают блокировки, и их интервалы работы пересекаются. Такой результат важен: блокировка должна защищать нужный ресурс, сохраняя полезную параллельность остальных операций.

Имя ресурса удобно составлять из приложения, среды, типа работы и проверенного идентификатора. Префикс среды отделяет production от staging, если они случайно используют общий Redis. Идентификатор клиента или документа позволяет ограничить именно ту часть работы, которая действительно конфликтует. Названия должны формироваться на сервере согласованно, без зависимости от перевода текста кнопки.

Не принимайте готовый lock-key из произвольного браузерного параметра. Пользователь может выбрать чужой ресурс или создать огромное количество имён. Проверяйте права на объект, нормализуйте входные данные и задавайте ограниченный формат ключа. Если ключ содержит чувствительный идентификатор, подумайте, нужно ли показывать его целиком в журнале.

$tenantId = 42; // Получен после проверки доступа.
$period = '2026-11'; // Проверен формат и допустимый диапазон.
$key = sprintf('myapp:prod:tenant-report:%d:%s', $tenantId, $period);
$lock = $factory->createLock($key);

Опыт 4. Исключение не должно оставлять ресурс занятым

Генерация отчёта может закончиться ошибкой чтения файла, исключением конвертера или неожиданным ответом внешнего API. В четвёртом опыте A получает Flock, затем намеренно выбрасывает RuntimeException. Ошибка записывается, а finally вызывает release. Ожидающий B после этого получает ресурс и успешно завершает работу.

Размещайте finally рядом с критической секцией. Если release находится только в конце успешной ветки, ранний return или exception легко его обойдут. Автоматическое освобождение при уничтожении объекта полезно, но явный finally делает намерение понятным и не привязывает длительность владения к случайному времени жизни переменной.

При этом обработка ошибки не должна скрывать предметный результат. Если отчёт не построен, не отправляйте пользователю «готово» только потому, что блокировка успешно освобождена. Это разные события. Запишите исходную причину сбоя, сохраните понятный статус задачи и решите, можно ли безопасно повторить операцию после частично выполненной работы.

if (!$lock->acquire()) {
    return;
}
try {
    generateReport();
} catch (Throwable $error) {
    // Зафиксировать неудачу задачи и исходную причину.
    throw $error;
} finally {
    $lock->release();
}

Опыт 5. Завершение владельца и оставшийся lock-файл

В пятом опыте A получает Flock и остаётся ждать. Контроллер завершает именно созданный им дочерний процесс, после чего B получает освобождённую блокировку. В записи есть отдельное событие контроллера: это не добровольный release из A. Такой опыт помогает понять связь Flock с открытым файловым дескриптором процесса.

Сам файл в каталоге может остаться. Проверять занятость по его существованию неправильно: наличие имени файла и удержание блокировки — разные вещи. Ещё опаснее удалять «старый lock-файл» при работающих процессах. Другой участник может открыть уже новый файл с тем же именем и перестать координироваться с прежним владельцем.

Учебный контроллер не ищет и не останавливает чужие PHP-процессы. Он хранит только собственные proc_open handles. В эксплуатации причина внезапного завершения может быть другой: OOM, stop service или авария. Проверять восстановление нужно на выбранной ОС и файловой системе, особенно если каталог расположен на сетевом томе.

Flock: один хост, файловая система и одинаковые пути

Локальный Flock удобен для одного сервера: нет отдельного сетевого сервиса, а состояние связано с файловой системой и процессами. В нашей лаборатории он проверен на Windows и в Linux-контейнере. Оба участника одного опыта используют один каталог. Это условие важно для смысла результата, а не просто для удобства расположения файлов.

Два контейнера с отдельными файловыми системами получают разные lock-каталоги, даже если строка пути внутри обоих выглядит одинаково. Общий bind mount может изменить ситуацию, но его поведение требуется проверить для выбранной среды. Нельзя автоматически переносить вывод о локальном диске на NFS, сетевое хранилище или другой способ монтирования.

У PHP есть собственная функция flock; Symfony предоставляет более удобный объектный интерфейс и сменяемые stores. Ограничения самой файловой блокировки всё равно остаются важными. Технические детали платформ описаны в PHP manual по flock. Для разных серверов часто понятнее использовать явно общее хранилище, чем надеяться на случайное совпадение путей.

Опыт 6. RedisStore: общий ключ и конкретный владелец

В шестом опыте два PHP-процесса подключены к одному Redis. A получает ресурс, B получает отказ. Внутри A дополнительно проверяется повторный acquire того же объекта и попытка отдельного объекта с тем же ключом. Повторный acquire владельца успешен; отдельный объект не становится владельцем автоматически. Его release не снимает блокировку A.

Это помогает избежать ошибки в архитектуре сервисов. Если два независимых обработчика разделяют один экземпляр Lock, повторное получение не обязательно создаст ожидаемое взаимное исключение между ними. Для независимых операций создавайте отдельные экземпляры через factory. Общий объект имеет смысл, когда части одного действия намеренно сотрудничают над одним владением.

Метод isAcquired отвечает о владении текущего объекта. Это не глобальный индикатор «ресурс кем-то занят». Для попытки начать работу вызывайте acquire и обрабатывайте результат. В мастерской события reentry, other_object и owner_check показывают эту разницу прямо на настоящем запуске, без условного объяснения в одну строку.

$a = $factory->createLock('myapp:prod:import', ttl: 30);
$b = $factory->createLock('myapp:prod:import', ttl: 30);

$a->acquire();       // true
$a->acquire();       // true: это тот же владелец
$b->acquire();       // false: отдельный объект
$b->release();       // Не освобождает владение A
$a->isAcquired();    // true
$a->release();

Подключение Redis: клиент, таймауты и приватные настройки

Для переносимого PHP-примера используется Predis: отдельное PHP-расширение redis не требуется. Класс Stores создаёт клиент с ограничениями времени соединения и чтения. Адрес и пароль читаются из окружения. В локальном Compose Redis слушает только loopback-порт 8533, а внутри сети контейнеров доступен как redis:6379.

Для рабочего сервера настройки должны соответствовать сети и требованиям приложения. Redis не следует открывать всем посетителям сайта. Ограничьте доступ, задайте подходящую аутентификацию, настройте TLS и проверку сертификата там, где соединение проходит через соответствующую границу. Пароль и полный DSN не должны попадать в публичную recording.json, HTML или исходный архив.

На сайте отдельно разобраны Redis, TTL и координация, а также настройки PHP через .env и getenv. Переменная окружения удобна для передачи секрета, но сама по себе не является шифрованием или системой управления доступом. Учётную запись, файлы конфигурации и журналы нужно защищать отдельно.

use Predis\Client;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;

$redis = new Client([
    'scheme' => 'tcp',
    'host' => getenv('LOCK_REDIS_HOST') ?: '127.0.0.1',
    'port' => (int) (getenv('LOCK_REDIS_PORT') ?: 8533),
    'timeout' => 2,
    'read_write_timeout' => 2,
]);
$factory = new LockFactory(new RedisStore($redis));

Опыт 7. TTL истёк, а старый процесс ещё работает

Удалённое хранилище не может просто посмотреть, жив ли PHP-процесс на другом сервере. Поэтому используется аренда с ограниченным сроком. В седьмом опыте A получает Redis-lock с TTL 350 мс, но останавливается на 900 мс. B приходит после истечения срока и получает тот же ресурс. Когда A просыпается, он уже не владелец.

В записи A проверяет владение, пробует refresh и получает исключение о потерянной аренде. Затем он явно отказывается от записи результата. Важная деталь: сам процесс A никуда не исчез. Истечение TTL освобождает право в хранилище, но не останавливает код на удалённой машине. Именно поэтому слишком короткая аренда способна создать опасное пересечение работы.

Числа в лаборатории намеренно маленькие, чтобы опыт быстро воспроизводился. Они не являются рекомендацией production TTL. В реальной задаче учитываются время порции работы, паузы среды, ожидания сети и поведение системы при перегрузке. После потери владения нельзя просто продолжить побочные эффекты с мыслью «мы ведь получили lock в начале».

$lock = $factory->createLock('myapp:prod:import', ttl: 30);
if (!$lock->acquire()) {
    return;
}
try {
    // Работа и паузы не должны незаметно выйти за срок аренды.
    $lock->refresh(); // Ошибка означает: продолжать как владелец нельзя.
} finally {
    $lock->release();
}

Опыт 8. Refresh: продлевать до истечения, а не после

В восьмом опыте A получает аренду 500 мс и обрабатывает работу небольшими порциями. Через 180 мс он вызывает refresh, затем повторяет это ещё несколько раз. B пробует получить тот же ключ во время работы и получает отказ. После release новый процесс C успешно начинает следующую операцию. На временной шкале видно четыре отдельных продления.

Это полезная модель для worker, который может делить импорт на небольшие части. Частота refresh должна оставлять достаточный запас до истечения. Если одна неделимая операция сама занимает дольше TTL, обновление в конце не спасёт уже потерянное владение. Нужно уменьшить порцию, подобрать другой бюджет или пересмотреть способ сохранения результата.

Продление тоже является сетевой операцией и может завершиться ошибкой. Обрабатывайте её как изменение условий выполнения, а не как необязательное предупреждение. Прекратите дальнейшую защищаемую работу, сохраните диагностическое состояние задачи и определите безопасный способ повтора. Автоматически создавать новую аренду и продолжать старую операцию часто означает скрыть факт возможной конкуренции.

$lock = $factory->createLock('myapp:prod:catalog-import', ttl: 30);
if (!$lock->acquire()) {
    return;
}
try {
    foreach ($batches as $batch) {
        $lock->refresh();
        processIdempotentBatch($batch);
    }
} finally {
    $lock->release();
}

Почему isAcquired перед записью не заменяет fencing

Проверка владения перед сохранением полезна для обнаружения уже известной потери. Но между этой проверкой и самой записью остаётся время. Процесс может быть приостановлен, аренда истечёт, новый владелец изменит ресурс, а старый затем продолжит выполнение. Одна проверка в PHP не заставляет базу или внешний сервис отклонить устаревшую запись.

Fencing переносит последнюю проверку на защищаемый ресурс: операция несёт монотонный номер владения, и ресурс отвергает номер старее уже принятого. Это требует поддержки на стороне конкретной БД, сервиса или протокола записи. Нельзя просто добавить случайную строку в лог и считать fencing реализованным. В нашей лаборатории такой механизм не реализован; опыт показывает только обнаружение потери аренды и добровольный отказ от записи.

Для одних задач достаточно атомарной проверки версии в БД, для других нужны транзакции, уникальность или специальный API. Выбор зависит от того, какой ущерб создаёт устаревшая операция. Ограничения распределённых блокировок стоит изучать вместе с официальным разбором Redis, а затем проверять именно свои условия отказа.

Lock, транзакция, UNIQUE и идемпотентность: собрать вместе

Рассмотрим webhook оплаты. Блокировка по ID события может уменьшить конкуренцию двух одновременных обработчиков. Уникальный индекс по этому ID фиксирует, что событие уже было принято. Транзакция связывает изменения нескольких таблиц. Идемпотентный ответ позволяет вернуть тот же результат повторному запросу. Ни одна из этих частей не является универсальной заменой остальных.

Если приложение отправило запрос внешнему сервису и потеряло ответ, локальный release не сообщает, выполнил ли сервис операцию. При повторе нужен поддерживаемый тем сервисом idempotency key или проверка сохранённого результата. Не обещайте exactly-once на основании того, что в лаборатории второй acquire вернул false.

Для состояний бизнес-процесса рядом полезен Symfony Workflow: он описывает допустимые переходы и guards. Lock отвечает на вопрос «кто сейчас выполняет действие», Workflow — «разрешено ли действие в этом состоянии», база — «как сохранить результат согласованно». Такое разделение делает поведение системы понятнее разработчику и пользователю.

Ошибка хранилища отличается от обычной занятости

Отказ acquire из-за конкурента — ожидаемое состояние. Недоступный Redis, ошибка прав каталога и сбой соединения относятся к другой категории. Если приложение превращает любое исключение в «lock не нужен, продолжим», оно теряет координацию именно в момент проблем инфраструктуры. Это часто опаснее обычной задержки.

В проекте ошибки дочерних процессов записываются как fatal и проваливают capture. Занятость записывается как denied и проходит проверку соответствующего сценария. В production удобно иметь такую же смысловую разницу в метриках и журнале: skipped не должен скрывать store error. Пользовательскому интерфейсу также нужны разные сообщения о текущей работе и временной технической недоступности.

При сбое не выводите пользователю полный адрес Redis, пароль или трассировку. Верните понятную ошибку с идентификатором диагностики, а подробности сохраните в закрытом журнале. Повтор должен иметь ограничение, задержку и конечное решение. Бесконечный быстрый retry множества worker способен усилить нагрузку на уже проблемное хранилище.

try {
    $acquired = $lock->acquire();
} catch (Throwable $error) {
    // Записать внутреннюю диагностику и завершить задачу с ошибкой.
    // Не выполнять работу без координации.
    throw $error;
}

if (!$acquired) {
    // Ожидаемая конкуренция: другой участник уже работает.
    return;
}

Windows: повторить лабораторию в PowerShell

Распакуйте архив в отдельный каталог и откройте PowerShell в корне проекта. Нужны PHP CLI 8.2+, Composer 2 и Docker Desktop с Linux-контейнерами для Redis-части. Предварительно проверьте php –version и docker version: установленная команда Docker без работающего Engine не запустит Redis. Все используемые порты в лаборатории привязаны к 127.0.0.1.

Composer install установит PHP-зависимости по lock. Compose запустит один временный Redis. Capture выполнит восемь опытов с отдельными PHP-процессами и запишет результат; test проверит события, хеши и владение. Build-public обновит доступные на странице исходники и документацию. Serve откроет мастерскую на порту 8512.

Если 8533 занят, выберите свободный порт и согласованно измените mapping Compose и LOCK_REDIS_PORT. Не завершайте чужую программу ради примера. Для Flock используйте локальную папку, доступную текущему пользователю. Не запускайте capture одновременно из нескольких терминалов без необходимости: хотя рабочие ключи получают случайный префикс запуска, итоговая recording.json остаётся одним файлом проекта.

php --version
docker version
composer install --no-interaction --no-plugins --prefer-dist
composer validate --strict
docker compose config --quiet
docker compose up -d --wait
composer capture
composer test
composer build-public
composer serve
# Открыть http://127.0.0.1:8512

Просмотр без Redis и пять отдельных Flock-опытов

Для первого знакомства необязательно запускать Docker. Готовая recording.json уже входит в архив, поэтому мастерскую можно открыть локальным HTTP-сервером и изучить все восемь записанных ситуаций. Это просмотр приложенного результата, а не новый запуск Redis. На странице явно показана среда, в которой запись была получена.

Чтобы выполнить собственные локальные опыты без Redis, используйте capture:flock. Команда запускает первые пять ситуаций и пишет отдельный recording-flock.json. Проверка с параметром –flock-only относится именно к нему. Полная запись не затирается частичной: основная мастерская продолжает показывать восемь приложенных опытов, пока вы не выполните полный capture.

Такой режим полезен на учебном компьютере или перед переносом старого cron на явный FlockStore. Можно сначала проверить конфликт, ожидание, исключение и завершение процесса на своей файловой системе, а затем отдельно переходить к сетевому store. Отсутствие Redis в этих пяти опытах не является проверкой распределённой блокировки.

composer install --no-interaction --no-plugins --prefer-dist
composer capture:flock
php tests/run.php --flock-only

# Только просмотр уже подготовленной мастерской:
php -S 127.0.0.1:8512 -t public

Linux: установка, права и тот же набор проверок

На Linux используются тот же проект и composer.lock. Нужны PHP CLI, Composer и Docker Engine с Compose plugin для полной лаборатории. Не смешивайте старую команду docker-compose с ожидаемой docker compose. Проверьте, что запускающий пользователь действительно имеет согласованный доступ к daemon; изменение прав socket на 666 не является правильным способом решить эту задачу.

После установки выполните те же команды capture, test и build-public. В нашей проверке Linux использовал чистый временный каталог и заново установленный vendor, а Redis был общим сервисом в отдельной Docker-сети. Это исключает случайное использование Windows-зависимостей и подтверждает работу многопроцессных сценариев в Linux-среде.

Для подключения из контейнера адрес 127.0.0.1 означает сам этот контейнер. Внутри сети лаборатории нужно имя redis и порт 6379. Для запуска PHP на хосте остаются 127.0.0.1 и 8533. Это частая причина ошибки «Redis недоступен», хотя контейнер здоров: адрес зависит от того, откуда выполняется клиент.

php --version
composer install --no-interaction --no-plugins --prefer-dist
docker compose up -d --wait
composer capture
composer test
composer build-public

# Для PHP-контейнера в сети этого Compose:
export LOCK_REDIS_HOST=redis
export LOCK_REDIS_PORT=6379

Linux/VPS: вынести код и состояние из web-root

На сервере сначала отделите код приложения, опубликованные файлы и рабочее состояние. Например, исходники находятся в /opt/report-app, каталог локальных блокировок — в /var/lib/report-app/locks, а веб-сервер отдаёт только public. Composer, vendor и .runtime не должны становиться доступными по URL. Для статической мастерской PHP-FPM вообще не требуется.

Запускайте фоновую команду от отдельного служебного пользователя с минимально необходимыми правами. В архиве есть пример подготовки каталога с 0700. Важно, чтобы ручной запуск и планировщик использовали одинаковые LOCK_DIR и ключ. Если один работает от root в одном каталоге, а второй от www-data в другом, красивое одинаковое имя ресурса не создаёт общей блокировки.

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

sudo useradd --system --home /var/lib/report-app \
  --shell /usr/sbin/nologin report-worker
sudo install -d -o report-worker -g report-worker -m 0700 \
  /var/lib/report-app/locks
sudo -u report-worker env LOCK_DIR=/var/lib/report-app/locks \
  php /opt/report-app/examples/report.php

Cron и systemd timer: выбрать один понятный способ запуска

В архиве есть короткая команда examples/report.php и два варианта планирования. Для systemd приложены service и timer; для cron — отдельная строка. Выберите один способ, чтобы случайно не удвоить расписание. Команда сама получает Flock, пишет acquired/skipped/released и завершает ошибку ненулевым кодом.

Systemd unit задаёт пользователя, рабочий каталог, путь к PHP, время жизни и разрешённый каталог записи. Сам timer не запускает второй экземпляр уже активного того же service unit, однако общий Lock всё равно полезен для ручных CLI-запусков и других входных путей. Он должен защищать ресурс, а не только имя конкретного планировщика.

Перед включением расписания запустите службу вручную, прочитайте журнал и проверьте ожидаемую работу. Поменяйте пути под свой сервер. Затем убедитесь, что timer появился в list-timers. Проверка синтаксиса unit не доказывает, что приложение имеет нужные права, может подключиться к Redis или корректно обработало реальную бизнес-задачу.

sudo cp deploy/report.service deploy/report.timer /etc/systemd/system/
sudo systemd-analyze verify /etc/systemd/system/report.service \
  /etc/systemd/system/report.timer
sudo systemctl daemon-reload
sudo systemctl start report.service
sudo journalctl -u report.service -n 50 --no-pager
sudo systemctl enable --now report.timer
systemctl list-timers report.timer

Несколько серверов: общий store, отказ и изменение конфигурации

Для нескольких хостов важен общий Redis и согласованные правила формирования ключей. Само использование класса RedisStore не гарантирует этого: один worker может смотреть в staging, другой — в production, третий — использовать другой префикс. В диагностике проверяйте среду подключения и нормализованное имя ресурса, не раскрывая секреты.

Учебный стенд использует один Redis и отключённое постоянное сохранение данных. Он не моделирует failover, сетевое разделение или восстановление из старой резервной копии. Удаление живых lock-ключей из-за политики вытеснения также меняет условия координации. Для production архитектуру Redis и реакцию приложения на потерю данных нужно проектировать отдельно от красивого примера acquire/release.

Смена store или формата ключа требует осторожной выкладки. Пока старые worker используют один ресурс, а новые уже другой, они могут работать одновременно. Дождитесь завершения старой работы или предусмотрите переходный протокол. Не считайте изменение DSN обычной косметической правкой конфигурации: для блокировки это изменение общего места договорённости.

Как показать занятость в интерфейсе и не раздражать пользователя

Когда сотрудник нажимает «Экспорт», отсутствие мгновенного файла ещё не означает ошибку. Интерфейсу полезно иметь состояния «запланировано», «выполняется», «готово» и «не удалось». При повторном запросе сервер может вернуть уже существующий ID задачи. Пользователь увидит прогресс той же операции, а не вторую строку и новый таймер.

Lock не хранит такую пользовательскую модель самостоятельно. ID задачи, состояние, ссылка на результат и срок хранения принадлежат приложению или очереди. Блокировка может защищать критический момент создания или выполнения, но данные для интерфейса нужно сохранять отдельно. Иначе после освобождения ресурса клиент не узнает, был ли результат вообще создан.

Для долгой работы удобнее очередь и отдельный endpoint состояния, чем удержание HTTP-соединения до конца. На сайте есть материал про BullMQ, повторы и прогресс задач. Даже если ваш backend написан на PHP, полезно само разделение: команда запуска, идентификатор, наблюдение и готовый результат. Конкретную очередь выбирают под инфраструктуру.

Что писать в журнал и на какие сигналы смотреть

Для каждой защищённой задачи полезны ID операции, безопасное имя ресурса, результат получения, длительность ожидания, время владения и причина завершения. Отдельно считайте пропуски, ошибки store, неудачные refresh и потерю владения. Большое число skipped может быть нормальным при частом расписании, а может указывать на слишком долгую или зависшую работу.

Время ожидания и время выполнения стоит различать. Если процесс долго не получает ресурс, проблема может быть в размере критической секции или слишком общем ключе. Если быстро получает, но долго выполняется, нужна диагностика самой задачи. Если постоянно падает refresh, следует проверить сеть, Redis и выбранные таймауты.

В лабораторной записи намеренно нет паролей подключения и приватных бизнес-данных. При переносе в приложение придерживайтесь той же идеи. Для поддержки обычно достаточно безопасного идентификатора и короткой причины. Полный payload клиента, access token или DSN с паролем в обычном журнале создадут лишние риски и не улучшат понимание конкуренции.

Как проверен проект и что остаётся проверить у себя

Полный capture выполняет восемь сценариев на настоящих дочерних PHP-процессах. В контроллер встроены проверки количества владельцев, отказов, последовательности blocking, пересечения независимых ресурсов, освобождения после исключения, завершения владельца, TTL и refresh. Перед стартом процессы проходят ready-barrier, поэтому опыт конкуренции не подменён последовательным вызовом одного объекта.

Дополнительный test проверяет хеши исходников, события, коды завершения и отдельный Flock ownership. Полный набор даёт 106 проверок целостности и поведения; отдельно выполнены пять Flock-сценариев без Redis. Те же исходники проверены в Windows и чистой Linux-среде. Длительности различаются и не обязаны совпадать побайтно: важны причинные условия и ожидаемые результаты.

Это не приёмка чужого production VPS. На своём сервере дополнительно проверяются права, файловая система, systemd/cron, доступность Redis, политика данных, условия failover, остановка worker и поведение внешних побочных эффектов. После изменения PHP пересоздайте запись, а затем сравните хеши. Старый JSON от другого кода не должен выдавать себя за результат новой версии.

composer capture
composer test
composer build-public
composer validate --strict
composer audit

# Для пяти локальных опытов:
composer capture:flock
php tests/run.php --flock-only

Диагностика: занято, нет доступа, неверный адрес или потерян TTL

Если acquire возвращает false, найдите текущую работу с тем же ресурсом и проверьте, не слишком ли широк ключ. Если возникает ошибка создания файла, проверьте каталог и пользователя. Если Redis отвечает отказом соединения, сравните адрес с местом запуска PHP: host и контейнер используют разные значения localhost. Это разные классы проблем, и одно увеличение таймаута не решает их все.

Если после изменения PHP test сообщает о несовпадении хеша, выполните capture и build-public заново. Если в браузере осталась старая последовательность, проверьте, какой recording.json отдаётся и не закеширован ли прежний вариант. Не редактируйте JSON вручную, чтобы «починить» доказательство: он должен отражать исполнение исходников.

При истечении TTL сначала измерьте максимальную порцию и реальные паузы, затем пересмотрите бюджет и способ сохранения. Не пытайтесь лечить любую проблему огромным TTL: после аварии такой ресурс будет дольше оставаться недоступным. Длинная аренда и частое продление — инструменты с компромиссами, а не универсальные значения из чужой статьи.

Исходники, остановка лаборатории и следующий шаг

В архив входят PHP-классы, контроллер процессов, worker, тесты, Composer lock, Compose Redis, мастерская, исходные записи, пример команды и Linux/VPS runbook. Vendor устанавливается через Composer и не упакован. Временные каталоги, рабочие lock-файлы и секреты не публикуются. Для просмотра страницы достаточно public, а для повторения опытов — полного проекта.

После работы остановите локальный HTTP-сервер через Ctrl+C и выполните docker compose down из каталога этой лаборатории. Команда относится к проекту aen-lock-b18. Глобальные prune, удаление чужих контейнеров и очистка общих volumes не нужны. Если рядом работают другие приложения, сначала проверьте, что вы находитесь в правильном каталоге и используете правильный Compose-файл.

Для внедрения выберите одну реальную задачу: отчёт, импорт или пересборку кеша. Сформулируйте ресурс, поведение конкурента, предел ожидания и способ безопасного повтора. Затем перенесите соответствующий опыт в тесты собственного проекта. Для справки используйте Symfony Lock 7.4, исходный код компонента и документацию Predis.

# Остановить только Redis и сеть данной лаборатории:
docker compose down

# Локальный HTTP-сервер:
# Ctrl+C в том терминале, где выполняется composer serve.