はじめに
PostgreSQLの行ロックの挙動は難しく、ドキュメントにも情報が皆無なので理解を諦めていたが、久しぶりにpg_locks等で調査する必要にせまられ、軽く調べてみたところ、sawadaさんがとても素晴らしい記事を書いていたのを発見したので紹介したい。
この記事だけでは完璧には理解できなかったので、実際にSQLをなげたときの挙動を確かめて理解を深めていきたい。なお、ドキュメントや挙動の確認はバージョン13を元に行っています。
前提知識
主にPostgreSQLのドキュメントをもとに前提知識を整理しましょう。
テーブルレベルロック
テーブル単位でのロック制御を行います。13.3. Explicit Lockingをもとにまとめたのが下記で
SHARE ROW EXCLUSIVE
/EXCLUSIVE
がグレーアウトされているのは比較的マイナーなので基本的に覚えなくていいと思っているからです
左端に追記されているコマンド群はPostgresSQLが内部的に該当のロックを利用することを表現しています。なお、ALTER TABLE
は一部ACCESS EXCLUSIVE
以外を利用しますがかなり少数派なので無視します。
Remember that all of these lock modes are table-level locks, even if the name contains the word “row”; the names of the lock modes are historical. To some extent the names reflect the typical usage of each lock mode — but the semantics are all the same.
とあるように、lock modeの命名にrow
が含まれていてもあくまでテーブルレベルのロックであることに注意しましょう。
テーブルレベルのロック機構は共有メモリにて管理され、deadlockの検出をサポートするなど作り込まれたものであり、PostgreSQL用語でRegular locks (a/k/a heavyweight locks)
と表現されることが多いようです。
このlock情報はpg_locks
をもとに確認することができます。
行レベルロック
同様に13.3. Explicit Lockingをもとに以下にまとめています。4つのモードが利用可能なようですが、あまり意識することもないと思います。行レベルロックにもモードが複数あるぞくらいの認識でいいと思ってます。
PostgreSQL doesn't remember any information about modified rows in memory, so there is no limit on the number of rows locked at one time. However, locking a row might cause a disk write, e.g., SELECT FOR UPDATE modifies selected rows to mark them locked, and so will result in disk writes.
とあります。行レベルのロックは、テーブルレベルロックと同様にメモリで管理するわけにはいかないので完全にディスクにて管理されます。
Although tuples are a lockable type of object, information about row-level locks is stored on disk, not in memory, and therefore row-level locks normally do not appear in this view. If a process is waiting for a row-level lock, it will usually appear in the view as waiting for the permanent transaction ID of the current holder of that row lock.
とあるように、pg_locks
には行レベルロックは現れません。pg_locks
はあくまでテーブルレベルロックのみのようです。
行レベルのロックはextensionで利用可能なpgrowlocksにて確認できるようです。1が、基本的にはアプリケーションエンジニアが利用するケースはほとんどないでしょう。
ページレベルロック
こちらはユーザがあまり意識することはなさそうですが、マニュアルにあったので念の為書いてます。ページ単位のロックです。
あまりPostgreSQLに詳しくない人のために補足すると、テーブルは物理的には一つのファイルであり、8Kバイトのブロック単位で分割されて管理されます。データが増えてブロック内に保存しきれなくなると、ファイルの末尾にブロックを追加してファイルサイズを増やします。このブロックをページと呼びます。2
ページ単位で共有メモリにバッファリングされており、ページ単位でロックが存在します。バッファから読み込む時に共有ロックをかけ、バッファに変更を加える時やディスクから読み込む時には排他ロックが利用されます。
ヒープタプルの構造
この記事ではそこまでdeepな話は書くつもりはないですが、ブログを理解するにはある程度事前知識があったほうがいいので軽く触れます。
ヒープタプルとは行レコードを指します。実際のレコードのデータと一緒にそのメタ情報がディスクに隣接して保存されます。HeapTupleHeaderData
と呼ばれます。
ソースとしては
https://github.com/postgres/postgres/blob/master/src/include/access/htup_details.h
のあたりです。
大事なものを一部紹介すると、t_xmin
にはそのタプルを追記したXID、t_xmax
にはそのタプルを削除、もしくは更新したXIDが保持され(実はロックの時もt_xmax
を利用しますが後ほど紹介)、t_tcid
には追記された新しいタプルへのポインタが保持されます。PostgreSQLは追記型ですが、これらの情報を利用することで該当レコードの更新情報を辿ることができたり、コミットログなどと合わせてMVCCを実現しています。
他にはt_infomask
、t_infomask2
にはビット情報を保持し、
- 行レベルロックがある場合はどのモードか
- update/deleteされたか
- xmin/xmaxはcommit/abortしたのか
など様々な情報を保持します。
行ロックはどのように動作するか
基本的には冒頭の記事を読んでください。
簡単に説明すると、
- テーブルレベルロックと同様にメモリでロックを管理するわけにはいかないので、ロック対象の行の
xmax
に自身のXIDを書き、t_infomask
、t_infomask2
を更新することで行レベルロックを実現 - そのままではテーブルレベルロックのマネージャだけではデッドロックを検出できないので、行レベルロックとテーブルレベルロック両方を利用する。
- 全てのトランザクションは、トランザクション開始時に自身のトランザクションIDに対して
ExclusiveLock
を取得し、そのロックはトランザクション完了時まで保持する。 - あるトランザクションが特定のトランザクションのロック開放を待つ時、そのトランザクションIDに対して
ShareLock
を要求する。これにより「あるトランザクションが行レベルロックを取得済みのあるトランザクションのロック開放を待っている」ということをテーブルレベルロックのマネージャだけで表現できる。 - PostgreSQLはロックスケジュールとしてFIFOを採用しているので、行毎にFIFOを実現するべきでそのためには工夫が必要
という背景があり、以下のように動作している。3
- 行の
xmax
を見て、空もしくはxmax
のトランザクションが終了していればxmax
に自身のトランザクションIDを書いてt_infomask
、t_infomask2
を更新し、6へ移動。そうでなければ以下続く。 - 行への
AccessExclusiveLock
を取得 - トランザクションID(=今現在の
xmax
の値)へのShareLock
を取得後すぐ開放 -
xmax
に自身のトランザクションIDを書き、t_infomask
、t_infomask2
を更新 - 行への
AccessExclusiveLock
を開放 - そのまま処理を続行し、終了時に自身のトランザクションIDに対しての
ExclusiveLock
が開放される。
ややこしいのですが、1と4のみが行レベルロックで、その他はテーブルレベルロックです。2のテーブルレベルロックに「行」という言葉が含まれるのでややこしいのですが、このロックは取得したい行レベルロックのための仮想的なテーブルレベルロックという理解をした方がいいと思います。取得済みの行レベルロックの情報はテーブルレベルロックには現れません。また、行へのAccessExclusiveLock
を同時に2つ以上取得もしくは待機することはありません4。この二つにより、テーブルレベルロックの構造が肥大化することがないことがわかると思います。
上記の仕組みはとても美しいですが、理解するのに時間を要すると思います。逆に理解できていれば次の章の挙動は全て自明に思えると思います。
挙動の確認
以下のコマンドで確認していきます。
select
l.pid,
l.locktype,
c.relname as table_name,
l.page,
l.tuple,
l.transactionid,
l.mode,
l.granted, -- trueの場合lock取得済み
s.state
from
pg_locks l
left join pg_class c on (l.relation=c.oid)
left join pg_stat_activity s on (l.pid=s.pid)
where
l.mode <> 'AccessShareLock' -- レコードが多くなり見辛くなるので対象外
and
l.pid <> pg_backend_pid() -- このクエリ実行自体は対象外
order by
l.pid;
調査のためのテーブル
create table users
(
id integer constraint users_pkey primary key,
user_type integer
);
INSERT INTO users (id, user_type) VALUES (1,1);
INSERT INTO users (id, user_type) VALUES (2,1);
INSERT INTO users (id, user_type) VALUES (3,1);
基本的な例
id=1を3つのセッションがselect for update
することを想定し、pidが、3718/3721/3734と順番にselect for update
していく。
わかることのメモ
- 常に
users
やそのpkeyに対してのRowShareLock
はlock取得済みの状態にある。 - lock待ちのstateは
active
であり、lock取得ずみのstateはSQL実行中ではないため、idle in transaction
である。 -
select id from users where ctid='(0,1)';
により、id=1のレコードのtupleが対象であることが確かめられる。 - 行ロック待ちの最初のトランザクションはトランザクションIDへの
ShareLock
待ち。続いてのトランザクションは行へのAccessExclusiveLock
待ちとなる。
複数レコードlockしようとする例
pid=3827がid=1をpid=3828がid=2をlockしている状態でpid=3829がid=1とid=2をlockしようとする状況。
pid=3827がid=1、pid=3828がid = 2をselect for update
pid=3829がid=1とid=2をselect for update
SELECT * FROM pgrowlocks('users');
わかることのメモ
- lock待ちは1行ずつ
- pg_locksには現れないが、pgrowlocksで確かに2行ロックしていることが確認できる。
-
https://qiita.com/aramaki/items/cfef1dfbef2402c485fd に詳しいです。 ↩
-
PostgreSQLの内部構造を手軽に理解するにはPostgreSQL全機能バイブルの2章がとてもおすすめです。 ↩
-
元記事の他にhttps://qiita.com/aramaki/items/5f26c6227ff436a56e2c#%E3%82%BB%E3%83%83%E3%82%B7%E3%83%A7%E3%83%B31%E3%81%AE%E5%8B%95%E3%81%8D も参照しているため元記事と異なる記載が多い。 ↩
-
https://github.com/postgres/postgres/blob/eeb60e45d82d5840b713a8741ae552238d57e8b9/src/backend/access/heap/README.tuplock#L32-L34 のあたりに記載を見つけた ↩