決済システムの設計
お金は1円もズレが許されない。二重支払いを冪等キーで止め、台帳のダブルエントリで残高を守り、Sagaと補償で外部連携の失敗を巻き戻す設計を、面接の解答手順そのままで学べる。
- 決済APIは必ず冪等キー(Idempotency-Key)を受け取り、`(merchant_id, key)` にユニーク制約を張って結果をキャッシュする。リトライやダブルクリックが同じキーで届いても、課金は1回だけに収束させる。
- 残高はダブルエントリ台帳で表現する。1取引は借方と貸方の複数行で合計が必ずゼロになり、行はappend-only(不変)。修正は逆仕訳で打ち消し、UPDATEで金額を書き換えない。これが監査可能性と整合性の土台になる。
- 外部PSP連携やウォレット振替はSagaで分割し、各ステップに補償トランザクションを用意する。最終的にはリコンサイル(PSP明細と自台帳の突合)で残った不一致を検出・修復し、結果整合性を担保する。
要件と規模の見積もり
決済システムは「入力の一部を受け付けてお金を動かす」機能です。まず機能要件と非機能要件を分け、規模を桁で押さえます。
機能要件は、(1) 加盟店(merchant)からの課金リクエスト受付、(2) 外部の決済プロバイダ(PSP: カード会社・ゲートウェイ)への承認・売上確定、(3) ユーザー残高やウォレット間の内部振替、(4) 返金・キャンセル、(5) 取引履歴と明細の照会です。非機能要件はこの領域では順序が特殊で、正確性(correctness)が可用性より優先されます。二重課金や残高破壊は不可逆で信頼を失うため、疑わしいときは「保留」に倒し、後からリコンサイルで確定させる方針を取ります。加えて監査性(誰がいつ何を動かしたか)、冪等性、そして規制対応(PCI DSS準拠、カード番号を自前で保持しない)が要件に入ります。
規模はフェルミ推定で桁を出します。仮に決済プラットフォームが1日1,000万件(10^7)の取引を捌くとします。1日は約86,400秒(およそ10^5秒)なので、平均は 10^7 / 10^5 = 100 QPS。ピークは平均の10倍を見て約1,000 QPSと置きます。書き込み主体のワークロードで、この程度なら適切に分割した関係データベースで十分に収まる桁です。
ストレージ側を見積もります。1取引レコードを、台帳の複数仕訳行・状態履歴・監査ログまで含めて約2KBと仮定すると、1日で 10^7 × 2KB = 20GB。年間では 20GB × 365 ≈ 7.3TB。金融データは保持義務(例: 7年)があるため、7.3TB × 7 ≈ 51TB を長期保管する前提で設計します。ホットデータ(直近90日)は数TBに収まり、それ以前はコールドストレージへ階層化します。帯域はリクエスト/レスポンスが各数KBとして、1,000 QPS × 数KB ≈ 数MB/s と小さく、ボトルネックはネットワークではなく書き込み整合性と外部PSPのレイテンシにあると読めます。
一般的なWebサービスは「多少データがズレても後で直せる」ため可用性を優先しますが、決済では二重課金や残高欠損が金銭的損害と法的責任に直結します。CAP定理の文脈では、ネットワーク分断時に一貫性(C)を選び、書けないなら書かずに保留する設計が基本になります。CAPの詳細はCAP定理を参照してください。
大枠の設計
APIは同期の受付と非同期の確定を分けます。中核となる課金エンドポイントは次の形にします。冪等キーはクライアントが生成し、ヘッダで渡します。
POST /v1/charges
Idempotency-Key: 5f3a9c1e-8b2d-4e77-a1c0-2f9b7e6d4a10
Content-Type: application/json
{ "merchant_id": "m_123", "amount": 4980, "currency": "JPY", "source": "tok_visa_xxx" }
データモデルの中心は3つです。transactions(取引ヘッダ: 状態を持つ)、ledger_entries(ダブルエントリの仕訳行: append-only)、idempotency_keys(キーと確定レスポンスのキャッシュ)。状態機械は INITIATED → AUTHORIZED → CAPTURED → SETTLED を正常系とし、失敗時は FAILED、返金は REFUNDED へ遷移します。
全体構成は次の流れです。API Gatewayが認証とレート制限を行い、Payment Serviceが冪等チェックと台帳記帳を担い、外部連携はメッセージキュー越しにWorkerが処理します。同期部分は「受け付けて保留を記帳するまで」に限定し、PSPとの往復のような遅くて失敗しうる処理は非同期に逃がすのが判断の要です。これにより、PSPが遅延しても受付APIのレイテンシとスループットを守れます。
Client ──> API Gateway ──> Payment Service ──> DB(transactions, ledger, idempotency)
│
└─(enqueue)─> Message Queue ──> PSP Worker ──> External PSP
│
(結果を台帳へ記帳・状態遷移)
同期処理と非同期処理の責務を対比します。
| 処理 | 同期(受付API内) | 非同期(Worker) |
|---|---|---|
| 主な仕事 | 冪等チェック・保留の記帳・受付応答 | PSP承認/確定・確定記帳・状態遷移 |
| レイテンシ要求 | 数十ms(ユーザー体験) | 秒〜分(リトライ許容) |
| 失敗時の扱い | 即エラー返却(副作用なし) | リトライ+補償で結果整合 |
| 外部依存 | 持たない(自DBのみ) | PSPに依存(タイムアウト前提) |
主要コンポーネントの深掘り
冪等キーによる二重支払い防止。 ネットワークは「レスポンスが返らないが処理は成功している」状態を必ず生みます。クライアントはリトライし、二重課金の危険が生まれます。対策は、idempotency_keys テーブルに (merchant_id, key) のユニーク制約を張ることです。受付時にこのキーで行を INSERT し、制約違反なら「処理中」または「確定済み」を意味するので、保存済みレスポンスをそのまま返します。ポイントは、キーのINSERTと取引の記帳を同一トランザクションでコミットすること。別々にすると、キーだけ入って記帳前にクラッシュした場合に矛盾します。
CREATE TABLE idempotency_keys (
merchant_id TEXT NOT NULL,
key TEXT NOT NULL,
status TEXT NOT NULL, -- 'in_progress' | 'completed'
response JSONB, -- 確定後のレスポンス本体
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (merchant_id, key)
);
ダブルエントリ台帳による整合性。 残高を1つの数値カラムで持ちUPDATEで増減させる設計は、更新競合と履歴喪失を招くため避けます。代わりに複式簿記(ダブルエントリ)の考え方を使います。すべての取引は借方(debit)と貸方(credit)の複数行に分解され、1取引内の金額合計は必ずゼロになります。残高は「その口座に属する仕訳行の合計」として導出します。行は不変(append-only)で、間違えたら逆符号の仕訳を追加して打ち消します。これにより任意時点の残高が仕訳から再構成でき、監査に耐えます。
取引 t_789: ユーザーAがショップBへ4,980円決済
ledger_entry #1 account=user_A amount=-4980 (借方: Aの残高が減る)
ledger_entry #2 account=merchant_B amount=+4980 (貸方: Bの残高が増える)
→ 合計 = 0(不変条件。崩れたら記帳を拒否)
不変条件「取引内合計ゼロ」をDB制約やアプリのアサーションで強制すれば、金額の湧き出し・消失をコード上のバグから守れます。行の追記のみでUPDATEを伴わないため、追記に最適化された永続化の考え方はWrite-Ahead Logに通じます。
Sagaによる分散トランザクション。 「自台帳の記帳」と「外部PSPへの課金」は別システムにまたがり、2フェーズコミットを外部PSPに強制できません。そこでSagaパターンを使い、一連の処理を複数のローカルトランザクションに分割し、各ステップに補償トランザクション(compensation)を用意します。あるステップが失敗したら、成功済みの前ステップを逆順に補償して全体を巻き戻します。
| ステップ | 正処理 | 補償処理 |
|---|---|---|
| 1. 与信枠確保 | ユーザー残高を保留(hold)計上 | 保留を解放(逆仕訳) |
| 2. PSP承認 | PSPへauthorizeを送信 | PSPへvoid(承認取消)を送信 |
| 3. 売上確定 | PSPへcaptureし確定記帳 | PSPへrefund(返金)を送信 |
| 4. 完了通知 | 加盟店へWebhook送出 | 取消Webhookを送出 |
Sagaはオーケストレーション型(中央のコーディネータが各ステップを指揮)か、コレオグラフィ型(各サービスがイベントを購読して連鎖)で実装します。決済では処理の可視性とデバッグ性が重要なため、状態を1箇所で追えるオーケストレーション型が扱いやすい選択です。
一度成立した承認や送金は、削除するのではなく打ち消す取引を追加して相殺します。台帳には正処理と補償の両方が痕跡として残り、これが後述の監査とリコンサイルの前提になります。データの消去ではなく相殺で整合性を回復する点が金融系の特徴です。
リコンサイルと監査ログ。 どれだけ丁寧に補償しても、PSP側の遅延確定・部分返金・こちらのクラッシュにより、自台帳とPSPの記録がズレることは避けられません。そこでバッチのリコンサイル(reconciliation)を回します。PSPが日次で発行する明細(settlement report)を取り込み、取引IDや金額をキーに自台帳と1件ずつ突合し、(a) 自台帳にありPSPにない、(b) PSPにあり自台帳にない、(c) 金額不一致、の3種の差分を検出します。多くは一時的なタイミング差なので、確定するまで保留し、恒久的な不一致だけを人手または自動補正に回します。
監査ログは、状態遷移と金銭移動のすべてを不変・追記のみで記録します。台帳自体がappend-onlyなので事実上の監査証跡になりますが、加えて「誰が・どのAPIキーで・いつ・どのリクエストID(冪等キー)で」操作したかを別ログに残し、規制対応と事後調査に備えます。
ボトルネックとトレードオフ
横にスクロール
スケール時に最初に詰まるのは、単一データベースの書き込みです。取引が増えると台帳への追記が集中します。分割戦略として merchant_id を分割キーにしたシャーディングが第一選択です。1加盟店の取引と台帳は同一シャードに収まるため、記帳を単一シャード内のローカルトランザクションで閉じられ、分散トランザクションを避けられます。分割の設計はシャーディング戦略やコンシステントハッシュ法が参考になります。ただしユーザー間の残高振替のように2加盟店(=2シャード)にまたがる操作は残り、これは前述のSagaで結果整合的に処理します。
外部PSPのレイテンシと不安定性も本質的な制約です。同期でPSPを待つとピーク時にスレッドが枯渇するため、非同期化とタイムアウト+リトライが必須です。ここでリトライは冪等でなければ二重課金を招くので、PSPへ送る際も自前の冪等キーを引き回し、キューは配信保証のセマンティクスを理解して使います(メッセージキューの配信保証)。多くのキューはat-least-once(少なくとも1回)配信で、重複が起こりうる前提のため、受け手(Worker)側を冪等に作ることで実質exactly-onceの効果を得ます。
整合性モデルの選択もトレードオフです。強整合を全面に敷けば実装は単純ですが、単一DBの書き込みがスループット上限になります。逆に台帳の残高集計を非正規化キャッシュで持てば読み取りは速くなるものの、キャッシュと真実の台帳がズレる余地が生まれます。実務では「真実は常に台帳、表示用残高はキャッシュ、定期的に再集計で補正」という役割分担が定石です。
| 論点 | 選択肢A | 選択肢B | 決済での判断 |
|---|---|---|---|
| 残高の保持 | 数値をUPDATE(1行) | ダブルエントリ台帳(追記) | Bを採用(監査可能性と履歴保持) |
| 外部連携の一貫性 | 分散トランザクション/2PC | Saga+補償 | Bを採用(PSPに2PCを強制不可) |
| 重複防止 | 受信側の後始末に任せる | 冪等キー+ユニーク制約 | Bを採用(設計時に確実に排除) |
| 最終的な整合 | 同期処理のみを信頼 | リコンサイルで突合・補正 | Bを併用(残差を必ず検出) |
決済の設計は「正確性 > 可用性」を明言して始めるのが定石です。二重支払いには冪等キー、整合性にはダブルエントリ台帳、分散トランザクションにはSaga+補償、そして最後の砦としてリコンサイルという4点セットを、それぞれ「なぜその選択か」とセットで説明できれば、原理を理解していると評価されます。
決済の設計は、分散システムの一貫性・可用性・分割耐性のトレードオフが最も先鋭に現れる題材です。土台となるデータベースの理論はデータベース、非同期連携の基盤はDevOpsの各トピックも合わせて参照してください。
システム設計の記事ガイド
決済システムの設計を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
決済
比較で見る軸
難易度: advanced / カテゴリ: システム設計 / タグ数: 6
導入後に効く点
残高はダブルエントリ台帳で表現する。1取引は借方と貸方の複数行で合計が必ずゼロになり、行はappend-only(不変)。修正は逆仕訳で打ち消し、UPDATEで金額を書き換えない。これが監査可能性と整合性の土台になる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- システム設計
- タグ数
- 6
判断チェックリスト
- 自社の用途が「決済 / 分散トランザクション」に近いか確認する。
- 強みである「決済APIは必ず冪等キー(Idempotency-Key)を受け取り、`(merchant_id, key)` にユニーク制約を張って結果をキャッシュする。リトライやダブルクリックが同じキーで届いても、課金は1回だけに収束させる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。