冪等キーは「処理結果」と一緒に、同じトランザクションで保存する
冪等キー(Idempotency-Key)を使うなら、キーと返したレスポンスを、副作用(注文の作成や残高の更新)と同じトランザクションで書く。別々に書くと、その間でプロセスが落ちたときにリトライが二重実行になる。
何が起きるか
よくある失敗は「処理する → 成功したらキーを保存」の二段階にしているケース。課金が成功した直後、キーを保存する前にプロセスが落ちると、クライアントのリトライは「未処理のキー」として扱われ、もう一度課金される。逆に「キーを先に保存 → 処理」にすると、処理の途中で落ちたときにキーだけが残り、リトライしても何も起きない(処理されないまま成功扱い)。
やり方
sql
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
request_hash text NOT NULL, -- 同じキーで別の内容が来たら拒否するため
status_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);1 リクエストの流れ(すべて 1 つのトランザクション):
sql
BEGIN;
-- 1. キーを確保。既にあれば 0 行になる
INSERT INTO idempotency_keys (key, request_hash)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING;
-- 0 行だったら: 既存の行を読んで、保存済みのレスポンスをそのまま返す
-- (request_hash が違えば 422 など、response_body がまだ空なら処理中として 409)
-- 2. 副作用(同じ DB の中の書き込み)
INSERT INTO orders (...) VALUES (...);
-- 3. 返すレスポンスをキーの行に書く
UPDATE idempotency_keys
SET status_code = 201, response_body = $3
WHERE key = $1;
COMMIT;途中で落ちればトランザクションごと消えるので、キーも注文も残らない。リトライは最初から正しくやり直される。
注意点
- 同じキーが同時に 2 本届いたとき、後から来た方の
INSERT ... ON CONFLICTは先のトランザクションの確定を待つ。待ったあとは「既存の行」として保存済みのレスポンスを返せる。 - 決済 API など外部への副作用は DB のトランザクションに入らない。外部サービスが冪等キーを受け付けるなら同じキーを渡す。受け付けないなら、外部呼び出しを Transactional outbox: DB の更新とイベント送信を同じトランザクションに入れる で後から一度だけ送る形にする。
- キーの行はずっと残すと膨らむ。保持期間(例: 24 時間)を決めて定期的に消す。
- リトライする側の作法は リトライは「1 リクエストごとの回数」だけでなく全体の予算で制限する(full jitter と組み合わせる) を参照。