Идемпотентность в цепочке микросервисов¶
Один ключ идемпотентности в одном сервисе — решённая задача. В цепочке всё сложнее: важный повтор происходит наверху, важный эффект — внизу, а между ними два–три перехода с собственными политиками повторов. Я построил 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. Никто не настраивал «повторить четыре раза»; два слоя настроили «повторить один раз».
Это первое, что нужно понять о цепочке. Число попыток перемножается, а не складывается. Самый глубокий сервис, который списывает деньги, отправляет письмо или посылку, видит произведение всех вышестоящих уровней.
Ошибка, похожая на исправление¶
Очевидное решение — дать каждому запросу ключ идемпотентности. Вот обычная реализация: каждый сервис создаёт ключ, когда его нет, и каждая попытка получает новый:
Лучше четырёх, хуже одного — худший промежуточный результат: кажется, что идемпотентность работает, а клиенту выставляют двойное списание.
Новый ключ на попытку — лишь учёт запросов. Назначение ключа — сказать серверу: «Это тот же запрос, который ты уже мог видеть». Созданный внутри цикла повторов ключ каждый раз сообщает обратное. Ошибиться легко: 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-клиент, это должно происходить за пределами его собственного цикла повторов. Иначе ошибка из предыдущего раздела просто переместится уровнем ниже.
Каждый повтор должен сохранять исходную идентичность операции на всех переходах. Каждому сервису по-прежнему нужны своя область ключа и политика для уже выполняющейся операции.
Строка, показывающая, что работа ещё не закончена¶
Посмотрите на средний столбец: вызывающая сторона не получила ответа. Списание одно, всё правильно, но пользователь всё ещё смотрит на индикатор ожидания.
Это ограничение дедлайна, а не недостаток идемпотентности. Первая попытка удерживала запись во время 1,5 секунды работы; повтор, пришедший на 600 мс, ждал её. Таймаут клиента сработал раньше завершения обоих. Ключ устраняет дубликат, но не помещает медленное действие в слишком маленький бюджет.
Зато следующая попытка становится быстрой и корректной:
Четыре миллисекунды, сохранённый результат, без второго списания. Поэтому пользовательский сценарий таков: снабдить запрос ключом, повторять его и ожидать один из трёх ответов — результат, «ещё выполняется» или сохранённый результат завершённой операции. Отсюда смысл 409 для выполняющегося ключа вместо блокирующего ожидания и клиента, понимающего «в процессе» как «спроси чуть позже», а не как сбой.
Когда передавать нечего¶
Не каждый источник присылает ключ: сообщение очереди, webhook провайдера или внутренний вызов, написанный до появления соглашения.
no header, a fingerprint per hop charged 1 time(s); a later retry answered in 6 ms,
charged again: False
Каждый переход хеширует описание действия — метод, путь, тело — и использует отпечаток как ключ. В этом сценарии результат такой же, как с передаваемым ключом, но компромиссы другие.
Это работает, если запрос полностью описывает намерение и два одинаковых запроса действительно обозначают одно действие. Опасный провал — когда это не так: два осознанных запроса «добавить один товар в корзину» побайтово одинаковы, но второй обязан выполниться. Отпечаток объединит их.
Поэтому отпечатки подходят сообщениям и webhook, несущим собственный идентификатор, но опасны как стандарт для пользовательского API, где повторение бывает осмысленным. Если есть оба механизма, явный ключ имеет приоритет, отпечаток служит запасным. Одинаковый ключ с другим телом — ошибка клиента, которую нужно явно отклонить, а не отвечать сохранённым результатом.
О чём договориться во всей цепочке¶
Цепочка работает благодаря общим соглашениям, а не одной лишь аккуратности каждого сервиса. Нужны четыре:
- Единое имя заголовка, передаваемого каждым сервисом во все исходящие вызовы, как trace id. Если это не встроено в общий HTTP-клиент, третий сервис о нём забудет.
- Один ключ на намерение, создаваемый самым внешним представляющим его компонентом и повторно используемый на любой глубине.
- Собственное имя операции в каждом сервисе, чтобы записи разных переходов не конфликтовали.
- Срок хранения дольше максимально позднего повтора. Истёкший до последней попытки ключ не защищает. Последней попыткой может быть нажатие пользователем после обеда.
И ещё одно соглашение о сбоях: идемпотентность самого глубокого сервиса проверяйте особенно тщательно, потому что его дубликат стоит денег. Его окно хранения должно быть самым длинным, ключ — максимально явным.
Инструменты¶
Хранилище и ход выполнения предоставляет idempotency-kit: координатор резервирует ключ до действия, сохраняет результат после и решает, что получит конкурентный запрос — ожидание результата или ответ о незавершённой операции. Передачу обеспечивает заголовок HTTP-клиента; это делает clientwright для исходящих вызовов. Вариант для одного сервиса разобран в статье о ключах идемпотентности.
Четыре списания, затем два, затем одно. Всё определяло место создания ключа.