Суть Читай реальные сниппеты саги — compensating action, шаг оркестратора и повторно вызванный handler, — предскажи сбой и выбери фикс с наибольшим рычагом.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Баги саги в коде не выглядят как баги саги — они выглядят как обычный handler, который при retry списывает дважды, или как compensation, которая ждёт rollback, что никогда не случится. Прочитай каждый сниппет и выбери фикс, который сеньор сделал бы до релиза.
Цель
Потренируй чтение, которое делаешь в каждом ревью саги: заметь отсутствующий idempotency key, compensation, которая на деле rollback в маскировке, и шаг оркестратора, теряющий процесс на лету при краше.
Сниппет 1 — compensating action
// T2 уже закоммитилась: карта списана и записана строка платежа.// C2 должна отменить это после падения более позднего шага.func compensateCharge(ctx context.Context, orderID string) error { // удаляем строку платежа, чтобы выглядело, будто списания не было return db.Exec(ctx, "DELETE FROM payments WHERE order_id = $1", orderID)}
Викторина
Completed
Почему эта compensation неверна и что она должна делать вместо этого?
Heads-up Согласованность с внешним миром, а не только с локальной таблицей. Шлюз всё ещё держит деньги клиента; удаление строки лишь прячет списание и ломает сверку.
Heads-up Мягкое удаление — лучше для учёта, но деньги назад не возвращает. Compensation — это новое прямое действие против шлюза, а не правка локальной строки.
Heads-up Откатывать нечего — T2 закоммитилась durable давно. Единственный откат для реального списания — реальный возврат.
Сниппет 2 — шаг оркестратора
# Оркестратор ведёт сагу вперёд, шаг за шагом, в памяти.def run_order_saga(order): book_flight(order) # T1 book_hotel(order) # T2 try: charge_card(order) # T3 except StepFailed: cancel_hotel(order) # C2 cancel_flight(order) # C1 # прогресс живёт только в этом кадре вызова
Викторина
Completed
Процесс оркестратора падает сразу после возврата book_hotel, но до charge_card. В чём сбой и что его чинит?
Heads-up Рестарт не входит заново в функцию, бывшую посреди выполнения; кадр вызова исчез. Без персистентного состояния забронированные рейс и отель осиротеют.
Heads-up Политика ретраев — отдельная забота. Краш здесь случается между шагами, так что даже идеальный retry не запустится — настоящий дефект это потерянное состояние в памяти.
Heads-up Шаги охватывают разные сервисы и их базы; ни одна транзакция их не покрывает. Это и есть причина существования саги. Фикс — durable состояние шага, а не глобальная транзакция.
Сниппет 3 — идемпотентный шаг
// Доставка at-least-once, так что этот handler может быть вызван// больше одного раза для одного шага саги.func handleChargeCard(msg ChargeMsg) error { amount := msg.Amount if err := gateway.Charge(msg.CustomerID, amount); err != nil { return err } return savePaymentRow(msg.OrderID, amount)}
Викторина
Completed
При доставке at-least-once у этого handler реальный денежный баг. В чём он и каков минимальный фикс?
Heads-up Шлюзы дедуплицируют, только когда ты дал им idempotency key. Без него два одинаковых вызова Charge — это два разных списания. Handler обязан передать ключ.
Heads-up Сквозной exactly-once через сеть — во многом миф; брокеры дают at-least-once, а ты делаешь consumer'ы идемпотентными. Фикс живёт в handler, а не в настройке брокера.
Heads-up Переупорядочивание всё равно гонка: две повторные доставки могут обе пройти проверку до того, как любая запишет. Нужна атомарная дедупликация — уникальный idempotency key, который обеспечит шлюз или уникальное ограничение в базе.
Сниппет 4 — semantic lock
-- Сага A начинает работать с заказом. Сага B может тронуть тот же-- заказ одновременно, потому что между шагами саги ничего не заблокировано.UPDATE orders SET status = 'PENDING_PAYMENT' WHERE id = $1;-- ... позже выполняются шаги саги A, затем при успехе ...UPDATE orders SET status = 'CONFIRMED' WHERE id = $1;
Викторина
Completed
Что здесь делает статус PENDING_PAYMENT и что должны делать другие саги, чтобы это работало?
Heads-up Блокировка строки живёт только на время одного UPDATE, а не на все шаги саги. Статус — это межшаговая блокировка, но лишь если другие саги её действительно соблюдают.
Heads-up Сам по себе он ничего не гарантирует — он рекомендательный. Принуждение в коде приложения, который читает статус и решает пропустить или подождать.
Heads-up Semantic lock приближает изоляцию в логике приложения; он не возвращает изоляцию уровня базы. Саги остаются ACID минус I — маркер лишь позволяет кооперирующим сагам избегать друг друга.
Итог
Каждый дефект саги в этом наборе — известная форма: compensation, которая удаляет локальную запись вместо реального обратного действия; оркестратор, держащий прогресс только в памяти и теряющий процесс при краше; шаг, списывающий дважды, потому что at-least-once встретился с неидемпотентным handler; и поле статуса, работающее как блокировка, только если каждая сага его соблюдает. Читай на отсутствующий idempotency key, на compensation-в-маскировке-под-rollback и на неперсистентный шаг — там и живут баги саги.