<? phpukraine СПІВБЕСІДИ
Пошук по платформі
АРХІТЕКТУРА · SENIOR

Як спроєктувати ідемпотентну обробку платежів?

Клієнт передає idempotency key, сервер зберігає його разом із результатом операції під унікальним індексом і на повторний запит повертає збережений результат замість другого платежу.

Клієнт натиснув «Оплатити» двічі: як не списати гроші двічі?
Де зберігати idempotency key і скільки?
Що робити, якщо вебхук від платіжки приходить повторно?
ідемпотентність платежі черги

Повтори в платіжній системі неминучі: подвійний клік, таймаут мережі з автоматичним retry, redelivery з черги, повторний вебхук провайдера. Ідемпотентність означає, що будь-який із цих повторів дає той самий результат, що й перший виклик, без другого списання.

Базовий механізм — idempotency key. Клієнт генерує унікальний ключ на бізнес-операцію, а не на HTTP-запит, і передає його заголовком. Сервер атомарно резервує ключ у таблиці з унікальним індексом, виконує операцію й зберігає результат разом із ключем. Повторний запит із тим самим ключем отримує збережену відповідь. Той самий ключ передається платіжному провайдеру, тому навіть падіння між викликом провайдера і збереженням результату не створює другого платежу.

Гонку закриває саме унікальний індекс, а не перевірка в коді: два одночасні запити проходять if (! exists) разом, але лише один пройде INSERT. Стан ключа має проміжне значення processing, щоб повтор під час виконання отримав 409 з Retry-After, а не чужий результат і не другу спробу. Ключ із тим самим значенням, але іншим тілом запиту повертає 422.

Вебхуки провайдера обробляються за тим самим принципом: дедуплікація по event id через унікальний індекс і перевірка стану агрегату перед кожною дією, бо доставка гарантована щонайменше один раз, а не рівно один. Ключі мають час життя, зазвичай від доби до трьох, і чистяться фоновим процесом.

// CREATE TABLE idempotency_keys (key text, client_id bigint, request_hash text,
//   status text, response_code int, response_body jsonb, created_at timestamptz,
//   PRIMARY KEY (client_id, key));

final class ChargeAction
{
    public function __invoke(ChargeRequest $request): JsonResponse
    {
        $key = $request->header('Idempotency-Key') ?? abort(400, 'Idempotency-Key required');
        $hash = hash('sha256', $request->getContent());

        // 1. Атомарне резервування ключа: унікальний індекс закриває гонку
        try {
            IdempotencyKey::create(['key' => $key, 'client_id' => $request->clientId(), 'request_hash' => $hash, 'status' => 'processing']);
        } catch (UniqueConstraintViolationException) {
            $existing = IdempotencyKey::where('client_id', $request->clientId())->where('key', $key)->firstOrFail();

            if ($existing->request_hash !== $hash) {
                abort(422, 'Idempotency-Key reused with a different payload');
            }
            if ($existing->status === 'processing') {
                return response()->json(['status' => 'processing'], 409)->header('Retry-After', '2');
            }

            return response()->json($existing->response_body, $existing->response_code); // збережений результат
        }

        // 2. Сама операція: провайдер отримує той самий ключ, повтор не створить другий платіж
        $charge = $this->gateway->charge($request->amount(), $request->card(), idempotencyKey: $key);

        // 3. Результат зберігається разом з ключем в одній транзакції
        DB::transaction(function () use ($key, $request, $charge) {
            Payment::create(['charge_id' => $charge->id, 'amount' => $request->amount()]);
            IdempotencyKey::where('client_id', $request->clientId())->where('key', $key)
                ->update(['status' => 'succeeded', 'response_code' => 201, 'response_body' => ['charge' => $charge->id]]);
        });

        return response()->json(['charge' => $charge->id], 201);
    }
}
Що ідемпотентність означає однаковий результат при повторі, і що повтори неминучі: подвійний клік, таймаут із retry, redelivery з черги, повторний вебхук.
Що ключ генерує клієнт на одну бізнес-операцію, а не на запит, і сервер зберігає його разом із результатом під унікальним індексом.
Що унікальний індекс у базі є останньою лінією захисту: перевірка існування в коді не закриває гонку двох одночасних запитів.
Що обробка йде через стани: pending, processing, succeeded, failed, і повтор під час processing отримує 409 або чекає, а після завершення отримує збережену відповідь.
Що вебхуки платіжного провайдера дедуплікуються по event id, а стан агрегату перевіряється перед кожною дією, бо доставка щонайменше один раз.
Перевіряти if (! exists) у коді й вважати, що цього досить: два запити проходять перевірку одночасно.
Генерувати ключ на сервері на кожен запит: повторний запит отримує новий ключ і створює другий платіж.
Повертати на повтор новий результат замість збереженого, або повертати 200 без тіла, ламаючи клієнта.
Зберігати ключі вічно без TTL або, навпаки, лише в Redis без гарантій, втрачаючи їх при рестарті.
Не розрізняти повтор того самого запиту й інший запит з тим самим ключем: другий має отримувати 422, а не чужий результат.
ПОРАДА

Згадайте унікальний індекс у БД як останню лінію захисту: перевірка в коді гонку не закриває. І назвіть, як обробляєте повтор під час processing, а не лише після завершення.

оновлено 3 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Ідемпотентність означає, що повтор дає той самий результат без подвійного списання.