Всем привет, меня зовут Сергей Прощаев, и в этой статье расскажу про одну из самых неприятных ошибок платёжного бэкенда — двойное списание — и про идемпотентность платежа, которая от него защищает.
Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в OTUS.
Утро понедельника. В поддержке три тикета с одним текстом: «За один заказ с карты списали деньги дважды».
В базе один платёж со статусом PAID.
В реестре эквайера два списания.
Код писала опытная команда, ревью прошло с двумя апрувами, тесты зелёные.
Ниже метод оплаты, логи двух инцидентов и лог балансировщика.
Сможете ли вы за 10 минут понять, откуда взялся дубль в каждом случае, и выбрать исправление, которое закроет оба?

Условие задачи: платёжный сервис на 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: двойной клик. Оба запроса пришли с одного устройства, но между ними 34 мс, а кнопка блокируется, так что человек здесь ни при чём. Логи балансировщика доказывают сам факт повторного POST сразу после
reset_after_send. В условиях задачи такой повтор отправляет HTTP‑клиент приложения. Клик отклоняю, поэтому вариант D в первом вопросе неверен.Гипотеза 3: гонка подов. Повтор ушёл на pod-3, пока pod-1 ещё ждал эквайера. Оба выполнили
existsByOrderIdAndStatus, оба ничего не нашли. Подтверждено: проверка в коде — это чтение, а не гарантия.
В итоге два механизма возникновения дубля:
Инцидент 1: таймаут → исход неизвестен →
@Retryableповторяет денежную операцию без ключа → второе списание.Инцидент 2: повторный POST → у запроса нет ключа идемпотентности → два параллельных исполнения → в базе нет ограничения, и оба доходят до эквайера.
В коде есть ещё два дефекта, которые эти дубли не создали, но сделают следующий инцидент тяжелее.
Первый — внешний вызов внутри транзакции. Соединение из пула занято всё время ожидания эквайера, под нагрузкой пул исчерпывается, растут таймауты и повторы, а при откате исчезает запись о платеже, по которому деньги уже могли уйти.
К этому же относится порядок прокси: каждая попытка ретрая должна идти в собственной транзакции, а при @Retryable и @Transactional на одном методе это зависит от порядка advisors и с ходу не очевидно.
Второй дефект — kafkaTemplate.send отправляет событие даже тогда, когда транзакция затем откатится.
Из разбора следует модель, на которой держится решение.
Защита строится не вокруг повтора HTTP‑вызова, а вокруг идентичности операции, и слоёв у неё четыре:
Граница идемпотентности «клиент → сервис»: стабильный ключ на одно платёжное намерение.
Граница идемпотентности «сервис → эквайер»: ключ в запросе к провайдеру и понятный контракт повтора.
Инвариант конкурентности в базе: уникальные ограничения и fencing вместо проверок в коде.
Инвариант восстановления: сверка для всего, что застряло между внешним списанием и локальной записью результата.
Как защищаются команды, для которых платежи — основной продукт
Мне как‑то попалась статья инженеров Airbnb “Avoiding double payments in a distributed payments system”, и в ней есть почти все слои из списка выше.
Их требование: одна операция движения денег, вызванная сколько угодно раз, двигает деньги не больше одного раза. Для этого команда написала библиотеку идемпотентности Orpheus.
Каждый запрос делится на три фазы: запись намерения в базу, сетевой вызов и запись результата.
Правила жёсткие: никакой сети в фазах работы с базой и никакой базы во время сетевого вызова. На ключ запрос берёт аренду через блокировку строки, у аренды есть срок, и он должен быть длиннее таймаута вызова. Ошибки делятся на повторяемые и неповторяемые.
Самое поучительное место — дубль, о котором редко думают. Информацию об идемпотентности читали с реплики. Клиент корректно повторял запрос, но ответ первого ещё не доехал до реплики, сервис его не находил и проводил платёж повторно.
Решение — читать и писать ключи только в мастер, а масштабироваться шардированием по самому ключу. По данным авторов, после запуска фреймворка консистентность платежей достигла пяти девяток, хотя годовой объём платежей за это время удвоился.
Вторая опорная реализация — Stripe. В документации по идемпотентным запросам описано, что сервис сохраняет код и тело ответа первого запроса по ключу, даже если это ошибка, сверяет параметры повтора с исходными, а ключи можно удалять не раньше чем через 24 часа.
В разделе про обработку ошибок есть правила, которые я переношу в любой платёжный сервис: после сетевой ошибки запрос повторяют с тем же ключом и теми же параметрами, результат запроса с ответом 500 Stripe рекомендует считать неопределённым, а ответ 429 может прийти ещё до слоя идемпотентности.
Со стандартом всё скромнее.
Заголовок Idempotency-Key давно стал де‑факто соглашением, но RFC так и не появился: последняя опубликованная версия черновика IETF, draft-07, истекла 18 апреля 2026 года, хотя работа над текстом в репозитории рабочей группы продолжается.
В разделе о безопасности черновик рекомендует хранить уникальный составной ключ поиска — например, ключ клиента вместе с атрибутами клиента, известными только серверу. А политику срока хранения ключей сервер должен определить и опубликовать в документации.
И самое важное. Ключ, переданный эквайеру, работает только тогда, когда известен контракт провайдера.
Прежде чем разрешать повтор после таймаута, я выясняю пять вещей:
поддерживает ли провайдер ключи идемпотентности вообще;
сколько он их хранит;
в какой области ключ уникален: мерчант, аккаунт или эндпоинт;
что он вернёт на повтор, пока первый запрос ещё выполняется;
можно ли найти операцию по ключу после таймаута.
Локальная идемпотентность и идемпотентность провайдера — два разных контракта, и их нужно согласовать. Наш ключ должен жить не меньше, чем клиент может повторять запрос, а ключ у провайдера — не меньше, чем длятся наши повторы и сверка.
Как предотвратить двойное списание: идемпотентность платежа на 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.

Главная мысль схемы: у каждого пути есть конечная точка, и ни один не заканчивается «повторим вслепую». Повтор получает сохранённый ответ или текущее состояние, конкурент получает 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 под нагрузкой: почему сервис работает локально и падает в рабочей среде». Записаться
Весь список вебинаров октября собрали в дайджесте.