一覧SD-12-3 · 業務ロジックと競合の防御

予約処理の冪等性と利用制限

何を同じ要求とみなすかを定め、再送を新しい処理として二重計上しない方法を説明します。

説明

通信の再送が同じ予約を二重に作らないよう、冪等キーで処理結果を保存します。ただし、キーだけが同じで内容や利用者が違う場合をどう扱うかも決める必要があります。

座席予約の固定試料を使い、同じ要求の再送、キーを変えない内容変更、別主体の同名キーを比較します。受付期限と、主体単位の回数上限も同じ保存処理へ組み込みます。

何を同じ要求とみなすかを定め、再送を新しい処理として二重計上しない方法を説明します。

1予約を確定し応答を保存する

キーk1、alice、座席A1を時刻0で予約します。受付期限は30です。

from sd12 import * lab = Reservations() print(lab.reserve予約と再送用応答を一体で確定する("alice","k1","A1",0)) print(lab.db.execute("SELECT * FROM bookings").fetchall())

reserved:A1が返り、予約1件を保存します。同じトランザクションで応答と本文ハッシュ、回数も記録しています。

2同じ内容を再送する

同じ主体・キー・座席を時刻1で送ります。

print(lab.reserve("alice","k1","A1",1同じ要求を再送する)) print(lab.db.execute("SELECT COUNT(*) FROM bookings").fetchone()) print(lab.db.execute("SELECT * FROM limits再送で回数が増えていないことを調べる").fetchall())

同じ応答が返り、予約1件・回数1のままです。既存結果の返却であり、新しい予約を再実行していません。

3同じキーで内容を変更する

キーk1をそのまま使い、座席をA2へ変えます。

print(lab.reserve("alice","k1","A2"同じキーの内容を変える,2)) print(lab.db.execute("SELECT seat FROM bookings").fetchall())

key_conflict、A1だけが残ります。キーだけで前回成功を返すと、変更後の要求まで成功したと誤認させるため、本文も照合します。

4同名キーを別利用者から送る

bobもk1という文字列を使ってB1を予約します。

print(lab.reserve("bob","k1"別主体の同名キーを使う,"B1",3)) print(lab.db.execute("SELECT subject,key,result FROM replies ORDER BY subject").fetchall())

alice/k1とbob/k1は別々に保存されます。認証主体をキーの範囲へ含め、他者の結果を返すことを避けます。

5回数上限を主体単位で集計する

aliceの同じ60秒窓で、2件目と3件目を要求します。

print(lab.reserve("alice","k2"同じ主体の2件目を確定する,"A2",4)) print(lab.reserve("alice","k3"キーを変えた上限超過を試す,"A3",5)) print(lab.db.execute("SELECT * FROM limits ORDER BY subject").fetchall())

2件目は確定、3件目はrate_limitedです。aliceは2、bobは1で、キーを変えてもaliceの集計は分散しません。

6期限の直前と境界を比較する

bobの2件目を時刻29で確定し、新しい予約を30で試します。

print(lab.reserve("bob","k2","B2",29期限の直前で予約する)) print(lab.reserve("carol","k1","C1",30期限ちょうどで新規予約を試す)) print(lab.db.execute("SELECT subject,seat FROM bookings ORDER BY subject,seat").fetchall())

29は予約でき、30はexpiredです。carolの予約は残りません。時刻はサーバー側で取得する値を想定し、利用者の申告時刻で延長しません。

7期限後の再送は既存結果の照会として扱う

確定済みalice/k1を期限後に再送します。

print(lab.reserve("alice","k1","A1",40期限後に同じ確定済み要求を照会する)) print(lab.db.execute("SELECT COUNT(*) FROM bookings処理が増えていないことを確認する").fetchone()) print(lab.db.execute("SELECT COUNT(*) FROM replies").fetchone())

reserved:A1を返して、予約も応答記録も4件のままです。新規受付の期限と、既存結果を返せる期間を分けています。実運用では冪等記録の保持期間も契約に含めます。

まとめ

主体・キー・内容を組として同じ要求か照合します。

予約・応答・回数を一体で確定します。

新規受付の期限と既存結果の再返却を分けます。

操作履歴と解説を振り返れます。

Python 3.15 / 固定の公開試料

1
from sd12 import * lab = Reservations() print(lab.reserve("alice","k1","A1",0)) print(lab.db.execute("SELECT * FROM bookings").fetchall())
reserved:A1
[('alice', 'A1', 0)]
2
print(lab.reserve("alice","k1","A1",1)) print(lab.db.execute("SELECT COUNT(*) FROM bookings").fetchone()) print(lab.db.execute("SELECT * FROM limits").fetchall())
reserved:A1
(1,)
[('alice', 0, 1)]
3
print(lab.reserve("alice","k1","A2",2)) print(lab.db.execute("SELECT seat FROM bookings").fetchall())
key_conflict
[('A1',)]
4
print(lab.reserve("bob","k1","B1",3)) print(lab.db.execute("SELECT subject,key,result FROM replies ORDER BY subject").fetchall())
reserved:B1
[('alice', 'k1', 'reserved:A1'), ('bob', 'k1', 'reserved:B1')]
5
print(lab.reserve("alice","k2","A2",4)) print(lab.reserve("alice","k3","A3",5)) print(lab.db.execute("SELECT * FROM limits ORDER BY subject").fetchall())
reserved:A2
rate_limited
[('alice', 0, 2), ('bob', 0, 1)]
6
print(lab.reserve("bob","k2","B2",29)) print(lab.reserve("carol","k1","C1",30)) print(lab.db.execute("SELECT subject,seat FROM bookings ORDER BY subject,seat").fetchall())
reserved:B2
expired
[('alice', 'A1'), ('alice', 'A2'), ('bob', 'B1'), ('bob', 'B2')]
7
print(lab.reserve("alice","k1","A1",40)) print(lab.db.execute("SELECT COUNT(*) FROM bookings").fetchone()) print(lab.db.execute("SELECT COUNT(*) FROM replies").fetchone())
reserved:A1
(4,)
(4,)

演習完了