前言
對於關係型數據庫事務,之前的理解還比較淺顯,基本還停留在面試寶典中長期背誦的那些以及最基本的操作上,比如一個事務可以執行一對 SQL,一旦遇到異常後會全部回滾,不會造成髒數據。這裏體現的是事務的原子性、一致性和持久性。對於隔離性,在之前的開發中基本沒有用到過,一直用的數據庫默認的隔離級別,也就沒用更新的認識了。直到最近開發中遇到了一個小問題,促使我對隔離性有了新的認識和理解,本文就來講講事務的隔離性。
遇到的問題
這裏就不對事務隔離性的定義再詳細展開了,可以看我參考資料中的幾篇博客,講的都很到位。
現在開發中有這麼一套業務邏輯:
// 1. 查詢數據庫中是否有某條記錄
// 2. 如果不存在,則通過 HTTP 調用另一個服務,執行一系列操作後,並插入該條數據
// 3. 再查一次,存在則繼續接下來的業務
@Transactional
public void foo(){
// # 1st query
EntityA a = aService.findOneBy();
if(a == null){
// call other service and insert data
httpUtils.doPost("http://ip:port/createA");
// # 2nd query
a = aService.findOneBy();
}
}
注: 這邊在設計上可能有一些問題,遠程調用服務方法 createA()
,其實直接將插入的數據 JSON 返回即可,不需要重複查詢數據庫,但是 createA()
這個方法默認是無返回的,由於某些原因無法做出改動。
乍一看這代碼似乎並沒有什麼問題,邏輯也很簡單。但實際上兩次查詢的結果是一樣的,我們無法在成功執行 createA()
後得到新的數據。這是爲什麼呢?具體原因就是受到數據庫隔離級別的影響。
事務的隔離性和 @Transactional
隔離性是關係型數據庫事務 ACID 特徵中的一種,意思是如果有多個事務併發執行,每個事務作出的修改必須與其他事務隔離。也就意味着事務間的操作相互不可見。同時爲了權衡數據庫性能和可靠性,SQL 標準中給出了集中隔離級別(不同的數據庫實現方式不同)READ UNCOMMITED、READ COMMITED、REPEATABLE READ 和 SERIALIZABLE。
RAED UNCOMMITED
:使用查詢語句不會加鎖,可能會讀到未提交的行(Dirty Read);READ COMMITED
:只對記錄加記錄鎖,而不會在記錄之間加間隙鎖,所以允許新的記錄插入到被鎖定記錄的附近,所以再多次使用查詢語句時,可能得到不同的結果(Non-Repeatable Read);REPEATABLE READ
:多次讀取同一範圍的數據會返回第一次查詢的快照,不會返回不同的數據行,但是可能發生幻讀(Phantom Read);SERIALIZABLE
:InnoDB 隱式地將全部的查詢語句加上共享鎖,解決了幻讀的問題;MySQL 中默認的事務隔離級別就是
REPEATABLE READ
,但是它通過 Next-Key 鎖也能夠在某種程度上解決幻讀的問題。
可通過以下 SQL 查看 MySQL 的隔離級別:
use performance_schema;
select * from global_variables where variable_name = 'tx_isolation';
再來看看 Spring 中的申明式事務註解的用法。@Transactional
中可以配置以下參數:
屬性名 | 說明 |
---|---|
name | 當在配置文件中有多個 TransactionManager , 可以用該屬性指定選擇哪個事務管理器。 |
propagation | 事務的傳播行爲,默認值爲 REQUIRED。 |
isolation | 事務的隔離度,默認值採用 DEFAULT。 |
timeout | 事務的超時時間,默認值爲 -1。如果超過該時間限制但事務還沒有完成,則自動回滾事務。 |
read-only | 指定事務是否爲只讀事務,默認值爲 false;爲了忽略那些不需要事務的方法,比如讀取數據,可以設置 read-only 爲 true。 |
rollback-for | 用於指定能夠觸發事務回滾的異常類型,如果有多個異常類型需要指定,各類型之間可以通過逗號分隔。 |
no-rollback- for | 拋出 no-rollback-for 指定的異常類型,不回滾事務。 |
isolation = DEFAULT
意味着 Spring 默認會採用數據中配置的隔離級別,也就是 REPEATABLE READ
,並且 MySQL 也解決了幻讀。因此兩次的 query 操作查詢到的記錄都爲 null。對於事務而言這顯然是一個正常的結果,但是對於我們的業務邏輯而言就存在問題了。按照上面的代碼,我們就是想 “幻讀” 到其他事務作出的修改。
讓事務可”幻讀“
當我們清楚地知道某些數據出現不可重複讀和幻讀的現象時,其實也就沒什麼關係了。爲了是的上面的代碼能夠正常運行,我們可以作出以上改動。
- 直接修改
foo()
上的事務註解配置
@Transactional(isolation = Isolation.READ_COMMITTED)
public void foo(){}
這樣相當於在 MySQL 數據庫的 REPEATABLE READ
隔離級別上降了一級,在 READ_COMMITTED
這個級別上會出現不可重複讀和幻讀的問題,也就意味着我們可以讀到其他事務對數據庫作出的修改,問題解決。
- 將
aService.findOneBy()
方法以非事務方式運行或單獨起一個事務
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public findOneBy(){}
以非事務方式運行就可以讀到其他事務的更新數據的。
@Transactional(propagation = Propagation.REQUIRES_NEW)
public findOneBy(){}
Propagation.REQUIRES_NEW
意思是創建一個新的事務,如果當前存在事務,則把當前事務掛起。這種方式相當於將 2 次查詢操作不加入 foo()
的事務中,單獨在自己的事務中運行,手動將事務串行化了,也就是 1st query --> createA --> 2nd query
依次進行,自然不存在併發事務,也就解決問題了。
總結
在大部分場景中,直接在 Service 層的方法上添加一個 @Transactional
就能讓方法以事務的形式運行,能夠很好的保證在出現異常的情況下有效的進行 rollback,防止產生髒數據。但是在某些特殊的場景下,還是需要手動細粒度的去控制事務的隔離級別和傳播行爲的,就比如文中描述到的這個場景。
隔離級別越高數據庫的一致性越強,但性能越差。這點就需要在開發中去協調了,事務常見的問題髒讀、不可重複讀和幻讀,除了髒讀比較致命外,其餘兩個個人覺得只要是可控的,就都不是問題了。
還有一個問題,不知道能不能得到解答:SQL 標準中定義了事務具有隔離性,事務內部無法讀到其他事務的操作結果,但如果 t1 讀取了某條數據,並進行計算,此時 t2 修改了並且 commit,那麼 t1 的計算結果不是全都會出現問題嗎?不可重複讀的意義在於哪?如果可以讀到外部事務的更新,會出現什麼問題?