Любая автоматизация, которая делает сетевые запросы — оплата заказа, отправка уведомления, синхронизация с внешним API — рано или поздно столкнётся с обрывом связи в худший момент: после того, как сервер выполнил операцию, но до того, как клиент получил подтверждение.
Клиент в этой ситуации не может отличить «запрос не дошёл» от «запрос выполнился, а ответ потерялся». Разумный шаг клиента — повторить запрос. Вопрос в том, что произойдёт на сервере при повторе.
Идемпотентность — свойство операции, при котором повторное её выполнение с теми же параметрами даёт тот же результат, что и однократное.
Проблема: retry дублирует эффект
Представим обработчик оплаты заказа: он просто вставляет новую запись о списании в таблицу платежей.
Скачать Jupyter Notebook Открыть в Google Colab
import sqlite3
conn = sqlite3.connect(':memory:')
conn.execute('''
CREATE TABLE payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT,
amount INTEGER
)
''')
def charge(order_id, amount):
conn.execute(
'INSERT INTO payments (order_id, amount) VALUES (?, ?)',
(order_id, amount)
)
conn.commit()
print(f'Списано {amount} ₽ за заказ {order_id}')
# Клиент не получил ответ вовремя и повторил тот же запрос
charge('order-42', 1500)
charge('order-42', 1500)
total = conn.execute(
'SELECT COUNT(*), SUM(amount) FROM payments WHERE order_id = ?',
('order-42',)
).fetchone()
print(f'\nВсего списаний: {total[0]}, на сумму {total[1]} ₽')
Списано 1500 ₽ за заказ order-42 Списано 1500 ₽ за заказ order-42 Всего списаний: 2, на сумму 3000 ₽
Функция сработала правильно оба раза — с точки зрения кода, ошибок не было. Проблема не в баге, а в том, что операция «списать деньги» не была спроектирована как идемпотентная: второй вызов с теми же параметрами не должен был ничего менять, а вместо этого создал ещё одну запись.
Какие операции идемпотентны от природы
HTTP-методы делят на идемпотентные и нет, но это разделение — контракт, который обязан соблюдать сам обработчик, а не гарантия протокола.
| Метод | Идемпотентен по спецификации | Почему |
|---|---|---|
| GET | Да | Только чтение, не меняет состояние |
| PUT | Да | Полностью заменяет ресурс тем же значением — повтор ничего не меняет |
| DELETE | Да | После первого удаления ресурса нет — повтор просто не находит, что удалять |
| POST | Нет | По умолчанию создаёт новую сущность при каждом вызове |
POST — самый частый источник проблем, потому что именно им обычно
создают заказы, платежи и уведомления: операции, где случайное дублирование стоит
реальных денег или испорченного пользовательского опыта.
Идемпотентный ключ
Решение — не запрещать повторные запросы, а сделать так, чтобы повтор с тем же идентификатором операции не производил эффекта дважды. Клиент генерирует уникальный ключ на попытку операции (а не на каждый HTTP-запрос) и передаёт его с каждым retry. Сервер запоминает, какие ключи уже обработаны.
conn2 = sqlite3.connect(':memory:')
conn2.execute('''
CREATE TABLE payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
idempotency_key TEXT UNIQUE,
order_id TEXT,
amount INTEGER
)
''')
def charge_idempotent(idempotency_key, order_id, amount):
try:
conn2.execute(
'INSERT INTO payments (idempotency_key, order_id, amount) VALUES (?, ?, ?)',
(idempotency_key, order_id, amount)
)
conn2.commit()
print(f'Списано {amount} ₽ за заказ {order_id}')
except sqlite3.IntegrityError:
print(f'Операция {idempotency_key} уже выполнена, повторное списание пропущено')
# Клиент повторяет запрос с тем же ключом идемпотентности
charge_idempotent('retry-key-abc123', 'order-42', 1500)
charge_idempotent('retry-key-abc123', 'order-42', 1500)
total = conn2.execute(
'SELECT COUNT(*), SUM(amount) FROM payments WHERE order_id = ?',
('order-42',)
).fetchone()
print(f'\nВсего списаний: {total[0]}, на сумму {total[1]} ₽')
Списано 1500 ₽ за заказ order-42 Операция retry-key-abc123 уже выполнена, повторное списание пропущено Всего списаний: 1, на сумму 1500 ₽
Ключевая деталь — UNIQUE-ограничение на колонке
idempotency_key. Проверка не зависит от гонки между двумя
одновременными повторами: если оба вызова придут параллельно, база данных
всё равно пропустит вставку только одной строки, а вторая упадёт с
IntegrityError. Проверять «а нет ли уже такого ключа» отдельным
SELECT перед INSERT — гонка: между чтением и записью
может проскочить второй повтор.
Откуда берётся ключ
Идемпотентный ключ должен привязываться к попытке выполнить операцию, а не генерироваться заново при каждом HTTP-запросе — иначе retry получит новый ключ и защита не сработает. На практике ключ берут из одного из источников:
-
Клиент генерирует UUID один раз перед первой попыткой
и переиспользует его во всех retry той же операции (так делают Stripe
и большинство платёжных API — заголовок
Idempotency-Key). - Естественный ключ предметной области — например, идентификатор заказа, если у заказа физически не может быть двух платежей.
- Хэш содержимого запроса — если два одинаковых по смыслу запроса всегда должны схлопываться в один, даже без явного ключа от клиента.
Идемпотентность — не то же самое, что «без побочных эффектов»
Частая путаница: идемпотентность не значит, что повтор ничего не делает — она значит,
что повтор безопасен. Отправка письма «ваш заказ
оформлен» тоже может быть идемпотентной, если обработчик проверяет, было ли уже
отправлено письмо с этим order_id, и не отправляет второе. Первый
вызов совершает действие, все последующие с тем же ключом — не делают ничего
нового, но и не возвращают ошибку.
Где это работает не через UNIQUE
Ограничение уникальности — не единственный механизм. В зависимости от хранилища подходят и другие способы:
| Хранилище / подход | Как достигается идемпотентность |
|---|---|
SQL, UNIQUE + ON CONFLICT |
INSERT ... ON CONFLICT (idempotency_key) DO NOTHING —
без исключений в коде приложения |
| Redis / key-value хранилище | SET key value NX — записать, только если ключа ещё нет |
| Очереди сообщений | Дедупликация по message id на стороне консьюмера
(у брокера обычно «at-least-once», а не «exactly-once» доставка) |
Чек-лист
- Может ли эта операция быть вызвана повторно из-за ретрая, таймаута или падения сети?
- Есть ли у операции идемпотентный ключ, который клиент переиспользует при повторе, а не создаёт заново?
- Полагается ли проверка «уже выполнено» на атомарное ограничение
(
UNIQUE,SET NX), а не на отдельныйSELECTперед записью? - Что вернётся клиенту при повторе с тем же ключом — тот же результат, что и при первом успешном вызове?
Вывод
Ретраи в распределённых системах неизбежны: сеть обрывается, серверы перезапускаются, таймауты срабатывают до завершения операции на стороне сервера. Идемпотентность — это не защита от повторных запросов, а гарантия того, что повтор безопасен. Дешевле спроектировать операцию идемпотентной один раз, чем потом разбирать инцидент из-за повторного выполнения.
Если операция меняет состояние и её можно вызвать через retry — она должна быть безопасна при повторе. Иначе рано или поздно ретрай после обрыва соединения, таймаута или перезапуска сервера приведёт к тому, что операция выполнится дважды.