一、簡(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)該具有同樣的作用。
滿(mǎn)足冪等條件的性能叫做 冪等性
。
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à),是否會(huì)多賺了一筆錢(qián)呢?
在我們?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ù)。 -
用戶(hù)惡意刷單
:例如在用戶(hù)投票這種功能時(shí),如果用戶(hù)針對(duì)一個(gè)用戶(hù)進(jìn)行重復(fù)提交投票,這樣會(huì)導(dǎo)致接口接收到用戶(hù)重復(fù)提交的投票信息,會(huì)使投票結(jié)果與事實(shí)嚴(yán)重不符。 -
接口超時(shí)重復(fù)提交
:很多時(shí)候 HTTP 客戶(hù)端工具都默認(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):
- 如果查詢(xún)到是成功,就走成功流程;
- 如果是失敗,就按失敗處理(重新請(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)化客戶(hù)端邏輯處理,能防止重復(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)求類(lèi)型 | 是否冪等 | 描述 |
---|---|---|
GET | 是 |
GET 方法用于獲取資源。一般不會(huì)也不應(yīng)當(dāng)對(duì)系統(tǒng)資源進(jìn)行改變,所以是冪等的。 |
POST | 否 |
POST 方法用于創(chuàng)建新的資源。每次執(zhí)行都會(huì)新增數(shù)據(jù),所以不是冪等的。 |
PUT | 不一定 |
PUT 方法一般用于修改資源。該操作分情況判斷是否滿(mǎn)足冪等,更新中直接根據(jù)某個(gè)值進(jìn)行更新,也能保持冪等。不過(guò)執(zhí)行累加操作的更新是非冪等的。 |
DELETE | 不一定 |
DELETE 方法一般用于刪除資源。該操作分情況判斷是否滿(mǎn)足冪等,當(dāng)根據(jù)唯一值進(jìn)行刪除時(shí),滿(mǎn)足冪等;但是帶查詢(xún)條件的刪除則不一定滿(mǎn)足。例如:根據(jù)條件刪除一批數(shù)據(jù)后,又有新增數(shù)據(jù)滿(mǎn)足該條件,再執(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è)線(xiàn)程使用,其他線(xiàn)程阻塞,用完后再把資源轉(zhuǎn)讓給其它資源。
悲觀鎖的實(shí)現(xiàn):
在訂單業(yè)務(wù)場(chǎng)景中,假設(shè)先查詢(xún)出訂單,如果查到的是處理中狀態(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 # 查詢(xún)訂單,判斷狀態(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)求階段:
- 客戶(hù)端請(qǐng)求申請(qǐng)獲取請(qǐng)求接口用的token,服務(wù)端生成token返回;
- 客戶(hù)端帶著token請(qǐng)求,服務(wù)端校驗(yàn)token。
流程圖如下:
- 客戶(hù)端發(fā)送請(qǐng)求,申請(qǐng)獲取 token。
- 服務(wù)端生成全局唯一的 token,保存到 redis 中(一般會(huì)設(shè)置一個(gè)過(guò)期時(shí)間),然后返回給客戶(hù)端。
- 客戶(hù)端帶著 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ù)邏輯,反之獲取失敗的話(huà),就舍棄請(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à)會(huì)占用存儲(chǔ)空間。
四、Java 代碼實(shí)現(xiàn)
4.1 @NotRepeat 注解
@NotRepeat 注解用于修飾需要進(jìn)行冪等性校驗(yàn)的類(lèi)。
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 工具類(lèi)
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工具類(lèi)
*/
@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è)試類(lèi)
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è)試類(lèi)
*/
@RequestMapping("/order")
@RestController
public class OrderController {
@NotRepeat
@GetMapping("/orderList")
public List<String> orderList() {
// 查詢(xún)列表
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ù)、拋出異常都可以正常釋放鎖。
整理完畢,完結(jié)撒花~ ??
參考地址:
1.實(shí)戰(zhàn),實(shí)現(xiàn)冪等的8種方案!https://blog.csdn.net/sufu1065/article/details/122335349
2.Java中的冪等性,https://blog.csdn.net/JewaveOxford/article/details/103578372文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-744328.html
3.Spring Boot 實(shí)現(xiàn)接口冪等性的 4 種方案!還有誰(shuí)不會(huì)?https://blog.csdn.net/youanyyou/article/details/114464708文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-744328.html
到了這里,關(guān)于【安全】Java冪等性校驗(yàn)解決重復(fù)點(diǎn)擊(6種實(shí)現(xiàn)方式)的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!