在庫が合わない、予約が重なる、送金が止まる
同時実行の不具合は、1件ずつ動かすテストでは起きない。セール当日のECサイトで5つの不具合を順に起こし、それぞれをどこで守るかを確かめる。
舞台:セール当日、人気商品に注文が集まる
同時実行の不具合は、2つのリクエストが同じデータにほぼ同じ瞬間に触れたときだけ起きる。開発中には見つからず、アクセスが集中する日に初めて現れる。
例はPostgreSQLで書き、MySQLで挙動が違うところはその都度ふれる。
在庫が合わない(lost update)
在庫10個の商品に、2件の注文が同時に来る。どちらも在庫を読み、アプリで1を引いて書き戻す。2件とも10を読んで9を書くので、2個売れたのに在庫は9になる。エラーはどこにも出ない。
- 注文A: 在庫を読む:10
- 注文B: 在庫を読む:10
- 注文A: 9を書く
- 注文B: 9を書く(Aの更新を上書き)
- DB(在庫): 2個売れたのに在庫は9
分離レベルを上げても、DBによっては防げない。PostgreSQLのRepeatable Readは後から書いたほうをエラーにするが、MySQL(InnoDB)のRepeatable Readでは上書きがそのまま通る。
加減算で済むなら、計算をDBに寄せるのが確実だ。2件目は1件目のコミットを待ってから最新の値で引くので、在庫は正しく8になる。
UPDATE items SET stock = stock - 1
WHERE id = 42 AND stock > 0;
-- 1行更新なら確保できた。0行なら在庫切れとして断る商品説明の編集のように計算を寄せられない更新は、読んだときのバージョンを条件に書く(楽観的ロック)。0行なら、誰かが先に書いている。
UPDATE items
SET description = $1, version = version + 1
WHERE id = 42 AND version = $2; -- $2 は読んだときのバージョン予約が重なる(write skew)
会議室の予約は「同じ部屋・同じ時間帯の予約が0件なら挿入する」。2人が同時に実行すると、どちらも0件を読み、別々の行を挿入して二重予約になる。
これはスナップショット分離(PostgreSQLのRepeatable Read)でも防げない。検出するのは同じ行への同時の書き込みで、2人は別の行に書いているからだ。
| 起きたこと | スナップショット分離で検出されるか |
|---|---|
| 同じ行を2人が更新した | される |
| 判断に使った行を、ほかの人が変えた | されない |
| 「ない」と確かめた範囲に、ほかの人が行を足した | されない |
守り方は3つある。
- 制約にする。 いちばん漏れがない。どの経路から書いても、DBが拒む。
- 起点の行をロックする。 ただし、まだ存在しない予約の行はロックできない。代わりに親の部屋の行を
SELECT ... FOR UPDATEでロックする。 - Serializableにする。 PostgreSQLは片方を直列化エラー(40001)で失敗させる。アプリに再試行が要る。
-- 整数の等号をGiSTで扱うための拡張
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE reservations ADD CONSTRAINT no_overlap
EXCLUDE USING gist (
room_id WITH =,
tstzrange(starts_at, ends_at) WITH &&
);
-- 重なる予約は、片方が制約違反(23P01)で失敗する送金が止まる(デッドロック)
口座1→2と、口座2→1の送金が同時に走る。一方は口座1を、もう一方は口座2を先にロックし、互いに相手の行を待って止まる。DBは片方を中止するが、PostgreSQLは循環を確かめる前に deadlock_timeout(既定1秒)待つので、そのたびに1秒以上を失う。
全員が「IDの小さい口座から」のように同じ順番でロックすれば、後の人は先の人を待つだけで、循環は生まれない。
BEGIN;
-- 送金の向きに関係なく、IDの小さい順にロックする
SELECT id, balance FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;
UPDATE accounts SET balance = balance - 5000 WHERE id = 1;
UPDATE accounts SET balance = balance + 5000 WHERE id = 2;
COMMIT;それでもゼロにはならない。外部キーの確認のように、アプリから見えないところで順番が逆になることがある。デッドロック(PostgreSQLは40P01、MySQLは1213)は、write skewの直列化エラー(40001)とまとめて、トランザクション全体を最初からやり直す1か所の処理で扱う。1文だけやり直すと、前に読んだ古い値のまま進んでしまう。
func inTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
for attempt := 0; ; attempt++ {
err := runTx(ctx, db, fn) // BEGINからCOMMITまで。失敗したらロールバック
// 40001と40P01だけを、3回まで最初からやり直す
if !retryable(err) || attempt == 2 {
return err
}
time.Sleep(backoff(attempt))
}
}人気商品だけが遅い(長いトランザクション)
注文の処理は、トランザクションの中で在庫を更新し、そのまま決済APIを呼んでからコミットしていた。行のロックはコミットまで外れないので、決済APIを待つ時間がそのままロックを持つ時間になる。
同じ行の更新は1件ずつしか進めない。1行に通せる件数の上限は「1 ÷ ロックを持つ時間」で、サーバーを足しても変わらない。
- SQLだけ(ロック5ms): 約200件
- 決済APIを待つ(800ms): 約1.25件
- 決済APIが30秒でタイムアウト: 約0.03件(1分に2件)
通れない注文はロック待ちで並び、それぞれがDBの接続を握ったまま待つ。プールが尽きると、関係のない画面まで止まる。開いたままのトランザクションは、PostgreSQLのVACUUMやMySQLのundoログの破棄も妨げる。
直し方は、外部APIの呼び出しをトランザクションの外に出すことだ。注文を「決済待ち」として短いトランザクションで書き、決済APIを呼び、結果を別の短いトランザクションで書く。決済には注文IDを冪等キーとして渡す。
列を1つ足しただけで、注文が止まる(DDLのロック待ち)
セールの最中に ALTER TABLE orders ADD COLUMN note text を流した。一瞬で終わるはずが、そこから5分間、注文の読み書きがすべて止まった。
- 分析のSELECT: ordersを読み始める(5分かかる)
- ALTER TABLE: 排他ロックを待つ
- 注文のクエリ: ALTERを追い越せず、後ろに並ぶ
- 分析のSELECT: 5分後に終わる
- ALTER TABLE: ロックを取り、一瞬で終わる
- 注文のクエリ: 5分遅れで再開する
ALTER TABLE の多くはACCESS EXCLUSIVEロックを取り、ふつうのSELECTとも衝突する。待っているALTERの後ろに来たクエリは、ALTERを追い越せない。ALTERがどれだけ速くても、前にいる最も長いクエリの時間だけテーブルが止まる。
-- 長く開いているトランザクションがないかを先に見る
SELECT pid, now() - xact_start AS age, left(query, 60)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY age DESC
LIMIT 5;
-- 3秒で取れなければ諦め、間をあけて再試行する
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN note text;長いトランザクションは、行の更新だけでなくマイグレーションも待たせ、そのマイグレーションが後ろのクエリを止める。トランザクションを短く閉じることは、ほかの不具合の被害も小さくする。
5つの不具合と、守る場所
| 不具合 | 起きる条件 | 守る場所 |
|---|---|---|
| lost update | 読んで計算して書き戻す | SQLの1文、バージョン列 |
| write skew | 同じ条件を読んで別の行を書く | 制約、親の行のロック、Serializable |
| デッドロック | 複数の行を逆の順でロック | ロックの順番、全体の再試行 |
| 長いトランザクション | トランザクションの中で外部を待つ | トランザクションの区切り方 |
| DDLのロック待ち | 長いクエリの後ろでALTER | lock_timeout |
面接で「在庫の二重販売をどう防ぎますか?」と聞かれたら
まず失敗の形を言う。「読んでから書き戻すと、同時の2件で更新が消えます」。そのうえで、UPDATE ... SET stock = stock - 1 WHERE stock > 0 で計算をDBに寄せ、0行の更新を在庫切れとして扱うと答える。
さらに、決済をトランザクションの外に出す理由(ロックを持つ時間が人気商品の上限を決める)と、複数の商品をID順にロックする理由まで話せると、1つの答えが設計全体の話になる。「Serializableにします」で終わらせず、払うもの(再試行が要る)も添える。