Распределённые системы
Собираем всё вместе: пайплайн заказа, где протекают швы
Клиенту вернули деньги дважды за один отменённый заказ. Финансы помечают это; дежурный инженер вытягивает трейс. Платёжный сервис идемпотентен, оркестратор саги — образцовый, у политики ретраев есть backoff и jitter — каждый слой проходит свои юнит-тесты. Баг не живёт ни в одном из них. Оркестратор выпустил компенсацию refund, вызов отвалился по таймауту на 30s, хотя возврат на самом деле прошёл, в бюджете ретраев было место — и слой повторил вызов, а у шага возврата не было ключа идемпотентности. Два корректных слоя при композиции дали неверное число в выписке реального клиента.
Система: один пайплайн заказа, шесть примитивов
Всё в этом треке было примитивом по отдельности. Капстоун — одна реалистичная система, использующая их сразу: пайплайн заказа/оплаты на четыре сервиса — Order, Payment, Inventory, Shipping — координируемый сагой. Пройди по запросу, и каждый прошлый урок всплывает как слой:
- Кворумная репликация (
R + W > N): сервис Order пишет в реплицированное хранилище. ПриN=3, W=2, R=2запись, легшая на 2 из 3 реплик, переживает потерю одной ноды, и любое последующее чтение её пересекает. Именно это делает «заказ создан» долговечным. - Выборы лидера + fencing-токены: оркестратор саги работает как единственный лидер, чтобы два координатора не вели один заказ. Когда лидер замирает (GC, сетевой раздел) и выбирается новый, старый может проснуться и всё ещё действовать — поэтому каждая запись вниз несёт монотонный fencing-токен, и ресурсы отвергают любой токен меньше наибольшего виденного.
- Часы / упорядочивание: нельзя доверять wall-clock меткам для упорядочивания событий между сервисами. Логические часы (или TrueTime Spanner с ограниченной неопределённостью) упорядочивают шаги саги; «Payment произошёл до Shipping» должен быть причинным фактом, а не сравнением
Date.now(). - Саги + компенсации: распределённой ACID-транзакции на четыре сервиса не существует. Сага идёт прямыми шагами и при сбое запускает компенсации — семантические откаты (возврат, возврат на склад), а не rollback.
- Дисциплина ретраев: каждый межсервисный вызов повторяется с экспоненциальным backoff и jitter, под бюджетом ретраев, за circuit breaker.
Каждый слой корректен. Интересные провалы не внутри слоёв — они в том, как слои соединяются.
Идемпотентность — несущий примитив
Доставка at-least-once и ретраи — дефолтная реальность распределённых систем: вызов, отвалившийся по таймауту, мог пройти, и со стороны вызывающего этого не отличить. Единственный безопасный ответ на «случилось ли это?», когда узнать нельзя, — сделать «сделать снова» безвредным. Это и есть идемпотентность, и именно она делает безопасными все остальные слои пайплайна.
Ключевое слово — ключ. Идемпотентность — это не «операция от природы повторяема»: decrement stock by 1 не идемпотентна. Это «операция несёт стабильный бизнес-ключ, а получатель записывает этот ключ, так что второе прибытие того же ключа — это no-op, возвращающий первый результат». Ловушка, за которой следит сеньор: ключ привязан не к тому. Привяжешь к id HTTP-запроса — и ретрай, сгенерированный другим слоем (сага vs SDK vs клиент), получит свежий id и проскользнёт. Ключ должен выводиться из бизнес-намерения — refund:order-8842:attempt-1 — и протягиваться через каждый слой, который может повторить вызов.
Почему это работает
«At-least-once» и «exactly-once» — это не два режима доставки, между которыми выбираешь. Доставка exactly-once по сети невозможна; то, что называют «exactly-once», на деле — доставка at-least-once плюс идемпотентная обработка по dedup-id. Сеть даёт дубликаты; ключ даёт exactly-once эффекты. Смешение этих двух — то, как команды выкатывают сагу в вере, что брокер гарантирует отсутствие дубликатов.
Провал композиции: компенсация в гонке с ретраем
Вот баг из Hook, механически. Сага решает отменить и выпускает компенсацию refund:
| t | Слой саги / ретраев | Платёжный сервис | Эффект |
|---|---|---|---|
| 0s | выпускает компенсацию refund | получает запрос, начинает возврат | — |
| 28s | ждёт ответа | возврат проходит, ответ задержан | $50 возвращены (1-й раз) |
| 30s | таймаут; бюджет OK → ретрай | получает 2-й запрос, без dedup-ключа | $50 возвращены (2-й раз) |
| 31s | ретрай возвращает 200 | — | двойной возврат |
Прогони каждый слой отдельно — он невиновен. Таймаут был разумным. В бюджете ретраев было место, так что ретрай был правильным решением политики. Логика компенсации верна — отменённый заказ должен возвращать деньги. Платёжный сервис был идемпотентен для пути списания. Дефект целиком в шве: ретрай (один корректный слой) заново вызвал компенсацию (другой корректный слой) через вызов без общего ключа идемпотентности. Чинится протягиванием одного ключа — refund:order-8842 — от саги через слой ретраев в платёжный сервис, где второе прибытие этого ключа возвращает результат первого возврата вместо выпуска нового. В самих слоях ничего не меняется; шов получает общий ключ.
Наблюдаемость — то, как этим реально управляют
Пайплайн не объявляет о собственном гниении. Провалы композиции прячутся, потому что дашборд каждого компонента зелёный. Систему ведут по сигналам, которые живут между слоями:
- Consumer lag — насколько отстали консьюмеры событий саги. Растущий lag значит, что пайплайн отстаёт от продюсеров; саги застревают на полпути, а компенсации в полёте копятся.
- Latency кворумной записи/чтения — p99 записи
W=2. Медленная третья реплика тянет хвостовую latency, даже когда ни одна нода технически не «упала». - Leader churn — выборы в минуту. Частые перевыборы значат, что fencing-токены постоянно растут и ты в одной паузе от попытки split-brain записи.
- Исчерпание бюджета ретраев — процент потреблённого бюджета. Когда бюджет около 100%, ретраи вот-вот будут отброшены, что выходит наружу как видимые пользователю ошибки, хотя каждый сервис вниз здоров. Бюджеты обычно ставят так, чтобы ретраи добавляли максимум ~10% сверх обычного rate запросов — ровно чтобы шторм ретраев не усилил мелкий сбой до аварии.
Инстинкт сеньора: алертить на швы, а не только на ноды. Пайплайн из здоровых сервисов всё равно может подводить клиентов.
Компенсацию `refund` твоей саги заказа слой ретраев может повторить после таймаута. Где поставить защиту, чтобы двойной возврат стал невозможен?
Каждый сервис в пайплайне заказа проходит свои тесты, но клиенты иногда видят двойные списания. Где баг скорее всего?
Оркестратор саги работает как выбранный лидер. После долгой паузы GC он просыпается и пытается записать шаг саги. Что мешает ему испортить состояние, которое новый лидер уже продвинул?
Расставь шаги диагностики провала композиции (периодический двойной возврат), где все сервисы зелёные:
- 1 Воспроизводи по трейсу, а не по логу одного сервиса — баги композиции пересекают слои
- 2 Найди шов: какие два корректных слоя взаимодействовали (здесь: слой ретраев, заново вызывающий компенсацию)
- 3 Проверь, пересекает ли этот шов стабильный ключ идемпотентности — обычно нет
- 4 Протяни один бизнес-ключ через каждый слой, который может повторить операцию
- 5 Делай dedup на получателе по этому ключу и добавь алерт уровня шва (например, счётчик дубль-эффектов)
- 01Проведи коллегу по тому, как компенсация возврата и ретрай складываются в двойной возврат, хотя оба слоя по отдельности корректны, и как одно изменение это чинит.
- 02Почему идемпотентность называют несущим примитивом всего пайплайна, и что делает ключ корректным против ключа, который молча проваливается?
Капстоун — не новый примитив, а осознание, что каждый примитив в этом треке корректен по отдельности, а реальные провалы живут в швах, где они соединяются. Один пайплайн заказа/оплаты использует их все: кворумную репликацию (R + W > N) для долговечных записей заказа, выбранного лидера с fencing-токенами, чтобы паузнутый оркестратор не испортил состояние, продвинутое новым лидером, логические часы или TrueTime для причинного упорядочивания шагов, саги с компенсациями, потому что распределённой ACID-транзакции нет, и ретраи с backoff, jitter и бюджетом, ограниченным около 10% сверх обычного трафика. Идемпотентность по бизнес-ключу — несущий примитив: доставка at-least-once делает дубликаты неизбежными, поэтому единственный безопасный ответ на непознаваемое «случилось ли это?» — сделать «сделать снова» безвредным. Каноничный провал композиции — компенсация возврата в гонке с ретраем через вызов без общего ключа, дающая двойной возврат, пока тесты каждого слоя остаются зелёными — чинится протягиванием одного ключа через шов, а не изменением слоя. Всем этим управляют по сигналам швов — consumer lag, latency кворума, leader churn, исчерпание бюджета ретраев — потому что пайплайн из здоровых сервисов всё равно может подводить реальных клиентов.