最初に、決済システムで最も重要なことを述べます。二重課金や残高不整合は、「気をつけて運用する」では絶対に防げません。コードの構造——冪等性(べきとうせい)と原子性(アトミシティ)——で、事故を「起こりえない」状態にして初めて防げます。 ネットワークは切れ、リクエストは重複し、Webhookは順不同で二重に届く。これを「前提」として設計したシステムだけが、本番で決済を止めずに正しく回り続けます。
本記事は、私が決済信頼性レイヤーを設計・主導し、本番稼働中の二重課金・残高不整合を0件に維持しているサーバーレス決済基盤の実例をもとに、(1) 二重課金がなぜ起きるのか、(2) どう構造で防ぐのか、(3) 発注者がベンダーに何を要求すべきか、をまとめます。決済を含むシステムを発注する立場の方にも、実装する立場の方にも役立つ内容です。
1. なぜ二重課金は起きるのか
二重課金は、開発者の不注意で起きるのではありません。分散システムの本質的な性質から起きます。
[スマホ] ──決済リクエスト──> [サーバ]
│ │ 課金処理(成功)
│ <──── タイムアウト ──── │ ← 応答が返る前に回線が切れる
│
│ 「失敗したかも」と思い再送 │
│ ──決済リクエスト(再送)─> [サーバ]
│ │ また課金してしまう ← 二重課金!
モバイル回線のタイムアウト、API Gateway や Lambda の自動リトライ、ユーザーの「反応がないからもう一度タップ」——これらは正常な動作です。問題は、サーバ側が「同じ決済リクエストが複数回届く」ことを想定していないと、その都度課金してしまうことです。
つまり、リトライ自体を止めることはできない(止めるべきでもない)。やるべきは、「何回届いても、課金は1回だけに収束する」よう設計することです。これが冪等性です。
2. 第一の柱:冪等性キーで「1回だけ」に収束させる
冪等性とは、「同じ操作を何回実行しても、結果が1回実行したときと同じになる」性質です。決済では、クライアントが発行する一意の冪等性キーを使って実現します。
考え方はシンプルです。「このキーの処理は初めてか?」を条件付き書き込みでチェックし、初回だけ処理する。
def already_processed(idempotency_key: str) -> bool:
try:
# キーが存在しない時だけ書き込む(条件付き挿入)
table.put_item(
Item={"idem_key": idempotency_key, "ttl": now() + NINETY_DAYS},
ConditionExpression="attribute_not_exists(idem_key)",
)
return False # 初回 → これから処理する
except ConditionalCheckFailedException:
return True # 既に処理済み → スキップ(二重課金を防ぐ)
ポイントは、この重複チェックと実際の課金処理を、同じトランザクションの中で原子的に実行することです。私が手がけた決済基盤では、冪等キーの挿入・残高の更新・取引履歴の記録を、単一の TransactWriteItems(DynamoDBの原子的トランザクション)にまとめています。これにより、「重複チェックは通ったが課金で失敗した」「課金はしたが記録が残らなかった」といった中途半端な状態が原理的に発生しません。
設計の肝: 冪等性は「あとから付け足す機能」ではなく、決済処理の骨格です。重複排除と本処理が別々のトランザクションに分かれていると、その隙間で事故が起きます。同じトランザクションに束ねることが、本番二重課金0件の核心でした。
3. 第二の柱:原子的な残高更新で「競合」に勝つ
二重課金と並ぶ事故が、残高不整合です。同じ口座に対して、決済・チャージ・返金が同時に走ると、典型的な「読み込んで→計算して→書き戻す」処理はレース(競合)を起こします。
時刻 t1: 処理A が残高を読む(1000円)
時刻 t2: 処理B が残高を読む(1000円) ← 両方とも1000円を見ている
時刻 t3: 処理A が 800円を引いて書く(200円)
時刻 t4: 処理B が 800円を引いて書く(200円) ← 本来は -600円のはず!
これを防ぐには、「読んでから書く」のではなく、データベースの原子的な操作で、条件付きに増減する設計にします。
# read-modify-write をやめ、原子的 ADD + 条件式に置き換える
transact_write([
{
"Update": {
"Key": {"account_id": account_id},
"UpdateExpression": "ADD balance :delta", # 原子的に増減
"ConditionExpression": "balance >= :amount", # 残高不足なら失敗
"ExpressionAttributeValues": {":delta": -amount, ":amount": amount},
}
},
# …同じトランザクションに冪等マーカー・履歴も含める
])
残高下限(残高 ≥ 金額)やチャージ上限(残高 + 金額 ≤ 上限)を条件式で表現することで、競合下でも残高がマイナスに沈んだり二重に減算されたりすることを、設計時点で構造的に排除できます。
さらに、再試行の扱いも重要です。一時的な競合(楽観ロックの衝突)だけを指数バックオフで再試行し、残高不足のような意味論的な失敗は再試行しない(何度やっても結果は同じだから)。この峻別ができていないと、無駄なリトライでシステムに負荷をかけたり、エラーの原因を取り違えたりします。
4. 第三の柱:金額はサーバ側で再計算し、Webhookは重複排除する
金額をクライアントから受け取らない
意外に多い脆弱性が、クライアントから送られてきた金額を、そのまま決済に使うことです。これは、利用者がリクエストを改ざんすれば「1000円の商品を1円で買える」典型的な金額改ざん脆弱性です。
金額は必ず、注文内容からサーバ側で再計算する——これが鉄則です。私のプロジェクトでも、初期のセキュリティ監査でまさにこの「クライアント指定金額の改ざん」を検出し、サーバ側で金額を解決する仕組みに修正しています。
Webhookは「少なくとも1回・順不同」で来る
Stripeなどの決済プロバイダからのWebhookは、「少なくとも1回」配信され、順不同で届くことがあります。つまり、同じイベントが複数回飛んでくるし、古いイベントが新しいイベントの後に届くこともあります。
これに対しては、
- イベントIDの一意制約で再送を排除する(同じイベントは1回だけ処理)
- イベントの発生時刻を比較して、古いイベントの後着でサブスク状態が巻き戻るのを防ぐ
- 生のWebhookペイロードに含まれるPII(カード情報・メール等)は、保存前にマスキングする
私のサブスク課金基盤では、Webhookのイベントを「イベントID一意制約 + 発生時刻による順序保証 + PII墨消し」で冪等化し、再送・順序逆転・PII保持の3つを同時に防いでいます。
5. 発注者のための「決済システム発注チェックリスト」
ここまでの内容を、発注者がベンダーを見極めるための質問に落とし込みます。決済を含むシステムを発注するとき、次の質問への答え方で、相手の信頼性がわかります。
発注チェックリスト
- 「二重課金をどう防ぎますか?」——「冪等性キーと条件付き書き込みで、リトライしても1回に収束させます」と構造で答えられるか。「気をつけます」「テストします」は危険信号。
- 「残高の整合性はどう保証しますか?」——原子的トランザクション・条件式での競合制御を説明できるか。
- 「金額の改ざんは防げますか?」——「金額はサーバ側で再計算します」と即答できるか。
- 「Webhookの重複・順序逆転に対応していますか?」——イベントIDの重複排除・順序保証を設計しているか。
- 「障害時はどう振る舞いますか?」——決済を止めるべき所と、最終的整合に倒す所の判断(fail-closed / fail-open)を説明できるか。
- 「テストと監査はどうしていますか?」——決済ロジックの単体テスト(DBなしで検証できる純粋関数化)、セキュリティ監査の有無。
決済は「動けばいい」では済まない領域です。正しさをコードの構造で保証し、それを言語化して説明できる——これが、決済システムを任せられる相手の条件です。
なぜここまでやるのか: 決済は、一度事故を起こせば金銭的損害だけでなく、信用そのものを失います。だからこそ私は、決済の「正しさ」を運用ルールやレビューの注意深さではなく、冪等性・原子性・サーバ側金額解決という構造で保証し、さらに可観測性(アラート)・回復性(バックアップ・DR)・最小権限IAMまで作り込みます。本番稼働中の二重課金0件は、この一貫した設計の結果です。
よくある質問(FAQ)
Q. 二重課金はどうやって防ぐのですか?
クライアントが発行する一意の「冪等性キー」を使い、条件付き書き込みで「この処理は初めてか」を判定して、初回だけ課金します。重要なのは、この重複チェックと実際の課金を同じトランザクションで原子的に実行すること。これにより、ネットワーク断やリトライで同じ決済が複数回届いても、課金は1回に収束します。運用の注意ではなく、コードの構造で防ぐのが要点です。
Q. Stripeを使えば二重課金は自動で防げますか?
Stripe自体に冪等性キーの仕組みはありますが、それを正しく使い、かつ自社側の処理(残高更新・Webhook処理・在庫引当など)も冪等に設計しなければ、二重課金や不整合は起こりえます。Stripeは強力な道具ですが、「Stripeを使えば自動で安全」ではありません。自社側の決済信頼性レイヤーの設計が必要です。
Q. 決済システムの開発を発注するとき、何を確認すべきですか?
「二重課金をどう防ぐか」「残高整合性をどう保証するか」「金額改ざんを防げるか」「Webhookの重複・順序逆転に対応するか」を質問してください。これらに「冪等性キー」「原子的トランザクション」「サーバ側で金額再計算」と構造で答えられる相手は信頼できます。「気をつけます」「テストでカバーします」しか言えない相手は要注意です。
Q. 既存の決済システムが二重課金を起こしています。直せますか?
多くの場合、直せます。まず原因を切り分け(リトライ経路、Webhook処理、残高更新の競合など)、冪等性キーと原子的トランザクションを導入して、再発を構造的に防ぎます。本番を止めずに段階的に移行する設計(二重書き込みによるゼロダウンタイム移行)も可能です。決済が動いている状態での改修こそ、慎重な設計が要ります。
まとめ:決済の「正しさ」は構造で保証する
決済・課金システムで事故を起こさないために、押さえるべきは次の通りです。
- 二重課金・残高不整合は「運用の注意」では防げない——冪等性と原子性という構造で防ぐ。
- 冪等性キー+条件付き書き込みで、何回届いても課金は1回に収束させる。
- 原子的トランザクション+条件式で、競合下でも残高不整合を起こさない。
- 金額はサーバ側で再計算し、WebhookはイベントIDで重複排除・順序保証する。
- 発注者は「どう防ぐか」を質問し、構造で答えられるかで信頼性を見極める。
決済・課金基盤の新規開発、既存システムの二重課金・不整合の改修、Stripe導入の設計レビュー——いずれも、本番二重課金0件を支えたのと同じ水準で、要件定義からセキュリティ・運用までお引き受けします。決済の信頼性に不安があれば、まずは現状の課題の切り分けからご相談ください。