並行するPostgreSQLの更新が重複する場合は、更新前に各トランザクションの対象全件を同じ不変キー順でロックすると、デッドロックを防げます。トランザクションには単一のnode-postgresクライアントを使い、再試行の対象となる失敗が起きたら最初からやり直してください。
このガイドは、合成記事行を2つの並行バッチで漏れなく更新する必要がある、macOS Terminalを使う開発者向けです。例ではPostgreSQLの最新ドキュメント(PostgreSQL 18を掲載)と、node-postgresの最新ドキュメントを使用します。対象は既存記事IDの確定済みリストであり、並行処理中にIDは変更されないものとします。PostgreSQL: 明示的ロック
重複する更新でデッドロックが起こる理由
2つのトランザクションを考えてみましょう。Aは記事1を更新してから記事2を更新し、Bは記事2を更新してから記事1を更新します。それぞれが最初の行ロックを取得してから2つ目を要求すると、互いが保持するロックの解放を待つことになります。PostgreSQLはこの循環を検出し、どちらか一方のトランザクションを中止します。どちらが中止されるかは、アプリケーションでは選べません。明示的なロック文がなくても、通常の更新でこの状況が起こることがあります。PostgreSQL: 明示的ロック
重要なのは、重複と循環を区別することです。重複すると待ちが発生する場合があります。循環が発生すると、どちらのトランザクションも処理を進められません。PostgreSQLは、アプリケーション間でロック取得順序を統一し、必要なロックモードのうち最も強いものを先に取得することを推奨しています。PostgreSQL: 明示的ロック
この記事の処理では、次のルールを適用します。変更を始める前に、要求された記事の行ロックをすべて id の昇順で取得します。このルールは、記事に触れる先行文を含め、トランザクション全体で守ってください。
更新前に対象全件をロックする
JavaScript配列をソートしても、一括SQL更新がその順序で行を処理するとは限りません。PostgreSQLの UPDATE 構文には、直接指定できる ORDER BY 句がありません。PostgreSQLのドキュメントでは、選択クエリによる順序付きロックの例が示されており、順序を統一すれば更新操作間のデッドロックを防げる場合があると説明されています。PostgreSQL: UPDATE
トランザクション内で、ロック用の別の文を実行し、完了を待ちます。次のSQLトランザクション例では記事1、2、3を対象にします。テーブルのセットアップは次のセクションにあります。
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT id
FROM deadlock_demo_articles
WHERE id = ANY(ARRAY[3, 1, 2]::integer[])
ORDER BY id
FOR UPDATE;
UPDATE deadlock_demo_articles
SET revision = revision + 1
WHERE id = ANY(ARRAY[3, 1, 2]::integer[]);
COMMIT;
最初の文でロックの取得順序を決めます。続く更新は、そのトランザクションがすでにロックしている行を対象にします。通常、行ロックはトランザクションが終了するまで保持されます。この間隔は短く保ってください。入力データは開始前に準備し、ロックを保持したままデスクトップからの入力を待たないでください。PostgreSQL: 明示的ロック
この例の順序付けに関する前提条件は次のとおりです。
- 競合するすべてのバッチが、記事
idの昇順を使う。 - 並行処理中にIDが変更されない。
- ロック前に、各バッチの対象全件が確定している。
- 先行する文が、別の順序で記事行をすでにロックしていない。
- 例のテーブルに、追加のロック依存関係を生じさせるトリガーや関連テーブル操作がない。
これらは、この処理に今回の考え方を適用するための前提です。たとえば、トランザクションが最初に記事9をロックし、その後記事1から3を要求した場合、最終クエリに ORDER BY id が含まれていても、すでにこのルールから外れています。
Macでnode-postgresの一通りの例を実行する
前提条件は、ローカルでPostgreSQLサーバーが稼働していること、既存の使い捨てデータベースがあること、Node.jsとnpmがあること、Terminalから psql を実行できることです。PostgreSQLの環境変数を使って、既存のデータベース接続を設定してください。node-postgresのプールと psql は同じ接続変数を使用します。両方のコマンドが同じサーバーに接続するよう、必要なら PGHOST を明示的に設定してください。node-postgres: 接続
新しい作業フォルダーで、依存パッケージをインストールします。
npm init -y
npm install pg
次の完全なセットアップを setup.sql として保存します。新しいデモ用テーブルを作成し、合成記事を3件追加します。使い捨てデータベースで一度実行してください。既存の内容を置き換えるのではなく、テーブルがすでにある場合は意図的に失敗します。
BEGIN;
CREATE TABLE deadlock_demo_articles (
id integer PRIMARY KEY,
title text NOT NULL,
revision integer NOT NULL DEFAULT 0
);
INSERT INTO deadlock_demo_articles (id, title)
VALUES
(1, 'Paper Shapes'),
(2, 'Color Notes'),
(3, 'Desk Sketches');
COMMIT;
psql -v ON_ERROR_STOP=1 -f setup.sql
次の完全なプログラムを update-articles.mjs として保存します。各試行に属するすべての文で、pool.connect() が返すクライアントを使います。node-postgresは、トランザクション全体を通して単一のクライアントを使うよう明示的に求めています。pool.query() 経由でトランザクション文を発行しても、この要件は満たせません。node-postgres: トランザクション
import pg from 'pg';
import { setTimeout as sleep } from 'node:timers/promises';
const { Pool } = pg;
const pool = new Pool({ max: 2, connectionTimeoutMillis: 5000 });
pool.on('error', error => {
console.error('Idle database client error:', error.message);
process.exitCode = 1;
});
function normalizeIds(input) {
if (!Array.isArray(input) || input.length === 0 ||
input.some(id => !Number.isInteger(id) ||
id < 1 || id > 2147483647)) {
throw new Error('Use a nonempty array of positive PostgreSQL integer IDs');
}
return [...new Set(input)].sort((a, b) => a - b);
}
async function incrementRevisions(input) {
const ids = normalizeIds(input);
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const client = await pool.connect();
let discard = false;
try {
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
const locked = await client.query(
`SELECT id
FROM deadlock_demo_articles
WHERE id = ANY($1::integer[])
ORDER BY id
FOR UPDATE`,
[ids]
);
if (locked.rows.length !== ids.length) {
throw new Error('A requested article is missing; batch cancelled');
}
const updated = await client.query(
`UPDATE deadlock_demo_articles
SET revision = revision + 1
WHERE id = ANY($1::integer[])
RETURNING id, revision`,
[ids]
);
if (updated.rowCount !== ids.length) {
throw new Error('Unexpected update count; batch cancelled');
}
await client.query('COMMIT');
return updated.rows.sort((a, b) => a.id - b.id);
} catch (error) {
try {
await client.query('ROLLBACK');
} catch (rollbackError) {
discard = true;
throw new Error('Rollback failed; connection discarded', {
cause: rollbackError
});
}
const retryable = error.code === '40P01' || error.code === '40001';
if (!retryable || attempt === maxAttempts) throw error;
} finally {
client.release(discard);
}
// Illustrative bounded backoff, after releasing the client.
await sleep(50 * 2 ** (attempt - 1) + Math.floor(Math.random() * 50));
}
}
try {
const outcomes = await Promise.allSettled([
incrementRevisions([3, 1, 2]),
incrementRevisions([3, 2])
]);
for (const outcome of outcomes) {
if (outcome.status === 'rejected') throw outcome.reason;
}
const result = await pool.query(
'SELECT id, title, revision FROM deadlock_demo_articles ORDER BY id'
);
console.table(result.rows);
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
await pool.end();
}
node update-articles.mjs
配列は1つのパラメーターとして渡し、明示的に integer[] にキャストします。node-postgresのドキュメントでは、JavaScript配列をPostgreSQL配列値に変換する方法が説明されています。重複を除くことで、要求件数は記事のユニーク数を意味します。ロックの取得順序を決めるのは入力配列の順序ではなく、データベースの ORDER BY です。node-postgres: クエリ
テーブルを初期化した直後で、ほかに書き込み処理がなく、両方のバッチが成功する場合、予想されるリビジョンは記事1が1、記事2と3が2です。どちらのバッチが先に完了するかに関係なく、各バッチの対象からこの値が決まります。スクリプトを再実行すると、既存の値がさらに増えます。要求されたIDが見つからない場合、そのバッチは見つかった行だけを暗黙に更新するのではなく、キャンセルされます。
各バッチは個別のトランザクションです。一方が成功し、もう一方が最終的に失敗しても、成功したバッチの変更はコミット済みのままです。Promise.allSettled() により、プログラムがプールを閉じる前に両方の試行を完了させます。最後の pool.query() は、どちらのトランザクションの外側でも実行されます。プールAPIのドキュメントでは、チェックアウト済みクライアントの解放、必要に応じたクライアントの破棄、スクリプト処理の完了後にプールを終了する方法が説明されています。node-postgres: プール
正規化の確認と例示したリビジョンの計算は、Node.jsで実行しました。調査環境ではPostgreSQLが利用できなかったため、SQLとデータベースの並行処理の挙動は実行して確認せず、ドキュメントと照合しました。
必要な分離レベルを維持する
このプログラムでは、固定IDの更新に READ COMMITTED を明示的に使用します。このレベルでは、ロック文が更新処理を待った後でも、条件に合致する限り、更新後の行バージョンをロックできます。各文は新しいスナップショットを取得します。そのため、ロックを取得する前に削除された行は対象から外れることがあります。プログラムは件数の確認でこの状況に対処します。PostgreSQL: トランザクション分離
この例を使うためだけに、既存のトランザクションの分離レベルを下げないでください。REPEATABLE READ では、トランザクションのスナップショット以降に変更された行をロックまたは更新しようとすると、シリアライゼーションエラーが発生することがあります。SERIALIZABLE でも再試行処理が必要です。また、コミット済みのシリアライゼーション異常を防ぎますが、通常の行ロックの取得順序を統一する代わりにはなりません。PostgreSQL: トランザクション分離
既知の行をロックしても、述語に一致する行の集合が変わらないことは保証されません。たとえば、この例は「条件にタイトルが一致するすべての記事を、並行して追加されたものも含めて更新する」処理を実装していません。これは別の一貫性要件です。固定IDの手順を応用する前に、必要な分離レベルと調整方法を決めてください。
また、順序付けに使う列は不変に保ってください。PostgreSQLのドキュメントによると、READ COMMITTED ではロック前にソートが行われます。文が待機している間に値が変更されると、返される行が順不同に見えることがあります。変更されないIDを使えば、この問題を避けられます。PostgreSQL: SELECT
トランザクション全体を再試行し、失敗が続く原因を調べる
PostgreSQLは、デッドロックのエラーをSQLSTATE 40P01、シリアライゼーションエラーを 40001 として示します。実行する文や値を決めるデータベース依存の判断も含め、トランザクション全体を再試行してください。最後の更新だけを再試行しても、この要件は満たせません。PostgreSQL: シリアライゼーション失敗の処理
この例では、合計3回まで試行できます。短い指数バックオフとジッターは、アプリケーション側の設定例であり、PostgreSQLの既定値ではありません。遅延前にクライアントを解放し、ほかのエラーが起きた場合は直ちに停止して、試行上限に達したことを報告します。PostgreSQLは、再試行しても最終的な完了が保証されるわけではないと注意しています。PostgreSQL: シリアライゼーション失敗の処理
実装を応用する際は、次の判断表を使ってください。
| 状況 | 対応 |
|---|---|
| 別のバッチが要求対象の行をすでに保持している | 要求された行すべてが必要なら、順序に従ったロック取得が待機するのを許容する。 |
| 要求されたIDが存在しない | ロールバックし、対象が見つからないことを報告する。 |
SQLSTATEが 40P01 または 40001 |
ロールバック後、対象となるトランザクション全体を回数制限付きで再試行する。 |
| 順序付きロックを行っても失敗が続く | 先行する文、ほかの書き込み処理、トリガー、追加テーブルを調べ、ロック順序のルールから外れていないか確認する。 |
| 操作が複数のテーブルを扱う | 競合するすべての処理経路について、行の順序に加えてテーブルの順序も統一する。 |
| バッチを原子的にコミットする必要がある | 1つのトランザクションを維持する。個別にコミットする分割処理では、処理内容が変わる。 |
すべての対象を更新するこのバッチに SKIP LOCKED を追加しないでください。すぐにロックできない行は対象から除外されます。PostgreSQLは、キュー型の利用者には便利ですが、一般的な一貫性のあるビューには適さないと説明しています。NOWAIT は競合する行ロックを待たずにエラーを返します。待機の挙動を変えるだけで、バッチ全体が完了するわけではありません。PostgreSQL: SELECT
対象全件を特定でき、順序キーを変更せずに保ち、競合するトランザクションすべてに同じ取得ルールを適用できる場合、この実装を応用できます。対象行が不足している場合や競合が起きた場合に明確な結果が得られるよう、対象行の確認と回数制限付きのトランザクション全体の再試行を維持してください。
出典
PostgreSQL 18: 明示的ロック — PostgreSQL Global Development Group。
PostgreSQL 18: UPDATE — PostgreSQL Global Development Group。
PostgreSQL 18: SELECT — PostgreSQL Global Development Group。
PostgreSQL 18: トランザクション分離 — PostgreSQL Global Development Group。
PostgreSQL 18: シリアライゼーション失敗の処理 — PostgreSQL Global Development Group。
トランザクション — node-postgres。
接続 — node-postgres。
クエリ — node-postgres。
プール — node-postgres。