Пользователь оплатил заказ, увидел бесконечный спиннер, подождал полминуты и нажал кнопку ещё раз. Второй раз всё прошло, спасибо за покупку.
Через час он написал в поддержку, потому что списаний оказалось два.
В логах видно, что первый запрос дошёл до платёжного шлюза и деньги списались, а ответ до браузера не доехал — соединение оборвалось. Клиент об успехе не узнал, повторил операцию, и сервер обработал её как новую, потому что отличить её от новой ему было нечем.
История эта настолько стандартная, что вокруг неё выросла целая инженерная дисциплина.
Разберём, как устроены ключи идемпотентности, почему наивная реализация ломается на первой же гонке и какие решения принимаются один раз и потом определяют поведение системы годами.
Таймаут не означает, что операция не выполнилась
Начнём с фундамента.
Когда клиент не получил ответ, он знает ровно одно: ответа нет.
Что именно случилось:
запрос не дошёл;
сервер упал до обработки;
обработка прошла, но упал ответ;
обработка идёт до сих пор.
По одному лишь отсутствию ответа определить нельзя.
Три из этих четырёх сценариев требуют повтора, один делает повтор опасным, а различить их, имея на руках только молчание сервера, нельзя.
Отсюда правило, которое стоит принять раньше всех технических решений:
Повтор в распределённой системе неизбежен.
Не «возможен при плохой сети», а именно неизбежен.
Его сделает клиентская библиотека, балансировщик, очередь с гарантией хотя бы один раз или человек, нажавший кнопку второй раз.
Вопрос только в том, окажется ли он безвредным.
Часть операций безвредна по своей природе.
Чтение, установка значения в конкретное состояние, удаление по идентификатору — их можно повторять сколько угодно, результат один и тот же.
Проблемные — те, где результат зависит от количества вызовов:
списать сумму;
создать заказ;
отправить письмо;
прибавить к счётчику.
Именно для них и нужен способ сказать серверу: «это не новая операция, это повтор той же самой».
Способ называется ключом идемпотентности.
Ключ генерирует клиент
Первое решение, которое принимают неправильно, — кто и из чего делает ключ.
Соблазнительная идея: взять хеш от тела запроса.
Одинаковое тело — одинаковый ключ, ничего передавать не надо.
Идея так себе при первом же сценарии: один и тот же человек покупает один и тот же кофе дважды за минуту. Тела запросов идентичны, ключи совпадают, вторая покупка молча исчезает.
Хеш содержимого отвечает на вопрос «одинаковые ли это данные».
Ключ идемпотентности отвечает на другой — «одна ли это операция».
Задачи разные, и подменять вторую первой нельзя.
Ключ генерирует клиент, до первой попытки, и переиспользует без изменений во всех повторах:
POST /v1/payments HTTP/1.1 Idempotency-Key: 7f3c1e8a-4b2d-4e91-a5f6-9c0d3b7e2a41 Content-Type: application/json {"amount": 4990, "currency": "RUB", "order_id": "A-10023"}
Случайного идентификатора четвёртой версии для этого достаточно — энтропии хватает, чтобы не думать о коллизиях.
Класть в ключ адрес почты, номер телефона или что‑то ещё осмысленное не стоит: он попадёт в логи, в трассировки, в метрики, и вы получите утечку персональных данных там, где её никто не искал.
Есть альтернатива случайной строке — производный ключ от стабильного объекта, с которым пользователь уже работает:
идентификатор корзины;
черновика заказа;
сессии оформления.
Это не то же самое, что хеш тела, потому что такой объект уникален для конкретного намерения, а не для набора полей.
И про сам заголовок Idempotency-Key: это де‑факто соглашение, а не стандарт.
Черновик спецификации в IETF истёк, не став полноценным документом, поэтому детали поведения определяет тот, кто реализовал сервер.
Из этого следует, что поведение надо описывать в документации вашего API явно, а не рассчитывать, что клиент угадает по названию заголовка.
Ключ живёт в трёх состояниях, а не в двух
Наивная схема выглядит так:
пришёл запрос;
посмотрели ключ в таблице;
нашли — вернули сохранённый ответ;
не нашли — обработали и сохранили.
Два состояния, и вроде простая логика.
Проблема в промежутке между «не нашли» и «сохранили».
Пока первый запрос обрабатывается, а обработка платежа занимает секунды, ключа в таблице ещё нет.
Повтор, пришедший в этот момент, проверку проходит и уходит на обработку вторым.
Двойное списание случается ровно так, и воспроизводится оно только под нагрузкой.
Состояний должно быть три:
ключа нет;
ключ занят обработкой;
ключ завершён с сохранённым результатом.
Второе часто забывают.
CREATE TABLE idempotency_keys ( tenant_id uuid NOT NULL, key text NOT NULL, state text NOT NULL, -- in_progress | completed request_hash text NOT NULL, response_code int, response_body jsonb, locked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, key) );
Первичный ключ здесь составной, и это второе решение, которое принимают неправильно.
Ключ сам по себе образует глобальное пространство имён: клиент одного тенанта может случайно совпасть с ключом другого, а при желании подобрать чужой и получить его ответ.
Уникальность всегда строится на паре «тенант + ключ», никогда на одном ключе.
Резервирование вместо проверки
Правильная реализация не проверяет наличие ключа отдельным запросом, а пытается его занять — атомарно, силами базы:
INSERT INTO idempotency_keys (tenant_id, key, state, request_hash) VALUES ($1, $2, 'in_progress', $3) ON CONFLICT (tenant_id, key) DO NOTHING RETURNING *;
Если строка вернулась, вы первый и можете обрабатывать.
Если не вернулась — ключ уже кто‑то занял, и дальше надо посмотреть, в каком он состоянии:
SELECT state, request_hash, response_code, response_body FROM idempotency_keys WHERE tenant_id = $1 AND key = $2;
Дальше три ветки:
состояние
completed— возвращаем сохранённый ответ, ничего не выполняя;состояние
in_progress— отвечаем кодом 409, сообщая, что операция с этим ключом уже выполняется и результат будет позже;строка исчезла между двумя запросами — редкий случай гонки на очистке, обрабатывается повтором всей процедуры.
Проверка и захват — одна операция базы, а не две.
Схема «сначала SELECT, потом INSERT» имеет между собой окно, и под нагрузкой в него попадают.
А еще стоит проверить, что уникальность действительно работает под конкуренцией.
Ограничение в базе её гарантирует, а вот кеш, надстройка ORM или самодельная блокировка на приложении — не обязательно.
Тест на это пишется просто:
import concurrent.futures, uuid, requests key = str(uuid.uuid4()) payload = {"amount": 4990, "currency": "RUB"} def send(_): return requests.post( "http://localhost:8080/v1/payments", json=payload, headers={"Idempotency-Key": key}, ).status_code with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool: codes = list(pool.map(send, range(20))) print(sorted(codes))
[200, 409, 409, 409, 409, 409, 409, 409, 409, 409, 409, 409, 409, 409, 409, 409, 409, 409, 409, 409]
Один успех и девятнадцать конфликтов — так и должно быть.
Два двухсотых означают, что где‑то в вашей цепочке уникальность не атомарна, и это надо чинить до продакшена, а не после.
Как это выглядит в коде обработчика
Собранная целиком схема укладывается в одну прослойку.
import hashlib, json from contextlib import contextmanager SIGNIFICANT = ("amount", "currency", "recipient") def fingerprint(body: dict) -> str: raw = json.dumps({k: body[k] for k in SIGNIFICANT}, sort_keys=True, separators=(",", ":")) return hashlib.sha256(raw.encode()).hexdigest() @contextmanager def idempotent(tenant_id: str, key: str, body: dict): fp = fingerprint(body) row = db.fetchone(RESERVE_SQL, (tenant_id, key, fp)) if row is None: # ключ уже кем-то занят found = db.fetchone(LOOKUP_SQL, (tenant_id, key)) if found is None: raise Retryable("ключ исчез между запросами") if found["request_hash"] != fp: raise KeyReuse(422) if found["state"] == "in_progress": raise InFlight(409) raise Replay(found["response_code"], found["response_body"]) try: result = yield # тело обработчика except InfrastructureError: db.execute(RELEASE_SQL, (tenant_id, key)) # отпускаем для честного повтора raise else: db.execute(COMPLETE_SQL, (result.code, json.dumps(result.body), tenant_id, key))
Запросы вынесены в константы, чтобы прослойка читалась целиком:
-- RESERVE_SQL INSERT INTO idempotency_keys (tenant_id, key, state, request_hash, locked_at) VALUES (%s, %s, 'in_progress', %s, now()) ON CONFLICT (tenant_id, key) DO NOTHING RETURNING key; -- COMPLETE_SQL UPDATE idempotency_keys SET state = 'completed', response_code = %s, response_body = %s WHERE tenant_id = %s AND key = %s;
Использование получается неприметным, и это правильно — обработчику про идемпотентность знать незачем:
@app.post("/v1/payments") def create_payment(req: Request, body: PaymentIn): key = req.headers.get("Idempotency-Key") if not key: return JSONResponse(400, {"error": "idempotency_key_required"}) with idempotent(req.state.tenant_id, key, body.dict()): payment = process_payment(body) return Result(200, payment.to_dict())
Отдельно стоит решить, требовать ли заголовок обязательно. Отвечать четырёхсотым при его отсутствии строго, зато честно: клиент без ключа не защищён от повторов, и лучше он узнает об этом при интеграции, чем при разборе двойного списания.
Тот же ключ с другим телом запроса
Ситуация, о которой не думают, пока не столкнутся:
клиент повторяет запрос с тем же ключом, но с другими данными.
Бывает от ошибки в клиенте, бывает от переиспользования ключа, изредка — от попытки что‑нибудь подобрать.
Молча выполнить второй вариант нельзя, молча вернуть ответ от первого — тоже, ведь клиент подумает, что провёл платёж на новую сумму.
Поэтому в таблице и лежит хеш запроса:
if stored.request_hash != current_hash: return JSONResponse( status_code=422, content={ "error": "idempotency_key_reuse", "message": "Ключ уже использован для другого запроса", }, )
Считать хеш надо от того, что определяет смысл операции:
сумма;
валюта;
получатель.
А не от всего тела целиком.
Иначе изменение необязательного поля вроде комментария или метки в метаданных превратится в отказ там, где повтор был совершенно законным.
Что именно сохранять в ответе
У завершённого ключа надо хранить код ответа и тело, чтобы отдать их при повторе байт в байт.
Ну, еще нужно работать и с ошибками.
Ошибки бывают двух сортов.
Отказ из‑за самого запроса:
недостаточно средств;
неверные реквизиты;
отклонение банком.
Это законный результат операции, и его надо сохранять и воспроизводить.
Повтор не изменит ситуацию, а вот выполнить операцию заново по тому же ключу было бы неверно.
Сбои инфраструктуры:
таймаут до шлюза;
недоступность базы;
паника в обработчике.
Сохранять их нельзя.
Клиент повторит, и повтор должен привести к реальной попытке, а не к воспроизведению чужой аварии, которая давно закончилась.
Отдельная категория — ошибки валидации и конфликты с параллельным запросом.
Их резонно не сохранять вовсе: запрос до бизнес‑логики не дошёл, состояние не изменилось, и превращать это в постоянный результат ключа незачем.
if isinstance(result, BusinessRejection): store_completed(key, 402, result.body) # запоминаем: это ответ elif isinstance(result, InfrastructureError): release_key(key) # отпускаем: пусть повторят
Граница между этими двумя категориями проходит по вопросу «изменит ли что‑нибудь повтор».
Банк отказал по недостатку средств — не изменит, это ответ.
База была недоступна три секунды — изменит, это помеха.
Ключ не спасает, если операция затрагивает несколько систем
Возьмём сценарий: списать со счёта, создать заказ, отправить письмо. Всё завёрнуто в один ключ. Первая попытка списала деньги, создала заказ и упала до отправки письма.
Клиент повторяет. Ключ в состоянии in_progress или уже помечен завершённым — и что дальше? Отдать успех, оставив клиента без письма? Выполнить всё заново и списать второй раз? Оба варианта плохи, а выбирать между ними приходится, потому что операция не атомарна.
Работающий подход — не пытаться сделать её атомарной, а разложить на шаги, каждый из которых идемпотентен сам по себе, и хранить прогресс:
ALTER TABLE idempotency_keys ADD COLUMN recovery_point text; -- debited | order_created | notified
Повтор смотрит на точку восстановления и продолжает с неё, а не с начала.
Каждый шаг при этом должен уметь пережить собственный повтор:
списание проверяет, не проведено ли уже;
создание заказа делает вставку с игнорированием конфликта;
отправка письма сверяется со своим журналом.
Для шагов во внешних системах способ обычно один — прокинуть свой ключ дальше:
gateway.charge( amount=payment.amount, idempotency_key=f"{payment.id}:charge", # детерминированный, переживёт рестарт )
Ключ здесь производный от идентификатора платежа, а не случайный:
при повторе он получится тем же самым, и шлюз распознает повтор на своей стороне.
Случайный ключ, сгенерированный в момент вызова, при рестарте процесса окажется другим и всю защиту обнулит.
Побочные эффекты, которые нельзя сделать идемпотентными в принципе:
отправка письма через сервис без поддержки ключей;
вызов чужого API без такой возможности.
Их выносят из синхронной цепочки в очередь с собственной защитой от повторов.
Тогда у операции остаётся ровно один момент фиксации, а всё, что после, живёт по своим правилам.
Сколько хранить ключи
Хранить вечно нельзя, таблица растёт, а ключей у активного сервиса миллионы в день. Хранить слишком мало — тоже. Клиент, повторивший запрос после долгого разрыва, получит новую операцию вместо распознанного повтора.
Отраслевой ориентир — сутки, и такое окно выбрано не случайно: оно перекрывает разумные схемы повторов с нарастающей задержкой и при этом не даёт таблице разрастись. Ключ старше этого срока считается несуществующим, и запрос с ним обрабатывается как новый.
DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';
А еще нужен присмотр за зависшими записями. Процесс мог упасть между захватом ключа и завершением обработки, и тогда ключ навсегда останется в состоянии in_progress, блокируя все повторы:
UPDATE idempotency_keys SET state = 'expired' WHERE state = 'in_progress' AND locked_at < now() - interval '5 minutes';
Значение таймаута берут заведомо больше самой долгой законной обработки, иначе вы освободите ключ операции, которая ещё выполняется, и получите то самое двойное списание — только теперь по собственной вине.
Что стоит решить до первой строчки кода
Идемпотентность обычно воспринимают как заголовок, который надо добавить в API.
А на деле это набор решений про то, как система ведёт себя при неопределённости — и почти все они принимаются один раз и потом переживают несколько переписываний сервиса.
Решить надо следующее:
Кто генерирует ключ и из чего — клиент случайной строкой или система из стабильного объекта, но точно не хеш тела запроса.
Что образует пространство уникальности — почти всегда пара «тенант плюс ключ».
Сколько состояний у ключа — три, потому что при двух вы уже написали гонку, просто ещё не видели её на графиках.
Какие результаты сохраняются как окончательные, а какие отпускают ключ для честного повтора.
Где проходит граница операции, если она задевает несколько систем.
Всё это дешевле продумать заранее, потому что задним числом идемпотентность добавляется тяжело:
клиенты уже привыкли к текущему поведению;
ключи в базе не заведены;
операции разложены так, что промежуточное состояние восстановить нечем.
Проверять готовность стоит не чтением кода, а тем тестом с двадцатью параллельными запросами.
Один двухсотый и девятнадцать конфликтов — система работает.
Два двухсотых — где‑то в цепочке уникальность оказалась не такой атомарной, как выглядела в схеме, и лучше узнать об этом до того, как это заметит пользователь.

Повторы запросов, сбои сети и гонки за данными рано или поздно появляются в любом сервисе, который работает с реальными пользователями и нагрузкой. Важно не просто знать термин «идемпотентность», а уметь проектировать систему так, чтобы повтор операции не превращался в двойное списание, потерянный заказ или неконсистентное состояние.
На практике такие задачи упираются в более широкую инженерную цель. Разобраться с этим помогут практические занятия с разбором архитектурных решений и production‑сценариев. Присоединяйтесь:
17 сентября, 20:00. «RabbitMQ в Production: Transactional Outbox, идемпотентность и DLQ в ASP.NET Core». Записаться
6 октября, 20:00. «Практические подходы к переходу от монолита на микросервисы». Записаться
22 октября, 19:00. «Основы проектирования бизнес‑логики в микросервисной архитектуре». Записаться
Больше бесплатных уроков сентября можно посмотреть в дайджесте.
tema_rebel
нверное, прикольная статья... но больно читать эти иишные обороты речи...