本文是一次工作中對併發問題的處理案例,問題發生在快遞分揀的流程中,我儘可能將業務背景簡化,讓大家只關註併發問題本身。 ...
1. 問題背景
問題發生在快遞分揀的流程中,我儘可能將業務背景簡化,讓大家只關註併發問題本身。
分揀業務針對每個快遞包裹都會生成一個任務,我們稱它為 task。task 中有兩個欄位需要關註,一個是分揀中發生的異常(exp_type),另一個是分揀任務的狀態(status)。另外,需要關註分揀狀態上報介面,通過它來記錄分揀過程中的異常和狀態變更。
一般情況下,分揀機在分揀異常發生時會及時調用介面上報,在分揀完成時調用介面來標記為完成狀態,兩次介面調用的時間間隔較長,不會發生併發問題。
但是有一種特殊的分揀機,它不會在異常發生時及時上報,而是在分揀完成時將分揀過程中發生的異常和分揀結果一起上報,那麼此時分揀狀態上報介面在同一時間內就會有兩次調用,這時便發生了預期外的併發問題。
我們先看下分揀狀態上報介面的執行流程:
- 先查詢到該分揀任務 task,預設情況下 exp_type 和 status 均為預設值0
- 分揀異常修改 task 中的 exp_type,分揀完成修改 status 欄位信息
- 修改完成將 task 寫入
併發問題發生的圖示如下:
資料庫初始值為1, 0, 0
,分揀異常和分揀完成幾乎同時上報,它們都讀取到該值。分揀異常動作將 exp_type 修改為9,寫入資料庫,此時資料庫值為1, 9, 0
;分揀完成動作將 status 修改為1,寫入資料庫,使得資料庫最終值為1, 0, 1
,它將異常欄位的值覆蓋掉了。正常情況下,最終值應該為1, 9, 1
,分揀完成動作應該讀取到分揀異常完成後的值1, 9, 0
後再進行修改才對。
2. 解決方案
發生這個問題的原因很容易就能發現:兩個事務同時執行讀取-修改-寫入序列,其中一個寫操作在沒有合併另一個寫操作變更的情況下,直接覆蓋了另一個寫操作的結果,所以導致了數據的丟失。
這種問題是比較典型的丟失更新問題,可以通過對資料庫讀操作加鎖或者改變資料庫的隔離級別為可串列化使事務串列執行的方式進行避免。下麵我會將大家在討論避免丟失更新問題時提出的方案進行介紹,並儘可能的用代碼來表現它們。
2.1 資料庫讀操作加鎖和可串列化隔離級別
我們可以考慮:如果對每條Task數據修改的事務都是在當前事務完成之後才允許後續事務進行修改,使事務串列執行,那麼我們就能夠避免這種情況。比較直接的實現是通過顯式加鎖來實現,如下
select exp_type, status
from task
where id = 1
for update;
先查詢該行數據的事務會獲取到該行數據的排他鎖,後續針對該數據的所有讀寫請求都會被阻塞,直到先前事務執行完將鎖釋放。
這樣通過加鎖的方式實現了事務的串列執行。但是,在為SQL添加加鎖語句時,需要確定是不是為該行數據加鎖而不是鎖住了整個表,如果是後者,那麼可能會造成系統性能嚴重下降,而且還需要關註有哪些業務場景使用到了該SQL,是否存在長時間執行的只讀事務使用,如果存在的話可能會出現因加鎖導致延遲和系統性能下降,所以需要謹慎的評估。
此外,可串列化的資料庫隔離級別也能保證事務的串列執行,不過它針對的是所有事務。一般情況下為了保證性能,我們不會採用這種方案(預設使用MySQL可重覆讀隔離級別)。
MySQL的InnoDB引擎實現可串列化隔離級別採用的是2PL機制:在第一階段事務執行時獲取鎖,第二階段事務執行完成釋放鎖。
2.2 針對業務只修改必要欄位
如果異常狀態請求僅修改 exp_type 欄位,分揀完成僅修改 status 欄位的話,那麼我們可以梳理一下業務邏輯,僅將必要修改的欄位寫入資料庫,這樣就不會發生丟失更新的異常,如下代碼所示:
// 處理異常狀態請求,封裝修改數據的對象
Task task = new Task();
tast.setId(id);
task.setExpType(expType);
// 更改數據
taskService.updateById(task);
在執行修改數據前,創建一個新的修改對象,並只為其必要修改欄位賦值。但是還需要考慮的是:如果這個業務流程處理已經很複雜了,很可能不清楚該為哪些欄位賦值而導致再發生新的異常,所以採用這種方法需要對業務足夠熟悉,並且在修改完後進行充分的測試。
2.3 分散式鎖
分散式鎖的方法與方法一類似,都是通過加鎖的方式來保證同時只有一個事務執行,區別是方法一的鎖加在了資料庫層,而分散式鎖是藉助Redis來實現。
這種實現方式的好處是鎖的粒度小,發生鎖爭搶僅限於單個包裹,無需像資料庫加鎖一樣去考慮鎖的粒度和對相關業務的影響。偽代碼如下所示:
// 分散式鎖KEY
String distributedKey = String.format(DISTRIBUTED_KEY_PREFIX, packageNo);
try {
// 分散式鎖阻塞同一包裹號的修改
lock(distributedKey);
// 處理業務邏輯
handler();
} finally {
// 執行完解鎖
redissonDistributedLocker.unlock(distributedKey);
}
需要註意,lock()
加鎖方法要保證加鎖失敗或發生其他異常情況不影響業務邏輯的執行,並設定好鎖持有時間和等待鎖的阻塞時間,此外解鎖方法務必添加到finally代碼塊中保證鎖的釋放。
2.4 CAS
CAS是樂觀的解決方案,它一般通過在資料庫中增加時間戳列來記錄上次數據更改的時間,當新的事務執行時,需要比對讀取時該行數據的時間戳和資料庫中保存的時間戳是否一致,以此來判斷事務執行期間是否有其他事務修改過該行數據,只有在沒有發生改變的情況下才允許更新,否則需要重試這個事務。樣例SQL如下所示:
update task
set exp_type = #{expType}, status = #{status}, ts = #{currentTs}
where id = #{id} and ts = #{readTs}
它的原理不難理解,但是實現起來可能會存在困難,因為需要考慮在執行失敗後該如何重試,重試的方式和重試的次數需要根據業務去判斷。
巨人的肩膀
- 《數據密集型應用系統設計》第七章 事務
作者:京東物流 王奕龍
來源:京東雲開發者社區 自猿其說Tech 轉載請註明出處