本文由 簡悅 SimpRead 轉碼, 原文地址 t.hao0.me
在日常開發中,一旦涉及到多個表操作時,便需要考慮事務及數據一致性的問題, 通常就是事務原子性和 保證數據能被正確且完整地記錄, 本文將探討下遇到的系統結算中碰到的相關問題。
-
事務基礎
-
事務特性 ACID:
原子性 (Atomicity): 一個事務(transaction)中的所有操作,要么全部完成,要么全部不完成,不會結束在中間某個環節。事務在執行過程中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣。
一致性 (Consistency): 在事務開始之前和事務結束以后,數據庫的完整性沒有被破壞。這表示寫入的資料必須完全符合所有的預設規則,這包含資料的精確度、串聯性以及后續數據庫可以自發性地完成預定的工作。
隔離性 (Isolation 兩個或者多個事務並發訪問(此處訪問指查詢和修改的操作)數據庫的同一數據時所表現出的相互關系。事務隔離分為不同級別,包括讀未提交(Read uncommitted)、讀提交(read committed)、可重復讀(repeatable read)和串行化(Serializable)。
持久性 (Durability 在事務完成以后,該事務對數據庫所作的更改便持久地保存在數據庫之中,並且是完全的。
-
四種事務隔離級別:
未提交讀 (READ UNCOMMITTED 是最低的隔離級別。允許臟讀,事務可以看到其他事務 “尚未提交” 的修改。
在提交讀 (READ COMMITTED 隔離級別中,基於鎖機制並發控制的 DBMS 需要對選定對象的寫鎖一直保持到事務結束,但是讀鎖在 SELECT 操作完成后馬上釋放,因此不可重復讀現象可能會發生,但不要求 “范圍鎖 (range-locks)”。
在可重復讀 (REPEATABLE READS) 隔離級別中,基於鎖機制並發控制的 DBMS 需要對選定對象的讀鎖和寫鎖一直保持到事務結束,但不要求 “范圍鎖”,因此可能會發生 “幻影讀”。
可序列化 (Serializable) 是最高的隔離級別,在基於鎖機制並發控制的 DBMS 實現可序列化要求在選定對象上的讀鎖和寫鎖保持直到事務結束后才能釋放。在 SELECT 的查詢中使用一個 “WHERE” 子句來描述一個范圍時應該獲得一個 “范圍鎖”。這種機制可以避免“幻影讀” 現象。
-
幾種讀現象:
臟讀: 一個事務允許讀取另外一個事務修改但未提交的數據:
不可重復讀: 在一次事務中,當一行數據獲取兩遍得到不同的結果表示發生了不可重復讀。在基於鎖的並發控制中 “不可重復讀” 現象發生在當執行 SELECT 操作時沒有獲得讀鎖或者 SELECT 操作執行完后馬上釋放了讀鎖。
事務 2 提交成功,因此他對 id 為 1 的行的修改就對其他事務可見了。但是事務 1 在此前已經從這行讀到了另外一個 “age” 的值。在可序列化和可重復讀的隔離級別時,數據庫在第二次 SELECT 請求的時候應該返回事務 2 更新之前的值。在提交讀和未提交讀,返回的是更新之后的值,這個現象就是不可重復讀。
幻影讀: 在事務執行過程中,當兩個完全相同的查詢語句執行得到不同的結果
當事務 1 兩次執行 SELECT ... WHERE 檢索一定范圍內數據的操作中間,事務 2 在這個表中創建了 (如 INSERT) 了一行新數據,這條新數據正好滿足事務 1 的 “WHERE” 子句。
-
隔離級別與讀現象關系:
* ### 隔離級別與鎖持續時間 (S: 鎖持續到當前語句執行完畢,C: 鎖會持續到事務提交):
* 業務場景 (假設 DB 是類似 MySQL 這類基於鎖管理事務的): 用戶提現
---------------------------------------
-
提現業務邏輯
-
用戶提現業務邏輯大概為
// 1. 查詢余額是否足夠
// 2. 保存提現記錄
// 3. 保存賬戶明細
// 4. 更新賬戶
上面的業務邏輯中,我們需要對多個表進行寫操作,勢必應該將這幾個操作放在同一事務中,以保證邏輯的原子性,由於對數據庫進行了分庫,為了盡量遠離到分布式事務,在對上述幾張表分庫時,則應將同一用戶的記錄分到同一庫中 (保證單機 DB 事務),比如按照用戶 ID 分庫, 在事務中進行幾個操作:
public Boolean doInTransaction(...) {
// 1. 查詢余額是否足夠
// 2. 保存提現記錄
// 3. 保存賬戶明細
// 4. 更新賬戶
}
-
隔離級別設置
-
假如我們設置隔離級別為最高級別可序列化 (Serializable),如
@Transactional(isolation = Isolation.SERIALIZABLE)
public Boolean doInTransaction(...) {
// ①. 查詢當前賬戶
Account account = queryAccount(...);
// ②. 檢查余額是否足夠
if (withdraw.getAmount() > account.getBalance()){
// ... throw Exp
}
// ③. 添加提現記錄
insertWithdraw(...)
// ④. 添加賬戶明細
insertDetail(...);
// ⑤. 更新賬戶余額
updateAccountBalance(...);
...
}
假如現在用戶賬戶里有 5000 元,事務 1 要提取 3000 元,事務 2 要提取 4000 元,兩個事務都並行着,勢必要保證這這兩個事務不能都成功。 按理,我們已經設置當前事務隔離級別為 Serializable,按照 上面的隔離級別 特性,那么如果事務 1 進入了該方法后,在①處就對該賬戶加上了讀鎖和寫鎖,即使事務 2 進來,也會等待在①處,但事實並非如此,在事務中的 SELECT 操作 autocommit=false 時),MySQL 會在 SELECT 語句后加上 LOCK IN SHARE MODE
(即對行加上共享鎖,其他事務依然可以對該記錄進行讀取操作),因此有可能事務 1 和事務 2 都同時讀到是 5000 元,這樣走下去,那么賬戶余額有可能就負數了,可以通過在①處查詢賬戶的 SELECT 語句后加上排他鎖 (SELECT .. FROM .. WHERE .. FOR UPDATE
),這樣事務 2 在①處將阻塞住,直到事務 1 結束,這時 Serializable 已沒有什么意義,但這種方案是不可取的,這有可能造成性能低下和隱秘的死鎖等問題,我們需要通過程序的方式間接處理上面的問題,如在更新賬戶余額后作再查詢一次賬戶余額作檢查,如:
public Boolean doInTransaction(...) {
// ①. 查詢當前賬戶
Account account = queryAccount(...);
// ②. 檢查余額是否足夠
if (withdraw.getAmount() > account.getBalance()){
// ... throw Exp
}
// ③. 添加提現記錄
insertWithdraw(...)
// ④. 添加賬戶明細
insertDetail(...);
// ⑤. 更新賬戶余額
updateAccountBalance(...);
// ⑥. 余額檢查
// 預計的余額
expected = account.getBalance() - withdraw.getAmount()l
// 實際的余額
account = queryAccount(...);
actual = account.getBalance();
if (expected != actual){
// 有其他事務更新過余額,throw Exp
}
...
}
或者更簡單些,在進行余額更新時,就將預計的余額作為條件 WHERE balance = :actual,類似於 CAS(Compare And Set),若更新失敗,說明余額發生變化,回滾事務:
public Boolean doInTransaction(...) {
// ①. 查詢當前賬戶
Account account = queryAccount(...);
// ②. 檢查余額是否足夠
if (withdraw.getAmount() > account.getBalance()){
// ... throw Exp
}
// ③. 添加提現記錄
insertWithdraw(...)
// ④. 添加賬戶明細
insertDetail(...);
// ⑤. 更新賬戶余額
// SQL如: UPDATE accounts SET balance=:newBalance WHERE id=:id AND balance=:oldBalance
updateAccountBalance(..., account.getBalance());
...
}
這樣就通過事務失敗來保證數據的正確性,但若是用戶賬戶的操作比較頻繁,那失敗的幾率也會有所增加, 這就需要通過其他方式,如消息隊列等來將用戶的賬戶操作像流水線一樣來進行處理。