Перейти к содержанию

Идемпотентность в цепочке микросервисов

Один ключ идемпотентности в одном сервисе — решённая задача. В цепочке всё сложнее: важный повтор происходит наверху, важный эффект — внизу, а между ними два–три перехода с собственными политиками повторов. Я построил gateway, вызывающий orders, который вызывает payments, сделал первую попытку дольше таймаута вызывающей стороны и посчитал списания. Без передаваемого по цепочке ключа один запрос пользователя дал четыре списания. С ним — одно.

Числа получены в эксперименте статьи: три сервиса внутри процесса и Redis в контейнере. Версии: idempotency-kit 0.3.0, httpx 0.28.1, Python 3.13.

Устройство проблемы

Gateway прекращает ждать через 600 мс и повторяет один раз. Orders тоже повторяет один раз. Первая попытка payments занимает 1,5 с — важный случай, когда работа завершилась, а ответ потерялся.

  no idempotency anywhere         charged 4 time(s); the caller answered on attempt 2;
                                  a later retry answered in 11 ms, charged again: True

Четыре списания от одного действия пользователя и пятое после повторного нажатия кнопки. Повторы перемножаются: две попытки gateway на две попытки orders, и все доходят до payments. Никто не настраивал «повторить четыре раза»; два слоя настроили «повторить один раз».

Это первое, что нужно понять о цепочке. Число попыток перемножается, а не складывается. Самый глубокий сервис, который списывает деньги, отправляет письмо или посылку, видит произведение всех вышестоящих уровней.

Ошибка, похожая на исправление

Очевидное решение — дать каждому запросу ключ идемпотентности. Вот обычная реализация: каждый сервис создаёт ключ, когда его нет, и каждая попытка получает новый:

  a fresh key per attempt, per hop  charged 2 time(s); the caller answered on attempt 2

Лучше четырёх, хуже одного — худший промежуточный результат: кажется, что идемпотентность работает, а клиенту выставляют двойное списание.

Новый ключ на попытку — лишь учёт запросов. Назначение ключа — сказать серверу: «Это тот же запрос, который ты уже мог видеть». Созданный внутри цикла повторов ключ каждый раз сообщает обратное. Ошибиться легко: str(uuid4()) в сборщике запроса находится внутри цикла. В тестах без таймаутов это незаметно.

Ключ принадлежит запросу, а не попытке

  the caller's key, propagated  charged 1 time(s); the caller no answer;
                                a later retry answered in 4 ms, charged again: False

Одно списание для действия, предпринятого четыре раза через два слоя повторов. Ключ создаётся один раз там, где представлено намерение пользователя: в gateway или самом клиенте. Дальше он передаётся заголовком через каждый переход. Сервисы используют его для собственных операций; одному действию соответствует одна запись на сервис, которую найдёт повтор на любой глубине.

Для этого важны две детали:

Ключ обозначает намерение, а не вызов. Payments и orders используют одну строку, но разные имена операций: «оформить заказ 42» и «оплатить заказ 42» — отдельные записи с собственной дедупликацией. Общий ключ без пространства имён операций позволил бы результату первого перехода ответить на вопрос второго.

Ключ должен переживать цикл повторов. Клиент создаёт его до первой попытки и использует во всех повторах запроса. Если ключи генерирует HTTP-клиент, это должно происходить за пределами его собственного цикла повторов. Иначе ошибка из предыдущего раздела просто переместится уровнем ниже.

ИДЕЯ В СХЕМЕОдин ключ операции сохраняется при всех повторах
---
config:
  theme: default
  look: classic
  sequence:
    useMaxWidth: false
    wrap: true
    width: 140
    actorMargin: 36
    mirrorActors: false
---
sequenceDiagram
    accTitle: Один ключ операции сохраняется при всех повторах
    accDescr: Каждый повтор должен сохранять исходную идентичность операции на всех переходах. Каждому сервису по-прежнему нужны своя область ключа и политика для уже выполняющейся операции.
 participant C as Клиент
 participant G as Шлюз
 participant O as Orders
 participant P as Payments
 C->>G: Запрос, ключ K
 G->>O: Запрос, ключ K
 O->>P: Запрос, ключ K
 P--xO: Ответ потерян после списания
 O->>P: Повтор, тот же ключ K
 P-->>O: Сохранённый результат
 O-->>G: Сохранённый результат
 G-->>C: Сохранённый результат

Каждый повтор должен сохранять исходную идентичность операции на всех переходах. Каждому сервису по-прежнему нужны своя область ключа и политика для уже выполняющейся операции.

Строка, показывающая, что работа ещё не закончена

Посмотрите на средний столбец: вызывающая сторона не получила ответа. Списание одно, всё правильно, но пользователь всё ещё смотрит на индикатор ожидания.

Это ограничение дедлайна, а не недостаток идемпотентности. Первая попытка удерживала запись во время 1,5 секунды работы; повтор, пришедший на 600 мс, ждал её. Таймаут клиента сработал раньше завершения обоих. Ключ устраняет дубликат, но не помещает медленное действие в слишком маленький бюджет.

Зато следующая попытка становится быстрой и корректной:

  a later retry answered in 4 ms, charged again: False

Четыре миллисекунды, сохранённый результат, без второго списания. Поэтому пользовательский сценарий таков: снабдить запрос ключом, повторять его и ожидать один из трёх ответов — результат, «ещё выполняется» или сохранённый результат завершённой операции. Отсюда смысл 409 для выполняющегося ключа вместо блокирующего ожидания и клиента, понимающего «в процессе» как «спроси чуть позже», а не как сбой.

Когда передавать нечего

Не каждый источник присылает ключ: сообщение очереди, webhook провайдера или внутренний вызов, написанный до появления соглашения.

  no header, a fingerprint per hop  charged 1 time(s); a later retry answered in 6 ms,
                                    charged again: False

Каждый переход хеширует описание действия — метод, путь, тело — и использует отпечаток как ключ. В этом сценарии результат такой же, как с передаваемым ключом, но компромиссы другие.

Это работает, если запрос полностью описывает намерение и два одинаковых запроса действительно обозначают одно действие. Опасный провал — когда это не так: два осознанных запроса «добавить один товар в корзину» побайтово одинаковы, но второй обязан выполниться. Отпечаток объединит их.

Поэтому отпечатки подходят сообщениям и webhook, несущим собственный идентификатор, но опасны как стандарт для пользовательского API, где повторение бывает осмысленным. Если есть оба механизма, явный ключ имеет приоритет, отпечаток служит запасным. Одинаковый ключ с другим телом — ошибка клиента, которую нужно явно отклонить, а не отвечать сохранённым результатом.

О чём договориться во всей цепочке

Цепочка работает благодаря общим соглашениям, а не одной лишь аккуратности каждого сервиса. Нужны четыре:

  1. Единое имя заголовка, передаваемого каждым сервисом во все исходящие вызовы, как trace id. Если это не встроено в общий HTTP-клиент, третий сервис о нём забудет.
  2. Один ключ на намерение, создаваемый самым внешним представляющим его компонентом и повторно используемый на любой глубине.
  3. Собственное имя операции в каждом сервисе, чтобы записи разных переходов не конфликтовали.
  4. Срок хранения дольше максимально позднего повтора. Истёкший до последней попытки ключ не защищает. Последней попыткой может быть нажатие пользователем после обеда.

И ещё одно соглашение о сбоях: идемпотентность самого глубокого сервиса проверяйте особенно тщательно, потому что его дубликат стоит денег. Его окно хранения должно быть самым длинным, ключ — максимально явным.

Инструменты

Хранилище и ход выполнения предоставляет idempotency-kit: координатор резервирует ключ до действия, сохраняет результат после и решает, что получит конкурентный запрос — ожидание результата или ответ о незавершённой операции. Передачу обеспечивает заголовок HTTP-клиента; это делает clientwright для исходящих вызовов. Вариант для одного сервиса разобран в статье о ключах идемпотентности.

Четыре списания, затем два, затем одно. Всё определяло место создания ключа.