iT邦幫忙

0

Prsima的transaction

zyx 2024-07-26 17:07:34447 瀏覽
  • 分享至 

  • xImage
  •  

Why use transaction?

在學習一個東西以前,一定都要知道原因,絕對不要因為別人有做就盲目的跟著做,總要知道它帶來什麼好處以及能解決什麼問題
我們都知道在和資料庫互動時,以商業邏輯來說,我們可能很常會碰到,同一隻api裡面除了要做新增之外,可能還需要再執行更新或是其他CRUD,舉個實際的例子:假設今天某電商平台的購買邏輯是,先付款成功並且產生發票,最後還有計算消費金額回饋點數給使用者,那麼就有可能造成在購物平台買了一些商品,最後結完帳之後,也產出發票了,但是最後計算點數時出現了一點錯誤(可能是某個人把這個function改炸了或其他狀況),但麼使用者的點數就掰掰了,這聽起來是一個悲劇,所以我們需要transaction來避免類似的情形發生

prsima(orm)

今天會以prsima為主來介紹它的transaction,其實下面的範例幾乎都是拿官方文件的範例,這篇是我自己讀完後做的紀錄和整理,如果想看到詳細的內容可以去官方文件
下面是一個簡單的表格,會以筆者自己的理解依序一個一個介紹~

情境 可用的方法
相依寫入 巢狀寫入
獨立寫入 $transaction([])、API 批次操作
讀取、修改、寫入 冪等操作、樂觀並行控制、互動式交易

相依寫入(Dependent writes)

巢狀寫入(Nested writes)

先來說一下情境好了,讓大家比較有想像空間,有一個功能是,首次發文章的時候,要建立使用者和文章內容,除此之外,首次發文章,可以先建立好多個草稿,並且一次發送,如果是你,你會怎麼做?

再來看看下面的方法,可以猜看看結果是什麼?
(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)

獨立寫入(Independent writes)

大量操作(Bulk operations)

其實這個蠻單純的,我想大家應該都挺熟悉的,他就是指以下這些熟悉的老面孔,然後這邊也小小提醒一下,如果沒有特別需要回傳值,不要用createManyAndReturn,效能會比createMany差一點

  • updateMany()
  • deleteMany()
  • createMany()
  • createManyAndReturn()
    另外有個值得注意的是,不能透過updateMany和deleteMany來處理巢狀的資料,例如
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
  },
})

$transaction([]) API

其實我覺得他也挺單純的,不過前後順序非常重要的,如果兩個資料有關聯性,請留意順序性的問題

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())

讀取、修改、寫入(Read, modify, write)

冪等操作(Idempotent APIs)

其實這個是我覺得比較不理想的作法,他是完全仰賴自己寫的邏輯去執行和資料庫的互動,官方的例子如下:

  • 非冪等:使用電子郵件地址 "letoya@prisma.io" 在資料庫中更新或插入使用者。User 資料表不強制執行唯一的電子郵件地址。如果您執行邏輯一次(建立一個使用者)或十次(建立十個使用者),對資料庫的影響會不同。
  • 冪等性:使用電子郵件地址 "letoya@prisma.io" 在資料庫中更新或插入使用者。User 表格會強制執行電子郵件地址的唯一性。如果您執行一次邏輯(建立一個使用者)或執行十次(使用相同的輸入更新現有使用者),對資料庫的影響是相同的。
    也就是說,他可能有以下幾種做法
  1. 在schema設定email為unique
  2. 在新增前,先執行一次find,再決定要不要執行create
    但正常的人類永遠沒辦法保證自己的邏輯有沒有bug或是想的是否完全周全,所以我自己覺得這個可能不是個理想的方法
    我自己的理解是,如果要做的話基本上會有一個(以上)的find去比對結果,如果寫完的邏輯沒有find,高機率是有問題的

樂觀並行控制(Optimistic concurrency control)

適合短時間內,有大量請求同時發生,例如:售票網站
可以來看看下面這段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成功

互動式交易(Interactive transactions)

這個是我在工作中比較常用到的,也是我自己認為,比較容易會遇到的情境,基本上比較無腦,就一直往裡面塞就對了,完全仰賴它幫我們做好所有的事情,直接看範例,很清楚地看到我們把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和設定的問題
依據來看一下這些設定在做什麼

  1. maxWait:這個指的比較是同時有很多請求的時候,等待sql有辦法回應自己的時間
  2. timeout:如果整個prisma.$transaction的執行時間大於5秒(default),那麼就會噴錯
  3. 交易隔離(蛤蜊)的等級(isolationLevel),也可以填入以下的等級ReadUncommitted、ReadCommitted、RepeatableRead、Snapshot和Serializable,至於default是哪一種要看資料庫而定
資料庫 預設
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

以上提供的解法為筆者的淺見。若以上內容有誤,煩請各位讀者用力指正,謝謝。


圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言