
雖然前端通常會做 debounce 或 throttle,防止使用者瘋狂請求,不過這是前端的視角!後端本身是一個獨立的程序,它要面對的是無數個使用者,因此除了流量壓力之外,資料讀寫的正確也很重要。
指的是同一筆資料同時被讀取並修改時發生的資料不一致問題,例如:
消費者 A 與消費者 B 在網站下單絕世好酒時,都還剩 1 個庫存,兩個人也不小心同時結帳了,資料庫也沒有做讀寫防護,因此絕世好酒的庫存變成 -1。
所以像是搶門票、週年慶搶下單、使用者網路不好所以一直連點等,多個請求同時併發送出時,都要預防 race condition 的問題,不然做生意真的會虧慘 XD
大部分的寫入功能在前面做過 transaction 了,現在要做的是防止資料同時被多個請求更新。
在查詢時就加入 .for('update')進行鎖定:
const [recipe] = await tx
.select({ id: schema.recipes.id, likesCount: schema.recipes.likesCount })
.from(schema.recipes)
.where(eq(schema.recipes.id, recipeId))
.for('update'); // row lock
在 transaction 結束前,其他請求都不能動這筆資料。
這樣的做法也被稱作悲觀鎖 (Pessimistic Lock),代表這筆資料一開始就被預期會頻繁變動,因此在查詢階段就直接使用 SQL 內建語法來鎖住,缺點是 transaction 的時間如果過長,後續的請求會耗費很長的時間等待。
有悲觀鎖就有樂觀鎖 (Optimistic Locking) 啦!樂觀鎖預期資料變動不頻繁,因此不使用鎖定語法,而改為加入一個欄位 version 或使用 updated_at。寫入時比對版本或時間,如果比對不相等就重試。
| 鎖定機制 | 情境 | 優點 | 缺點 |
|---|---|---|---|
| 樂觀鎖 | 寫入少 | 1. 不需額外使用鎖語法,併發效能高2. 不會產生 deadlock | 1. 寫入衝突高時會頻繁重試,消耗 CPU2. 需要額外實作版號或時間戳機制 |
| 悲觀鎖 | 寫入多 | 1. 嚴格保證資料一致性2. 避免了重試成本 | 1. 併發效能較差,長時間佔用資源2. 容易發生 deadlock |