ストーリー
「同じ商品の最後の1個を2人が同時に購入できてしまった…」
在庫管理の障害報告に、高橋アーキテクトが対応する。
「これは同時実行制御の問題だ。2つのトランザクションが同時に在庫を読み、両方とも『残り1個ある』と判断して購入処理を進めてしまった。ロック戦略を正しく設計しないと、こういう問題は必ず起きる」
同時実行で起きる問題
Dirty Read(汚れた読み取り)
コミットされていない他のトランザクションの変更を読む。
Tx A: UPDATE products SET stock = 0 WHERE id = 1;
Tx B: SELECT stock FROM products WHERE id = 1; → 0(未コミットの値)
Tx A: ROLLBACK; -- Aはロールバック
-- Bは存在しない値(0)を読んでしまった
Non-Repeatable Read(反復不能読み取り)
同一トランザクション内で同じクエリが異なる結果を返す。
Tx A: SELECT stock FROM products WHERE id = 1; → 10
Tx B: UPDATE products SET stock = 5 WHERE id = 1; COMMIT;
Tx A: SELECT stock FROM products WHERE id = 1; → 5(変わった!)
Phantom Read(ファントムリード)
同一トランザクション内で、行数が変わる。
Tx A: SELECT COUNT(*) FROM orders WHERE status = 'pending'; → 10
Tx B: INSERT INTO orders (status) VALUES ('pending'); COMMIT;
Tx A: SELECT COUNT(*) FROM orders WHERE status = 'pending'; → 11(増えた!)
4つの分離レベル
| 分離レベル | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 起きる | 起きる | 起きる |
| READ COMMITTED | 防止 | 起きる | 起きる |
| REPEATABLE READ | 防止 | 防止 | 起きる |
| SERIALIZABLE | 防止 | 防止 | 防止 |
分離レベルの設定
-- セッションレベルで設定
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- トランザクションごとに設定
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;
各分離レベルの使い分け
| 分離レベル | 用途 | パフォーマンス |
|---|---|---|
| READ UNCOMMITTED | ほぼ使わない | 最高 |
| READ COMMITTED | 一般的な操作(PostgreSQLデフォルト) | 高 |
| REPEATABLE READ | 一貫性が必要なレポート(MySQLデフォルト) | 中 |
| SERIALIZABLE | 金融取引、在庫管理 | 低 |
悲観的ロック(Pessimistic Locking)
「衝突が起きるはず」と想定し、先にロックを取得する。
-- SELECT FOR UPDATE: 行ロックを取得
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- ↑ この行がロックされる。他のトランザクションはロック解放を待つ
-- 在庫チェック&更新
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
-- ↑ COMMITでロック解放
TypeScriptでの悲観的ロック
// Prismaでの悲観的ロック
async function purchaseProduct(productId: number, quantity: number) {
return await prisma.$transaction(async (tx) => {
// FOR UPDATE で行ロック
const [product] = await tx.$queryRaw<Product[]>`
SELECT * FROM products WHERE id = ${productId} FOR UPDATE
`;
if (product.stock < quantity) {
throw new Error('Insufficient stock');
}
await tx.product.update({
where: { id: productId },
data: { stock: { decrement: quantity } },
});
return { success: true, remainingStock: product.stock - quantity };
});
}
ロックの種類
-- 排他ロック(書き込みロック): 他のトランザクションの読み書きをブロック
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- 共有ロック(読み取りロック): 他の読み取りは許可、書き込みはブロック
SELECT * FROM products WHERE id = 1 FOR SHARE;
-- NOWAIT: ロック取得できなければ即座にエラー
SELECT * FROM products WHERE id = 1 FOR UPDATE NOWAIT;
-- SKIP LOCKED: ロックされている行をスキップ
SELECT * FROM tasks WHERE status = 'pending'
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED;
楽観的ロック(Optimistic Locking)
「衝突は稀」と想定し、更新時に競合を検出する。
-- バージョンカラムを使用
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
stock INT NOT NULL,
version INT NOT NULL DEFAULT 0 -- 楽観的ロック用
);
-- 更新時にバージョンをチェック
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5; -- 読み取り時のバージョン
-- affected rows = 0 → 他のトランザクションが先に更新した → リトライ
TypeScriptでの楽観的ロック
// 楽観的ロック + リトライ
async function purchaseWithOptimisticLock(
productId: number,
quantity: number,
maxRetries: number = 3
): Promise<PurchaseResult> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
// 現在の値を読む
const product = await prisma.product.findUnique({
where: { id: productId },
});
if (!product || product.stock < quantity) {
throw new Error('Insufficient stock');
}
// バージョンチェック付きで更新
const updated = await prisma.product.updateMany({
where: {
id: productId,
version: product.version, // 読み取り時のバージョン
},
data: {
stock: { decrement: quantity },
version: { increment: 1 },
},
});
if (updated.count > 0) {
return { success: true, remainingStock: product.stock - quantity };
}
// 競合検出 → リトライ
console.log(`Optimistic lock conflict, retry ${attempt + 1}`);
}
throw new Error('Max retries exceeded');
}
悲観的ロック vs 楽観的ロック
| 特性 | 悲観的ロック | 楽観的ロック |
|---|---|---|
| 前提 | 衝突が頻繁に起きる | 衝突は稀 |
| ロック取得 | 先にロック | ロックしない |
| 衝突検出 | ロック待ち | 更新時にバージョンチェック |
| デッドロック | リスクあり | リスクなし |
| パフォーマンス | 衝突多い場合に優位 | 衝突少ない場合に優位 |
| 適用場面 | 在庫管理、座席予約 | プロフィール更新、Wiki編集 |
デッドロックの回避
Tx A: LOCK row 1 → LOCK row 2(待ち)
Tx B: LOCK row 2 → LOCK row 1(待ち)
→ 両方が相手のロック解放を永遠に待つ = デッドロック
回避策
| 対策 | 説明 |
|---|---|
| ロック順序の統一 | 常にID昇順でロック取得 |
| タイムアウト設定 | SET lock_timeout = '5s' |
| FOR UPDATE NOWAIT | ロック取得失敗で即座にエラー |
| 楽観的ロック | そもそもロックしない |
| トランザクション短縮 | ロック保持時間を最小化 |
// ロック順序の統一
async function transferFunds(fromId: number, toId: number, amount: number) {
// 常にID昇順でロック取得
const [firstId, secondId] = fromId < toId
? [fromId, toId]
: [toId, fromId];
return await prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT 1 FROM accounts WHERE id = ${firstId} FOR UPDATE`;
await tx.$queryRaw`SELECT 1 FROM accounts WHERE id = ${secondId} FOR UPDATE`;
// 送金処理...
});
}
まとめ
| ポイント | 内容 |
|---|---|
| 同時実行の問題 | Dirty Read, Non-Repeatable Read, Phantom Read |
| 分離レベル | READ UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE |
| 悲観的ロック | FOR UPDATE で先にロック取得 |
| 楽観的ロック | バージョンカラムで更新時に検出 |
| デッドロック回避 | ロック順序統一、タイムアウト、NOWAIT |
理解度チェックリスト
- 3つの同時実行問題を説明できる
- 4つの分離レベルの違いを説明できる
- 悲観的ロックと楽観的ロックの実装ができる
- デッドロックの原因と回避策を説明できる
次のステップ
次のレッスンでは分散トランザクションを学ぶ。複数のデータベースやサービスにまたがるトランザクションの難しさと解決手法(2PC、Saga)を理解しよう。
推定読了時間: 40分