談談事務的隔離性及在開發中的應用

前言

對於關係型數據庫事務,之前的理解還比較淺顯,基本還停留在面試寶典中長期背誦的那些以及最基本的操作上,比如一個事務可以執行一對 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。對於事務而言這顯然是一個正常的結果,但是對於我們的業務邏輯而言就存在問題了。按照上面的代碼,我們就是想 “幻讀” 到其他事務作出的修改。

讓事務可”幻讀“

當我們清楚地知道某些數據出現不可重複讀和幻讀的現象時,其實也就沒什麼關係了。爲了是的上面的代碼能夠正常運行,我們可以作出以上改動。

  1. 直接修改 foo() 上的事務註解配置
@Transactional(isolation = Isolation.READ_COMMITTED)
public void foo(){}

這樣相當於在 MySQL 數據庫的 REPEATABLE READ 隔離級別上降了一級,在 READ_COMMITTED 這個級別上會出現不可重複讀和幻讀的問題,也就意味着我們可以讀到其他事務對數據庫作出的修改,問題解決。

  1. 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 的計算結果不是全都會出現問題嗎?不可重複讀的意義在於哪?如果可以讀到外部事務的更新,會出現什麼問題?

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章