Ваш сервис падает на полпути при обработке запроса POST /charge. Клиент видит таймаут и повторяет запрос. Теперь у вас два списания. Клиент злится. База данных согласована. Ваша бизнес-логика — нет.
Это не крайний случай. Это стандартное поведение распределённых систем. Сети теряют пакеты. Контейнеры убиваются по OOM посреди запроса. Балансировщики нагрузки возвращают 502 для запросов, которые уже достигли бэкенда. Если ваш API исходит из того, что «я получил ошибку, значит, операция точно не выполнилась», вы строите фабрику багов.
Реальность at-least-once, от которой не убежать
По умолчанию HTTP работает как at-least-once. TCP повторяет потерянные пакеты. Ваш HTTP-клиент повторяет запрос при таймауте. Ваша инфраструктура повторяет запрос при 5xx. Каждый уровень предполагает, что предыдущий может отказать, и переотправляет данные.
Проблема не в повторах. Проблема в неидемпотентных операциях.
GET безопасно повторять, потому что прочитать дважды — то же самое, что прочитать один раз. POST /charge или POST /orders — нет. Выполнить дважды — создать два ресурса. Выполнить ноль раз — потерять продажу. В ненадёжной сети нельзя выбрать «exactly once». Можно выбрать только между «at least once» с дедупликацией или «может ноль, может два» с порчей данных.
Идемпотентность — это способ сделать «at least once» безопасным.
Как на самом деле работают ключи идемпотентности
Stripe популяризовал этот паттерн, но идея старше. Клиент генерирует уникальный ключ (UUID) и отправляет его в заголовке: Idempotency-Key: <uuid>. Сервер сохраняет кортеж (key, request_body, response) до выполнения побочного эффекта. Если тот же ключ приходит снова, сервер возвращает сохранённый ответ, не переисполняя операцию.
Ключевой инсайт: сервер должен сохранить ключ до выполнения работы, а не после. Если вы сначала запишете строку списания, а потом упадёте до сохранения ключа, повторный запрос создаст второе списание. Хранение ключа идемпотентности и бизнес-мутация должны быть атомарными.
На практике это означает одно из двух:
-
Хранилище идемпотентности и бизнес-хранилище используют общую транзакцию базы данных. Вы вставляете в
idempotency_keysиchargesв рамках одногоBEGIN ... COMMIT. Если коммит успешен, обе записи существуют. Если нет — ни одной. -
Хранилище идемпотентности — это бизнес-хранилище. В таблице
chargesесть колонкаclient_idempotency_keyс ограничениемUNIQUE. Повторный запрос не проходит проверку уникальности, и вы возвращаете существующую строку.
Вариант 2 проще, и с него стоит начать большинству команд.
Рабочая реализация с атомарным хранилищем
Вот минимальный, но полноценный сервер на Python с использованием SQLite. Ключ идемпотентности находится в одной транзакции с бизнес-мутацией. Если сервер падает после COMMIT, повторный запрос попадает в кэш. Если падает до COMMIT, ничего не сохраняется, и повторный запрос безопасно переисполняется.
import sqlite3
import json
import uuid
DB_PATH = "/data/inventory.db"
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS reservations (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL,
quantity INTEGER NOT NULL,
idempotency_key TEXT UNIQUE NOT NULL,
created_at INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS idempotency_responses (
key TEXT PRIMARY KEY,
response_body TEXT NOT NULL,
created_at INTEGER
)
""")
conn.commit()
return conn
def reserve_inventory(conn, idempotency_key, item_id, quantity):
# Step 1: Check for an existing response.
cursor = conn.execute(
"SELECT response_body FROM idempotency_responses WHERE key = ?",
(idempotency_key,)
)
row = cursor.fetchone()
if row:
return json.loads(row[0])
# Step 2: Do the work inside a single transaction.
conn.execute("BEGIN")
# Deduct inventory atomically.
conn.execute(
"UPDATE inventory SET available = available - ? WHERE item_id = ? AND available >= ?",
(quantity, item_id, quantity)
)
if conn.total_changes == 0:
conn.rollback()
return {"error": "insufficient inventory"}
# Record the reservation.
reservation_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO reservations (id, item_id, quantity, idempotency_key, created_at) VALUES (?, ?, ?, ?, strftime('%s','now'))",
(reservation_id, item_id, quantity, idempotency_key)
)
# Cache the response for retries.
response = {"reservation_id": reservation_id, "status": "reserved"}
conn.execute(
"INSERT INTO idempotency_responses (key, response_body, created_at) VALUES (?, ?, strftime('%s','now'))",
(idempotency_key, json.dumps(response))
)
conn.commit()
return response
Таблица idempotency_responses — это страховочная сеть. Первый запрос выполняет мутацию, коммитит результат и кэширует ответ. Каждый последующий запрос с тем же ключом пропускает работу и возвращает закэшированный JSON. Резервирование и запись в кэш происходят в одной транзакции, поэтому они либо обе видны, либо обе отсутствуют.
Где это ломается: граница побочных эффектов
Ключи идемпотентности обрабатывают дублирующие запросы от одного клиента. Они не обрабатывают одновременные дублирующие запросы от разных клиентов. Если два пользователя нажимают «Купить» на последний товар на складе, вам всё равно нужен пессимистичный locking или optimistic concurrency control. Проверка available >= ? в примере выше — примитивная форма этого, но реальным системам инвентаря нужно больше.
Большая проблема — побочные эффекты вне транзакции. Если вы списываете средства через Stripe, отправляете письмо через SendGrid и пишете в базу данных, ключ идемпотентности защищает только часть с БД. Письмо может уйти дважды. Карта может быть списана дважды, если собственное окно идемпотентности Stripe истекло. Настоящая безопасность требует участия каждой downstream-системы.
Поэтому Stripe принимает собственный заголовок Idempotency-Key при создании списания. Они дедуплицируют на своём уровне. Вам стоит делать то же самое на своём. Прокидывайте тот же ключ в любой idempotent downstream-сервис. Для сервисов, которые не поддерживают это, оборачивайте вызов в локальную транзакцию или принимайте риск.
Коллизии ключей, TTL и другие операционные ловушки
UUID4 даёт 122 бита энтропии. Вероятность коллизии пренебрежимо мала для любого реалистичного объёма. Не используйте последовательные целые числа, таймстемпы или хэши тела запроса в качестве ключей. Клиентский UUID — это индустриальный стандарт, и на то есть причина.
Хранилище ключей растёт бесконечно, если не истечать старые записи. Задайте TTL: стандарт — 24 часа. После этого удаляйте старые ключи. Если клиент повторяет запрос после TTL, он получит дубль. Задокументируйте это. Окно повторов и TTL — это бизнес-контракт, а не техническая деталь.
Хранилище идемпотентности должно быть доступно как минимум на уровне API. Если ваш Redis-кэш недоступен, вы не можете проверить ключи. Некоторые команды откатываются к «считать новым запросом», что создаёт дубли во время сбоев. Другие отклоняют запрос — это безопаснее, но порождает другой режим отказа. Здесь нет бесплатного обеда.
Клиентская сторона не менее важна
Серверные ключи идемпотентности бесполезны, если клиент их не отправляет. Каждый мутирующий запрос должен генерировать ключ на месте вызова и повторять при таймауте с тем же ключом:
import uuid
import requests
def safe_post(url, payload, max_retries=3):
key = str(uuid.uuid4())
for attempt in range(max_retries):
try:
resp = requests.post(
url,
json=payload,
headers={"Idempotency-Key": key},
timeout=10,
)
return resp
except requests.Timeout:
if attempt == max_retries - 1:
raise
# Retry with the SAME key. The server deduplicates.
Ключ должен генерироваться один раз на логическую операцию, а не на каждую HTTP-попытку. Если вы генерируете новый UUID на каждый повтор, вы не поняли суть. Ключ — это контракт, который связывает повторы воедино.
FAQ
Что если клиент — браузер, а пользователь обновляет страницу?
Обновление страницы создаёт новый JavaScript-контекст. Новый запрос получит новый ключ идемпотентности, если вы не сохраните ключ в localStorage или URL. Большинство команд не заморачивается для некритичных сценариев. Для платежей — стоит.
Должны ли GET-запросы использовать ключи идемпотентности?
Нет. GET уже безопасен по семантике HTTP. Ключи идемпотентности нужны для методов, изменяющих состояние: POST, PUT, PATCH, DELETE.
Могу ли я использовать хэш тела запроса как ключ идемпотентности?
Только если тело детерминировано и не содержит таймстемпов или случайных значений. На практике клиентские UUID проще и надёжнее.
Как долго хранить ключи идемпотентности?
Дольше, чем ваше самое длинное окно повторов клиента. Если клиенты повторяют запросы в течение 60 секунд, храните ключи 24 часа. Если клиенты могут повторить завтра из-за batch-задания, храните неделю.
Сделайте ваш первый запрос идемпотентным уже сегодня
Добавьте заголовок Idempotency-Key к каждому мутирующему эндпоинту. Начните с 24-часового кэша. Используйте UUID4. Сделайте хранение атомарным с вашей бизнес-транзакцией. Когда в первый раз контейнер умрёт посреди запроса, а клиент повторит его, вы будете рады, что сделали это. Альтернатива — объяснять вашей финансовой команде, почему у одного клиента семнадцать одинаковых списаний.