# Черга

## Що це таке

Черга — це проміжне сховище між двома програмами: одна сторона кладе туди
повідомлення чи задачі, інша забирає їх, коли готова, зазвичай від найстаріших.
Майже кожна система має кілька черг, по одній на кожен вид роботи. Програму, що
додає роботу, називають виробником, програму, що її забирає, — споживачем, а те,
що зберігає повідомлення між ними, — брокером. Брокером може бути окремий сервер
або таблиця в базі даних, яка в системі вже є.

Сенс у тому, що дві сторони перестають чекати одна на одну. Вебзапит, якому
треба надіслати лист, кладе повідомлення в чергу й одразу відповідає
користувачеві; воркер забирає повідомлення за мить, а якщо їх надійде сотня
разом, розбирає їх у власному темпі. Сплеск перетворюється на доробок, а
залежність — на затримку.

Що черга обіцяє щодо доставки, описують два поняття. Перше — підтвердження:
споживач повідомляє брокера, що закінчив із повідомленням, і лише тоді воно
зникає з черги. У моделі AMQP 0-9-1, яку реалізує RabbitMQ, повідомлення, чий
споживач помер, не встигнувши підтвердити, дістається іншому споживачеві. Друге
— довговічність: довговічна черга з постійними повідомленнями переживає
перезапуск брокера, а черга, що існує лише в пам'яті, втрачає все.

Варто розрізняти три сусідні поняття. Черга повідомлень переносить дані між
сервісами. Черга задач переносить одиниці роботи для фонових воркерів, і кожна
задача має аргументи, стан і лічильник спроб. Журнал, модель, на якій побудовано
Apache Kafka, ззовні схожий на чергу, але зберігає події й після прочитання,
протягом строку, заданого для кожного топіка, тож кілька читачів можуть пройти
ту саму історію. Читання з черги прибирає повідомлення; читання з журналу лише
зсуває позицію читача.

Черга не обов'язково є окремим продуктом. PostgreSQL упорається з цією роллю за
допомогою таблиці задач і запиту, який бере наступний рядок і переступає через
рядки, вже заблоковані іншим воркером. Документація прямо називає таблиці на
кшталт черги причиною, з якої існує `SKIP LOCKED`:

```sql
WITH next AS (
  SELECT id
    FROM jobs
   WHERE state = 'available'
   ORDER BY id
   LIMIT 1
     FOR UPDATE SKIP LOCKED
)
UPDATE jobs
   SET state = 'running',
       attempt = attempt + 1
  FROM next
 WHERE jobs.id = next.id
RETURNING jobs.id, jobs.args;
```

## Чому це важливо

Черга вирішує, на що чекає користувач. Робота, покладена в чергу, виконується
вже після відповіді, тож сторінка, яка запускає лист, PDF, перебудову
пошукового індексу й перерахунок, може відповісти за час запису чотирьох
рядків, а чотири повільні задачі доходять до кінця згодом. Для покупця це
різниця між оформленням замовлення, що відповідає за кількасот мілісекунд, і
таким, що відповідає, коли поштовому серверу заманеться.

Черга вирішує й те, що переживе збій. Робота, запущена у фоновому потоці, живе
лише в пам'яті процесу, і розгортання, яке перезапускає процес, забирає її з
собою без жодного сліду. Робота в довговічній черзі натомість є записом: її
можна повторити, переглянути й порахувати.

А ще черга розводить дві швидкості. Виробники рухаються в темпі вхідного
трафіку, споживачі — в темпі найповільнішого сервісу, до якого звертаються.
Черга між ними поглинає різницю, і її глибина стає одним із найчесніших чисел,
які система може повідомити. Черга, що росте вже годину, каже: споживачі
відстають, хоч би що показував графік процесора.

Ці переваги мають ціну. Робота тепер лежить поза полем зору між відправленням і
виконанням. Доставка майже завжди відбувається щонайменше один раз, тож
споживач мусить витримувати те саме повідомлення двічі. Порядок слабший, ніж
здається. А окремий брокер — це ще один сервер, який треба запускати,
захищати, стежити за ним і оновлювати.

## Як це виглядає на практиці

Надсилання листів, перебудову пошукового індексу, генерування документів і
перерахунок рейтингів на одній платформі винесли зі шляху запиту.
Робота, віддана горутині, зникає, коли процес перезапускається посеред
розгортання, тому
[роботу перенесли в чергу задач River, що живе в PostgreSQL](/uk/portfolio/moved-slow-work-onto-a-river-job-queue-97/).
База даних і так була авторитетним джерелом даних, тож постановка задачі в
чергу стає частиною тієї самої транзакції, що й дані, яких задача стосується.
Платформа отримала повторні спроби й історію, яку можна відкрити й прочитати;
заявлена межа — черга в базі даних не масштабується безкінечно.

У застосунку для macOS, збудованому над бібліотекою на C, черга мала тримати
порядок. Блокувальний цикл подій бібліотеки видає події в послідовності, яка
має значення: редагування мусить прийти після повідомлення, яке воно змінює.
Окрема задача на кожну подію дозволяє середовищу виконання планувати їх у
будь-якому порядку, і редагування може обігнати власне повідомлення. Обране
рішення має
[один потік-виробник, одного споживача й одну чергу між ними](/uk/portfolio/adopted-swift-6-strict-concurrency-over-a-c-event-loop-156/),
а далі єдиний стрибок на головного актора. Порядок тримається, бо шлях лише
один. Ціна — паралельність: усі події проходять через цього одного споживача.

Черга без межі шкодить, і це видно з програми, що заповнювала пам'ять
приблизно на 41 МБ за секунду. Однією з трьох причин був потік подій, який
споживали без жодного обмеження: події надходили швидше, ніж інтерфейс встигав
їх застосувати, і доробок не відкидався, а накопичувався. Разом із двома іншими
причинами це довело обсяг стиснутих сторінок до 111 ГБ на машині, що фізично
має 36 ГБ.
[Кожному необмеженому шляху дали межу](/uk/portfolio/stopped-an-application-filling-memory-at-41mb-a-second-155/)
з явною політикою для заповненого буфера, бо, як сказано на сторінці, одна
обмежена черга поруч із трьома необмеженими не є виправленням. Регресійний тест
тепер перевіряє стелю пам'яті.

## Де виникають проблеми

Проблеми з чергами виринають пізно й деінде, бо робота навмисно лежить поза
полем зору.

**Доробок росте, доки не скінчиться пам'ять чи диск.** Черга без межі приймає
роботу швидше, ніж та виконується, і зберігає різницю. Задайте кожній черзі
найбільшу довжину й заздалегідь вирішіть, що відбувається, коли її досягнуто:
пригальмувати виробника, відхиляти нову роботу чи викидати найстаріше. Потім
стежте за двома числами, глибиною і віком найстарішого повідомлення, бо коротка
черга дуже старих повідомлень не краща за довгу.

**Те саме повідомлення обробляють двічі.** Споживач завершує роботу й падає до
того, як підтвердив; брокер цілком слушно доставляє повідомлення знову.
Документація RabbitMQ радить споживачам чекати повторних доставок і писати код з
думкою про ідемпотентність. Дайте кожному повідомленню ідентифікатор і
записуйте вже оброблені ідентифікатори в тій самій транзакції, що й саму
роботу. Нотатка на цьому сайті про те, як
[продати обмежений запас, не продавши більше, ніж є](/uk/notes/selling-limited-stock-without-overselling/),
будує свого записувача саме на цьому припущенні.

**Повідомлення приходять не в тому порядку, в якому їх надіслали.** Паралельні
споживачі, попередня вибірка й повторні спроби, що ставлять невдале
повідомлення за новішими, — усе це переставляє роботу. Коли порядок важливий,
спрямовуйте все, що стосується одного рахунку чи одного замовлення, через ту
саму партицію або того самого споживача. Kafka робить це за ключем, і ціна в
тому, що один завантажений ключ рухається не швидше за одного споживача.

**Одне погане повідомлення блокує чергу або ходить по колу.** Повідомлення, вміст
якого щоразу валить споживача, повертається в чергу, доставляється знову й знову
його валить. Обмежте кількість спроб, а повідомлення, яке вичерпало їх усі,
перенесіть в окреме місце, де на нього подивиться людина. RabbitMQ називає це
dead-lettering і застосовує до відхилених повідомлень, прострочених і тих, що
переповнили б обмеження довжини.

**Робота зникає під час перезапуску.** Канал, список у пам'яті чи фоновий потік
— це черга, яка нічого не зберігає між перезапусками. В AMQP довговічної черги
самої теж мало: повідомлення мають публікуватися як постійні. Для кожного виду
роботи вирішіть, чи прийнятно її втратити.

**База даних і черга розходяться.** Сервіс записує рядок, потім надсилає
повідомлення й падає між цими двома кроками; тепер одне існує без іншого. Або
тримайте чергу в тій самій базі, щоб задача ставала видимою для воркерів лише
після коміту її транзакції, як це робить River, або записуйте повідомлення в
таблицю outbox у тій самій транзакції й надсилайте його звідти.

## Що почитати далі

- [AMQP 0-9-1 Model Explained](https://www.rabbitmq.com/tutorials/amqp-concepts)
  — обмінники, черги, прив'язки й підтвердження в поясненні RabbitMQ.
- [Consumer Acknowledgements and Publisher Confirms](https://www.rabbitmq.com/docs/confirms)
  — коли брокер вважає повідомлення доставленим і чому доставка повторюється.
- [Dead Letter Exchanges](https://www.rabbitmq.com/docs/dlx) — умови, за яких
  повідомлення відкладають убік, і куди воно потрапляє.
- [The Locking Clause of SELECT](https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE)
  — `FOR UPDATE` і `SKIP LOCKED` та таблиці на кшталт черги, яким вони пасують.
- [River: Getting started](https://riverqueue.com/docs) — черга задач, яка
  зберігає задачі у власній базі даних застосунку.
- [Apache Kafka: Introduction](https://kafka.apache.org/41/getting-started/introduction/)
  — топіки, їхні розділи (partitions) й строк зберігання в моделі журналу.

<https://engineer.company/uk/glossary/queue/>
