事务和批量查询
数据库事务是指一系列读/写操作,这些操作保证作为一个整体要么成功要么失败。本节介绍 Prisma Client API 支持事务的方式。
事务概述
在 Prisma ORM 4.4.0 版本之前,您无法在事务上设置隔离级别。数据库配置中的隔离级别始终适用。
开发人员通过将操作包装在事务中来利用数据库提供的安全保证。这些保证通常使用 ACID 首字母缩略词来概括
- 原子性:确保事务的所有或没有操作成功。事务要么成功提交,要么中止并回滚。
- 一致性:确保事务之前和之后的数据库状态是有效的(即,任何关于数据的现有不变量都得到维护)。
- 隔离性:确保并发运行的事务具有与串行运行相同的效果。
- 持久性:确保事务成功后,任何写入都将被持久存储。
虽然每个属性都有很多歧义和细微差别(例如,一致性实际上可以被认为是应用程序级别的责任,而不是数据库属性,或者隔离通常根据更强和更弱的隔离级别来保证),但总的来说,当开发人员考虑数据库事务时,它们可以作为对期望的良好高级别指导。
“事务是一个抽象层,它允许应用程序假装某些并发问题和某些类型的硬件和软件故障不存在。大量的错误被简化为一个简单的事务中止,应用程序只需要再次尝试。” Designing Data-Intensive Applications, Martin Kleppmann
Prisma Client 支持六种不同的事务处理方式,适用于三种不同的场景
场景 | 可用技术 |
---|---|
依赖写入 |
|
独立写入 |
|
读取、修改、写入 |
|
您选择的技术取决于您的具体用例。
注意:为了本指南的目的,写入数据库包括创建、更新和删除数据。
关于 Prisma Client 中的事务
Prisma Client 提供了以下使用事务的选项
- 嵌套写入:使用 Prisma Client API 在同一事务中处理对一个或多个相关记录的多个操作。
- 批量/批量事务:使用
updateMany
、deleteMany
和createMany
批量处理一个或多个操作。 - Prisma Client 中的
$transaction
API
嵌套写入
嵌套写入 允许您执行单个 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: '[email protected]',
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: '[email protected]' },
},
},
})
批量/批量操作
以下批量操作作为事务运行
createMany()
createManyAndReturn()
updateMany()
updateManyAndReturn()
deleteMany()
有关更多示例,请参阅关于批量操作的部分。
$transaction
API
$transaction
API 可以通过两种方式使用
-
顺序操作:传递一个 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 将确保所有三个 create
操作要么都成功,要么都失败。
注意:操作按照它们在事务中放置的顺序执行。在事务中使用查询不会影响查询本身的操作顺序。
有关更多示例,请参阅关于事务 API 的部分。
从 4.4.0 版本开始,顺序操作事务 API 有第二个参数。您可以在此参数中使用以下可选配置选项
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 schema 的 generator 块中添加 interactiveTransactions
预览功能。
要使用交互式事务,您可以将异步函数传递到$transaction
。
传递到此异步函数的第一个参数是 Prisma Client 的实例。下面,我们将此实例称为 tx
。在此 tx
实例上调用的任何 Prisma Client 调用都封装在事务中。
谨慎使用交互式事务。长时间保持事务打开会损害数据库性能,甚至可能导致死锁。尽量避免在事务函数中执行网络请求和执行慢查询。我们建议您尽快进出!
示例
让我们看一个例子
假设您正在构建一个在线银行系统。要执行的操作之一是将钱从一个人发送给另一个人。
作为经验丰富的开发人员,我们希望确保在转账期间,
- 金额不会消失
- 金额不会翻倍
这是交互式事务的一个很好的用例,因为我们需要在写入之间执行逻辑来检查余额。
在下面的示例中,Alice 和 Bob 的账户中各有 100 美元。如果他们试图发送超过他们拥有的钱,转账将被拒绝。
Alice 应该能够进行 1 次 100 美元的转账,而另一次转账将被拒绝。这将导致 Alice 拥有 0 美元,而 Bob 拥有 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('[email protected]', '[email protected]', 100)
// This transfer fails because Alice doesn't have enough funds in her account
await transfer('[email protected]', '[email protected]', 100)
}
main()
在上面的示例中,两个 update
查询都在数据库事务中运行。当应用程序到达函数末尾时,事务将提交到数据库。
如果您的应用程序在途中遇到错误,异步函数将抛出异常并自动回滚事务。
要捕获异常,您可以将 $transaction
包装在 try-catch 块中
try {
await prisma.$transaction(async (tx) => {
// Code running in a transaction...
})
} catch (err) {
// Handle the rollback...
}
事务选项
事务 API 有第二个参数。对于交互式事务,您可以在此参数中使用以下可选配置选项
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 的第二个参数中使用 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
隔离级别。
当两个或多个事务在某些隔离级别中并发运行时,时序问题可能导致写冲突或死锁,例如违反唯一约束。例如,考虑以下事件序列,其中事务 A 和事务 B 都尝试执行 deleteMany
和 createMany
操作
- 事务 B:
createMany
操作创建一组新行。 - 事务 B:应用程序提交事务 B。
- 事务 A:
createMany
操作。 - 事务 A:应用程序提交事务 A。新行与事务 B 在步骤 2 中添加的行冲突。
此冲突可能发生在隔离级别 ReadCommited
,这是 PostgreSQL 和 Microsoft SQL Server 中的默认隔离级别。为避免此问题,您可以设置更高的隔离级别(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()
的调用中,则事务内的查询将串行执行(即一个接一个地执行)
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()
通常并行化传递给它的调用。
此行为的原因是
- 一个事务意味着它内部的所有查询都必须在同一连接上运行。
- 数据库连接一次只能执行一个查询。
- 由于一个查询在执行其工作时会阻塞连接,因此将事务放入
Promise.all
实际上意味着查询应该一个接一个地运行。
依赖写入
如果写入彼此依赖,则认为它们是依赖的
- 操作依赖于先前操作的结果(例如,数据库生成 ID)
最常见的场景是创建记录并使用生成的 ID 来创建或更新相关记录。示例包括
- 创建一个用户和两个相关的博客帖子(一对多关系) - 必须在创建博客帖子之前知道作者 ID
- 创建一个团队并分配成员(多对多关系) - 必须在分配成员之前知道团队 ID
依赖写入必须一起成功才能维护数据一致性并防止意外行为,例如没有作者的博客帖子或没有成员的团队。
嵌套写入
Prisma Client 针对依赖写入的解决方案是 嵌套写入 功能,create
和 update
支持此功能。以下嵌套写入创建一个用户和两个博客帖子
const nestedWrite = await prisma.user.create({
data: {
email: '[email protected]',
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
)当前不支持嵌套写入。
何时使用嵌套写入
如果出现以下情况,请考虑使用嵌套写入
- ✔ 您想同时创建两个或多个由 ID 关联的记录(例如,创建一个博客帖子和一个用户)
- ✔ 您想同时更新和创建由 ID 关联的记录(例如,更改用户的姓名并创建一个新的博客帖子)
场景:注册流程
考虑 Slack 注册流程,它
- 创建一个团队
- 向该团队添加一个用户,该用户自动成为该团队的管理员
此场景可以用以下 schema 表示 - 请注意,用户可以属于多个团队,团队可以拥有多个用户(多对多关系)
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: '[email protected]',
team: {
connect: {
id: team.id,
},
},
},
})
但是,此代码存在问题 - 考虑以下场景
- 创建团队成功 - “Aurora Adventures” 现在已被占用
- 创建和连接用户失败 - 团队 “Aurora Adventures” 存在,但没有用户
- 再次完成注册流程并尝试重新创建 “Aurora Adventures” 失败 - 团队已存在
创建团队和添加用户应该是一个原子操作,作为一个整体要么成功要么失败。
要在低级数据库客户端中实现原子写入,您必须将您的插入包装在 BEGIN
、COMMIT
和 ROLLBACK
语句中。Prisma Client 使用嵌套写入解决了这个问题。以下查询创建一个团队,创建一个用户,并在单个事务中连接记录
const team = await prisma.team.create({
data: {
name: 'Aurora Adventures',
members: {
create: {
email: '[email protected]',
},
},
},
})
此外,如果在任何时候发生错误,Prisma Client 都会回滚整个事务。
嵌套写入常见问题解答
为什么我不能使用 $transaction([])
API 来解决同样的问题?
$transaction([])
API 不允许您在不同的操作之间传递 ID。在以下示例中,createUserOperation.id
尚不可用
const createUserOperation = prisma.user.create({
data: {
email: '[email protected]',
},
})
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,
},
},
},
},
},
})
我可以执行多个嵌套写入吗 - 例如,创建两个新团队并分配用户?
是的,但这结合了场景和技术
- 创建团队和分配用户是依赖写入 - 使用嵌套写入
- 同时创建所有团队和用户是独立写入,因为团队/用户组合 #1 和团队/用户组合 #2 是不相关的写入 - 使用
$transaction([])
API
// Nested write
const createOne = prisma.team.create({
data: {
name: 'Aurora Adventures',
members: {
create: {
email: '[email protected]',
},
},
},
})
// Nested write
const createTwo = prisma.team.create({
data: {
name: 'Cool Crew',
members: {
create: {
email: '[email protected]',
},
},
},
})
// $transaction([]) API
await prisma.$transaction([createTwo, createOne])
独立写入
如果写入不依赖于先前操作的结果,则认为它们是独立的。以下独立的写入组可以按任何顺序发生
- 将订单列表的状态字段更新为“已发货”
- 将电子邮件列表标记为“已读”
注意:如果存在约束,独立写入可能必须按特定顺序发生 - 例如,如果帖子具有强制性的
authorId
字段,您必须在博客作者之前删除博客帖子。但是,它们仍然被认为是独立写入,因为没有操作依赖于先前操作的结果,例如数据库返回生成的 ID。
根据您的要求,Prisma Client 有四个选项来处理应该一起成功或失败的独立写入。
批量操作
批量写入允许您在单个事务中写入同一类型的多个记录 - 如果任何操作失败,Prisma Client 将回滚整个事务。Prisma Client 当前支持
createMany()
createManyAndReturn()
updateMany()
updateManyAndReturn()
deleteMany()
何时使用批量操作
如果出现以下情况,请考虑将批量操作作为解决方案
- ✔ 您想要更新一批相同类型的记录,例如一批电子邮件
场景:将电子邮件标记为已读
您正在构建像 gmail.com 这样的服务,您的客户想要一个 “标记为已读” 功能,允许用户将所有电子邮件标记为已读。对电子邮件状态的每次更新都是独立的写入,因为电子邮件彼此不依赖 - 例如,您阿姨的 “生日快乐!🍰” 电子邮件与宜家的促销电子邮件无关。
在以下 schema 中,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
}
基于此 schema,您可以使用 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 一起使用吗?
是的 — 例如,您可以在 $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 和其他隐私立法赋予用户要求组织删除其所有个人数据的权利。在以下示例 schema 中,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
}
如果用户调用被遗忘权,我们必须删除三个记录:用户记录、私人消息和帖子。所有删除操作一起成功或完全不成功至关重要,这使其成为事务的用例。但是,在这种情况下,不可能使用像 deleteMany
这样的单个批量操作,因为我们需要跨三个模型进行删除。相反,我们可以使用 $transaction([])
API 一起运行三个操作 - 两个 deleteMany
和一个 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: '[email protected]',
},
},
},
})
与其自动生成 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: '[email protected]',
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: '[email protected]',
team: {
id: teamID,
},
},
},
},
})
如果您已经在使用自动生成的 ID 和嵌套写入,则没有令人信服的理由切换到手动生成的 ID 和 $transaction([])
API。
读取、修改、写入
在某些情况下,您可能需要在原子操作中执行自定义逻辑 - 也称为读取-修改-写入模式。以下是读取-修改-写入模式的示例
- 从数据库读取一个值
- 运行一些逻辑来操作该值(例如,联系外部 API)
- 将值写回数据库
所有操作应该 同时成功或同时失败,而不会对数据库进行不必要的更改,但您不一定需要使用实际的数据库事务。本指南的这一节描述了使用 Prisma Client 和读-修改-写模式的两种方法。
- 设计幂等 API
- 乐观并发控制
幂等 API
幂等性是指使用相同的参数多次运行相同的逻辑,得到相同结果的能力:对数据库的影响是相同的,无论您运行一次还是一千次。例如
- 非幂等: 在数据库中使用电子邮件地址
"[email protected]"
进行 Upsert (更新或插入) 用户。User
表不强制执行唯一的电子邮件地址。如果您运行逻辑一次(创建一个用户)或十次(创建十个用户),则对数据库的影响是不同的。 - 幂等: 在数据库中使用电子邮件地址
"[email protected]"
进行 Upsert (更新或插入) 用户。User
表确实强制执行唯一的电子邮件地址。如果您运行逻辑一次(创建一个用户)或十次(使用相同的输入更新现有用户),则对数据库的影响是相同的。
幂等性是您可以并且应该在您的应用程序中尽可能积极地设计的功能。
何时设计幂等 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,
},
})
此示例存在一个问题:您只能运行逻辑一次。考虑以下场景
-
Stripe 创建一个新的客户和订阅,并返回一个客户 ID
-
更新团队**失败** - 团队在 Slack 数据库中未被标记为客户
-
客户被 Stripe 收费,但 Slack 中的付费功能未解锁,因为团队缺少有效的
customerId
-
再次运行相同的代码,要么
- 导致错误,因为团队(由
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
操作中在非唯一字段上进行过滤,以便使用乐观并发控制。此功能也可通过 Preview 标志extendedWhereUnique
在 4.5.0 到 4.16.2 版本中使用。
何时使用乐观并发控制
- ✔ 您预计会有大量的并发请求(多人预订电影院座位)
- ✔ 您预计这些并发请求之间的冲突将很少发生
在具有大量并发请求的应用程序中避免锁可以使应用程序更具负载弹性和整体可扩展性。虽然锁定本身并没有什么不好,但在高并发环境中锁定可能会导致意想不到的后果 - 即使您只锁定单个行,并且只锁定很短的时间。有关更多信息,请参阅
场景:预订电影院座位
您正在为电影院创建一个预订系统。每部电影都有一组座位。以下模式对电影和座位进行建模
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,
},
})
但是,此代码存在“重复预订问题” - 两个人可能预订相同的座位
- 座位 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 = '[email protected]'
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.`)
}
现在两个人不可能预订相同的座位
- 座位 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 调用都封装在事务中。
在下面的示例中,Alice 和 Bob 的账户中各有 100 美元。如果他们试图发送超过他们拥有的钱,转账将被拒绝。
预期的结果是 Alice 进行 1 次 100 美元的转账,而另一次转账将被拒绝。这将导致 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('[email protected]', '[email protected]', 100)
// This transfer fails because Alice doesn't have enough funds in her account
await transfer('[email protected]', '[email protected]', 100)
}
main()
在上面的示例中,两个 update
查询都在数据库事务中运行。当应用程序到达函数末尾时,事务将提交到数据库。
如果应用程序在此过程中遇到错误,则异步函数将抛出异常并自动回滚事务。
您可以在本节中了解有关交互式事务的更多信息。
谨慎使用交互式事务。长时间保持事务打开会损害数据库性能,甚至可能导致死锁。尽量避免在事务函数中执行网络请求和执行慢查询。我们建议您尽快进出!
结论
Prisma Client 支持多种处理事务的方式,可以直接通过 API,也可以支持您在应用程序中引入乐观并发控制和幂等性的能力。如果您认为您的应用程序中有任何用例未被任何建议的选项覆盖,请打开一个 GitHub issue 以开始讨论。