ブログ一覧

在庫が合わない、予約が重なる、送金が止まる

· 約10分で読めます

同時実行の不具合は、1件ずつ動かすテストでは起きない。セール当日のECサイトで5つの不具合を順に起こし、それぞれをどこで守るかを確かめる。

舞台:セール当日、人気商品に注文が集まる

同時実行の不具合は、2つのリクエストが同じデータにほぼ同じ瞬間に触れたときだけ起きる。開発中には見つからず、アクセスが集中する日に初めて現れる。

例はPostgreSQLで書き、MySQLで挙動が違うところはその都度ふれる。

在庫が合わない(lost update)

在庫10個の商品に、2件の注文が同時に来る。どちらも在庫を読み、アプリで1を引いて書き戻す。2件とも10を読んで9を書くので、2個売れたのに在庫は9になる。エラーはどこにも出ない。

読んで、計算して、書き戻すと、片方の更新が消える
  1. 注文A: 在庫を読む:10
  2. 注文B: 在庫を読む:10
  3. 注文A: 9を書く
  4. 注文B: 9を書く(Aの更新を上書き)
  5. DB(在庫): 2個売れたのに在庫は9

分離レベルを上げても、DBによっては防げない。PostgreSQLのRepeatable Readは後から書いたほうをエラーにするが、MySQL(InnoDB)のRepeatable Readでは上書きがそのまま通る。

加減算で済むなら、計算をDBに寄せるのが確実だ。2件目は1件目のコミットを待ってから最新の値で引くので、在庫は正しく8になる。

SQL:計算をDBに寄せる
UPDATE items SET stock = stock - 1
 WHERE id = 42 AND stock > 0;
-- 1行更新なら確保できた。0行なら在庫切れとして断る

商品説明の編集のように計算を寄せられない更新は、読んだときのバージョンを条件に書く(楽観的ロック)。0行なら、誰かが先に書いている。

SQL:バージョン列で衝突を検出する
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つある。

  1. 制約にする。 いちばん漏れがない。どの経路から書いても、DBが拒む。
  2. 起点の行をロックする。 ただし、まだ存在しない予約の行はロックできない。代わりに親の部屋の行を SELECT ... FOR UPDATE でロックする。
  3. Serializableにする。 PostgreSQLは片方を直列化エラー(40001)で失敗させる。アプリに再試行が要る。
SQL:時間帯の重なりを排他制約で禁止する(PostgreSQL)
-- 整数の等号を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の小さい口座から」のように同じ順番でロックすれば、後の人は先の人を待つだけで、循環は生まれない。

SQL:ロックの順番をそろえる(PostgreSQL)
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文だけやり直すと、前に読んだ古い値のまま進んでしまう。

Go:トランザクション全体を上限付きで再試行する
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 ÷ ロックを持つ時間」で、サーバーを足しても変わらない。

同じ商品の注文を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分間、注文の読み書きがすべて止まった。

待っているALTERの後ろに、ふつうのクエリが並ぶ
  1. 分析のSELECT: ordersを読み始める(5分かかる)
  2. ALTER TABLE: 排他ロックを待つ
  3. 注文のクエリ: ALTERを追い越せず、後ろに並ぶ
  4. 分析のSELECT: 5分後に終わる
  5. ALTER TABLE: ロックを取り、一瞬で終わる
  6. 注文のクエリ: 5分遅れで再開する

ALTER TABLE の多くはACCESS EXCLUSIVEロックを取り、ふつうのSELECTとも衝突する。待っているALTERの後ろに来たクエリは、ALTERを追い越せない。ALTERがどれだけ速くても、前にいる最も長いクエリの時間だけテーブルが止まる。

SQL:ロックを待つ上限を決めてから変更する
-- 長く開いているトランザクションがないかを先に見る
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のロック待ち長いクエリの後ろでALTERlock_timeout

面接で「在庫の二重販売をどう防ぎますか?」と聞かれたら

まず失敗の形を言う。「読んでから書き戻すと、同時の2件で更新が消えます」。そのうえで、UPDATE ... SET stock = stock - 1 WHERE stock > 0 で計算をDBに寄せ、0行の更新を在庫切れとして扱うと答える。

さらに、決済をトランザクションの外に出す理由(ロックを持つ時間が人気商品の上限を決める)と、複数の商品をID順にロックする理由まで話せると、1つの答えが設計全体の話になる。「Serializableにします」で終わらせず、払うもの(再試行が要る)も添える。

この記事で深掘りしたTips