1 什么是冪等
冪等的概念來自數學和計算機科學
數學概念:冪等函數 f(x) = f (f(x)) ,如絕對值函數abs(-5) = abs(abs(-5))
計算機科學:多次請求同同一資源與一次請求的影響是一致的
2 為什么要冪等設計
為什么要冪等設計呢?
我們舉個例子:
除了轉賬的例子,我們日常開發中還會遇到許多其他考慮冪等的例子
A.MQ下游消費系統重復消費的問題
B.前端form表單重復提交的問題
3 接口超時了如何設計
我們在調用下游接口超時的時候該如何處理呢?一般來講有兩種方案:方案一:下游系統提供一個查詢接口,上游調用超時,調用查詢接口,根據查詢接口返回的結果做相應的處理
方案二:下游支付中心支持冪等,超時之后,發起重試
4 如何設計冪等
這么多場景需要考慮冪等,那如何設計冪等呢冪等的核心問題是保持請求的唯一性,那首先要解決的問題是全局唯一性ID
如果使用唯一索引控制冪等,那么唯一索引就是唯一的;
如果使用數據庫主鍵控制冪等,那么主鍵就是唯一的;
如果使用悲觀鎖控制冪等,那么底層標志還是全局唯一ID
4.1 全局唯一ID
如UUID、雪花算法(snowflake IDs),UUID的方式簡單,但是可讀性差,
雪花算法是一種生成分布式全局唯一ID的算法,生成的ID稱為Snowflake IDs。這種算法由Twitter創建,并用于推文的ID。
一個snowflake是64位
第一位代表正負,1代表負數、0代表整數,一般ID都是整數,所以默認是0;
接下來的41位是時間戳,自選定日期以來的毫秒數;
接下來的10位代表計算機ID;
最后12位代表這個機器生成的ID序列碼,允許在同一毫秒內創建多少個snowflake ID
當然,全局唯一ID還可以使用美團的Leaf,百度的Uidgenerator.
4.2 冪等設計的基本流程
冪等設計歸根結底是過濾一下請求,要過濾請求,必須滿足兩個條件,全局唯一ID,請求記錄被保存,當收到請求時,優先查詢請求記錄,記錄存在則返回結果,不存在則處理請求并記錄,然后返回結果。
5 實現冪等設計的8中方案冪等設計的基本流程都一致,簡單概括起來有8中實現方案
5.1 select + Insert + 主鍵/唯一索引沖突當請求過來,根據唯一流水號查詢請求記錄記錄存在直接返回結果記錄不存在,插入記錄,記錄插入成功,返回結果,插入報主鍵沖突,根據已經插入的記錄返回結果。
偽代碼如下:
/**
* 冪等處理
*/
Rsp idempotent(Request req){
Object requestRecord =selectByBizSeq(bizSeq);
if(requestRecord !=null){
//攔截是重復請求
log.info("重復請求,直接返回成功,流水號:{}",bizSeq);
return rsp;
}
try{
insert(req);
}catch(DuplicateKeyException e){
//攔截是重復請求,直接返回成功
log.info("主鍵沖突,是重復請求,直接返回成功,流水號:{}",bizSeq);
return rsp;
}
//正常處理請求
dealRequest(req);
return rsp;
}
5.2 直接Insert + 主鍵/唯一索引沖突
5.1 的方案中先判斷了請求是否存在,如果并發度不是很高的時候,可以跳過查詢,根據主鍵沖突判重。
偽代碼如下:
/**冪等處理 */*/Rsp idempotent(Request req){? ? try{? ? ? ? insert(req);? ? }catch(DuplicateKeyException e){? ? //攔截是重復請求,直接返回成功? ? ? ? log.info("主鍵沖突,是重復請求,直接返回成功,流水號:{}",bizSeq);? ? ? return rsp;? ? }? ? //正常處理請求? ? dealRequest(req);? ? return rsp;}
5.3 狀態機
很多業務表都是有狀態,如轉賬流水表,狀態一般有0:待處理;1:處理中;2:成功;3:失敗,轉賬流水的更新一般都會有狀態的變更,即狀態機(狀態變更圖)。比如轉賬成功后,把處理中的轉賬流水更新為成功狀態,SQL這么寫:
update transfr_flow set status=2 where biz_seq=‘666’ and status=1;
偽代碼如下:
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(“更新成功,可以處理該請求”);? //其他業務邏輯處理? return rsp;}else if(rows==0){? log.info(“更新不成功,不處理該請求”);? //不處理,直接返回? return rsp;}? log.warn("數據異常")? return rsp:
}
5.4 抽取防重表
5.1和5.2的方案,都是建立在業務流水表上bizSeq的唯一性上。很多時候,我們業務表唯一流水號希望后端系統生成,又或者我們希望防重功能與業務表分隔開來,這時候我們可以單獨搞個防重表。當然防重表也是利用主鍵/索引的唯一性,如果插入防重表沖突即直接返回成功,如果插入成功,即去處理請求。
5.5 token令牌
為每個請求生成一個唯一的Token,并在服務端進行校驗,一旦處理了對應的請求,就丟棄該Token,避免重復處理。具體步驟:
1、服務端提供了發送 token 的接口。我們在分析業務的時候,哪些業務是存在冪等問題的, 就必須在執行業務前,先去獲取 token,服務器會把 token 保存到 redis 中。
2、然后調用業務接口請求時,把 token 攜帶過去,一般放在請求頭部。
3、服務器判斷 token 是否存在 redis 中,存在表示第一次請求,然后刪除 token,繼續執行業務。
4、如果判斷token不存在redis中,就表示是重復操作,直接返回重復標記給 client,這樣就保證了業務代碼,不被重復執行。
核心邏輯:
// 服務端接口,接收請求并處理token
void do(String token) {
? ? if (Redis.exists(token)) {
? ? ? // 刪除token,確保不會重復處理
? ? ? ? Redis.del(token);
? ? ? ? // 執行具體的業務操作
? ? ? ? doSometing();
? ? } else {
? ? ? ? log.info(token);
? ? }
}
注意:最好設計為先刪除 token,如果業務調用失敗,就重新獲取 token 再次請求。可以在 redis 使用 lua 腳本完成這個操作
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
5.6 悲觀鎖
通俗點講就是很悲觀,每次去操作數據時,都覺得別人中途會修改,所以每次在拿數據的時候都會上鎖。官方點講就是,共享資源每次只給一個線程使用,其它線程阻塞,用完后再把資源轉讓給其它線程。
悲觀鎖如何控制冪等的呢?就是加鎖呀,一般配合事務來實現。
偽代碼如下:
begin;? # 1.開始事務select * from order where order_id='666' # 查詢訂單,判斷狀態if(status !=處理中){//非處理中狀態,直接返回;return ;}## 處理業務邏輯update order set status='完成' where order_id='666' # 更新完成commit; # 5.提交事務
這里面order_id需要是索引或主鍵哈,要鎖住這條記錄就好,如果不是索引或者主鍵,會鎖表的!
悲觀鎖在同一事務操作過程中,鎖住了一行數據。別的請求過來只能等待,如果當前事務耗時比較長,就很影響接口性能。所以一般不建議用悲觀鎖做這個事情。
5.7 樂觀鎖
通過記錄數據的版本號或時間戳,僅當數據未被其他事務修改時,才允許更新操作執行。每次更新數據時,版本號都會遞增。
UPDATE orders
SET
? quantity = 1,
? order_status = 1,
? pay_time = '2024-04-30 10:20:00',
? version = version + 1
WHERE
? order_id = 'ORD-20231023-0001' AND
? version = 1;
效果演示:
如果 Session-01 已經提交了事務,Session-02 的更新操作將不會影響任何行,因為 version 已經從 1 增加到了 2。
5.8 分布式鎖
分布式鎖實現冪等性的邏輯就是,請求過來時,先去嘗試獲得分布式鎖,如果獲得成功,就執行業務邏輯,反之獲取失敗的話,就舍棄請求直接返回成功。執行流程如下圖所示:
分布式鎖可以使用Redis,也可以使用ZooKeeper,不過還是Redis相對好點,因為較輕量級。
Redis分布式鎖,可以使用命令SET EX PX NX + 唯一流水號實現,分布式鎖的key必須為業務的唯一標識哈
Redis執行設置key的動作時,要設置過期時間哈,這個過期時間不能太短,太短攔截不了重復請求,也不能設置太長,會占存儲空間,處理完成,要在finally中刪除鎖。