トランザクション

複数のクエリを「全部成功 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 }
  })
})

オプション

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並列でも順次実行と同じ結果(最も厳しい・遅い)

使い分け

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" })

デッドロック対策

リトライヘルパ

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 との関係

使い分けの整理

用途選択
独立クエリのアトミック化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 limitPool 枯渇。トランザクションを短く
timeout外部 API を抜き出す / option で延ばす
ネストエラーtx をさらに $transaction に渡している
並列性が出ない長いインタラクティブを使い過ぎている
迷ったら

まず updateMany + where 条件でアトミック性を出せないか考える。 無理ならInteractive + 楽観ロック。それでも厳しいならSerializable + リトライ。 現実の業務はこの 3 段階で大半が解ける。