Cron-задача на PHP запускается два раза: как поставить защиту
Если cron-задача запускается повторно до завершения предыдущего запуска, последствия могут быть неприятными: дубли писем, повторная отправка заказов в CRM, двойное списание временных резервов, блокировки в базе, конфликтующие импорты. На маленьком объёме это может быть незаметно, но при росте данных проблема становится регулярной.
Любая cron-задача, которая может выполняться дольше своего интервала, должна иметь защиту от параллельного запуска.
Как понять, что задача запускается два раза
Типовые признаки:
- в логах два одинаковых старта рядом;
- одна и та же заявка обработана дважды;
- появляются дубли записей;
- в базе видны блокировки во время импорта;
- задача запускается каждую минуту, а работает несколько минут;
- после зависания начинается несколько копий процесса.
Сначала стоит добавить лог старта и завершения с PID.
error_log(json_encode([
'event' => 'cron_start',
'pid' => getmypid(),
'time' => date('c'),
], JSON_UNESCAPED_UNICODE));
Простая защита через flock
Для консольных PHP-скриптов часто достаточно flock. Он создаёт lock-файл и не даёт второму процессу войти в критическую секцию.
$lockFile = fopen(__DIR__ . '/runtime/import.lock', 'c');
if (!$lockFile || !flock($lockFile, LOCK_EX | LOCK_NB)) {
echo "Already running\n";
exit(0);
}
try {
// основная работа
} finally {
flock($lockFile, LOCK_UN);
fclose($lockFile);
}
LOCK_NB означает “не ждать”. Если задача уже выполняется, второй запуск спокойно завершится.
Не удалять lock-файл вручную без проверки
Сам lock-файл может существовать всегда. Важна блокировка, а не наличие файла. Поэтому не нужно считать, что файл import.lock означает зависшую задачу.
Если используется flock, блокировка снимается, когда процесс завершился и файл закрыт.
Lock с TTL
Если lock хранится в Redis, базе или кэше, нужен TTL. Иначе после аварийного завершения можно получить вечную блокировку.
$key = 'cron:import:lock';
$ttl = 1800;
if (!setLock($key, $ttl)) {
exit(0);
}
try {
// импорт
} finally {
releaseLock($key);
}
TTL должен быть больше обычного времени выполнения задачи, но не бесконечным. Если задача иногда работает дольше TTL, блокировка может сняться слишком рано, и второй процесс стартует параллельно.
systemd и Restart
Если задача работает как daemon или queue worker, лучше запускать её через systemd или supervisor. Но Restart тоже нужно настраивать аккуратно, чтобы сервис не создавал несколько конкурирующих процессов.
[Service]
WorkingDirectory=/var/www/site.ru
ExecStart=/usr/bin/php yii queue/listen
Restart=always
User=www-data
Для периодических задач systemd timer может быть удобнее cron, но защита от параллельного запуска всё равно нужна, если выполнение может пересекаться.
Логировать завершение
Если в логах есть только старт, сложно понять, задача завершилась или зависла. Нужно писать итог.
error_log(json_encode([
'event' => 'cron_finish',
'pid' => getmypid(),
'processed' => $processed,
'errors' => $errors,
'duration' => round(microtime(true) - $startedAt, 3),
], JSON_UNESCAPED_UNICODE));
Обработка ошибок
Lock должен освобождаться даже при исключении. Поэтому основную работу заворачивают в try/finally. Если ошибка завершила процесс до освобождения lock, поведение зависит от механизма: flock снимется автоматически, Redis-lock может жить до TTL.
Разделить тяжёлую задачу на пакеты
Если cron не успевает завершиться до следующего запуска, возможно, задача слишком большая. Её можно разбить на пакеты: каждый запуск обрабатывает ограниченное число записей и сохраняет прогресс.
SELECT *
FROM import_queue
WHERE status = 'new'
ORDER BY id
LIMIT 500;
Такой подход проще контролировать и безопаснее повторять.
Чек-лист
- Добавить лог старта и завершения задачи.
- Записывать PID и длительность выполнения.
- Проверить интервал cron и реальное время работы.
- Добавить flock или другой lock.
- Использовать try/finally для освобождения lock.
- Не полагаться только на наличие lock-файла.
- Для Redis или DB-lock использовать TTL.
- Разбить тяжёлую задачу на пакеты, если она не укладывается в интервал.
Защита от параллельного запуска — базовая часть любой важной фоновой задачи. Она дешевле, чем разбирать дубли заказов, повторные письма и блокировки после каждого тяжёлого импорта.
Комментарии (0)
Пока нет комментариев. Будьте первым!