在學習一個東西以前,一定都要知道原因,絕對不要因為別人有做就盲目的跟著做,總要知道它帶來什麼好處以及能解決什麼問題
我們都知道在和資料庫互動時,以商業邏輯來說,我們可能很常會碰到,同一隻api裡面除了要做新增之外,可能還需要再執行更新或是其他CRUD,舉個實際的例子:假設今天某電商平台的購買邏輯是,先付款成功並且產生發票,最後還有計算消費金額回饋點數給使用者,那麼就有可能造成在購物平台買了一些商品,最後結完帳之後,也產出發票了,但是最後計算點數時出現了一點錯誤(可能是某個人把這個function改炸了或其他狀況),但麼使用者的點數就掰掰了,這聽起來是一個悲劇,所以我們需要transaction來避免類似的情形發生
今天會以prsima為主來介紹它的transaction,其實下面的範例幾乎都是拿官方文件的範例,這篇是我自己讀完後做的紀錄和整理,如果想看到詳細的內容可以去官方文件
下面是一個簡單的表格,會以筆者自己的理解依序一個一個介紹~
情境 | 可用的方法 |
---|---|
相依寫入 | 巢狀寫入 |
獨立寫入 | $transaction([])、API 批次操作 |
讀取、修改、寫入 | 冪等操作、樂觀並行控制、互動式交易 |
先來說一下情境好了,讓大家比較有想像空間,有一個功能是,首次發文章的時候,要建立使用者和文章內容,除此之外,首次發文章,可以先建立好多個草稿,並且一次發送,如果是你,你會怎麼做?
再來看看下面的方法,可以猜看看結果是什麼?
(A)全部都成功
(B)user成功post全失敗
(C)user成功post第一筆成功第二筆失敗
(D)全失敗
model User {
id Int @id @default(autoincrement())
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
title String @db.VarChar(10)
content String
}
const newUser = await prisma.user.create({
data: {
posts: {
create: [
{ title: '2', content: '2' },
{
title: '0123456789abc',
content: '33',
},
],
},
},
});
我們知道了結果(全失敗)後,也了解了prsima是如何看到這種相依的關係,另外提一下update的時候也可以達到一樣的效果.(原因是第二個title的長度超過10)
其實這個蠻單純的,我想大家應該都挺熟悉的,他就是指以下這些熟悉的老面孔,然後這邊也小小提醒一下,如果沒有特別需要回傳值,不要用createManyAndReturn,效能會比createMany差一點
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
}
(基本上在寫的時候,就會有紅色毛毛蟲了)
await prisma.team.deleteMany({
where: {
id: {
in: [2, 99, 2, 11],
},
},
data: {
members: {}, // Cannot access members here
},
})
其實我覺得他也挺單純的,不過前後順序非常重要的,如果兩個資料有關聯性,請留意順序性的問題
await prisma.$transaction([iRunFirst, iRunSecond, iRunThird])
網站的範例是提到,如果同時要刪除:使用者、私人訊息和文章這三個東西的時候,就是有相依性,可以看得簡單的小範例,要注意順序很重要!!,敘述是使用者私人訊息跟文章,但實際操作的時候,使用者最後才能刪除
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])
巢狀結構如何處理?
其實就是自己生成id往裡面傳,可以看個簡單的範例
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',
},
}),
])
此外還有一個需要留意的就是 schema的id,要將自動生成移除
- id Int @id @default(autoincrement())
+ id String @id @default(uuid())
其實這個是我覺得比較不理想的作法,他是完全仰賴自己寫的邏輯去執行和資料庫的互動,官方的例子如下:
適合短時間內,有大量請求同時發生,例如:售票網站
可以來看看下面這段code會遇到什麼問題,
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 = '玩命關頭99'
const availableSeat = await prisma.seat.findFirst({
where: {
movie: {
name: movieName,
},
claimedBy: null,
},
})
if (!availableSeat) {
throw new Error(`Oh no! ${movieName} is all booked.`)
}
await prisma.seat.update({
data: {
claimedBy: userId,
},
where: {
id: availableSeat.id,
},
})
如果有兩個人(小白跟小黑)同時定了電影:玩命關頭99,並且都是自動選號,那可能查詢出來的availableSeat就都會是1A,然後最後,小白先執行完prisma.seat.update後小黑又執行了一次prisma.seat.update,那麼做後seat的資料表就會是小黑的位子,那麼小白就要坐在小黑的大腿上看電影了就沒座位了,那我們該怎麼做呢?
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
}
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.`)
}
+const seats = await client.seat.updateMany({
+ data: {
+ claimedBy: userEmail,
+ version: {
+ increment: 1,
+ },
+ },
+ where: {
+ id: availableSeat.id,
+ version: availableSeat.version,
+ },
+})
+ if (seats.count === 0) {
+ throw new Error(`That seat is already booked! Please try again.`)
+ }
可以發現,在更新時,還會再檢查version,也就是說只會有一次機會,seat的version會是0,之後就會被更新到1了,也就是同時不論有多少人進來,就只會有一個update成功
這個是我在工作中比較常用到的,也是我自己認為,比較容易會遇到的情境,基本上比較無腦,就一直往裡面塞就對了,完全仰賴它幫我們做好所有的事情,直接看範例,很清楚地看到我們把tx傳入後,就是用它來執行update(create、find和delete),我是覺得沒啥大問題
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.
const sender = await tx.account.update({
data: {
balance: {
decrement: amount,
},
},
where: {
email: from,
},
})
if (sender.balance < 0) {
throw new Error(`${from} doesn't have enough to send ${amount}`)
}
// 2.
const recipient = tx.account.update({
data: {
balance: {
increment: amount,
},
},
where: {
email: to,
},
})
return recipient
})
}
值得留意的是timeout和設定的問題
依據來看一下這些設定在做什麼
資料庫 | 預設 |
---|---|
PostgreSQL | ReadCommitted |
MySQL | $RepeatableRead |
SQL Server | ReadCommitted |
CockroachDB | Serializable |
SQLite | Serializable |
這邊不一一介紹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
}
)
reference:https://www.prisma.io/docs/orm/prisma-client/queries/transactions#read-modify-write
以上提供的解法為筆者的淺見。若以上內容有誤,煩請各位讀者用力指正,謝謝。