トランザクション
複数のクエリを「全部成功 or 全部失敗」でまとめる仕組み。 Batch / Interactive / Isolation Level / 楽観ロック / デッドロックまで。
そもそもなぜトランザクションが要るか
例: 銀行の送金。「A の残高 -1000」「B の残高 +1000」を別々に実行すると、 途中で失敗したら整合性が壊れる。両方をひとまとめにしてアトミックに実行する仕組みが必要。
Prisma の 2 つの方式
| 方式 | 特徴 |
|---|---|
| Batch(配列) | 独立クエリを順番に実行。互いの結果は使えない |
| Interactive | 関数内で順次実行。前の結果を次の入力に使える |
Batch(配列形式)
const [user, post, log] = await prisma.$transaction([
prisma.user.update({ where: { id: u }, data: { credits: { decrement: 1 } } }),
prisma.post.create({ data: { title, authorId: u } }),
prisma.log.create({ data: { event: "post_created", userId: u } }),
])
配列のいずれか 1 つでも失敗したら全部ロールバック。
Interactive(関数形式)
const result = await prisma.$transaction(async (tx) => {
const sender = await tx.account.findUnique({ where: { id: senderId } })
if (!sender || sender.balance < amount) {
throw new Error("Insufficient balance")
}
await tx.account.update({
where: { id: senderId },
data: { balance: { decrement: amount } }
})
await tx.account.update({
where: { id: receiverId },
data: { balance: { increment: amount } }
})
return await tx.transfer.create({
data: { senderId, receiverId, amount }
})
})
- 関数が throw すれば自動 ROLLBACK
- 関数が return すれば COMMIT
txは通常の prisma と同じ API(その範囲だけ)- その関数の外で prisma を使うと別接続になり、トランザクション範囲外
オプション
await prisma.$transaction(
async (tx) => { ... },
{
maxWait: 5000, // 接続待ちの最大 ms
timeout: 10000, // トランザクション全体の timeout
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}
)
Isolation Level(分離レベル)
並列トランザクションがどれだけ「独立して見えるか」を決める設定。
| レベル | 特徴 |
|---|---|
| ReadUncommitted | 未コミットも見える(ダーティリード) |
| ReadCommitted | コミット済みのみ見える(Postgres デフォルト) |
| RepeatableRead | 同じクエリが同じ結果を返す(Postgres は Snapshot Isolation 相当) |
| Serializable | 並列でも順次実行と同じ結果(最も厳しい・遅い) |
使い分け
- 普通の業務 → デフォルト(ReadCommitted)
- 残高チェック後に更新 → RepeatableRead 以上を検討
- 厳格に整合性 → Serializable(その代わり再試行が必要)
Postgres のロック(参考)
Prisma から発行できる SELECT FOR UPDATE 系は $queryRaw で:
await prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw`
SELECT id, balance FROM "Account"
WHERE id = ${id}
FOR UPDATE
`
// この行が他のトランザクションでブロックされる
})
楽観ロック(バージョン管理)
ロックを取らず、更新時に「version が一致するか」で衝突検知:
model Post {
id String @id
title String
version Int @default(0)
}
// 読み取り
const post = await prisma.post.findUnique({ where: { id } })
// 更新(バージョン一致で実行)
const updated = await prisma.post.updateMany({
where: { id, version: post.version },
data: {
title: "new title",
version: { increment: 1 },
}
})
if (updated.count === 0) throw new Error("Conflict, retry")
悲観ロック
他のトランザクションを待たせるロック。残高検証など競合が起きやすい場面で。
await prisma.$transaction(async (tx) => {
// SELECT ... FOR UPDATE
const [account] = await tx.$queryRaw<[{ id: string, balance: number }]>`
SELECT id, balance FROM "Account" WHERE id = ${id} FOR UPDATE
`
if (account.balance < amount) throw new Error("Insufficient")
await tx.account.update({
where: { id },
data: { balance: { decrement: amount } },
})
}, { isolationLevel: "Serializable" })
デッドロック対策
- 常に同じ順序でロックを取る(A→B、B→A だとデッドロック)
- 長時間トランザクションを避ける
- 失敗時は指数バックオフでリトライ
max_lock_waitを短めに
リトライヘルパ
async function retryTx<T>(fn: () => Promise<T>, retries = 3): Promise<T> {
for (let i = 0; i < retries; i++) {
try {
return await fn()
} catch (e: any) {
if (e.code === "P2034" /* serialization failure */ && i < retries - 1) {
await new Promise(r => setTimeout(r, 50 * 2 ** i))
continue
}
throw e
}
}
throw new Error("unreachable")
}
await retryTx(() => prisma.$transaction(async (tx) => { ... }, { isolationLevel: "Serializable" }))
$transaction の中で外部 API を呼ばない
トランザクション内で fetch / 外部 API 呼び出しをすると、長時間ロックを保持してしまう。 外部 API は事前に呼んで、トランザクション内は DB 操作だけに。
read-only トランザクション
Postgres の最適化を効かせるため:
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SET TRANSACTION READ ONLY`
const result = await tx.user.findMany({ ... })
return result
})
Saga パターン(分散トランザクション)
マイクロサービス間で「DB トランザクション」が成立しない場合、各ステップに補償処理を用意する。
1. 注文作成 → 失敗時: なし
2. 在庫確保 → 失敗時: 注文取消
3. 決済 → 失敗時: 在庫戻し + 注文取消
4. 配送指示 → 失敗時: 返金 + 在庫戻し + 注文取消
Prisma 単体では Saga サポートは無いが、各ステップを独立した小さなトランザクションで実装し、 失敗時に逆操作を順序通り呼ぶ。
$transaction のネスト
Prisma はネスト不可。tx をさらに $transaction に渡せない。
必要なら関数を分けて、外側で 1 トランザクションにする。
Connection Pool との関係
- Interactive transaction は1 接続を保持するので並列性が落ちる
- Serverless / Edge では特に短くすること
- 長時間処理は別ジョブに切り出す
使い分けの整理
| 用途 | 選択 |
|---|---|
| 独立クエリのアトミック化 | Batch(配列) |
| 前の結果を次に使う | Interactive |
| 残高 / 在庫の競合 | Interactive + 楽観/悲観ロック |
| マイクロサービス間 | Saga パターン |
| 大量バッチ処理 | 分割して個別に処理(1 トランザクションに入れない) |
典型コード集
1. 投票(同一ユーザの重複防止)
await prisma.$transaction(async (tx) => {
const existing = await tx.vote.findUnique({
where: { userId_postId: { userId, postId } }
})
if (existing) throw new Error("Already voted")
await tx.vote.create({ data: { userId, postId } })
await tx.post.update({
where: { id: postId },
data: { score: { increment: 1 } }
})
})
2. 在庫減らし
await prisma.$transaction(async (tx) => {
// 在庫を確認 + 減らす
const updated = await tx.product.updateMany({
where: { id, stock: { gte: qty } },
data: { stock: { decrement: qty } }
})
if (updated.count === 0) throw new Error("Out of stock")
await tx.order.create({ data: { productId: id, qty } })
})
updateMany + 条件 where は楽観ロックの強力なパターン。「N 個以上あれば減らす」が 1 SQL で原子的に実行される。
3. 集計の整合
await prisma.$transaction(async (tx) => {
const post = await tx.post.create({ data: { ... } })
await tx.user.update({
where: { id: authorId },
data: { postCount: { increment: 1 } }
})
return post
})
失敗パターン
| 症状 | 原因 / 対処 |
|---|---|
| P2034 Serialization | 競合。リトライ |
| P1013 connection limit | Pool 枯渇。トランザクションを短く |
| timeout | 外部 API を抜き出す / option で延ばす |
| ネストエラー | tx をさらに $transaction に渡している |
| 並列性が出ない | 長いインタラクティブを使い過ぎている |
まず updateMany + where 条件でアトミック性を出せないか考える。 無理ならInteractive + 楽観ロック。それでも厳しいならSerializable + リトライ。 現実の業務はこの 3 段階で大半が解ける。