PostgreSQL 基礎講座 #6 トランザクションと MVCC — 同時実行の基本原理

読了 4分

ここまでは 1 人で使う DB でしたが、実際のサービスでは数十、数百の接続が同じテーブルを同時に読み書きします。それでも残高が二重に引かれたり、半分だけ保存された注文が見えたりしない理由が、トランザクションと MVCC です。この原理は実践編の VACUUM、ロック、レプリケーションがすべて立脚する土台なので、基礎の最後の山場としてここで固めておきます。

トランザクション: 全部成るか、全部成らないか #

口座振替トランザクション
BEGIN;
UPDATE accounts SET balance = balance - 50000 WHERE id = 1;
UPDATE accounts SET balance = balance + 50000 WHERE id = 2;
COMMIT;  -- 2 つの UPDATE がひとまとまりで確定。途中で問題があれば ROLLBACK;

口座振替の 2 つの UPDATE のうち片方だけ反映される世界があってはいけません。BEGINCOMMIT の間の作業は 1 つの単位に束ねられ、全部確定する(COMMIT)か、全部なかったことになる(ROLLBACK)かのどちらかです。途中でエラーが出たり接続が切れたりしても自動で ROLLBACK です。ちなみに明示的な BEGIN なしで実行した SQL 1 文も、それぞれが自前のトランザクションです(自動コミット)。「複数の文がひとかたまりであるべきか」が BEGIN を使うかどうかの判断基準です。

MVCC: 読み取りは書き込みを妨げません #

同時実行の素朴な実装はロックです。誰かが読んでいる間、他の書き込みを止めれば安全ですが、全員が互いを待つ DB になります。PostgreSQL の答えは MVCC(Multi-Version Concurrency Control)です。データを上書きする代わりにバージョンを複数保持し、各トランザクションは自分の開始時点で有効なバージョン(スナップショット)を見ます。

  • トランザクション A が長い集計クエリを回している間、トランザクション B が同じテーブルに UPDATE をしても互いに待ちません。A は自分のスナップショットの古いバージョンを見て、B は新しいバージョンを作るだけです。
  • だから PostgreSQL では読み取りと書き込みは互いを妨げません。 互いに妨げるのは、同じ行に対する書き込みと書き込みだけです(このロックの話は実践編で)。

ここには重要な帰結が 1 つあります。UPDATE はその場での修正ではなく、「新しいバージョンの行を追加して、古いバージョンに廃棄予定の印を付ける」作業です。 DELETE も実際に消すのではなく印を付けるだけです。こうして積もる古いバージョン(dead tuple)を片付ける掃除役が VACUUM で、これが実践編で VACUUM が大きな主題として登場する理由です。

分離レベル: 既定値 Read Committed の意味 #

スナップショットを「いつ」撮るかが分離レベルです。PostgreSQL の既定値は Read Committed で、文が始まるたびに新しいスナップショットを撮ります。コミット済みの他人の変更が、次の文から見えるという意味です。ほとんどの Web アプリケーションにはこれで十分ですが、罠が 1 つあります。1 つのトランザクション内の 2 つの SELECT が、互いに違う結果を見ることがあります(間に他人のコミットが割り込むと)。読んで → 判断して → 書くロジックなら、この隙間が問題になります。

分離レベルスナップショットの時点使う場合
Read Committed(既定)文ごと一般的な OLTP のほとんど
Repeatable Readトランザクション開始時に 1 回レポート・整合性検査のように「1 枚の一貫した写真」が必要なとき
Serializable開始時に 1 回 + 直列実行の検証同時実行の相互作用まで防ぐ必要のあるお金の計算など
分離レベル指定
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- この中のすべての SELECT は同じスナップショットを見ます
COMMIT;

上げるときの代価も知っておく必要があります。Repeatable Read 以上では、同時の変更と衝突するとエラー(直列化失敗)になり、アプリケーションがトランザクションを再試行する準備ができていなければなりません。安全をタダでくれるのではなく、「衝突を黙って見逃す代わりにエラーで知らせる」方式です。

一方、「読んで判断して書く」のより一般的な実務の解法は、分離レベルを上げることよりも、原子的な 1 文にまとめることです。UPDATE accounts SET balance = balance - 50000 WHERE id = 1 AND balance >= 50000 のように条件と更新を 1 文に入れれば、分離レベルと無関係に、残高チェックと引き落としの間に隙間がありません。

まとめ #

  • トランザクションは全か無かです。複数の文がひとかたまりなら BEGIN/COMMIT で束ね、エラー時は自動 ROLLBACK です。
  • MVCC のおかげで、読み取りと書き込みは互いを妨げません。各トランザクションは自分のスナップショットを見ます。
  • UPDATE・DELETE は新バージョンの追加と廃棄の印です。ここで生まれる dead tuple の掃除(VACUUM)が実践編の大きな主題です。
  • 既定の分離レベル Read Committed は文ごとのスナップショットです。1 つのトランザクション内でも 2 つの SELECT の結果が違うことがあります。
  • 一貫したスナップショットが必要なら Repeatable Read、完全な直列性が必要なら Serializable を、再試行を準備した上で使います。読んで-判断して-書くは、まず原子的な 1 文にまとめるのが先です。
X