Очереди сообщений
Очередь точка-точка развязывает producer и consumer во времени, чтобы медленный downstream не утянул вызывающего. Цена — семантика доставки: at-least-once и at-most-once — единственные честные варианты, а «exactly-once» на деле effectively-once через идемпотентность.
Сервис оформления заказа синхронно дёргал сервис email, чтобы отправить чек. Однажды днём провайдер email затормозил — 8 секунд на вызов вместо 80 мс. За минуты сам checkout начал отваливаться по таймауту: каждый платёжный поток висел в ожидании email, пул вычерпался, и некритичная зависимость уронила ту часть системы, что приносит деньги. Починка была не «найти быстрый email». Она была в том, чтобы вообще перестать связывать их во времени: бросить сообщение «отправить чек» в очередь, сразу вернуть ответ клиенту, и позволить отдельному воркеру вычерпывать очередь в том темпе, что позволяет email. Чек теперь поздний, а не потерянный — и checkout вообще не замечает, что email болеет.
Что на самом деле даёт очередь точка-точка
Очередь сообщений — это долговечный буфер между двумя сторонами. Producer дописывает сообщение; очередь его сохраняет; consumer забирает его позже, обрабатывает и подтверждает (ack). «Точка-точка» означает, что каждое сообщение доставляется одному consumer’у в группе и затем удаляется — в отличие от веерной рассылки (fan-out) pub/sub из следующего урока, где копию получает каждый подписчик. Определяющее свойство — развязка во времени (temporal decoupling): producer и consumer больше не обязаны быть живыми, быстрыми и доступными в один и тот же момент.
Это одно свойство сразу даёт три вещи. Выравнивание нагрузки (load levelling) — всплеск в 10 000 enqueue не обязан обрабатываться на 10 000/с; очередь поглощает всплеск, а consumer вычерпывает его в своём устойчивом темпе. Изоляция отказов — если consumer падает, сообщения ждут в очереди, а не возвращаются ошибкой producer’у; работа возобновится, когда consumer восстановится. Независимое масштабирование — producer’ов и consumer’ов масштабируешь раздельно, добавляя инстансы consumer’а, чтобы вычерпывать быстрее, не трогая producer.
Senior-формулировка: очередь превращает синхронную зависимость по доступности в асинхронную зависимость по задержке. Синхронно твоя доступность — произведение доступностей всех downstream’ов: три сервиса по 99,9% в цепочке вызовов дают 99,7%. Поставь между ними долговечную очередь — и успех producer’а больше не зависит от того, жив ли consumer; он зависит лишь от того, жива ли очередь. Ты обменял «весь запрос падает, когда email лежит» на «чек приходит на пару минут позже».
Честная семантика доставки: at-least-once против at-most-once
Вот часть, которую junior пропускает, а senior закладывает в дизайн. Очередь и её consumer общаются по ненадёжной сети, и consumer может упасть в любой момент. Это вынуждает выбрать, когда подтверждать сообщение, и выбор фиксирует гарантию доставки:
at-most-once ack ДО обработки → если воркер падает в середине,
сообщение потеряно. Дублей нет, потеря возможна.
at-least-once ack ПОСЛЕ обработки → если воркер падает после работы,
но до ack, сообщение переотправляется. Потери нет,
дубли возможны.Третьего честного варианта по ненадёжной сети нет. Ты либо рискуешь потерять сообщение (ранний ack), либо рискуешь обработать его дважды (поздний ack). Почти всякая долговечная очередь — SQS, RabbitMQ, классический JMS — по умолчанию at-least-once, потому что для большинства систем дубль восстановим, а потерянный заказ — нет. Следствие, которое надо усвоить: твой consumer рано или поздно увидит одно и то же сообщение дважды. Проектируй так, будто это гарантировано — на масштабе так и есть.
▸Почему это работает
Почему брокер не может просто дать exactly-once и закрыть спор? Потому что «обработать сообщение» и «подтвердить сообщение» — два отдельных действия, и никакой протокол не сделает их одним атомарным шагом через сеть с падениями. В каком бы порядке ты их ни поставил, падение может попасть в зазор между ними: ack-затем-обработка теряет работу, обработка-затем-ack повторяет её. Брокер может сделать своё собственное хранилище exactly-once (записать сообщение один раз), но он не может залезть в твой обработчик — который списывает с карты или шлёт письмо — и сделать этот побочный эффект атомарным с ack. Поэтому единственное место, где exactly-once реально достижим, — внутри твоего consumer’а: сделать повторную обработку безвредной.
«Exactly-once» — это effectively-once: идемпотентность + дедупликация
Когда вендор продаёт «exactly-once», он имеет в виду effectively-once: сообщение может быть доставлено более одного раза, но его эффект случается один раз. Достигается это двумя инструментами вместе. Идемпотентность — спроектируй операцию так, чтобы применение дважды было равно применению один раз: set balance = 100 идемпотентна; add 100 to balance — нет. Дедупликация — дай каждому сообщению стабильный бизнес-ключ (ID заказа, ID платёжного намерения) и веди запись уже обработанных ключей; при переотправке ты видишь ключ, пропускаешь работу и просто переподтверждаешь.
producer: enqueue { idempotency_key: "order-8842", action: "charge $50" }
consumer на каждой доставке:
if seen("order-8842"): ack и стоп ← дедуп ловит переотправку
else: списать, mark seen("order-8842"), ackХранилище дедупа (строка с уникальным ограничением, ключ в Redis с TTL) — это то, что превращает доставку at-least-once в обработку effectively-once. Поэтому правильная ментальная модель — никогда не «я найду очередь, которая не дублирует». Она звучит: «я приму дубли на входе и сделаю их безвредными внутри». Effectively-once — свойство твоего кода, а не галочка у брокера.
Механика, на которой работает at-least-once: visibility timeout, DLQ, порядок
Три механизма превращают теорию в работающую систему:
- Visibility timeout (таймаут видимости). Когда consumer забирает сообщение, очередь не удаляет его — она прячет его на настроенное окно. Если consumer успел и удалил его в окне — готово. Если consumer падает (так и не удалил), таймаут истекает и сообщение всплывает для другого воркера. Это и есть то, как происходит переотправка at-least-once. Поставь слишком коротким — сообщение медленного, но живого воркера всплывёт и обработается дважды; слишком длинным — реально упавшее сообщение будет вечно ждать ретрая. Долгие задачи обязаны heartbeat — продлевать таймаут, пока всё ещё работают, — иначе очередь отдаст то же сообщение второму воркеру и ты получишь параллельную дублирующую обработку.
- Dead-letter queue (DLQ, очередь мёртвых писем). Сообщение, что падает раз за разом («ядовитое» — кривой вход, баг), иначе переотправлялось бы вечно, выжигая ёмкость и забивая очередь. После N неудачных попыток брокер перекладывает его в отдельную dead-letter queue для разбора человеком. Алармь на глубину DLQ: непустой DLQ означает баг, а не транзиентную икоту.
- Порядок (ordering). Обычная очередь с множеством параллельных consumer’ов не обещает глобального порядка — сообщение B может завершиться раньше A. Если нужен порядок, нужен порядок по ключу (message group в SQS FIFO, одна партиция), а это стоит пропускной способности, потому что сообщения одной группы нельзя обрабатывать параллельно. Большинству систем не нужен глобальный порядок; им нужен порядок внутри одной сущности (события одного пользователя), и ключ проектируется соответственно.
▸Частая ошибка
Классический баг visibility timeout: воркер забирает сообщение, работа занимает дольше таймаута (медленная зависимость, большой батч), и пока он всё ещё работает, сообщение всплывает и второй воркер начинает ту же задачу. Теперь два воркера параллельно списывают с одной карты — и если их работа тоже затягивается, присоединяется третий, и сервис «бомбит сам себя» под нагрузкой ровно тогда, когда задержка и так высока. Чинится так: размеряй таймаут выше реального p99 времени обработки, heartbeat’и долгие задачи (продлевай таймаут по ходу) и — всегда — делай обработчик идемпотентным, чтобы дубль был безвреден даже при неверном таймауте.
Твоя очередь настроена на at-least-once, а consumer списывает с карты на каждом сообщении. Каков минимальный корректный дизайн, чтобы переотправка не списала дважды?
Воркер забрал сообщение, но задача легитимно занимает 90 секунд при visibility timeout в 30 секунд. Что произойдёт и какова правильная починка?
Поскольку долговечная очередь по умолчанию работает на at-least-once, твой consumer обязан быть _______ — применение одного сообщения дважды даёт тот же эффект, что и один раз, — чтобы переотправки, которые visibility timeout неизбежно вызывает, были безвредны.
Этот раздел держится на высоте композиции систем — где очередь стоит в архитектуре и какую гарантию она меняет. Отдельный трек queues уходит на уровень глубже в сами брокеры: как SQS, RabbitMQ и Kafka реализуют доставку, точные протоколы ack и тюнинг брокера. Бери его, когда конфигурируешь конкретный брокер; оставайся здесь, когда решаешь, нужна ли очередь в дизайне вообще.
- 01Что развязывает очередь точка-точка и чего это стоит?
- 02Сравни at-least-once и at-most-once и объясни, почему exactly-once — это на деле effectively-once.
- 03Что отвечает за visibility timeout, DLQ и порядок по отдельности?
Очередь сообщений точка-точка — это долговечный буфер, развязывающий producer и consumer во времени: producer кладёт сообщение и идёт дальше, consumer вычерпывает позже, и каждое сообщение доставляется одному consumer’у и удаляется. Это одно свойство превращает синхронную зависимость по доступности (запрос падает, если downstream лежит) в асинхронную зависимость по задержке (работа просто приходит позже), давая выравнивание нагрузки, изоляцию отказов и независимое масштабирование. Цена — семантика доставки: поскольку «обработка» и «подтверждение» не могут быть атомарны по ненадёжной сети, ты выбираешь at-most-once (ack первым → без дублей, возможна потеря) или at-least-once (ack последним → без потерь, возможны дубли), и почти все берут at-least-once. Так называемый exactly-once — это на деле effectively-once: ты принимаешь дубли на входе и делаешь их безвредными через идемпотентность + дедуп-ключ, обеспеченные в твоём коде, а не галочкой брокера. Рабочая механика — это visibility timeout (двигатель переотправки — размеряй выше p99, heartbeat’ь долгие задачи), dead-letter queue (отводить ядовитые сообщения, алармить на глубину) и порядок по ключу, когда он реально нужен. Решай здесь, нужна ли очередь в дизайне; ныряй в отдельный трек queues, когда тюнишь конкретный брокер. Теперь, когда встретишь падение checkout из-за медленного email-сервиса, первым делом спроси: есть ли между ними долговечная очередь — и если да, идемпотентен ли consumer?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.