天天看點

接口幂等性及常見的解決方案

作者:Esgoon

幂等的概念

幂等是一個數學與計算機科學概念,在數學中,幂等可使用這樣的函數表達式表示:f(x) = f(f(x))。比如求絕對值的函數:Abs(val) = Abs(Abs(val))。

在計算機軟體中,程式接口的幂等性是指其任意多次被調用執行,所産生的效果及影響與被調用執行一次所産生的效果及影響相同。擁有幂等性的接口或方法,可以使用相同參數重複執行,而不用擔心會發生異常情況。比如我們實作一個“簽到”功能的接口,最終執行資料庫表的操作可能如下:

UPDATE user_sign SET signed=true WHERE user_id=xxxx;

此操作隻是把簽到表 user_sign 中表示是否簽到的字段 signed 設定為 true,對于這樣一個接口所執行的操作,無論是執行1次還是多次,效果都是一樣,資料也并不會産生差異。

還有,在現在常見的分布式場景下,由于網絡延遲和單點故障等因素,多個業務服務的資料保證最終一緻性是一種常見做法,這就要求服務提供者在處理重複調用時需要給出相同的預期響應,且不會對資料造成破壞。也就是服務提供者提供的API需要具有幂等性。

在軟體系統中,接口方法的幂等性的現實意義在很大程度上是指,系統的資料滿足業務要求,符合業務預期結果,且不會産生備援的、差錯的資料,也是保證系統穩定性的因素之一。

需要幂等的業務場景

場景一:首先講一個我早些年遇到的一個問題:在一個表單頁面填寫資料,之後點選“送出”按鈕,資料庫儲存表單所送出的資料,頁面跳轉到資料清單頁面。再去點選浏覽器上的“重新整理”按鈕,此時清單頁面會多出一條與剛剛送出的資料一樣的資料。(注:當時使用Struts、Jsp等技術,表單對應的資料庫表主鍵是資料庫自增的,其他表單字段未設定唯一索引)。

這個問題的原因是當使用者點選浏覽器“重新整理”按鈕時,實際是再次送出了“表單”頁面,導緻背景程式儲存資料的接口方法再次被調用,儲存了重複的資料。本質上來講,這就是一個幂等性問題,“儲存”操作的接口不具備幂等性,造成接口被多次調用後,資料庫出現了備援資料。

當時工作經驗還較少,我是從使用者互動的角度去解決這個問題的:當送出表單成功後頁面跳轉到清單頁面,使用者再重新整理浏覽器的時候,實際上使用者想要重新整理的是清單頁面,調用的應該是資料清單的查詢接口。具體做法是:在清單頁面(list.jsp)的HTML body标簽中寫上:

onLoad=”window.url=xxx.do?action=selectList”

也就是說,在清單頁面加載的時候,就将浏覽器的window對象的url設定為資料清單查詢接口的url。這樣,當使用者在清單頁面重新整理的時候,重新整理的是資料清單的查詢接口。

再延伸一點,為什麼當不在清單頁面增加onLoad函數,使用者重新整理操作重新整理的是表單送出的接口URL呢? 因為使用者在表單頁面點選“送出”按鈕的時候,浏覽器window對象記住了本次操作,而資料儲存成功後跳轉到清單頁面是在背景程式進行的操作,浏覽器window對象記錄的最新請求依舊是送出表單的操作,所依即使我們看到的是清單頁面,執行重新整理操作時,浏覽器發出的還是“送出”表單的請求。

但這種解決方案隻能算是一種臨時方案,并未從根本上解決資料儲存接口的幂等性問題。從接口幂等性的角度解決此問題,有兩種方法。

方法一

① 資料庫表設計方面,除主鍵外,還需要對必要的業務字段設定唯一性索引。

② 程式實作方面,在資料儲存操作insert執行之前,先根據表單字段查詢資料庫,如果查詢結果存在,則直接跳轉到清單頁面;如果查詢結果不存在,再執行insert操作後跳轉到清單頁面。

方法二

① 資料庫表增加一個字段存儲唯一性辨別ID字段,這個ID可用于唯一辨別每一次送出表單的請求。

② 在進入Form表單頁面之前,先生成一個唯一性ID,在Form表單頁面使用hidden隐藏域儲存這個ID值,送出表單時,将該ID值和其他表單業務字段一起作為參數傳入後端,執行儲存操作時,先查詢資料庫表是否有這個ID辨別對應的記錄。沒有則儲存後跳轉清單頁面,有則表示表單已被送出過,直接跳轉到清單頁面。

方法二跟方法一有些類似,差別是其引入了與業務無關的ID辨別字段。這也是解決幂等性問題的通用思想:用全局唯一ID辨別每一次請求或調用,然後過濾已處理過的請求或調用。

還有其他的一些需要幂等性的場景,比如:

場景二:轉賬業務,調用轉賬接口時,由于網絡逾時或者其他原因,使用者未接收到響應結果,此時使用者可能再次嘗試轉賬操作,那麼就需要幂等性保證使用者不會進行重複轉賬。

場景三:消息中間件(MQ)的消息隊列中可能存在重複消息,消費者讀取消息時會存在重複消費的情況。

場景四:服務異常中止,重新開機服務後需要重新執行一批任務(這并不是一個具體的業務場景,但是一種我們容易了解的問題場景。也說明了我們在系統設計開發的時候,要更全面地考慮幂等性問題,它是系統穩定性的重要因素之一)。

針對不同的系統不同的場景幂等性也沒有絕對銀彈, 下面介紹一些相對通用的方法。

幂等性的幾種解決方案

1、token方案

① 在分布式環境中,可以借助redis,當接口調用方發起調用前先生成一個防重token(可使用UUID生成或其他的ID生成機制産生的一個唯一性ID),并把此token存放到redis。

② 在調用下遊接口時,把token傳參過去,可通過請求頭傳參。

③ 下遊接口在執行業務邏輯時,首先取出token并判斷是否存在于redis中,若存在則執行業務,同時從redis中删除token。若不存在則表示相同的請求已調用過,直接傳回重複辨別給調用端。

接口幂等性及常見的解決方案

token方案類似于上面提到的問題場景一的處理方案二,不同的是引入了redis。在此方案中需要注意的是,下遊接口應在業務邏輯處理前執行從redis擷取token、與入參token比較、從redis删除token等操作,且要保證這三個操作的原子性,通常使用LUA腳本處理。

實作的僞代碼如下:

//入參傳遞過來的token
String token = "xxxx";
String tokenKey = "some-business-key";
//LUA腳本保證token擷取、比對token、删除token操作的原子性. 1:驗證成功; 0:驗證失敗
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = (Long) redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList(tokenKey), token);
if (result == 0) {
    //驗證失敗,傳回調用端
    return ...;
} else {
    //驗證成功,執行業務邏輯
    doSomething();
}           

2、防重表方案

單獨建立一張防重表,專門用于防重校驗。例如在MQ消息消費場景中,為了保證消息處理的幂等性,可将處理過的消息放到消費日志表(如:tb_consumer_log),該表存儲消息相關的字段,例如:id、group、tag、key、消息實體、消費狀态等。消費者處理一條消息時,先到這個表進行查詢,判斷是否被消費過。隻有未被消費過的消息才進行處理,處理完成後,将該消息的相關字段存入此消費日志表。通常将這個消費日志表稱為防重表或幂等表。

在一些高并發場景下,防重表也用來與業務表配合使用,将對防重表和業務表的操作放到同一個資料庫事務中,防重表通常存放有唯一性要求的業務字段,設定唯一性索引。通過資料庫事務保證去重處理,當同時往防重表和業務表寫入資料時,如果唯一索引沖突,操作失敗,則表示重複操作,相應的資料也會復原。

3、悲觀鎖與樂觀鎖方案

資料庫悲觀鎖是指在高并發場景下,如果要對某一個資料進行更新,首先鎖定該資料,使用SELECT...FOR UPDATE文法。

在MySQL資料庫中,SELECT...FOR UPDATE與InnoDB存儲引擎一起使用(因為支援資料庫事務),則查詢檢查的行将被寫鎖定,直到目前事務結束。

例如,電商系統中,我們需要更新使用者積分。僞代碼如下:

//開始事務
Begin Transaction;
//查詢出使用者記錄,并鎖住這條記錄
SELECT * FROM tb_user WHERE user_id = 1001 FOR UPDATE;
//更新積分
UPDATE tb_user SET user_score = user_score + 100 WHERE user_id = 1001;
Commit;           

這個例子中,user_id需要是主鍵或者唯一索引,否則會鎖定整個表。此外,如果事務進行中的邏輯複雜,耗時較長,會造成大量請求等待,存在性能問題。

資料庫樂觀鎖通常是在業務表中增加一個無業務意義的version字段,在更新資料前先查詢出version字段,在更新操作時,将version字段作為條件,同時将version+1。還是更新使用者積分的例子,僞代碼如下:

//更新積分前,先查詢出version字段。Version字段需要傳參到調用端,再傳遞到被調用端
SELECT user_id, version FROM tb_user WHERE user_id = 1001;
//假設第一次更新,查詢出的version為0. 執行更新操作,将version作為條件同時version+1
UPDATE tb_user SET user_score = user_score + 100, version = version + 1 WHERE user_id = 1001 AND version=0;           

判斷UPDATE操作影響的行數,如果影響行數為0,則表示version字段已被更新過,目前的請求是重複請求,可直接傳回成功。

4、分布式鎖方案

處理業務邏輯之前首先擷取分布式鎖,擷取成功則執行業務邏輯,擷取失敗則不執行業務邏輯。此種情況适用于服務部署了多個執行個體,多個執行個體有可能同時調用同一個接口的情況。

還有,前面講的防重表方案實際上是利用了資料庫的分布式鎖特性。某些需要更高性能業務場景下,調用業務接口前可以将唯一性業務辨別通過setNx指令存入redis,若存入成功則進行業務處理,若存入失敗則表示是重複的調用。

接口幂等性及常見的解決方案

小結

幂等性是系統穩定性的重要因素之一,特别是對于分布式系統,接口的幂等性顯得尤為重要。在進行系統設計與開發時,需要全面考量,重點關注。很多情況下,幂等性問題不單單是技術問題,而是需要站在業務語義的角度思考與設計,選擇最合适的處理方案。而幂等性問題處理的根本思路有以下要點:

  • 每一次請求/調用的唯一辨別
  • 資料庫層面的控制:業務字段的唯一索引
  • 執行業務處理前先查詢确認(先查後寫)
  • 鎖機制:資料庫鎖(樂觀鎖、悲觀鎖)、分布式鎖

繼續閱讀