TT Lab
はじめる
学ぶ 学習パス コース

並行性 — 二人が同じ行を触るとき

一人では絶対に出ないバグ

TT Labで続きを見る

一言でいうと

並行性のバグは、コードが間違っているからではなく、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つです。

  1. select ... for update: 読むときにその行をロックします。他の人は待ちます
  2. 1つの文で終わらせる: update stock set qty = qty - 1 where id = 1 and qty > 0
  3. 楽観的ロック: バージョン列を置いて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をセッションのデフォルトとして設定しておき、間違いが起きても、コネクションプールまで崩れないようにします。