什么是接口冪等性苏章?
冪等是數(shù)學和計算機學的概念请垛,常見于抽象代數(shù)中暑椰,即f(f(x)) = f(x)餐抢。簡單來講就是接口被調(diào)用多次獲得的結(jié)果和接口被調(diào)用一次獲得的結(jié)果是一致的现使。在開發(fā)過程中,有很多操作天生就具有冪等性旷痕,比如數(shù)據(jù)庫的select操作碳锈,無論查詢多少次,與查詢一次的結(jié)果都是一致的欺抗。很多情況的接口冪等性都需要我們自己處理的售碳,特別是在分布式系統(tǒng)中,能不能保證接口冪等性對系統(tǒng)影響是非常大的绞呈。例如下單支付的操作場景贸人,由于分布式系統(tǒng)環(huán)境的網(wǎng)絡復雜性、用戶誤操作佃声、網(wǎng)絡抖動艺智、消息重發(fā)、服務超時導致業(yè)務自動重試等等各種情況都可能造成線上數(shù)據(jù)不一致圾亏,導致事故十拣。
接口冪等性的典型案例
在微服務架構(gòu)下封拧,我們在完成一個訂單流程時經(jīng)常遇到下面的場景:
- 一個訂單創(chuàng)建接口,第一次調(diào)用超時了夭问,然后調(diào)用方重試了一次
- 在訂單創(chuàng)建時泽西,我們需要去扣減庫存,這時接口發(fā)生了超時缰趋,調(diào)用方重試了一次
- 當這筆訂單開始支付捧杉,在支付請求發(fā)出之后,在服務端發(fā)生了扣錢操作秘血,接口響應超時了糠溜,調(diào)用方重試了一次
- 一個訂單狀態(tài)更新接口,調(diào)用方連續(xù)發(fā)送了兩個消息直撤,一個是已創(chuàng)建非竿,一個是已付款。但是你先接收到已付款谋竖,然后又接收到了已創(chuàng)建
- 在支付完成訂單之后红柱,需要發(fā)送一條短信,當一臺機器接收到短信發(fā)送的消息之后蓖乘,處理較慢锤悄。消息中間件又把消息投遞給另外一臺機器處理
解決接口冪等性問題的方案
首先,我們在開發(fā)過程中嘉抒,一旦遇到所謂的高并發(fā)情況零聚,第一時間想到的就是鎖,有的時候認為加上分布式鎖或者單機鎖就可以解決問題了些侍,但是并不是隶症。確實有些時候加上鎖可以解決問題,那么同時也讓程序變成單線程執(zhí)行岗宣,還得注意鎖不要加錯位置蚂会,需要先搞清楚程序同步的臨界區(qū)是什么。否則不但沒能解決問題還降低系統(tǒng)TPS造成性能影響耗式,而說到鎖很多人的第一個反應就是Jdk提供的同步鎖synchronized胁住,一般情況下同步鎖確實能解決多線程訪問臨界區(qū)造成的數(shù)據(jù)安全問題即并發(fā)問題,同步鎖的一般使用方式要么是鎖住整個方法要么是方法內(nèi)部鎖住一個程序片段刊咳,不管哪一種先要明白鎖的是當前這個類的實例對象彪见,即多個線程同時訪問代碼片段時訪問的是同一個對象(如果每個線程都會創(chuàng)建一個新的實例對象的話,加鎖也就毫無意義了)比方說Spring受管的bean娱挨,默認情況下都是單實例的余指,也就是說多線程共享的,這個時候才需要考慮并發(fā)的問題让蕾。而我們平時在做項目的過程中浪规,除了要完成業(yè)務開發(fā)之外或听,還得多想想業(yè)務之外的一些東西比如接口需不需要保證冪等,代碼有沒有很強的擴展性等等笋婿。
接下來舉例分享一下:
假設我們數(shù)據(jù)庫里現(xiàn)在有一張order表誉裆,字段有id,userId,planId(計劃ID),money,createTime;假設有這么一個業(yè)務,前端在用戶下單就是針對某一個計劃進行提交一條數(shù)據(jù)缸濒。那么這個時候我們的業(yè)務偽代碼是這樣的:
public void savePlan(userId,planId,money){
boolean exist = select(userId,planId);
if(exist){
insertOrder(userId,planId,money);
}
}
那么上面的情況下在加同步鎖是可以保證高并發(fā)的情況下訪問不會出現(xiàn)問題足丢,但如果在insert之前沒有先從db中select出來就直接insert了,那么加鎖也是白加庇配,因為鎖的本質(zhì)也是在排隊斩跌,第一個請求執(zhí)行完之后,緊接著等待隊列中的第二個請求一樣會執(zhí)行捞慌。另外一個問題是單機鎖無法解決系統(tǒng)集群或者分布式的場景耀鸦,要知道現(xiàn)在大部分的互聯(lián)網(wǎng)應用都是集群或分布式的,JDK的同步鎖也只能鎖住單個進程啸澡,系統(tǒng)由于負載均衡袖订,并發(fā)的兩個線程不一定就請求到同一臺服務器,所以這種場景下加鎖很大幾率是無效的嗅虏。當然分布式鎖是可以解決這兩個問題洛姑,之前的文章有使用redis實現(xiàn)的分布式鎖,可以參考redis實現(xiàn)分布式鎖(完善版)皮服。在這里采用redis的setnx實現(xiàn)的一個簡易版的鎖,寫一段偽代碼進行演示:
//加鎖
public static boolean acquiredLock(key,expired,timeout,timeUnit){
try(Jedis jedis = getJedis()){
long time = System.nanoTime();
while (System.nanoTime() - time < timeUnit.toNanos(timeout)){
long lock = jedis.setnx(key, UUID.randomUUID().toString());
if (lock == 1) {
jedis.expire(key, expired);
return true;
}
}
}
return false;
}
//解鎖
public static void unLock(key) {
try (Jedis jedis = getJedis()) {
jedis.del(key);
}
}
我們在程序中可以先定義一個字符串常量的key,并根據(jù)實際情況控制好timeout,那么當?shù)谝粋€線程進來的時候拿到了鎖就執(zhí)行下面的業(yè)務楞艾,另一個線程發(fā)現(xiàn)鎖已經(jīng)被拿走了,就執(zhí)行返回失敗或者給個友好的提示“不能重復提交”之類的龄广,偽代碼如下:
public void saveOrder(userId,planId,money){
if(acquiredLock(key,timeout)){
insertOrder(userId,planId,money);
}else{
throw new RuntimeException("不能重復下單")硫眯;
}
}
以上這段代碼初看好像也沒什么問題,但是采用redis來做控制也是有很多坑的,比方說這個超時時間就很不好控制還得考慮redis掛掉了怎么處理蜀细,還要注意解鎖舟铜,搞不好會變成死鎖等等
加鎖這種方案在這里基本上是不適用的,那么該怎么做呢奠衔,其實方案很多,但首先我們得先分析出現(xiàn)數(shù)據(jù)問題的根源才好做出相應的解決方案塘娶。比如客戶端bug归斤,網(wǎng)絡不穩(wěn)定導致的服務超時,app閃退或者人工強退等等都是很常見的問題刁岸。事實上類似這種問題都無法僅僅通過客戶單或者服務端就能解決的脏里,我們項目出現(xiàn)的問題很可能就是服務端和客戶端都沒做處理。其實至少客戶端需要做一個提交之后按鈕的置灰功能吧虹曙,雖然對于很多人來講這沒什么用但是針對大量的小白用戶來說已經(jīng)可以阻止他們誤操作了迫横,所以說接口校驗的原則(請求的合法性,參數(shù)的正確性等)應該是前后端一起做的番舆。
具體的解決方案之一,就是利用db的唯一索引約束結(jié)合客戶端來保證接口的冪等性矾踱。
我們可以在表字段userId和planId加上聯(lián)合唯一索引約束dedup_key恨狈,然后再業(yè)務層顯示捕獲拋出的異常,在多做一層異常的封裝呛讲,這樣就可以返回客戶端友好的提示了禾怠,偽代碼如下:
public void saveOrder(userId,planId,money) throws BusinessException {
try {
insertOrder(userId,planId,money);
}catch (RuntimeException e) {
if (e.getMessage().contains("Duplicate entry")
&& e.getMessage().contains("dedup_key")){
throw new BusinessException ("不能重復下單");
}else{
throw e;//其他類型的異常要往外拋出
}
}
}
上面的方法看起來比較簡潔贝搁,但并不是很好吗氏,如果我們在訂單插入之前做了一些其他關聯(lián)表的插入或編輯的操作,那么如果一旦訂單插入拋出異常雷逆,我們需要將之前的操作全部回滾弦讽。所以上面的方法會額外增加系統(tǒng)的復雜性。 相對更好一點的方法是不在業(yè)務表上加唯一索引膀哲,而是獨立出一張token_table坦袍,通常稱為排重表或者令牌表,表中主要有一個字段unique_key等太,并在字段上建一個unique index捂齐,那么這時候可以使用上面采用過的通用方案即并發(fā)時由數(shù)據(jù)庫自動拋出異常馁蒂,業(yè)務service來捕獲最終返回給客戶端友好的提示镜廉,或者我們還可以利用mysql的insert ignore特性來處理這個問題。我們借助mysql的insert ignore特性嚷那,假如token_table有一個主鍵taskId瞻想,第一次插入一條數(shù)據(jù)压真,會返回1,表示數(shù)據(jù)庫沒有這條數(shù)據(jù)蘑险,第二次插入不會拋出異常滴肿,而是返回0。說明數(shù)據(jù)庫已經(jīng)存在這條數(shù)據(jù)了佃迄,我們就可以給出友好的提示了泼差。那我們的業(yè)務就可以這樣:
- 先使用insert ignore插入一條數(shù)據(jù)到令牌表中,得到返回的值為0或者為1
- 在service方法中無需顯示捕獲異常呵俏,只需判斷第一步獲取到的結(jié)果堆缘,如果大于0則說明是第一次插入此時拿到令牌,則可以往下走普碎,否則拋出重復提交的異常給客戶端提示即可吼肥,代碼也很簡潔,偽代碼如下所示:
public void saveOrder(userId,planId,money){
int token = insert ignore token_table(unique_key) value(uniqueKey);
if(token>0){
insertOrder(userId,planId,money);
}else{
throw new BusinessException ("不能重復下單");
}
}
以上所列出來的方案都是屬于業(yè)務本身存在唯一標示的字段(userId+planId)缀皱,但如果業(yè)務本身不存在這樣的字段來建unique index該怎么處理呢斗这,一般有兩種處理方式,第一種是由客戶端來生成啤斗,而且每次生成之后要cache起來以便下次使用的時候能辨別出是否是重復的請求具體可參考開頭提到的那篇文章表箭,第二種則是由服務端根據(jù)業(yè)務具體情況來統(tǒng)一生成全局標示,做成一個全局的微服務争占,但需要考慮的東西比較多架構(gòu)實現(xiàn)也比較復雜燃逻。
還有一種解決方案是利用數(shù)據(jù)庫的鎖機制來處理即共享讀鎖+普通索引。
總結(jié)一下:
接口冪等性和并發(fā)是有關聯(lián)的臂痕。我們在單節(jié)點的情況下伯襟,解決并發(fā)問題一般都會使用synchronized同步鎖,但是一旦變成分布式就不適用了握童,那么我們可能會考慮使用基于redis實現(xiàn)的分布式鎖姆怪,但是原理也是排隊,然后變成但線程訪問澡绩,很大可能影響效率稽揭,還有就是實現(xiàn)比較麻煩,而且如果redis掛了肥卡,那么就無解了溪掀。所以我們這里說到的接口冪等性也是一樣的,只不過是涉及到的是一個業(yè)務只需要執(zhí)行一次步鉴,由于其他原因提交多次問題揪胃。和并發(fā)問題類似。所以我們解決接口冪等性的方案有幾種:
- 由前后端配合解決氛琢,解決的是與前端交互的時候喊递,前端重復提交問題。
- 服務端之間調(diào)用阳似,上面案例提到的場景
- 消息重復消費骚勘,MQ消息隊列,消息重復消費撮奏。
(1) token機制:提交時客戶端先請求后端獲取token俏讹,服務端生成token先進行緩存,然后返回給客戶端挽荡。接下來藐石,客戶端帶著token來請求,服務端先進行token驗證定拟,判斷token是否存在,如果存在則處理接下來的業(yè)務流程,然后刪除token青自。如果不存在則提示客戶端重復操作株依。
(2) 去重表,和上述說的一樣延窜,表里需要一個主鍵或者unique index索引
(3) redis恋腕,分布式鎖方式
(4) 狀態(tài)機,通過固定的狀態(tài)順序判斷冪等逆瑞,比如訂單的待提交荠藤、待支付、已支付获高、已支付待發(fā)貨哈肖、已發(fā)貨、已完成念秧、已取消淤井。待支付之前一定是待提交狀態(tài),處于待支付的訂單摊趾,就不能再做提價操作了币狠,這樣也可以解決接口冪等的問題。
(5) 全局ID砾层,根據(jù)業(yè)務和操作生成全局id漩绵,執(zhí)行操作之前先判斷全局id是否存在,來判斷該操作是否已經(jīng)執(zhí)行了
(6) 插入或更新肛炮,適用于新增操作并且有唯一索引的情況
(7) 多版本控制止吐,適合于更新場景
參考文章:
https://www.cnblogs.com/sea520/p/10117729.html
https://www.cnblogs.com/jajian/p/10926681.html