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

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

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

トランザクションの概要

情報

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

開発者は、トランザクションで操作をラップすることにより、データベースによって提供される安全性の保証を利用します。これらの保証は、ACIDの頭字語を使用して要約されることがよくあります。

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

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

「トランザクションは、アプリケーションが特定の同時実行性の問題や特定の種類のハードウェアおよびソフトウェア障害が存在しないふりをすることを可能にする抽象化レイヤーです。大規模なエラークラスは、単純なトランザクション中止に縮小され、アプリケーションは再試行するだけで済みます。」データ集約型アプリケーションの設計Martin Kleppmann

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コード、およびその他の制御フローを含むユーザーコードを含むことができる関数を渡します。

ネストされた書き込み

ネストされた書き込みを使用すると、複数の関連レコードに触れる複数の操作を含む単一の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つは、ある人から別の人にお金を送金することです。

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

  • 金額が消えないこと
  • 金額が2倍にならないこと

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

次の例では、アリスとボブはそれぞれアカウントに100ドルを持っています。彼らが持っているよりも多くのお金を送金しようとすると、送金は拒否されます。

アリスは100ドルの送金を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は分離レベルをサポートしていないためです。

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

情報

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

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. トランザクションで、Prisma.TransactionIsolationLevel.SerializableisolationLevelパラメータを使用します。

    これにより、アプリケーションは複数の同時トランザクションまたは並列トランザクションをシリアルに実行されたかのようにコミットすることが保証されます。書き込み競合またはデッドロックが原因でトランザクションが失敗した場合、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の使用

Promise.all()の呼び出し内で$transactionをラップすると、トランザクション内のクエリはシリアル(つまり、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つのクエリしか実行できません。
  • 1つのクエリが作業中に接続をブロックするため、トランザクションをPromise.allに入れることは、事実上、クエリを1つずつ実行する必要があることを意味します。

依存書き込み

書き込みは、次の場合は依存していると見なされます。

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

最も一般的なシナリオは、レコードを作成し、生成されたIDを使用して関連レコードを作成または更新することです。例としては、次のようなものがあります。

  • ユーザーと2つの関連するブログ投稿を作成する(1対多の関係)-ブログ投稿を作成する前に、作成者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.deleteManyclient.user.updateManyなどのトップレベルのバルク操作ではサポートされていません。

ネストされた書き込みを使用する場合

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

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

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

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つのアトミック操作である必要があります。

低レベルのデータベースクライアントでアトミック書き込みを実装するには、BEGINCOMMIT、および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,
},
})

バルクオペレーションでネストされた書き込みを使用できますか?

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

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

$transaction([]) API でバルクオペレーションを使用できますか?

はい — たとえば、$transaction([]) の内部に複数の deleteMany オペレーションを含めることができます。

$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 つのレコードを削除する必要があります。すべての削除オペレーションが一緒に成功するか、まったく成功しないことが重要であり、これがトランザクションのユースケースとなります。ただし、このシナリオでは、deleteMany のような単一のバルクオペレーションを使用することはできません。なぜなら、3 つのモデルにまたがって削除する必要があるからです。代わりに、$transaction([]) API を使用して、3 つのオペレーション(2 つの deleteMany と 1 つの delete)をまとめて実行できます。

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" を持つユーザーをデータベースで Upsert(更新または挿入)します。User テーブルは、一意のメールアドレスを強制しません。ロジックを 1 回実行した場合(1 人のユーザーが作成される)と 10 回実行した場合(10 人のユーザーが作成される)では、データベースへの影響が異なります。
  • 冪等: メールアドレス "letoya@prisma.io" を持つユーザーをデータベースで Upsert(更新または挿入)します。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. 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 までの Preview フラグ 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ドルを持っています。彼らが持っているよりも多くのお金を送金しようとすると、送金は拒否されます。

期待される結果は、Alice が 100 ドルの送金を 1 回行い、もう 1 回の送金は拒否されることです。これにより、Alice の残高は 0 ドルになり、Bob の残高は 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 をオープンして議論を開始してください。