メインコンテンツへスキップ

トランザクションとバッチクエリ

データベーストランザクションとは、一連の読み取り/書き込み操作が全体として成功するか失敗するかのどちらかであることが保証されているものを指します。このセクションでは、Prisma Client APIがトランザクションをサポートする方法について説明します。

トランザクションの概要

情報

Prisma ORMバージョン4.4.0以前では、トランザクションの分離レベルを設定できませんでした。データベース設定の分離レベルが常に適用されていました。

開発者は、操作をトランザクションで囲むことで、データベースが提供する安全性保証を利用します。これらの保証は、しばしばACIDという頭字語でまとめられます

  • 原子性 (Atomic): トランザクションの操作がすべて成功するか、すべて失敗するかのいずれかであることを保証します。トランザクションは、正常にコミットされるか、中止されてロールバックされます
  • 一貫性 (Consistent): トランザクションの前後でデータベースの状態が有効であることを保証します(つまり、データに関する既存の不変条件が維持されます)。
  • 分離性 (Isolated): 同時に実行されているトランザクションが、あたかも順番に実行されているかのように同じ効果を持つことを保証します。
  • 永続性 (Durability): トランザクションが成功した後、すべての書き込みが永続的に保存されることを保証します。

これらの各特性には多くの曖昧さとニュアンスがありますが(たとえば、一貫性は実際にはデータベースのプロパティではなくアプリケーションレベルの責任と見なされることもありますし、分離性は通常、より強いまたは弱い分離レベルで保証されます)、全体としては、データベーストランザクションについて考える際に開発者が抱く期待の優れた高レベルガイドラインとして機能します。

「「トランザクションは、特定の並行処理の問題や、特定の種類のハードウェアおよびソフトウェアの障害が存在しないかのようにアプリケーションが振る舞うことを可能にする抽象化レイヤーです。多くの種類のエラーは、単純なトランザクションの中止に集約され、アプリケーションは単に再試行するだけで済みます。」 データ指向アプリケーションデザインマーティン・ケップマン

Prisma Clientは、3つの異なるシナリオに対し、6つの異なる方法でトランザクションを処理することをサポートしています

シナリオ利用可能な手法
依存書き込み
  • ネストされた書き込み
独立した書き込み
  • $transaction([]) API
  • バッチ操作
読み取り、変更、書き込み
  • 冪等な操作
  • 楽観的並行性制御
  • インタラクティブトランザクション

選択する手法は、特定のユースケースによって異なります。

: このガイドの目的において、データベースへの書き込みとは、データの作成、更新、削除を指します。

Prisma Clientにおけるトランザクションについて

Prisma Clientは、トランザクションを使用するための以下のオプションを提供します

  • ネストされた書き込み: Prisma Client APIを使用して、同じトランザクション内で1つ以上の関連レコードに対する複数の操作を処理します。
  • バッチ/一括トランザクション: updateManydeleteMany、およびcreateManyを使用して、1つ以上の操作を一括で処理します。
  • Prisma Clientの$transaction API
    • シーケンシャル操作: $transaction<R>(queries: PrismaPromise<R>[]): Promise<R[]>を使用して、トランザクション内で順次実行されるPrisma Clientクエリの配列を渡します。
    • インタラクティブトランザクション: $transaction<R>(fn: (prisma: PrismaClient) => R, options?: object): Rを使用して、Prisma Clientクエリ、非Prismaコード、その他の制御フローを含むユーザーコードをトランザクション内で実行できる関数を渡します。

ネストされた書き込み

A ネストされた書き込みを使用すると、複数の関連レコードに影響する複数の操作を単一のPrisma Client API呼び出しで実行できます。たとえば、ユーザー投稿を一緒に作成したり、注文請求書を一緒に更新したりできます。Prisma Clientは、すべての操作が全体として成功するか失敗するかを保証します。

以下の例は、createを使用したネストされた書き込みを示しています

// Create a new user with two posts in a
// single transaction
const newUser: User = await prisma.user.create({
data: {
email: 'alice@prisma.io',
posts: {
create: [
{ title: 'Join the Prisma Discord at https://pris.ly/discord' },
{ title: 'Follow @prisma on Twitter' },
],
},
},
})

以下の例は、updateを使用したネストされた書き込みを示しています

// Change the author of a post in a single transaction
const updatedPost: Post = await prisma.post.update({
where: { id: 42 },
data: {
author: {
connect: { email: 'alice@prisma.io' },
},
},
})

バッチ/一括操作

以下のバルク操作はトランザクションとして実行されます

  • createMany()
  • createManyAndReturn()
  • updateMany()
  • updateManyAndReturn()
  • deleteMany()

詳細な例については、バルク操作に関するセクションを参照してください。

$transaction API

$transaction APIは2つの方法で使用できます

  • シーケンシャル操作: トランザクション内で順次実行されるPrisma Clientクエリの配列を渡します。

    $transaction<R>(queries: PrismaPromise<R>[]): Promise<R[]>

  • インタラクティブトランザクション: Prisma Clientクエリ、非Prismaコード、その他の制御フローを含むユーザーコードをトランザクション内で実行できる関数を渡します。

    $transaction<R>(fn: (prisma: PrismaClient) => R): R

Prisma Clientのシーケンシャル操作

以下のクエリは、提供されたフィルターに一致するすべての投稿と、すべての投稿のカウントを返します

const [posts, totalPosts] = await prisma.$transaction([
prisma.post.findMany({ where: { title: { contains: 'prisma' } } }),
prisma.post.count(),
])

$transaction内で生クエリを使用することもできます

import { selectUserTitles, updateUserName } from '@prisma/client/sql'

const [userList, updateUser] = await prisma.$transaction([
prisma.$queryRawTyped(selectUserTitles()),
prisma.$queryRawTyped(updateUserName(2)),
])

各操作が実行されたときにすぐに結果を待つのではなく、まず操作自体が変数に格納され、後で$transactionというメソッドでデータベースに送信されます。Prisma Clientは、3つのcreate操作すべてが成功するか、どれも成功しないかのいずれかを保証します。

: 操作は、トランザクション内に配置された順序に従って実行されます。トランザクション内でクエリを使用しても、クエリ自体の操作の順序には影響しません。

詳細な例については、トランザクションAPIに関するセクションを参照してください。

バージョン4.4.0以降、シーケンシャル操作トランザクションAPIには2番目のパラメータがあります。このパラメータでは、以下のオプション設定を使用できます

await prisma.$transaction(
[
prisma.resource.deleteMany({ where: { name: 'name' } }),
prisma.resource.createMany({ data }),
],
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable, // optional, default defined by database configuration
}
)

インタラクティブトランザクション

概要

トランザクション内で実行されるクエリをより細かく制御する必要がある場合があります。インタラクティブトランザクションは、そのような場合の抜け道を提供することを意図しています。

情報

インタラクティブトランザクションは、バージョン4.7.0から一般利用可能になりました。

バージョン2.29.0から4.6.1(両端を含む)のプレビューでインタラクティブトランザクションを使用する場合、PrismaスキーマのジェネレーターブロックにinteractiveTransactionsプレビュー機能を追加する必要があります。

インタラクティブトランザクションを使用するには、$transactionに非同期関数を渡すことができます。

この非同期関数に渡される最初の引数は、Prisma Clientのインスタンスです。以下では、このインスタンスをtxと呼びます。このtxインスタンスで呼び出されたPrisma Clientの呼び出しはすべて、トランザクションにカプセル化されます。

警告

インタラクティブトランザクションは慎重に使用してください。トランザクションを長時間開いたままにすると、データベースのパフォーマンスが低下し、デッドロックを引き起こす可能性さえあります。トランザクション関数内でネットワークリクエストの実行や低速なクエリの実行は避けるようにしてください。できるだけ早く処理を完了させることをお勧めします!

例を見てみましょう

オンラインバンキングシステムを構築していると想像してください。実行すべきアクションの1つは、ある人から別の人へ送金することです。

経験豊富な開発者として、送金中に以下のことを確認したいと考えます。

  • 金額が消えないこと
  • 金額が二重にならないこと

これはインタラクティブトランザクションの優れたユースケースです。なぜなら、残高を確認するために書き込みの間にロジックを実行する必要があるからです。

以下の例では、アリスとボブはそれぞれ口座に100ドルを持っています。もし持っている金額よりも多く送金しようとすると、送金は拒否されます。

アリスは100ドルの送金を1回成功させることが期待され、もう1回の送金は拒否されます。これにより、アリスは0ドル、ボブは200ドルになるはずです。

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

function transfer(from: string, to: string, amount: number) {
return prisma.$transaction(async (tx) => {
// 1. Decrement amount from the sender.
const sender = await tx.account.update({
data: {
balance: {
decrement: amount,
},
},
where: {
email: from,
},
})

// 2. Verify that the sender's balance didn't go below zero.
if (sender.balance < 0) {
throw new Error(`${from} doesn't have enough to send ${amount}`)
}

// 3. Increment the recipient's balance by amount
const recipient = await tx.account.update({
data: {
balance: {
increment: amount,
},
},
where: {
email: to,
},
})

return recipient
})
}

async function main() {
// This transfer is successful
await transfer('alice@prisma.io', 'bob@prisma.io', 100)
// This transfer fails because Alice doesn't have enough funds in her account
await transfer('alice@prisma.io', 'bob@prisma.io', 100)
}

main()

上記の例では、両方のupdateクエリがデータベーストランザクション内で実行されます。アプリケーションが関数の終わりに達すると、トランザクションはデータベースにコミットされます

もしアプリケーションが途中でエラーに遭遇した場合、非同期関数は例外をスローし、トランザクションを自動的にロールバックします。

例外をキャッチするには、$transactionをtry-catchブロックで囲むことができます

try {
await prisma.$transaction(async (tx) => {
// Code running in a transaction...
})
} catch (err) {
// Handle the rollback...
}

トランザクションオプション

トランザクションAPIには2番目のパラメータがあります。インタラクティブトランザクションの場合、このパラメータで以下のオプション設定を使用できます

  • maxWait: Prisma Clientがデータベースからトランザクションを取得するまで待機する最大時間。デフォルト値は2秒です。
  • timeout: インタラクティブトランザクションがキャンセルされ、ロールバックされるまでに実行できる最大時間。デフォルト値は5秒です。
  • isolationLevel: トランザクションの分離レベルを設定します。デフォルトでは、データベースで現在設定されている値に設定されます。

await prisma.$transaction(
async (tx) => {
// Code running in a transaction...
},
{
maxWait: 5000, // default: 2000
timeout: 10000, // default: 5000
isolationLevel: Prisma.TransactionIsolationLevel.Serializable, // optional, default defined by database configuration
}
)

これらはコンストラクターレベルでグローバルに設定することもできます

const prisma = new PrismaClient({
transactionOptions: {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000, // default: 2000
timeout: 10000, // default: 5000
},
})

トランザクション分離レベル

情報

この機能はMongoDBでは利用できません。MongoDBは分離レベルをサポートしていないためです。

トランザクションの分離レベルを設定できます。

情報

これは、インタラクティブトランザクションではバージョン4.2.0以降、シーケンシャル操作ではバージョン4.4.0以降のPrisma ORMで利用可能です。

バージョン4.2.0以前(インタラクティブトランザクションの場合)または4.4.0以前(シーケンシャル操作の場合)では、Prisma ORMレベルでトランザクション分離レベルを設定することはできません。Prisma ORMは明示的に分離レベルを設定しないため、データベースで設定されている分離レベルが使用されます。

分離レベルを設定する

トランザクション分離レベルを設定するには、APIの2番目のパラメータでisolationLevelオプションを使用します。

シーケンシャル操作の場合

await prisma.$transaction(
[
// Prisma Client operations running in a transaction...
],
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable, // optional, default defined by database configuration
}
)

インタラクティブトランザクションの場合

await prisma.$transaction(
async (prisma) => {
// Code running in a transaction...
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable, // optional, default defined by database configuration
maxWait: 5000, // default: 2000
timeout: 10000, // default: 5000
}
)

サポートされている分離レベル

Prisma Clientは、基になるデータベースで利用可能な場合、以下の分離レベルをサポートします

  • ReadUncommitted
  • ReadCommitted
  • RepeatableRead
  • Snapshot
  • Serializable

各データベースコネクタで利用可能な分離レベルは以下の通りです

データベースReadUncommittedReadCommittedRepeatableReadSnapshotSerializable
PostgreSQL✔️✔️✔️いいえ✔️
MySQL✔️✔️✔️いいえ✔️
SQL Server✔️✔️✔️✔️✔️
CockroachDBいいえいいえいいえいいえ✔️
SQLiteいいえいいえいいえいいえ✔️

デフォルトでは、Prisma Clientは分離レベルをデータベースで現在設定されている値に設定します。

各データベースでデフォルトで設定されている分離レベルは以下の通りです

データベースデフォルト
PostgreSQLReadCommitted
MySQLRepeatableRead
SQL ServerReadCommitted
CockroachDBSerializable
SQLiteSerializable

分離レベルに関するデータベース固有の情報

以下のリソースを参照してください

CockroachDBとSQLiteはSerializable分離レベルのみをサポートしています。

トランザクションのタイミングの問題

情報
  • このセクションの解決策はMongoDBには適用されません。MongoDBは分離レベルをサポートしていないためです。
  • このセクションで議論されているタイミングの問題は、CockroachDBとSQLiteには適用されません。これらのデータベースは最高のSerializable分離レベルのみをサポートしているためです。

特定の分離レベルで2つ以上のトランザクションが同時に実行されると、タイミングの問題により、一意性制約の違反などの書き込み競合やデッドロックが発生する可能性があります。たとえば、トランザクションAとトランザクションBの両方がdeleteManyおよびcreateMany操作を実行しようとする、以下のイベントシーケンスを考えてみましょう

  1. トランザクションB: createMany操作が新しい行のセットを作成します。
  2. トランザクションB: アプリケーションがトランザクションBをコミットします。
  3. トランザクションA: createMany操作。
  4. トランザクションA: アプリケーションがトランザクションAをコミットします。新しい行は、ステップ2でトランザクションBが追加した行と競合します。

この競合は、PostgreSQLおよびMicrosoft SQL Serverのデフォルト分離レベルであるReadCommited分離レベルで発生する可能性があります。この問題を回避するには、より高い分離レベル(RepeatableReadまたはSerializable)を設定できます。トランザクションで分離レベルを設定できます。これにより、そのトランザクションのデータベース分離レベルがオーバーライドされます。

トランザクションでの書き込み競合とデッドロックを避けるには

  1. トランザクションで、isolationLevelパラメータをPrisma.TransactionIsolationLevel.Serializableに設定します。

    これにより、アプリケーションが複数の同時または並列トランザクションをあたかも順次実行されたかのようにコミットすることが保証されます。書き込み競合またはデッドロックによりトランザクションが失敗した場合、Prisma ClientはP2034エラーを返します。

  2. アプリケーションコードで、この例に示すように、P2034エラーを処理するためにトランザクションの周囲に再試行を追加します

    import { Prisma, PrismaClient } from '@prisma/client'

    const prisma = new PrismaClient()
    async function main() {
    const MAX_RETRIES = 5
    let retries = 0

    let result
    while (retries < MAX_RETRIES) {
    try {
    result = await prisma.$transaction(
    [
    prisma.user.deleteMany({
    where: {
    /** args */
    },
    }),
    prisma.post.createMany({
    data: {
    /** args */
    },
    }),
    ],
    {
    isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
    }
    )
    break
    } catch (error) {
    if (error.code === 'P2034') {
    retries++
    continue
    }
    throw error
    }
    }
    }

Promise.all()内での$transactionの使用

$transactionPromise.all()の呼び出し内にラップすると、トランザクション内のクエリは順次(つまり、1つずつ)実行されます

await prisma.$transaction(async (prisma) => {
await Promise.all([
prisma.user.findMany(),
prisma.user.findMany(),
prisma.user.findMany(),
prisma.user.findMany(),
prisma.user.findMany(),
prisma.user.findMany(),
prisma.user.findMany(),
prisma.user.findMany(),
prisma.user.findMany(),
prisma.user.findMany(),
])
})

これは、Promise.all()が通常、それに渡された呼び出しを並列化するため、直感に反するかもしれません。

この動作の理由は次のとおりです

  • 1つのトランザクションとは、その内部のすべてのクエリが同じ接続で実行されなければならないことを意味します。
  • データベース接続は一度に1つのクエリしか実行できません。
  • あるクエリが作業中に接続をブロックするため、トランザクションをPromise.allに入れることは、実質的にクエリが次々に実行されるべきであることを意味します。

依存書き込み

書き込みは、以下の場合に相互に依存していると見なされます

  • 操作が先行する操作の結果に依存している場合(例:データベースがIDを生成する場合)

最も一般的なシナリオは、レコードを作成し、生成されたIDを使用して関連レコードを作成または更新することです。例としては以下が挙げられます

  • ユーザーと2つの関連するブログ投稿を作成する場合(一対多の関係) - 投稿を作成する前に著者IDが知られている必要があります
  • チームを作成し、メンバーを割り当てる場合(多対多の関係) - メンバーを割り当てる前にチームIDが知られている必要があります

データの一貫性を維持し、著者なしのブログ投稿やメンバーなしのチームといった予期せぬ動作を防ぐために、依存書き込みは一緒に成功する必要があります。

ネストされた書き込み

Prisma Clientが依存書き込みに対して提供する解決策は、createupdateでサポートされているネストされた書き込み機能です。以下のネストされた書き込みは、1人のユーザーと2つのブログ投稿を作成します

const nestedWrite = await prisma.user.create({
data: {
email: 'imani@prisma.io',
posts: {
create: [
{ title: 'My first day at Prisma' },
{ title: 'How to configure a unique constraint in PostgreSQL' },
],
},
},
})

いずれかの操作が失敗した場合、Prisma Clientはトランザクション全体をロールバックします。ネストされた書き込みは現在、client.user.deleteManyclient.user.updateManyのようなトップレベルのバルク操作ではサポートされていません。

ネストされた書き込みを使用するタイミング

以下の場合にネストされた書き込みの使用を検討してください

  • ✔ 2つ以上のIDで関連するレコードを同時に作成したい場合(例:ブログ投稿とユーザーを作成する)
  • ✔ IDで関連するレコードを同時に更新および作成したい場合(例:ユーザー名を変更し、新しいブログ投稿を作成する)
ヒント

IDを事前計算する場合、ネストされた書き込みと$transaction([]) APIの使用のどちらかを選択できます。

シナリオ: サインアップフロー

Slackのサインアップフローを考えてみましょう。これは

  1. チームを作成し
  2. そのチームにユーザーを1人追加し、そのユーザーが自動的にそのチームの管理者になります

このシナリオは以下のスキーマで表現できます。ユーザーは複数のチームに所属でき、チームは複数のユーザーを持つことができます(多対多の関係)。

model Team {
id Int @id @default(autoincrement())
name String
members User[] // Many team members
}

model User {
id Int @id @default(autoincrement())
email String @unique
teams Team[] // Many teams
}

最も簡単なアプローチは、チームを作成し、その後ユーザーを作成してそのチームに紐付けることです

// Create a team
const team = await prisma.team.create({
data: {
name: 'Aurora Adventures',
},
})

// Create a user and assign them to the team
const user = await prisma.user.create({
data: {
email: 'alice@prisma.io',
team: {
connect: {
id: team.id,
},
},
},
})

しかし、このコードには問題があります。次のシナリオを考えてみましょう

  1. チームの作成は成功し、"Aurora Adventures"はすでに使用されています
  2. ユーザーの作成と接続が失敗し、チーム"Aurora Adventures"は存在するもののユーザーがいません
  3. 再びサインアップフローを実行し、"Aurora Adventures"を再作成しようとすると失敗します。チームがすでに存在するためです

チームの作成とユーザーの追加は、全体として成功するか失敗するかのどちらかである、1つのアトミックな操作であるべきです。

低レベルのデータベースクライアントでアトミックな書き込みを実装するには、BEGINCOMMITROLLBACKステートメントで挿入をラップする必要があります。Prisma Clientは、ネストされた書き込みでこの問題を解決します。以下のクエリは、チームを作成し、ユーザーを作成し、単一のトランザクションでレコードを接続します

const team = await prisma.team.create({
data: {
name: 'Aurora Adventures',
members: {
create: {
email: 'alice@prisma.io',
},
},
},
})

さらに、途中でエラーが発生した場合、Prisma Clientはトランザクション全体をロールバックします。

ネストされた書き込みに関するFAQ

同じ問題を解決するために$transaction([]) APIを使用できないのはなぜですか?

$transaction([]) APIでは、異なる操作間でIDを渡すことはできません。以下の例では、createUserOperation.idはまだ利用できません

const createUserOperation = prisma.user.create({
data: {
email: 'ebony@prisma.io',
},
})

const createTeamOperation = prisma.team.create({
data: {
name: 'Aurora Adventures',
members: {
connect: {
id: createUserOperation.id, // Not possible, ID not yet available
},
},
},
})

await prisma.$transaction([createUserOperation, createTeamOperation])
ネストされた書き込みはネストされた更新をサポートしますが、更新は依存書き込みではありません。$transaction([]) APIを使用すべきですか?

チームのIDが分かっているため、$transaction([])内でチームとそのチームメンバーを独立して更新できるというのは正しいです。以下の例は、$transaction([])で両方の操作を実行します

const updateTeam = prisma.team.update({
where: {
id: 1,
},
data: {
name: 'Aurora Adventures Ltd',
},
})

const updateUsers = prisma.user.updateMany({
where: {
teams: {
some: {
id: 1,
},
},
name: {
equals: null,
},
},
data: {
name: 'Unknown User',
},
})

await prisma.$transaction([updateUsers, updateTeam])

ただし、ネストされた書き込みでも同じ結果を得ることができます

const updateTeam = await prisma.team.update({
where: {
id: 1,
},
data: {
name: 'Aurora Adventures Ltd', // Update team name
members: {
updateMany: {
// Update team members that do not have a name
data: {
name: 'Unknown User',
},
where: {
name: {
equals: null,
},
},
},
},
},
})
複数のネストされた書き込みを実行できますか?例えば、2つの新しいチームを作成してユーザーを割り当てることはできますか?

はい、しかしこれはシナリオと手法の組み合わせです

  • チームを作成してユーザーを割り当てるのは依存書き込みです。ネストされた書き込みを使用します
  • すべてのチームとユーザーを同時に作成することは独立した書き込みです。チーム/ユーザーの組み合わせ #1 とチーム/ユーザーの組み合わせ #2 は関連のない書き込みであるため、$transaction([]) APIを使用します
// Nested write
const createOne = prisma.team.create({
data: {
name: 'Aurora Adventures',
members: {
create: {
email: 'alice@prisma.io',
},
},
},
})

// Nested write
const createTwo = prisma.team.create({
data: {
name: 'Cool Crew',
members: {
create: {
email: 'elsa@prisma.io',
},
},
},
})

// $transaction([]) API
await prisma.$transaction([createTwo, createOne])

独立した書き込み

書き込みは、以前の操作の結果に依存しない場合、独立していると見なされます。以下の独立した書き込みのグループは、どのような順序でも発生する可能性があります

  • 注文リストのステータスフィールドを「発送済み」に更新する
  • メールリストを「既読」としてマークする

: 独立した書き込みは、制約が存在する場合、特定の順序で発生する必要がある場合があります。たとえば、投稿に必須のauthorIdフィールドがある場合、ブログの著者を削除する前にブログ投稿を削除する必要があります。しかし、それらはまだ独立した書き込みと見なされます。なぜなら、データベースが生成されたIDを返すといった、以前の操作の結果に依存する操作がないためです。

要件に応じて、Prisma Clientには、成功または失敗をまとめて処理する必要がある独立した書き込みを扱うための4つのオプションがあります。

バルク操作

バルク書き込みを使用すると、同じタイプの複数のレコードを単一のトランザクションで書き込むことができます。いずれかの操作が失敗した場合、Prisma Clientはトランザクション全体をロールバックします。Prisma Clientは現在、以下をサポートしています

  • createMany()
  • createManyAndReturn()
  • updateMany()
  • updateManyAndReturn()
  • deleteMany()

バルク操作を使用するタイミング

以下の場合にバルク操作を解決策として検討してください

  • ✔ メールの一括処理のように、同じタイプのレコードのバッチを更新したい場合

シナリオ: メールを既読にする

gmail.comのようなサービスを構築しており、顧客はユーザーがすべてのメールを既読にする「すべて既読にする」機能を望んでいます。メールのステータスへの各更新は独立した書き込みです。なぜなら、メールは互いに依存しないためです。例えば、おばさんからの「誕生日おめでとう!🍰」メールは、IKEAからのプロモーションメールとは関係ありません。

以下のスキーマでは、Userは複数の受信メールを持つことができます(一対多の関係)。

model User {
id Int @id @default(autoincrement())
email String @unique
receivedEmails Email[] // Many emails
}

model Email {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
subject String
body String
unread Boolean
}

このスキーマに基づき、updateManyを使用して未読メールをすべて既読にすることができます

await prisma.email.updateMany({
where: {
user: {
id: 10,
},
unread: true,
},
data: {
unread: false,
},
})

バルク操作でネストされた書き込みを使用できますか?

いいえ。現在、updateManydeleteManyもネストされた書き込みをサポートしていません。例えば、複数のチームとそのすべてのメンバーを削除する(カスケード削除)ことはできません。

await prisma.team.deleteMany({
where: {
id: {
in: [2, 99, 2, 11],
},
},
data: {
members: {}, // Cannot access members here
},
})

$transaction([]) APIでバルク操作を使用できますか?

はい。例えば、複数のdeleteMany操作を$transaction([])内に含めることができます。

$transaction([]) API

$transaction([]) APIは、独立した書き込みに対する汎用的な解決策であり、複数の操作を単一のアトミックな操作として実行できます。いずれかの操作が失敗した場合、Prisma Clientはトランザクション全体をロールバックします。

また、操作はトランザクションに配置された順序に従って実行されることにも留意してください。

await prisma.$transaction([iRunFirst, iRunSecond, iRunThird])

: トランザクション内でクエリを使用しても、クエリ自体の操作の順序には影響しません。

Prisma Clientの進化に伴い、$transaction([]) APIのユースケースは、より専門的なバルク操作(createManyなど)やネストされた書き込みに置き換えられていくでしょう。

$transaction([]) APIを使用するタイミング

以下の場合に$transaction([]) APIの使用を検討してください

  • ✔ メールやユーザーなど、異なるタイプのレコードを含むバッチを更新したい場合。レコードはどのような形でも関連している必要はありません。
  • ✔ 生のSQLクエリ($executeRaw)をバッチ処理したい場合(例:Prisma Clientがまだサポートしていない機能のため)。

シナリオ: プライバシー規制

GDPRおよびその他のプライバシー規制は、ユーザーに組織が個人データをすべて削除するよう要求する権利を与えています。以下のスキーマ例では、Userは複数の投稿とプライベートメッセージを持つことができます

model User {
id Int @id @default(autoincrement())
posts Post[]
privateMessages PrivateMessage[]
}

model Post {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
title String
content String
}

model PrivateMessage {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
message String
}

ユーザーが「忘れられる権利」を行使した場合、ユーザーレコード、プライベートメッセージ、投稿の3つのレコードを削除する必要があります。すべての削除操作がまとめて成功するか、まったく成功しないかのどちらかであることが極めて重要であり、これがトランザクションのユースケースとなります。しかし、このシナリオでは、3つのモデルにわたって削除する必要があるため、deleteManyのような単一のバルク操作を使用することはできません。代わりに、$transaction([]) APIを使用して、2つのdeleteManyと1つのdeleteの3つの操作をまとめて実行できます

const id = 9 // User to be deleted

const deletePosts = prisma.post.deleteMany({
where: {
userId: id,
},
})

const deleteMessages = prisma.privateMessage.deleteMany({
where: {
userId: id,
},
})

const deleteUser = prisma.user.delete({
where: {
id: id,
},
})

await prisma.$transaction([deletePosts, deleteMessages, deleteUser]) // Operations succeed or fail together

シナリオ: 事前計算されたIDと$transaction([]) API

$transaction([]) APIは依存書き込みをサポートしていません。操作Aが操作Bによって生成されたIDに依存する場合、ネストされた書き込みを使用してください。ただし、IDを(GUIDを生成するなどして)事前計算した場合、書き込みは独立したものになります。ネストされた書き込みの例からサインアップフローを考えてみましょう

await prisma.team.create({
data: {
name: 'Aurora Adventures',
members: {
create: {
email: 'alice@prisma.io',
},
},
},
})

IDを自動生成する代わりに、TeamUseridフィールドをStringに変更します(値を指定しない場合、UUIDが自動的に生成されます)。この例ではUUIDを使用します

model Team {
id Int @id @default(autoincrement())
id String @id @default(uuid())
name String
members User[]
}

model User {
id Int @id @default(autoincrement())
id String @id @default(uuid())
email String @unique
teams Team[]
}

サインアップフローの例を、ネストされた書き込みの代わりに$transaction([]) APIを使用するようにリファクタリングします

import { v4 } from 'uuid'

const teamID = v4()
const userID = v4()

await prisma.$transaction([
prisma.user.create({
data: {
id: userID,
email: 'alice@prisma.io',
team: {
id: teamID,
},
},
}),
prisma.team.create({
data: {
id: teamID,
name: 'Aurora Adventures',
},
}),
])

技術的には、もしその構文を好むのであれば、事前計算されたAPIとネストされた書き込みを併用することもできます

import { v4 } from 'uuid'

const teamID = v4()
const userID = v4()

await prisma.team.create({
data: {
id: teamID,
name: 'Aurora Adventures',
members: {
create: {
id: userID,
email: 'alice@prisma.io',
team: {
id: teamID,
},
},
},
},
})

自動生成されたIDとネストされた書き込みをすでに使用している場合、手動で生成されたIDと$transaction([]) APIに切り替える説得力のある理由はありません。

読み取り、変更、書き込み

場合によっては、アトミック操作の一部としてカスタムロジックを実行する必要があるかもしれません。これは読み取り-変更-書き込みパターンとしても知られています。以下は読み取り-変更-書き込みパターンの例です

  • データベースから値を読み取る
  • その値を操作するためのロジックを実行する(例:外部APIに接続する)
  • その値をデータベースに書き戻す

すべての操作は、データベースに不要な変更を加えることなく、まとめて成功するか失敗するかのどちらかであるべきですが、必ずしも実際のデータベーストランザクションを使用する必要はありません。このガイドのこのセクションでは、Prisma Clientと読み取り-変更-書き込みパターンを扱う2つの方法について説明します

  • 冪等なAPIの設計
  • 楽観的並行性制御

冪等なAPI

冪等性とは、同じロジックを同じパラメータで複数回実行しても同じ結果が得られる能力のことです。つまり、そのロジックを1回実行しても1000回実行しても、データベースへの影響は同じです。例を挙げます

  • 冪等ではない: メールアドレス"letoya@prisma.io"を持つユーザーをデータベースにアップサート(更新または挿入)する。Userテーブルは、一意のメールアドレスを強制しない。ロジックを1回実行した場合(ユーザーが1人作成される)と10回実行した場合(ユーザーが10人作成される)では、データベースへの影響が異なります。
  • 冪等である: メールアドレス"letoya@prisma.io"を持つユーザーをデータベースにアップサート(更新または挿入)する。Userテーブルは、一意のメールアドレスを強制する。ロジックを1回実行した場合(ユーザーが1人作成される)と10回実行した場合(既存のユーザーが同じ入力で更新される)では、データベースへの影響は同じです。

冪等性は、可能な限りアプリケーションに積極的に設計すべきものです。

冪等なAPIを設計するタイミング

  • ✔ データベースに望ましくない副作用を生じさせることなく、同じロジックを再試行できる必要がある場合

シナリオ: Slackチームのアップグレード

Slackのアップグレードフローを作成しています。これにより、チームは有料機能をアンロックできます。チームは異なるプランから選択でき、ユーザーごとに月額を支払います。Stripeを決済ゲートウェイとして使用し、Teamモデルを拡張してstripeCustomerIdを保存します。購読はStripeで管理されます。

model Team {
id Int @id @default(autoincrement())
name String
User User[]
stripeCustomerId String?
}

アップグレードフローは次のようになります

  1. ユーザー数をカウントする
  2. ユーザー数を含むStripeでの購読を作成する
  3. チームをStripe顧客IDに関連付けて、有料機能をアンロックする
const teamId = 9
const planId = 'plan_id'

// Count team members
const numTeammates = await prisma.user.count({
where: {
teams: {
some: {
id: teamId,
},
},
},
})

// Create a customer in Stripe for plan-9454549
const customer = await stripe.customers.create({
externalId: teamId,
plan: planId,
quantity: numTeammates,
})

// Update the team with the customer id to indicate that they are a customer
// and support querying this customer in Stripe from our application code.
await prisma.team.update({
data: {
customerId: customer.id,
},
where: {
id: teamId,
},
})

この例には問題があります。ロジックを1回しか実行できません。以下のシナリオを考えてみましょう

  1. Stripeが新しい顧客と購読を作成し、顧客IDを返す

  2. チームの更新が失敗する - Slackデータベースでチームが顧客としてマークされない

  3. 顧客はStripeから課金されるが、チームに有効なcustomerIdがないため、Slackで有料機能がアンロックされない

  4. 同じコードを再度実行すると、次のいずれかの結果になります

    • チーム(externalIdで定義)がすでに存在するためエラーになる - Stripeは顧客IDを返さない
    • もしexternalIdが一意性制約の対象でない場合、Stripeはさらに別の購読を作成します(冪等ではない

エラーが発生した場合、このコードを再実行することはできませんし、二重に課金されることなく別のプランに変更することもできません。

以下のリファクタリング(ハイライト表示)は、購読がすでに存在するかどうかをチェックし、購読を作成するか既存の購読を更新する(入力が同じであれば変更されない)メカニズムを導入しています

// Calculate the number of users times the cost per user
const numTeammates = await prisma.user.count({
where: {
teams: {
some: {
id: teamId,
},
},
},
})

// Find customer in Stripe
let customer = await stripe.customers.get({ externalId: teamID })

if (customer) {
// If team already exists, update
customer = await stripe.customers.update({
externalId: teamId,
plan: 'plan_id',
quantity: numTeammates,
})
} else {
customer = await stripe.customers.create({
// If team does not exist, create customer
externalId: teamId,
plan: 'plan_id',
quantity: numTeammates,
})
}

// Update the team with the customer id to indicate that they are a customer
// and support querying this customer in Stripe from our application code.
await prisma.team.update({
data: {
customerId: customer.id,
},
where: {
id: teamId,
},
})

これで、同じ入力を複数回使用して同じロジックを悪影響なく再試行できるようになりました。この例をさらに強化するには、更新が規定回数試行しても成功しない場合に、購読がキャンセルまたは一時的に無効化されるメカニズムを導入できます。

楽観的並行性制御

楽観的並行性制御(OCC)は、🔒ロックに依存しない単一エンティティに対する同時操作を処理するためのモデルです。代わりに、レコードが読み取りと書き込みの間で変更されないと楽観的に仮定し、同時実行トークン(タイムスタンプまたはバージョンフィールド)を使用してレコードへの変更を検出します。

❌競合が発生した場合(あなたがレコードを読み取ってから他の誰かが変更した場合)、トランザクションをキャンセルします。シナリオに応じて、その後以下のいずれかを実行できます

  • トランザクションを再試行する(別の映画館の座席を予約する)
  • エラーをスローする(他の誰かによって行われた変更を上書きしようとしていることをユーザーに警告する)

このセクションでは、独自の楽観的並行性制御を構築する方法について説明します。以下も参照してください: GitHubでのアプリケーションレベルの楽観的並行性制御の計画

情報
  • バージョン4.4.0以前を使用している場合、一意でないフィールドでフィルタリングできないため、update操作で楽観的並行性制御を使用できません。楽観的並行性制御で使用する必要があるversionフィールドは一意でないフィールドです。

  • バージョン5.0.0以降、update操作で一意でないフィールドでフィルタリングできるようになり、楽観的並行性制御が使用できるようになりました。この機能は、バージョン4.5.0から4.16.2までプレビューフラグextendedWhereUniqueを介しても利用可能でした。

楽観的並行性制御を使用するタイミング

  • ✔ 同時リクエストが多数発生すると予想される場合(複数人が映画館の座席を予約するなど)
  • ✔ それらの同時リクエスト間の競合がまれであると予想される場合

多数の同時リクエストがあるアプリケーションでロックを避けることで、アプリケーションは負荷に対する耐性が高まり、全体としてスケーラブルになります。ロック自体は本質的に悪いものではありませんが、高並行環境でのロックは、個々の行を短時間だけロックする場合でも、意図しない結果を招く可能性があります。詳細については、以下を参照してください

シナリオ: 映画館の座席を予約する

映画館の予約システムを作成しています。各映画には決まった数の座席があります。以下のスキーマは映画と座席をモデル化しています

model Seat {
id Int @id @default(autoincrement())
userId Int?
claimedBy User? @relation(fields: [userId], references: [id])
movieId Int
movie Movie @relation(fields: [movieId], references: [id])
}

model Movie {
id Int @id @default(autoincrement())
name String @unique
seats Seat[]
}

以下のサンプルコードは、最初に利用可能な座席を見つけ、その座席をユーザーに割り当てます

const movieName = 'Hidden Figures'

// Find first available seat
const availableSeat = await prisma.seat.findFirst({
where: {
movie: {
name: movieName,
},
claimedBy: null,
},
})

// Throw an error if no seats are available
if (!availableSeat) {
throw new Error(`Oh no! ${movieName} is all booked.`)
}

// Claim the seat
await prisma.seat.update({
data: {
claimedBy: userId,
},
where: {
id: availableSeat.id,
},
})

しかし、このコードには「二重予約問題」という問題があります。2人の人が同じ座席を予約する可能性があります

  1. 座席3AがSorchaに返される(findFirst
  2. 座席3AがEllenに返される(findFirst
  3. 座席3AがSorchaによって予約される(update
  4. 座席3AがEllenによって予約される(update - Sorchaの予約を上書き)

Sorchaが座席を正常に予約したにもかかわらず、システムは最終的にEllenの予約を保存します。この問題を楽観的並行性制御で解決するには、座席にversionフィールドを追加します

model Seat {
id Int @id @default(autoincrement())
userId Int?
claimedBy User? @relation(fields: [userId], references: [id])
movieId Int
movie Movie @relation(fields: [movieId], references: [id])
version Int
}

次に、更新前にversionフィールドをチェックするようにコードを調整します

const userEmail = 'alice@prisma.io'
const movieName = 'Hidden Figures'

// Find the first available seat
// availableSeat.version might be 0
const availableSeat = await client.seat.findFirst({
where: {
Movie: {
name: movieName,
},
claimedBy: null,
},
})

if (!availableSeat) {
throw new Error(`Oh no! ${movieName} is all booked.`)
}

// Only mark the seat as claimed if the availableSeat.version
// matches the version we're updating. Additionally, increment the
// version when we perform this update so all other clients trying
// to book this same seat will have an outdated version.
const seats = await client.seat.updateMany({
data: {
claimedBy: userEmail,
version: {
increment: 1,
},
},
where: {
id: availableSeat.id,
version: availableSeat.version, // This version field is the key; only claim seat if in-memory version matches database version, indicating that the field has not been updated
},
})

if (seats.count === 0) {
throw new Error(`That seat is already booked! Please try again.`)
}

これで、2人が同じ座席を予約することは不可能になります

  1. 座席3AがSorchaに返される(versionは0)
  2. 座席3AがEllenに返される(versionは0)
  3. 座席3AがSorchaによって予約される(versionは1にインクリメントされ、予約は成功する)
  4. 座席3AがEllenによって予約される(メモリ内のversion (0) がデータベースのversion (1) と一致しない - 予約は成功しない)

インタラクティブトランザクション

既存のアプリケーションがある場合、楽観的並行性制御を使用するようにアプリケーションをリファクタリングするのはかなりの労力を要する場合があります。インタラクティブトランザクションは、このような場合に役立つエスケープハッチを提供します。

インタラクティブトランザクションを作成するには、$transactionに非同期関数を渡します。

この非同期関数に渡される最初の引数は、Prisma Clientのインスタンスです。以下では、このインスタンスをtxと呼びます。このtxインスタンスで呼び出されたPrisma Clientの呼び出しはすべて、トランザクションにカプセル化されます。

以下の例では、アリスとボブはそれぞれ口座に100ドルを持っています。もし持っている金額よりも多く送金しようとすると、送金は拒否されます。

期待される結果は、アリスが100ドルの送金を1回行い、もう1回の送金は拒否されることです。これにより、アリスは0ドル、ボブは200ドルになるはずです。

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

async function transfer(from: string, to: string, amount: number) {
return await prisma.$transaction(async (tx) => {
// 1. Decrement amount from the sender.
const sender = await tx.account.update({
data: {
balance: {
decrement: amount,
},
},
where: {
email: from,
},
})

// 2. Verify that the sender's balance didn't go below zero.
if (sender.balance < 0) {
throw new Error(`${from} doesn't have enough to send ${amount}`)
}

// 3. Increment the recipient's balance by amount
const recipient = tx.account.update({
data: {
balance: {
increment: amount,
},
},
where: {
email: to,
},
})

return recipient
})
}

async function main() {
// This transfer is successful
await transfer('alice@prisma.io', 'bob@prisma.io', 100)
// This transfer fails because Alice doesn't have enough funds in her account
await transfer('alice@prisma.io', 'bob@prisma.io', 100)
}

main()

上記の例では、両方のupdateクエリがデータベーストランザクション内で実行されます。アプリケーションが関数の終わりに達すると、トランザクションはデータベースにコミットされます

もしアプリケーションが途中でエラーに遭遇した場合、非同期関数は例外をスローし、トランザクションを自動的にロールバックします。

インタラクティブトランザクションの詳細については、このセクションで確認できます。

警告

インタラクティブトランザクションは慎重に使用してください。トランザクションを長時間開いたままにすると、データベースのパフォーマンスが低下し、デッドロックを引き起こす可能性さえあります。トランザクション関数内でネットワークリクエストの実行や低速なクエリの実行は避けるようにしてください。できるだけ早く処理を完了させることをお勧めします!

結論

Prisma Clientは、APIを介して直接、またはアプリケーションに楽観的並行性制御と冪等性を導入する機能のサポートによって、複数のトランザクション処理方法をサポートしています。提示されたオプションのいずれでもカバーされていないユースケースがアプリケーションにあると感じる場合は、GitHub issueを開いて議論を開始してください。

© . All rights reserved.