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