學點後端知識之 Sequelize 中建立事務和?

HelKyle發表於2019-03-26

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 中建立事務和?

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  

MySqlPostgres 都實現了悲觀鎖,執行相關語句即可(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
});
複製程式碼

相關連結

相關文章