はじめに
データベースの同時実行制御において、FOR UPDATE は重要な機能です。本記事では、PostgreSQL の FOR UPDATE による行ロックの挙動を、Node.js + Prisma を使った実際のコードで確認します。
FOR UPDATE とは
FOR UPDATE は SELECT 文に付与することで、取得した行に対して排他ロック(行ロック)を取得します。
SELECT * FROM m_user WHERE user_id = 1 FOR UPDATE;
このロックには以下の特徴があります:
-
排他的: 他のトランザクションが同じ行に対して
FOR UPDATEを実行しようとすると、ロックが解放されるまで待機する -
トランザクション終了まで保持:
COMMITまたはROLLBACKでロックが解放される - 行レベル: テーブル全体ではなく、条件に一致した行のみがロックされる
ユースケース
- 残高の更新(二重引き落とし防止)
- 在庫の減算(売り過ぎ防止)
- 予約システム(ダブルブッキング防止)
検証コード
2つのトランザクションを並行実行し、FOR UPDATE の挙動を確認します。
import { PrismaClient } from '@prisma/client';
async function main() {
const prisma1 = new PrismaClient();
const prisma2 = new PrismaClient();
const userId = 1;
// トランザクション1: 先にロックを取得
const transaction1 = async () => {
console.log('[TX1] 開始');
await prisma1.$transaction(
async (tx) => {
console.log('[TX1] FOR UPDATE でロック取得');
await tx.$queryRaw`
SELECT * FROM m_user
WHERE user_id = ${userId}
FOR UPDATE
`;
console.log('[TX1] ロック取得完了。5秒待機...');
await new Promise((resolve) => setTimeout(resolve, 5000));
console.log('[TX1] UPDATE 実行');
await tx.user.update({
where: { userId },
data: { firstName: '先にロック取得' },
});
console.log('[TX1] COMMIT');
},
{ timeout: 30000 },
);
console.log('[TX1] 完了\n');
};
// トランザクション2: 少し遅れて開始(ロック待ちになる)
const transaction2 = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log('[TX2] 開始');
await prisma2.$transaction(
async (tx) => {
console.log('[TX2] FOR UPDATE でロック取得を試みる...');
await tx.$queryRaw`
SELECT * FROM m_user
WHERE user_id = ${userId}
FOR UPDATE
`;
console.log('[TX2] ロック取得完了(TX1のCOMMIT後)');
console.log('[TX2] UPDATE 実行');
await tx.user.update({
where: { userId },
data: { firstName: '後からロック取得' },
});
console.log('[TX2] COMMIT');
},
{ timeout: 30000 },
);
console.log('[TX2] 完了\n');
};
// 並行実行
await Promise.all([transaction1(), transaction2()]);
// 最終結果を確認
const result = await prisma1.user.findUnique({
where: { userId },
select: { userId: true, firstName: true },
});
console.log('=== 最終結果 ===');
console.log(result);
}
main();
ポイント
- 2つの PrismaClient インスタンス: 別々のデータベース接続を模倣するため
- TX1 は 5 秒待機: ロック保持中に TX2 がロック取得を試みる状況を作る
- TX2 は 1 秒遅れで開始: TX1 がロックを取得した後に開始させる
実行結果
[TX1] 開始
[TX1] FOR UPDATE でロック取得
[TX1] ロック取得完了。5秒待機...
[TX2] 開始
[TX2] FOR UPDATE でロック取得を試みる...
← ここで TX2 はブロックされる(約4秒間)
[TX1] UPDATE 実行
[TX1] COMMIT
[TX1] 完了
[TX2] ロック取得完了(TX1のCOMMIT後)
[TX2] UPDATE 実行
[TX2] COMMIT
[TX2] 完了
=== 最終結果 ===
{ userId: 1, firstName: '後からロック取得' }
実行結果から分かること
| 時点 | TX1 | TX2 |
|---|---|---|
| 0秒 | ロック取得 | - |
| 1秒 | 待機中 | ロック取得試行 → ブロック |
| 5秒 | UPDATE + COMMIT | ブロック中 |
| 5秒直後 | 完了 | ロック取得成功 |
| 5秒+ | - | UPDATE + COMMIT |
重要な観察点:
- TX2 の
FOR UPDATEは、TX1 がCOMMITするまでブロックされる - TX1 の
COMMIT直後に TX2 がロックを取得できる - 最終的な値は、後から
COMMITした TX2 の値になる
FOR UPDATE のバリエーション
PostgreSQL では FOR UPDATE にオプションを付けられます:
-- ロック取得できなければ即座にエラー
SELECT * FROM m_user WHERE user_id = 1 FOR UPDATE NOWAIT;
-- 指定時間待ってもロック取得できなければエラー(PostgreSQL 9.3+)
SET lock_timeout = '5s';
SELECT * FROM m_user WHERE user_id = 1 FOR UPDATE;
-- ロック取得できなければスキップ(バッチ処理向け)
SELECT * FROM m_user WHERE user_id = 1 FOR UPDATE SKIP LOCKED;
Prisma での注意点
Prisma で FOR UPDATE を使う場合、$queryRaw を使用する必要があります:
// FOR UPDATE は $queryRaw で実行
await tx.$queryRaw`
SELECT * FROM m_user WHERE user_id = ${userId} FOR UPDATE
`;
// その後の更新は通常の Prisma API でOK
await tx.user.update({
where: { userId },
data: { firstName: '更新値' },
});
まとめ
-
FOR UPDATEは行レベルの排他ロックを取得する - ロックは
COMMIT/ROLLBACKまで保持される - 他のトランザクションは、ロックが解放されるまで待機する
- 同時実行制御が必要な場面(残高更新、在庫管理など)で活用できる