プログラマのための IT 教科書

間違えられない処理を作る

この部の 13 / 13 章 ・ 全体で 55 / 76 章 ・ 読了目安 50 分

この章を読むとできるようになること
  • 「もう一度実行してよいか」で処理を見分けられる
  • タイムアウトを「結果不明」として扱える
  • 二重実行を原理的に防ぐ設計ができる

システムの中には、間違えると取り返しがつかない処理があります。

決済        二重に引き落とされた。返金には手間も信用の損失も伴う
在庫引当    実際には無い商品を売ってしまった
予約        同じ枠に2人を入れてしまった
ポイント    同じクーポンが2回使われた
請求        請求書を二重に発行した
通知・送信  同じメールを3通送った。取り消せない
外部発注    発注を二重に出した

これらに共通するのは、「もう一度実行すればいい」が通用しないことです。

普通の機能:  バグ → 直して、もう一度動かせばいい
この種の処理: バグ → 現実世界で何かが起きてしまっている

この章では、この種の処理に共通する設計を扱い、 最後に最も要求が厳しい決済を例として具体的に見ます。

どの業界でも必ず出てくる

EC でなくても、金融でなくても、この種の処理は必ずあります。

SaaS       → 課金、利用量の集計、ライセンス数の管理
予約サービス → 枠の確保
物流       → 出荷指示、伝票発行
社内システム → 承認、発令、権限の付与
ゲーム      → アイテム付与、ガチャの抽選結果

「一度きりしか実行してはいけない」処理を見分けられることが、 この章のゴールです。

共通する原則

1. 二重実行は必ず起きる。防ぐのは設計

まず、なぜ二重に実行されるのかを整理します。

□ 利用者がボタンを2回押した
□ 通信がタイムアウトし、クライアントが再送した(モバイルアプリの実務)
□ サーバー側のリトライ(性能と負荷対策)
□ キューの再配信(非同期処理とメッセージング)
□ 障害復旧の手動リカバリで、同じバッチをもう一度流した(バッチとジョブ)

どれも日常的に起きます。 だから「起きないようにする」のではなく、 「起きても結果が変わらないようにする」のが正しい方針です。

// 呼び出し側が生成した冪等キーで、同じ操作は1回しか成立させない
func (s *Service) Execute(ctx context.Context, key IdempotencyKey, req Request) (*Result, error) {
    if existing, ok := s.store.FindByIdempotencyKey(ctx, key); ok {
        return existing, nil          // 2回目以降は実行せず、前回の結果を返す
    }
    // 実行し、キーとともに結果を保存する(一意制約で同時実行も防ぐ)
}
□ 冪等キーは**呼び出し側**が生成する(UUID など)
□ 「同じ操作の2回目」を判定できる形で保存する
□ 保存は**一意制約**で守る(アプリでのチェックは競合に負ける。見つけにくいバグ)
□ ボタンの二度押し防止(フロントエンドの実務)は補助であって、本体ではない

2. タイムアウトは「失敗」ではなく「結果不明」

この誤解が、最も多くの事故を生みます。

リクエストを送った → タイムアウトした

× 「失敗したから、もう一度送ろう」
○ 「届いているかもしれない。まず状態を確認しよう」
□ 状態を照会する手段を、必ず用意する
□ 再送する場合は、同じ冪等キーを使う
□ 「不明」という状態を持てるようにする(成功/失敗の2値にしない)
「不明」を表現できない設計にしない
status: 'success' | 'failed'

この2値だと、タイムアウトした処理をどちらかに倒すしかありません。 そしてどちらに倒しても間違いです。

status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'unknown'

「まだ分からない」を持てることが、後からの調査と復旧を可能にします。

3. 状態機械として設計する

この種の処理は、たいてい状態を持ちます。

[申込] → [確保] → [確定] → [完了]
           │        │
           ↓        ↓
        [解放]   [取消/返金]
□ 取りうる状態を、すべて書き出す
□ どの状態からどこへ遷移できるか(できないか)を表にする
□ 状態の変更は必ず1箇所を通す(ドメイン駆動設計の実践の集約)
□ 直接 UPDATE で状態を書き換えるコードを作らない
同じ言葉が、状態によって別の処理になる

ドメインとは何かで見たとおりです。

状態「キャンセル」の意味
確保しただけ枠を解放するだけ
確定後解放 + 返金 / 補償
実行後(出荷済・送信済)取り消せない。別の手続き(返品・訂正)

「キャンセル機能を作って」を額面通りに受け取ると、必ず事故ります。

4. 数量と金額は整数で持つ

ビットとバイトの内容がそのまま効きます。

□ 金額は最小通貨単位の整数、または Decimal。float を使わない
□ 通貨・単位を値とセットで持つ(ドメインとは何かの値オブジェクト)
□ 丸めの方法とタイミングを仕様として決める
□ 在庫数・ポイント数も、負にならないことを型と制約で守る

1件のずれが、業務では大問題になります。

5. 外部システムとの整合

自社 DB と、外部システム(決済・在庫・メール送信)の両方に、 アトミックに書き込むことはできません(非同期処理とメッセージング)。

外部に依頼した → 成功
自社 DB に記録  → ここで落ちた
→ 現実には実行されたのに、システム上は記録が無い

対策は非同期処理とメッセージングの Outbox パターンと、事前の意図の記録です。

1. 「これから実行する」を自社 DB に記録する(処理中の状態で)
2. 外部に依頼する
3. 結果を記録する
4. 3 に失敗しても、1 の記録から後で照合・復旧できる

「呼ぶ前に記録する」のが要点です。呼んでから記録すると、 落ちた時に何が起きたか分からなくなります。

6. 結果は後から来ることがある

□ 外部システムからの非同期通知(Webhook)
□ 後日の入金・確定
□ 数日〜数ヶ月後の取り消し・異議申し立て

通知を受ける実装では、次が必須です(非同期処理とメッセージング)。

□ 署名を検証する(誰でも偽の通知を送れる状態にしない。認証と認可の実装)
□ 冪等に処理する(同じ通知が複数回届く)
□ 順序が入れ替わる前提で書く
□ 受け取ったら即座に応答し、処理は非同期にする
□ **通知だけに依存しない**(届かないことがある。定期的に照合する)

7. 記録を残す

□ いつ・誰が・何を・どの状態から どの状態へ 変えたか
□ 外部システムに何を送り、何が返ってきたか(秘密情報は除く)
□ 冪等キーで後から引けるようにする

この記録が無いと、事故が起きた時に何も再現できません。 「たぶんこうだったと思います」では、業務側にも監査にも通りません(エンジニアと法律)。

8. 突合する

定期的に、自社の記録と現実を突き合わせます(バッチとジョブ)。

□ 自社の記録と外部システムの記録が一致しているか
□ 在庫データと実在庫が一致しているか
□ 集計と明細の合計が一致しているか
□ 何度流しても同じ結果になるバッチにする
□ ズレた明細を、人が確認できる形で出力する
□ 「ズレ0件」を監視する(バッチとジョブの成功の監視)

ズレは早く見つければ直せます。数ヶ月放置すると追跡が困難になります。

9. 異常系こそテストする

□ 途中で落ちた場合
□ タイムアウトした場合
□ 同じ操作が2回来た場合
□ 通知が3回届いた場合、順序が入れ替わった場合
□ 取り消し・返金・訂正の経路

正常系のテストは、この種の処理では最も価値が低い部分です。 テストを書くの「何をテストするか」で、最も投資すべき箇所になります。

10. 障害時は一人で判断しない

1. 勝手に再実行しない(まず状態を確認する)
2. 影響範囲を件数と金額(または数量)で特定する
3. 記録を残す
4. 上長・関係部署に早く共有する
技術部門だけの問題ではない
□ 利用者への説明と補償の判断
□ 経理・会計への影響
□ 場合によっては報告義務(エンジニアと法律)

新人が一人で判断してよい範囲を、明らかに超えています。 気づいた時点ですぐ上へ(エンジニアとしての立ち振る舞い・ルールを守る — 情報を扱う者の日常)。

具体例1: 在庫の引当

[在庫あり] → [引当(確保)] → [出荷] → [減算確定]
                  │
                  ↓ 一定時間で自動解放
              [在庫に戻る]
□ 引当の期限を決める(決済されないまま押さえ続けない)
□ 解放バッチが冪等であること(二重に戻すと在庫が増える)
□ 同時実行での超過販売を防ぐ(DB の制約か、行ロック)
□ 実在庫とのズレを定期的に突合する
「在庫を確認してから引く」は競合に負ける
// 危険: 確認と更新の間に、他のリクエストが同じことをする(見つけにくいバグ)
if stock.Available >= qty {
    stock.Available -= qty
}

同時に10件来たら、在庫1個の商品が10個売れます。

-- 条件付き更新で、原子的に処理する
UPDATE stock SET available = available - $1
WHERE product_id = $2 AND available >= $1;
-- 更新件数が 0 なら、在庫不足として扱う

「更新できた件数」で成否を判定するのが定石です。

具体例2: 予約

□ 同じ枠に複数人を入れない(一意制約 + 条件付き更新)
□ 仮予約の期限を設ける
□ キャンセル期限と料金の規定を、状態遷移に反映する
□ 時刻とタイムゾーン(見つけにくいバグ)。日をまたぐ予約、夏時間

予約は在庫と同じ構造です。「枠」という在庫を引き当てています。

具体例3: ポイント・クーポン

□ 同じクーポンが2回使われないようにする(利用済みの記録 + 一意制約)
□ 有効期限の判定(境界の扱い。見つけにくいバグ)
□ 失効バッチの冪等性(二重に失効させない)
□ 付与と消費の履歴を残し、残高は履歴から再計算できるようにする
残高を持つか、履歴から計算するか
残高だけ持つ    → 速いが、ずれた時に原因が分からない
履歴だけ持つ    → 正確だが、毎回集計が必要
両方持つ        → 実務ではこれが多い。**定期的に突合する**

「残高が合わない」時に履歴から再計算できることが、 復旧できるかどうかを分けます。

具体例4: 決済(最も要求が厳しい例)

ここまでの原則が、すべて必要になるのが決済です。 決済を扱わない場合も、この節は「厳しい要求の例」として読む価値があります。

この節の位置づけ

以下は決済の一般的な考え方です。 実際の実装は、利用する決済代行サービスの仕様と、 適用される法令・カードブランドの規則に従ってください(エンジニアと法律)。

登場人物

[利用者] --カード--> [自社サービス(加盟店)]
                            │ API
                     [決済代行(PSP)]
                            │
                [アクワイアラ → ブランド → イシュア]

実務では、ほとんどの場合 PSP(決済代行)と話します。 重要なのは、この経路のどこかが遅い・落ちる・タイムアウトすることです。

お金は2段階で動く

1. オーソリ(与信確保)  「1万円使えますか」と枠を押さえる。まだお金は動かない
2. キャプチャ(売上確定) 「実際に請求します」と確定する

商品を発送するまで請求すべきでないため、分かれています。 これは在庫の「引当 → 確定」と同じ構造です。

オーソリには期限がある

押さえた枠は、一定期間で自動的に解放されます(期間はカード会社により異なる)。

発送までに時間がかかる商材では、再オーソリの設計が必要です。 後から気づくと作り直しになります。

カード情報を自社で持たない

□ カード番号を自社サーバーに送らない・保存しない・ログに出さない
□ ブラウザやアプリから PSP に直接送り、トークンを受け取る
□ 自社が扱うのはトークンだけ

これは PCI DSS への対応であると同時に、 漏洩リスクを根本的に無くす設計です。

リクエストを丸ごとログに出す実装(セキュリティ)は、この文脈では致命的になります。

返金・チャージバック

□ 全額返金と部分返金で処理が違う
□ 返金にも失敗しうる
□ チャージバック(利用者の異議申し立て)は数ヶ月後に来ることがある
□ 会計の締めをまたぐ返金の扱いを、業務側と決めておく

見分け方

自分が実装しようとしている処理が「この種のもの」かを判断する問いです。

□ もう一度実行したら、何が起きるか?
□ 取り消せるか? 取り消しにコストはかかるか?
□ 現実世界に影響するか?(お金・在庫・通知・外部への指示)
□ 数字が合わないと困る人がいるか?

1つでも当てはまるなら、この章の原則を適用してください。

クーポンの利用処理を実装します。「利用済みかを確認してから、利用済みにする」というコードをレビューで見つけました。

実務の落とし穴まとめ

  1. 冪等性が無い — 二重実行は必ず起きる
  2. タイムアウトを失敗と決めつけて再実行 — 最も多い事故の原因
  3. 状態が「成功/失敗」の2値 — 「不明」を表現できない
  4. 「確認してから更新」 — 同時実行に負ける。条件付き更新か一意制約で
  5. 金額・数量を float で扱う — ずれる(ビットとバイト)
  6. 外部に依頼してから記録する — 落ちた時に何も分からない。先に記録する
  7. 通知(Webhook)だけに依存 — 届かないことがある。定期照合が必要
  8. 署名を検証しない — 偽の通知を受け入れる
  9. 突合バッチが無い — ズレに気づけない
  10. 正常系しかテストしない — 事故は異常系で起きる
  11. 記録を残さない — 再現も説明もできない
  12. 障害を技術部門だけで処理 — 業務・経理・法務が関わる

まとめ

  • 「もう一度実行すればいい」が通用しない処理は、決済に限らず 在庫・予約・ポイント・請求・通知・外部発注と、どの業界にもある
  • 二重実行は必ず起きる。防ぐのではなく、結果が変わらないように設計する(冪等性)
  • タイムアウトは「失敗」ではなく「結果不明」。 状態に**「不明」を持てるようにする**
  • 状態機械として設計し、「キャンセル」が状態ごとに別処理であることを明示する
  • 金額・数量は整数。単位とセットで持つ
  • 外部に依頼する前に、意図を記録する。Outbox で整合を取る(非同期処理とメッセージング)
  • 通知は署名検証・冪等・順序非保証、そしてそれだけに依存しない
  • 突合バッチが生命線(バッチとジョブ)。「ズレ0件」を監視する
  • テストは異常系にこそ投資する
  • 障害時は再実行しない・記録する・一人で判断しない

公式ドキュメント

迷ったら一次情報に戻ってください。

対象リンク
The Amazon Builders Libraryhttps://aws.amazon.com/builders-library/
Stripe: 冪等リクエストhttps://docs.stripe.com/api/idempotent_requests
microservices.io: Saga パターンhttps://microservices.io/patterns/data/saga.html
PCI Security Standards Councilhttps://www.pcisecuritystandards.org/

章末問題

外部システムへの発注 API を呼んだところ、タイムアウトしました。どうしますか。

第5部はここまでです。次の第6部では、ここまで作ってきたものを どう組み立てるか——設計と品質の話に移ります。

読み終わったら記録しておくと、目次で進み具合が分かります。