12k
All articles

Идемпотентность простыми словами и что она значит для вашего API

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

OpenReplay Team
OpenReplay Team
Идемпотентность простыми словами и что она значит для вашего API

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

Обычно это слово всплывает в одном из двух контекстов: дублирующееся списание в продакшене или платёжный API, который требует заголовок Idempotency-Key, не особенно объясняя, что сервер с ним делает. В этой статье разбираются обе половины контракта: что с ключом делает клиент и что делает сервер, чтобы повторный запрос был безвредным, а не «как правило, безвредным».

Ключевые выводы

  • У ключа идемпотентности три состояния, а не два: отсутствует, выполняется и завершён. Запрос, который обнаружил ключ в состоянии «выполняется», должен получить 409 Conflict, а не второе выполнение.
  • Проверка существования ключа с последующей его записью — это и есть состояние гонки; единственный безопасный способ «застолбить» ключ — одна атомарная вставка до начала любой работы.
  • Сохранённый результат должен фиксироваться в той же транзакции БД, что и бизнес-изменение; раздельная фиксация лишь смещает гонку.
  • Клиент генерирует ключ до первой попытки, переиспользует его при каждом ретрае и никогда не выводит его хешированием тела запроса.
  • Тестируйте, отправляя два идентичных запроса одновременно. Тест на дубликаты, выполняющий их последовательно, проходит даже при наличии гонки в коде.

Что предотвращает идемпотентность?

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

Почему ретраи продолжают приходить

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

RFC 9110 определяет PUT и DELETE как идемпотентные, а POST — нет; PATCH, описанный в RFC 5789, тоже не идемпотентен. Само по себе название метода никогда не делает ваш обработчик безопасным; идемпотентность — свойство вашей реализации, а не глагола.

Клиентская половина контракта

Клиент генерирует ключ идемпотентности до первой попытки, отправляет идентичный ключ при каждом ретрае этой операции и использует новый ключ только для действительно новой операции. Подойдёт случайная строка с высокой энтропией, например UUID. Рекомендации Stripe — UUID версии 4, предел в 255 символов и никаких чувствительных данных внутри самого ключа: ни адресов электронной почты, ни других персональных идентификаторов, потому что ключи попадают в логи.

Никогда не выводите ключ хешированием тела запроса. Два заказа, которые случайно оказались идентичными — например, один и тот же клиент дважды подряд покупает один и тот же товар, — дали бы одинаковый хеш и слились бы в один. Хеш говорит, совпадают ли два payload’а; ключ говорит, какую операцию имел в виду вызывающий. Это разные задачи. Вывод ключа из чего-то стабильного, с чем пользователь уже работает, например из ID корзины, вполне работает, потому что корзина здесь и есть представление операции.

Учтите, что заголовок Idempotency-Key — отраслевая договорённость, а не утверждённый стандарт. Черновик рабочей группы IETF httpapi истёк на редакции 07, так и не став RFC, поэтому каждый провайдер определяет собственную семантику.

Серверная половина: атомарно застолбите ключ

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

INSERT INTO idempotency_keys
  (tenant_id, idem_key, fingerprint, state, locked_until)
VALUES
  ($1, $2, $3, 'in_flight', now() + interval '90 seconds')
ON CONFLICT (tenant_id, idem_key) DO NOTHING
RETURNING id;

В PostgreSQL ON CONFLICT DO NOTHING пропускает вставку, а RETURNING не возвращает ни одной строки при конфликте ключа, поэтому ноль возвращённых строк означает, что ключом владеет другой запрос. Прочитайте существующую строку: если её состояние complete, воспроизведите сохранённый результат; если она всё ещё in_flight, верните 409 Conflict вместо повторного выполнения. Это соответствует документированному поведению провайдеров: Stripe возвращает 409 Conflict, когда ключ переиспользуется, пока первый запрос ещё выполняется, и не записывает этот конфликт в историю ключа, так что клиент волен вернуться позже. Stripe также помечает воспроизведённый ответ заголовком Idempotent-Replayed: true — дешёвая любезность, которую стоит перенять.

Фиксируйте результат вместе с бизнес-изменением

Сохранённый результат и бизнес-изменение должны фиксироваться в одной транзакции базы данных. Раздельная запись не устраняет гонку, а сдвигает её в промежуток между двумя коммитами. Если процесс умрёт после списания, но до обновления ключа, деньги уже переведены, а строка всё ещё числится как in_flight.

BEGIN;

INSERT INTO orders (tenant_id, customer_id, total_cents)
VALUES ($1, $2, $3);

UPDATE idempotency_keys
SET state = 'complete', status_code = 201, response_body = $4
WHERE tenant_id = $1 AND idem_key = $5;

COMMIT;

Либо существуют обе строки, либо ни одной — в этом весь смысл.

Что следует хранить вместе с ключом и как долго?

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

Кратко о трёх вариантах хранения:

  • Полный ответ. Проще всего воспроизвести в точности; объём хранилища растёт вместе с размером payload’а.
  • Ссылка на ресурс. Храните ID созданного заказа и пересобирайте ответ; легче, но требует дополнительного запроса.
  • Маркер плюс отпечаток запроса. Минимум хранилища; годится только тогда, когда ответ можно вычислить заново, и отпечаток становится обязательным, а не опциональным.

Четыре правила действуют независимо от выбранного варианта. Ставьте уникальное ограничение на (tenant_id, key), а не на один только ключ, чтобы один тенант не мог столкнуться с ключами другого тенанта или заняться их перебором. Задайте срок жизни: Stripe удаляет ключи, когда они переваливают за отметку в 24 часа; принцип в том, чтобы пережить окно ретраев, но не дать таблице расти бесконечно. Ставьте лизинг на строки в состоянии in-flight (колонка locked_until выше), чтобы процесс, умерший посреди запроса, не блокировал ретраи навсегда. И отклоняйте любой ретрай, отпечаток которого не совпадает с сохранённым. Тот же ключ с другим телом указывает на баг в клиенте, а отдать в ответ несвязанный результат было бы куда хуже.

Как правильно тестировать идемпотентность?

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

KEY=$(uuidgen)
for i in 1 2; do
  curl -s -o "resp_$i.json" -w "%{http_code}\n" \
    -X POST http://localhost:3000/orders \
    -H "Idempotency-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d '{"cart_id":"c_42","total_cents":1900}' &
done
wait

Проверьте, что в orders для этой корзины одна строка и что два кода статуса — это один 201 плюс либо воспроизведённый 201, либо 409. Если оба вернулись с 201 и разными ID заказов, у вас гонка типа «проверил-затем-записал».

Три вещи, которые нужно сделать правильно

Заголовок лишь даёт двум системам общее имя для одной операции. Сама безопасность обеспечивается тремя вещами в вашей базе данных: уникальным ограничением, атомарным захватом ключа и границей транзакции. Сделайте это правильно — и ваш эндпоинт переживёт любого клиента, который делает ретраи, то есть любого клиента вообще. Тот же подход переносится на потребителей сообщений, где доставка at-least-once означает, что ту же работу выполняет ключ дедупликации, просто под другим именем. Начните с самого опасного POST-эндпоинта, добавьте таблицу ключей и напишите конкурентный тест, прежде чем ему доверять.

Часто задаваемые вопросы

Нужны ли ключи идемпотентности для GET- и PUT-запросов?

Обычно нет. RFC 9110 определяет GET как безопасный, а PUT и DELETE как идемпотентные, поэтому повторённый PUT, полностью заменяющий ресурс, оставляет то же состояние и без ключа. Ключи важны для POST, где каждый запрос создаёт что-то новое. Исключение — обработчик PUT или DELETE с побочными эффектами, например отправкой письма или вызовом вебхука: ему по-прежнему нужна дедупликация на стороне сервера.

В чём разница между ключом идемпотентности и идентификатором запроса?

При ретраях они ведут себя противоположным образом. Request ID или correlation ID идентифицирует отдельную HTTP-попытку для логирования и трассировки, поэтому каждый ретрай получает новый. Ключ идемпотентности идентифицирует одну намеченную операцию, поэтому каждый ретрай переиспользует один и тот же. Клиент, который генерирует новый ключ идемпотентности на каждый ретрай, полностью сводит дедупликацию на нет, и сервер выполняет операцию дважды.

Можно ли хранить ключи идемпотентности в Redis вместо PostgreSQL?

Для атомарного захвата — да: SET с флагом NX захватывает ключ за один атомарный шаг, что соответствует паттерну insert-on-conflict. Чего Redis дать не может, так это единой транзакции, которая зафиксирует результат по ключу вместе с бизнес-строкой, хранящейся в другом месте. Сбой между записью в Redis и коммитом в базу данных снова открывает гонку, поэтому хранить ключи в бизнес-базе безопаснее.

Что произойдёт, если клиент сделает ретрай после истечения срока жизни ключа идемпотентности?

Сервер воспримет ретрай как совершенно новый запрос и выполнит его заново, что может создать дубликат. Stripe, например, удаляет ключи по прошествии 24 часов, поэтому ключ, переиспользованный после этого окна, выполнит операцию во второй раз. Задавайте срок хранения дольше самой длинной задержки ретрая, которую в принципе может создать любой клиент, очередь или пакетное задание.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.