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