跳至主要内容

事务和批量查询

数据库事务指的是一系列读写操作,这些操作保证要么全部成功,要么全部失败。本节介绍 Prisma 客户端 API 支持事务的方式。

事务概述

info

在 Prisma ORM 4.4.0 版本之前,您无法在事务上设置隔离级别。您的数据库配置中的隔离级别始终适用。

开发人员通过将操作包装在事务中来利用数据库提供的安全保证。这些保证通常用 ACID 首字母缩略词概括

  • 原子性: 确保事务的所有操作要么成功,要么全部失败。事务要么成功提交,要么中止回滚
  • 一致性: 确保数据库在事务前后处于有效状态(即,维护关于数据的任何现有不变性)。
  • 隔离性: 确保并发运行的事务具有与串行运行时相同的效用。
  • 持久性: 确保事务成功后,任何写入都将持久存储。

虽然这些属性中的每一个都有很多模糊性和细微差别(例如,一致性实际上可以被认为是应用程序级责任,而不是数据库属性,或者隔离性通常是根据更强和更弱的隔离级别来保证的),但总的来说,它们为开发人员在考虑数据库事务时所期望的内容提供了一个很好的高级指南。

“事务是一个抽象层,允许应用程序假装某些并发问题以及某些类型的硬件和软件故障不存在。大量错误被简化为简单的事务中止,应用程序只需要重试。” 设计数据密集型应用程序, Martin Kleppmann

Prisma 客户端支持六种不同的方式来处理三种不同场景的事务

场景可用技术
依赖写入
  • 嵌套写入
独立写入
  • $transaction([]) API
  • 批量操作
读取、修改、写入
  • 幂等操作
  • 乐观并发控制
  • 交互式事务

您选择的技术取决于您的特定用例。

注意: 在本指南中,写入数据库包括创建、更新和删除数据。

关于 Prisma 客户端中的事务

Prisma 客户端提供以下选项用于使用事务

  • 嵌套写入: 使用 Prisma 客户端 API 在同一事务中处理一个或多个相关记录上的多个操作。
  • 批量/大容量事务: 使用 updateManydeleteManycreateMany 批量处理一个或多个操作。
  • Prisma 客户端中的 $transaction API
    • 顺序操作: 使用 $transaction<R>(queries: PrismaPromise<R>[]): Promise<R[]> 传递一个 Prisma 客户端查询数组,这些查询将在事务中顺序执行。
    • 交互式事务: 使用 $transaction<R>(fn: (prisma: PrismaClient) => R, options?: object): R 传递一个可以包含用户代码(包括 Prisma 客户端查询、非 Prisma 代码和其他控制流)的函数,该函数将在事务中执行。

嵌套写入

一个 嵌套写入 允许您使用 Prisma 客户端 API 执行单个调用,该调用具有多个操作,这些操作会影响多个相关记录。例如,创建用户以及帖子,或更新订单以及发票。Prisma 客户端确保所有操作都成功或失败。

以下示例演示了带有 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]' },
},
},
})

批量/大容量操作

以下大容量操作作为事务运行

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

有关更多示例,请参阅有关大容量操作的部分。

$transaction API

$transaction API 可以以两种方式使用

  • 顺序操作: 传递一个 Prisma 客户端查询数组,这些查询将在事务中顺序执行。

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

  • 交互式事务: 传递一个可以包含用户代码(包括 Prisma 客户端查询、非 Prisma 代码和其他控制流)的函数,该函数将在事务中执行。

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

顺序 Prisma 客户端操作

以下查询返回与提供的过滤器匹配的所有帖子以及所有帖子的计数

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 客户端将确保所有三个 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
}
)

交互式事务

概述

有时您需要更多地控制在事务中执行哪些查询。交互式事务旨在为您提供一个备用方案。

info

交互式事务从 4.7.0 版本开始普遍可用。

如果您在 2.29.0 到 4.6.1(包括)版本中使用预览版的交互式事务,则需要将 interactiveTransactions 预览功能添加到 Prisma 架构的生成器块中。

要使用交互式事务,您可以将异步函数传递给$transaction

传递给此异步函数的第一个参数是 Prisma 客户端的实例。下面,我们将调用此实例 tx。对这个 tx 实例调用的任何 Prisma 客户端调用都将封装到事务中。

warning

谨慎使用交互式事务。长时间保持事务打开会损害数据库性能,甚至会导致死锁。尝试避免在事务函数中执行网络请求和执行缓慢的查询。我们建议您尽快进出!

示例

让我们看一个例子

假设您正在构建一个在线银行系统。要执行的操作之一是将钱从一个人转账到另一个人。

作为经验丰富的开发人员,我们希望确保在转账过程中,

  • 金额不会消失
  • 金额不会翻倍

这是交互式事务的一个很好的用例,因为我们需要在写入之间执行逻辑以检查余额。

在下面的示例中,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
},
})

事务隔离级别

info

此功能在 MongoDB 上不可用,因为 MongoDB 不支持隔离级别。

您可以为事务设置事务 隔离级别

info

这在以下 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

每个数据库连接器可用的隔离级别如下

数据库ReadUncommittedReadCommittedRepeatableReadSnapshotSerializable
PostgreSQL✔️✔️✔️✔️
MySQL✔️✔️✔️✔️
SQL Server✔️✔️✔️✔️✔️
CockroachDB✔️
SQLite✔️

默认情况下,Prisma Client 将隔离级别设置为当前在数据库中配置的值。

每个数据库中默认配置的隔离级别如下

数据库默认值
PostgreSQLReadCommitted
MySQLRepeatableRead
SQL ServerReadCommitted
CockroachDBSerializable
SQLiteSerializable

数据库特定隔离级别信息

请参考以下资源

CockroachDB 和 SQLite 仅支持 Serializable 隔离级别。

事务时序问题

info
  • 本节中的解决方案不适用于 MongoDB,因为 MongoDB 不支持 隔离级别
  • 本节中讨论的时序问题不适用于 CockroachDB 和 SQLite,因为这些数据库仅支持最高的 Serializable 隔离级别。

当两个或多个事务在某些 隔离级别 中并发运行时,时序问题会导致写入冲突或死锁,例如违反唯一约束。例如,考虑以下事件序列,其中事务 A 和事务 B 都尝试执行 deleteManycreateMany 操作

  1. 事务 B:createMany 操作创建一组新行。
  2. 事务 B:应用程序提交事务 B。
  3. 事务 A:createMany 操作。
  4. 事务 A:应用程序提交事务 A。新行与事务 B 在步骤 2 中添加的行发生冲突。

这种冲突可能发生在 ReadCommited 隔离级别,这是 PostgreSQL 和 Microsoft SQL Server 中的默认隔离级别。为了避免此问题,您可以设置更高的隔离级别(RepeatableReadSerializable)。您可以在事务上设置隔离级别。这将覆盖该事务的数据库隔离级别。

为了避免事务写入冲突和死锁

  1. 在您的事务上,使用 isolationLevel 参数设置为 Prisma.TransactionIsolationLevel.Serializable

    这将确保您的应用程序提交多个并发或并行事务,就像它们按顺序运行一样。当事务因写入冲突或死锁而失败时,Prisma Client 会返回 P2034 错误

  2. 在您的应用程序代码中,在您的事务周围添加重试以处理任何 P2034 错误,如以下示例所示

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

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

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

依赖写入

如果写入相互依赖,则认为

  • 操作取决于先前操作的结果(例如,数据库生成 ID)

最常见的情况是创建记录并使用生成的 ID 来创建或更新相关记录。示例包括

  • 创建用户和两个相关的博客文章(一对多关系) - 必须先知道作者 ID,才能创建博客文章
  • 创建团队并分配成员(多对多关系) - 必须先知道团队 ID,才能分配成员

为了维护数据一致性并防止意外行为(例如没有作者的博客文章或没有成员的团队),依赖写入必须一起成功。

嵌套写入

Prisma Client 对依赖写入的解决方案是嵌套写入功能,该功能由 createupdate 支持。以下嵌套写入创建了一个用户和两个博客文章

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.deleteManyclient.user.updateMany)支持。

何时使用嵌套写入

如果您想考虑使用嵌套写入

  • ✔ 想要同时创建两个或多个通过 ID 相关的记录(例如,创建博客文章和用户)
  • ✔ 想要同时更新和创建通过 ID 相关的记录(例如,更改用户的姓名并创建新的博客文章)

场景:注册流程

考虑 Slack 的注册流程,它

  1. 创建一个团队
  2. 将一个用户添加到该团队,该用户会自动成为该团队的管理员

此场景可以用以下模式表示 - 请注意,用户可以属于多个团队,团队可以拥有多个用户(多对多关系)

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,
},
},
},
})

但是,此代码存在问题 - 考虑以下场景

  1. 创建团队成功 - “Aurora Adventures” 现在已被占用
  2. 创建和连接用户失败 - 团队“Aurora Adventures” 存在,但没有用户
  3. 再次进行注册流程并尝试重新创建“Aurora Adventures” 失败 - 团队已经存在

创建团队并添加用户应该是一个原子操作,它全部成功或全部失败

为了在低级数据库客户端中实现原子写入,您必须将插入语句包装在 BEGINCOMMITROLLBACK 语句中。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 目前支持

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

何时使用批量操作

如果满足以下条件,请考虑将批量操作作为解决方案。

  • ✔ 您希望更新一批同一类型的记录,例如一批电子邮件。

场景:将电子邮件标记为已读

您正在构建类似 gmail.com 的服务,您的客户需要一个“标记为已读”功能,允许用户将所有电子邮件标记为已读。对电子邮件状态的每次更新都是一个独立写入操作,因为电子邮件彼此之间没有依赖关系 - 例如,您阿姨发来的“生日快乐!🍰”邮件与宜家发来的促销邮件无关。

在以下模式中,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
}

如果用户行使“被遗忘权”,我们必须删除三条记录:用户记录、私信和帖子。至关重要的是,所有删除操作必须一起成功或一起失败,这使得此场景成为使用事务的用例。但是,由于我们需要跨三个模型进行删除,因此无法在此场景中使用 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,而是将 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: '[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]" 在数据库中更新(或插入)用户。User没有强制执行唯一电子邮件地址。如果您运行逻辑一次(创建一个用户)或十次(创建十个用户),对数据库的影响是不同的。
  • 幂等:使用电子邮件地址 "[email protected]" 在数据库中更新(或插入)用户。User强制执行唯一电子邮件地址。如果您运行逻辑一次(创建一个用户)或十次(使用相同的输入更新现有用户),对数据库的影响是相同的。

幂等性是您可以并且应该积极设计到您的应用程序中的东西,只要有可能。

何时设计幂等 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 向客户收取费用,但 Slack 中的付费功能未解锁,因为团队缺少有效的 customerId

  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 上的应用程序级乐观并发控制

info
  • 如果您使用的是 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,
},
})

但是,此代码存在“双重预订问题” - 两个人有可能预订同一个座位

  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 = '[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.`)
}

现在两个人不可能预订同一个座位了

  1. 座位 3A 返回给 Sorcha(version 为 0)
  2. 座位 3A 返回给 Ellen(version 为 0)
  3. 座位 3A 被 Sorcha 认领(version 增加到 1,预订成功)
  4. 座位 3A 被 Ellen 认领(内存中的version(0)与数据库中的version(1)不匹配 - 预订不成功)

交互式事务

如果您有一个现有的应用程序,那么将应用程序重构为使用乐观并发控制可能是一项重大的任务。交互式事务为这种情况提供了实用的解决方法。

要创建交互式事务,请将一个异步函数传递给$transaction

传递给此异步函数的第一个参数是 Prisma 客户端的实例。下面,我们将调用此实例 tx。对这个 tx 实例调用的任何 Prisma 客户端调用都将封装到事务中。

在下面的示例中,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 查询都在数据库事务中运行。当应用程序到达函数末尾时,事务将提交到数据库。

如果应用程序在此过程中遇到错误,异步函数将抛出异常并自动**回滚**事务。

您可以在本部分了解更多关于交互式事务的信息。

warning

谨慎使用交互式事务。长时间保持事务打开会损害数据库性能,甚至会导致死锁。尝试避免在事务函数中执行网络请求和执行缓慢的查询。我们建议您尽快进出!

结论

Prisma Client 支持多种处理事务的方法,既可以通过 API 直接处理,也可以通过支持您在应用程序中引入乐观并发控制和幂等性来处理。如果您觉得您的应用程序中存在任何未涵盖在上述建议选项中的用例,请打开一个GitHub 问题,开始讨论。