單機事務不同隔離級別的並發問題整理

本篇文章主要關注什麼樣的業務場景是一個一勞永逸的單機事務所不能解決並且難以測試的,以及不同的隔離級別在並發下會出現什麼問題。

0.0 概念說明

事務,這裡不贅述,不明白事務的同學可以先參考事務的wiki:Database transaction

事務的隔離級別,是由事務的並發所引入的概念,而為何需要並發,是由於事務的概念以及存儲硬體IO耗時共同決定的,這個概念遞進關係如下:

事務需要的Duratility -> 事務需要持久化到磁碟 -> 磁碟IO耗時久 -> 單線程執行事務慢 -> 多個事務需要多線程並行 -> 同一數據可能會被多線程更改 -> 需要一個更改的順序規則 = 隔離級別。

本文將層次遞交的說明不同隔離級別的並發問題,注意,更高隔離級別的並發問題,肯定會在較低級別上發生。

0.1 魔鬼在細節

事務最難的點在於細節繁多,實現上不盡統一,本文將描述出一個並發問題來源和這個並發問題所作用的最高隔離級別。

1. Read-uncommitted 讀未遞交

顧名思義,讀未遞交是指,用戶可以讀取到未遞交的數據。

讀未遞交的隔離級別僅保證數據可被回滾或可被持久化,但無法保證不同的事務之間操作同一塊數據的因果邏輯。

讀未遞交一般的問題有兩個:臟讀(Dirty reads)以及臟寫(Dirty writes)

一個典型的場景,融合了臟讀和臟寫:

egin{array}{c|c} 	ext{time} & 	ext{User1} & 	ext{User2} \ hline 1 & & 	ext{begin} \ 2 & 	ext{} & 	ext{get x = 2} \ 3 & 	ext{begin} & \ 4 & 	ext{set x = 3} \ 5 & 	ext{} & 	ext{get x = 3} \ 6 & 	ext{rollback}\ 7 & 	ext{} & 	ext{set x = x+1} \ 8 & 	ext{} & 	ext{get x = 4} \ 9 & 	ext{} & 	ext{commit} \ end{array}

這個場景中,x的值在User2的事務中即被臟讀,也由臟讀造成了臟寫。

從上面的例子可以看出:read-uncommitted 在任何存在修改同一行數據的場景下都是非常危險且不穩定的,而且結果難以預料,所以,read-uncommitted 的適用場景主要為只做單表的批量插入,不做update,不保證讀一致性的場景之中,這種場景一般只需要兩種確定結果,持久化成功則返回,持久化失敗,則再次重試,由read-uncommitted 可以比較好的保證事務的原子性和持久性。

2. Read Committed 讀已遞交

作為入門隔離級別,read committed在對latency有較大要求的業務場景中應用的比較廣泛。

讀已遞交,顧名思義,只有事務已遞交的數據才能被讀取到。

雖然read committed在事務執行過程中可以保證無法讀取到臟數據(這裡的臟數據是指會被回滾的數據),但read committed事務自身卻會產生業務邏輯錯誤的數據,比如一個較長事務重讀某個數據的場景,會發生兩次讀取的數據不一樣,從而使業務產生錯誤的邏輯。

nonrepeatable reads (不可重複讀)

egin{array}{c|c} 	ext{time} & 	ext{User1} & 	ext{User2} \ hline 1 & & 	ext{begin} \ 2 & 	ext{} & 	ext{get x = 3} \ 3 & 	ext{begin} & \ 4 & 	ext{set x = 2} \ 5 & 	ext{commit}\ 6 & 	ext{} & 	ext{get x = 2} \ end{array}

在這種場景中,User2的事務中,x重複讀取的結果就是讓業務獲取到了錯誤的數據,但字面意義上來說,這種場景不能被稱為臟讀(因為x=2不是臟數據),但最終產生了非業務期望的效果。

lost update (更新丟失)

egin{array}{c|c} 	ext{time} & 	ext{User1} & 	ext{User2} \ hline 1 & & 	ext{begin} \ 2 & 	ext{} & 	ext{get x = 3} \ 3 & 	ext{begin} & \ 4 & 	ext{set x = 2} \ 5 & 	ext{commit}\ 6 & 	ext{} & 	ext{x = x +1 -> x = 4} \ 7 & 	ext{} & 	ext{set x = 4} \ 8 & 	ext{} & 	ext{commit} \ end{array}

這種場景中,user1的遞交彷彿沒有發生過一樣,因為User2的更改是基於時間上更前的 x = 2的基礎的更改,導致業務邏輯出問題。

explicit lock (排他鎖)

一些關係型資料庫的實現,支持通過顯示的指明排他鎖來防止不可重複讀這種錯誤場景的發生,如Mysql innodb支持的for update語法:

select * from user where id = 1234 for update

在這個語句下,當前用戶會持有user id = 1234這行數據直到事務回滾,超時或遞交,從而保證這行數據的業務邏輯無重入。

排他鎖的缺點是,當業務無法精確的指明排他行數,持有的行數過多,會互相影響,以及大的排他鎖持有者,會讓大量小業務阻塞,從而出現熱點數據瓶頸。

迴避該錯誤場景另一種方式是使用更高的隔離級別,兩種方法在效率上取決於場景,數據密集且長事務更新的場景,更適合使用更高的隔離級別,而短事務場景,則更適合使用排他鎖。

Read committed 比較適合的業務場景為,業務不存在並發修改狀態類數據的場景,或是業務可以非常精確通過排他鎖修改狀態類數據的場景。

3. Repeated Read 可重複讀

可重複讀的隔離級別,解決了nonrepeatable reads場景中重複讀出錯的問題。

可重複讀在解決lost updates的場景上存在分歧,有的關係型資料庫如sql server的snapshot isolation,postgre的repeatable read 實現了自動檢測lost updates,但mysql innodb的repeated read並沒有。

所以在mysql的repeated read場景下,業務仍需要指明排他鎖,特別是針對一些餘額類的業務更改。

write skew 以及 phantoms(幻讀)

我們用一個實際的例子來說明什麼是write skew,假設有一個航空公司,用booking表來記錄一次成功的預定,那麼在兩個用戶並發的做預定操作時,會發生如下的場景:

egin{array}{c|c} 	ext{time} & 	ext{session1} & 	ext{session2} & 	ext{rest - bookedCount} \ hline 1 & 	ext{begin} & 	ext{} & 1\ 2 & 	ext{select count(*) from booking where flight_id = 1234} & & 1 \ 3 & 	ext{if (rest - count > 0)} & 	ext{begin} & 1\ 4 & 	ext{} & 	ext{select count(*) from booking where flight_id = 1234} &1 \ 5 & 	ext{insert booking record} & 	ext{if (rest - count > 0)} &1\ 6 & & 	ext{insert booking record} &1\ 7 & 	ext{commit} & 	ext{commit} &-1\ 8 & 	ext{} & 	ext{} &-1\ end{array}

這個場景既不是臟寫,也不是lost update,因為沒有更新在中途丟失,但是在結束業務的時候發生了機票超賣。

再使用一個實際例子來說明什麼是phantoms

egin{array}{c|c} 	ext{time} & 	ext{session1} & 	ext{session2} \ hline 1 & 	ext{begin} & 	ext{begin} \ 2 & 	ext{SELECT * FROM users WHERE age BETWEEN 10 AND 30;} & \ 3 & & 	ext{INSERT INTO users(id,name,age) VALUES ( 3, Bob, 27 ); COMMIT; } \ 2 & 	ext{SELECT * FROM users WHERE age BETWEEN 10 AND 30;} & \ end{array}

repeated read在這種場景下無法保證session讀取到的值在事務中間不變,這就是phantoms。

phantom 和 write skew的解決方法也類似於lost update,用戶可以使用顯示的鎖行,或materializing conflict(物化衝突)的思路,創建一個專門用來發現衝突的表記錄,比如在航空公司的例子中,用戶可以創建一個 bookingrest 表,專門用來記錄某個航班的餘量,把單行插入的業務變更為插入一條booking記錄並更新bookingrest的兩步操作,在repeated read隔離級別中,遞交booking_rest時,衝突事務會因為衝突而被自動回滾。

如果業務影響太大,則可以使用另一種辦法,也就是提升到serialize 隔離級別來避免phantom和write skew。

4. 總結

本篇文章主要總結了三種不同的事務隔離級別以及在這三種隔離級別下可能引發的錯誤和解決辦法。

推薦閱讀:

簡析關係型資料庫和非關係型資料庫的比較(上)
簡析關係型資料庫和非關係型資料庫的比較(下)

TAG:資料庫 | 演算法 | 高並發 |