PostgreSQLが持つpg_lockシステムビューを使うと、PostgreSQLのロックマネージャが管理している情報を見ることができ、誰がどのようなオブジェクトに対し、どの種類のロックを取得しているのか、または取得できずに待っているのかを確認することができます。

PostgreSQLはロック対象のオブジェクトとして、テーブル、インデックス、行(タプル)やトランザクションIDがあります。実はトランザクションIDへのロックはPostgreSQLでは多用していて、pg_locksビューやサーバログでトランザクションID(やトランザクション)に対してExclusiveLockやShareLockの取得は見たことがある人も多いのではと思います。ですが、トランザクションIDへのロックは直感的ではないので、なぜそのような機能があるのか?と疑問に思う人も多いはず(僕もそうでした)。

先週末にこの辺を調べたので、本記事ではトランザクションIDへのロックとはどのようなもので、なぜそのようなことをする必要があるのかを解説します。ツッコミ大歓迎です。

  • トランザクションIDへのExclusiveLock取得 (pg_locksビュー)
=# SELECT locktype, database, relation, page, tuple transactionid, mode, granted FROM pg_locks;
   locktype    | database | relation | page | transactionid |       mode       | granted
---------------+----------+----------+------+---------------+------------------+---------
 relation      |    13260 |    24851 |      |               | RowExclusiveLock | t
 virtualxid    |          |          |      |               | ExclusiveLock    | t
 relation      |    13260 |    11668 |      |               | AccessShareLock  | t
 virtualxid    |          |          |      |               | ExclusiveLock    | t
 transactionid |          |          |      |               | ExclusiveLock    | t        --★トランザクションIDにExclusiveLock?
(5 rows)
  • トランザクションIDへのShareLock取得待ち(サーバログ)
# ★トランザクションにShareLock?
LOG:  process 67234 still waiting for ShareLock on transaction 1252 after 1000.548 ms
DETAIL:  Process holding the lock: 41506. Wait queue: 67234.
CONTEXT:  while updating tuple (0,26) in relation "hoge"

TL;DR

  • PostgreSQLの行ロックは、行ロック+XMAXへのトランザクションID書き込み+トランザクションIDへのロックで実現している
  • トランザクションIDへのロックは、特定のトランザクションの終了を待つときに使われる
  • サーバログにトランザクションIDへのロック待ちが出たらまずは行ロックを疑うのが吉

PostgreSQLのロックマネージャ

まずはPostgreSQLのロックマネージャについてです。

PostgreSQLのロックマネージャは共有メモリ上にあるハッシュテーブル(ロックテーブル)を使って管理しています1。そのハッシュテーブルを確認することで、誰がどのようなロックを取得しているか等がわかります。

ロックの対象となるのは、データベース、リレーション、ページ、行など様々あり、トランザクションIDもそのうちの一つです。詳細は省きますが、デッドロック検知もこのロックテーブルの情報を用いて行います。

行ロック

行ロックは、ロックマネージャに行ロックの情報を登録する(かつ、トランザクション終了までそれを保持し続ける)事で実現可能です。しかし、一つのトランザクションや一つのSQLで大量の行をロックする可能性があり、そうするとロックテーブル用のメモリがいくらあっても足りないので、単純に行ロックをロックマネージャに登録して保持し続けることはしません。

そのかわりに、PostgreSQLはロック対象の行のXMAX2に自身のXIDを書き込むことで行ロックを実現しています。

行ロックする時は、まず行のXMAXを見て、XMAXが空であれば自身のXMAXを書き込みます。XMAXが空でなければ(つまり、他のトランザクションIDが書かれている)、そのトランザクションの状態を確認します。そして、そのトランザクションがすでに終了済みであれば、すでにロックは解放済みという事なので、行ロックが取得できます3。一方、まだトランザクションが実行中の場合は、そのトランザクションがCOMMITまたはABORTするまで待つ。という感じで動きます。

行ロック待ち

ロック待ちをするときには、自分が「行○○のロック待っている」という情報をロックマネージャに登録した上で待つ(sleep)する必要があります。でないと、デッドロック検知はロックテーブルを元に行なわれますので、デッドロック検知ができなくなってしまいます。

ですが、各行へのロック情報をロックマネージャに登録してしまうと、最初に懸念したように、ロックテーブルのサイズが大きくなりすぎてしまいます。ここでトランザクションIDへのロックを使います。

トランザクションIDへのロック

全てのトランザクションは、トランザクション開始時(正確にはトランザクションID取得時)に自身のトランザクションIDに対して排他ロック(ExclusiveLocK)を取得し、そのロックはトランザクション完了時まで保持します。

そして、トランザクションの完了を待つトランザクションは、待つ対象のトランザクションのトランザクションIDに対して共有ロック(ShareLock)を要求します(待つ対象のトランザクションIDはXMAXに書いてある)。そのため、対象のトランザクションが終了したときに、共有ロックを要求しているトランザクションは共有ロックが取得できます。共有ロックを取得した後は、すぐにロックを開放します。

このように、トランザクションIDへのロックは、特定のトランザクションの終了を待つ時に使われます。

トランザクション完了の順番待ち

行ロックを「XMAXへの記入+トランザクションIDへのロック」で実現することにより、ロックテーブルを効率的に使いながら行ロックを実現することができます。しかし、この方式だけでは異なる行へのロック待ちも全て一つのトランザクションIDへのロック待ちに集約されてしまうので、行毎のロック待ちでFIFOが実現できません。PostgreSQLはロックスケジュールとしてFIFO4を採用しているので、行毎にFIFOを実現するべきです。5

ここでようやく行ロックが出てきます。PostgreSQLはトランザクションIDへのロックを行う前に、行への排他ロックを取得し、その情報はロックマネージャに登録します。

つまり、これまでの内容をまとめると、行ロックは、

  1. 行への排他ロックを取得
  2. トランザクションIDへの共有ロックを取得
  3. トランザクションIDのロックを開放
  4. XMAXに自身のトランザクションIDを書く
  5. 行のロックを開放

という感じ動きます。そのため、行ロック待ちがサーバログに出力されると、冒頭の例のように、トランザクションIDへのShareLock取得待ちと記載されます。

# ★transaction 1252が行(0,26)に対してロックを取得しているので、process 67234がtransaction 1252の完了を待っている、というログ
LOG:  process 67234 still waiting for ShareLock on transaction 1252 after 1000.548 ms
DETAIL:  Process holding the lock: 41506. Wait queue: 67234.
CONTEXT:  while updating tuple (0,26) in relation "hoge"

まとめ

  • 行ロックは、XMAXへのトランザクションID書き込み+αで実現している
  • トランザクションIDへのロックは、特定のトランザクションの終了を待つときに使われる
  • 全トランザクションは、トランザクション開始時に自身のトランザクションIDに排他ロックを取得する
  • サーバログにトランザクションIDへのロック待ちが出たらまずは行ロックを疑うのが吉
    • 2人目以降の行ロック待ちでは、ちゃんと「行ロックで待ってます」というメッセージがでる
      LOG:  process 75098 still waiting for ExclusiveLock on tuple (0,26) of relation 16387 of database 13259 after 1000.363 ms
      DETAIL:  Process holding the lock: 67234. Wait queue: 75098.
      

いろいろと細かい所は端折っていますが、大まかな流れはこんな感じのはず。

参考

  • src/backend/access/heap/README.tuplock
  • src/backend/storage/lmgr/lock.c
  • src/backend/storage/lmgr/lmgr.c
  1. PostgreSQLは用途に応じてheavy-weight lock, light-weight lock, spin lockを使い分けるのですが、ここではheavy weight lockを対象としています。 

  2. PostgreSQLのテーブルの各行には、その行の可視性等を判断するために2つのトランザクション(xmin, xmax)が格納されている 

  3. UPDATE/DELETE済み(そのトランザクションから見えない)の行はそもそも行ロック対象にならないので、「自分が行ロックをしようとしている & その行のXMAXにすでに記載がある」というのは、他のトランザクションが変更中 or 変更していたけどabortした、ということになります。 

  4. 厳密にはFIFOをちょっと改善したものだった気がする 

  5. 例えばTx-AがタプルXをロックしていて、その後にTx-BとTx-Cが同タプルをロックしに来ると、Tx-AのXIDの共有ロック取得で待たされる。TX-A終了後、Tx-BとTx-Cは同時に解放される(互いに競合しないため)ので、Tx-Cが先にロックを取得してしまう可能性がある。