Перейти к основному содержимому

Идемпотентность в распределённых системах

Идемпотентность — свойство операции, которое гарантирует, что повторное выполнение одной и той же операции приведёт к такому же результату, как и первое выполнение. Т.е. если отправить один и тот же запрос 10 раз, результат должен быть таким же, как если отправить его один раз.

Пример: Если повторное списание денег с карты не приводит к новому списанию, а возвращает результат первого списания — операция идемпотентна. Если же деньги спишутся дважды — это ошибка, нарушающая идемпотентность.

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

Примеры проблем обеспечения идемпотентности​

  • Повторные сетевые запросы: клиент не получил ответ вовремя (из-за таймаута, сетевого сбоя) и отправил запрос повторно. Сервер же успешно обработал первый запрос
  • Дублирование сообщений в брокерах: брокеры (Kafka, RabbitMQ) гарантируют доставку как минимум одни раз (at-least-once). В случае сбоя потребителя сообщение может быть доставлено повторно
  • Неопределённость состояния при сбоях: cервис обработал запрос, но упал до того, как отправил подтверждение. Оркестратор (например, Kubernetes) перезапустит контейнер, и обработка начнётся заново

Возможные последствия отсутствия идемпотентности:​

  • фин потери (двойные списания)
  • нарушение консистентности данных (два одинаковых пользователя в БД)
  • времязатратные отладки и исправления данных

Подходы к реализации идемпотентности​

Уникальный идентификатор запроса (Request ID)​

Клиент генерирует уникальный ID (UUID) для каждой бизнес-операции и передаёт его в каждом запросе (чаще всего в HTTP-заголовке, например, Idempotency-Key). Сервер, получив запрос, проверяет, не обрабатывался ли уже запрос с таким ID. Если нет — выполняет операцию и сохраняет результат (или просто факт выполнения) в быстрое хранилище (Redis, Memcached) с ключом = ID Если да — возвращает сохранённый ранее ответ, не выполняя операцию повторно

  • Плюсы: относительная простота, универсальность (подходит для REST, gRPC, Webhooks).
  • Минусы: требует наличия общего быстрого хранилища состояния для всех инстансов сервиса. Необходимо определять TTL для ключей

Сценарии: подходит для идемпотентных POST-запросов в API (например, создание платежа, заказа)

Журналирование и Outbox-паттерн​

Паттерн для надежной отправки сообщений в брокер в контексте транзакции с БД. Сервис не отправляет сообщение в брокер напрямую. Вместо этого он в рамках одной транзакции с основным бизнес-действием записывает сообщение в специальную таблицу в БД (outbox). Отдельный процесс (CDC, Change Data Capture) считывает новые записи из outbox и публикует их в брокер. После успешной публикации запись из outbox удаляется.

  • Плюсы: гарантирует, что сообщение будет отправлено в брокер тогда и только тогда, когда бизнес-транзакция commit'ится. Решает проблему дублей на стороне отправителя.
  • Минусы: архитектурная сложность, необходимость настройки и поддержки CDC-процесса.

Сценарии: Микросервисные асинхронные интеграции, где критична гарантия доставки события после записи в БД

Exactly-Once на уровне брокеров (Kafka)​

Брокеры предлагают встроенные механизмы для обеспечения семантики "точно один раз" Для продюсеров: Использование transactional.id и подтверждений от всех партиций (acks=all) гарантирует, что сообщение не будет потеряно и не будет записано дублем Для консьюмеров: Чтение сообщения и commit offset'а происходят атомарно. Консьюмер не получит одно и то же сообщение дважды после успешной обработки и коммита

  • Плюсы: высокая надёжность "из коробки".
  • Минусы: сложность конфигурации, накладные расходы на производительность, привязка к конкретной технологии.

Когда использовать: в проектах на Kafka, где требования к надёжности обработки потоков данных крайне высоки

Оптимистичная блокировка (CAS, Compare-and-swap) в хранилищах​

Каждая версия данных (запись, документ) снабжается меткой версии (version number, timestamp, ETag). Клиент, читая данные, получает их текущую версию. При изменении он отправляет данные обратно вместе с этой версией. Система проверяет, не изменилась ли версия с момента чтения. Если не изменилась — операция выполняется, версия инкрементируется Если изменилась — операция отклоняется (клиент должен перечитать данные и повторить попытку)

  • Плюсы: не требует пессимистичных блокировок, хорошо масштабируется в распределённых БД (Cassandra, DynamoDB).
  • Минусы: клиент должен быть готов обрабатывать коллизии и делать ретраи. Плохо подходит для сценариев с высокой конкуренцией

Сценарии: высоконагруженные системы, где обновления одних и тех же данных происходят нечасто, но нужно гарантировать их консистентность

Сравнение подходов​

ПодходСложностьПроизводительностьГарантииИдеальные сценарии
Request IDНизкаяВысокая (с быстрым кэшем)СильныеСинхронные API (платежи, заказы)
Outbox PatternВысокаяСредняяОчень сильныеАсинхронные интеграции между сервисами
Exactly-Once у брокеровСредняяСредняя/НизкаяОчень сильныеПотоковая обработка данных в экосистеме Kafka
Оптимистичная блокировкаНизкаяОчень высокаяЗависит от реализацииЧастые обновления в распределённых БД

Критерий выбора: какие последствия у дублирования операции?

  • Критично (финансы, заказы): можно использовать Request ID или Outbox + EOS
  • Некритично (логирование, аналитика): можно обойтись семантикой at-least-once
  • Частые обновления данных: оптимистичная блокировка

Уровни идемпотентности: Request-Level vs. Entity-Level​

Request-Level Idempotency (идемпотентность на уровне запроса)​

Уникальный ключ (например, UUID) генерируется для каждого конкретного запроса. Это самый распространенный подход, который гарантирует, что именно эта операция будет выполнена только один раз.

Пример: Клиент отправляет POST /payments с Idempotency-Key: abc123. Сервер создаёт платёж и сохраняет ключ. Второй точно такой же запрос с abc123 не создаст новый платёж, а вернет результат первого.

Что гарантирует: Не будет двух одинаковых платежей из-за дубля запроса

Entity-Level Idempotency (идемпотентность на уровне сущности)​

Ключ генерируется на основе модели сущности (например, "payment-1234-refund"). Более строгий подход, который гарантирует, что бизнес-операция над конкретной сущностью будет выполнена только один раз, даже если запросы были разными.

Пример: Система пытается списать деньги со счёта ACC-123 в рамках разных запросов (например, из-за сбоя и ретрая с разными Idempotency-Key). Механизм на уровне сущности «Счёт» не даст списать средства дважды, проверив, что операция списание X для платежа Y уже была выполнена.

Что гарантирует: Не будет двух логически одинаковых списаний, даже если запросы технически разные

  • Request-Level защищает от технических дубликатов.
  • Entity-Level — от бизнес-дубликатов. Надежная система требует обоих уровней для критичных операций.

Важные особенности​

Обязанности клиента в системе с идемпотентностью​

Клиент - активный участник обеспечения идемпотентности и должен:

  • Генерировать и надежно хранить уникальные idempotency-key для каждой операции
  • Использовать один и тот же ключ для повторных попыток той же операции
  • Реализовывать стратегии повторных попыток (retry) с экспоненциальной backoff-задержкой и добавлением случайности (jitter), чтобы избежать "толпы"
  • Корректно обрабатывать успешные ответы, чтобы не повторять уже завершенные операции

Отказ от реплик БД для чтения состояния идемпотентности​

  • Для проверки состояния обработки запроса по его ключу не стоит использовать реплики БД из-за потенциальной задержки репликации
  • Чтение должно осуществляться только из master-ноды БД для гарантии сильной согласованности (strong consistency). В противном случае есть риск, что второй запрос не увидит результат первого, еще не примененного на реплике, и выполнит операцию повторно

Жёсткое требование к неизменности полезной нагрузке​

Полезная нагрузка (payload) запроса не должна изменяться между повторными попытками. Если клиент изменит payload при ретрае, это нарушит принцип идемпотентности и приведет к непредсказуемым результатам. Клиент обязан отправлять абсолютно идентичные данные

Payload (полезная нагрузка) — это фактические данные, которые передаются внутри запроса или сообщения, ради которых он и отправляется. Всё, что не является служебной информацией.

Аренда (Lease) и блокировки для ключей идемпотентности​

Механизм временной монопольной блокировки ключа, предотвращает конкурентную обработку одинаковых запросов.

Используется для:

  • предотвращения Race Condition: без lease два параллельных одинаковых запросов могут одновременно не найти ключ в кеше, начать обработку и выполнить операцию дважды
  • контроль времени обработки: Lease timeout (например, 30 сек) не даст «зависшему» запросу блокировать ключ вечно. По истечении таймаута ключ освободится, и запрос можно будет обработать повторно

Примеры​

1: REST API для создания платежа Проблема: Клиентское приложение из-за плохой связи отправило запрос на списание средств несколько раз.

Решение: Эндпоинт POST /api/v1/payments требует заголовок Idempotency-Key. Платёжный сервис использует Redis для проверки ключа. Все дублирующиеся запросы возвращают результат первого, без списания денег.

2: Интеграция через Kafka Проблема: Сервис заказов отправил в Kafka сообщение "OrderCreated", а сервис склада, обрабатывая его, упал перед коммитом offset. При перезапуске он получит то же сообщение снова и попытается дважды списать товар.

Решение: В обработчике сообщения в сервисе склада реализована проверка по order_id (который уникален). Перед списанием товара проверяется, не был ли уже обработан этот заказ. Альтернативно — использование Transactional ID в Kafka для обеспечения exactly-Once.

Рекомендации​

В спецификациях API и интеграций указывать, какие методы/эндпоинты должны быть идемпотентными. Ввести обязательный заголовок Idempotency-Key для модифицирующих POST/PATCH-запросов

Проектировать с учётом идемпотентности:

  • "Что произойдёт, если этот запрос уйдёт дважды?"
  • "Как будут определяться и обрабатываться дубли?"
  • "Какое хранилище использовать для проверки уникальности Request ID?"

Антипаттерны:

  • Не использовать для идемпотентности данные, которые могут меняться (например, timestamp)
  • Не полагаться на ретраи на транспортном уровне (HTTP, TCP) как на гарантию доставки. Это гарантия доставки запроса, но не бизнес-логики

Материалы​

  1. Идемпотентность: что это, примеры и применение в API
  2. Идемпотентность: больше, чем кажется
  3. Идемпотентность: искусство не менять мир дважды
  4. Как сделать хорошую интеграцию? Часть 2. Идемпотентные операции – основа устойчивой интеграции
  5. История одного идемпотентного метода
  6. Идемпотентность в такси-приложении: кейс из практики
  7. Что такое идемпотентность HTTP-методов и почему она важна
  8. Стажёр Вася и его истории об идемпотентности API
  9. Важность идемпотентности в распределенных системах
  10. Борьба с дубликатами: делаем POST идемпотентным
  11. Идемпотентность микросервисов
  12. Атомарность и идемпотентность в Apache AirFlow
  13. Оптимистические и пессимистические блокировки на примере Hibernate (JPA)
  14. Change Data Capture (CDC) в Yandex Data Transfer: гид по технологии с примерами
  15. Паттерн Outbox: как не растерять сообщения в микросервисной архитектуре
  16. Transactional Outbox: от идеи до open-source
  17. Payload body в API: что это, форматы и как работать
  18. Idempotency Keys in RESTful APIs (eng)
  19. Обеспечение идемпотентности API

Видео​

  1. Что такое ИДЕМПОТЕНТНОСТЬ, или история Васи и его приложения
  2. Алексей Окружко, Идемпотентность: что, где и как?
  3. Микросервисы: Идемпотентность операций
  4. Филипп Вагнер «Распределенные транзакции в условиях микросервисной архитектуры» (SAGA, Outbox)
  5. Паттерн Outbox - теория и практика | Архитектура Микросервисов
  6. Compare and Swap, aka CAS - оптимистичная блокировка наглядно

Конференции​

  1. Analyst days: Event-driven архитектура: Outbox паттерн и варианты его имплементации

Книги​

  1. Высоконагруженные приложения. Программирование, масштабирование, поддержка - Мартин Клеппман (Глава 11)
  2. Создание микросервисов - Сэм Ньюмен (Глава 12)