TT Lab
はじめる
学ぶ 学習パス コース

冪等性 — 二度押しても決済は一度だけ

最後の在庫を二人が予約した:設計原理

TT Labで続きを見る

一言でいうと

在庫の減算・予約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つ書いてみてください。