記一次事務併發引起的線上數據BUG

1. 問題現象

二手財務系統在收到用戶付款後,會做費用項明細拆分。即按照應收費用明細順序,依次做金額填充,生成實收費用項明細。

然而,財務最近發生了一起奇怪的拆分,分別收取了金額¥2000 和¥3000的收入,最終拆分結果是:

費用一:¥2000、費用一:¥2000、費用二:¥1000

並不是預期結果:

費用一:¥2000、費用二:¥3000

這是怎麼回事?

在業務實際場景中, 我們假定用戶應該支付:

費用一:2000、費用二:3000,一共待支付5000。

在支付時,可以選擇一次性支付5000,或者分多次支付。

當用戶選擇一次性付款5000時,系統會按照應收明細拆分成兩筆收款,分別是¥2000 和¥3000。

當用戶分兩次完成支付時,系統同樣是按照應收明細拆分成了兩筆:

可以看出,正常情況下,不論用戶怎樣進行付款,最後收款都會按照應收明細拆分成一樣的結果。

根據問題現象進行反推,我們猜測問題可能是這樣產生的:

第二筆收入進來時,並沒有按照¥3000的應收明細進行拆分,而是按照先前已經拆分完的第一筆應收明細進行拆分,再將餘額按照第二筆應收明細進行拆分。簡單講,就是收入拆分重複了。

2. 初次探究

經過排查日誌,發現對這兩筆收入的拆分發生在了兩個不同的進程中。我們簡單梳理一下每筆收入的拆分邏輯,系統會從數據庫中讀取“待分配”和“已分配”的明細,來確定該對照着哪筆“待分配明細”進行拆分。

那麼會不會是讀取的“已分配明細”出錯了呢?處理收入¥2000 和¥3000的時候,都認爲自己是最新的收入,然後參照着同一份“待分配明細”去填充。爲此我們找到兩個進程的的日誌去看,大致時間如下:

另外,我們還對比了第二步讀取待分配和已分配明細中獲得的數據,發現是一樣的,很明顯這是一個經典的髒讀問題。

爲了解決併發導致的數據錯誤,首先想到的方法是加鎖,使同一筆訂單,在同一時間,只能由一個線程處理。筆者使用Redis鎖進行限制,並將合同號作爲Redis鎖的key。至於鎖加在哪裏,當然是加在拆分的方法之外了。

修改後的邏輯大致是這樣的:

3. 進一步探究

給出這個解決方案後,在窗口期就進行了上線修復。本以爲事情告一段落了,誰知兩週之後,叕出現了拆分重複的情況。

看來之前提出加鎖的方案並沒有解決問題,這個問題必定隱藏着深層次的原因。前面已經進行了分析,必定是引起的髒讀導致的,可是爲什麼加鎖解決不了呢?難道是鎖沒有生效?

在進一步探究之前,我們先來複習一些概念。

3.1 數據庫隔離級別

爲了提高效率,數據庫使用了多線程對數據進行讀寫,這也就可能導致數據讀寫存在問題。問題可以歸爲三類:

併發問題 描述
髒讀 事務1沒有提交數據,事務B就讀取未提交的數據並進行處理
不可重複讀 事務B分兩次讀取數據,期間事務1提交了一次數據,導致事務B讀取的數據不一致
幻讀 事務B兩次讀取表格數據,此時事務1進行了增刪數據的更新,導致事務B讀取的數據條數不一致

爲了應對上述數據讀寫存在的問題,MySQL設置了4種隔離級別,每種隔離級別可以避免不同的問題,如下表所示:

隔離級別 髒讀 不可重複讀 幻讀
讀未提交(RU, Read uncommitted) 不可避免 不可避免 不可避免
讀已提交(RC, Read committed) 可避免 不可避免 不可避免
可重複讀(RR, Repeatable read) 可避免 可避免 不可避免
可串行化(Serializable) 可避免 可避免 可避免
  • 讀未提交(RU, Read uncommitted)
    是最自由的隔離級別,讀和寫事務可以自由對數據進行操作。因此也無法避免任何一種問題。
    舉例來說就是:事務1和事務2各寫各的,各讀各的,完全感知不到對方的存在;
  • 讀已提交(RC, Read committed)
    是爲了避免讀事務讀取到寫事務沒有提交的數據,可以避免髒讀問題。
    舉例來說就是:事務1拿取到數據進行加工處理,還沒有提交結果,這時候就禁止事務B讀取到未加工完成的數據。但是這樣做仍不能避免不可重複讀問題;
  • 可重複讀(RR, Repeatable read)
    是MySQL默認的隔離級別,可以保證讀事務對數據的多次讀取的值是一致的。
    舉例來說就是:數據庫針對事務B做一個拷貝,這樣就算事務1進行提交,也不會影響到事務B了;
  • 可串行化(Serializable)
    是最嚴格的隔離級別,它要求事務串行進行。可以避免全部併發導致的問題。

選取數據庫的隔離級別時,一般需要考慮數據併發安全和效率兩方面,取一個均衡的方案。

3.2 事務導致的髒讀問題

事實上,通過抓取事務執行的binlog日誌可以看出(下圖所示),拆分兩筆收入的事務,其中拆分¥2000的事務(事務1)commit與拆分¥3000的事務(事務2)的begin是在20:18:02。

根據現象可以推測:事務2讀取到了事務1沒有提交的數據,所以都認爲自己是新的收入,導致按照相同的待填充明細將收入進行拆分。

按照常理來說,Redis鎖生效了,應該會鎖到第一個事務提交完成。可是從日誌上來看,這個鎖似乎沒有生效。問題一定是出在了這個地方。

通過研究這部分的代碼,終於理清了處理邏輯。如下圖所示,由於歷史的設計原因,這個地方存在兩個事務嵌套,事務2是事務1的子事務,而其中的Redis鎖加在了父事務和子事務之間。

這種設計是存在問題的。

首先來複習一下基本知識—— Spring支持的事務傳播機制,簡單講就是當兩個事務存在包含和被包含關係的時候,事務應該怎樣去執行。Spring支持7種傳播機制。

傳播機制 描述
PROPAGATION_REQUIRED 如果當前沒有事務,就新建一個事務。如果已經存在於一個事務中,加入到這個事務中
PROPAGATION_SUPPORTS 支持當前事務,如果當前沒有事務,就以非事務方式執行
PROPAGATION_MANDATORY 使用當前的事務,如果當前沒有事務,就拋出異常
PROPAGATION_REQUIRES_NEW 新建事務,如果當前存在事務,把當前事務掛起
PROPAGATION_NOT_SUPPORTED 以非事務方式執行,如果當前存在事務,就把當前事務掛起
PROPAGATION_NEVER 以非事務方式執行,如果當前存在事務,則拋出異常
PROPAGATION_NESTED 如果當前存在事務,則在嵌套事務內執行;如果當前沒有事務,則執行與 PROPAGATION_REQUIRED類似的操作

通過分析代碼得出,二手財務系統使用的是Spring默認傳播機制:

PROPAGATION_REQUIRED,這就意味着在收入的拆分邏輯中,事務2是不起作用的,也就意味着Redis鎖是在事務1內部生效的。由於Redis鎖是在事務1內部生效的,也就無法起到控制事務的作用。在高併發狀態下,就會出現兩個事務同時處理數據的情況。

根據MVCC原理,我們再來回顧一下開始發現的可重複讀的問題:事務1開始後,事務2就會拷貝一份數據用作select,而這份數據就有可能是事務1未提交的。

對整個問題進行流程分析後,梳理結果如下圖。由於沒有設置鎖,步驟4和步驟6返回的待分配明細和已分配明細是一模一樣的,也就導致兩個機器填充的是同一份帶填充明細。

4. 處理方案

認識到了問題的本質,給出的修復方案就簡單了。對此,我們針對事務和鎖的處理,提出了兩種解決方法。

4.1 修改事務傳播機制

既然內部的事務由於默認的傳播機制沒有生效,那可以將傳播機制改爲PROPAGATION_REQUIRES_NEW,來保證嵌套在內部的事務2可以正常執行,能夠在Redis鎖釋放之前提交數據。

修改代碼如下所示:

@Transactional("transactionManager")
public void consume(Long id) throws Exception {
    lock.tryLock(LockKeyGenerator.gen("Receivable", item.getContractId()),
        new ILockBiz<Object>() {
            reconciliationSuccessWithTrans();
        }
}

@Transactional(transactionManager = "transactionManager", propagation = Propagation.REQUIRES_NEW)
public void reconciliationSuccessWithTrans() {
    //收入拆分邏輯
    ...
}

4.2 修改Redis鎖生效範圍

首先,事務2沒有生效,可以直接刪除;而第一次修復是將Redis鎖加在了事務的內部,這本身就是會導致髒讀的問題,因此將Redis鎖放到事務的外部即可。

修復後的邏輯如下圖所示:

修改代碼如下所示:

@Transactional("transactionManager")
public void consume(Long id) throws Exception {
    lock.tryLock(LockKeyGenerator.gen("Receivable", item.getContractId()),
        new ILockBiz<Object>() {
            //其他邏輯
            ...
            reconciliationSuccessWithTrans();
        }
}
public void reconciliationSuccessWithTrans() {
    //收入拆分邏輯
    ...
}

當然,要根據具體的業務場景選擇解決方案。由於財務系統在兩個事務的差集部分仍存在數據的提交,將事務設置成PROPAGATION_REQUIRES_NEW的傳播機制,雖然可以保證內嵌事務回滾引發外層事務回滾,但是外層事務的回滾不會影響內嵌事務,所以需要評估是否會導致數據不一致。此外通過對比邏輯的修改量,我們最終選擇了第二種修復方法。

5. 總結

讓我們再次回顧下問題原因和解決過程:

  1. 首次發現問題

    經過初步排查,猜測是由於併發導致

  2. 首次修改問題

    通過加鎖來保證各個事務是順序執行的

  3. 再次出現問題

    通過分析發現導致的原因:

    a. Spring 傳播機制,導致了內部事務無效,鎖僅在外部事務的內部生效,不能控制事務順序執行;

    b. MySQL 默認的隔離級別,導致後起事務讀取使用的是前面事務的未提交數據;

  4. 最終解決問題

    方案一:將鎖提前,設置在外部事務外面,保證對事務的控制;

    方案二:修改Spring事務隔離級別,保證內部事務可以獨立生效,同時也可以保證鎖的作用

大家平時在對數據庫進行寫操作時,一定要注意事務的處理,這次問題就是由於歷史邏輯設計不合理所導致的。

隨着公司業務量的增加,這種高併發的問題會暴露得更多。因此在編程時,我們一定要鍛煉出 良好的高併發思維,做到未雨綢繆徹桑土、御冬旨蓄備桃諸

本文轉載自公衆號貝殼產品技術(ID:beikeTC)。

原文鏈接

https://mp.weixin.qq.com/s?__biz=MzIyMTg0OTExOQ==&mid=2247485712&idx=3&sn=80cf6470327a02228ae90d5e982f39ba&chksm=e8373a60df40b3769b96c27345847d53d632966fd73396295f8bbea0c299ae0005ac1e363184&scene=27#wechat_redirect

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