交易與批次查詢
資料庫交易指的是一連串的讀/寫操作,這些操作保證要么全部成功,要么全部失敗。本節介紹 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: '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
預覽功能。
要使用互動式交易,您可以將一個 async 函數傳遞到 $transaction
中。
傳遞到此 async 函數的第一個參數是 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('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
查詢都在資料庫交易中執行。當應用程式到達函數末尾時,交易將提交到資料庫。
如果您的應用程式在過程中遇到錯誤,async 函數將拋出異常並自動回滾交易。
要捕獲異常,您可以將 $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,
},
},
},
})
但是,此程式碼存在一個問題 - 考慮以下情境
- 建立團隊成功 - 'Aurora Adventures' 現在已被使用
- 建立和連線使用者失敗 - 團隊 'Aurora Adventures' 存在,但沒有使用者
- 再次執行註冊流程並嘗試重新建立 'Aurora Adventures' 失敗 - 團隊已存在
建立團隊和新增使用者應該是一個原子操作,要么全部成功,要么全部失敗。
要在低階資料庫客戶端中實作原子寫入,您必須將插入操作包裝在 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 和其他隱私權法規賦予使用者權利,要求組織刪除其所有個人資料。在以下範例結構描述中,一個 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',
},
},
},
})
將 Team
和 User
的 id
欄位變更為 String
,而不是自動產生 ID(如果您未提供值,則會自動產生 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 版本的 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,
},
})
但是,此程式碼會遇到「重複預訂問題」 - 兩個人有可能預訂相同的座位
- 座位 3A 傳回給 Sorcha (
findFirst
) - 座位 3A 傳回給 Ellen (
findFirst
) - 座位 3A 由 Sorcha 聲明 (
update
) - 座位 3A 由 Ellen 聲明 (
update
- 覆寫 Sorcha 的聲明)
即使 Sorcha 已成功預訂座位,系統最終仍儲存 Ellen 的聲明。為了使用樂觀並行控制解決此問題,請將 version
欄位新增至座位
model Seat {
id Int @id @default(autoincrement())
userId Int?
claimedBy User? @relation(fields: [userId], references: [id])
movieId Int
movie Movie @relation(fields: [movieId], references: [id])
version Int
}
接下來,調整程式碼以在更新之前檢查 version
欄位
const userEmail = 'alice@prisma.io'
const movieName = 'Hidden Figures'
// Find the first available seat
// availableSeat.version might be 0
const availableSeat = await client.seat.findFirst({
where: {
Movie: {
name: movieName,
},
claimedBy: null,
},
})
if (!availableSeat) {
throw new Error(`Oh no! ${movieName} is all booked.`)
}
// Only mark the seat as claimed if the availableSeat.version
// matches the version we're updating. Additionally, increment the
// version when we perform this update so all other clients trying
// to book this same seat will have an outdated version.
const seats = await client.seat.updateMany({
data: {
claimedBy: userEmail,
version: {
increment: 1,
},
},
where: {
id: availableSeat.id,
version: availableSeat.version, // This version field is the key; only claim seat if in-memory version matches database version, indicating that the field has not been updated
},
})
if (seats.count === 0) {
throw new Error(`That seat is already booked! Please try again.`)
}
現在兩個人不可能預訂相同的座位
- 座位 3A 傳回給 Sorcha (
version
為 0) - 座位 3A 傳回給 Ellen (
version
為 0) - 座位 3A 由 Sorcha 聲明 (
version
遞增為 1,預訂成功) - 座位 3A 由 Ellen 聲明 (記憶體中的
version
(0) 與資料庫version
(1) 不符 - 預訂不成功)
互動式交易
如果您有現有的應用程式,則重構您的應用程式以使用樂觀並行控制可能是一項重大的工作。在這種情況下,互動式交易提供了一個有用的應急方案。
若要建立互動式交易,請將非同步函式傳遞到 $transaction 中。
傳遞到此 async 函數的第一個參數是 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('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 以開始討論。