Два клика — одна оплата: Форсаж вокруг платежного шлюза

Как‑то раз мне пришлось переносить с монолитного легаси в новенький микросервис интеграцию с платежным провайдером.
Изучив старый код, я понял, что так дело не пойдет. Два клика по «Оплатить» — два запроса в провайдер. Два Check — два финансовых документа по одной оплате. Два Cancel — два Reverse. В монолите это еще как‑то жило, а в микросервисе, который запускается в двух репликах, локальный SemaphoreSlim вообще перестал что‑либо гарантировать.
Пришлось переделывать конкурентный доступ целиком: от Claim/Register до фонового Sweep Job. По ходу дела это стало похоже на «Форсаж» — две машины на одной стартовой линии, а финишировать должна одна.
Ниже — что именно ломалось и почему для Create, Check и Cancel понадобились разные механизмы.
Наш гараж
Наши инструменты: .NET с PostgreSQL и внешний платежный шлюз.
Три операции:
Create — выпустить машину на трассу; создать платежную сессию и зарегистрировать ее у провайдера; Check — довести машину до финиша; проверить статус платежа и применить результат; Cancel — развернуть машину; отменить платеж через провайдера.
Трасса платежной сессии:

На схеме только переходы, важные для гонок. Общие терминальные состояния описаны отдельно:
-> Failed— при ошибке на любом шаге, кромеCompleted;
-> Canceling -> Canceled— после подтвержденияReverseу провайдера, кромеCompleted.Из
FailedиCanceledсессия уже не выходит.
Правила заезда:
Только одна активная машина на трассе заказа;
Если клиент повторяет запрос с теми же параметрами, возвращаем 200 или 202;
Если параметры отличаются, а предыдущая машина еще едет, то 409.
С трассой определились, время все ломать!
Этап 1. Press W and Hold
Первая версия Create выглядит максимально просто:
//Пробуем получить активную сессию
var active = await repository
.FindActiveByOrderIdAsync(orderId, ctx);
//Проверяем, что активная есть
if (active is not null)
return active;
//Если активной нет, то регистрируем новую
var payment = new PaymentSession(orderId, amount);
await repository.AddAsync(payment, ctx);
await uow.SaveChangesAsync(ctx);
var result = await gateway.RegisterAsync(payment, ctx);
payment.SetExternalId(result.PaymentId);
await repository.UpdateAsync(payment, ctx);
await uow.SaveChangesAsync(ctx);
return payment;В обычной последовательной обработке схема работает. Но что происходит, когда два запроса приходят одновременно?
У нас есть два водителя — Request A и Request B. Каждый уверен, что именно он должен начать заезд.
Время | Request A | Request B |
T1 | FindActiveByOrderIdAsync → null | |
T2 | FindActiveByOrderIdAsync → null | |
T3 | AddAsync(A) | |
T4 | AddAsync(B) | |
T5 | SaveChangesAsync(A) | |
T6 | SaveChangesAsync(B) | |
T7 | RegisterAsync(A) | |
T8 | RegisterAsync(B) | |
T9 | ExternalId = 1 | |
T10 | ExternalId = 2 | |
T11 | SaveChangesAsync(A) | |
T12 | SaveChangesAsync(B) |
Оба водителя посмотрели на трассу. Оба увидели, что она пуста. Оба нажали W. На трассе оказались две машины. При этом никакой ошибки в отдельном запросе нет: каждый выполнил проверку и создал запись в платежном провайдере. Обе машины получили разрешение на старт, хотя заказ должен был получить только одну активную сессию. Мы рассчитывали, что последовательность SELECT -> INSERT выполнится как единое неделимое действие, хотя такого обещания никто не давал.
Заблокировать кнопку на фронте можно, но это косметика. Повторная отправка HTTP, несколько вкладок, клиентские retry и конкурентные запросы других источников — все они пройдут мимо кнопки.
Этап 2. Светофор на всю гонку
Первое решение — поставить блокировку. Допустим, используем SemaphoreSlim с ключом заказа.
private static readonly ConcurrentDictionary<long, SemaphoreSlim> locks = new();
public async Task<PaymentSession> CreateAsync(long orderId,
decimal amount,
CancellationToken ctx)
{
var sem = locks.GetOrAdd(orderId, _ => new SemaphoreSlim(1, 1));
await sem.WaitAsync(ctx);
try
{
var active = await repository
.FindActiveByOrderIdAsync(orderId, ctx);
if (active is not null)
return active;
var payment = new PaymentSession(orderId, amount);
await repository.AddAsync(payment, ctx);
await uow.SaveChangesAsync(ctx);
var result = await gateway.RegisterAsync(payment, ctx);
payment.SetExternalId(result.PaymentId);
await repository.UpdateAsync(payment, ctx);
await uow.SaveChangesAsync(ctx);
return payment;
}
finally
{
sem.Release();
}
}Первый запрос захватывает блокировку и выполняет Сreate. Второй ждет, пока первый закончит.

Теперь два запроса внутри одного экземпляра приложения не регистрируют платеж одновременно, если оба пользуются этой блокировкой.
Но есть нюанс
Мы закрыли стартовую линию на все время гонки и заодно весь заезд до финиша.
Провайдер может отвечать несколько секунд. Пока он обрабатывает Register, второй запрос продолжает ждать, хотя создавать новую сессию не собирается;
Наш словарь разрастается без ограничений: каждый новый заказ — новая запись. Утечка небольшая, но бесконечная.
Блокировка по конкретному orderId не остановит все остальные заказы, но удерживать ее во время сетевого вызова все равно не хочется.
Нагрузка выросла и мы решили запустить вторую реплику.

Instance-1 разрешил старт первому запросу. Instance-2 разрешил старт второму. Оба локальных замка сработали правильно, но общих правил движения у них нет.
Надо добавить общий пит‑стоп.
Этап 3. Короткий пит‑стоп вместо блокировки всей трассы
Разделим Create на две операции:
Claim — получить право создать платежную сессию;
Register — зарегистрировать эту сессию у провайдера.
Представим пит‑стоп перед выездом. Механик проверяет машину, дает разрешение на старт и сразу освобождает бокс. Ему не нужно ехать вместе с водителем до финиша, удерживая за ним весь пит‑лейн. Так же поступим с транзакцией БД.
Advisory lock — наш стартовый светофор
Для координации мы используем pg_advisory_xact_lock в PostgreSQL.
А что за зверь pg_advisory_xact_lock?
pg_advisory_xact_lock — это блокировка по числовому ключу, которую мы выбираем сами. Живет до конца транзакции и видна всем подключениям к PostgreSQL.
В нашем случае логическим ключом будет идентификатор заказа — bigint.
Запускаем транзакцию и вызываем lock:
BEGIN;
SELECT pg_advisory_xact_lock(@orderId);
-- Проверка существующей сессии
-- Принятие решения
-- При необходимости INSERT
COMMIT;
-- Только после COMMIT вызываем RegisterAsyncТеперь первый запрос получает блокировку по orderId.
Если второй запрос обратится к pg_advisory_xact_lock с тем же ключом, он будет ждать, пока первая транзакция не завершится. При этом запросы для других заказов используют другие ключи и не конфликтуют друг с другом по этой advisory‑блокировке.
В отличие от обычного
lockв C#, механизм работает между разными экземплярами API, подключенными к одной PostgreSQL.
Сама блокировка существует только в рамках транзакции и автоматически освобождается при COMMIT или ROLLBACK.
Немного боли
У pg_advisory_xact_lock две перегрузки: (bigint) и (int, int). В первой версии мы взяли bigint и расшарили между бизнес‑операциями. Как можно догадаться, поймали блокировки между несвязанными бизнес‑операциями: один заказ ждал другой, хотя между ними ничего общего. Поэтому добавили общий enum для namespace (первый аргумент) и hashtext от id.
SELECT pg_advisory_xact_lock(@namespace, hashtext(@id::text));Почему не SELECT FOR UPDATE?
Можно было бы заблокировать строку заказа через SELECT FOR UPDATE, и это тоже рабочий подход. Но наша задача — координировать бизнес‑операцию по идентификатору заказа. Для этого необязательно блокировать саму строку orders.
А что, если кто‑то объедет светофор?
У advisory lock есть важная особенность — это добровольный протокол.
Может случиться так, что новый разработчик добавит обработчик, который напрямую вставляет платежные сессии, забыв получить advisory lock. Или в одном из старых сервисов сохранился другой путь создания платежа. PostgreSQL не запретит такой INSERT только из‑за существования advisory lock.
Чтобы это избежать, добавим дополнительную защиту на уровне БД:
CREATE UNIQUE INDEX ux_payment_active_order -- Частичный уникальный индекс
ON payment_sessions(order_id)
WHERE status IN (
'Registering',
'Registered',
'Unknown',
'Authorized',
'Canceling'
);Он запрещает хранить две активные платежные сессии с одинаковым order_id, если обе записи удовлетворяют условию индекса. Сам индекс не защищает от создания новой сессии для уже оплаченного заказа, если предыдущая сессия перешла в Completed и перестала удовлетворять условию.
Теперь у нас два уровня защиты:
Advisory lock — согласует решение конкурентных Create;
Уникальный индекс — не дает нарушить ограничение активных сессий даже при обходе прикладного протокола.
Но что, если два Check одновременно узнают, что платеж прошел успешно, и оба попытаются зафиксировать оплату?
Этап 4. Два водителя на финише
С Create разобрались. Теперь посмотрим на Check.
Два запроса одновременно проверяют статус одного платежа. Провайдер обоим возвращает Succeeded. Если оба обработчика независимо проверят состояние и применят результат, можем получить две записи о поступлении одной оплаты. Два водителя привезли судьям результат одного заезда — и каждый рассчитывает получить кубок.
Для этого мы воспользуемся READ COMMITTED и блокировками нужных строк через SELECT FOR UPDATE.
А если я хочу REPEATABLE READ?
Ну тогда вместо того чтобы дождаться завершения запущенной транзакции и получить готовый результат, он просто упадет с ошибкой сериализации.
Сначала получаем статус у провайдера, а уже после открываем короткую транзакцию.
BEGIN;
-- Сначала блокируем заказ
SELECT id, status, active_payment_id
FROM orders
WHERE id = @orderId
FOR UPDATE;
-- Затем платежную сессию
SELECT id, status
FROM payment_sessions
WHERE id = @paymentId
AND order_id = @orderId
FOR UPDATE;Теперь проверяем, что оплата еще не применена, сессия актуальна и переход в Completed допустим. Если заказ уже оплачен этой сессией, повторный Check просто возвращает существующий результат.
Иначе выполняем обновление:
UPDATE payment_sessions
SET status = 'Completed'
WHERE id = @paymentId
AND status IN ('Registered', 'Authorized')
RETURNING id;RETURNING позволяет проверить, действительно ли произошел переход состояния.
Аналогично обновляем заказ:
UPDATE orders
SET status = 'Paid'
WHERE id = @orderId
AND status = 'AwaitingPayment'
AND active_payment_id = @paymentId
RETURNING id;Если любой из этих UPDATE не изменил ровно одну строку, финансовую запись не создаем. Транзакцию откатываем либо обрабатываем уже достигнутое состояние без повторных изменений. Если оба перехода успешны, создаем финансовую запись и выполняем COMMIT в рамках той же транзакции.
Для дополнительной защиты добавим уникальный индекс:
CREATE UNIQUE INDEX ux_order_payment_receipt
ON accounting_entries(order_id)
WHERE entry_type = 'PaymentReceived';Что теперь произойдет с двумя Check?
Request A первым заблокирует заказ и применит результат.
Request B будет ждать освобождения строки. После COMMIT первого запроса он получит актуальное состояние, увидит уже проведенную оплату и не создаст второй документ.
Итого три уровня защиты:
SELECT FOR UPDATE— не позволяет двум Check одновременно применять изменения к заблокированным записям;
UPDATE— проверяет допустимость перехода состояния;Уникальный индекс — не дает создать второй документ поступления, даже если кто‑то обойдет основную логику.
При этом все обработчики, которые меняют связанные состояния заказа и платежа, должны соблюдать согласованный порядок блокировок — например, сначала заказ, затем сессия. Иначе можно получить deadlock.
А откуда deadlock?
Check брал order, потом session, а Cancel наоборот.
С тех пор правило:
Все обработчики берут локи в одном порядке: order → session → finance.
Этап 5. Разворот посреди гонки
Осталась отмена.
Допустим, пользователь подтверждает Cancel одноразовым кодом, после чего мы вызываем Reverse у провайдера.
Наивная реализация:
if (await otpStore.ValidateAsync(code))
{
await gateway.ReverseAsync(payment);
await otpStore.ConsumeAsync(code);
}Два конкурентных запроса могут одновременно проверить один код и оба вызвать Reverse. Два механика получили одно разрешение на разворот.
Чтобы этого избежать, право на отмену нужно забрать атомарно до обращения к провайдеру.
Если OTP и платеж хранятся в одной PostgreSQL, можно в одной транзакции проверить и погасить код, проверить допустимость отмены, создать операцию Reverse с идемпотентным идентификатором и перевести платеж в Canceling.
А если OTP в Redis?
Тогда либо GETDEL и отдельная идемпотентность на Cancel, либо мириться с окном.
После COMMIT вызываем провайдера. Повторный Cancel увидит уже начатую операцию и не создаст вторую. А что, если Reverse выполнился, но ответ потерялся?
Для этого у нас уже есть Check, он смотрит на статус сессии у провайдера, а завершение зависших сессий возьмет на себя фоновый обработчик.
Этап 6. Ночной патруль
Пользователь оплатил заказ и закрыл браузер. Провайдер знает, что платеж завершен, но Check никто не вызвал. Без фоновой обработки такие платежи могут зависнуть.
Запускаем Sweep Job — обработчик, который периодически находит незавершенные сессии и пытается довести их до конечного состояния.
Если срок жизни сессии истек, закрываем ее локально только тогда, когда правила провайдера позволяют считать внешнюю операцию завершенной. Сам по себе timeout или истечение локального TTL не доказывает, что платеж не был выполнен.
Sweep Job может работать в несколько реплик. Если все одновременно выберут одну зависшую сессию — новая гонка. Для распределения задач используем PostgreSQL FOR UPDATE SKIP LOCKED.
WITH picked AS (
SELECT id
FROM payment_sessions
WHERE next_check_at <= now()
AND status IN (
'Registering',
'Registered',
'Unknown',
'Canceling'
)
AND (
lease_until IS NULL
OR lease_until < now()
)
ORDER BY next_check_at, id
FOR UPDATE SKIP LOCKED
LIMIT 20
)
UPDATE payment_sessions p
SET lease_until = now() + interval '30 seconds'
FROM picked
WHERE p.id = picked.id
RETURNING p.id, p.status;В короткой транзакции один обработчик захватывает задачу, а остальные пропускают заблокированную строку и выбирают другие. После commit обработчик выполняет Check.
Sweep Job научился делить трассу, а Check отвечает за то, чтобы результаты нескольких проверок не создали повторный финансовый эффект.
Что прогоняем на полигоне
Теперь проверим, что построенные механизмы действительно работают.
Возьмем .NET, реальную PostgreSQL и тестовый платежный провайдер с управляемыми задержками.
Сценарий | Что ожидаем |
|---|---|
Create × 2 с одинаковыми параметрами | Одна активная сессия |
Create × 2 с разными параметрами | Одна сессия, второй запрос получает конфликт |
Create на двух репликах API | Одна активная сессия |
Check × 2 | Финансовый результат применяется один раз |
Cancel × 2 с одним OTP | Создается одна операция отмены |
Два Sweep Job | Одна задача не захватывается одновременно |
Для воспроизведения гонок можно использовать Task.WhenAll, но лучше добавить управляемые точки синхронизации (TaskCompletionSource). Без нее Task.WhenAll не гарантирует, что запросы придут одновременно.
[Fact]
public async Task ConcurrentCreate_RegistersOnlyOnce()
{
var start = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
async Task RunAsync()
{
await using var scope = services.CreateAsyncScope();
var service = scope.ServiceProvider
.GetRequiredService<IPaymentService>();
await start.Task;
await service.CreateAsync(orderId);
}
var a = RunAsync();
var b = RunAsync();
start.SetResult();
await Task.WhenAll(a, b);
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
var sessions = await db.PaymentSessions
.Where(s => s.OrderId == orderId && s.IsActive)
.ToListAsync();
Assert.Single(sessions);
gatewayMock.Verify(
x => x.RegisterAsync(
It.IsAny<PaymentSession>(),
It.IsAny<CancellationToken>()),
Times.Once);
}Проверяем не только количество созданных сессий, но и количество вызовов Register у провайдера. Для гарантированного воспроизведения старой гонки синхронизируем обработчики после проверки существующей сессии.
Вместо заключения
Мы начали как уличная гонка без правил: два клика, два Register, общий хаос. Потом чуть не убили трассу ручником на весь HTTP. Кончили тем, что разнесли Claim и Register, поставили advisory lock и уникальный индекс, а зависшие сессии отдали ночному патрулю.
Три правила, которые вывели кровью:
Спрашивать статус дважды можно; забирать приз дважды — нет;
Жизнь платежа не должна держаться только на пальце пользователя;
Сначала лок, потом проверка. А если локов несколько — единый порядок захвата.
А вот что проверить в своем платежном сервисе:
Register у провайдера идет с идемпотентным идентификатором — иначе никакие локи не спасут от двух сессий;
На
(order_id)есть частичный уникальный индекс по активным статусам;Claim и Register разделены — лок не держится во время HTTP‑вызова;
Check берет
SELECT FOR UPDATEнаorderиsessionв одном порядке во всех обработчиках;Cancel атомарно гасит OTP до вызова провайдера —
UPDATE ... RETURNING, а неSELECT+UPDATE;Sweep JobиспользуетSKIP LOCKEDи не создает второй финансовый документ;Есть тест, который воспроизводит гонку через
TaskCompletionSource, а неTask.WhenAllс проверкой внешнего эффекта.
Что осталось за кадром: распределенный лок на несколько инстансов PostgreSQL, сверка поздних оплат — когда провайдер присылает вебхук через сутки, и граница между синхронным ответом клиенту и асинхронным догоном через Check.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.