我們一起聊聊冪等設(shè)計(jì)
前言
大家好,我是程序員田螺。今天我們一起來(lái)聊聊冪等設(shè)計(jì)。
- 什么是冪等
- 為什么需要冪等
- 接口超時(shí),如何處理呢?
- 如何設(shè)計(jì)冪等?
- 實(shí)現(xiàn)冪等的8種方案
- HTTP的冪等
1. 什么是冪等?
冪等是一個(gè)數(shù)學(xué)與計(jì)算機(jī)科學(xué)概念。
- 在數(shù)學(xué)中,冪等用函數(shù)表達(dá)式就是:f(x) = f(f(x))。比如求絕對(duì)值的函數(shù),就是冪等的,abs(x) = abs(abs(x))。
- 計(jì)算機(jī)科學(xué)中,冪等表示一次和多次請(qǐng)求某一個(gè)資源應(yīng)該具有同樣的副作用,或者說(shuō),多次請(qǐng)求所產(chǎn)生的影響與一次請(qǐng)求執(zhí)行的影響效果相同。
2. 為什么需要冪等
舉個(gè)例子:
我們開(kāi)發(fā)一個(gè)轉(zhuǎn)賬功能,假設(shè)我們調(diào)用下游接口超時(shí)了。一般情況下,超時(shí)可能是網(wǎng)絡(luò)傳輸丟包的問(wèn)題,也可能是請(qǐng)求時(shí)沒(méi)送到,還有可能是請(qǐng)求到了,返回結(jié)果卻丟了。這時(shí)候我們是否可以重試呢?如果重試的話,是否會(huì)多轉(zhuǎn)了一筆錢呢?
轉(zhuǎn)賬超時(shí)
當(dāng)前互聯(lián)網(wǎng)的系統(tǒng)幾乎都是解耦隔離后,會(huì)存在各個(gè)不同系統(tǒng)的相互遠(yuǎn)程調(diào)用。調(diào)用遠(yuǎn)程服務(wù)會(huì)有三個(gè)狀態(tài):成功,失敗,或者超時(shí)。前兩者都是明確的狀態(tài),而超時(shí)則是未知狀態(tài)。我們轉(zhuǎn)賬超時(shí)的時(shí)候,如果下游轉(zhuǎn)賬系統(tǒng)做好冪等控制,我們發(fā)起重試,那即可以保證轉(zhuǎn)賬正常進(jìn)行,又可以保證不會(huì)多轉(zhuǎn)一筆。
其實(shí)除了轉(zhuǎn)賬這個(gè)例子,日常開(kāi)發(fā)中,還有很多很多例子需要考慮冪等。比如:
- MQ(消息中間件)消費(fèi)者讀取消息時(shí),有可能會(huì)讀取到重復(fù)消息。(重復(fù)消費(fèi))
- 比如提交form表單時(shí),如果快速點(diǎn)擊提交按鈕,可能產(chǎn)生了兩條一樣的數(shù)據(jù)(前端重復(fù)提交)
3. 接口超時(shí)了,到底如何處理?
如果我們調(diào)用下游接口超時(shí)了,我們應(yīng)該怎么處理呢?
有兩種方案處理:
- 方案一:就是下游系統(tǒng)提供一個(gè)對(duì)應(yīng)的查詢接口。如果接口超時(shí)了,先查下對(duì)應(yīng)的記錄,如果查到是成功,就走成功流程,如果是失敗,就按失敗處理。
拿我們的轉(zhuǎn)賬例子來(lái)說(shuō),轉(zhuǎn)賬系統(tǒng)提供一個(gè)查詢轉(zhuǎn)賬記錄的接口,如果渠道系統(tǒng)調(diào)用轉(zhuǎn)賬系統(tǒng)超時(shí)時(shí),渠道系統(tǒng)先去查詢一下這筆記錄,看下這筆轉(zhuǎn)賬記錄成功還是失敗,如果成功就走成功流程,失敗再重試發(fā)起轉(zhuǎn)賬。
方案二:下游接口支持冪等,上游系統(tǒng)如果調(diào)用超時(shí),發(fā)起重試即可。
兩種方案都是挺不錯(cuò)的,但是如果是MQ重復(fù)消費(fèi)的場(chǎng)景,方案一處理并不是很妥,所以,我們還是要求下游系統(tǒng)對(duì)外接口支持冪等。
4. 如何設(shè)計(jì)冪等
既然這么多場(chǎng)景需要考慮冪等,那我們?nèi)绾卧O(shè)計(jì)冪等呢?
冪等意味著一條請(qǐng)求的唯一性。不管是你哪個(gè)方案去設(shè)計(jì)冪等,都需要一個(gè)全局唯一的ID,去標(biāo)記這個(gè)請(qǐng)求是獨(dú)一無(wú)二的。
- 如果你是利用唯一索引控制冪等,那唯一索引是唯一的
- 如果你是利用數(shù)據(jù)庫(kù)主鍵控制冪等,那主鍵是唯一的
- 如果你是悲觀鎖的方式,底層標(biāo)記還是全局唯一的ID
4.1 全局的唯一性ID
全局唯一性ID,我們?cè)趺慈ド赡?你可以回想下,數(shù)據(jù)庫(kù)主鍵Id怎么生成的呢?
是的,我們可以使用UUID,但是UUID的缺點(diǎn)比較明顯,它字符串占用的空間比較大,生成的ID過(guò)于隨機(jī),可讀性差,而且沒(méi)有遞增。
我們還可以使用雪花算法(Snowflake) 生成唯一性ID。
雪花算法是一種生成分布式全局唯一ID的算法,生成的ID稱為Snowflake IDs。這種算法由Twitter創(chuàng)建,并用于推文的ID。
一個(gè)Snowflake ID有64位。
- 第1位:Java中l(wèi)ong的最高位是符號(hào)位代表正負(fù),正數(shù)是0,負(fù)數(shù)是1,一般生成ID都為正數(shù),所以默認(rèn)為0。
- 接下來(lái)前41位是時(shí)間戳,表示了自選定的時(shí)期以來(lái)的毫秒數(shù)。
- 接下來(lái)的10位代表計(jì)算機(jī)ID,防止沖突。
- 其余12位代表每臺(tái)機(jī)器上生成ID的序列號(hào),這允許在同一毫秒內(nèi)創(chuàng)建多個(gè)Snowflake ID。
雪花算法
當(dāng)然,全局唯一性的ID,還可以使用百度的Uidgenerator,或者美團(tuán)的Leaf。
4.2 冪等設(shè)計(jì)的基本流程
冪等處理的過(guò)程,說(shuō)到底其實(shí)就是過(guò)濾一下已經(jīng)收到的請(qǐng)求,當(dāng)然,請(qǐng)求一定要有一個(gè)全局唯一的ID標(biāo)記哈。然后,怎么判斷請(qǐng)求是否之前收到過(guò)呢?把請(qǐng)求儲(chǔ)存起來(lái),收到請(qǐng)求時(shí),先查下存儲(chǔ)記錄,記錄存在就返回上次的結(jié)果,不存在就處理請(qǐng)求。
一般的冪等處理就是這樣啦,如下:
5. 實(shí)現(xiàn)冪等的8種方案
冪等設(shè)計(jì)的基本流程都是類似的,我們簡(jiǎn)簡(jiǎn)單單來(lái)過(guò)一下冪等實(shí)現(xiàn)的8中方案哈
5.1 select+insert+主鍵/唯一索引沖突
日常開(kāi)發(fā)中,為了實(shí)現(xiàn)交易接口冪等,我是這樣實(shí)現(xiàn)的:
交易請(qǐng)求過(guò)來(lái),我會(huì)先根據(jù)請(qǐng)求的唯一流水號(hào) bizSeq字段,先select一下數(shù)據(jù)庫(kù)的流水表
- 如果數(shù)據(jù)已經(jīng)存在,就攔截是重復(fù)請(qǐng)求,直接返回成功;
- 如果數(shù)據(jù)不存在,就執(zhí)行insert插入,如果insert成功,則直接返回成功,如果insert產(chǎn)生主鍵沖突異常,則捕獲異常,接著直接返回成功。
流程圖如下
偽代碼如下:
- /**
- * 冪等處理
- */
- Rsp idempotent(Request req){
- Object requestRecord =selectByBizSeq(bizSeq);
- if(requestRecord !=null){
- //攔截是重復(fù)請(qǐng)求
- log.info("重復(fù)請(qǐng)求,直接返回成功,流水號(hào):{}",bizSeq);
- return rsp;
- }
- try{
- insert(req);
- }catch(DuplicateKeyException e){
- //攔截是重復(fù)請(qǐng)求,直接返回成功
- log.info("主鍵沖突,是重復(fù)請(qǐng)求,直接返回成功,流水號(hào):{}",bizSeq);
- return rsp;
- }
- //正常處理請(qǐng)求
- dealRequest(req);
- return rsp;
- }
為什么前面已經(jīng)select查詢了,還需要try...catch...捕獲重復(fù)異常呢?
是因?yàn)楦卟l(fā)場(chǎng)景下,兩個(gè)請(qǐng)求去select的時(shí)候,可能都沒(méi)查到,然后都走到insert的地方啦。
當(dāng)然,用唯一索引代替數(shù)據(jù)庫(kù)主鍵也是可以的哈,都是全局唯一的ID即可。
5.2. 直接insert + 主鍵/唯一索引沖突
在5.1方案中,都會(huì)先查一下流水表的交易請(qǐng)求,判斷是否存在,然后不存在再插入請(qǐng)求記錄。如果重復(fù)請(qǐng)求的概率比較低的話,我們可以直接插入請(qǐng)求,利用主鍵/唯一索引沖突,去判斷是重復(fù)請(qǐng)求。
流程圖如下:
偽代碼如下:
- /**
- * 冪等處理
- */
- Rsp idempotent(Request req){
- try{
- insert(req);
- }catch(DuplicateKeyException e){
- //攔截是重復(fù)請(qǐng)求,直接返回成功
- log.info("主鍵沖突,是重復(fù)請(qǐng)求,直接返回成功,流水號(hào):{}",bizSeq);
- return rsp;
- }
- //正常處理請(qǐng)求
- dealRequest(req);
- return rsp;
- }
溫馨提示 :
大家別搞混哈,防重和冪等設(shè)計(jì)其實(shí)是有區(qū)別的。防重主要為了避免產(chǎn)生重復(fù)數(shù)據(jù),把重復(fù)請(qǐng)求攔截下來(lái)即可。而冪等設(shè)計(jì)除了攔截已經(jīng)處理的請(qǐng)求,還要求每次相同的請(qǐng)求都返回一樣的效果。不過(guò)呢,很多時(shí)候,它們的處理流程可以是類似的。
5.3 狀態(tài)機(jī)冪等
很多業(yè)務(wù)表,都是有狀態(tài)的,比如轉(zhuǎn)賬流水表,就會(huì)有0-待處理,1-處理中、2-成功、3-失敗狀態(tài)。轉(zhuǎn)賬流水更新的時(shí)候,都會(huì)涉及流水狀態(tài)更新,即涉及狀態(tài)機(jī) (即狀態(tài)變更圖)。我們可以利用狀態(tài)機(jī)實(shí)現(xiàn)冪等,一起來(lái)看下它是怎么實(shí)現(xiàn)的。
比如轉(zhuǎn)賬成功后,把處理中的轉(zhuǎn)賬流水更新為成功狀態(tài),SQL這么寫:
- update transfr_flow set status=2 where biz_seq=‘666’ and status=1;
簡(jiǎn)要流程圖如下:
偽代碼實(shí)現(xiàn)如下:
- Rsp idempotentTransfer(Request req){
- String bizSeq = req.getBizSeq();
- int rows= "update transfr_flow set status=2 where biz_seq=#{bizSeq} and status=1;"
- if(rows==1){
- log.info(“更新成功,可以處理該請(qǐng)求”);
- //其他業(yè)務(wù)邏輯處理
- return rsp;
- }else if(rows==0){
- log.info(“更新不成功,不處理該請(qǐng)求”);
- //不處理,直接返回
- return rsp;
- }
- log.warn("數(shù)據(jù)異常")
- return rsp:
- }
狀態(tài)機(jī)是怎么實(shí)現(xiàn)冪等的呢?
第1次請(qǐng)求來(lái)時(shí),bizSeq流水號(hào)是 666,該流水的狀態(tài)是處理中,值是 1,要更新為2-成功的狀態(tài),所以該update語(yǔ)句可以正常更新數(shù)據(jù),sql執(zhí)行結(jié)果的影響行數(shù)是1,流水狀態(tài)最后變成了2。
第2請(qǐng)求也過(guò)來(lái)了,如果它的流水號(hào)還是 666,因?yàn)樵摿魉疇顟B(tài)已經(jīng)2-成功的狀態(tài)了,所以更新結(jié)果是0,不會(huì)再處理業(yè)務(wù)邏輯,接口直接返回。
5.4 抽取防重表
5.1和5.2的方案,都是建立在業(yè)務(wù)流水表上bizSeq的唯一性上。很多時(shí)候,我們業(yè)務(wù)表唯一流水號(hào)希望后端系統(tǒng)生成,又或者我們希望防重功能與業(yè)務(wù)表分隔開(kāi)來(lái),這時(shí)候我們可以單獨(dú)搞個(gè)防重表。當(dāng)然防重表也是利用主鍵/索引的唯一性,如果插入防重表沖突即直接返回成功,如果插入成功,即去處理請(qǐng)求。
5.5 token令牌
token 令牌方案一般包括兩個(gè)請(qǐng)求階段:
客戶端請(qǐng)求申請(qǐng)獲取token,服務(wù)端生成token返回
客戶端帶著token請(qǐng)求,服務(wù)端校驗(yàn)token
流程圖如下:
客戶端發(fā)起請(qǐng)求,申請(qǐng)獲取token。
服務(wù)端生成全局唯一的token,保存到redis中(一般會(huì)設(shè)置一個(gè)過(guò)期時(shí)間),然后返回給客戶端。
客戶端帶著token,發(fā)起請(qǐng)求。
服務(wù)端去redis確認(rèn)token是否存在,一般用 redis.del(token)的方式,如果存在會(huì)刪除成功,即處理業(yè)務(wù)邏輯,如果刪除失敗不處理業(yè)務(wù)邏輯,直接返回結(jié)果。
5.6 悲觀鎖(如select for update)
什么是悲觀鎖?
通俗點(diǎn)講就是很悲觀,每次去操作數(shù)據(jù)時(shí),都覺(jué)得別人中途會(huì)修改,所以每次在拿數(shù)據(jù)的時(shí)候都會(huì)上鎖。官方點(diǎn)講就是,共享資源每次只給一個(gè)線程使用,其它線程阻塞,用完后再把資源轉(zhuǎn)讓給其它線程。
悲觀鎖如何控制冪等的呢?就是加鎖呀,一般配合事務(wù)來(lái)實(shí)現(xiàn)。
舉個(gè)更新訂單的業(yè)務(wù)場(chǎng)景:
假設(shè)先查出訂單,如果查到的是處理中狀態(tài),就處理完業(yè)務(wù),再然后更新訂單狀態(tài)為完成。如果查到訂單,并且是不是處理中的狀態(tài),則直接返回
整體的偽代碼如下:
- begin; # 1.開(kāi)始事務(wù)
- select * from order where order_id='666' # 查詢訂單,判斷狀態(tài)
- if(status !=處理中){
- //非處理中狀態(tài),直接返回;
- return ;
- }
- ## 處理業(yè)務(wù)邏輯
- update order set status='完成' where order_id='666' # 更新完成
- commit; # 5.提交事務(wù)
這種場(chǎng)景是非原子操作的,在高并發(fā)環(huán)境下,可能會(huì)造成一個(gè)業(yè)務(wù)被執(zhí)行兩次的問(wèn)題:
當(dāng)一個(gè)請(qǐng)求A在執(zhí)行中時(shí),而另一個(gè)請(qǐng)求B也開(kāi)始狀態(tài)判斷的操作。因?yàn)檎?qǐng)求A還未來(lái)得及更改狀態(tài),所以請(qǐng)求B也能執(zhí)行成功,這就導(dǎo)致一個(gè)業(yè)務(wù)被執(zhí)行了兩次。
可以使用數(shù)據(jù)庫(kù)悲觀鎖(select ...for update)解決這個(gè)問(wèn)題.
- begin; # 1.開(kāi)始事務(wù)
- select * from order where order_id='666' for update # 查詢訂單,判斷狀態(tài),鎖住這條記錄
- if(status !=處理中){
- //非處理中狀態(tài),直接返回;
- return ;
- }
- ## 處理業(yè)務(wù)邏輯
- update order set status='完成' where order_id='666' # 更新完成
- commit; # 5.提交事務(wù)
這里面order_id需要是索引或主鍵哈,要鎖住這條記錄就好,如果不是索引或者主鍵,會(huì)鎖表的!
悲觀鎖在同一事務(wù)操作過(guò)程中,鎖住了一行數(shù)據(jù)。別的請(qǐng)求過(guò)來(lái)只能等待,如果當(dāng)前事務(wù)耗時(shí)比較長(zhǎng),就很影響接口性能。所以一般不建議用悲觀鎖做這個(gè)事情。
5.7 樂(lè)觀鎖
悲觀鎖有性能問(wèn)題,可以試下樂(lè)觀鎖。
什么是樂(lè)觀鎖?
樂(lè)觀鎖在操作數(shù)據(jù)時(shí),則非常樂(lè)觀,認(rèn)為別人不會(huì)同時(shí)在修改數(shù)據(jù),因此樂(lè)觀鎖不會(huì)上鎖。只是在執(zhí)行更新的時(shí)候判斷一下,在此期間別人是否修改了數(shù)據(jù)。
怎樣實(shí)現(xiàn)樂(lè)觀鎖呢?
就是給表的加多一列version版本號(hào),每次更新記錄version都升級(jí)一下(version=version+1)。具體流程就是先查出當(dāng)前的版本號(hào)version,然后去更新修改數(shù)據(jù)時(shí),確認(rèn)下是不是剛剛查出的版本號(hào),如果是才執(zhí)行更新
比如,我們更新前,先查下數(shù)據(jù),查出的版本號(hào)是version =1
- select order_id,version from order where order_id='666';
然后使用version =1和訂單Id一起作為條件,再去更新
- update order set version = version +1,status='P' where order_id='666' and version =1
最后更新成功,才可以處理業(yè)務(wù)邏輯,如果更新失敗,默認(rèn)為重復(fù)請(qǐng)求,直接返回。
流程圖如下:
為什么版本號(hào)建議自增的呢?
因?yàn)闃?lè)觀鎖存在ABA的問(wèn)題,如果version版本一直是自增的就不會(huì)出現(xiàn)ABA的情況啦。
5.8 分布式鎖
分布式鎖實(shí)現(xiàn)冪等性的邏輯就是,請(qǐng)求過(guò)來(lái)時(shí),先去嘗試獲得分布式鎖,如果獲得成功,就執(zhí)行業(yè)務(wù)邏輯,反之獲取失敗的話,就舍棄請(qǐng)求直接返回成功。執(zhí)行流程如下圖所示:
分布式鎖可以使用Redis,也可以使用ZooKeeper,不過(guò)還是Redis相對(duì)好點(diǎn),因?yàn)檩^輕量級(jí)。
Redis分布式鎖,可以使用命令SET EX PX NX + 唯一流水號(hào)實(shí)現(xiàn),分布式鎖的key必須為業(yè)務(wù)的唯一標(biāo)識(shí)哈
Redis執(zhí)行設(shè)置key的動(dòng)作時(shí),要設(shè)置過(guò)期時(shí)間哈,這個(gè)過(guò)期時(shí)間不能太短,太短攔截不了重復(fù)請(qǐng)求,也不能設(shè)置太長(zhǎng),會(huì)占存儲(chǔ)空間。
6. HTTP的冪等
我們的接口,一般都是基于http的,所以我們?cè)賮?lái)聊聊Http的冪等吧。HTTP 請(qǐng)求方法主要有以下這幾種,我們看下各個(gè)接口是否都是冪等的。
- GET方法
- HEAD方法
- OPTIONS方法
- DELETE方法
- POST 方法
- PUT方法
6.1 GET 方法
HTTP 的GET方法用于獲取資源,可以類比于數(shù)據(jù)庫(kù)的select查詢,不應(yīng)該有副作用,所以是冪等的。它不會(huì)改變資源的狀態(tài),不論你調(diào)用一次還是調(diào)用多次,效果一樣的,都沒(méi)有副作用。
如果你的GET方法是獲取最近最新的新聞,不同時(shí)間點(diǎn)調(diào)用,返回的資源內(nèi)容雖然不一樣,但是最終對(duì)資源本質(zhì)是沒(méi)有影響的哈,所以還是冪等的。
6.2 HEAD 方法
HTTP HEAD和GET有點(diǎn)像,主要區(qū)別是HEAD不含有呈現(xiàn)數(shù)據(jù),而僅僅是HTTP的頭信息,所以它也是冪等的。如果想判斷某個(gè)資源是否存在,很多人會(huì)使用GET,實(shí)際上用HEAD則更加恰當(dāng)。即HEAD方法通常用來(lái)做探活使用。
6.3 OPTIONS方法
HTTP OPTIONS 主要用于獲取當(dāng)前URL所支持的方法,也是有點(diǎn)像查詢,因此也是冪等的。
6.4 DELETE方法
HTTP DELETE 方法用于刪除資源,它是的冪等的。比如我們要?jiǎng)h除id=666的帖子,一次執(zhí)行和多次執(zhí)行,影響的效果是一樣的呢。
6.5 POST 方法
HTTP POST 方法用于創(chuàng)建資源,可以類比于提交信息,顯然一次和多次提交是有副作用,執(zhí)行效果是不一樣的,不滿足冪等性。
比如:POST http://www.tianluo.com/articles的語(yǔ)義是在http://www.tianluo.com/articles下創(chuàng)建一篇帖子,HTTP 響應(yīng)中應(yīng)包含帖子的創(chuàng)建狀態(tài)以及帖子的 URI。兩次相同的POST請(qǐng)求會(huì)在服務(wù)器端創(chuàng)建兩份資源,它們具有不同的 URI;所以,POST方法不具備冪等性。
6.6 PUT 方法
HTTP PUT 方法用于創(chuàng)建或更新操作,所對(duì)應(yīng)的URI是要?jiǎng)?chuàng)建或更新的資源本身,有副作用,它應(yīng)該滿足冪等性。
比如:PUT http://www.tianluo.com/articles/666的語(yǔ)義是創(chuàng)建或更新 ID 為666的帖子。對(duì)同一 URI 進(jìn)行多次 PUT 的副作用和一次 PUT 是相同的;因此,PUT 方法具有冪等性。
參考資料
[1]彈力設(shè)計(jì)篇之“冪等性設(shè)計(jì)”: https://time.geekbang.org/column/article/4050