Skip to content

Compare versions

From:To:
3
11¶ # Что это за разбор
22
33 Очередь задач в базе устроена просто ровно до второго воркера. Дальше встаёт вопрос, который решают все и по-разному: как двум процессам взять из одной таблицы разные задачи и не подраться за одну.
44
55 Это разбор того, как на него отвечали в [graphile-worker](https://github.com/graphile/worker) — очереди задач на Postgres. Автор и основной разработчик — Бенджи Гиллам (Benjie Gillam), тот же человек, что делает PostGraphile.
66
77 Всё ниже взято из истории коммитов, кода и документации проекта. Ссылки на месте — можно проверить и не поверить.
88¶ ## Как это должно работать
99
1010 Postgres даёт готовый ответ — `FOR UPDATE SKIP LOCKED`: «возьми строки, пропуская те, что уже кем-то заняты». graphile-worker вынес это прямо в список своих достоинств:
1111
1212 > High performance (uses `SKIP LOCKED` to find jobs to execute, resulting in faster fetches)
1313
1414 Казалось бы, тема закрыта.
1515
1616 Но у задач есть ещё и **именованные очереди** — когда задачи с одинаковым именем обязаны выполняться строго по одной. И вот тут одного `SKIP LOCKED` уже мало: надо как-то занимать саму очередь, а не только задачу.
1717¶ ## Кадр первый: 29 июня 2022
1818
1919 Приезжает коммит с названием, которое ничего не обещает:
2020
2121 > `Add a few strategies`
2222
2323 Сто строк в файле выбора задачи. Описания у коммита нет — вся документация внутри, комментарием над настройкой. И это лучший вид документации, потому что она не про то, как работает код, а про то, **что показали замеры**:
2424
2525 > **0** — we're not using named queues; skip them! …it's the absolute fastest strategy.
2626 >
2727 > **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.
2828 >
2929 > **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…
3030 >
3131 > **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.**
3232
3333 Четыре варианта, из них один — «скорее всего небезопасен, не пользуйтесь». Оставлен в коде и снабжён предупреждением, потому что кому-то он всё-таки может понадобиться.
3434¶ ## Кадр второй: пятый вариант, которого нет
3535
3636 Чуть ниже в том же файле лежит закомментированный кусок SQL с эпитафией:
3737
3838 > This strategy causes incredibly bad performance, presumably due to the lack of lock/skip locked
3939 >
4040 > *Эта стратегия даёт чудовищную производительность, предположительно из-за отсутствия lock/skip locked.*
4141
4242 Его не удалили. Его положили рядом с надписью «пробовали, плохо» — чтобы следующий, кому эта идея покажется хорошей, сначала прочитал.
4343
4444 Слово `presumably` здесь важнее остального: автор не выдумывает объяснение задним числом, а честно помечает, что замер есть, а причина — догадка.
4545¶ ## Кадр третий: 14 ноября 2023
4646
4747 Через год и четыре месяца приходит коммит с названием, которое просится в рамку:
4848
4949 > `Go back to the old way of doing it`
5050
5151 Минус 267 строк, плюс 73. Файл выбора задачи худеет на 177 строк. Настраиваемость выбрасывают целиком.
5252
5353 Четыре стратегии прожили полтора года и свернулись обратно в одну. Замеры при этом никуда не делись — описания остались в коде. Перестал существовать сам выбор.
5454
5555 Почему именно так, в коммите не объяснено: он идёт в серии из семи коммитов одного дня с названиями вроде `Checkout basics` и `More backporting`, то есть в середине крупной перекладки. Так что от домыслов воздержимся. Известно одно: выбор из четырёх стратегий не пережил полутора лет.
5656¶ ## Цена решения, описанная ими самими
5757
5858 У конструкции «пометить задачу взятой» есть цена, и найти её можно не в коде, а в документации для пользователей — в разделе про обработку ошибок:
5959
6060 > 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.
6161 >
6262 > *Если воркер завершился так, что обработать это нельзя — …кто-то выдернул шнур… — то задачи, которые он выполнял, остаются заблокированными не менее четырёх часов.*
6363
6464 Вот она, вся арифметика в одном абзаце. Задача помечается взятой (`locked_at`) на время работы. Воркер умер — пометка осталась. Значит нужен ещё один механизм, который ходит и разблокирует; в проекте под него отдельный файл, `resetLockedAt.ts`.
6565
6666 Выдернули шнур — пользователь ждёт четыре часа. Не потому что кто-то недодумал, а потому что это честная цена за «пометить взятой».
6767¶ ## Развилка, которая есть у каждого
6868
6969 Способов взять задачу из очереди в базе, по большому счёту, два.
7070
7171 **Первый: пометить задачу взятой на время работы.** Ставим `locked_at`, работаем, снимаем. Просто и наглядно. Цена — процесс, умерший не по-хорошему, оставляет пометку навсегда, и нужен второй механизм, который её снимет.
7272
7373 **Второй: не помечать ничего.** Взять задачу и тем же запросом записать «попытка номер N, следующая в такое-то время». Дальше работать без всякой блокировки. Умер процесс — просто не будет записи об успехе, и задача созреет снова сама, по расписанию повторов.
7474
7575 У второго тоже есть цена: доставка становится «хотя бы раз», а не «ровно раз». Если процесс умер **после** отправки, но до записи об успехе, получатель получит сообщение дважды.
7676
7777 Выбор между ними — это выбор между «нужен уборщик» и «нужна защита от повторов». Обе цены реальны, и обе лучше платить осознанно.
7878## Проверь у себя
79791. Выяснить, кто снимет пометку после SIGKILL
8080 Если в вашей очереди задача помечается взятой на время работы — найдите механизм, который снимает пометку после аварийного завершения. Он должен существовать явно.
8181
8282 ```sql
8383 -- есть ли вообще такая колонка
8484 \d+ your_queue_table
8585
8686 -- и главное: сколько задач висят взятыми прямо сейчас
8787 select count(*), min(locked_at)
8888 from your_queue_table
8989 where locked_at is not null;
9090 ```
9191
9292 Если `min(locked_at)` старше вашего таймаута обработки — уборщика либо нет, либо он не работает.
9393 why: Механизм уборки обычно пишут не сразу, а после первого инцидента: до него «взятые» задачи никто не считает, потому что в обычной жизни их не бывает. Первым это замечает пользователь, чей заказ завис.
9494 - [ ] Найдено место в коде, которое снимает пометку у зависших задач
9595 - [ ] Известен таймаут, после которого задача считается зависшей
9696 - [ ] Проверено, что сейчас нет задач, висящих дольше этого таймаута
97972. Убедиться, что мёртвые записи не тормозят выборку [recommended]
9898 Описание стратегии 1 предупреждает про «large jobs table with many higher priority but stuck jobs» — то есть про таблицу, разросшуюся от старых записей.
9999
100100 Лечится это не уборкой, а частичным индексом: доставленные и брошенные записи в него просто не попадают.
101101
102102 ```sql
103103 create index queue_due_idx
104104 on notification_queue (next_attempt_at, created_at)
105105 where delivered_at is null and abandoned_at is null;
106106 ```
107107
108108 Проверить, что индекс действительно используется:
109109
110110 ```sql
111111 explain (analyze, buffers)
112112 select id from notification_queue
113113 where delivered_at is null and abandoned_at is null
114114 and next_attempt_at <= now()
115115 order by created_at limit 10
116116 for update skip locked;
117117 ```
118118 $ explain (analyze, buffers) select id from your_queue where ... for update skip locked;
119119 why: Опрос очереди идёт каждые несколько секунд и на пустой очереди тоже. Если он читает всю таблицу, стоимость растёт вместе с историей — и замедление приходит не в час пик, а через полгода.
120120 - [ ] В плане запроса виден Index Scan, а не Seq Scan
121121 - [ ] Индекс частичный — старые записи в него не попадают
122122 → SELECT ... FOR UPDATE SKIP LOCKED — https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
123123¶ ## Вывод, который не про код
124124
125125 Четыре стратегии с настройкой прожили полтора года и свернулись в одну.
126126
127127 Прежде чем выносить выбор наружу, стоит спросить: **а есть ли у того, кто будет выбирать, чем этот выбор сделать?** Чтобы выбрать между стратегиями 1 и 2, нужно знать про свои именованные очереди, застрявшие задачи и размер таблицы. Большинству проще, чтобы выбрали за них.
128128
129129 И отдельно — про отвергнутый вариант, оставленный в коде с пометкой «пробовали, плохо». Это дешёвая и очень полезная привычка: она экономит время следующему, кому та же идея покажется свежей.
130
131
132