關于接口冪等性的總結

前言

元旦放假哪也沒去一個人在家里悶得慌,突然間想寫點東西打發(fā)打發(fā)時間,剛好想起前幾天在公司聽到一些同事在討論線上數(shù)據(jù)庫出現(xiàn)數(shù)據(jù)重復的問題,據(jù)說是因為接口與前端都沒有做重復提交的約束導致的問題,因為我沒有參與到相關業(yè)務的開發(fā)中,所以具體情況不了解,只是聽他們在討論過程中知道一點就是有可能是用戶誤操作導致接口出現(xiàn)并發(fā)問題,還猜測有可能是用戶端通過程序腳本的方式來刷接口,雖說后端API使用了Https協(xié)議加密傳輸,但還是有被刷的可能性,也有同學表示說接口要加個同步鎖來排重等等。初一聽好像沒有什么問題,仔細想下也是有問題的,加上之前有在簡書上看過一篇關于怎么處理接口冪等的文章,覺得寫的很贊(如何避免重復下單),這里就再結合自己過去在一些項目中對于這種重復提交的做法做下記錄總結吧。

什么是接口冪等

冪等(idempotent、idempotence)是一個數(shù)學與計算機學概念,常見于抽象代數(shù)中,即f(f(x)) = f(x).簡單的來說就是一個操作多次執(zhí)行產(chǎn)生的結果與一次執(zhí)行產(chǎn)生的結果一致。有些系統(tǒng)操作天生就具有冪等性例如數(shù)據(jù)庫的select語句,但更多時候是需要程序員來做保證的,尤其是在分布式系統(tǒng)環(huán)境中,接口能不能做到保證冪等性對系統(tǒng)的影響可能是非常大的,例如很常見的支付下單等場景,由于分布式環(huán)境中網(wǎng)絡的復雜性,用戶誤操作,網(wǎng)絡抖動,消息重復,服務超時導致業(yè)務自動重試等等各種情況都可能會使線上數(shù)據(jù)產(chǎn)生了不一致,造成生產(chǎn)事故。

如何解決冪等性問題

首先我發(fā)現(xiàn)很多人在做項目的過程中一旦發(fā)現(xiàn)程序出現(xiàn)了所謂高并發(fā)問題(其實很多時候是在意淫哈,哪有那么多高并發(fā)),第一個反應就是加鎖,不管是分布式鎖還是單機鎖,好像加了鎖并發(fā)問題就真的不存在了似的,確實在很多情況下加鎖是能解決問題的,但程序也變成了單線程執(zhí)行, 還得注意鎖不要加錯了地方(先要搞清楚程序需要同步的臨界區(qū)是什么)否則不但沒能解決問題還降低系統(tǒng)TPS造成性能影響。而說到鎖很多人的第一個反應就是Jdk提供的同步鎖synchronized,一般情況下同步鎖確實能解決多線程訪問臨界區(qū)造成的數(shù)據(jù)安全問題即并發(fā)問題,同步鎖的一般使用方式要么是鎖住整個方法要么是方法內部鎖住一個程序片段,不管哪一種先要明白鎖的是當前這個類的實例對象,即多個線程同時訪問代碼片段時訪問的是同一個對象(如果每個線程都會創(chuàng)建一個新的實例對象的話,加鎖也就毫無意義了)比方說Spring受管的bean,默認情況下都是單實例的,也就是說多線程共享的,這個時候才需要考慮并發(fā)的問題。而我們平時在做項目的過程中,除了要完成業(yè)務開發(fā)之外,還得多想想業(yè)務之外的一些東西比如接口需不需要保證冪等,代碼有沒有很強的擴展性等等,我覺得這也是高級程序員和初級程序員之間的區(qū)別吧?;氐轿恼麻_頭提到的問題,假設我們的數(shù)據(jù)庫里面有一張表是order 字段有id,userId,planId(計劃ID),money.createTime這幾個字段,前端用戶在下單的同時就是針對某一個計劃提交了一條數(shù)據(jù)(業(yè)務要保證只能提交一次,不了解真實情況所以這業(yè)務是假設的),那么這個時候如果說我們的業(yè)務偽代碼中是這么寫的:

    public void saveOrder(userId,planId,money){
         boolean exist = select(userId,planId);
         if(exist){
             insertOrder(userId,planId,money);
         }
    }

那么這種情況下加同步鎖是可以保證多個線程并發(fā)訪問不會有問題,但如果在insert之前沒有先從db中select出來就直接insert了,那么加鎖也是白加,因為鎖的本質也是在排隊,第一個請求執(zhí)行完之后,緊接著等待隊列中的第二個請求一樣會執(zhí)行。另外一個問題是單機鎖無法解決系統(tǒng)集群或者分布式的場景,要知道現(xiàn)在大部分的互聯(lián)網(wǎng)應用都是集群或分布式的,JDK的同步鎖也只能鎖住單個進程,系統(tǒng)由于負載均衡,并發(fā)的兩個線程不一定就請求到同一臺服務器,所以這種場景下加鎖很大幾率是無效的,當然分布式鎖可以解決這兩個問題比方說下面這個采用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的唯一索引約束結合客戶端來保證接口的冪等.比如做法可以是:
1.我們可以給表字段userId和planId加上聯(lián)合唯一索引約束dedup_key.
2.在業(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;//其他類型的異常要往外拋出
         }
    }
}

上面這種實現(xiàn)雖然可以解決問題而且代碼看起來也挺簡潔,但并不是一種好的做法,因為如果我們在插入訂單表之前又做了其他的一些關聯(lián)表的插入或修改,那么一旦發(fā)現(xiàn)訂單表的插入失敗這個時候是不是還要去回滾之前所做的一些操作呢又比如說之前使用過MQ發(fā)送過消息那又要如何處理消息回滾呢,所以上面這種做法會額外增加系統(tǒng)復雜性,更好的實現(xiàn)應該是不在業(yè)務表上面建唯一索引了,而是獨立出一張表token_table,通常稱為排重表或者令牌表,表中主要有一個字段unique_key,并且在這個字段上面建一個unique index,那么這時候可以使用上面采用過的通用方案即并發(fā)時由數(shù)據(jù)庫自動拋出異常,業(yè)務service來捕獲最終返回給客戶端友好的提示,或者我們還可以利用mysql的insert ignore特性來處理這個問題。
順便簡單普及下mysql的insert ignore特性,這是mysql提供的三種可以防止重復插入數(shù)據(jù)的方式之一(另外兩種是ReplaceON DUPLICATE KEY UPDATE 這兩種也能解決重復提交問題,這里不展開描述,具體可以參考MySql避免重復插入記錄方法),如果表table中有主鍵pkId那么重復插入相同的pkId不會拋出異常[Err] 1062 - Duplicate entry '1' for key 'PRIMARY',而是直接返回結果0,如果表中某個字段建有唯一索引,同樣的除了第一次插入返回1外,其余皆返回0,那么我們就可以在業(yè)務方法中這樣做:
1.先使用insert ignore插入一條數(shù)據(jù)到令牌表中,得到返回的值為0或者為1
2.在service方法中無需顯示捕獲異常,只需判斷第一步獲取到的結果,如果大于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)一生成全局標示,做成一個全局的微服務,但需要考慮的東西比較多架構實現(xiàn)也比較復雜,可參考美團的技術文章分布式系統(tǒng)互斥性與冪等性問題的分析與解決
還有一種解決方案是利用數(shù)據(jù)庫的鎖機制來處理即共享讀鎖+普通索引。
下面這個截圖來自MySQL技術內幕InnoDB存儲引擎這本書,這種方式我沒在生產(chǎn)中使用過,但理論上來說應該也是可行的。

image.png

最后再說說類似那種使用程序來使接口無限被重放的情況,其實我一直都認為這不算是接口的主要職能了,接口的主要職責是處理業(yè)務邏輯,其他的安全措施應該交給框架層面來統(tǒng)一解決,但對于小系統(tǒng)來講可能沒有那么完善的基礎設施,所以該做的還是要做的比方說接口必須要登錄認證,可以結合nginx,redis做限制訪問,后臺還可以按照制定好的規(guī)則算法來對接口參數(shù)做排序和加密,防止 客戶端構造出非法的請求等等。

最后總結

1.同步鎖(單線程,集群可能會失效)
2.分布式鎖如redis(實現(xiàn)復雜)
2.業(yè)務字段加唯一約束(簡單)
3.令牌表+唯一約束(簡單推薦)
4.mysql的insert ignore或者on duplicate key update(簡單)
5.共享鎖+普通索引(簡單)
6.利用MQ或者Redis擴展(排隊)
7.其他方案如多版本控制MVCC 樂觀鎖 悲觀鎖 狀態(tài)機等。。。
對客戶端請求排隊或者單線程都可以處理冪等問題,需要根據(jù)具體業(yè)務選擇合適的方案但必須前后端一起做,前端做了可以提升用戶體驗,后端則可以保證數(shù)據(jù)安全。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容