トランザクションとバッチクエリ
データベーストランザクションとは、一連の読み取り/書き込み操作が全体として成功するか失敗するかのどちらかであることが保証されているものを指します。このセクションでは、Prisma Client APIがトランザクションをサポートする方法について説明します。
トランザクションの概要
Prisma ORMバージョン4.4.0以前では、トランザクションの分離レベルを設定できませんでした。データベース設定の分離レベルが常に適用されていました。
開発者は、操作をトランザクションで囲むことで、データベースが提供する安全性保証を利用します。これらの保証は、しばしばACIDという頭字語でまとめられます
- 原子性 (Atomic): トランザクションの操作がすべて成功するか、すべて失敗するかのいずれかであることを保証します。トランザクションは、正常にコミットされるか、中止されてロールバックされます。
- 一貫性 (Consistent): トランザクションの前後でデータベースの状態が有効であることを保証します(つまり、データに関する既存の不変条件が維持されます)。
- 分離性 (Isolated): 同時に実行されているトランザクションが、あたかも順番に実行されているかのように同じ効果を持つことを保証します。
- 永続性 (Durability): トランザクションが成功した後、すべての書き込みが永続的に保存されることを保証します。
これらの各特性には多くの曖昧さとニュアンスがありますが(たとえば、一貫性は実際にはデータベースのプロパティではなくアプリケーションレベルの責任と見なされることもありますし、分離性は通常、より強いまたは弱い分離レベルで保証されます)、全体としては、データベーストランザクションについて考える際に開発者が抱く期待の優れた高レベルガイドラインとして機能します。
「「トランザクションは、特定の並行処理の問題や、特定の種類のハードウェアおよびソフトウェアの障害が存在しないかのようにアプリケーションが振る舞うことを可能にする抽象化レイヤーです。多くの種類のエラーは、単純なトランザクションの中止に集約され、アプリケーションは単に再試行するだけで済みます。」 データ指向アプリケーションデザイン、マーティン・ケップマン」
Prisma Clientは、3つの異なるシナリオに対し、6つの異なる方法でトランザクションを処理することをサポートしています
シナリオ | 利用可能な手法 |
---|---|
依存書き込み |
|
独立した書き込み |
|
読み取り、変更、書き込み |
|
選択する手法は、特定のユースケースによって異なります。
注: このガイドの目的において、データベースへの書き込みとは、データの作成、更新、削除を指します。
Prisma Clientにおけるトランザクションについて
Prisma Clientは、トランザクションを使用するための以下のオプションを提供します
- ネストされた書き込み: Prisma Client APIを使用して、同じトランザクション内で1つ以上の関連レコードに対する複数の操作を処理します。
- バッチ/一括トランザクション:
updateMany
、deleteMany
、および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
内で生クエリを使用することもできます
- リレーショナルデータベース
- MongoDB
import { selectUserTitles, updateUserName } from '@prisma/client/sql'
const [userList, updateUser] = await prisma.$transaction([
prisma.$queryRawTyped(selectUserTitles()),
prisma.$queryRawTyped(updateUserName(2)),
])
const [findRawData, aggregateRawData, commandRawData] =
await prisma.$transaction([
prisma.user.findRaw({
filter: { age: { $gt: 25 } },
}),
prisma.user.aggregateRaw({
pipeline: [
{ $match: { status: 'registered' } },
{ $group: { _id: '$country', total: { $sum: 1 } } },
],
}),
prisma.$runCommandRaw({
aggregate: 'User',
pipeline: [
{ $match: { name: 'Bob' } },
{ $project: { email: true, _id: false } },
],
explain: false,
}),
])
各操作が実行されたときにすぐに結果を待つのではなく、まず操作自体が変数に格納され、後で$transaction
というメソッドでデータベースに送信されます。Prisma Clientは、3つのcreate
操作すべてが成功するか、どれも成功しないかのいずれかを保証します。
注: 操作は、トランザクション内に配置された順序に従って実行されます。トランザクション内でクエリを使用しても、クエリ自体の操作の順序には影響しません。
詳細な例については、トランザクションAPIに関するセクションを参照してください。
バージョン4.4.0以降、シーケンシャル操作トランザクションAPIには2番目のパラメータがあります。このパラメータでは、以下のオプション設定を使用できます
isolationLevel
: トランザクションの分離レベルを設定します。デフォルトでは、データベースで現在設定されている値に設定されます。
例
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
各データベースコネクタで利用可能な分離レベルは以下の通りです
データベース | ReadUncommitted | ReadCommitted | RepeatableRead | Snapshot | Serializable |
---|---|---|---|---|---|
PostgreSQL | ✔️ | ✔️ | ✔️ | いいえ | ✔️ |
MySQL | ✔️ | ✔️ | ✔️ | いいえ | ✔️ |
SQL Server | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
CockroachDB | いいえ | いいえ | いいえ | いいえ | ✔️ |
SQLite | いいえ | いいえ | いいえ | いいえ | ✔️ |
デフォルトでは、Prisma Clientは分離レベルをデータベースで現在設定されている値に設定します。
各データベースでデフォルトで設定されている分離レベルは以下の通りです
データベース | デフォルト |
---|---|
PostgreSQL | ReadCommitted |
MySQL | RepeatableRead |
SQL Server | ReadCommitted |
CockroachDB | Serializable |
SQLite | Serializable |
分離レベルに関するデータベース固有の情報
以下のリソースを参照してください
CockroachDBとSQLiteはSerializable
分離レベルのみをサポートしています。
トランザクションのタイミングの問題
- このセクションの解決策はMongoDBには適用されません。MongoDBは分離レベルをサポートしていないためです。
- このセクションで議論されているタイミングの問題は、CockroachDBとSQLiteには適用されません。これらのデータベースは最高の
Serializable
分離レベルのみをサポートしているためです。
特定の分離レベルで2つ以上のトランザクションが同時に実行されると、タイミングの問題により、一意性制約の違反などの書き込み競合やデッドロックが発生する可能性があります。たとえば、トランザクションAとトランザクションBの両方がdeleteMany
およびcreateMany
操作を実行しようとする、以下のイベントシーケンスを考えてみましょう
- トランザクションB:
createMany
操作が新しい行のセットを作成します。 - トランザクションB: アプリケーションがトランザクションBをコミットします。
- トランザクションA:
createMany
操作。 - トランザクションA: アプリケーションがトランザクションAをコミットします。新しい行は、ステップ2でトランザクションBが追加した行と競合します。
この競合は、PostgreSQLおよびMicrosoft SQL Serverのデフォルト分離レベルであるReadCommited
分離レベルで発生する可能性があります。この問題を回避するには、より高い分離レベル(RepeatableRead
またはSerializable
)を設定できます。トランザクションで分離レベルを設定できます。これにより、そのトランザクションのデータベース分離レベルがオーバーライドされます。
トランザクションでの書き込み競合とデッドロックを避けるには
-
トランザクションで、
isolationLevel
パラメータをPrisma.TransactionIsolationLevel.Serializable
に設定します。これにより、アプリケーションが複数の同時または並列トランザクションをあたかも順次実行されたかのようにコミットすることが保証されます。書き込み競合またはデッドロックによりトランザクションが失敗した場合、Prisma ClientはP2034エラーを返します。
-
アプリケーションコードで、この例に示すように、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
の使用
$transaction
をPromise.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が依存書き込みに対して提供する解決策は、create
とupdate
でサポートされているネストされた書き込み機能です。以下のネストされた書き込みは、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.deleteMany
やclient.user.updateMany
のようなトップレベルのバルク操作ではサポートされていません。
ネストされた書き込みを使用するタイミング
以下の場合にネストされた書き込みの使用を検討してください
- ✔ 2つ以上のIDで関連するレコードを同時に作成したい場合(例:ブログ投稿とユーザーを作成する)
- ✔ IDで関連するレコードを同時に更新および作成したい場合(例:ユーザー名を変更し、新しいブログ投稿を作成する)
IDを事前計算する場合、ネストされた書き込みと$transaction([])
APIの使用のどちらかを選択できます。
シナリオ: サインアップフロー
Slackのサインアップフローを考えてみましょう。これは
- チームを作成し
- そのチームにユーザーを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,
},
},
},
})
しかし、このコードには問題があります。次のシナリオを考えてみましょう
- チームの作成は成功し、"Aurora Adventures"はすでに使用されています
- ユーザーの作成と接続が失敗し、チーム"Aurora Adventures"は存在するもののユーザーがいません
- 再びサインアップフローを実行し、"Aurora Adventures"を再作成しようとすると失敗します。チームがすでに存在するためです
チームの作成とユーザーの追加は、全体として成功するか失敗するかのどちらかである、1つのアトミックな操作であるべきです。
低レベルのデータベースクライアントでアトミックな書き込みを実装するには、BEGIN
、COMMIT
、ROLLBACK
ステートメントで挿入をラップする必要があります。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,
},
})
バルク操作でネストされた書き込みを使用できますか?
いいえ。現在、updateMany
もdeleteMany
もネストされた書き込みをサポートしていません。例えば、複数のチームとそのすべてのメンバーを削除する(カスケード削除)ことはできません。
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を自動生成する代わりに、Team
とUser
のid
フィールドを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?
}
アップグレードフローは次のようになります
- ユーザー数をカウントする
- ユーザー数を含むStripeでの購読を作成する
- チームを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回しか実行できません。以下のシナリオを考えてみましょう
-
Stripeが新しい顧客と購読を作成し、顧客IDを返す
-
チームの更新が失敗する - Slackデータベースでチームが顧客としてマークされない
-
顧客はStripeから課金されるが、チームに有効な
customerId
がないため、Slackで有料機能がアンロックされない -
同じコードを再度実行すると、次のいずれかの結果になります
- チーム(
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人の人が同じ座席を予約する可能性があります
- 座席3AがSorchaに返される(
findFirst
) - 座席3AがEllenに返される(
findFirst
) - 座席3AがSorchaによって予約される(
update
) - 座席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人が同じ座席を予約することは不可能になります
- 座席3AがSorchaに返される(
version
は0) - 座席3AがEllenに返される(
version
は0) - 座席3AがSorchaによって予約される(
version
は1にインクリメントされ、予約は成功する) - 座席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を開いて議論を開始してください。