Skip to content

Четыре стратегии и один откат: как в graphile-worker выбирали, чем брать задачи из очереди

Разбор настоящей истории из открытого проекта: четыре стратегии блокировки, эпитафия отвергнутой пятой и коммит «Go back to the old way of doing it» через полтора года. И чем платят за решение «пометить задачу взятой».

v2 0 stars 0 forks 0 watchers 1 branch 0 runs Public
patched via APIv2
Что это за разбор

Очередь задач в базе устроена просто ровно до второго воркера. Дальше встаёт вопрос, который решают все и по-разному: как двум процессам взять из одной таблицы разные задачи и не подраться за одну.

Это разбор того, как на него отвечали в graphile-worker — очереди задач на Postgres. Автор и основной разработчик — Бенджи Гиллам (Benjie Gillam), тот же человек, что делает PostGraphile.

Всё ниже взято из истории коммитов, кода и документации проекта. Ссылки на месте — можно проверить и не поверить.

Как это должно работать

Postgres даёт готовый ответ — FOR UPDATE SKIP LOCKED: «возьми строки, пропуская те, что уже кем-то заняты». graphile-worker вынес это прямо в список своих достоинств:

High performance (uses SKIP LOCKED to find jobs to execute, resulting in faster fetches)

Казалось бы, тема закрыта.

Но у задач есть ещё и именованные очереди — когда задачи с одинаковым именем обязаны выполняться строго по одной. И вот тут одного SKIP LOCKED уже мало: надо как-то занимать саму очередь, а не только задачу.

Кадр первый: 29 июня 2022

Приезжает коммит с названием, которое ничего не обещает:

Add a few strategies

Сто строк в файле выбора задачи. Описания у коммита нет — вся документация внутри, комментарием над настройкой. И это лучший вид документации, потому что она не про то, как работает код, а про то, что показали замеры:

0 — we're not using named queues; skip them! …it's the absolute fastest strategy.

1 — for each matched job, go lock its job queue if you can. …what Worker traditionally used… but these days its a terrible strategy unless you're still randomly generating queue names (don't do that!). Performance is abysmal if you have a large jobs table with many higher priority but stuck jobs.

2 — lock the job queues up front, then find a job to do. …seems to be the fastest strategy for jobs that aren't in a queue…

3 — explicitly avoid locked job queues, but risk multiple jobs in same queue running at same time. Strategy 3 is probably unsafe. Don't use it.

Четыре варианта, из них один — «скорее всего небезопасен, не пользуйтесь». Оставлен в коде и снабжён предупреждением, потому что кому-то он всё-таки может понадобиться.

Кадр второй: пятый вариант, которого нет

Чуть ниже в том же файле лежит закомментированный кусок SQL с эпитафией:

This strategy causes incredibly bad performance, presumably due to the lack of lock/skip locked

Эта стратегия даёт чудовищную производительность, предположительно из-за отсутствия lock/skip locked.

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

Слово presumably здесь важнее остального: автор не выдумывает объяснение задним числом, а честно помечает, что замер есть, а причина — догадка.

Кадр третий: 14 ноября 2023

Через год и четыре месяца приходит коммит с названием, которое просится в рамку:

Go back to the old way of doing it

Минус 267 строк, плюс 73. Файл выбора задачи худеет на 177 строк. Настраиваемость выбрасывают целиком.

Четыре стратегии прожили полтора года и свернулись обратно в одну. Замеры при этом никуда не делись — описания остались в коде. Перестал существовать сам выбор.

Почему именно так, в коммите не объяснено: он идёт в серии из семи коммитов одного дня с названиями вроде Checkout basics и More backporting, то есть в середине крупной перекладки. Так что от домыслов воздержимся. Известно одно: выбор из четырёх стратегий не пережил полутора лет.

Цена решения, описанная ими самими

У конструкции «пометить задачу взятой» есть цена, и найти её можно не в коде, а в документации для пользователей — в разделе про обработку ошибок:

If the worker is terminated in a way that cannot be handled (e.g. process.exit(), segfault, SIGKILL, someone pulled the power cord, etc) then the jobs that that worker was executing remain locked for at least 4 hours. Every 8-10 minutes a worker will sweep for jobs that have been locked for more than 4 hours and will make them available to be processed again automatically.

Если воркер завершился так, что обработать это нельзя — …кто-то выдернул шнур… — то задачи, которые он выполнял, остаются заблокированными не менее четырёх часов.

Вот она, вся арифметика в одном абзаце. Задача помечается взятой (locked_at) на время работы. Воркер умер — пометка осталась. Значит нужен ещё один механизм, который ходит и разблокирует; в проекте под него отдельный файл, resetLockedAt.ts.

Выдернули шнур — пользователь ждёт четыре часа. Не потому что кто-то недодумал, а потому что это честная цена за «пометить взятой».

Развилка, которая есть у каждого

Способов взять задачу из очереди в базе, по большому счёту, два.

Первый: пометить задачу взятой на время работы. Ставим locked_at, работаем, снимаем. Просто и наглядно. Цена — процесс, умерший не по-хорошему, оставляет пометку навсегда, и нужен второй механизм, который её снимет.

Второй: не помечать ничего. Взять задачу и тем же запросом записать «попытка номер N, следующая в такое-то время». Дальше работать без всякой блокировки. Умер процесс — просто не будет записи об успехе, и задача созреет снова сама, по расписанию повторов.

У второго тоже есть цена: доставка становится «хотя бы раз», а не «ровно раз». Если процесс умер после отправки, но до записи об успехе, получатель получит сообщение дважды.

Выбор между ними — это выбор между «нужен уборщик» и «нужна защита от повторов». Обе цены реальны, и обе лучше платить осознанно.

Проверь у себя

1
Выяснить, кто снимет пометку после SIGKILL

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

sql
1-- есть ли вообще такая колонка
2\d+ your_queue_table
3
4-- и главное: сколько задач висят взятыми прямо сейчас
5select count(*), min(locked_at)
6from your_queue_table
7where locked_at is not null;

Если min(locked_at) старше вашего таймаута обработки — уборщика либо нет, либо он не работает.

Why: Механизм уборки обычно пишут не сразу, а после первого инцидента: до него «взятые» задачи никто не считает, потому что в обычной жизни их не бывает. Первым это замечает пользователь, чей заказ завис.
Check
  • Найдено место в коде, которое снимает пометку у зависших задач
  • Известен таймаут, после которого задача считается зависшей
  • Проверено, что сейчас нет задач, висящих дольше этого таймаута
2
Убедиться, что мёртвые записи не тормозят выборкуRecommended

Описание стратегии 1 предупреждает про «large jobs table with many higher priority but stuck jobs» — то есть про таблицу, разросшуюся от старых записей.

Лечится это не уборкой, а частичным индексом: доставленные и брошенные записи в него просто не попадают.

sql
1create index queue_due_idx
2 on notification_queue (next_attempt_at, created_at)
3 where delivered_at is null and abandoned_at is null;

Проверить, что индекс действительно используется:

sql
1explain (analyze, buffers)
2select id from notification_queue
3where delivered_at is null and abandoned_at is null
4 and next_attempt_at <= now()
5order by created_at limit 10
6for update skip locked;
Why: Опрос очереди идёт каждые несколько секунд и на пустой очереди тоже. Если он читает всю таблицу, стоимость растёт вместе с историей — и замедление приходит не в час пик, а через полгода.
$explain (analyze, buffers) select id from your_queue where ... for update skip locked;
Check
  • В плане запроса виден Index Scan, а не Seq Scan
  • Индекс частичный — старые записи в него не попадают
Вывод, который не про код

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

Прежде чем выносить выбор наружу, стоит спросить: а есть ли у того, кто будет выбирать, чем этот выбор сделать? Чтобы выбрать между стратегиями 1 и 2, нужно знать про свои именованные очереди, застрявшие задачи и размер таблицы. Большинству проще, чтобы выбрали за них.

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

Разбор бага из плагина лояльности Medusa: кнопка «убрать скидку» выдавала максимальную. Одна строка, три исправления за три дня и верная проверка, лежавшая тремя строками ниже.

v2Public 0 0
updated Sep 1, 2026

Пользователь просил «1000 запросов в час», а получил «1000 запросов, потом час тишины от последней попытки». Разбор задачи в Better Auth, открытой девять месяцев, и почему её нельзя починить одной строкой.

v2Public 0 0
updated Sep 1, 2026