IT Blog

Интересные факты из аналитики, разработки, автоматизации и AI

← Все статьи

Идемпотентность в автоматизации: почему повторный запрос не должен дублировать платёж

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

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

Клиент в этой ситуации не может отличить «запрос не дошёл» от «запрос выполнился, а ответ потерялся». Разумный шаг клиента — повторить запрос. Вопрос в том, что произойдёт на сервере при повторе.

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

Проблема: retry дублирует эффект

Представим обработчик оплаты заказа: он просто вставляет новую запись о списании в таблицу платежей.

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» доставка)

Чек-лист

  1. Может ли эта операция быть вызвана повторно из-за ретрая, таймаута или падения сети?
  2. Есть ли у операции идемпотентный ключ, который клиент переиспользует при повторе, а не создаёт заново?
  3. Полагается ли проверка «уже выполнено» на атомарное ограничение (UNIQUE, SET NX), а не на отдельный SELECT перед записью?
  4. Что вернётся клиенту при повторе с тем же ключом — тот же результат, что и при первом успешном вызове?

Вывод

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

Если операция меняет состояние и её можно вызвать через retry — она должна быть безопасна при повторе. Иначе рано или поздно ретрай после обрыва соединения, таймаута или перезапуска сервера приведёт к тому, что операция выполнится дважды.
← Все статьи