Дубли задач в распределённом планировщике на Python: разбор инцидента
Один cron-джоб на трёх воркерах отправил клиентам письмо трижды. Разбираем, почему APScheduler и наивный планировщик дублируют задачи в распределённой среде и как это лечить блокировкой в БД.

В 09:00 по расписанию сервис должен был разослать 12 400 писем с еженедельным отчётом. Разослал 37 200 — по три письма на адрес. Причина оказалась банальной: планировщик задач жил внутри приложения, а приложение крутилось на трёх подах в Kubernetes. Каждый под честно сработал по своему расписанию. Разбираем, где именно ломается наивная схема с APScheduler внутри процесса, как отличить эту проблему от повторной доставки из очереди и какие механизмы блокировки реально останавливают дубли.
Что именно произошло
Сервис на FastAPI (Python 3.11) использовал APScheduler 3.10 с BackgroundScheduler. Планировщик стартовал в событии запуска приложения. Один экземпляр приложения — один планировщик, всё работает. Когда сервис масштабировали до трёх реплик ради устойчивости, никто не подумал о планировщике: он поднялся в каждом процессе. Расписание одно и то же, часы синхронизированы по NTP, поэтому три планировщика сработали почти одновременно.
Ключевая деталь: рассылка не была идемпотентной. Функция брала всех активных клиентов и слала письмо. Никакой отметки «этому уже отправлено сегодня» не было — расчёт строился на том, что джоб запускается ровно один раз. В моно-развёртывании это допущение держалось. При трёх репликах оно рухнуло.
Планировщик внутри процесса приложения безопасен ровно до тех пор, пока процесс один. Как только вы поставили вторую реплику ради отказоустойчивости, вы получили второй планировщик — и цена этого решения оплачивается в проде дублями.
Почему это не «повторная доставка из очереди»
Первая гипотеза дежурного была про Celery и at-least-once доставку: брокер отдал задачу дважды, потому что воркер не успел подтвердить. Это отдельный класс проблем, и лечится он идемпотентностью обработчика. Но здесь Celery не было вовсе — рассылку запускал именно встроенный планировщик по времени.
Разница принципиальная. При at-least-once дубль приходит от одного планировщика через ненадёжную доставку: задача поставлена один раз, выполнена несколько. В нашем случае задача была поставлена трижды — тремя независимыми планировщиками, которые ничего не знали друг о друге. Инвалидация очереди тут ни при чём: очереди не было.
Диагностика заняла время именно из-за путаницы. Различить их можно по логам: если в момент инцидента вы видите три записи «scheduler fired job weekly_report» с трёх разных хостов — это дубль планировщика. Если запись одна, а обработчик отработал трижды — это доставка.
Механизм: как APScheduler хранит состояние
APScheduler считает задачи через jobstore. По умолчанию это MemoryJobStore — расписание живёт в памяти процесса. У каждой реплики своя память, поэтому каждая ведёт свой независимый счёт времени срабатывания. Даже если вы подключите общий SQLAlchemyJobStore в одну базу, сам по себе он не гарантирует единичный запуск: несколько планировщиков, подключённых к одному стору, при определённой настройке всё равно способны конкурировать за задачу.
APScheduler не задумывался как распределённый оркестратор. Он планировщик процесса. Попытка сделать из него кластерный планировщик простым добавлением общей БД — это работа против его модели, а не по ней.
Что мерить, чтобы поймать это заранее
Готовых цифр по нагрузке здесь нет — проблема логическая, а не производительная. Мерить нужно факт двойного срабатывания:
- счётчик запусков каждого джоба с меткой
hostname/pod— в норме за интервал ровно один инкремент; - алерт на условие «за окно расписания сработало больше одного экземпляра одного и того же джоба»;
- для рассылок и списаний — отдельный лог факта побочного эффекта (письмо отправлено, деньги списаны) с ключом идемпотентности.
Варианты решения
Задача сводится к одному: из N реплик по расписанию должна сработать ровно одна. Есть несколько способов это гарантировать.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| Вынести планировщик в отдельный процесс (одна реплика деплоймента) | Простота, планировщик снова один | Этот процесс — точка отказа; при рестарте расписание простаивает |
| Распределённая блокировка в БД (advisory lock в PostgreSQL) | Реплик много, задачу берёт первая захватившая лок | Нужна общая надёжная БД; лок надо корректно отпускать |
| Внешний планировщик: Kubernetes CronJob | Планированием занимается платформа, а не приложение | Привязка к Kubernetes; сложнее локальная отладка |
| Идемпотентность обработчика поверх любого из вариантов | Дубль запуска не приводит к дублю эффекта | Не отменяет двойную работу, только гасит её последствия |
В инциденте мы применили два слоя сразу: блокировку через advisory lock, чтобы гасить конкуренцию реплик, и идемпотентность рассылки как страховку.
Блокировка через PostgreSQL advisory lock
Advisory lock — это блокировка*, которую вы захватываете по произвольному числовому ключу; сама база не связывает её ни с какими строками, вы решаете, что этот ключ значит. Функция pg_try_advisory_lock возвращает true, если лок свободен и захвачен вами, и false, если его уже держит кто-то другой. Первая реплика, дошедшая до строки, захватывает лок и делает работу; остальные получают false и молча выходят.
# Python 3.11, psycopg 3.x, вызывается из тела запланированной задачи
import psycopg
# Ключ лока — стабильное число, одно на конкретный джоб.
# Разные джобы должны использовать разные ключи.
LOCK_KEY_WEEKLY_REPORT = 4210001
def run_weekly_report(dsn: str) -> None:
# Отдельное соединение: сессионный advisory lock держится,
# пока живо соединение, поэтому его нельзя брать из общего пула надолго.
with psycopg.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute("SELECT pg_try_advisory_lock(%s)", (LOCK_KEY_WEEKLY_REPORT,))
acquired = cur.fetchone()[0]
if not acquired:
# Лок держит другая реплика — эта ничего не делает.
return
try:
send_weekly_report() # сам побочный эффект
finally:
# Отпускаем лок явно, чтобы следующее срабатывание прошло.
cur.execute("SELECT pg_advisory_unlock(%s)", (LOCK_KEY_WEEKLY_REPORT,))
Тонкость, из-за которой люди спотыкаются: сессионный pg_advisory_lock привязан к соединению. Если взять соединение из пула, вернуть его в пул и не отпустить лок, лок останется висеть до закрытия физического соединения. Поэтому под лок берётся отдельное соединение и лок отпускается в finally. Есть и транзакционный вариант — pg_try_advisory_xact_lock, который отпускается автоматически при завершении транзакции; он проще, но держит блокировку ровно на время транзакции.
Идемпотентность как второй слой
Блокировка защищает от двойного запуска. Но если письмо уже частично разослано и реплика упала посреди работы, повторный запуск снова пройдётся по клиентам. Поэтому сам эффект делают идемпотентным: перед отправкой проверяют, не отправлено ли уже сегодня, и пишут факт отправки в таблицу с уникальным ключом вроде (client_id, report_date). Тогда повтор не создаёт дубль, а натыкается на конфликт уникальности и пропускает адрес.
Где каждый подход ломается
- Отдельная реплика планировщика. Ломается на рестарте и на выкатке: пока под перезапускается, расписание не срабатывает. Для минутных задач это редко критично, для ежесекундных — уже вопрос.
- Advisory lock. Ломается, если соединение с л348ком порвалось в момент работы: PostgreSQL отпустит лок при обрыве соединения, и другая реплика сможет войти, пока первая ещё доделывает. Без идемпотентности это снова дубль. Лечится тем самым вторым слоем.
- Kubernetes CronJob. Рассчитан на запуск пода по расписанию, но при определённых сбоях платформа тоже может запустить джоб дважды — политика по умолчанию не строго «ровно один раз». Идемпотентность нужна и здесь.
- Синхронизация часов. Все схемы, где реплики решают по локальному времени, зависят от NTP. Расхождение часов растягивает окно гонки и усложняет диагностику.
Общий вывод инцидента: ни одна блокировка не заменяет идемпотентность. Блокировка убирает лишнюю работу в штатном режиме, идемпотентность защищает от дубля эффекта в нештатном.
Что делать дальше
Если у вас планировщик живёт внутри приложения и вы планируете больше одной реплики — начните с ревизии:
- Найдите все места, где расписание стартует внутри процесса приложения (события старта,
BackgroundScheduler, декораторы задач). - Для каждого джоба ответьте на один вопрос: что будет, если он сработает дважды подряд. Если ответ «дубль эффекта» — джоб надо защищать.
- Добавьте счётчик срабатываний с меткой хоста и алерт на «больше одного за окно» — это ловит проблему до жалобы клиента.
- Побочные эффекты (рассылки, списания, начисления) делайте идемпотентными через уникальный ключ, независимо от выбранной схемы планирования.
Планирование при единственной реплике и разграничение зон ответственности между планировщиком и очередью — отдельные темы, здесь мы разбирали только гонку нескольких планировщиков. Точный набор функций и поведение блокировок сверяйте с документацией вашей версии PostgreSQL и APScheduler: между мажорными версиями детали менялись.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: APScheduler documentation, PostgreSQL: Advisory Lock Functions
Частые вопросы
У меня APScheduler на одной реплике, мне это грозит?
А если у меня MySQL, а не PostgreSQL — advisory lock есть?
Лок захватился, а задача упала — что произойдёт?
Зачем блокировка, если можно просто сделать обработчик идемпотентным?
Kubernetes CronJob полностью решает проблему дублей?
Как понять по логам, что это дубль планировщика, а не повторная доставка из очереди?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.