Суть Читай реальный SQL и сниппеты хендлеров — схему outbox, claim-запрос relay и баг dual-write — предсказывай поведение и выбирай фикс с наибольшим рычагом.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Схема, запрос relay и хендлер — это место, где корректность outbox выигрывается или теряется. Прочитай каждый сниппет, предскажи, как он ведёт себя под крахом или нагрузкой, затем выбери фикс, который senior сделает первым.
Цель
Отработай цикл, который ты запускаешь в каждом ревью outbox: прочитай SQL и хендлер, найди разрыв, который вскрывает крах, и потянись за фиксом, закрывающим его без изобретения распределённой транзакции.
Сниппет 1 — хендлер
async function placeOrder(db, broker, order) { await db.query("INSERT INTO orders (id, status) VALUES ($1, 'placed')", [order.id]); await broker.publish("OrderPlaced", order); // отдельная система, отдельный вызов}
Викторина
Completed
Этот хендлер — тот самый dual-write, ради убийства которого существует юнит. Где разрыв и какой минимальный фикс?
Heads-up OOM-kill или потеря питания между двумя вызовами не бросает ничего, что можно поймать, и нельзя откатить закоммиченный заказ из мёртвого процесса. try/catch не компенсирует крах в разрыве — поэтому outbox выносит публикацию из хендлера целиком.
Heads-up Это превращает потерянное событие в фантомное — крах после publish и до коммита, и консьюмеры реагируют на заказ, который откатился. Перестановка двух неатомарных вызовов никогда не закрывает разрыв.
Heads-up Ретраи живут в памяти хендлера и умирают вместе с pod, и нет долговечной записи, что публикация была должна. Outbox делает эту запись долговечной в БД, чтобы relay мог ретраить после рестарта.
Сниппет 2 — схема outbox
CREATE TABLE outbox ( id uuid PRIMARY KEY, aggregate text NOT NULL, event_type text NOT NULL, payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), sent_at timestamptz -- NULL, пока relay не пометит sent);CREATE INDEX outbox_unsent ON outbox (created_at) WHERE sent_at IS NULL;
Викторина
Completed
Почему частичный индекс `WHERE sent_at IS NULL` — правильная форма для нагрузки relay?
Heads-up Этот индекс на таблице outbox и вообще не трогает таблицу orders. Он нацелен на чтение relay неотправленных строк, а не на бизнес-запись.
Heads-up Индекс меняет скорость запроса, а не семантику доставки. Доставка всё ещё at-least-once, потому что publish и mark-sent — два неатомарных шага; индекс лишь держит poll дешёвым.
Heads-up Полный индекс покрывает и миллионы sent-строк, которые relay никогда не запрашивает, поэтому растёт большим и дороже в обслуживании на каждой записи. Частичный индекс покрывает только строки, которые relay читает.
Сниппет 3 — claim-запрос relay
BEGIN;SELECT id, event_type, payload FROM outbox WHERE sent_at IS NULL ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED;-- публикуем каждую строку в брокер, затем:UPDATE outbox SET sent_at = now() WHERE id = ANY($claimed_ids);COMMIT;
Викторина
Completed
Три реплики relay гоняют этот запрос конкурентно. Что даёт FOR UPDATE SKIP LOCKED и какая гарантия всё ещё НЕ держится?
Heads-up SKIP LOCKED лишь не даёт двум репликам захватить одни строки за один проход. Разрыв publish-then-UPDATE всё ещё есть на каждой реплике, поэтому крах там переигрывает батч — доставка остаётся at-least-once.
Heads-up Без SKIP LOCKED вторая реплика блокируется на залоченных строках, сериализуя relay и убивая пропускную способность, ради которой ты масштабировался. SKIP LOCKED делает параллельные relay действительно параллельными.
Heads-up Поперёк трёх реплик, публикующих конкурентно, глобальный порядок теряется несмотря на ORDER BY. Порядок по aggregate требует keying по aggregate id, чтобы события одного aggregate шли в одну партицию.
Сниппет 4 — консьюмер
async function onOrderPlaced(db, event) { // event.id — стабильный id outbox-строки, пронесённый через брокер await db.query("INSERT INTO orders_processed (event_id) VALUES ($1)", [event.id]); await chargeCard(event.order);}
Викторина
Completed
Доставка at-least-once, поэтому этот консьюмер может получить то же событие дважды. Что не так и как сделать его idempotent?
Heads-up Запись id — это не дедуп, пока колонка не уникальна и ты не реагируешь на конфликт. Как написано, дубликат просто вставляет ещё строку, и карта списывается снова.
Heads-up Списание первым делает двойное списание более вероятным, а не менее. Фикс — уникальное ограничение плюс обработка конфликта, чтобы побочный эффект выполнялся ровно раз на event id.
Heads-up Relay outbox at-least-once по построению (разрыв publish-then-mark); никакая настройка брокера это сквозно не меняет. Идемпотентность по стабильному event id — ответственность консьюмера.
Итог
Каждое ревью outbox читается одинаково в коде: голый хендлер показывает разрыв dual-write, который вскрывает крах; частичный индекс схемы держит poll неотправленных строк дешёвым по мере роста таблицы; FOR UPDATE SKIP LOCKED в claim-запросе даёт репликам масштабироваться без двойной публикации, но разрыв publish-then-mark держит доставку at-least-once; а консьюмер замыкает петлю уникальным event id плюс ON CONFLICT DO NOTHING, чтобы повтор списал карту ровно раз. Найди разрыв, сделай намерение долговечным, захватывай строки непересекающимися батчами, дедупь downstream.