awesome-everything EN
↑ Обратно к восхождению

Очереди, потоки, события

Сквозной pipeline заказов: где живёт каждая гарантия доставки

Суть Проводим одну запись заказа через outbox, CDC, партиционированные Kafka-топики, идемпотентные consumer groups, dead-letter queue и eventually-consistent UI — и видим, почему идемпотентность — единственный инвариант, который держит всё вместе.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 17 min

Клиента списали дважды за один заказ. Трейс рассказывает историю: payment-консьюмер обработал событие order.placed, вызвал шлюз, успешно — а потом под убили посреди коммита, до того как сдвинулся Kafka-офсет. Группа сделала ребаланс, новый консьюмер взял тот же офсет и переиграл событие. Два списания, один заказ. У вызова шлюза не было idempotency-ключа, так что он не мог понять, что это то же самое списание. Pipeline был корректен везде, кроме одного места, которое и было важным.

Одна запись, шесть хопов и одна гарантия, что их связывает

Проследи один заказ через event-driven pipeline — и пересечёшь шесть границ, у каждой свой режим отказа. Запись происходит один раз. Всё после — это эстафета через сети и процессы, каждый из которых может ретраить, падать, переупорядочивать или застревать. Идея капстоуна в том, что у тебя нет одной глобальной ручки «exactly-once». Ты выбираешь гарантию на каждый хоп, а потом защищаешь швы одним инвариантом — идемпотентностью — потому что каждый честный хоп в этой цепочке — это at-least-once.

Поток:

ХопМеханизмКакую гарантию даётЧто её защищает
1. Запись заказаСтрока в БД + строка outbox в одной txnАтомарность: событие есть тогда и только тогда, когда есть заказACID-транзакция
2. ПубликацияCDC-relay читает WAL → KafkaAt-least-once в топикВозобновление с log-офсета
3. МаршрутизацияТопик партиционирован по id заказаПорядок в пределах ключаСтабильное назначение партиций
4. ОбработкаConsumer group: payment / inventory / notifyДоставка at-least-onceИдемпотентный handler + дедуп
5. КарантинРетраи с backoff → DLQЯдовитые сообщения не блокируютБюджет ретраев + алерт на DLQ
6. ОтображениеUI показывает оптимистично + pendingEventual consistency, честноСостояние pending + сверка

Хоп 1–2: запись атомарна, публикация — нет

Первое решение — знаменитая проблема двойной записи (dual-write): надо сохранить заказ и отправить order.placed. Если пишешь в БД, а потом публикуешь в Kafka двумя отдельными вызовами, падение между ними либо теряет событие (заказ есть, никто не знает), либо отправляет фантом (событие ушло, заказ откатился). Транзакционный outbox убирает зазор: в одной локальной транзакции БД ты пишешь и строку заказа, и строку outbox. Либо коммитятся обе, либо ни одной. Теперь событие существует тогда и только тогда, когда существует заказ.

Публикация — отдельная забота, и она честно at-least-once. CDC-relay (Debezium, читающий WAL Postgres — каноничная схема) читает закоммиченные строки outbox из лога и пушит их в Kafka. Его суперсила — восстанавливаемость: он отслеживает свой log-офсет, поэтому при рестарте возобновляется с точной позиции — ноль поллинга, почти ноль нагрузки на БД, потому что он читает лог, а не таблицу. Но «возобновиться с офсета» означает, что он может переотправить строку, которую опубликовал прямо перед падением. Это нормально, и в этом весь смысл: ты не гонишься за exactly-once на проводе, ты перекладываешь ответственность за дедуп на консьюмер, где ей и место.

Почему это работает

Сеньорская команда начинает с поллинг-публикатора (воркер, который SELECT-ит неотправленные строки outbox и помечает их отправленными), прежде чем тянуться к CDC. Его тривиально собрать, отладить и эксплуатировать, и он корректен. На log-tailing CDC переходят только когда задержка поллинга или нагрузка от записи маркера становятся реальной проблемой — а не по умолчанию. Паттерн тот же; меняется только relay.

Хоп 3: партиционируй по ключу, который владеет заказом

Kafka гарантирует порядок только внутри партиции, а партицию выбирает ключ записи. Ключуй события заказа по order_id (или customer_id, когда нужно сериализовать события клиента) — и каждое событие этого заказа попадёт в ту же партицию, обработается в порядке коммита одним консьюмером. payment.authorized никогда не обгонит order.placed для одного заказа. Порядок между заказами не гарантируется и не нужен — именно это и позволяет масштабироваться добавлением партиций.

Ловушка операционная: дефолтный партиционер — это hash(key) % partitionCount. В тот момент, когда ты добавляешь партиции для масштабирования, модуль меняется, тот же ключ маппится в другую партицию, и порядок для этого ключа ломается на шве. Сеньорские команды переоверпровиживают партиции заранее или мигрируют ключи осознанно, а не бампают число партиций в горячую пятницу. Число партиций — дверь в одну сторону для порядка по ключу.

Хоп 4–5: at-least-once — это обещание доставить дубликаты

Consumer group — место, где живёт несущее правило. Чтобы получить at-least-once, ты коммитишь офсет только после успеха handler — обработай, потом коммить. Цена — дубликаты: если под падает после обработки, но до коммита (ровно как в Hook), следующий консьюмер переигрывает этот офсет. At-least-once — не баг, который надо чинить; это контракт, который говорит «я доставлю твоё сообщение, возможно, не один раз». Единственный корректный ответ — идемпотентный handler.

Идемпотентность означает, что обработка одного события дважды даёт тот же эффект, что и один раз. Механизм — дедуп-ключ (id события или бизнесовый idempotency-ключ), записанный в дедуп-таблицу с уникальным ограничением, в той же транзакции, что и побочный эффект. Переотправка попадает в ограничение и становится no-op. Для внешних вызовов прокидывай ключ дальше: заголовок Idempotency-Key у Stripe дедупит 24 часа, поэтому, передав ключ события в вызов списания, ты получишь на ретрае исходное списание, а не второе. Этот единственный заголовок — разница между тем, был ли Hook почти-промахом или двойным списанием.

Часть отказов не транзиентна — кривой payload, навсегда отклонённая карта. Ретраить такое вечно — значит блокировать партицию (head-of-line blocking) и давать лагу расти без границ. Поэтому каждому сообщению даёшь бюджет ретраев с экспоненциальным backoff для транзиентных ошибок, а при исчерпании маршрутизируешь его в dead-letter queue. Дизайн надёжного репроцессинга у Uber использует ярусные retry-топики, питающие DLQ, именно чтобы ядовитое сообщение было в карантине, а не стопорило живой поток. DLQ — не кладбище; это инбокс, который надо вычерпывать.

Выбери лучший вариант

Payment-консьюмер иногда списывает дважды на ретрае после падения. Куда поставить фикс?

Хоп 6: UI обязан говорить правду о лаге

Поскольку каждый хоп асинхронный, в момент клика «Оформить заказ» ответ ещё не известен. Inventory и notification могут устаканиться сотнями миллисекунд позже; под нагрузкой — когда сольётся лаг консьюмеров. Сеньорский UI про это не врёт. Он применяет оптимистичное обновление, чтобы заказ появился мгновенно, помечает его pending, пока не придёт событие подтверждения, и сверяет до confirmed или failed по реальному состоянию. Окно eventual-consistency — это продуктовая поверхность, а не дефект, который надо прятать.

Здесь же отрабатывает observability. Метрики, ловящие тихих убийц, — это лаг консьюмеров (зазор между последним офсетом и закоммиченным — Burrow смотрит на его тренд по скользящему окну, помечая OK / WARNING / ERR, а не сырое число), глубина DLQ (растущий DLQ означает, что handler-ы падают быстрее, чем ты их вычерпываешь) и сквозная задержка с correlation id, проставленным на событие на хопе 1 и пронесённым через каждый асинхронный хоп, чтобы один трейс охватил всю дорогу. Двойное списание из Hook невидимо без этого трейса. Лаг, растущий до переполнения DLQ, невидим без этих двух датчиков и алерта на каждый.

Викторина

Твой CDC-relay перезапустился и переотправил несколько строк outbox, которые уже публиковал. Pipeline сломан?

Викторина

Лаг консьюмеров растёт уже час, и DLQ наполняется. Что эта комбинация вероятнее всего означает?

Расставь шаги по порядку

Расставь путь одного события заказа по pipeline:

  1. 1 Записать строку заказа и строку outbox в одной транзакции БД
  2. 2 CDC-relay читает WAL и публикует закоммиченную строку в Kafka
  3. 3 Топик партиционирован по id заказа, сохраняя порядок в пределах заказа
  4. 4 Consumer group обрабатывает событие; идемпотентный handler дедупит любой переигрыш
  5. 5 При исчерпанных ретраях ядовитое сообщение уходит в dead-letter queue
  6. 6 UI сверяет свою оптимистичную pending-строку до confirmed или failed
Вспомните перед уходом
  1. 01
    Пройди по тому, почему именно идемпотентность, а не exactly-once доставка, — инвариант, держащий event-driven pipeline заказов вместе.
  2. 02
    Две метрики вместе — растущий лаг консьюмеров и наполняющийся dead-letter queue — описывают конкретный отказ. Объясни его и где должна жить observability.
Итог

Event-driven pipeline заказов — это шесть хопов, и на каждом ты выбираешь гарантию: запись заказа атомарна, потому что строка и её outbox-событие коммитятся в одной транзакции; CDC-relay публикует at-least-once, возобновляясь с log-офсета; топик сохраняет порядок по ключу, партиционируя по id заказа, и поэтому же бамп числа партиций — дверь в одну сторону, которая может сломать порядок по ключу; consumer group доставляет at-least-once, коммитя офсеты только после обработки; ядовитые сообщения, исчерпавшие бюджет ретраев, уходят в dead-letter queue вместо блокировки партиции; а UI говорит правду об окне eventual-consistency состоянием optimistic-then-pending. Нить через всё это — идемпотентность: дедуп-ключ под уникальным ограничением плюс Idempotency-Key, прокинутый во внешние вызовы, — потому что каждый честный хоп at-least-once и дубликаты — это контракт, а не баг. Observability живёт на швах: correlation id через каждый асинхронный хоп, лаг консьюмеров, отслеживаемый по тренду, и алерт на глубину DLQ, чтобы двух тихих убийц — пропавший idempotency-ключ, что списывает дважды, и лаг, что растёт до переполнения DLQ — поймали раньше, чем клиента.

Продолжить восхождение ↑Капстоун очередей: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.