Sequelize
是 Node.js 的一個 ORM
庫,通過 Sequelize
我們能用熟悉的 js 連結,運算元據庫。工作需要,筆者日常工作除了切圖,也開始要寫後端程式碼了。由於缺乏經驗,之前開發的功能在併發情況下出現了異常。深入理解之後重新回來梳理問題,並記錄學習的筆記。
基礎知識
事務(Transaction):事務是資料庫執行過程中的一個邏輯單位,由一系列有限的資料庫操作序列構成。被事務包裹起來的這些操作會有共同的執行結果,要麼全部成功,要麼失敗,全部回滾。all-or-nothing
以銀行轉賬為例子。使用者 A 給 使用者 B 轉賬 100。
虛擬碼(忽略細節)
A 餘額 = A餘額 - 100
B 餘額 = B餘額 + 100
複製程式碼
A 賬號 -100,和 B 賬號 +100 是兩條獨立的語句,如果在中間發生異常,導致程式中斷,就會出現 A 賬號上的 100 憑空消失。誰也不想看到這樣的結果, 為了保證轉賬操作的原子性 ,用事務包裹起來。如果這中間發生錯誤,則會全部回滾。
start transaction;
// 轉賬操作
commit;
複製程式碼
Terminal 中演示 transaction
的作用。
Sequelize
提供了 Transaction
類,通過 Sequelize.transaction
建立事務,並在每一次資料庫操作設定當前操作屬於哪個事務。
await sequelize.transaction({}, async (transaction) => {
const instance = await Accounts.findOne({
where: {
name: 'HelKyle',
},
transaction,
});
await instance.update({
balances: instance.balances + number,
}, {
transaction,
})
})
複製程式碼
從 Squelize log 中能看到,建立了事務 id 為444a5afe-9635-40fd-90d7-10f5aa16077a
,之後的查詢,更新都在這個事務中執行。
Executing (444a5afe-9635-40fd-90d7-10f5aa16077a): START TRANSACTION;
Executing (444a5afe-9635-40fd-90d7-10f5aa16077a): SELECT "name", "balances" FROM "accounts" AS "accounts" WHERE "accounts"."name" = 'HelKyle';
Executing (444a5afe-9635-40fd-90d7-10f5aa16077a): UPDATE "accounts" SET "balances"=$1 WHERE "name" = $2
Executing (444a5afe-9635-40fd-90d7-10f5aa16077a): COMMIT;
複製程式碼
如果不想每次都手動傳 transaction
物件,可以通過配置 CLS 的方式,配置全域性預設 transaction
。
併發時候的悲劇
事務只解決了操作原子性的問題,另一個棘手的問題是併發。假設在 A 給 B 轉賬的過程中,恰巧 C 也給 A 轉賬 80,用 table 的形式演示併發過程中可能會發生的問題。
事務一(A 給 B 轉賬) | 事務二(C 給 A 轉賬) |
---|---|
查詢 A 餘額 200 | |
查詢 A 餘額 200 | |
更新 A 餘額 = 200 + 80 | |
更新 A 餘額 = 200 - 100 |
從結果上可以看到,A 最終沒有收到來自 C 的 80,C 使用者的?丟了。併發的問題可以通過加鎖來避免。
鎖的概念
悲觀鎖 VS 樂觀鎖:
悲觀鎖對外界持保留態度,為了避免衝突,不管三七二十一,先給記錄加上鎖,在當前事務釋放之前,其他事務要對該記錄執行操作必須等待。
事務一(A 給 B 轉賬) | 事務二(C 給 A 轉賬) |
---|---|
查詢 A 餘額 200,並鎖定記錄 | |
查詢 A 餘額,發現有其他事務鎖定了記錄,等待... | |
更新 A 餘額 = 200 + 80,釋放鎖 | |
獲得執行權,查詢 A 餘額,280 | |
更新 A 餘額 = 280 - 100 |
MySql
,Postgres
都實現了悲觀鎖,執行相關語句即可(select for update
),不需要開發。悲觀鎖的缺點是在讀操作頻繁的場景下,會影響吞吐量。
和悲觀相對的是樂觀鎖,樂觀鎖認為衝突沒那麼多,任何事務都可以先讀取資源,在寫入更新的時候做判斷。通常會增加 version 欄位,每次更新的時候 verion + 1,提交更新到資料庫的時候,判斷 version ,如果已失效則重試。
事務一(A 給 B 轉賬) | 事務二(C 給 A 轉賬) |
---|---|
查詢 A 餘額 200,版本號 n | |
查詢 A 餘額 200,版本號 n | |
更新 A 餘額 = 200 + 80,版本號 = n + 1 | |
發現最新版本已經不是 n, 重試 | |
查詢 A 餘額 280,版本號 n + 1 | |
更新 A 餘額 = 280 - 100,版本號 = n + 2 |
sql 程式碼:
select name, balances, version from accounts where name='HelKyle';
update accounts set version=version+1, balances=balances+100
where name='HelKyle' and version=#{version}
複製程式碼
樂觀鎖在寫操作頻繁的場景下會不斷髮生重試,也會影響吞吐量。
排他鎖 VS 共享鎖:
排他鎖是悲觀鎖的一種,查詢的時候時候加鎖。同一資源同一時間只能有一個排他鎖,其他事務往這條記錄上新增排他鎖必須等待當前事務的完成(其他事務讀需要等待)。
sql 程式碼
select * from accounts where name='HelKyle' for update;
複製程式碼
Sequelize 寫法
await Accounts.findOne({
where: { name: 'HelKyle' },
lock: Sequelize.Transaction.LOCK.UPDATE
});
複製程式碼
演示: ? 事務沒有結束的時候,? 事務只能等待,直到排他鎖釋放。
事務一 | 事務二 |
---|---|
start transaction; | start transaction; |
select * from accounts where name='A' for update; | |
輸出:A 100 | |
select * from accounts where name='A' for update; | |
waiting... | |
commit; | |
輸出:A,100 | |
commit; |
共享鎖允許同一資源同時存在多個,當需要執行修改,刪除等操作時,必須等其他所有共享鎖都釋放之後才能執行。
sql
程式碼
select * from accounts where name='HelKyle' for share;
複製程式碼
Sequelize
寫法
await Accounts.findOne({
where: { name: 'HelKyle' },
lock: Sequelize.Transaction.LOCK.SHARE
});
複製程式碼
演示: ? ?的事務都能查詢,?事務想修改資料時,由於?共享鎖沒有釋放,修改操作只能等待。
事務一 | 事務二 |
---|---|
start transaction; | start transaction; |
select * from accounts where name='A' for share; | |
輸出:A 100 | |
select * from accounts where name='A' for share; | |
輸出:A 100 | |
update accounts set balances=10 where name='A' | |
waiting... | |
commit; | |
set A.balances = 10 | |
commit; |
除了 lock,還有另一個配置和鎖相關,是 sequelize.transaction(options)
的配置引數 isolationLevel
,支援四個級別,分別是:
級別 | 髒讀 | 不可重複讀 | 幻讀 |
---|---|---|---|
READ_UNCOMMITTED 讀未提交 | |||
READ_COMMITTED 讀已提交 | ❌ | ||
REPEATABLE_READ 可重複讀 | ❌ | ❌ | |
SERIALIZABLE 可序列化 | ❌ | ❌ | ❌ |
?的 ❌ 表示在這種級別裡面,某類問題不會出現。
名詞解析:
-
髒讀
,指的是在一個事務中能讀取到另一個事務內未 commit 的內容,如果另一個事務最終失敗了,沒有寫入資料庫,那麼第一個事務就拿到了不存在的資料。事務一 事務二 start transaction; start transaction; select * from accounts where name='A'; 輸出:A 100 update accounts set balances=10 where name='A' select * from accounts where name='A'; 輸出:A 10 (這時候事務一併沒有 commit) -
不可重複讀
描述在一個事務中,事務多次讀取統一資源(本事務中沒有修改操作),得到不同的結果。事務一 事務二 start transaction; start transaction; select * from accounts where name='A'; 輸出:A 100 update accounts set balances=10 where name='A' commit; select * from accounts where name='A'; 輸出:A 10 -
幻讀
,指的是出現了符合查詢條件,但是之前沒有?到過。比如 queryAll 一個表中所有資料,設定 balances 為 0,但是由於其他事務同時寫入新內容,於是新記錄明明符合 queryAll 但是沒有 balances 並不為 0,像?一樣。事務一 事務二 start transaction; start transaction; select * from accounts; 輸出:A 100 update accounts set balances=0; insert into accounts values ('B', 100); commit; commit; select * from accounts; 輸出:A 0, B 100
在 Sequelize 中配置 isolationLevel
sequelize.transaction({
isolationLevel: Sequelize.Transaction.ISOLATION_LEVELS.SERIALIZABLE
}, transaction => {
// your transactions
});
複製程式碼