業務冪等性設計的六種方案
現如今很多系統都會基于分布式或微服務思想完成對系統的架構設計。那么在這一個系統中,就會存在若干個微服務,而且服務間也會產生相互通信調用。
那么既然產生了服務調用,就必然會存在服務調用延遲或失敗的問題。當出現這種問題,服務端會進行重試等操作或客戶端有可能會進行多次點擊提交。在存在重復請求的場景中(如支付交易),為確保系統最終處理結果的一致性并避免資損風險,必須通過業務冪等性設計保障數據操作的唯一性。
什么叫冪等
冪等(Idempotence) 是計算機科學和分布式系統中的核心概念,指在特定上下文中,對同一操作進行多次執行所產生的影響,與僅執行一次該操作的影響完全相同。無論該操作被調用一次還是多次,系統的最終狀態始終保持一致,資源狀態或業務結果不會因為重復調用而發生額外改變。
冪等用數學語言表達就是:f(f(x))=f(x)
圖片
事故:轉賬無冪等、交易無冪等、發優惠券無冪等,都會造成不小的事故。
冪等性設計主要從兩個維度進行考慮:空間、時間。
- 空間:定義了冪等的范圍,如生成訂單的話,不允許出現重復下單。
- 時間:定義冪等的有效期。有些業務需要永久性保證冪等,如下單、支付等。而部分業務只要保證一段時間冪等即可。
業務問題拋出
在業務開發與分布式系統設計中,有非常多的場景需要考慮冪等性的問題,如:
- 當用戶購物進行下單操作,用戶操作多次,但訂單系統對于本次操作只能產生一個訂單。
- 當用戶對訂單進行付款,支付系統不管出現什么問題,應該只對用戶扣一次款。
- 當支付成功對庫存扣減時,庫存系統對訂單中商品的庫存數量也只能扣減一次。
- 當對商品進行發貨時,也需保證物流系統有且只能發一次貨。
但是一旦考慮冪等后,服務邏輯務必會變的更加復雜。因此是否要考慮冪等,需要根據具體業務場景具體分析。
此處以下單減庫存為例,當用戶生成訂單成功后,會對訂單中商品進行扣減庫存。 訂單服務會調用庫存服務進行庫存扣減。庫存服務會完成具體扣減實現:
圖片
如果出現調用超時,如網絡抖動,雖然庫存服務執行成功了,但結果并沒有在指定時間內返回,則訂單服務會進行重試。那就會出現問題,此時出現庫存扣減兩次的問題。 對于這種問題,就需要考慮冪等性設計。
冪等設計實現
方案一:數據庫唯一索引
在保存數據前,可以先 select 一下數據是否存在。如果數據已存在,說明是重復數據,則不再寫入數據,如果數據不存在,則執行 insert 操作。如果 insert 成功,則直接返回成功,如果 insert 產生主鍵沖突異常,則捕獲異常進行處理。
但在高并發的場景下,可能會出現兩個請求 select 的時候,都沒有查到數據,然后都執行了 insert 操作,所以此時會有重復數據產生,因此在數據庫中,我們需要添加唯一索引來保證冪等,唯一索引是不會引起重復數據的兜底策略。
方案二:防重表機制
防重表機制與唯一索引機制是相同的原理,只不過是單獨建一個防重表,防重表也必須引入唯一索引,而且防重表與業務表必須在同一數據庫,并且操作要在同一個事務中。
防重表機制的主要流程:把唯一主鍵插入防重表,再進行業務操作,且它們處于同一個事務中。當重復請求時,因為防重表有唯一約束,導致請求失敗,可以避免冪等問題。
注意防重表和業務表應該在同一個庫中,這樣就保證處在一個事務中,即使業務操作失敗,也會把防重表的數據回滾。保證了數據的一致性。
該方案也是比較常用的,防重表跟業務無關,很多業務可以共用同一個防重表,只要規劃好唯一主鍵即可。
圖片
方案三:數據庫樂觀鎖
樂觀鎖實現的方式有兩種:基于版本號、基于條件。但是實現思想都是基于行鎖來實現的。
基于版本號實現
通過為表增加一個 “version” 字段來實現。讀取出數據時,將此版本號一同讀出,之后更新時,對此版本號加一。此時,將提交數據的版本號與對應記錄的當前版本號進行比對,如果提交的版本號等于當前版本號,則予以更新,否則認為是過期數據。
圖片
基于條件實現
版本號控制在并發場景中雖然能保證數據一致性,但在高并發庫存扣減的場景下存在體驗問題:當多個用戶同時查詢到可售庫存后,只有基于版本號的最新請求能扣減成功,這會導致一些用戶看似有庫存卻最終下單失敗。
從業務角度而言,只要確保庫存實際不發生超賣即可,此時更推薦直接通過數據庫條件控制:
update tb_stock set amount=amount-#{num}
where goods_id=#{goodsId} and amount-#{num}>=0"
總結:在競爭不激烈,出現并發沖突幾率較小時,推薦使用樂觀鎖。但是,樂觀鎖的每次沖突檢測都需要與數據庫交互,頻繁的更新操作仍會對數據庫產生一定壓力。此外,在高并發場景下,大量事務競爭可能導致數據庫連接池耗盡或成為性能瓶頸。
方案四:悲觀鎖
悲觀鎖的實現,往往依靠數據庫提供的鎖機制,具有強烈的獨占和排他性。
通過 for update 可以實現排它鎖;
select * from account where id = 123 for update;
悲觀鎖在同一事務操作過程中,鎖住了一行數據。別的請求過來只能等待,如果當前事務耗時比較長,就很影響接口性能。所以一般不建議用悲觀鎖做這個事情。
方案五:防重 Token 令牌
采用 Token 機制確保冪等性是一種廣泛應用的解決方案,能夠覆蓋絕大多數業務場景。該方案通過前后端協作實現。此方案包含兩個請求階段:
- 客戶端請求服務端申請獲取 token。
- 客戶端攜帶 token 再次請求,服務端校驗 token 后進行操作。
圖片
整體流程如下:
- 服務端提供獲取 token 接口,供客戶端進行使用。服務端生成 token 后,如果當前為分布式架構,將 token 存放于 redis 中(一般會設置一個過期時間),如果是單體架構,可以保存在本地緩存。
- 當客戶端獲取到 token 后,會攜帶著 token 發起請求。
- 服務端接收到客戶端請求后,首先會判斷該 token 在 redis 中是否存在。如果存在,則完成進行業務處理,業務處理完成后,再刪除 token。如果不存在,代表當前請求是重復請求,直接向客戶端返回對應標識。
存在問題
但是現在有一個問題,當前是先執行業務再刪除 token。在高并發下,很有可能出現第一次訪問時 token 存在,完成具體業務操作。但在還沒有刪除 token 時,客戶端又攜帶 token發起請求,此時,因為 token 還存在,第二次請求也會驗證通過,執行具體業務操作。
針對該問題,我們提出兩種解決方案進行探討:
第一種方案:對于業務代碼執行和刪除 token 整體加線程鎖。 當后續線程再來訪問時,則阻塞排隊。
第二種方案:借助 redis 單線程和 incr 是原子性的特點。當第一次獲取 token 時,以 token 作為 key,對其進行自增。然后將 token 進行返回,當客戶端攜帶 token 訪問執行業務代碼時,對于判斷 token 是否存在不用刪除,而是對其繼續 incr。 如果 incr 后的返回值為 2。則是一個合法請求允許執行,如果是其他值,則代表是非法請求,直接返回。
圖片
前面提到的都是先執行業務再刪除 token,那如果先刪除 token 再執行業務呢?其實也會存在問題,假設具體業務代碼執行超時或失敗,沒有向客戶端返回明確結果,那客戶端就很有可能會進行重試,但此時之前的 token 已經被刪除了,則會被認為是重復請求,不再進行業務處理。
圖片
這種方案無需進行額外處理,一個 token 只能代表一次請求。 一旦業務執行出現異常,則讓客戶端重新獲取令牌,重新發起一次訪問即可。推薦使用先刪除 token 方案。
但是無論先刪 token 還是后刪 token,都會有一個相同的問題。每次業務請求都會產生一個額外的請求去獲 token。但是,業務失敗或超時,在生產環境下,一萬個里最多也就十個左右會失敗,那為了這十來個請求,讓其他九千九百多個請求都產生額外請求,就有一些得不償失了。雖然 redis 性能好,但是這也是一種資源的浪費。
方案六:分布式鎖
分布式鎖實現冪等性的邏輯就是,請求過來時,先去嘗試獲得分布式鎖,如果獲得成功,就執行業務邏輯,反之獲取失敗的話,就舍棄請求直接返回成功。
分布式鎖可以使用 Redis,也可以使用 ZooKeeper,Redis 相對來說會更加輕量級。
Redis 分布式鎖,可以使用命令SETNX + 唯一流水號
實現,分布式鎖的 key 必須為業務的唯一標識。
Redis 執行設置 key 的動作時,要設置過期時間,這個過期時間不能太短,太短攔截不了重復請求,也不能設置太長,會占存儲空間。