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;

Такой подход проще контролировать и безопаснее повторять.

Чек-лист

  1. Добавить лог старта и завершения задачи.
  2. Записывать PID и длительность выполнения.
  3. Проверить интервал cron и реальное время работы.
  4. Добавить flock или другой lock.
  5. Использовать try/finally для освобождения lock.
  6. Не полагаться только на наличие lock-файла.
  7. Для Redis или DB-lock использовать TTL.
  8. Разбить тяжёлую задачу на пакеты, если она не укладывается в интервал.

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

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

Пока нет комментариев. Будьте первым!