去年、フードデリバリーアプリでライダーが1週間に3回ダブルブッキングされた。2つの注文が同じ人にアサインされた。怒った顧客。返金。深夜2時にデバッグするオンコールエンジニア(私だ)。
バグはシンプルだった:2つのアプリサーバーが同じミリ秒でライダーが空いているか確認した。両方が「利用可能」を見た。両方が注文をアサインした。
これが古典的なレースコンディションであり、想像以上に多くのシステムを壊す。
コードはこうなっていた:
妥当に見える。まず確認して、それから更新。問題は:別のサーバーがSELECTとUPDATEの間に同じSELECTを実行できること。両方が勝ったと思う。
このパターンは「check-then-act」と呼ばれ、並行システムでは設計上壊れている。
解決策は確認と更新を単一のアトミック操作にすること:
行がすでに予約されていたら、影響された行はゼロ。利用可能だったら、1行更新。レースウィンドウなし。
影響された行数を確認しろ。ゼロなら、ライダーはすでに取られている — 別のを探せ。
単一のSQL文で足りないこともある。在庫を確認し、支払いを検証し、ストックを予約する — すべてアトミックに必要かもしれない。
ここで分散ロックの出番だ。RedisでSET key value NX PX 30000を使えば、有効期限付きのロックが得られる。ロックを取得し、作業をして、解放する。
NXフラグは「存在しない場合のみセット」を意味する — アトミックなcheck-and-set。
適切なロックがあっても、データベース制約を追加しろ。最後の防衛線だ:
これでアプリケーションコードにバグがあっても、データベースがダブルブッキングを許さない。
一度しかアサインできない共有リソースがあるたびに:
ライダー問題はどこにでも現れる。決済処理。在庫管理。座席予約。イベントチケット。あらゆる希少リソース。
間違えると深夜2時にデバッグだ。正しくやれば夜通し眠れる。
check-then-actはアトミックではない。それがレッスンのすべてだ。
— blanho
SELECT status FROM riders WHERE id = 123;
-- 返り値: AVAILABLE
UPDATE riders SET status = 'BOOKED' WHERE id = 123;UPDATE riders
SET status = 'BOOKED', order_id = 456
WHERE id = 123 AND status = 'AVAILABLE';if redis.set(f"lock:rider:{rider_id}", "1", nx=True, px=5000):
try:
# 予約を行う
finally:
redis.delete(f"lock:rider:{rider_id}")ALTER TABLE orders
ADD CONSTRAINT unique_rider_active_order
UNIQUE (rider_id) WHERE status = 'ACTIVE';