Всем привет, меня зовут Сергей Прощаев, и в этой статье расскажу про одну из самых неприятных ошибок платёжного бэкенда — двойное списание — и про идемпотентность платежа, которая от него защищает.

Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в OTUS.


Утро понедельника. В поддержке три тикета с одним текстом: «За один заказ с карты списали деньги дважды».

  • В базе один платёж со статусом PAID.

  • В реестре эквайера два списания.

  • Код писала опытная команда, ревью прошло с двумя апрувами, тесты зелёные.

Ниже метод оплаты, логи двух инцидентов и лог балансировщика.

Сможете ли вы за 10 минут понять, откуда взялся дубль в каждом случае, и выбрать исправление, которое закроет оба?

Рис. 1. Один заказ — два списания
Рис. 1. Один заказ — два списания

Условие задачи: платёжный сервис на Spring Boot, который списывает дважды

Окружение сервиса:

  • payment‑service на Java 25 и Spring Boot 4.1, три пода за балансировщиком;

  • в конфигурации включён @EnableResilientMethods, поэтому @Retryable действительно работает;

  • PostgreSQL, уровень изоляции по умолчанию READ COMMITTED;

  • мобильное приложение: таймаут 10 секунд, HTTP‑клиент сам повторяет запрос, если соединение оборвалось;

  • эквайер в пиковые часы отвечает от 3 до 8 секунд, read timeout нашего клиента — 5 секунд;

  • кнопка «Оплатить» на фронте блокируется после нажатия.

Метод оплаты, который прошёл ревью:

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final PaymentRepository paymentRepository;
    private final AcquiringClient acquiringClient;
    private final KafkaTemplate<String, OrderPaidEvent> kafkaTemplate;

    @Transactional
    @Retryable(includes = ResourceAccessException.class,
               maxRetries = 2, delay = 1000, multiplier = 2)
    public PaymentResponse pay(PayOrderRequest request) {
        // защита от повторной оплаты
        if (paymentRepository.existsByOrderIdAndStatus(
                request.orderId(), PaymentStatus.PAID)) {
            return PaymentResponse.alreadyPaid(request.orderId());
        }

        Payment payment = paymentRepository.save(
                Payment.newPending(request.orderId(), request.amount()));

        ChargeResult result = acquiringClient.charge(
                request.cardToken(), request.amount(), request.orderId());

        payment.markPaid(result.transactionId());

        kafkaTemplate.send("orders.paid", request.orderId().toString(),
                new OrderPaidEvent(request.orderId(), result.transactionId()));

        return PaymentResponse.paid(payment);
    }
}

Инцидент 1. Заказ 784512, логи сервиса:

19:42:03.118 INFO [pod-2,exec-17] PaymentService  charge start orderId=784512 amount=4990.00
19:42:08.121 WARN [pod-2,exec-17] AcquiringClient read timeout after 5000 ms orderId=784512
19:42:09.124 INFO [pod-2,exec-17] PaymentService  charge start orderId=784512 amount=4990.00
19:42:10.472 INFO [pod-2,exec-17] AcquiringClient charge ok orderId=784512 txId=A-99130551

Реестр эквайера по этому заказу: A-99130512 проведено в 19:42:08.9, A-99130551 проведено в 19:42:10.4.

Инцидент 2. Заказ 781077, неделей раньше.

Лог балансировщика:

19:07:41.497 lb POST /payments orderId=781077 device=ios-5F2A upstream=pod-1 client_conn=reset_after_send
19:07:41.531 lb POST /payments orderId=781077 device=ios-5F2A upstream=pod-3 client_conn=ok

Логи сервиса:

19:07:41.502 INFO [pod-1,exec-9]  PaymentService  charge start orderId=781077 amount=1250.00
19:07:41.536 INFO [pod-3,exec-22] PaymentService  charge start orderId=781077 amount=1250.00
19:07:43.114 INFO [pod-1,exec-9]  AcquiringClient charge ok orderId=781077 txId=A-98807310
19:07:43.290 INFO [pod-3,exec-22] AcquiringClient charge ok orderId=781077 txId=A-98807316

Попробуйте ответить сами

Вопрос 1. Что привело к дублю в каждом инциденте?

  • A. В обоих случаях гонка между подами.

  • B. В обоих случаях @Retryable на методе оплаты.

  • C. В первом — повтор списания после таймаута, во втором — повторный POST от клиента и гонка между подами.

  • D. В первом — медленный эквайер, во втором — двойной клик пользователя.

Вопрос 2. Какое исправление закроет оба сценария?

  • A. Поставить synchronized на метод pay.

  • B. Убрать @Retryable.

  • C. Взять Redis‑лок с фиксированным TTL по orderId.

  • D. Проверять не только PAID, а любой статус платежа по заказу.

  • E. Ключ идемпотентности на платёжное намерение, ограничение в базе и сверка неопределённых исходов.

Подсказка: в первом инциденте сравните время первого списания в реестре эквайера с моментом таймаута. Во втором посмотрите на поля device и client_conn в логе балансировщика.

Дальше идёт разбор. Если ответа ещё нет, самое время остановиться.

Почему очевидные исправления не спасают

A: synchronized. Подов три, а монитор живёт внутри одной JVM. Даже на одном инстансе он отпускается при выходе из метода, а транзакция коммитится позже, в прокси, и соседний поток не видит незакоммиченный платёж. Помню, как однажды мы полдня смотрели на такой «защищённый» метод и не понимали, откуда дубли на стенде с одним инстансом.

B: убрать @Retryable. Первый инцидент исчезнет, второй останется: там ретрая нет вовсе. И появится новая проблема: таймаут эквайера пробросит исключение, @Transactional откатит запись PENDING, а деньги к этому моменту уже могут быть списаны. В базе не останется следа платежа.

C: наивный Redis‑лок с фиксированным TTL. Лок на 5 секунд истекает посреди вызова эквайера, который отвечает 8, и второй запрос заходит. TTL в минуту — и при падении пода заказ залипнет на минуту. Корректная аренда требует продления, fencing и восстановления, но даже она не даёт идемпотентности на стороне эквайера и надёжно сохранённого состояния платежа.

D: проверять любой статус. Это по‑прежнему check‑then‑act. При READ COMMITTED соседний под не видит незакоммиченную вставку, окно гонки сужается, но не закрывается. Во втором инциденте между запросами 34 миллисекунды, этого хватает.

Ни один из вариантов A‑D не закрывает оба сценария. Правильные ответы: C на первый вопрос и E на второй.

Разбор: как таймаут и повтор запроса приводят к двойному списанию

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

  • Гипотеза 1: наш ретрай. Таймаут случился в 08.121, повторный старт — в 09.124, ровно через delay = 1000. А в реестре эквайера первое списание проведено в 08.9, уже после нашего таймаута. Подтверждено: эквайер деньги списал, мы решили, что нет, и повторили.

На рис. 2 этот сценарий показан целиком.

Рис. 2. Как таймаут и ретрай превращают один клик в два списания
Рис. 2. Как таймаут и ретрай превращают один клик в два списания

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

  • Гипотеза 2: двойной клик. Оба запроса пришли с одного устройства, но между ними 34 мс, а кнопка блокируется, так что человек здесь ни при чём. Логи балансировщика доказывают сам факт повторного POST сразу после reset_after_send. В условиях задачи такой повтор отправляет HTTP‑клиент приложения. Клик отклоняю, поэтому вариант D в первом вопросе неверен.

  • Гипотеза 3: гонка подов. Повтор ушёл на pod-3, пока pod-1 ещё ждал эквайера. Оба выполнили existsByOrderIdAndStatus, оба ничего не нашли. Подтверждено: проверка в коде — это чтение, а не гарантия.

В итоге два механизма возникновения дубля:

  • Инцидент 1: таймаут → исход неизвестен → @Retryable повторяет денежную операцию без ключа → второе списание.

  • Инцидент 2: повторный POST → у запроса нет ключа идемпотентности → два параллельных исполнения → в базе нет ограничения, и оба доходят до эквайера.

В коде есть ещё два дефекта, которые эти дубли не создали, но сделают следующий инцидент тяжелее.

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

К этому же относится порядок прокси: каждая попытка ретрая должна идти в собственной транзакции, а при @Retryable и @Transactional на одном методе это зависит от порядка advisors и с ходу не очевидно.

Второй дефект — kafkaTemplate.send отправляет событие даже тогда, когда транзакция затем откатится.

Из разбора следует модель, на которой держится решение.

Защита строится не вокруг повтора HTTP‑вызова, а вокруг идентичности операции, и слоёв у неё четыре:

  1. Граница идемпотентности «клиент → сервис»: стабильный ключ на одно платёжное намерение.

  2. Граница идемпотентности «сервис → эквайер»: ключ в запросе к провайдеру и понятный контракт повтора.

  3. Инвариант конкурентности в базе: уникальные ограничения и fencing вместо проверок в коде.

  4. Инвариант восстановления: сверка для всего, что застряло между внешним списанием и локальной записью результата.

Как защищаются команды, для которых платежи — основной продукт

Мне как‑то попалась статья инженеров Airbnb “Avoiding double payments in a distributed payments system”, и в ней есть почти все слои из списка выше.

Их требование: одна операция движения денег, вызванная сколько угодно раз, двигает деньги не больше одного раза. Для этого команда написала библиотеку идемпотентности Orpheus.

Каждый запрос делится на три фазы: запись намерения в базу, сетевой вызов и запись результата.

Правила жёсткие: никакой сети в фазах работы с базой и никакой базы во время сетевого вызова. На ключ запрос берёт аренду через блокировку строки, у аренды есть срок, и он должен быть длиннее таймаута вызова. Ошибки делятся на повторяемые и неповторяемые.

Самое поучительное место — дубль, о котором редко думают. Информацию об идемпотентности читали с реплики. Клиент корректно повторял запрос, но ответ первого ещё не доехал до реплики, сервис его не находил и проводил платёж повторно.

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

Вторая опорная реализация — Stripe. В документации по идемпотентным запросам описано, что сервис сохраняет код и тело ответа первого запроса по ключу, даже если это ошибка, сверяет параметры повтора с исходными, а ключи можно удалять не раньше чем через 24 часа.

В разделе про обработку ошибок есть правила, которые я переношу в любой платёжный сервис: после сетевой ошибки запрос повторяют с тем же ключом и теми же параметрами, результат запроса с ответом 500 Stripe рекомендует считать неопределённым, а ответ 429 может прийти ещё до слоя идемпотентности.

Со стандартом всё скромнее.

Заголовок Idempotency-Key давно стал де‑факто соглашением, но RFC так и не появился: последняя опубликованная версия черновика IETF, draft-07, истекла 18 апреля 2026 года, хотя работа над текстом в репозитории рабочей группы продолжается.

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

И самое важное. Ключ, переданный эквайеру, работает только тогда, когда известен контракт провайдера.

Прежде чем разрешать повтор после таймаута, я выясняю пять вещей:

  1. поддерживает ли провайдер ключи идемпотентности вообще;

  2. сколько он их хранит;

  3. в какой области ключ уникален: мерчант, аккаунт или эндпоинт;

  4. что он вернёт на повтор, пока первый запрос ещё выполняется;

  5. можно ли найти операцию по ключу после таймаута.

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

Как предотвратить двойное списание: идемпотентность платежа на Spring Boot

Мой вариант, который я обычно использую, укладывается в шесть шагов.

Сначала один термин. В учебном примере платёжное намерение совпадает с оплатой заказа, но в реальном домене у заказа бывают повторные попытки после отказа, доплаты и несколько способов оплаты.

Поэтому инвариант я формулирую так: у одного payment intent не может быть больше одной попытки, пока предыдущая не закончилась отказом, а успешно оплаченное намерение повторно оплатить нельзя.

Шаг 1. Схема ключей идемпотентности: область, аренда и срок хранения

Жизненный цикл ключа и статус платежа — две разные машины состояний. У ключа три состояния: IN_PROGRESS, AWAITING_RESULT и COMPLETED. У платежа — PENDING, UNKNOWN, PAID, DECLINED и FAILED.

Область ключа определяет сервер. Таблица ниже обслуживает только POST /payments, у возвратов и переводов свои пространства ключей.

Внутри операции ключ ограничен мерчантом, а мерчант берётся из аутентификации, а не из тела запроса. Ключ идемпотентности — идентификатор операции, а не секрет, и авторизацию на нём строить нельзя.

-- пространство ключей только для POST /payments
CREATE TABLE idempotency_keys (
    merchant_id      BIGINT       NOT NULL,
    idempotency_key  VARCHAR(64)  NOT NULL,
    request_hash     CHAR(64)     NOT NULL,
    status           VARCHAR(20)  NOT NULL,            -- IN_PROGRESS | AWAITING_RESULT | COMPLETED
    attempt          INT          NOT NULL DEFAULT 1,  -- fencing-токен текущего владельца
    lease_until      TIMESTAMPTZ  NOT NULL,
    expires_at       TIMESTAMPTZ  NOT NULL,            -- не раньше окна повторов клиента и эквайера
    response_code    INT,
    response_body    JSONB,
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT now(),
    PRIMARY KEY (merchant_id, idempotency_key)
);

-- поиск записей для восстановления
CREATE INDEX ix_idempotency_keys_recovery
    ON idempotency_keys (lease_until)
    WHERE status IN ('IN_PROGRESS', 'AWAITING_RESULT');

-- очистка без полного сканирования таблицы
CREATE INDEX ix_idempotency_keys_expiration
    ON idempotency_keys (expires_at)
    WHERE status = 'COMPLETED';

-- одна попытка на намерение до окончательного отказа; оплаченное намерение не оплачивается повторно
CREATE UNIQUE INDEX ux_payment_intent_single_attempt
    ON payments (payment_intent_id)
    WHERE status IN ('PENDING', 'UNKNOWN', 'PAID');

-- регулярная очистка пачками: только завершённые ключи с истёкшим сроком хранения
DELETE FROM idempotency_keys
 WHERE (merchant_id, idempotency_key) IN (
        SELECT merchant_id, idempotency_key
          FROM idempotency_keys
         WHERE status = 'COMPLETED' AND expires_at < now()
         LIMIT 10000);

Незавершённые ключи очистка не трогает: пока попытка может повториться или сверяться, ключ удалять нельзя.

Отпечаток запроса строится из канонической формы бизнес‑параметров. Форматирование JSON, порядок полей и технические поля не должны менять отпечаток, иначе честный повтор получит 422.

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

public record ClientKey(long merchantId, String value) {

    // ключ для эквайера тоже ограничен мерчантом
    public String providerKey() {
        return merchantId + ":" + value;
    }
}

final class RequestHasher {

    // только бизнес-поля в фиксированном порядке, сумма в минорных единицах;
    // каждое поле кодируется как «длина:значение», поэтому разделители не создают коллизий
    static String fingerprint(PayRequest r) {
        String canonical = Stream.of(
                        Long.toString(r.paymentIntentId()),
                        Long.toString(r.amountMinor()),
                        r.currency(),
                        r.paymentMethodId())
                .map(v -> v.length() + ":" + v)
                .collect(Collectors.joining());
        return HexFormat.of().formatHex(sha256(canonical.getBytes(StandardCharsets.UTF_8)));
    }
}

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

Шаг 2. Атомарный захват ключа в PostgreSQL и fencing‑токен

Кто первым вставил строку, тот и обрабатывает запрос. Если под умер, не освободив ключ, аренда истечёт и следующий повтор перехватит её, получив новый номер попытки. Здесь важная оговорка.

Аренда — механизм восстановления, а не гарантия эксклюзивности: процесс может встать на GC‑паузе или под нехваткой CPU дольше аренды и проснуться, когда ключ уже перехвачен.

Поэтому номер попытки работает как fencing‑токен: записать результат может только текущий владелец. Владение операцией хранит строка idempotency_keys, а Payment при финальной записи дополнительно защищён @Version.

public enum ClaimKind { FIRST, RECLAIMED, BUSY }

public record Claim(ClaimKind kind, int attempt) { }

@Repository
@RequiredArgsConstructor
public class IdempotencyKeyRepository {

    private final JdbcClient jdbc;
    private final JsonMapper json;

    public Claim tryClaim(ClientKey ck, String hash, Duration lease, Duration retention) {
        int inserted = jdbc.sql("""
                INSERT INTO idempotency_keys
                       (merchant_id, idempotency_key, request_hash, status, lease_until, expires_at)
                VALUES (:merchant, :key, :hash, 'IN_PROGRESS',
                        now() + make_interval(secs => :lease),
                        now() + make_interval(secs => :retention))
                ON CONFLICT (merchant_id, idempotency_key) DO NOTHING
                """)
                .param("merchant", ck.merchantId())
                .param("key", ck.value())
                .param("hash", hash)
                .param("lease", lease.toSeconds())
                .param("retention", retention.toSeconds())
                .update();
        if (inserted == 1) {
            return new Claim(ClaimKind.FIRST, 1);
        }

        // аренда истекла: забираем ключ и получаем новый fencing-токен
        return jdbc.sql("""
                UPDATE idempotency_keys
                   SET attempt = attempt + 1,
                       lease_until = now() + make_interval(secs => :lease)
                 WHERE merchant_id = :merchant AND idempotency_key = :key
                   AND request_hash = :hash
                   AND status = 'IN_PROGRESS'
                   AND lease_until < now()
                RETURNING attempt
                """)
                .param("merchant", ck.merchantId())
                .param("key", ck.value())
                .param("hash", hash)
                .param("lease", lease.toSeconds())
                .query(Integer.class)
                .optional()
                .map(attempt -> new Claim(ClaimKind.RECLAIMED, attempt))
                .orElse(new Claim(ClaimKind.BUSY, 0));
    }

    // сверка захватывает и IN_PROGRESS, и AWAITING_RESULT: один победитель на запись
    public Optional<Integer> claimForRecovery(ClientKey ck, Duration lease) {
        return jdbc.sql("""
                UPDATE idempotency_keys
                   SET attempt = attempt + 1,
                       lease_until = now() + make_interval(secs => :lease)
                 WHERE merchant_id = :merchant AND idempotency_key = :key
                   AND status IN ('IN_PROGRESS', 'AWAITING_RESULT')
                   AND lease_until < now()
                RETURNING attempt
                """)
                .param("merchant", ck.merchantId())
                .param("key", ck.value())
                .param("lease", lease.toSeconds())
                .query(Integer.class)
                .optional();
    }

    public PaymentResponse complete(ClientKey ck, int attempt, PaymentResponse response) {
        int updated = jdbc.sql("""
                UPDATE idempotency_keys
                   SET status = 'COMPLETED',
                       response_code = :code,
                       response_body = CAST(:body AS jsonb)
                 WHERE merchant_id = :merchant AND idempotency_key = :key
                   AND attempt = :attempt
                   AND status IN ('IN_PROGRESS', 'AWAITING_RESULT')
                """)
                .param("merchant", ck.merchantId())
                .param("key", ck.value())
                .param("attempt", attempt)
                .param("code", response.httpStatus())
                .param("body", json.writeValueAsString(response))
                .update();
        if (updated != 1) {
            throw new StaleAttemptException(ck, attempt);   // ключ перехвачен: транзакция откатится
        }
        return response;
    }

    public void awaitResult(ClientKey ck, int attempt, Duration grace) {
        int updated = jdbc.sql("""
                UPDATE idempotency_keys
                   SET status = 'AWAITING_RESULT',
                       lease_until = now() + make_interval(secs => :grace)
                 WHERE merchant_id = :merchant AND idempotency_key = :key
                   AND attempt = :attempt
                   AND status IN ('IN_PROGRESS', 'AWAITING_RESULT')
                """)
                .param("merchant", ck.merchantId())
                .param("key", ck.value())
                .param("attempt", attempt)
                .param("grace", grace.toSeconds())
                .update();
        if (updated != 1) {
            throw new StaleAttemptException(ck, attempt);
        }
    }
}

Ключ рождается на клиенте в момент нажатия «Оплатить», сохраняется до отправки и живёт до финального ответа.

Частая практическая ошибка — генерировать новый UUID в интерсепторе HTTP‑клиента на каждый вызов. Тогда повторный POST из второго инцидента получит новый ключ, и вся защита превратится в декорацию.

Инвариант: устаревший владелец не может завершить попытку, которую уже перехватил другой исполнитель.

Шаг 3. Платёж в три фазы без транзакции вокруг вызова эквайера

@Service
@RequiredArgsConstructor
public class PaymentService {

    static final Duration LEASE = Duration.ofSeconds(30);                 // восстановление, не эксклюзивность
    static final Duration RETENTION = Duration.ofDays(7);                 // не меньше окна повторов
    static final Duration RECONCILIATION_GRACE = Duration.ofSeconds(30);  // время эквайеру обновить статус

    private final TransactionTemplate tx;
    private final IdempotencyKeyRepository keys;
    private final PaymentRepository payments;
    private final PaymentAccessPolicy access;
    private final AcquiringClient acquiring;
    private final PaymentSettlement settlement;

    public PaymentResponse pay(ClientKey ck, PayRequest request) {
        // знание ключа не даёт права читать или менять операцию: доступ проверяется до повтора ответа
        access.requireCanPay(ck.merchantId(), request.paymentIntentId());

        String hash = RequestHasher.fingerprint(request);

        // Фаза 1: захват ключа и фиксация намерения. Короткая транзакция, без сети.
        Attempt attempt;
        try {
            attempt = tx.execute(status -> {
                Claim claim = keys.tryClaim(ck, hash, LEASE, RETENTION);
                return switch (claim.kind()) {
                    case FIRST -> Attempt.first(claim.attempt(),
                            payments.saveAndFlush(Payment.newPending(ck, request)));
                    case RECLAIMED -> Attempt.reclaimed(claim.attempt(),
                            payments.findByClientKey(ck).orElseThrow());
                    case BUSY -> null;
                };
            });
        } catch (DataIntegrityViolationException e) {
            if (Constraints.isViolated(e, "ux_payment_intent_single_attempt")) {
                throw new PaymentIntentAttemptExistsException(request.paymentIntentId());   // 409
            }
            throw e;   // FK, NOT NULL, CHECK: это дефект, а не конкурентный повтор
        }

        if (attempt == null) {
            return replayOrReject(ck, hash);
        }

        // Фаза 2: сеть без транзакции, соединение из пула свободно.
        ChargeOutcome outcome = callAcquirer(attempt, request, ck);

        // Фаза 3: результат записывает только текущий владелец попытки.
        // StaleAttemptException уходит клиенту как 409: владение передано другому исполнителю.
        return settlement.apply(attempt.paymentId(), ck, attempt.number(), outcome);
    }

    private ChargeOutcome callAcquirer(Attempt attempt, PayRequest request, ClientKey ck) {
        try {
            if (attempt.reclaimed()) {
                // прежний владелец мог успеть списать деньги: сначала статус, потом одна попытка
                return acquiring.findByIdempotencyKey(ck)
                        .orElseGet(() -> acquiring.chargeOnce(request, ck));
            }
            return acquiring.charge(request, ck);
        } catch (RestClientException e) {
            return new ChargeOutcome.Unknown();   // таймаут или обрыв: не знаем, было ли списание
        }
    }

    private PaymentResponse replayOrReject(ClientKey ck, String hash) {
        IdempotencyRecord record = keys.find(ck).orElseThrow();
        if (!record.requestHash().equals(hash)) {
            throw new IdempotencyKeyReuseException(ck);                          // 422
        }
        return switch (record.status()) {
            case IN_PROGRESS -> throw new PaymentInProgressException(ck);        // 409 + Retry-After
            case AWAITING_RESULT -> PaymentResponse.processing(
                    payments.findByClientKey(ck).orElseThrow());                 // 202, текущее состояние
            case COMPLETED -> record.storedResponse();                           // тот же финальный ответ
        };
    }
}

@Component
@RequiredArgsConstructor
class PaymentSettlement {

    private final PaymentRepository payments;   // Payment содержит @Version
    private final IdempotencyKeyRepository keys;
    private final ApplicationEventPublisher events;

    @Transactional
    public PaymentResponse apply(long paymentId, ClientKey ck, int attempt, ChargeOutcome outcome) {
        Payment p = payments.findById(paymentId).orElseThrow();
        return switch (outcome) {
            case ChargeOutcome.Success s -> {
                p.markPaid(s.transactionId());
                events.publishEvent(new PaymentSucceeded(p.getPaymentIntentId(), s.transactionId()));
                yield keys.complete(ck, attempt, PaymentResponse.paid(p));
            }
            case ChargeOutcome.Declined d -> {
                p.markDeclined(d.reason());
                yield keys.complete(ck, attempt, PaymentResponse.declined(p));
            }
            case ChargeOutcome.Failed f -> {
                p.markFailed(f.reason());
                yield keys.complete(ck, attempt, PaymentResponse.failed(p));
            }
            case ChargeOutcome.Unknown u -> {
                p.markUnknown();
                keys.awaitResult(ck, attempt, PaymentService.RECONCILIATION_GRACE);
                yield PaymentResponse.processing(p);   // 202
            }
        };
    }
}

final class Constraints {

    // Hibernate извлекает имя нарушенного ограничения из ответа PostgreSQL
    static boolean isViolated(Throwable e, String constraintName) {
        for (Throwable t = e; t != null; t = t.getCause()) {
            if (t instanceof org.hibernate.exception.ConstraintViolationException cve) {
                return constraintName.equalsIgnoreCase(cve.getConstraintName());
            }
        }
        return false;
    }
}

Контракт повторов нужно явно описать в документации для клиентов. У меня он такой:

Ситуация

HTTP

Что это значит для клиента

Первый запрос с ключом

200 или 202

Операция завершена или её итог ещё выясняется

Ключ сейчас обрабатывает другой исполнитель

409 + Retry‑After

Это не отказ платежа: повторите запрос с тем же ключом позже

Владение передано другому исполнителю

409

Это тоже не отказ: итог зафиксирует новый владелец, новое намерение создавать не нужно

Итог ещё не известен

202

Возвращается текущее состояние того же платежа

Операция завершена

Сохранённый код

Повтор сохранённого финального ответа

Тот же ключ с другими данными

422

Ошибка клиента: ключ использован для другой операции

Строка про 202 — осознанное отличие от Stripe. Там сохранённый ответ возвращается как есть, а у меня повтор получает текущее состояние платежа.

Инвариант: 409 никогда не означает отказ платежа. Это сигнал повторить запрос с тем же ключом, а не создавать новое намерение.

Шаг 4. Классификация исходов и ретрай эквайера с бюджетом времени

Прежде чем что‑то повторять, нужно договориться, что означает каждый исход.

Классифицировать его стоит по смыслу, а не по классу HTTP‑кода, тогда модель переносится между эквайерами.

Смысл исхода

Пример в контракте провайдера

Статус платежа

Что дальше

Окончательный бизнес‑отказ

402, отказ банка в теле ответа

DECLINED

Новая попытка только с новым ключом

Окончательная ошибка запроса

400, 401, 403, 422

FAILED

Исправить запрос, новый ключ

Неоднозначное состояние у провайдера

409, 429, 5xx

UNKNOWN

Сверка

Транспортный сбой

Таймаут, обрыв соединения

UNKNOWN

Один повтор с тем же ключом в пределах бюджета, затем сверка

Маппинг HTTP‑ответов на доменные исходы определяется контрактом конкретного провайдера, таблица — пример такого контракта.

Второе правило — бюджет времени.

Десять секунд клиента — это не десять секунд сервера: часть уходит на сеть, балансировщик и фазы работы с базой. Поэтому бюджет на вызов эквайера я задаю в 8 секунд и считаю худший случай с учётом и connect, и read timeout.

Одна попытка — это 0,5 + 3 = 3,5 секунды. Обычная ветка: 3,5 + пауза до 0,4 + 3,5 = 7,4 секунды. Ветка после перехвата ключа: запрос статуса 3,5 + одна попытка 3,5 = 7 секунд.

Важная оговорка: @Retryable задаёт число повторов и паузы, но сам не ограничивает общее время операции. Таймауты подобраны так, чтобы расчётный худший случай укладывался в бюджет, а в production общий дедлайн операции нужно контролировать отдельно.

Если эквайер в пике отвечает дольше, платёж уйдёт в UNKNOWN и закроется сверкой. Это честнее, чем ретрай, который формально идемпотентен, но заканчивается уже после того, как клиент перестал ждать.

@Configuration
@EnableResilientMethods
class ResilienceConfig {
}

@Component
@RequiredArgsConstructor
public class AcquiringClient {

    private final RestClient acquiringRestClient;   // connect timeout 500 мс, read timeout 3 с
    private final AcquirerOutcomeMapper mapper;

    // Расчётный худший случай: 3,5 с + пауза до 0,4 с + 3,5 с = 7,4 с при бюджете 8 с.
    // maxRetries = 1 — это первая попытка и не больше одного повтора.
    // Повтор допустим только потому, что эквайер получает тот же ключ.
    @Retryable(includes = ResourceAccessException.class,
               maxRetries = 1, delay = 300, jitter = 100, maxDelay = 400)
    public ChargeOutcome charge(PayRequest request, ClientKey ck) {
        return send(request, ck);
    }

    // для перехваченного ключа и сверки: одна попытка без ретрая
    public ChargeOutcome chargeOnce(PayRequest request, ClientKey ck) {
        return send(request, ck);
    }

    public Optional<ChargeOutcome> findByIdempotencyKey(ClientKey ck) {
        return acquiringRestClient.get()
                .uri("/charges?idempotency_key={key}", ck.providerKey())
                .exchange((req, res) -> {
                    if (res.getStatusCode().value() == 404) {
                        return Optional.<ChargeOutcome>empty();
                    }
                    if (res.getStatusCode().isError()) {
                        throw new RestClientException("Acquirer status API: " + res.getStatusCode());
                    }
                    return Optional.of(res.bodyTo(ChargeResponse.class).toOutcome());
                });
    }

    private ChargeOutcome send(PayRequest request, ClientKey ck) {
        try {
            return acquiringRestClient.post()
                    .uri("/charges")
                    .header("Idempotency-Key", ck.providerKey())
                    .body(ChargeRequest.from(request))
                    .retrieve()
                    .body(ChargeResponse.class)
                    .toOutcome();
        } catch (HttpStatusCodeException e) {
            return mapper.fromHttpStatus(e);   // транспортные сбои летят дальше и попадают в ретрай
        }
    }
}

// Маппинг определяется контрактом конкретного провайдера.
@Component
class AcquirerOutcomeMapper {

    ChargeOutcome fromHttpStatus(HttpStatusCodeException e) {
        return switch (e.getStatusCode().value()) {
            case 402 -> new ChargeOutcome.Declined(e.getResponseBodyAsString());   // окончательный бизнес-отказ
            case 400, 401, 403, 422 -> new ChargeOutcome.Failed("request rejected: " + e.getStatusCode());
            default -> new ChargeOutcome.Unknown();   // 409, 429, 5xx: исход не доказан
        };
    }
}

Инвариант: неизвестный исход никогда не превращается в слепой повтор — только в повтор с тем же ключом в пределах бюджета или в сверку.

Шаг 5. Событие об оплате через outbox вместо Kafka в транзакции

В исходном коде kafkaTemplate.send стоял прямо в транзакции.

Я публикую событие через ApplicationEventPublisher. Spring Modulith записывает публикацию в журнал в рамках той же бизнес‑транзакции, а доставку в Kafka выполняет отдельный механизм экстернализации.

Здесь важно не упрощать: документация Spring Modulith честно предупреждает, что во встроенной экстернализации нет части возможностей настоящего outbox.

Полноценный режим outbox появился в Spring Modulith 2.1 через Namastack или JobRunr и включается отдельным свойством.

@Externalized("payments.succeeded::#{#this.paymentIntentId()}")
public record PaymentSucceeded(long paymentIntentId, String transactionId) {
}
# зависимости: spring-modulith-starter-jdbc, spring-modulith-events-kafka, spring-modulith-starter-namastack
spring.modulith.events.externalization.mode=outbox

Потребитель события должен быть идемпотентным: повторная доставка одного и того же события возможна.

Если хотите лучше понять, как Kafka используется для обмена событиями между сервисами и что важно учитывать при работе с сообщениями, можно продолжить тему на открытом уроке.

Шаг 6. Сверка платежей с истёкшей арендой

Сверка подбирает платежи в статусах PENDING и UNKNOWN, у которых истекла аренда ключа.

Причины у них разные. PENDING означает, что потеряно локальное состояние владельца: под упал между вызовом эквайера и фазой 3, и сверка начинается сразу после истечения аренды. UNKNOWN означает, что внешний вызов завершился с неоднозначным результатом, и сверка начинается после grace‑периода в 30 секунд, чтобы эквайер успел обновить статус.

Каждую запись сверка сначала захватывает тем же атомарным UPDATE по строке idempotency_keys, поэтому провайдера опрашивает только один под.

Одна оговорка про границы гарантий. Fencing защищает наше локальное состояние, но не движение денег у провайдера.

Повторное списание в сверке безопасно ровно настолько, насколько провайдер выполняет свой контракт идемпотентности.

@Component
@RequiredArgsConstructor
class StuckPaymentsReconciler {

    private final PaymentRepository payments;
    private final IdempotencyKeyRepository keys;
    private final AcquiringClient acquiring;
    private final PaymentSettlement settlement;
    private final ManualReviewQueue manualReview;

    @Scheduled(fixedDelay = 15_000)
    void reconcile() {
        // PENDING и UNKNOWN, у которых lease_until < now()
        for (StuckPayment s : payments.findRecoverable(100)) {
            Optional<Integer> attempt = keys.claimForRecovery(s.clientKey(), PaymentService.LEASE);
            if (attempt.isEmpty()) {
                continue;   // запись уже взял другой под или повторный запрос клиента
            }
            try {
                ChargeOutcome outcome = acquiring.findByIdempotencyKey(s.clientKey())
                        .orElseGet(() -> s.providerKeyStillValid()
                                // тот же ключ: повтор безопасен при выполнении контракта идемпотентности провайдера
                                ? acquiring.chargeOnce(s.request(), s.clientKey())
                                : new ChargeOutcome.Unknown());
                settlement.apply(s.paymentId(), s.clientKey(), attempt.get(), outcome);
                if (outcome instanceof ChargeOutcome.Unknown && !s.providerKeyStillValid()) {
                    manualReview.enqueue(s);   // окно провайдера закрыто: решает человек, а не автоотмена
                }
            } catch (RestClientException e) {
                // провайдер недоступен: аренда истечёт, запись возьмёт следующий проход
            } catch (StaleAttemptException | OptimisticLockingFailureException e) {
                // результат уже записал более новый владелец
            }
        }
    }
}

Сверка в реальной системе — почти отдельная подсистема. Статусный API провайдера может не искать по ключу, отвечать 404 до синхронизации или возвращать «processing». Поэтому у меня три правила:

  • сверка работает через захват аренды и optimistic lock (@Version на Payment), чтобы не перезаписать результат, который параллельно записал повторный запрос;

  • если провайдер не умеет искать по ключу, ищем по своему идентификатору, переданному ему в метаданных запроса;

  • всё, что не решилось до закрытия окна провайдера, уходит в очередь ручного разбора, а не автоматически в отказ.

Инвариант: когда контракт провайдера перестал действовать, сверка не создаёт новое списание, а передаёт решение человеку.

Как шесть шагов складываются в один поток, показано на рис. 3.

Рис. 3. Идемпотентная оплата: три фазы, аренда с fencing и сверка
Рис. 3. Идемпотентная оплата: три фазы, аренда с fencing и сверка

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

Как проверить: пять тестов для четырёх слоёв

Первый тест проверяет слои 1 и 3 на настоящем PostgreSQL в Testcontainers. Вместо Thread.sleep здесь CyclicBarrier: все двадцать потоков стартуют одновременно, и результат не зависит от удачи планировщика. Эквайер замокан, поэтому тест доказывает ровно одно: наш сервис делает не больше одного вызова на ключ.

@SpringBootTest
@Testcontainers
class PaymentConcurrencyTest {

    private static final long PAYMENT_INTENT_ID = 784512L;

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");

    @Autowired PaymentService paymentService;
    @Autowired PaymentRepository payments;
    @MockitoBean AcquiringClient acquiringClient;

    @Test
    void concurrentRequestsWithSameKeyCreateOnePaymentAndOneCharge() throws Exception {
        var request = new PayRequest(PAYMENT_INTENT_ID, 499_000L, "RUB", "pm_test");
        var ck = new ClientKey(42L, UUID.randomUUID().toString());
        when(acquiringClient.charge(any(), eq(ck))).thenReturn(new ChargeOutcome.Success("A-1"));

        int threads = 20;
        var barrier = new CyclicBarrier(threads);
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var futures = IntStream.range(0, threads)
                    .mapToObj(i -> executor.submit(() -> {
                        barrier.await();   // все потоки стартуют одновременно
                        try {
                            return paymentService.pay(ck, request);
                        } catch (PaymentInProgressException e) {
                            return null;   // 409 для конкурентных повторов — ожидаемо
                        }
                    }))
                    .toList();
            for (var f : futures) {
                f.get(10, TimeUnit.SECONDS);
            }
        }

        verify(acquiringClient, times(1)).charge(any(), eq(ck));
        assertThat(payments.countByPaymentIntentId(PAYMENT_INTENT_ID)).isEqualTo(1);
    }
}

Второй тест проверяет слой 2 с настоящим AcquiringClient и его ретраем, а эквайер эмулирует WireMock. Первый ответ приходит позже read timeout, второй — сразу. Тест доказывает, что повтор уходит с тем же ключом. А то, что эквайер не проведёт второе списание, — уже его контракт, и проверяется он в песочнице провайдера, а не у нас в CI.

@SpringBootTest
class AcquiringRetryTest {

    @RegisterExtension
    static WireMockExtension acquirer = WireMockExtension.newInstance()
            .options(wireMockConfig().dynamicPort())
            .build();

    @DynamicPropertySource
    static void acquirerUrl(DynamicPropertyRegistry registry) {
        registry.add("acquiring.base-url", acquirer::baseUrl);
    }

    @Autowired AcquiringClient acquiringClient;

    @Test
    void readTimeoutIsRetriedWithTheSameIdempotencyKey() {
        acquirer.stubFor(post("/charges").inScenario("slow-acquirer")
                .whenScenarioStateIs(STARTED)
                .willReturn(okJson(APPROVED_JSON).withFixedDelay(5_000))   // дольше read timeout 3 с
                .willSetStateTo("fast"));
        acquirer.stubFor(post("/charges").inScenario("slow-acquirer")
                .whenScenarioStateIs("fast")
                .willReturn(okJson(APPROVED_JSON)));

        var ck = new ClientKey(42L, UUID.randomUUID().toString());
        acquiringClient.charge(TEST_REQUEST, ck);

        acquirer.verify(2, postRequestedFor(urlEqualTo("/charges"))
                .withHeader("Idempotency-Key", equalTo(ck.providerKey())));
    }
}

Третий тест проверяет слой 4 — ту самую дыру между успешным списанием и фазой 3. Состояние после падения пода создаёт фикстура, а эквайер сообщает, что списание по ключу есть.

@Test
void stuckPendingAfterCrashIsSettledByReconciliation() {
    // «под умер между фазами 2 и 3»: у эквайера списание есть, у нас PENDING с истёкшей арендой
    var ck = fixtures.stuckPendingPayment(PAYMENT_INTENT_ID);
    acquirer.stubFor(get(urlPathEqualTo("/charges"))
            .withQueryParam("idempotency_key", equalTo(ck.providerKey()))
            .willReturn(okJson(APPROVED_JSON)));

    reconciler.reconcile();

    assertThat(payments.findByClientKey(ck)).get()
            .extracting(Payment::getStatus).isEqualTo(PaymentStatus.PAID);
    assertThat(keys.find(ck)).get()
            .extracting(IdempotencyRecord::status).isEqualTo(KeyStatus.COMPLETED);
}

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

@Test
void recoveryIsClaimedByExactlyOneWorker() throws Exception {
    var ck = fixtures.stuckPendingPayment(PAYMENT_INTENT_ID);
    acquirer.stubFor(get(urlPathEqualTo("/charges"))
            .withQueryParam("idempotency_key", equalTo(ck.providerKey()))
            .willReturn(okJson(APPROVED_JSON).withFixedDelay(500)));

    var barrier = new CyclicBarrier(3);
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var futures = List.of(
                executor.submit(() -> { barrier.await(); reconciler.reconcile(); return null; }),
                executor.submit(() -> { barrier.await(); reconciler.reconcile(); return null; }),
                executor.submit(() -> { barrier.await(); return safePay(ck, fixtures.requestFor(ck)); }));
        for (var f : futures) {
            f.get(10, TimeUnit.SECONDS);
        }
    }

    acquirer.verify(1, getRequestedFor(urlPathEqualTo("/charges")));   // статус спросил один владелец
    assertThat(payments.findByClientKey(ck)).get()
            .extracting(Payment::getStatus).isEqualTo(PaymentStatus.PAID);
}

Пятый тест проверяет границу основной гарантии: что происходит, когда контракт провайдера уже не действует. Окно хранения ключа у эквайера истекло, найти операцию по ключу нельзя. Сверка не должна отправлять новое списание вслепую — платёж остаётся UNKNOWN и уходит на ручной разбор.

@Test
void expiredProviderKeyWindowGoesToManualReviewInsteadOfNewCharge() {
    var ck = fixtures.stuckUnknownPaymentWithExpiredProviderKey(PAYMENT_INTENT_ID);
    acquirer.stubFor(get(urlPathEqualTo("/charges"))
            .withQueryParam("idempotency_key", equalTo(ck.providerKey()))
            .willReturn(notFound()));

    reconciler.reconcile();

    acquirer.verify(0, postRequestedFor(urlEqualTo("/charges")));   // никакого нового списания вслепую
    assertThat(manualReview.contains(ck)).isTrue();
    assertThat(payments.findByClientKey(ck)).get()
            .extracting(Payment::getStatus).isEqualTo(PaymentStatus.UNKNOWN);
}

Что ещё нужно в production: наблюдаемость

Всё описанное выше бесполезно, если во время инцидента нельзя быстро понять, в каком состоянии платёж и кто им владеет.

Минимальный набор полей в структурированных логах: merchant_id, payment_intent_id, хэш ключа идемпотентности, attempt, статус платежа, идентификатор транзакции и запроса у провайдера, причина попадания в сверку.

Сам ключ целиком я в логи не пишу, только хэш или усечённый отпечаток.

Метрики, на которые я ставлю алерты:

  • число платежей в UNKNOWN и возраст самого старого из них;

  • задержка сверки от истечения аренды до финального статуса;

  • число перехватов ключа и число StaleAttemptException;

  • размер очереди ручного разбора.

Рост перехватов и устаревших попыток обычно первым показывает, что аренда стала короче реального времени обработки.

Где это решение не сработает

  • Эквайер не поддерживает ключи идемпотентности. Ретрай после таймаута запрещён. Остаются статус UNKNOWN и сверка по собственному идентификатору в метаданных.

  • Ключи читаются с реплики. Ровно тот сценарий, на котором споткнулся Airbnb. Захват и чтение ключа — только с мастера.

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

  • Сроки хранения ключей не согласованы. Если клиент повторяет запрос через сутки, а ключ у нас или у провайдера живёт меньше, защита молча исчезает.

  • Несколько внешних вызовов в одной операции. Авторизация, списание и фискальный чек не укладываются в три фазы. Нужна машина состояний, где каждый переход — отдельный идемпотентный шаг.

Итог: какой навык проверяла задача

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

Сервис проверяет доступ, атомарно захватывает ключ в базе с арендой и fencing‑токеном, идёт к эквайеру вне транзакции и в пределах бюджета времени, а результат записывает только текущий владелец попытки.

Неопределённый исход становится статусом UNKNOWN и уходит в сверку, а повтор внешнего вызова допустим только при согласованном контракте идемпотентности у провайдера. Такая система не обещает «ровно один раз».

Она обещает доставку «хотя бы один раз», идемпотентность и сверку, а в сумме — не более одного движения денег на одно платёжное намерение, пока провайдер выполняет свой контракт.

Задача проверяла не знание аннотаций Spring, а умение смотреть на операцию с деньгами как на распределённую систему: перечислить всех, кто может повторить запрос, проверить каждую гипотезу по логам, отличить «ошибку» от «неизвестности» и понимать, что гарантию дают ограничения в базе, fencing и сверка, а не проверка в коде.

Если на первый вопрос вы ответили C, а на второй E и сами назвали хотя бы три слоя из четырёх, этот код не прошёл бы ваше ревью.

Таймауты, повторные запросы и конкурентное выполнение неизбежны. Важно спроектировать систему так, чтобы они не превращались в двойные списания, потерянные состояния и сбои в работе сервиса.

Разобраться глубже в проектировании устойчивых серверных систем можно на бесплатных открытых уроках:

  • 22 октября, 19:00. «Основы проектирования бизнес‑логики в микросервисной архитектуре». Записаться

  • 22 октября, 20:00. «Spring Boot под нагрузкой: почему сервис работает локально и падает в рабочей среде». Записаться

Весь список вебинаров октября собрали в дайджесте.