跳到主要內容

事務和批次查詢

資料庫事務是指一系列讀/寫操作,這些操作保證要麼全部成功,要麼全部失敗。本節描述了 Prisma Client API 支援事務的方式。

事務概覽

資訊

在 Prisma ORM 4.4.0 版本之前,您無法在事務上設定隔離級別。資料庫配置中的隔離級別始終適用。

開發者透過將操作封裝在事務中,利用資料庫提供的安全保障。這些保障通常用 ACID 首字母縮寫來概括

  • 原子性(Atomic):確保事務的所有操作要麼全部成功,要麼全部失敗。事務要麼成功提交,要麼中止回滾
  • 一致性(Consistent):確保資料庫在事務之前和之後的狀態都有效(即維護資料的所有現有不變數)。
  • 隔離性(Isolated):確保併發執行的事務具有與序列執行相同的效果。
  • 永續性(Durability):確保事務成功後,所有寫入都被持久儲存。

儘管這些屬性中的每一個都存在許多歧義和細微差別(例如,一致性實際上可以被視為應用層面的責任而不是資料庫屬性,或者隔離通常透過更強和更弱的隔離級別來保證),但總的來說,它們為開發者在考慮資料庫事務時所期望的提供了良好的高層指導。

"事務是一個抽象層,允許應用程式假裝某些併發問題以及某些型別的硬體和軟體故障不存在。大量的錯誤被簡化為簡單的事務中止,應用程式只需重試即可。" 設計資料密集型應用, Martin Kleppmann

Prisma Client 支援六種不同的事務處理方式,適用於三種不同的場景

場景可用技術
依賴寫入
  • 巢狀寫入
獨立寫入
  • $transaction([]) API
  • 批次操作
讀取、修改、寫入
  • 冪等操作
  • 樂觀併發控制
  • 互動式事務

您選擇的技術取決於您的具體用例。

注意:就本指南而言,向資料庫寫入操作包括建立、更新和刪除資料。

關於 Prisma Client 中的事務

Prisma Client 提供以下使用事務的選項

  • 巢狀寫入:使用 Prisma Client API 在同一事務內處理一個或多個相關記錄的多個操作。
  • 批次/批次事務:使用 updateManydeleteManycreateMany 批次處理一個或多個操作。
  • Prisma Client 中的 $transaction API
    • 順序操作:傳遞一個 Prisma Client 查詢陣列,這些查詢將使用 $transaction<R>(queries: PrismaPromise<R>[]): Promise<R[]> 在事務內部順序執行。
    • 互動式事務:傳遞一個函式,該函式可以包含使用者程式碼,包括 Prisma Client 查詢、非 Prisma 程式碼和其他控制流,這些程式碼將使用 $transaction<R>(fn: (prisma: PrismaClient) => R, options?: object): R 在事務中執行。

巢狀寫入

一個巢狀寫入允許您透過單個 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 中使用原始查詢

import { selectUserTitles, updateUserName } from '@prisma/client/sql'

const [userList, updateUser] = await prisma.$transaction([
prisma.$queryRawTyped(selectUserTitles()),
prisma.$queryRawTyped(updateUserName(2)),
])

執行每個操作時,不會立即等待結果,而是先將操作本身儲存在一個變數中,然後透過名為 $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

各資料庫聯結器可用的隔離級別如下

資料庫ReadUncommittedReadCommittedRepeatableReadSnapshotSerializable
PostgreSQL✔️✔️✔️✔️
MySQL✔️✔️✔️✔️
SQL Server✔️✔️✔️✔️✔️
CockroachDB✔️
SQLite✔️

預設情況下,Prisma Client 將隔離級別設定為您資料庫中當前配置的值。

各資料庫預設配置的隔離級別如下

資料庫預設
PostgreSQLReadCommitted
MySQLRepeatableRead
SQL ServerReadCommitted
CockroachDBSerializable
SQLiteSerializable

資料庫特定的隔離級別資訊

請參閱以下資源

CockroachDB 和 SQLite 僅支援 Serializable 隔離級別。

事務時序問題

資訊
  • 本節中的解決方案不適用於 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
    }
    }
    }

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 解決依賴寫入的方案是巢狀寫入功能,它由 createupdate 支援。以下巢狀寫入建立一個使用者和兩篇部落格文章

const nestedWrite = await prisma.user.create({
data: {
email: 'imani@prisma.io',
posts: {
create: [
{ title: 'My first day at Prisma' },
{ title: 'How to configure a unique constraint in PostgreSQL' },
],
},
},
})

如果任何操作失敗,Prisma Client 將回滾整個事務。頂級批次操作(如 client.user.deleteManyclient.user.updateMany)目前不支援巢狀寫入。

何時使用巢狀寫入

如果滿足以下條件,請考慮使用巢狀寫入

  • ✔ 您想同時建立兩個或更多透過 ID 關聯的記錄(例如,建立部落格文章和使用者)
  • ✔ 您想同時更新和建立透過 ID 關聯的記錄(例如,更改使用者姓名並建立一篇新部落格文章)

場景:註冊流程

考慮 Slack 的註冊流程,該流程

  1. 建立團隊
  2. 向該團隊新增一名使用者,該使用者將自動成為該團隊的管理員

此場景可以用以下 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,
},
},
},
})

然而,這段程式碼存在一個問題——考慮以下場景

  1. 建立團隊成功——“極光冒險”已被佔用
  2. 建立並連線使用者失敗——團隊“極光冒險”存在,但沒有使用者
  3. 再次執行註冊流程並嘗試重新建立“極光冒險”失敗——該團隊已存在

建立團隊並新增使用者應該是一個原子操作,它要麼全部成功,要麼全部失敗

要在低階資料庫客戶端中實現原子寫入,您必須將插入操作封裝在 BEGINCOMMITROLLBACK 語句中。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,
},
})

我可以在批次操作中使用巢狀寫入嗎?

不可以——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 和其他隱私立法賦予使用者要求組織刪除其所有個人資料的權利。在以下示例 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,而是將 TeamUserid 欄位更改為 String 型別(如果您不提供值,將自動生成 UUID)。本例使用 UUID。

model Team {
id Int @id @default(autoincrement())
id String @id @default(uuid())
name String
members User[]
}

model User {
id Int @id @default(autoincrement())
id String @id @default(uuid())
email String @unique
teams Team[]
}

重構註冊流程示例,使其使用 $transaction([]) API 而不是巢狀寫入

import { v4 } from 'uuid'

const teamID = v4()
const userID = v4()

await prisma.$transaction([
prisma.user.create({
data: {
id: userID,
email: 'alice@prisma.io',
team: {
id: teamID,
},
},
}),
prisma.team.create({
data: {
id: teamID,
name: 'Aurora Adventures',
},
}),
])

從技術上講,如果您更喜歡該語法,您仍然可以使用預計算 API 進行巢狀寫入

import { v4 } from 'uuid'

const teamID = v4()
const userID = v4()

await prisma.team.create({
data: {
id: teamID,
name: 'Aurora Adventures',
members: {
create: {
id: userID,
email: 'alice@prisma.io',
team: {
id: teamID,
},
},
},
},
})

如果您已經在使用自動生成的 ID 和巢狀寫入,沒有令人信服的理由切換到手動生成的 ID 和 $transaction([]) API。

讀取、修改、寫入

在某些情況下,您可能需要將自定義邏輯作為原子操作的一部分執行——這也稱為讀-修改-寫模式。以下是讀-修改-寫模式的示例

  • 從資料庫讀取值
  • 執行一些邏輯來操作該值(例如,呼叫外部 API)
  • 將值寫回資料庫

所有操作都應一起成功或失敗,而不會對資料庫進行不必要的更改,但您不一定需要使用實際的資料庫事務。本指南的這一節介紹了使用 Prisma Client 和讀-修改-寫模式的兩種方法

  • 設計冪等 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?
}

升級流程如下

  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 上的應用層樂觀併發控制計劃

資訊
  • 如果您使用 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,
},
})

然而,這段程式碼存在“重複預訂問題”——兩個人可能會預訂同一個座位

  1. 座位 3A 返回給 Sorcha(findFirst
  2. 座位 3A 返回給 Ellen(findFirst
  3. Sorcha 佔用了座位 3A(update
  4. 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.`)
}

現在兩個人不可能預訂同一個座位

  1. 座位 3A 返回給 Sorcha(version 為 0)
  2. 座位 3A 返回給 Ellen(version 為 0)
  3. Sorcha 佔用了座位 3A(version 增加到 1,預訂成功)
  4. 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 來發起討論。

© . This site is unofficial and not affiliated with Prisma Data, Inc.