最後の在庫を二人が予約した:設計原理
一言でいうと
在庫の減算・予約id・キャンセルをアトミックにまとめ、過剰販売を防ぎます。
なぜ必要なのか
在庫が1つ残っているのに、2つのリクエストが同時に参照して、どちらも予約に成功しました。その後、同じキャンセルメッセージが2回届くと、在庫が元より増えました。予約の重複排除とキャンセルの重複排除は、それぞれ別の状態遷移であり、残りの数量の検査も、減算と同じトランザクションの中にある必要があります。
どう動くのか
stockは現在の残りの数量を、reservationsは、予約id・商品・数量・キャンセルの有無を保管します。同じ予約idと同じ内容の再リクエストは、Falseで終わり、別の内容は衝突です。最初の予約でだけ在庫を減算し、キャンセルは、activeからcancelledに変わるときに1回だけ在庫を戻します。キャンセルされた予約idを、同じ予約として再利用しても、新しい予約はできません。
재고 검사 + 예약 삽입 + 차감 → COMMIT
예약 active → 취소 + 재고 반환 → cancelled → 재취소 무동작
契約を読んで失敗を予測するワークシート
以下は、実装を丸ごと暗記するための解答ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を壊しています。変更後も、正常なケースは通ることがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが現れるかを予想し、実装したあとで、その予想と結果を比べます。
1. 数量を整数の契約に固定する
quantity(value)は、boolを除く正のintだけを返し、それ以外はValueErrorです。
判断の根拠: 負の予約が、在庫を増やせないようにします。
レビューする誤った変更の断片:
not isinstance(value,int)
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
2. 在庫と予約を分けて保存する
init_db(path)は、stock(sku TEXT PRIMARY KEY,available INTEGER NOT NULL)とreservations(id TEXT PRIMARY KEY,sku TEXT NOT NULL,qty INTEGER NOT NULL,cancelled INTEGER NOT NULL DEFAULT 0)を、冪等に作成します。
判断の根拠: キャンセルされた予約も残しておいて初めて、同じキャンセルと再予約を区別できます。
レビューする誤った変更の断片:
CREATE TABLE reservations
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
3. 商品の初期化を、在庫の変更と区別する
add_stock(path,sku,amount)は、boolを除く0以上のintを検査し、新しい商品だけをINSERTします。重複した商品は、IntegrityErrorです。
判断の根拠: 同じ初期化コマンドが、使用中の在庫を上書きしないようにします。
レビューする誤った変更の断片:
INSERT OR REPLACE INTO stock VALUES
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
4. 残りの数量を取得する
available(path,sku)は、保存された数量を返し、商品がなければKeyErrorです。
判断の根拠: ない商品を売り切れと区別して、間違った商品idを隠さないようにします。
レビューする誤った変更の断片:
return 0
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
5. 予約と在庫の減算をまとめる
reserve(path,rid,sku,qty,fault=lambda:None)は、数量を検証します。既存の予約idは、同じ商品/数量ならFalse、違えばValueErrorです。新しい予約は、商品の存在と在庫を確認して、減算→fault→予約の挿入のあとにTrueです。不足・ない商品はValueErrorです。
判断の根拠: 検査と減算を同じトランザクションに置き、障害のときはすべてロールバックします。
レビューする誤った変更の断片:
db.commit()
fault()
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
6. キャンセルも1回だけ反映する
cancel(path,rid)は、ない予約・すでにキャンセルされた予約ならFalseです。activeの予約なら、同じトランザクションで、cancelled=1と在庫の返却を行ってTrueです。
判断の根拠: キャンセルメッセージの再送で、在庫が増え続けてはいけません。
レビューする誤った変更の断片:
if row is None:
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
7. 予約の状態を確認する
reservation(path,rid)は、(sku,qty,cancelled)のタプル、またはNoneを返します。
判断の根拠: 戻り値だけでなく、キャンセルの状態がDBに残ったかを読みます。
レビューする誤った変更の断片:
return db.execute("SELECT sku,qty,0
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
8. 最後の在庫の競合を再現する
compete(path,sku)は、reserveを、互いに異なるid first/second、数量1で、2つのスレッドで実行します。ValueErrorだけをFalseに変えた、入力順の結果のリストを返します。
判断の根拠: 在庫1と在庫2の2つの状況を比べて初めて、無条件に1つだけを成功させる実装も、見分けられます。
レビューする誤った変更の断片:
["first","first"]
この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。
現場での姿
決済の承認と在庫の予約を、1つの分散トランザクションにまとめるラボではありません。予約の期限切れと、決済の失敗の補償は、別の流れです。取得した時点の在庫の数字を信じず、書き込むトランザクションの中で確認する原理を、小さなSQLite DBで学びます。
次のラボですること
8つのステップが、1つの実行可能な成果物につながります。数量を整数の契約に固定する → 在庫と予約を分けて保存する → 商品の初期化を、在庫の変更と区別する → 残りの数量を取得する → 予約と在庫の減算をまとめる → キャンセルも1回だけ反映する → 予約の状態を確認する → 最後の在庫の競合を再現する。
各ステップは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとは、わざと境界の比較や後始末のコードを変えて、どの試験が失敗するかを確認してください。前の試験が次のステップでも維持される理由を説明し、このラボが保証しない運用上の条件を1つ書いてみてください。