Очереди, потоки, события
Сквозной pipeline заказов: где живёт каждая гарантия доставки
Клиента списали дважды за один заказ. Трейс рассказывает историю: payment-консьюмер обработал событие order.placed, вызвал шлюз, успешно — а потом под убили посреди коммита, до того как сдвинулся Kafka-офсет. Группа сделала ребаланс, новый консьюмер взял тот же офсет и переиграл событие. Два списания, один заказ. У вызова шлюза не было idempotency-ключа, так что он не мог понять, что это то же самое списание. Pipeline был корректен везде, кроме одного места, которое и было важным.
Одна запись, шесть хопов и одна гарантия, что их связывает
Проследи один заказ через event-driven pipeline — и пересечёшь шесть границ, у каждой свой режим отказа. Запись происходит один раз. Всё после — это эстафета через сети и процессы, каждый из которых может ретраить, падать, переупорядочивать или застревать. Идея капстоуна в том, что у тебя нет одной глобальной ручки «exactly-once». Ты выбираешь гарантию на каждый хоп, а потом защищаешь швы одним инвариантом — идемпотентностью — потому что каждый честный хоп в этой цепочке — это at-least-once.
Поток:
| Хоп | Механизм | Какую гарантию даёт | Что её защищает |
|---|---|---|---|
| 1. Запись заказа | Строка в БД + строка outbox в одной txn | Атомарность: событие есть тогда и только тогда, когда есть заказ | ACID-транзакция |
| 2. Публикация | CDC-relay читает WAL → Kafka | At-least-once в топик | Возобновление с log-офсета |
| 3. Маршрутизация | Топик партиционирован по id заказа | Порядок в пределах ключа | Стабильное назначение партиций |
| 4. Обработка | Consumer group: payment / inventory / notify | Доставка at-least-once | Идемпотентный handler + дедуп |
| 5. Карантин | Ретраи с backoff → DLQ | Ядовитые сообщения не блокируют | Бюджет ретраев + алерт на DLQ |
| 6. Отображение | UI показывает оптимистично + pending | Eventual 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 Записать строку заказа и строку outbox в одной транзакции БД
- 2 CDC-relay читает WAL и публикует закоммиченную строку в Kafka
- 3 Топик партиционирован по id заказа, сохраняя порядок в пределах заказа
- 4 Consumer group обрабатывает событие; идемпотентный handler дедупит любой переигрыш
- 5 При исчерпанных ретраях ядовитое сообщение уходит в dead-letter queue
- 6 UI сверяет свою оптимистичную pending-строку до confirmed или failed
- 01Пройди по тому, почему именно идемпотентность, а не exactly-once доставка, — инвариант, держащий event-driven pipeline заказов вместе.
- 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 — поймали раньше, чем клиента.