データベース

PostgresのFOR UPDATEは子行ではなく親行をロックする

`READ COMMITTED`では、`SELECT FOR UPDATE`で親行をロックしても子行は保護されません。それが引き起こす3つの罠と、それぞれの修正方法。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

PostgreSQLの行ロックは1つの行を保護します。それを指す行の集合は保護しません。デフォルトのREAD COMMITTED分離レベルでは、親行に対するSELECT ... FOR UPDATEは、どの子行が存在するか、別のセッションがどの子行を挿入しようとしているか、またはどの子行の状態が変化しているかについては何も示しません。この投稿では、行ロックをリレーションシップのロックとして解釈することから生じる3つの並行性バグと、それらを修正するクエリの形状、トランザクションの境界、およびタイムアウトについて説明します。

行ロックは述語ロックではない

SELECT ... FOR UPDATEは、そのステートメントが返した行をマークします。それらの同じ行を更新、削除、またはロックしようとする別のトランザクションは、あなたがコミットまたはロールバックするまで待機します。それが保証のすべてです。あなたのステートメントが実行されたときには存在しなかった行や、あなたがロックした行を参照する他のテーブルの行については何も言及していません。

この誤解は、私たちがそれについてどのように話すかに起因します。「親をロックしたから、誰もそれに触れない」という言葉が、いつの間にか「それに属するものは誰も触れない」という意味に変わってしまいます。ドキュメントモデルでは、子は親レコードの内部に存在するため、その解釈は成り立ちます。リレーショナルスキーマでは、それらは独自のロックを持つ別々の行です。

PostgreSQLには、集合について推論するモードがあります。SERIALIZABLE分離レベルでは、述語読み取りを追跡し、読み取りセットと書き込みセットが競合する2つのトランザクションのうちの1つをアボートさせます。これには、まだ存在していなかった行を介した競合も含まれます。デフォルトはREAD COMMITTEDで、すべてのステートメントが新しいスナップショットを取得し、述語は何も追跡されません。その場合、行の集合を対象とする不変条件は、その集合全体に触れる1つのステートメントによって、またはすべての書き込み手が共通してロックする単一の行の背後で直列化することによって、強制されなければなりません。

落とし穴 1: 2つのステータスを対象とした更新の間のギャップ

状況: 親行はリタイア (論理削除) させることができます。リタイアさせると、その配下にあるすべての子行は rejected に移行し、呼び出し元は監査行のために以前のステータスごとの件数を必要とします。単純なコードでは、ステータスごとに1つのステートメントを記述します。

-- Two statements, one logical intent. The gap between them is the bug.
UPDATE child SET status = 'rejected'
 WHERE parent_id = $1 AND status = 'confirmed';   -- count A

UPDATE child SET status = 'rejected'
 WHERE parent_id = $1 AND status = 'candidate';   -- count B

両方のステートメントは親にスコープされており、それぞれが単独でアトミックであり、トランザクションがそれらをラップしているため、これは安全に見えます。しかし、そうではありません。これらのステートメントの間に、別のセッションが子行を candidate から confirmed にコミットする可能性があります。最初のステートメントはすでに実行されており、その行を confirmed としては認識していません。2番目のステートメントは candidate でフィルタリングするため、もはやその行を candidate としては認識しません。その結果、その行はどちらのフィルターにも一致せず、リタイアした親の下で confirmed のまま残ってしまいます。

先頭に SELECT ... FROM parent WHERE id = $1 FOR UPDATE を追加しても、このギャップは埋まりません。そのロックは parent 内の1行をカバーするだけです。昇格した子行は child に存在し、その書き込み手は親のロックを必要としませんでした。なぜなら、外部キーチェックは挿入時とキー列の更新時に発動するものであり、ステータスの変更時には発動しないからです。その行はAからもBからも欠落しているため、両方のカウントも間違っています。

2つのステータススコープ更新の間のウィンドウ lucidnote ブランド図: cascade session が2つの更新を連続して実行し、その間のギャップで別のセッションが状態変更をコミットし、影響を受ける子行はどちらのフィルターにも一致しない。 cascade session other session 1 ギャップ 2 コミット 3 一致なし reject confirmed reject candidate promote child 行が存続 // 昇格した行はどちらのフィルターにも一致しない

この修正は、ターゲットセットをロックし、次にそのセットをそのまま書き込む単一のステートメントです。

WITH target AS MATERIALIZED (
  SELECT id, status AS old_status FROM child
   WHERE parent_id = $1 AND status IN ('confirmed','candidate') FOR UPDATE
), upd AS (
  UPDATE child c SET status = 'rejected'
    FROM target t WHERE c.id = t.id RETURNING t.id, t.old_status
) SELECT id, old_status FROM upd

target内のFOR UPDATEは、一致するすべての子行をロックします。もし、ステートメントのスナップショットの後に、別のセッションがそれらの行のいずれかに変更をコミットした場合、PostgreSQLはロックを待機し、新しくコミットされたバージョンに対してWHERE句を再評価します。そのメカニズムがEvalPlanQualです。confirmedになったばかりの行は、依然としてstatus IN ('confirmed','candidate')を満たすため、残りの行とともに降格されます。rejectedに移動したばかりの行は、すでにターゲットの状態にあるため、正しく対象から外れます。

カウントが正しくなるのも同じ理由です。つまり、カウントは、異なる時点でサンプリングされた2つのカウントではなく、ステートメントが実際にロックして変更した行を表します。MATERIALIZEDは、CTEが一度だけ参照されるため、ここではCTEが実行される頻度を変更しません。これは、ターゲットセットを固定し、まさにそのセットに対して操作を行うという意図を表明するものです。

落とし穴 2: ロックなしで読み取るガードはガードではない

同じ機能には逆の防御が必要です。親が一度リタイアされると、その下に新しい子を挿入することも、既存の子を確定することもできなくなります。そのチェックを行うのに自然な場所は、書き込みパスの先頭です。

// Broken: the read runs in its own implicit transaction, so any lock it
// takes is released before the write begins.
func AddChild(ctx context.Context, pool *pgxpool.Pool, parentID int64, label string) error {
	status, err := parentStatus(ctx, pool, parentID) // own connection, own snapshot
	if err != nil {
		return err
	}
	if status == statusRejected {
		return ErrParentRejected
	}
	return insertChild(ctx, pool, parentID, label) // new transaction, new snapshot
}

このコードはコンパイルもレビューも通りますが、何も強制しません。その原因は、コードが要求していないロックにあります。

childへのinsertは、その外部キーを検証する必要があり、その検証では参照先の親行にFOR KEY SHAREロックが取得されます。FOR KEY SHAREFOR UPDATEと競合するため、カスケードが親をロックしている間、insertは外部キーチェックの途中でブロックされ、それを待ちます。その部分は、期待どおりの動作です。

問題は、ガード読み取りがその待機に対してどの位置で行われるかです。それは、それより前に、別の接続上で、それ自身の単一ステートメントトランザクション内で実行され、カスケードがまだコミットされていなかったためcandidateを読み取りました。その後、insertはブロックされ、カスケードがコミットされると、insertは再開して成功します。外部キーロックによって、そのインターリーブが発生しにくくなるわけではありません。むしろ、カスケードが完了するまで正確にinsertを待機させるため、そのインターリーブが優先的に発生するようになります。

修正方法は、書き込みと同じトランザクション内でガードを実行し、カスケードと競合するロックを取得することです。

SELECT status FROM parent WHERE id = $1 FOR SHARE

FOR SHAREFOR UPDATEと競合するため、ガードはカスケードを迂回して読み取るのではなく待機し、ロックが付与されるとEvalPlanQualは新しくコミットされたバージョンを返します。ガードはrejectedを認識して拒否します。FOR SHAREは、プレーンなUPDATE parent SET status = ...が暗黙的に取るモードであるFOR NO KEY UPDATEとも競合するため、ロック句を書き込まなかったカスケードに対しても待機します。共有ロックには互換性があるため、同時に2つのガードがあっても互いにブロックし合うことはありません。

// Fixed: guard and write share one transaction, and the guard holds a lock
// that conflicts with the cascade until that transaction commits.
func AddChildTx(ctx context.Context, tx pgx.Tx, parentID int64, label string) error {
	var status string
	err := tx.QueryRow(ctx,
		`SELECT status FROM parent WHERE id = $1 FOR SHARE`, parentID).Scan(&status)
	if err != nil {
		return err
	}
	if status == statusRejected {
		return ErrParentRejected
	}
	return insertChildTx(ctx, tx, parentID, label)
}

一般的な教訓: ガードが機能するかどうかは、関数シグネチャによって決まります。トランザクションハンドルを受け取る関数は呼び出し元のトランザクションを継承するため、そのトランザクションがコミットされるまでガードのロックは存続します。コネクションプールを受け取る関数は、独自のトランザクションを開始し、戻る前にロックを返却します。どちらも型チェックをパスし、diff上では同じに見えますが、片方だけが何らかの強制力を持ちます。

ガードが存在する場所が、そのロックが保持されるかどうかを決定する lucidnoteブランドの図:2つのレーンで、1つのトランザクション内のガード(共有ロックがパス全体に及ぶ)と、独自のプールされた接続上のガード(ロックが読み取り直後に終了する)を比較します。 ガードがトランザクションを取得 1 2 FOR SHARE 子を挿入 コミット コミットまでロックを保持 ガードがプールを取得 3 4 ステータス読み取り カスケードが優先 挿入が優先 ロックが早期に終了 // ガードは挿入がコミットされるまでロックを保持する必要がある

落とし穴 3: ロックを追加するたびに、新たな待機場所が生まれる

ロックを取得するガードは、これまでロックを必要としなかったパスのタイミングを変化させます。そして、その競合相手は、あなたが今書いたカスケードだけではありません。親行を長時間保持するトランザクションは、その親の下にあるすべての書き込みを遅延させるようになります。親をロックし、ゆっくりと動作し、最後にコミットする夜間ジョブは、その実行中ずっと、ユーザーの確認クリックを停滞させます。行ロックの待機には、デフォルトのタイムアウトがありません。

したがって、ロックとその制限は一緒に導入してください。

BEGIN;
SET LOCAL lock_timeout = '3s';
SELECT status FROM parent WHERE id = $1 FOR SHARE;

SET LOCALは設定を現在のトランザクションにスコープするため、プールされた接続の次のユーザーに漏洩することはありません。待機が上限を超えると、PostgreSQLはSQLSTATE 55P03 (lock_not_available) を発生させます。これは、呼び出し元が対処できるものに変換する必要があります。

var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "55P03" { // lock_not_available
	return ErrBusyRetryLater
}

人が待機しているリクエストの場合、55P03は「このレコードはビジー状態です。しばらくしてから再試行してください」を意味します。多くの親を対象とするバックグラウンドパスの場合、それは「今回はこれを残し、次回の実行で処理する」を意味し、「バッチを中止する」ではありません。それを間違えると、1回のクリックが夜間実行の失敗に変わります。誰かが子を確認すると、バッチが3秒の境界に達し、それを致命的なエラーとして扱い、何百もの親が未処理のまま停止してしまいます。NOWAITSKIP LOCKEDは、同じ考え方をよりシャープにしたものです。

ロックの順序、タイムスタンプ、そして振る舞いを固定するテスト

上記の修正が実際のコードベースで正しくあり続けるかどうかは、3つの詳細によって決まります。

まずはロックの順序です。デッドロックには、同じ2つの行を逆の順序で取得する2つのパスが必要です。すべてのパスが子より先に親を取得する場合、サイクルは形成されず、新しいガードは最初に親を読み取るため、その順序に加わります。親より先に子をロックするパスが1つでもあるとサイクルが作られてしまうため、記憶に頼らず、検索して検証してください。書き込みパスで子のテーブルに対するロック読み取りと更新をgrepし、それぞれが何をすでに保持しているかを確認します。

次にタイムスタンプですが、これが引き起こすバグは静かです。このような一括状態遷移では、人がその行を判定した日時を記録するためのreviewed_atのようなカラムに触れることがよくあります。ダッシュボードがそのカラムでカウントをグループ化している場合、カスケード中にタイムスタンプを押すと履歴が書き換えられてしまいます。昨日判定された行が今日の拒否としてカウントされ、誰も何も編集していないのに先週の数値が移動します。一括処理の結果は人間の判定ではないため、そのタイムスタンプには触れないでください。

3つ目はテストです。状態遷移の分岐が相互排他的であるかどうかは、現在の状態と要求された状態の純粋関数であるため、SQLのパスから切り離し、テーブルテストで固定してください。この作業では、すでにリタイア済みの親をリタイアさせるリクエストが、復活の分岐に入ってしまいました。これは、分岐の条件が排他的でない別々の条件文だったためです。コンパイルは通り、既存のテストもパスし、手動チェックでは見逃されました。なぜなら、現在のクライアントはより新しいエンドポイントを使用しており、古いパスだけがその分岐に到達していたからです。

tests := []struct {
	name    string
	current string
	request string
	want    action
}{
	{"retire a live parent", statusActive, statusRejected, actionCascade},
	{"retire an already retired parent", statusRejected, statusRejected, actionNoop},
	{"revive a retired parent", statusRejected, statusActive, actionRevive},
	{"revive a live parent", statusActive, statusActive, actionNoop},
}

よくある質問

代わりにSERIALIZABLEに切り替えるべきですか?

それは根本原因に対処します。つまり、まだ存在しなかった行の挿入を含む述語の競合が検出され、一方のトランザクションがSQLSTATE 40001でアボートします。その代償として、すべてのトランザクションがリトライ可能になり、すべての書き込みパスでリトライ ループが必要になり、すべての副作用が再実行を許容しなければならなくなります。述語ロックの追跡はメモリも消費し、行からページへの粒度にエスカレートする可能性があり、これは決して重複しなかったトランザクション間の競合として現れます。一部のホットパスをシリアライザブルにし、残りをREAD COMMITTEDのままにしても、ほとんど意味がありません。なぜなら、チェックはそのレベルで実行されている両方のトランザクション間にのみ適用されるからです。

勧告的ロックの方がシンプルでしょうか?

親IDから派生したキーに対するpg_advisory_xact_lockは、その親の下にあるすべての書き込みを直列化し、ロックする行がない場合でも機能します。欠点は2つあります。ロックは制約ではなく規約であるため、それを忘れた新しいコードパスはエラーも待機も発生せず、誰も気づかない競合状態になるだけです。そして、キー空間はグローバルであるため、名前空間を持つ2引数の形式を使用しない限り、無関係な機能が衝突する可能性があります。

FOR NO KEY UPDATEについてはどうですか?

これは、キー以外の列に対する単純なUPDATEが取るより弱いモードであり、キーを壊すことのない更新に対して外部キーチェックが待機しないようにするために存在します。それが要点です。FOR NO KEY UPDATEFOR KEY SHAREと競合しないため、子の挿入を待機させません。挿入を保留するには、親をFOR UPDATEでロックします。ガードのためにはFOR SHAREで十分です。なぜなら、それは両方の書き込みモードと競合するからです。

データベースはこれをトリガーや制約で強制すべきですか?

childに対するBEFORE INSERTトリガーで親のステータスを読み取るものには、アプリケーションのガードと同じ欠陥があります。理由は同じで、その読み取りにはFOR SHAREが必要であり、ブロックしたであろう外部キーチェックはステートメントの後半で実行されるからです。宣言的なバージョンも可能です。親の状態を子の外部キーに持たせ、(id, status)への複合参照をON UPDATE CASCADE付きで設定し、さらに子に対してリタイアした値を禁止するCHECK制約を追加します。どのコードパスもそれをバイパスすることはできませんが、その代償としてキーが広くなり、失敗が逆転します。つまり、今度は親をリタイアさせることが失敗するようになります。

1つの境界については、はっきりと述べておく価値があります。ここでのすべての修正は、単一の親行に対する書き込みを直列化します。これにより、異なる親に対する2つの操作が決して競合しないため、コストが低く抑えられます。確認済みの子の総数上限など、複数の親にまたがる不変条件には、ロックすべき単一の行がなく、すべての書き込みが取得する専用の行、ルールに対する勧告的ロック、またはSERIALIZABLEが必要になります。ほとんどの場合、親行が適切な単位であり、唯一の問題は、あなたのステートメントとトランザクション境界がそれを使用しているかどうかです。