Как решить проблему параллельной обработки заявок несколькими инстансами приложения с использованием scheduler
Что проверяют
Понимает ли кандидат, что @Scheduled срабатывает на каждом инстансе одновременно, и знает ли рабочие способы это разрулить: либо выбрать одного исполнителя, либо сделать захват задач атомарным. Плюс идемпотентность и восстановление после падения инстанса.
Как отвечать
Проблема формулируется точно: три пода подняли планировщик, в назначенную секунду все три сделали SELECT * FROM requests WHERE status = 'NEW' и взяли одни и те же строки. Есть два принципиально разных пути.
Путь 1: пусть работает один инстанс.
- ShedLock ставит распределённый замок на выполнение конкретной джобы через общее хранилище (JDBC, Redis, Mongo).
@Scheduled(fixedDelay = 10_000)
@SchedulerLock(name = "requestsPoller", lockAtMostFor = "5m", lockAtLeastFor = "5s")
public void poll() { ... }lockAtMostFor обязателен: если инстанс умрёт, не сняв замок, по истечении этого срока задачу подберёт другой. lockAtLeastFor защищает от повторного запуска при расхождении часов между узлами.
- Quartz в кластерном режиме с JDBC JobStore (
isClustered = true) делает то же самое штатно, через блокировки в таблицахQRTZ_*, и дополнительно умеет misfire-политику и перезапуск задач упавшего узла.
Минус пути очевиден: обрабатывает один узел, горизонтально это не масштабируется.
Путь 2: работают все, но захват заявки атомарен. Это то, что обычно и нужно.
UPDATE requests
SET status = 'IN_PROGRESS',
locked_by = :instanceId,
locked_until = now() + interval '5 minutes',
attempts = attempts + 1
WHERE id IN (
SELECT id FROM requests
WHERE status = 'NEW' AND (locked_until IS NULL OR locked_until < now())
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 50
)
RETURNING id;FOR UPDATE SKIP LOCKED (PostgreSQL 9.5+, MySQL 8.0+, Oracle) заставляет параллельные транзакции пропускать уже заблокированные строки вместо ожидания, поэтому каждый инстанс уносит свою пачку без блокировок и дедлоков. locked_until возвращает в очередь заявки инстанса, который упал посреди обработки. Альтернатива без SKIP LOCKED: оптимистическая блокировка через @Version, но при конкуренции она вырождается в поток OptimisticLockException и ретраев.
Важно: транзакция захвата коммитится до долгой обработки. Если держать её открытой на всё время работы, соединение из пула занято минутами, а таймаут транзакции рвёт всё на середине.
Очередь вместо базы. При больших объёмах правильнее вынести распределение в Kafka или RabbitMQ: consumer group сама раздаёт партиции по инстансам, планировщик остаётся только для того, чтобы наливать заявки в очередь. Ключ партиционирования выбирается так, чтобы связанные заявки шли в один порядок.
Что должно быть в любом варианте: идемпотентность обработчика (доставка at-least-once означает дубликаты, спасает ключ идемпотентности и уникальный индекс на результат), ретраи с экспоненциальной паузой и лимитом попыток, DLQ или статус FAILED для разбора, метрики возраста самой старой заявки в статусе NEW и числа зависших IN_PROGRESS.
Где спотыкаются
Ставят @Scheduled и считают задачу решённой, забывая, что в трёх подах он запустится трижды. Второе: делают захват и обработку в одной длинной транзакции, из-за чего выгребается пул соединений, а при таймауте вся пачка откатывается и обрабатывается заново.