送金の途中で失敗しても残高を保つ:設計原理
一言でいうと
減算・加算・送金の記録をアトミックにまとめ、同時の残高競合を再現します。
なぜ必要なのか
送信口座からはお金を引いたものの、受信口座に足す前にプロセスが死にました。リトライには送金idがなかったので、またお金を引きました。逆に、送金の記録だけを先にコミットすると、リトライは完了したと判断して、入金が永遠に抜け落ちます。金額の計算を整数で正確に行っても、トランザクションの境界が間違っていれば、結果は間違います。
どう動くのか
accountsとtransfersを、同じSQLiteファイルに置きます。有効な金額と、互いに異なる口座を検証し、BEGIN IMMEDIATEの中で、再送かどうかと残高を確認します。減算のあとに障害の注入地点を置き、入金と送金の記録まで、一度にコミットします。同じidと同じ内容は、重複による何もしない処理で、別の内容は衝突です。最後に、残高が足りない2つの送金を同時に実行して、1つだけが成功するかを確認します。
BEGIN IMMEDIATE → 중복/잔액 검사 → 차감 → 장애 지점 → 입금+기록 → COMMIT
契約を読んで失敗を予測するワークシート
以下は、実装を丸ごと暗記するための解答ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を壊しています。変更後も、正常なケースは通ることがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが現れるかを予想し、実装したあとで、その予想と結果を比べます。
1. 元帳テーブルを作る
init_db(path)は、accounts(id TEXT PRIMARY KEY,balance INTEGER NOT NULL)とtransfers(id TEXT PRIMARY KEY,source TEXT NOT NULL,target TEXT NOT NULL,amount INTEGER NOT NULL)を、冪等に作成します。
判断の根拠: 同じ送金idが2回コミットされないように、一意性をDBに置きます。
レビューする誤った変更の断片:
CREATE TABLE transfers
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
2. 口座を1回だけ作る
add_account(path, account_id, amount)は、boolを除く0以上のintだけを許可し、INSERTで口座を作ります。すでにある口座は、sqlite3.IntegrityErrorで拒否し、残高は保持します。
判断の根拠: 初期化のUPSERTが、実際の残高を初期値に戻してしまわないようにします。
レビューする誤った変更の断片:
INSERT OR REPLACE INTO accounts VALUES
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
3. ない口座と0ウォンを区別する
balance(path, account_id)は、口座の残高を返し、なければKeyErrorです。
判断の根拠: ないことを0に変えると、間違った口座への送金を進められてしまいます。
レビューする誤った変更の断片:
return 0
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
4. 送金の入力を検証する
validate_transfer(source,target,amount)は、source!=targetで、amountがboolを除く正のintならNone、そうでなければValueErrorです。
判断の根拠: 同一口座の送金と、負の送金を、事前に拒否します。
レビューする誤った変更の断片:
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
5. 3つの書き込みを1つのトランザクションにまとめる
transfer(path,tx_id,source,target,amount,fault=lambda:None)は、同じid/内容ならFalse、idの衝突・ない口座・不足した残高はValueErrorです。新しい送金は、減算→fault()→入金→transfersへの挿入を、アトミックに実行してTrueです。
判断の根拠: 減算のあとに強制的に例外を出して、残高と送金の記録が、どちらも元のままかを検査します。
レビューする誤った変更の断片:
db.commit()
fault()
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
6. 送金履歴を決定的に読む
history(path)は、transfersをid昇順の(id,source,target,amount)タプルのリストで返します。
判断の根拠: 入力の順序と取得の順序を混同しないように、ORDER BYを明示します。
レビューする誤った変更の断片:
ORDER BY id DESC"
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
7. 保存量を確認する
total_balance(path)は、accounts.balanceの合計です。口座がなければ0です。
判断の根拠: 個々のリクエストの戻り値と、全体の金額の保存を、別々に検証します。
レビューする誤った変更の断片:
MAX(balance)
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
8. 同時の送金が残高を超えない
compete(path,source,target,amount)は、互いに異なるid race-a/race-bで、同じ金額のtransferを、2つのスレッドで実行します。ValueErrorだけをFalseに変換した結果のリストを返します。残高が1回分しかなければ、成功は1つです。
判断の根拠: トランザクションの外で残高を先に検査すると、2つのリクエストが同じ残高を見て、両方とも成功することがあります。
レビューする誤った変更の断片:
["race-a","race-a"]
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
現場での姿
実際の金融の元帳の、会計・監査・法的な要件を代替するシステムではありません。通貨・小数の単位・手数料・複数通貨は扱わず、金額は最小単位の正の整数です。外部の決済システムの呼び出しは、このDBトランザクションに含まれないので、別の設計が必要です。
次のラボですること
8つのステップが、1つの実行可能な成果物につながります。元帳テーブルを作る → 口座を1回だけ作る → ない口座と0ウォンを区別する → 送金の入力を検証する → 3つの書き込みを1つのトランザクションにまとめる → 送金履歴を決定的に読む → 保存量を確認する → 同時の送金が残高を超えない。
各ステップは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとは、わざと境界の比較や後始末のコードを変えて、どの試験が失敗するかを確認してください。前の試験が次のステップでも維持される理由を説明し、このラボが保証しない運用上の条件を1つ書いてみてください。