一人では絶対に出ないバグ
一言でいうと
並行性のバグは、コードが間違っているからではなく、2人が同じ瞬間に同じ行を触るから起きます。そのため、1人でテストしても絶対に再現されません。
なぜ必要なのか: 在庫がマイナスになる事故
select qty from stock where id = 1; -- 1 이 남았다
-- (여기서 다른 사람도 똑같이 1 을 읽는다)
update stock set qty = qty - 1 where id = 1;
2人とも「1つ残っている」を読み、2人とも減算します。在庫が-1になります。
読んで、判断して、書く間に、他人が割り込めることが問題です。これをロストアップデート(lost update)と言います。
直す方法は3つです。
select ... for update: 読むときにその行をロックします。他の人は待ちます- 1つの文で終わらせる:
update stock set qty = qty - 1 where id = 1 and qty > 0 - 楽観的ロック: バージョン列を置いて
where version = %sとし、0行ならリトライします
2つ目が可能なら、2つ目がいちばん安上がりです。読んで判断しなければならない場合に、1つ目を使います。
ロックはトランザクションが終わるまで維持される
for updateで取った行は、commitかrollbackまでロックされたままです。そのためトランザクションの中で外部APIを呼んではいけません。 そのAPIが3秒かかれば、その行は3秒間ロックされます。
トランザクションは短く。ロックを取ったまま、人やネットワークを待たないでください。
デッドロック: 互いに相手が持っているものを待つ
A: id=1 잠금 → id=2 요청
B: id=2 잠금 → id=1 요청
2つとも永遠に待ちます。PostgreSQLはdeadlock_timeout(デフォルト1秒)のあとにこの循環を見つけ、片方を強制終了させます。
ERROR: deadlock detected
DETAIL: Process 123 waits for ShareLock on transaction 773; blocked by process 122.
Process 122 waits for ShareLock on transaction 774; blocked by process 123.
エラーメッセージが誰が何を待ったのかを正確に教えてくれます。この2行の読み方がわかれば、原因の特定がずっと速くなります。
防ぐ方法は1つだけです。ロックする順序を全員で同じにします。 常にid昇順でロックすれば、循環は生じえません。並べ替えの基準は何でもよく、全員が同じ基準を使うことだけが重要です。
そして、デッドロックは完全にはなくせません。 そのためアプリケーションは、40P01に出会ったらリトライできなければなりません。
無限に待たないようにする
set lock_timeout = '3s';
これをかけないと、ロック待ちがコネクションを握ったまま増え続け、そのコネクションがプールを食い尽くすと、DBではなくアプリケーションが止まります。 症状は「DBが遅い」ではなく「サーバーが応答しない」として現れます。
マイグレーションでは特に重要です。alter tableはACCESS EXCLUSIVEを要求しますが、長いクエリが1つその前にあると、そのあとのすべてのクエリが列に並びます。 lock_timeoutなしでデプロイすると、その瞬間にサービスが止まります。
誰が誰を止めているのか
select pid, pg_blocking_pids(pid), query
from pg_stat_activity
where cardinality(pg_blocking_pids(pid)) > 0;
1行でブロックの関係が出ます。pg_locksを直接結合するよりも、こちらが速いです。
キューを作るとき: SKIP LOCKED
ワーカーが複数、同じテーブルから仕事を取っていく必要があるなら、for updateだけではワーカーが列に並びます。 最初のワーカーが取った行を、2つ目が待つからです。
select id from jobs where state = 'ready'
order by id limit 10
for update skip locked;
skip lockedはロックされた行を飛ばします。 2つ目のワーカーは待たずに次のものを取ります。専用のキューミドルウェアなしにDBでジョブキューを作るときの、核心の1行です。
行がないものにロックをかけたいとき
「このユーザーに対する精算は一度に1つだけ」のような規則には、ロックする行がありません。そんなときはアドバイザリーロックを使います。
select pg_try_advisory_lock(12345); -- 얻으면 true, 아니면 즉시 false
注意が2つあります。キー空間がグローバルです。 別の機能が同じ数字を使うと衝突します。そしてセッションに結び付きます。 コネクションプールでは、返却の前に必ず解放する必要があります。そうしないと、次の人がロックされたままのコネクションを受け取ります。
まとめ
並行性の問題は、再現が難しいから難しいのです。そのため、このコースのラボはすべて2つのセッションを実際に立ち上げてぶつけます。画面で待ちとデッドロックを直接見ることが、理解の大部分です。
現場では
この問題は、開発環境ではほとんど再現されません。1人でクリックしてみると常に正常で、負荷テストも、別々の商品を買うなら重なりません。実際には、数量限定販売やクーポン発行のように、全員が同じ1つの行を狙う瞬間に起きます。
そのため、障害のあとの調査でロックを疑うまでに時間がかかります。データベースのメトリクスはすべて正常で、アプリケーションのログには「在庫減算成功」だけがずらりと残っているからです。残る手がかりは、結果データの矛盾1つだけです。在庫がマイナスだったり、発行数が上限を超えていたり。
本番に出る前に確認することは2つあります。同じ行を同時に触る経路がどこかの一覧を作り、その経路ごとに、1つの文で終わらせられるのか、それともロックが必要なのかを決めます。そしてlock_timeoutをセッションのデフォルトとして設定しておき、間違いが起きても、コネクションプールまで崩れないようにします。