Практическое объяснение того, почему идемпотентные API всё равно порождают фантомные записи в продакшене, и безгоночный транзакционный паттерн, который этого не допускает.

Реализации идемпотентности обычно проходят unit-тесты, но в продакшене незаметно повреждают данные из-за четырёх режимов отказа — включая те, что проявляются как «фантомные записи» — паттерн, ранее не описанный как единый класс провалов идемпотентности. В статье эти паттерны выявляются на основе разборов 12 продакшен-инцидентов и предлагается паттерн Idempotency Barrier — единый подход, сочетающий транзакционные конечные автоматы, атомарное «захватывание» и распространение ключей с учётом границ. После внедрения на трёх финансовых платформах паттерн устранил 99,98% инцидентов с дубликатами платежей и сократил ежемесячные затраты на сверку более чем на $220 000.

Раскрытие информации: это исследование основано на разборе продакшен-инцидентов в нескольких высоконагруженных платёжных системах и платформах исполнения заказов в период 2023–2025 годов. Детали, привязанные к конкретным компаниям, анонимизированы.

Введение

На часах 2:47 ночи, и дежурный инженер смотрит на дашборд: за последний час система обработала 342 дубликата платёжных транзакций на сумму $47 000. API идемпотентен — по крайней мере, так написано в дизайн-документе. Таблица ключей идемпотентности есть, проверка на дубль выполняется на каждом запросе, все unit-тесты проходят. И всё же деньги списались дважды.

Это и есть Phantom Write Problem: класс отказов, при котором проверки идемпотентности «проходят», система уверена, что ведёт себя корректно, но данные всё равно повреждаются. Сколько ваших «идемпотентных» API переживут 26-часовую задержку потребителя?

Хотя идемпотентность хорошо описана в литературе по распределённым системам [1][2], Phantom Write Problem — класс отказов, при котором проверки идемпотентности проходят, но повреждение данных всё равно происходит, — не имеет формального описания ни в индустриальной документации, ни в академических обзорах. Отдельные режимы отказа встречаются в виде анекдотических упоминаний в post-mortem отчётах, однако эта статья (опубликована в феврале 2026 года) — первая, которая формально каталогизирует все четыре режима отказа Phantom Write как единый паттерн и предлагает подтверждённую в продакшене стратегию предотвращения, основанную на анализе 12 post-mortem разборов инцидентов на нескольких платёжных платформах. В ходе этих расследований данные режимы отказа проявились в 9 из 12 проверенных платёжных систем — каждая из них прошла code review и выдержала unit-тестирование, прежде чем проявиться при реальной конкурентности и реальных условиях отказов.

Четыре режима отказа

Режим отказа 1: ловушка истечения TTL

Самая распространённая реализация идемпотентности хранит ключ запроса с time-to-live (TTL) — обычно 24 или 48 часов. Предполагается, что любой дубль придёт в пределах этого окна. На практике это предположение часто ломается.

Представьте систему, где апстрим-сервис публикует события в Kafka-топик, а даунстрим-потребитель обрабатывает их идемпотентно. В нормальных условиях сообщения обрабатываются за секунды. Но во время деплоя потребителя ребаланс приводит к тому, что один партишн остаётся необработанным 26 часов. Когда потребитель «догоняет» поток, он переигрывает сообщения, чьи ключи идемпотентности уже истекли и исчезли из Redis. Каждое из них обрабатывается как «новый» запрос.

Ловушка истечения TTL
Ловушка истечения TTL

Исправление: никогда не используйте идемпотентность, основанную только на TTL, для операций с неограниченным окном повторов. Вместо этого применяйте хранилище ключей идемпотентности на базе БД с трёхсостояной моделью (IN_PROGRESS, COMPLETED, FAILED), где колонка expires_at управляет джобой очистки ради управления объёмом хранения — но не корректностью. Окно очистки должно быть значительно больше вашего худшего окна реплея (минимум 7 дней для систем на базе Kafka).

Режим отказа 2: призрак частичного выполнения

Это самый опасный режим отказа, потому что он приводит к повреждению данных, которое обычно обнаруживается только ручной сверкой по бухгалтерскому/платёжному леджеру (среднее время обнаружения в проанализированных инцидентах: 14 часов).

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

Призрак частичного выполнения
Призрак частичного выполнения

Типовые реализации упираются в компромисс между безопасностью и живучестью, который корректно не разрешается ни в одну сторону: трактовка IN_PROGRESS как «ещё не завершено» грозит повторным выполнением уже частично сохранённых побочных эффектов, а трактовка как «уже завершено» означает, что операция тихо никогда не завершится.

Исправление: оборачивайте и бизнес-логику, и переход состояния идемпотентности в одну транзакцию БД. Если транзакция откатывается, вместе откатываются и бизнес-данные, и статус идемпотентности. Для «залежавшихся» ключей IN_PROGRESS (когда исходный обработчик, вероятно, уже мёртв) используйте настраиваемый тайм-аут, чтобы безопасно перехватить ключ и выполнить повторно.

Комментарий от Михаила Поливаха

Иногда ситуация сложнее, и бывает, что мы просто не может поместить часть логики в транзакцию. Например, нам нужно вызвать 2 API по HTTP в рамках бизнес логики. Тут уже никакую транзакцию открыть, тут только договариваться с downstream сервисами о том, чтобы они корректно обрабатывали дубли.

Режим отказа 3: гонка при конкурентной проверке

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

Гонка при конкурентной проверке
Гонка при конкурентной проверке

Наивная связка SELECT, затем INSERT создаёт TOCTOU-гонку (Time-of-Check-to-Time-of-Use). Даже при UNIQUE-ограничении второй запрос падает с ошибкой нарушения ограничения, вместо того чтобы быть корректно обработанным как дубль.

Исправление: используйте INSERT ... ON CONFLICT DO NOTHING (PostgreSQL 9.5+), чтобы сделать проверку и «захват» атомарными. Если RETURNING не возвращает строк, ключ уже существовал — прочитайте его статус через SELECT ... FOR UPDATE. Для неблокирующего поведения SELECT ... FOR UPDATE SKIP LOCKED позволяет второму экземпляру сразу вернуть 409 Conflict, не ожидая.

Режим отказа 4: рассогласование слоёв

Слой API идемпотентен — дублирующиеся HTTP-запросы корректно дедуплицируются. Но система публикует Kafka-событие в ходе обработки, а даунстрим-потребитель не идемпотентен. Дубль возникает не на границе API, а на границе событий.

Рассогласование слоёв
Рассогласование слоёв

Kafka по умолчанию обеспечивает доставку at-least-once. Идемпотентность на уровне API не даёт сквозной семантики exactly-once. Хотя семантика exactly-once в Kafka решает задачу дедупликации на стороне продюсера [2], она не покрывает дедупликацию у потребителя через границы систем — сценарий, описанный здесь.

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

Паттерн Idempotency Barrier

Отдельные исправления складываются в единый паттерн — Idempotency Barrier — который одновременно закрывает все четыре режима отказа.

Паттерн Idempotency Barrier
Паттерн Idempotency Barrier

Паттерн Idempotency Barrier не вводит новых примитивов. Его вклад — синтезировать транзакционную безопасность, атомарный «захват» и распространение ключей через границы в единый подход, закрывающий режимы отказа, которые раньше воспринимались как разрозненные «краевые случаи». Барьер обеспечивает четыре гарантии:

  • Отсутствие зависимости корректности от TTL. Хранилище идемпотентности основано на персистентности в базе данных. Очистка запускается отдельно и никогда не влияет на точность дедупликации.

  • Атомарные переходы состояний. Бизнес-запись и смена статуса идемпотентности разделяют одну транзакцию БД, делая частичное выполнение невозможным.

  • Захват без гонок. INSERT ... ON CONFLICT или SELECT ... FOR UPDATE SKIP LOCKED гарантируют, что «захват» выиграет ровно один экземпляр.

  • Распространение с учётом границ. Ключи идемпотентности распространяются как заголовки через каждую даунстрим-систему, а каждый потребитель запускает свой барьер.

Быстрый старт (Spring Boot)

// Bean configured with PostgreSQL 9.5+ and Spring Boot 2.7+ support
@PostMapping("/payments")
public PaymentResponse createPayment(
        @RequestHeader("Idempotency-Key") String key,
        @RequestBody PaymentRequest req) {
    return idempotentExecutor.executeIdempotent(
        key,
        () -> paymentService.process(req)
    );
}

Требуется PostgreSQL 9.5+ (для INSERT ... ON CONFLICT) и Spring Boot 2.7+.

Выбор хранилища идемпотентности

Не каждой системе нужен полноценный barrier на базе БД. Подходы «только Redis» работают, когда допустима best-effort дедупликация и окна повторов ограничены. Нативная семантика exactly-once в Kafka подходит для stream processing «Kafka-to-Kafka». Idempotency Barrier на базе БД необходим, когда корректность критична: финансовые транзакции, исполнение заказов и любая система, где дубликаты имеют денежную цену.

Три метрики, за которыми стоит следить

Без наблюдаемости фантомные записи невидимы. Отслеживайте idempotency.hit.rate (доля запросов, совпавших с уже существующим ключом — 0% означает, что ваш слой сломан, всплеск означает шторм повторов), idempotency.stale.reclaim.count (ненулевые значения указывают на падение процессов в середине выполнения), и idempotency.status.distribution (растущая куча записей IN_PROGRESS сигнализирует о системной проблеме).

Эффект в продакшене

После внедрения паттерна Idempotency Barrier на трёх платёжных платформах (2024–2025):

  • Устранено 99,98% инцидентов с дубликатами платежей на 14B+ транзакций в месяц (от пика 342/час до 0,07/час)

  • Ежемесячные затраты на сверку снижены более чем на $220 000

  • Число post-mortem расследований по сбоям платежей сокращено на 73%

  • Паттерн принят как стандартная реализация идемпотентности в двух инженерных организациях, обрабатывающих 50 000+ транзакций в секунду

  • Независимо подтверждён инженерными командами в трёх отдельных организациях без общей кодовой базы

Заключение

Идемпотентность — это не галочка в чек-листе, а инвариант, который должен сохраняться при истечении TTL, при сценариях crash-recovery, при конкурентном выполнении и на каждой границе систем, через которую проходят ваши данные. Phantom Write Problem существует потому, что большинство реализаций закрывают лишь одно или два из этих измерений.

Паттерн Idempotency Barrier корректно закрывает все четыре: персистентность в БД ради корректности, переходы состояний в одной транзакции, атомарный «захват» для устранения гонок и распространение ключей с учётом границ.

В следующий раз, когда на code review вы увидите if (key_exists) return cached_response, спросите себя: что произойдёт, когда истечёт TTL, процесс упадёт посреди записи, два потока одновременно проверят ключ или даунстрим-потребитель Kafka обработает событие дважды?

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Комментарии (3)


  1. Akina
    04.06.2026 12:02

    Отдельные исправления складываются в единый паттерн — Idempotency Barrier — который одновременно закрывает все четыре режима отказа.

    • Устранено 99,98% инцидентов с дубликатами платежей на 14B+ транзакций в месяц (от пика 342/час до 0,07/час)

    То есть один-два дубля в день таки приключаются. А по какому сценарию? Какому-то неописанному пятому? Или всё же закрытие не абсолютно? Или вообще по этому моменту никакой информации нет?


  1. K-s2
    04.06.2026 12:02

    Вопрос по 2 режиму отказа -

    если у нас транзакция rollback, то почему payment вместе с балансом не роллбек ? это уже не ACID транзакция тогда, нам же нужна атомарность.

    Если же у нас вся бизнес логика полностью атомарна, то при считывание IN_PROGRESS можно смело начинать транзакцию заново, разве нет ?


    1. alexxisr
      04.06.2026 12:02

      я так понимаю - мы не знаем ролбекнулась транзакция или еще обрабатывается, если мы начнем новую, то есть шанс что старая доработает и будет дубль.