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

データベースの概念

分離レベルが何を防ぎ、何を防げないのか

TT Labで続きを見る

目標

読み物で見た表の4つ目の列、つまりどの分離レベルも捕まえてくれないライトスキューを、本物のPostgreSQLで自分で作ってみます。そして、それを防ぐ3つの道具である行ロック、直列化可能レベル、ジョブキュー用のスキップを、順に使ってみます。

なぜ重要なのか

並行性のバグに出会うと、「分離レベルを上げろ」という処方が先に出てきます。このラボは、その処方がいつ効き、いつ効かないかを数字で見せてくれます。ステップ2で失った100ウォンは、ステップ4ではエラーに変わり、ステップ5では、エラーすらなくルールが破られます。

特に注目してほしいのは、同じ900が2回出てくる点です。1回は更新が消えたため、もう1回は更新が拒否されたためです。値だけを見ると区別できず、どちらなのかは、エラーを受け取ったかどうかでしかわかりません。本番では、この違いが、報告される事故と、永遠に気づかれない事故を分けます。

環境

このPodの中でPostgreSQL 16が動いています。接続は、次のとおりです。

export PGPASSWORD=lab
psql -X -q -v ON_ERROR_STOP=1 -h 127.0.0.1 -U lab -d labdb

セッション2つを重ねる必要があるラボです。ターミナルは1つしかないので、片方をバックグラウンドで起動します。トランザクションの中にSELECT pg_sleep(3);を入れておくと、そのセッションがトランザクションを開いたまま待ちます。

psql ... -f /root/txn/lost.sql > /root/txn/lost_a.out 2>&1 &
sleep 1
psql ... -f /root/txn/lost.sql > /root/txn/lost_b.out 2>&1
wait

出力物はすべて/root/txn/の下に置きます。シード用の表(customersなど)には触れません。

ステップ

  1. /root/txn/schema.sqlで、purse、oncall、tasksの3つの表を作って、データを投入します。
  2. /root/txn/lost.sqlで、口座1の更新の消失を再現して、/root/txn/02-lost.txtに残します。
  3. /root/txn/forupdate.sqlで、口座2を行ロックで守って、/root/txn/03-forupdate.txtに残します。
  4. /root/txn/repeatable.sqlで、口座3の直列化エラーを受け取って、/root/txn/04-repeatable.txtに残します。
  5. /root/txn/skew.sqlで、rrチームの当直ルールを破って、/root/txn/05-skew.txtに残します。
  6. /root/txn/serializable.sqlで、ssiチームのルールを守って、/root/txn/06-serializable.txtに残します。
  7. /root/txn/claim.sqlで、2つのワーカーがキューを分け合って取っていくようにして、/root/txn/07-queue.txtに残します。
  8. 学んだことをまとめて、/root/txn/08-notes.mdに残します。

参考

ラボ用の表を3つ作る

/root/txn/schema.sqlを作って投入します。purse(id, owner, balance)に残高1000の口座を3つ(id 1、2、3)、oncall(id, team, name, on_duty)にrrチームの2人(id 1、2)とssiチームの2人(id 3、4)をすべて当直として、tasks(id, state, worker)にqueued状態のジョブ6件(id 1–6)を入れます。

接続はpsql -h 127.0.0.1 -U lab -d labdbで、パスワードはlabです。export PGPASSWORD=labをしておけば、毎回聞かれません。

何回投入しても同じ状態になるように、DROP TABLE IF EXISTSから始めてください。後のステップがこの表を繰り返し元に戻して使うので、冪等性が重要です。

ジョブ6件は、INSERT ... SELECT g, 'queued', null FROM generate_series(1, 6) gで一度に入れられます。

更新の消失を再現する

/root/txn/lost.sqlを作って、口座1から100を引くトランザクションを、2つのセッションで重ねて動かします。残高を先に読んで、3秒休み、読んだ値から100を引いた絶対値を書きます。結果を/root/txn/02-lost.txtに、before、after、expectedの3行で残します。

psqlはSELECT balance AS b FROM purse WHERE id = 1 \gsetで照会結果を変数に入れ、後で:bとして使います。SELECT pg_sleep(3);は、トランザクションを開いたまま3秒待たせます。

片方のセッションをバックグラウンドで起動して、1秒後に2つ目のセッションを動かすと、2つのセッションが重なります。両方ともコミット前の1000を読むので、両方とも900を書きます。2回出金したのに、残高は1回分しか減りません。

動かす前に、口座1を1000に戻しておいてください。そうすれば、何回やり直しても同じ結果になります。

SELECT ... FOR UPDATEで防ぐ

/root/txn/forupdate.sqlを作ります。ステップ2と同じ流れですが、残高を読むときにFOR UPDATEで行をロックします。口座2で2つのセッションを重ねて動かし、結果を/root/txn/03-forupdate.txtに、before、after、expectedの3行で残します。

SELECT balance AS b FROM purse WHERE id = 2 FOR UPDATE \gsetで読むと、その行がロックされます。2つ目のセッションは、最初のセッションがコミットするまで、この照会そのもので待ち、起きた後は更新された値を読みます。

悲観的ロックと呼ぶ理由がこれです。衝突が起きると先に見越して、順番待ちをさせます。その代わり、待ち時間が生まれ、複数の行を別々の順序でロックすると、デッドロックが起きます。

REPEATABLE READはエラーで防ぐ

/root/txn/repeatable.sqlを作ります。ステップ2と同じ流れをBEGIN ISOLATION LEVEL REPEATABLE READ;で始めて、口座3で2つのセッションを重ねて動かします。遅れて書こうとしたセッションが受け取ったSQLSTATEと最終残高を、/root/txn/04-repeatable.txtに、sqlstate、after、expectedの3行で残します。

psqlはデフォルトでは、エラーメッセージにSQLSTATEを表示しません。ファイルの先頭に\set VERBOSITY verboseを入れると、ERROR: 40001: ...のようにコードも一緒に出ます。

ここで残高は900になります。ステップ2と同じ数字ですが、意味は正反対です。ステップ2は出金の1つが静かに消えた900で、ステップ4は出金の1つが拒否された900です。拒否された側はリトライすればよいのですが、消えた側は、リトライする機会すらありません。

REPEATABLE READでも防げないもの

/root/txn/skew.sqlを作ります。rrチームの当直者の数が2人を超えているかを確認してから、自分自身を当直から外すトランザクションで、分離レベルはREPEATABLE READです。2つのセッションが、それぞれid 1とid 2で重なって動くようにして、残りの当直者数を/root/txn/05-skew.txtに、isolation、on_dutyの2行で残します。

条件の判断と更新をpsqlの中でつなげるには、SELECT (count(*) > 1)::text AS ok ... \gsetで真偽を変数に入れ、\if :okと\endifで囲めばよいです。自分の番号はpsql -v me=1のように渡して、SQLで:meとして使います。

2つのセッションは、別々の行を書き換えます。書き込みが重ならないので、スナップショット分離には衝突を検知する根拠がありません。両方とも成功し、当直者は0人になります。これが、読み取りの結果に頼って別の行を書き換えるときに生まれる、ライトスキューです。

SERIALIZABLEでライトスキューを捕まえる

/root/txn/serializable.sqlを作ります。ステップ5と同じ流れをBEGIN ISOLATION LEVEL SERIALIZABLE;で始めて、対象はssiチーム(id 3と4)です。片方が受け取ったSQLSTATEと、残りの当直者数を、/root/txn/06-serializable.txtに、sqlstate、on_dutyの2行で残します。

SERIALIZABLEは、書き込みの衝突だけを見るのではなく、読み取りと書き込みの間の依存関係まで追跡します。2つのトランザクションが、お互いが読んだ集合を書き換えたことに気づき、片方をロールバックします。

ステップ5のファイルをコピーして、分離レベルとチーム名、自分の番号だけを変えれば十分です。SQLSTATEを見るには、ここでも\set VERBOSITY verboseが必要です。

代償はエラーです。このレベルを使うことにしたなら、アプリケーションにリトライが必ずなければなりません。

SKIP LOCKEDでジョブを分けて取る

/root/txn/claim.sqlを作ります。queued状態のジョブをid順に3件選んでFOR UPDATE SKIP LOCKEDでロックし、runningに変えながら、workerに自分の名前を書くトランザクションです。worker-aとworker-bの2つのセッションを重ねて動かして6件をすべて取っていき、結果を/root/txn/07-queue.txtに、worker_a、worker_b、unclaimedの3行で残します。

選ぶ行と書き換える行を1つの文に収めるには、CTEが便利です。

WITH picked AS (
  SELECT id FROM tasks WHERE state = 'queued' ORDER BY id
  FOR UPDATE SKIP LOCKED LIMIT 3
)
UPDATE tasks t SET state = 'running', worker = :'me'
FROM picked WHERE t.id = picked.id;

:'me'は、psql変数を文字列リテラルで囲んで入れます。SKIP LOCKEDがないと、2つ目のワーカーが最初のワーカーがロックした行の前で順番待ちをし、起きた後は、すでに処理された行をもう一度見ることになります。

何が何を防いだのかをまとめる

/root/txn/08-notes.mdに4行以上書きます。ステップ2とステップ4の残高がなぜ同じ900なのに意味が違うのか、ステップ5でREPEATABLE READがなぜ防げなかったのか、ロックと分離レベルをそれぞれどんな状況で選ぶのかを書きます。

本文に쓰기 편향、FOR UPDATE、SKIP LOCKEDが含まれている必要があります(韓国語の語は「ライトスキュー」を意味します)。

特にステップ5をよくまとめておいてください。実務の並行性の事故は、大半がその、表にない4つ目の列で起こります。