Java冪等性校驗解決重復點擊的六種實現(xiàn)方式
一、簡介
1.1 什么是冪等?
冪等
是一個數(shù)學與計算機科學概念,英文 idempotent [a??demp?t?nt]。
- 在數(shù)學中,冪等用函數(shù)表達式就是:
f(x) = f(f(x))
。比如 求絕對值 的函數(shù),就是冪等的,abs(x) = abs(abs(x))。 - 計算機科學中,冪等表示一次和多次請求某一個資源應該具有同樣的作用。
滿足冪等條件的性能叫做 冪等性
。
1.2 為什么需要冪等性?
我們開發(fā)一個轉賬功能,假設我們調用下游接口 超時 了。一般情況下,超時可能是網絡傳輸丟包的問題,也可能是請求時沒送到,還有可能是請求到了,返回結果卻丟了。這時候我們是否可以 重試 呢?如果重試的話,是否會多賺了一筆錢呢?
在我們日常開發(fā)中,會存在各種不同系統(tǒng)之間的相互遠程調用。調用遠程服務會有三個狀態(tài):成功
、失敗
、超時
。
前兩者都是明確的狀態(tài),但超時則是 未知狀態(tài)。我們轉賬 超時 的時候,如果下游轉賬系統(tǒng)做好 冪等性校驗,我們判斷超時后直接發(fā)起重試,既可以保證轉賬正常進行,又可以保證不會多轉一筆。
日常開發(fā)中,需要考慮冪等性的場景:
前端重復提交
:比如提交 form 表單時,如果快速點擊提交按鈕,就可能產生兩條一樣的數(shù)據(jù)。用戶惡意刷 單
:例如在用戶投票這種功能時,如果用戶針對一個用戶進行重復提交投票,這樣會導致接口接收到用戶重復提交的投票信息,會使投票結果與事實嚴重不符。接口超時重復提交
:很多時候 HTTP 客戶端工具都默認開啟超時重試的機制,尤其是第三方調用接口的時候,為了防止網絡波動等造成的請求失敗,都會添加重試機制,導致一個請求提交多次。MQ重復消費
:消費者讀取消息時,有可能會讀取到重復消息。
1.3 接口超時,應該如何處理?
如果我們調用下游接口超時了,我們應該如何處理?其實從生產者和消費者兩個角度來看,有兩種方案處理:
- 方案一:消費者角度。在接口超時后,調用下游接口檢查數(shù)據(jù)狀態(tài):
- 如果查詢到是成功,就走成功流程;
- 如果是失敗,就按失敗處理(重新請求)。
- 方案二:生產者角度。下游接口支持冪等,上有系統(tǒng)如果調用超時,發(fā)起重試即可。
兩種方案都是可以的,但如果是 MQ重復消費的場景,方案一處理并不是很妥當,所以我們還是要求下游系統(tǒng) 對外接口支持冪等。
1.4 冪等性對系統(tǒng)的影響
冪等性是為了簡化客戶端邏輯處理,能防止重復提交等操作,但卻增加了 服務端的邏輯復雜性和成本,其主要是:
- 把并行執(zhí)行的功能改為串行執(zhí)行,降低了執(zhí)行效率。
- 增加了額外控制冪等的業(yè)務邏輯,復雜化了業(yè)務功能。
在使用前,需要根據(jù)實際業(yè)務場景具體分析,除了業(yè)務上的特殊要求外,一般情況下不需要引入接口的冪等性。
二、Restful API 接口的冪等性
Restful 推薦的幾種 HTTP 接口方法中,不同的請求對冪等性的要求不同:
請求類型 | 是否冪等 | 描述 |
---|---|---|
GET | 是 | GET 方法用于獲取資源。一般不會也不應當對系統(tǒng)資源進行改變,所以是冪等的。 |
POST | 否 | POST 方法用于創(chuàng)建新的資源。每次執(zhí)行都會新增數(shù)據(jù),所以不是冪等的。 |
PUT | 不一定 | PUT 方法一般用于修改資源。該操作分情況判斷是否滿足冪等,更新中直接根據(jù)某個值進行更新,也能保持冪等。不過執(zhí)行累加操作的更新是非冪等的。 |
DELETE | 不一定 | DELETE 方法一般用于刪除資源。該操作分情況判斷是否滿足冪等,當根據(jù)唯一值進行刪除時,滿足冪等;但是帶查詢條件的刪除則不一定滿足。例如:根據(jù)條件刪除一批數(shù)據(jù)后,又有新增數(shù)據(jù)滿足該條件,再執(zhí)行就會將新增數(shù)據(jù)刪除,需要根據(jù)業(yè)務判斷是否校驗冪等。 |
三、實現(xiàn)方式
3.1 數(shù)據(jù)庫層面,主鍵/唯一索引沖突
日常開發(fā)中,為了實現(xiàn)接口冪等性校驗,可以這樣實現(xiàn):
- 提前在數(shù)據(jù)庫中為唯一存在的字段(如:唯一流水號 bizSeq 字段)添加唯一索引,或者直接設置為主鍵。
- 請求過來,直接將數(shù)據(jù)插入、更新到數(shù)據(jù)庫中,并進行
try-catch
捕獲。 - 如果拋出異常,說明為重復請求,可以直接返回成功,或提示請求重復。
補充: 也可以新建一張 防止重復點擊表,將唯一標識放到表中,存為主鍵或唯一索引,然后配合 tra-catch 對重復點擊的請求進行處理。
偽代碼如下:
/** * 冪等處理 */ Rsp idempotent(Request req){ try { insert(req); } catch (DuplicateKeyException e) { //攔截是重復請求,直接返回成功 log.info("主鍵沖突,是重復請求,直接返回成功,流水號:{}",bizSeq); return rsp; } //正常處理請求 dealRequest(req); return rsp; }
3.2 數(shù)據(jù)庫層面,樂觀鎖
樂觀鎖
:樂觀鎖在操作數(shù)據(jù)時,非常樂觀,認為別人不會同時在修改數(shù)據(jù)。因此樂觀鎖不會上鎖,只是在執(zhí)行更新的時候判斷一下,在此期間是否有人修改了數(shù)據(jù)。
樂觀鎖的實現(xiàn):
就是給表多加一列 version 版本號,每次更新數(shù)據(jù)前,先查出來確認下是不是剛剛的版本號,沒有改動再去執(zhí)行更新,并升級 version(version=version+1)。
比如,我們更新前,先查一下數(shù)據(jù),查出來的版本號是 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è)務邏輯,如果更新失敗,默認為重復請求,直接返回。
流程圖如下:
為什么版本號建議自增呢?
因為樂觀鎖存在 ABA 的問題,如果 version 版本一直是自增的就不會出現(xiàn) ABA 的情況。
3.3 數(shù)據(jù)庫層面,悲觀鎖(select for update)【不推薦】
悲觀鎖
:通俗點講就是很悲觀,每次去操作數(shù)據(jù)時,都覺得別人中途會修改,所以每次在拿數(shù)據(jù)的時候都會上鎖。官方點講就是,共享資源每次只給一個線程使用,其他線程阻塞,用完后再把資源轉讓給其它資源。
悲觀鎖的實現(xiàn):
在訂單業(yè)務場景中,假設先查詢出訂單,如果查到的是處理中狀態(tài),就處理完業(yè)務,然后再更新訂單狀態(tài)為完成。如果查到訂單,并且不是處理中的狀態(tài),則直接返回。
可以使用數(shù)據(jù)庫悲觀鎖(select … for update)解決這個問題:
begin; # 1.開始事務 select * from order where order_id='666' for update # 查詢訂單,判斷狀態(tài),鎖住這條記錄 if(status !=處理中){ //非處理中狀態(tài),直接返回; return ; } ## 處理業(yè)務邏輯 update order set status='完成' where order_id='666' # 更新完成 commit; # 5.提交事務
注意:
- 這里的 order_id 需要是主鍵或索引,只用行級鎖鎖住這條數(shù)據(jù)即可,如果不是主鍵或索引,會鎖住整張表。
- 悲觀鎖在同一事務操作過程中,鎖住了一行數(shù)據(jù)。這樣 別的請求過來只能等待,如果當前事務耗時比較長,就很影響接口性能。所以一般 不建議用悲觀鎖的實現(xiàn)方式。
3.4 數(shù)據(jù)庫層面,狀態(tài)機
很多業(yè)務表,都是由狀態(tài)的,比如:轉賬流水表,就會有 0-待處理,1-處理中,2-成功,3-失敗的狀態(tài)。轉賬流水更新的時候,都會涉及流水狀態(tài)更新,即涉及 狀態(tài)機(即狀態(tài)變更圖)。我們可以利用狀態(tài)機來實現(xiàn)冪等性校驗。
狀態(tài)機的實現(xiàn):
比如:轉賬成功后,把 處理中 的轉賬流水更新為成功的狀態(tài),SQL 如下:
update transfor_flow set status = 2 where biz_seq='666' and status = 1;
流程圖如下:
- 第1次請求來時,bizSeq 流水號是 666,該流水的狀態(tài)是處理中,值是 1,要更新為 2-成功的狀態(tài),所以該 update 語句可以正常更新數(shù)據(jù),sql 執(zhí)行結果的影響行數(shù)是 1,流水狀態(tài)最后變成了 2。
- 第2次請求也過來了,如果它的流水號還是 666,因為該流水狀態(tài)已經變?yōu)?2-成功的狀態(tài),所以更新結果是0,不會再處理業(yè)務邏輯,接口直接返回。
偽代碼實現(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(“更新成功,可以處理該請求”); //其他業(yè)務邏輯處理 return rsp; } else if(rows == 0) { log.info(“更新不成功,不處理該請求”); //不處理,直接返回 return rsp; } log.warn("數(shù)據(jù)異常") return rsp: }
3.5 應用層面,token令牌【不推薦】
token 唯一令牌方案一般包括兩個請求階段:
- 客戶端請求申請獲取請求接口用的token,服務端生成token返回;
- 客戶端帶著token請求,服務端校驗token。
流程圖如下:
- 客戶端發(fā)送請求,申請獲取 token。
- 服務端生成全局唯一的 token,保存到 redis 中(一般會設置一個過期時間),然后返回給客戶端。
- 客戶端帶著 token,發(fā)起請求。
- 服務端去 redis 確認 token 是否存在,一般用
redis.del(token)
的方式,如果存在會刪除成功,即處理業(yè)務邏輯,如果刪除失敗,則直接返回結果。
補充: 這種方式個人不推薦,說兩方面原因:
- 需要前后端聯(lián)調才能實現(xiàn),存在溝通成本,最終效果可能與設想不一致。
- 如果前端多次獲取多個 token,還是可以重復請求的,如果再在獲取 token 處加分布式鎖控制,就不如直接用分布式鎖來控制冪等性了,即下面這種解決方式。
3.6 應用層面,分布式鎖【推薦】
分布式鎖
實現(xiàn)冪等性的邏輯就是,請求過來時,先去嘗試獲取分布式鎖,如果獲取成功,就執(zhí)行業(yè)務邏輯,反之獲取失敗的話,就舍棄請求直接返回成功。
流程圖如下:
- 分布式鎖可以使用 Redis,也可以使用 Zookeeper,不過 Redis 相對好點,比較輕量級。
- Redis 分布式鎖,可以使用
setIfAbsent()
來實現(xiàn),注意分布式鎖的 key 必須為業(yè)務的唯一標識。 - Redis 執(zhí)行設置 key 的動作時,要設置過期時間,防止釋放鎖失敗。這個過期時間不能太短,太短攔截不了重復請求,也不能設置太長,請求量多的話會占用存儲空間。
四、Java 代碼實現(xiàn)
4.1 @NotRepeat 注解
@NotRepeat 注解用于修飾需要進行冪等性校驗的類。
NotRepeat.java
import java.lang.annotation.*; /** * 冪等性校驗注解 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface NotRepeat { }
4.2 AOP 切面
AOP切面監(jiān)控被 @Idempotent 注解修飾的方法調用,實現(xiàn)冪等性校驗邏輯。
IdempotentAOP.java
import com.demo.util.RedisUtils; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.After; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Pointcut; import org.springframework.stereotype.Component; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import java.util.concurrent.TimeUnit; /** * 重復點擊校驗 */ @Slf4j @Aspect @Component public class IdempotentAOP { /** Redis前綴 */ private String API_IDEMPOTENT_CHECK = "API_IDEMPOTENT_CHECK:"; @Resource private HttpServletRequest request; @Resource private RedisUtils redisUtils; /** * 定義切面 */ @Pointcut("@annotation(com.demo.annotation.NotRepeat)") public void notRepeat() { } /** * 在接口原有的方法執(zhí)行前,將會首先執(zhí)行此處的代碼 */ @Before("notRepeat()") public void doBefore(JoinPoint joinPoint) { String uri = request.getRequestURI(); // 登錄后才做校驗 UserInfo loginUser = AuthUtil.getLoginUser(); if (loginUser != null) { assert uri != null; String key = loginUser.getAccount() + "_" + uri; log.info(">>>>>>>>>> 【IDEMPOTENT】開始冪等性校驗,加鎖,account: {},uri: {}", loginUser.getAccount(), uri); // 加分布式鎖 boolean lockSuccess = redisUtils.setIfAbsent(API_IDEMPOTENT_CHECK + key, "1", 30, TimeUnit.MINUTES); log.info(">>>>>>>>>> 【IDEMPOTENT】分布式鎖是否加鎖成功:{}", lockSuccess); if (!lockSuccess) { if (uri.contains("contract/saveDraftContract")) { log.error(">>>>>>>>>> 【IDEMPOTENT】文件保存中,請稍后"); throw new IllegalArgumentException("文件保存中,請稍后"); } else if (uri.contains("contract/saveContract")) { log.error(">>>>>>>>>> 【IDEMPOTENT】文件發(fā)起中,請稍后"); throw new IllegalArgumentException("文件發(fā)起中,請稍后"); } } } } /** * 在接口原有的方法執(zhí)行后,都會執(zhí)行此處的代碼(final) */ @After("notRepeat()") public void doAfter(JoinPoint joinPoint) { // 釋放鎖 String uri = request.getRequestURI(); assert uri != null; UserInfo loginUser = SysUserUtil.getloginUser(); if (loginUser != null) { String key = loginUser.getAccount() + "_" + uri; log.info(">>>>>>>>>> 【IDEMPOTENT】冪等性校驗結束,釋放鎖,account: {},uri: {}", loginUser.getAccount(), uri); redisUtils.del(API_IDEMPOTENT_CHECK + key); } } }
4.3 RedisUtils 工具類
RedisUtils.java
import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import java.util.Arrays; import java.util.concurrent.TimeUnit; /** * redis工具類 */ @Slf4j @Component public class RedisUtils { /** * 默認RedisObjectSerializer序列化 */ @Autowired private RedisTemplate<String, Object> redisTemplate; /** * 加分布式鎖 */ public boolean setIfAbsent(String key, String value, long timeout, TimeUnit unit) { return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); } /** * 釋放鎖 */ public void del(String... keys) { if (keys != null && keys.length > 0) { //將參數(shù)key轉為集合 redisTemplate.delete(Arrays.asList(keys)); } } }
4.4 測試類
OrderController.java
import com.demo.annotation.NotRepeat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Arrays; import java.util.List; /** * 冪等性校驗測試類 */ @RequestMapping("/order") @RestController public class OrderController { @NotRepeat @GetMapping("/orderList") public List<String> orderList() { // 查詢列表 return Arrays.asList("Order_A", "Order_B", "Order_C"); // throw new RuntimeException("參數(shù)錯誤"); } }
4.5 測試結果
請求地址:http://localhost:8080/order/orderList
日志信息如下:
經測試,加鎖后,正常處理業(yè)務、拋出異常都可以正常釋放鎖。
以上就是Java冪等性校驗解決重復點擊的六種實現(xiàn)方式的詳細內容,更多關于Java冪等性解決重復點擊的資料請關注腳本之家其它相關文章!
相關文章
springboot FeignClient注解及參數(shù)
這篇文章主要介紹了springboot FeignClient注解及參數(shù),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2019-12-12Spring IOC簡單理解及創(chuàng)建對象的方式
這篇文章主要介紹了Spring IOC簡單理解及創(chuàng)建對象的方式,本文通過兩種方式給大家介紹創(chuàng)建對象的方法,通過實例代碼給大家介紹的非常詳細,需要的朋友可以參考下2021-09-09SpringBoot2使用JTA組件實現(xiàn)基于JdbcTemplate多數(shù)據(jù)源事務管理(親測好用)
這篇文章主要介紹了SpringBoot2使用JTA組件實現(xiàn)基于JdbcTemplate多數(shù)據(jù)源事務管理(親測好用),在Spring?Boot?2.x中,整合了這兩個JTA的實現(xiàn)分別是Atomikos和Bitronix,本文通過實例代碼給大家介紹的非常詳細,需要的朋友可以參考下2022-07-07java優(yōu)先隊列PriorityQueue中Comparator的用法詳解
這篇文章主要介紹了java優(yōu)先隊列PriorityQueue中Comparator的用法詳解,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2020-02-02