SkillGridИзучай computer science на практике

Обмен сообщениями: передать работу, не держа линию

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

16 мин · 50 XP

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

Способ, который ты выучил первым, — синхронный вызов: сервис A отправляет запрос сервису B и ждёт ответа. О нём легко рассуждать, потому что ответ приходит на том же дыхании, что и вопрос, и ошибки очевидны. Проблема не в том, что он медленный. Проблема в том, что он связывает два сервиса во времени: B должен быть жив, достижим и быстр ровно в тот момент, когда он нужен A, иначе A тоже падает. Поставь в цепочку три сервиса — и перемножь эти моменты. Если каждый доступен 99,9 процента времени, цепочка доступна 99,7 процента, а это десятикратный рост простоя для системы, части которой по отдельности превосходны.

CustomerOrder serviceMail serviceInvoice serviceplace order 9001send confirmation, pleasemail provider is slow todaywaits 8 s, holds a connectioncreate invoicetimeoutsorry, order failedthe order was saved, the customer was told no

Альтернатива — перестать ждать. Сервис заказов записывает заказ, публикует короткое сообщение о том, что заказ оформлен, и немедленно возвращает ответ. Что-то другое, отдельно, подхватывает сообщение и отправляет письмо. Эти две части больше не обязаны быть живы в один и тот же момент, и клиент получает ответ за десятки миллисекунд. Механизм называется очередью или брокером, и важный сдвиг здесь не в технологии, а в направлении зависимости: сервис заказов теперь зависит от того, что очередь примет сообщение, — маленькой, быстрой, скучной операции, — а не от того, что провайдер рассылки добьётся успеха, что медленно и вне твоего контроля.

CustomerOrder serviceQueueWorkersplace order 9001order.placed, 9001202 accepted in 40 msdeliver to a free consumerack only after the work is donean unacked message comes back

Стоит точно сказать, что очередь на самом деле покупает, потому что её продают как лекарство от всего. Она покупает четыре вещи. Развязывание: производитель не знает и не заботится, кто потребляет сообщение, поэтому второго потребителя можно добавить, не трогая производителя. Буферизация: всплеск приходит как глубокая очередь, а не как упавший потребитель, и очередь разгружается с той скоростью, которую потребитель может выдержать. Повторы: теперь переотправка — работа брокера, и производителю не нужно реализовывать backoff. И долговечность: хороший брокер хранит сообщение на диске, поэтому перезапустившийся потребитель не теряет работу, которая его ждала.

SYNCHRONOUS CALL              MESSAGE QUEUE

  caller waits                  caller gets 202 now
  both must be up               consumer may be down
  load hits the callee          queue absorbs the spike
  retry is the caller problem   redelivery is the broker job
  one failure, one error        one failure, a growing depthОдин и тот же заказ, который должен запустить письмо и счёт, устроенный двумя способами.

Теперь часть, которая решает, как ты напишешь потребителя. Каждая система сообщений обязана ответить на один вопрос: что случится, если потребитель упадёт между получением сообщения и завершением работы? Честных ответов только три. «Не более одного раза» означает, что брокер отправляет сообщение и забывает о нём. Если потребитель упадёт посреди работы, сообщение пропало, и клиент никогда не получит счёт. «Хотя бы один раз» означает, что брокер держит сообщение, пока потребитель не подтвердит, и переотправляет, если подтверждение не пришло. Работа никогда не теряется и может быть сделана дважды, потому что само подтверждение может потеряться после того, как работа удалась. «Ровно один раз» — то, о чём все просят, и это вообще не режим доставки.

AT MOST ONCE           send, forget, never retry
  risk: a lost payment

AT LEAST ONCE          send, retry until acked
  risk: a duplicate payment
  this is what brokers give you

EXACTLY ONCE           not a delivery mode
  it is at-least-once plus a dedup keyДве настоящие гарантии доставки и одна, которая на деле является комбинацией второй с логикой приложения.

Причина, по которой «хотя бы раз» неизбежно, — это окно, которое нельзя закрыть. Потребитель получает сообщение, делает работу, а затем подтверждает. Если он упадёт после работы, но до подтверждения, брокер никак не может узнать, что работа произошла, потому что подтверждения он не получил. Переотправка — единственный безопасный выбор, ведь альтернатива — молча выбросить счёт. Окно можно сузить, подтверждая после работы, а не до неё, но убрать его нельзя, потому что само подтверждение идёт по тому же ненадёжному пути, что и всё остальное. Это не ограничение конкретного брокера. RabbitMQ, Kafka и SQS ведут себя так же, и ни один флаг конфигурации этого не меняет.

Значит, практический ответ — сделать потребителя идемпотентным, а это тот же ключ идемпотентности из прошлого урока, переехавший на сторону потребителя. Потребитель записывает id сообщения в ту же транзакцию, что и работу, которую выполняет. Если сообщение доставят повторно, вставка id упадёт на первичном ключе, транзакция откатится, и вторая попытка не сделает ничего. Это намеренно скучно: одна таблица, одно ограничение уникальности, одна транзакция, делающая обе вещи вместе. Порядок здесь значит очень много. Если сначала записать id, а потом сделать работу, падение между ними потеряет работу навсегда. Если сначала сделать работу, а потом записать id, падение между ними продублирует работу. Правильно только делать и то и другое одним атомарным шагом, и поэтому таблица обязана жить в той же базе, что и эффект.

CREATE TABLE processed (
  message_id  text PRIMARY KEY,
  handled_at  timestamptz NOT NULL DEFAULT now()
);

-- one transaction: record the id and do the work together
BEGIN;
INSERT INTO processed (message_id) VALUES ('9f2c-1');
UPDATE invoices SET sent = true WHERE id = 9001;
COMMIT;

-- a redelivery hits the primary key and rolls backСтруктура, превращающая повторную доставку в пустышку. Обе инструкции фиксируются вместе или ни одна.

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

MAIN QUEUE             orders.email
RETRY                  backoff 1 s, 10 s, 100 s, 1000 s
MAX DELIVERIES         5
DEAD LETTER QUEUE      orders.email.dlq
ALERT ON               dlq depth rises at allКонфигурация, которая не даёт одному отравленному сообщению утащить за собой весь конвейер.

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

Число, за которым надо следить, — это возраст очереди, а не её глубина. Глубина сама по себе говорит мало: 50 000 сообщений — это нормально, если потребитель разгружает их за десять секунд, и это катастрофа, если те же 50 000 разгружаются четыре часа. Возраст отвечает на единственный вопрос, важный клиенту: сколько будет ждать сообщение, опубликованное сейчас, прежде чем им кто-то займётся. Одно число — возраст самого старого необработанного сообщения — превращает технический завал в обещание о сроке доставки.

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

Практика

  1. Сервис заказов публикует сообщение в очередь после сохранения заказа. Воркер рассылки падает на десять минут. Что почувствует клиент и почему это и есть работа проекта как задумано?

    Подсказки
    • Никто не ждёт воркер, поэтому ни один запрос не может из-за него упасть.
    • Брокер держит сообщение, пока кто-нибудь его не подтвердит.
    • Заказ долговечен; уведомление — это не заказ.
  2. Отравленное сообщение падает на каждой попытке и блокирует одну партицию. Расставь действия в том порядке, в каком их применит аккуратная команда.

    1. 1Дать воркеру повторить ограниченное число раз с экспоненциальной задержкой
    2. 2Алертить на глубину мёртвых писем, чтобы человек увидел это немедленно
    3. 3Переместить сообщение в очередь мёртвых писем после последней попытки
    4. 4И только потом очистить очередь от сообщений, которые уже были обработаны
    5. 5Починить причину и затем переиграть сообщение из очереди мёртвых писем
    6. 6Прочитать упавшее сообщение и понять, отказ временный или постоянный
    Подсказки
    • Прежде чем автоматизировать, пойми, чем это сообщение отличается от остальных.
    • Повтори несколько раз с растущей задержкой, потом убери его с дороги.
    • Очередь, за которой никто не следит, — то же самое, что выбросить сообщение.
  3. Обработчик ниже получает одно и то же сообщение дважды, потому что очередь доставляет хотя бы раз. Сделай его идемпотентным: вторая доставка не должна отправить второе письмо или второй счёт.

    Код выполняется у тебя, а не у нас — мы его не запускаем. Проверь решение по каждому требованию и отметь выполненные.

    Подсказки
    • У каждого сообщения есть id; состояние должно помнить, какие id уже обработаны.
    • Верни результат, который говорит, сделала ли эта доставка работу или была повтором.
    • Повтор должен всё равно сообщать об успехе, а не об ошибке.

Вопросы по этому уроку

Это бесплатно?

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

На какой возраст рассчитано?

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

Нужно ли уже уметь программировать?

Нет. Логика, физика и сети начинаются с нуля. Код ты пишешь в разделе языков — и он тоже начинается с самого начала.

Нужно что-то устанавливать?

Нет. Всё работает в браузере: теория, практика и интерактивные симуляции. Скачивать нечего и настраивать нечего.

Сколько времени занимает один предмет?

Урок — от 10 до 15 минут. Грейд «джун» это примерно 12–20 уроков, то есть несколько недель регулярных занятий, а не один вечер.

Обязательно идти по порядку?

Внутри грейда уровни открываются по очереди: каждый опирается на предыдущий. А между предметами ты свободен — начинай с логики, физики или сетей.