二つの窓口が同じ残高を見た — 同時出金と限度額
目標
同じ口座に重なって入ってきた出金で、マイナス残高を再現可能に作ってみて、確認と更新を1つの文に合わせて、その隙間をなくします。1日の累計限度額を同じ方式でかけ、ロックの競合にだけ上限付きのリトライを加えたあと、不変条件の点検と証拠レポートで締めくくります。
なぜ重要なのか
残高を読んで確認してから別に差し引く構造は、1つずつリクエストが入ってくる間は永遠に正常です。同じ口座にリクエストが重なる瞬間、どちらも同じ残高を根拠に承認され、差し引きが2回とも反映されて残高が負の数になります。このとき元帳と残高は互いにずれないので、「元帳の合計 == 残高」だけを見る締めの点検は、この事故を通過させます。 負荷をかけて再現しようとすると、確率に頼ることになります。このラボは、負荷の代わりに重なる地点を合わせる方法で、決定的な再現を作ります。リクエスト40件が1秒もかからず、回すたびに同じ数字が出ます。 採点ツールは、書かれた文を信用しません。残された記録ファイルの値を、実物のDBと1つずつ突き合わせます。採点ツールが競合を再び起こすことはありません。再現が確率なら、採点も揺らぐからです。
ステップ
- /root/limit/make_bank.pyを作成して実行し、/root/limit/bank.dbに口座6個と期首の元帳6行を入れてください。
- /root/limit/race_naive.pyで、読み取り・更新・書き込みの経路を作り、A-RACE口座に2人x20ラウンドで重ねて回し、結果を/root/limit/race_naive.jsonに残してください。
- /root/limit/transfer.pyに条件付き更新の経路を作り、A-SAFE口座に同じ条件で回して、結果を/root/limit/race_safe.jsonに残してください。
- A-FEE口座で書き込みロックを自分で体験し、busy_timeoutが0の場合と十分な値の場合の違いを、/root/limit/lock_probe.jsonに残してください。
- A-DAILY口座に、1日の累計限度額300000ウォンを更新文の中でかけ、結果を/root/limit/daily_limit.jsonに残してください。
- A-RETRY口座に3人x10ラウンドでリクエストを入れ、ロックの競合にだけかける上限付きのリトライポリシーとその結果を、/root/limit/retry_policy.jsonに残してください。
- 口座6個に3つの不変条件をかけ、違反の一覧を/root/limit/invariant.jsonに残してください。
- ここまでの数字を元帳から取り出して、/root/limit/limit_report.mdに4つの節で整理してください。
参考
- 出力物はすべて/root/limitの下にまとめます。営業日付は2026-09-17の1つだけを使います。
- 重なりの作り方: ラウンドごとに「両方が到着」と「両方が読んだ」の2回を待てば、同じ残高を読みます。ファイルを1つずつ作っておき、その数を数えれば十分です。
- 変わった行数: SQLiteではchanges()、Pythonのsqlite3ではカーソルのrowcountです。
- BEGINを直接打つには、sqlite3.connect(..., isolation_level=None)で開く必要があります。
- 状態を戻す: sqlite3 /root/limit/bank.db "UPDATE account SET balance=150000 WHERE acct_id='A-RACE';"
- よくある間違い: 読んでおいた残高で承認を判定する、累計限度額を別の欄に持つ、業務の判定(残高不足)にまでリトライをかける、点検を元帳の合計1つだけにする。
口座のスナップショットを作る
/root/limit/make_bank.pyを作成して実行し、/root/limit/bank.dbにaccount、ledger、attemptの3つのテーブルと、口座6個、期首の元帳6行を入れてください。
口座は、A-RACE 150000/100000、A-SAFE 150000/200000、A-DAILY 1000000/300000、A-RETRY 300000/500000、A-FEE 80000/50000、A-POOL 1950000/5000000です(残高/1日の限度額)。期首の行は、pathを'OPEN'にし、biz_dateは2026-09-17に揃えます。あとのステップがこの行に手を触れないようにしておけば、再度採点しても同じ答えが出ます。
重なる2つのリクエストでマイナス残高を再現する
/root/limit/race_naive.pyを作成し、A-RACE口座に、作業者2人x20ラウンドx10000ウォンで重ねて回して、結果を/root/limit/race_naive.jsonに残してください。
残高を読み、読んだ値で判断し、そのあとbalance = balance - amountで更新する順序を、そのまま保ってください。ラウンドごとに2人の作業者が両方読むまで待たせれば、同じ残高を見ます。記録には、承認/拒否の数、承認の合計、最後の残高を入れ、attemptテーブルにもリクエストごとに1行ずつ残してください。
確認と更新を1つの文に入れる
/root/limit/transfer.pyを作成し、A-SAFE口座にステップ2と同じ条件で回して、結果を/root/limit/race_safe.jsonに残してください。
残高の条件をUPDATEのWHEREに付け、承認するかどうかは、変わった行数で判定します。読んでおいた残高は、記録(seen_balance)にだけ使ってください。読んだときは十分に見えたのに拒否されたリクエストが何件かが、このステップの証拠です。
書き込みロックとbusy_timeoutを自分で体験する
A-FEE口座で、一方がBEGIN IMMEDIATEでロックを握っている間に、同じ更新を、busy_timeoutが0の場合と十分な値の場合でそれぞれ試し、/root/limit/lock_probe.jsonに残してください。
握る側は1000ウォン、待つ側は2000ウォンを引き、pathは'lock'と書きます。busy_timeoutが0なら即座に失敗し、十分な値ならロックが解けるまで眠って成功します。ブロックされた試行も、attemptテーブルにdecision='busy'で残してください。記録がなければ、何があったのかわかりません。
1日の累計限度額を同じ文の中に入れる
A-DAILY口座に2人x10ラウンドx50000ウォンを入れ、1日の限度額300000ウォンを更新文の中で判定して、結果を/root/limit/daily_limit.jsonに残してください。
累計額を別の欄に持っていると、その欄と元帳がずれた瞬間に限度額が嘘をつきます。その日の出金合計を元帳で数えるサブクエリを、UPDATEの条件に入れてください。拒否の理由は、残高不足と限度額超過を区別して残します。
リトライをポリシーとして書き、上限を守る
A-RETRY口座に3人x10ラウンドx20000ウォンを入れ、ロックの競合にだけかける上限付きのリトライポリシーとその結果を、/root/limit/retry_policy.jsonに残してください。
busy_timeoutを0にしておくと、競合が例外として上がってくるので、それだけを捕まえてもう一度かけます。残高不足は、何回かけ直しても同じ答えなので、かけてはいけません。リクエストごとに何回試行したかをattempt.attemptsに残し、ポリシーの上限を超えた試行がないようにしてください。
3つの不変条件で口座6個を点検する
口座6個に、balance_matches_ledger、no_negative_balance、daily_limit_respectedの3つをかけ、違反の一覧を/root/limit/invariant.jsonに残してください。
最初の不変条件だけを見ると、ステップ2の事故は通過します。承認された分は、元帳にもすべて書かれているからです。違反ごとに、口座、どの不変条件か、観測値(observed)、基準値(limit)をあわせて書いてください。採点ツールは同じ計算をDBで再度行い、一覧を突き合わせます。
証拠レポートで締めくくる
/root/limit/limit_report.mdに、「何が起きたか」「どう再現したか」「どう防いだか」「残るリスクと運用ルール」の4つの節を置き、元帳から取り出した数字とともに書いてください。
数字を手で書き写すと、1回は合っても、次の実行から間違います。DBを読んでレポートを作るスクリプトを書いてください。「何が起きたか」の節には最後の残高と超過して出た金額、再現の節には重ねて回したリクエストの数、防いだ節には条件付き更新で承認された件数、最後の節には当日の限度額まで出た金額とリトライを含む試行の合計が、なければなりません。