事务和批量查询
数据库事务是指一系列读/写操作,这些操作保证要么全部成功,要么全部失败。本节描述了 Prisma Client API 支持事务的方式。
事务概览
在 Prisma ORM 4.4.0 版本之前,您无法在事务上设置隔离级别。数据库配置中的隔离级别始终适用。
开发者通过将操作封装在事务中,利用数据库提供的安全保障。这些保障通常用 ACID 首字母缩写来概括
- 原子性(Atomic):确保事务的所有操作要么全部成功,要么全部失败。事务要么成功提交,要么中止并回滚。
- 一致性(Consistent):确保数据库在事务之前和之后的状态都有效(即维护数据的所有现有不变量)。
- 隔离性(Isolated):确保并发运行的事务具有与串行运行相同的效果。
- 持久性(Durability):确保事务成功后,所有写入都被持久存储。
尽管这些属性中的每一个都存在许多歧义和细微差别(例如,一致性实际上可以被视为应用层面的责任而不是数据库属性,或者隔离通常通过更强和更弱的隔离级别来保证),但总的来说,它们为开发者在考虑数据库事务时所期望的提供了良好的高层指导。
"事务是一个抽象层,允许应用程序假装某些并发问题以及某些类型的硬件和软件故障不存在。大量的错误被简化为简单的事务中止,应用程序只需重试即可。" 设计数据密集型应用, 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: '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 可以通过两种方式使用
-
顺序操作:传递一个 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 调用都封装在事务中。
谨慎使用交互式事务。长时间保持事务开启会损害数据库性能,甚至可能导致死锁。尽量避免在事务函数内部执行网络请求和慢查询。我们建议您尽快进入和退出!
示例
让我们看一个示例
假设您正在构建一个在线银行系统。其中一个操作是将钱从一个人发送给另一个人。
作为经验丰富的开发者,我们希望确保在转账过程中,
- 金额不会消失
- 金额不会翻倍
这是一个交互式事务的绝佳用例,因为我们需要在写入操作之间执行逻辑以检查余额。
在下面的示例中,爱丽丝和鲍勃的账户中各有 100 美元。如果他们试图发送超出其拥有的金额,转账将被拒绝。
爱丽丝预计能够进行一笔 100 美元的转账,而另一笔转账将被拒绝。这将导致爱丽丝拥有 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 有第二个参数。对于交互式事务,您可以在此参数中使用以下可选配置选项
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: '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
)目前不支持嵌套写入。
何时使用嵌套写入
如果满足以下条件,请考虑使用嵌套写入
- ✔ 您想同时创建两个或更多通过 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: 'alice@prisma.io',
team: {
connect: {
id: team.id,
},
},
},
})
然而,这段代码存在一个问题——考虑以下场景
- 创建团队成功——“极光冒险”已被占用
- 创建并连接用户失败——团队“极光冒险”存在,但没有用户
- 再次执行注册流程并尝试重新创建“极光冒险”失败——该团队已存在
创建团队并添加用户应该是一个原子操作,它要么全部成功,要么全部失败。
要在低级数据库客户端中实现原子写入,您必须将插入操作封装在 BEGIN
、COMMIT
和 ROLLBACK
语句中。Prisma Client 使用嵌套写入解决了这个问题。以下查询在单个事务中创建团队、创建用户并连接记录
const team = await prisma.team.create({
data: {
name: 'Aurora Adventures',
members: {
create: {
email: 'alice@prisma.io',
},
},
},
})
此外,如果在任何时候发生错误,Prisma Client 将回滚整个事务。
嵌套写入常见问题
为什么我不能使用 $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,
},
},
},
},
},
})
我可以执行多个嵌套写入吗——例如,创建两个新团队并分配用户?
是的,但这结合了不同的场景和技术
- 创建团队并分配用户是依赖写入——使用嵌套写入
- 同时创建所有团队和用户是独立写入,因为团队/用户组合 #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 有四种处理应一起成功或失败的独立写入的选项。
批量操作
批量写入允许您在单个事务中写入相同类型的多个记录——如果任何操作失败,Prisma Client 将回滚整个事务。Prisma Client 目前支持
createMany()
createManyAndReturn()
updateMany()
updateManyAndReturn()
deleteMany()
何时使用批量操作
如果满足以下条件,请考虑将批量操作作为解决方案
- ✔ 您想更新一批相同类型的记录,例如一批电子邮件
场景:将邮件标记为已读
您正在构建一个类似 gmail.com 的服务,您的客户希望有一个“标记为已读”功能,允许用户将所有邮件标记为已读。对邮件状态的每次更新都是独立写入,因为邮件之间没有依赖关系——例如,您阿姨发来的“生日快乐!🍰”邮件与宜家(IKEA)的促销邮件无关。
在以下 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: '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 和读-修改-写模式的两种方法
- 设计幂等 API
- 乐观并发控制
幂等 API
幂等性是指使用相同参数多次运行相同逻辑,每次都得到相同结果的能力:无论您运行逻辑一次还是一千次,对数据库的影响都是相同的。例如
- 非幂等:在数据库中对电子邮件地址为
"letoya@prisma.io"
的用户执行 Upsert(更新或插入)操作。User
表不强制执行唯一的电子邮件地址。如果您运行逻辑一次(创建一个用户)或十次(创建十个用户),对数据库的影响将不同。 - 幂等:在数据库中对电子邮件地址为
"letoya@prisma.io"
的用户执行 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
操作中按非唯一字段进行筛选,以便使用乐观并发控制。此功能在 4.5.0 到 4.16.2 版本中也通过预览标志extendedWhereUnique
提供。
何时使用乐观并发控制
- ✔ 您预计会有大量并发请求(多人预订电影院座位)
- ✔ 您预计这些并发请求之间的冲突将很少发生
在具有大量并发请求的应用程序中避免锁会使应用程序对负载更具弹性并整体上更具可伸缩性。尽管锁定本身并非不好,但在高并发环境中锁定可能导致意想不到的后果——即使您只锁定单个行,并且只锁定很短的时间。有关更多信息,请参阅
场景:预订电影院座位
您正在创建一个电影院的预订系统。每部电影都有固定数量的座位。以下 schema 对电影和座位进行建模
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
) - Sorcha 占用了座位 3A(
update
) - Ellen 占用了座位 3A(
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.`)
}
现在两个人不可能预订同一个座位
- 座位 3A 返回给 Sorcha(
version
为 0) - 座位 3A 返回给 Ellen(
version
为 0) - Sorcha 占用了座位 3A(
version
增加到 1,预订成功) - Ellen 占用了座位 3A(内存中的
version
(0) 与数据库中的version
(1) 不匹配 - 预订不成功)
交互式事务
如果您有一个现有应用程序,重构它以使用乐观并发控制可能是一项重大的工作。交互式事务为此类情况提供了一个有用的逃生舱口。
要创建交互式事务,请将一个异步函数传递给 $transaction。
传递给此异步函数的第一个参数是 Prisma Client 的实例。下面,我们将此实例称为 tx
。在此 tx
实例上调用的任何 Prisma Client 调用都封装在事务中。
在下面的示例中,爱丽丝和鲍勃的账户中各有 100 美元。如果他们试图发送超出其拥有的金额,转账将被拒绝。
预期结果是爱丽丝进行一笔 100 美元的转账,而另一笔转账将被拒绝。这将导致爱丽丝拥有 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 来发起讨论。