Idempotence: Proč máte v databázi stejnou objednávku dvakrát?

Klient pošle Idempotency-Key, ale ochránit vás musí server. Jak napsat endpoint a handler, které snesou stejný request dvakrát: unikátní index, tabulka klíčů, souběh, platební brána a Messenger.

// obsah 9
  1. 01 Co vlastně idempotence slibuje?
  2. 02 Proč nestačí se nejdřív podívat?
  3. 03 Jak to vypadá v kódu?
  4. 04 Co uvidí druhý request, když první ještě běží?
  5. 05 Co s platební bránou uprostřed?
  6. 06 Potřebujete vždycky tabulku klíčů?
  7. 07 A co Messenger a webhooky?
  8. 08 Na co si dát bacha?
  9. 09 Zdroje

Idempotenci musí zajistit server.

V článku o retry patterns jsem řešil klienta: jak dlouho čekat, jak rozprostřít opakované pokusy a proč do requestu patří Idempotency-Key. Na konci jsem napsal, že s idempotencí je víc práce, hlavně na straně serveru. Klient vám totiž může poslat sebelepší klíč, a když s ním server nic neudělá, máte v databázi dvě stejné objednávky.

Mně se to stávalo hlavně u API, na která si klienti napojili vlastní aplikace (člověk kliknul na tlačítko dvakrát, protože odpověď dlouho trvala, nebo prostě udělal dvojklik).

Situací, kdy stejný request přijde dvakrát, je víc, než by se zdálo. Uživatel dvakrát klikne na „Objednat“. Mobil ztratí signál uprostřed odpovědi a aplikace to zkusí znovu. Klient dostane timeout, i když server request mezitím v klidu dokončil. Worker v Messengeru zprávu zpracuje, ale spadne dřív, než ji stihne potvrdit. A platební brána vám pošle stejný webhook podruhé, protože u prvního nedostala odpověď včas.

Pokud se vám líp kouká, než čte, je k článku i video. Má zhruba deset minut.

Co vlastně idempotence slibuje?

Specifikace HTTP (RFC 9110) říká, že metoda je idempotentní, když má opakovaný request na serveru stejný zamýšlený efekt jako jeden request. Idempotentní jsou GET, HEAD, OPTIONS, TRACE, PUT a DELETE. POST ne, protože typicky něco vytvoří, a když ho zavoláte dvakrát, vytvoří dvě věci. PATCH taky ne (ten je mimochodem definovaný v samostatném RFC 5789), i když konkrétní PATCH idempotentní být může: {"stock": 42} můžete poslat, kolikrát chcete, ale „přičti jeden kus ke skladu“ už ne.

Důležité je tam slovo efekt, protože RFC výslovně připouští, že se odpověď může lišit. DELETE /orders/1 napoprvé vrátí 204, napodruhé klidně 404, a pořád je to idempotentní. Vzor s hlavičkou Idempotency-Key, který používá Stripe nebo Adyen, jde dál: kromě efektu slibuje i stejnou odpověď. Server si ji pod klíčem uloží a na opakovaný request ji přehraje.

Pro hlavičku Idempotency-Key existuje v IETF návrh standardu. Od roku 2020 ale nepokročil dál než do stavu draftu a teď je dokonce expirovaný (poslední verze je z října 2025). Samotnou hlavičku přitom velké platební brány používají roky (draft z nich vlastně vychází), takže jako konvence je zavedená. Jen nečekejte, že implementaci na serveru někdo udělá za vás.

Proč nestačí se nejdřív podívat?

První verze, která člověka napadne, vypadá zhruba takhle: podívám se do tabulky, jestli klíč znám, a když ne, zpracuju request a klíč uložím.

Jenže mezi tím „podívám se“ a „uložím“ je okno. Dva requesty se stejným klíčem přijdou skoro současně (dvojklik je přesně tenhle případ), oba se podívají do tabulky, nic nenajdou a každý vytvoří svou objednávku. Kontrola v aplikaci tohle okno nezavře, protože to jsou pořád dva samostatné kroky.

Zavře ho až databáze. Klíč musí být první věc, kterou zapíšete, a o tom, kdo byl první, musí rozhodnout unikátní index:

INSERT INTO idempotency_keys (client_id, idem_key, fingerprint, status, expires_at)
VALUES (:client, :key, :fingerprint, 'processing', :expires)
ON CONFLICT (client_id, idem_key) DO NOTHING

Když INSERT vloží jeden řádek, jste první a request zpracujete. Když nevloží nic, klíč už existuje a místo zpracování se podíváte, co je pod ním uložené.

Unikátní je přitom dvojice (client_id, idem_key), ne samotný klíč. Draft IETF to doporučuje kvůli bezpečnosti: kdyby byl klíč unikátní globálně a někdo by ho uhodl, dostal by uloženou odpověď jiného klienta. A Stripe i Adyen to mají stejně, klíč platí vždycky jen v rámci účtu.

Jak to vypadá v kódu?

Tabulka klíčů vypadá takhle:

CREATE TABLE idempotency_keys (
    client_id     TEXT        NOT NULL,
    idem_key      TEXT        NOT NULL CHECK (char_length(idem_key) <= 255),
    fingerprint   TEXT        NOT NULL,  -- otisk obsahu requestu
    status        TEXT        NOT NULL CHECK (status IN ('processing', 'completed')),
    response_code INT         NULL,
    response_body JSONB       NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at    TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (client_id, idem_key)
);

fingerprint je otisk obsahu requestu. Bez něj byste nepoznali, že vám klient poslal stejný klíč s jinou objednávkou. status rozliší, jestli je request hotový, nebo se ještě zpracovává. A expires_at slouží k úklidu (prošlé klíče může mazat třeba Scheduler), jinak by tabulka rostla donekonečna.

Samotná služba pak dělá tři kroky v jedné transakci: zapíše klíč, vytvoří objednávku a uloží odpověď.

final class CreateOrder
{
    public function __construct(private readonly Connection $db)
    {
    }

    public function handle(string $clientId, ?string $key, array $payload): IdempotentResult
    {
        if ($key === null || $key === '' || strlen($key) > 255) {
            return new IdempotentResult(400, ['error' => 'Chybí nebo je neplatná hlavička Idempotency-Key']);
        }

        $fingerprint = hash('sha256', 'POST /orders ' . json_encode($this->normalize($payload), JSON_THROW_ON_ERROR));

        return $this->db->transactional(function (Connection $db) use ($clientId, $key, $fingerprint, $payload) {
            // 1) klíč jako první zápis, o vítězi rozhodne unikátní index
            $inserted = $db->executeStatement(
                "INSERT INTO idempotency_keys (client_id, idem_key, fingerprint, status, expires_at)
                 VALUES (?, ?, ?, 'processing', ?)
                 ON CONFLICT (client_id, idem_key) DO NOTHING",
                [$clientId, $key, $fingerprint, (new \DateTimeImmutable('+24 hours'))->format(DATE_ATOM)],
            );

            if ($inserted === 0) {
                return $this->replay($db, $clientId, $key, $fingerprint);
            }

            // 2) zápis objednávky
            $db->insert('orders', [
                'client_id' => $clientId,
                'product'   => $payload['product'],
                'quantity'  => $payload['quantity'],
            ]);
            $result = new IdempotentResult(201, ['order_id' => (int) $db->lastInsertId()]);

            // 3) uložená odpověď, pořád ve stejné transakci
            $db->update('idempotency_keys', [
                'status'        => 'completed',
                'response_code' => $result->status,
                'response_body' => json_encode($result->body, JSON_THROW_ON_ERROR),
            ], ['client_id' => $clientId, 'idem_key' => $key]);

            return $result;
        });
    }

    private function replay(Connection $db, string $clientId, string $key, string $fingerprint): IdempotentResult
    {
        $row = $db->fetchAssociative(
            'SELECT fingerprint, status, response_code, response_body
               FROM idempotency_keys WHERE client_id = ? AND idem_key = ?',
            [$clientId, $key],
        );

        if ($row['fingerprint'] !== $fingerprint) {
            return new IdempotentResult(422, ['error' => 'Klíč už byl použitý s jiným obsahem']);
        }

        if ($row['status'] !== 'completed') {
            return new IdempotentResult(409, ['error' => 'Request s tímhle klíčem se ještě zpracovává']);
        }

        return new IdempotentResult(
            (int) $row['response_code'],
            json_decode($row['response_body'], true),
            ['Idempotent-Replayed' => 'true'],
        );
    }

    private function normalize(array $data): array
    {
        ksort($data);

        return array_map(fn ($v) => is_array($v) ? $this->normalize($v) : $v, $data);
    }
}

Connection je Doctrine DBAL, IdempotentResult je jen obyčejná třída se stavovým kódem, tělem a hlavičkami. Controller z ní udělá JsonResponse a client_id bere z přihlášeného uživatele nebo z API klíče, nikdy z těla requestu.

Co se stane v jednotlivých případech:

Request Odpověď Objednávek v DB
klient A, klíč K1, poprvé 201 {"order_id":1} 1
klient A, klíč K1, znovu 201 {"order_id":1} + Idempotent-Replayed: true 1
klient A, klíč K1, stejná data v jiném pořadí přehraná odpověď 1
klient A, klíč K1, jiné množství 422 1
klient B, klíč K1 201 {"order_id":2} 2
bez klíče 400 2

Transakci tam mám hlavně kvůli pádu uprostřed. Když spadne zápis objednávky, rollback zruší i řádek s klíčem a retry projde znovu, jako by se nic nestalo. Kdybyste klíč s odpovědí zapisovali až po commitu objednávky, mohl by proces mezi těmi dvěma kroky spadnout a retry by vytvořil druhou objednávku.

normalize() seřadí názvy polí, aby {"quantity":1,"product":"kniha"} a {"product":"kniha","quantity":1} měly stejný otisk. Kdybyste hashovali přímo bajty JSONu, vraceli byste 422 i requestům, které se liší jen pořadím polí nebo mezerou. Ještě jistější je hashovat až zvalidovaný příkaz. Do otisku patří i metoda a cesta (POST /orders), aby stejný klíč poslaný na jiný endpoint nepřehrál odpověď z úplně jiného endpointu.

Stavové kódy 400, 409 a 422 jsou z draftu IETF. Stripe u souběhu vrací taky 409, u stejného klíče s jiným obsahem ale dokumentace uvádí jen typ chyby idempotency_error, HTTP kód ne.

PHP kód jde pustit i proti SQLite, ON CONFLICT … DO NOTHING tam funguje stejně (schéma je pro PostgreSQL, pro SQLite je potřeba upravit typy a výchozí hodnoty).

Co uvidí druhý request, když první ještě běží?

Tady záleží na databázi. V PostgreSQL s výchozí izolací (Read Committed) druhý INSERT se stejným klíčem počká, dokud první transakce neskončí (dokumentace to popisuje u kontroly unikátních indexů). Když první transakce commitne, druhý INSERT nic nevloží, další SELECT už uvidí hotový řádek a druhý request dostane přehranou odpověď. Když první transakce spadne a udělá rollback, druhý INSERT projde a druhý request objednávku zpracuje sám.

Při jedné transakci stav processing nikdo jiný nikdy neuvidí, protože je pořád necommitnutý. Větev s 409 je tedy v kódu výš vlastně navíc. Smysl dává až ve variantě se dvěma transakcemi (viz platební brána níž).

Má to jeden háček: druhý request po dobu čekání drží spojení do databáze. U krátké transakce je to jedno, ale jakmile se v ní volá něco po síti, čeká druhý request i na to síťové volání.

Kdybyste si to zkoušeli na SQLite, chová se jinak. Zamyká pro zápis celou databázi, takže druhé spojení nečeká na konkrétní řádek. A když první transakce nedoběhne, než vyprší timeout, druhé spojení spadne na database is locked. Souběh proto na SQLite neladte a nevyvozujte z něj nic pro produkci.

Co s platební bránou uprostřed?

Dokud endpoint sahá jen do vaší databáze, stačí jedna transakce. Jakmile ale uprostřed voláte cizí API (typicky platební bránu), přestane to platit. Rollback vaší databáze totiž nevrátí platbu, kterou brána mezitím provedla.

Brandur Leach (tehdy ve Stripe) to v roce 2017 popsal v článku Implementing Stripe-like Idempotency Keys in Postgres a jeho řešení se dá shrnout do tří bodů:

  • Zpracování requestu rozdělíte na fáze. Každá lokální fáze je samostatná transakce a mezi nimi je externí volání. Klíč se zapíše a commitne hned v první fázi, takže tady už druhý request uvidí processing a dostane 409.
  • U klíče si pamatujete, kam jste došli (Brandur tomu říká recovery point, třeba started, charge_created, finished). Retry pak nezačíná od začátku, ale naváže za posledním dokončeným krokem.
  • Bráně posíláte vlastní idempotency key, odvozený z vašeho interního ID (řádku s klíčem nebo pokusu o platbu). Klíč od klienta beze změny dál neposílejte. U brány máte jeden účet, takže by se tam mohly potkat klíče dvou vašich klientů.

Tahle varianta má jednu nepříjemnou vlastnost: řádek může zůstat viset ve stavu processing, když proces spadne mezi fázemi. Brandur na to má sloupec locked_at s časovým limitem a proces, který rozpracované requesty po čase dokončí nebo uklidí. Je to víc práce, a proto bych tuhle variantu dělal jen tam, kde externí volání opravdu je.

Potřebujete vždycky tabulku klíčů?

Často ani ne. Obecná tabulka klíčů se hodí hlavně u veřejného API, kde chcete klientovi vrátit přesně stejnou odpověď. Uvnitř aplikace se dá idempotence většinou zařídit jednodušeji, přímo nad byznys daty:

  • Unikátní constraint na byznys klíči. Jedna faktura na objednávku (UNIQUE (order_id)), jedna platba na pokus o platbu (UNIQUE (payment_attempt_id)). Druhý zápis spadne na indexu a vy víte, že ta operace už jednou proběhla (uvnitř transakce radši s ON CONFLICT DO NOTHING, jinak vám chyba v PostgreSQL shodí celou transakci).
  • Přechod stavu s podmínkou. UPDATE orders SET status = 'paid' WHERE id = :id AND status = 'pending'. Když to nezmění žádný řádek, objednávka už není ve stavu pending (nejspíš ji zaplatil předchozí pokus) a nic dalšího se dělat nemá. PostgreSQL navíc (ve výchozím Read Committed) u souběžného UPDATE počká na první a podmínku ve WHERE vyhodnotí znovu nad novou verzí řádku.
  • Absolutní hodnota místo změny. Stejně jako u PATCH výš: „nastav sklad na 42“ zopakovat jde, „odečti jeden kus“ ne.

A co Messenger a webhooky?

Tady se idempotence řeší úplně stejně, jen místo HTTP requestu přichází zpráva.

Symfony dokumentace v sekci o idempotentních handlerech píše na rovinu, že zpráva může být za normálního provozu doručená víckrát (třeba kvůli workeru z úvodu, který spadne před potvrzením). RabbitMQ to má v dokumentaci taky: potvrzování zaručuje doručení „aspoň jednou“ a konzument má být napsaný tak, aby opakované doručení snesl. Symfony doporučuje dát do zprávy stabilní klíč odvozený z byznys události (třeba payment-{orderId}-{orderVersion}), v handleru ho zkontrolovat a pojistit unikátním constraintem v databázi. UUID vygenerované při dispatchi nestačí. Opakované doručení té samé zprávy sice chytí, ale dvakrát odeslaný formulář vyrobí dvě různá UUID.

Bacha na DeduplicateStamp (v Messengeru od Symfony 7.3). Podle názvu by se zdálo, že tohle řeší za vás, ale ve zdrojovém kódu je vidět, co přesně dělá: při dispatchi zkusí získat zámek s TTL (výchozích 300 sekund) a když ho nedostane, zprávu tiše zahodí. Ve workeru se zámek už nekontroluje. Zabrání tedy tomu, aby se do fronty dostala druhá zpráva se stejným klíčem (typicky když někdo pětkrát klikne na „přegenerovat report“), dokud ta první nedoběhne, nejdéle ale po dobu TTL. Opakované doručení stejné zprávy tím ovšem nezastavíte, takže idempotentní handler potřebujete pořád.

Symfony Lock na tom není jinak. Zámek pomůže, když přijdou dva requesty najednou. Když ale první request doběhne a zámek pustí, druhý ho klidně dostane a operaci provede znovu, protože zámek si nepamatuje výsledek. Navíc expirující zámek může vypršet uprostřed práce (dokumentace Lock na to výslovně upozorňuje). Což neznamená, že je zámek k ničemu, jen pod ním pořád chcete unikátní index.

U webhooků od Stripe je to podobné. Dokumentace upozorňuje, že stejná událost může přijít víckrát, doporučuje si zpracovaná ID událostí ukládat a zmiňuje i případ, kdy pro jednu změnu vzniknou dvě události s různým ID. Takže tabulka zpracovaných event_id je dobrý začátek, ale handler by měl být idempotentní i nad samotnými daty (platba podle payment_intent_id, přechod stavu s podmínkou).

Na co si dát bacha?

  • Klíč bez scope na klienta. Dva klienti se stejným klíčem se srazí a v horším případě jeden dostane odpověď toho druhého.
  • Uložená chyba 500. Když přechodnou chybu uložíte jako výsledek, klient se přes ni nedostane, dokud klíč nevyprší, protože mu ji budete pořád přehrávat. Jednotné to nemá ani Stripe (v1 přehrává i uloženou 500, v2 neúspěšný request zkusí provést znovu). Mně dává smysl ukládat úspěch a definitivní chyby (třeba zamítnutou platbu) a přechodné chyby nechat rollbacknout. U jedné transakce to máte zadarmo.
  • Krátké TTL. Klíč musí žít déle, než trvá nejdelší retry klienta. U Messengeru musíte počítat i s failure transportem a ručním messenger:failed:retry. Stripe drží klíče aspoň 24 hodin, Adyen 7 až 14 dní.
  • Klient při retry vygeneruje nový klíč. Pro server je to pak nová operace a nemá jak to poznat. Tohle chytí jen byznys klíč, o kterém jsem psal výš (třeba UNIQUE (cart_id) u objednávky).

Na interní operace vám většinou stačí unikátní constraint na byznys klíči a celou tabulku klíčů si nechte pro veřejné API, kde klientovi chcete vracet stejnou odpověď.

Zdroje

Michal Katuščák
Michal Katuščák

Navrhuji a vyvíjím aplikace nad Symfony, zajímám se o architekturu softwaru. Žiju v Českých Budějovicích.