一瓤介、簡述
所謂冪等性,就是一個接口赘那,多次發(fā)起同一個請求刑桑,該接口得保證結(jié)果是準(zhǔn)確的,比如不能多扣款募舟、不能多插入一條數(shù)據(jù)祠斧、不能將統(tǒng)計值多統(tǒng)計 1。這就是冪等性拱礁。1??在編程中常見的冪等
①select 查詢天然冪等
②delete 刪除也是冪等琢锋,刪除同一個多次效果一樣
③update 直接更新某個值的辕漂,冪等
④update 更新累加操作的,非冪等
⑤insert 非冪等操作吴超,每次新增一條
2??產(chǎn)生原因:由于重復(fù)點擊或者網(wǎng)絡(luò)重發(fā)
①點擊提交按鈕兩次
②點擊刷新按鈕
③使用瀏覽器后退按鈕重復(fù)之前的操作钉嘹,導(dǎo)致重復(fù)提交表單
④使用瀏覽器歷史記錄重復(fù)提交表單
⑤瀏覽器重復(fù)的 HTTP 請求
⑥nginx 重發(fā)等情況
⑦分布式 RPC 的 try 重發(fā)等
二、理解
1??問題背景
分布式系統(tǒng)中的接口鲸阻,如何保證冪等性跋涣?做分布式系統(tǒng)的時候,這是一個必須要考慮的生產(chǎn)環(huán)境的技術(shù)問題鸟悴。
假如有個服務(wù)提供付款接口陈辱,結(jié)果這服務(wù)部署在了 5 臺機器上。然后用戶在前端上操作的時候细诸,不知道為啥沛贪,總之就是一個訂單不小心發(fā)起了兩次支付請求,然后該請求分散在了這個服務(wù)部署的不同的機器上揍堰,結(jié)果一個訂單扣款兩次鹏浅。
或者是訂單系統(tǒng)調(diào)用支付系統(tǒng)進行支付,結(jié)果不小心因為網(wǎng)絡(luò)超時了屏歹,然后訂單系統(tǒng)走了重試機制隐砸,支付系統(tǒng)收到一個支付請求兩次,而且因為負(fù)載均衡算法落在了不同的機器上蝙眶,問題由此而生季希。
2??問題剖析
這個不是技術(shù)問題,這個沒有通用的一個方法幽纷,這個應(yīng)該結(jié)合業(yè)務(wù)來保證冪等性式塌。其實保證冪等性主要是三點:
- 對于每個請求必須有一個唯一的標(biāo)識。舉個例子:訂單支付請求友浸,肯定得包含訂單 id峰尝,一個訂單 id 最多支付一次。
- 每次處理完請求之后收恢,必須有一個記錄標(biāo)識這個請求處理過了武学。常見的方案是在數(shù)據(jù)庫中記錄個狀態(tài),比如支付之前記錄一條這個訂單的支付流水伦意。
- 每次接收請求需要進行判斷火窒,判斷之前是否處理過。比如說驮肉,如果有一個訂單已經(jīng)支付了熏矿,就已經(jīng)有了一條支付流水,那么如果重復(fù)發(fā)送這個請求,則此時先插入支付流水票编,orderId 已經(jīng)存在了褪储,唯一鍵約束生效,報錯插入不進去的栏妖。然后系統(tǒng)就不用再扣款了乱豆。
3??實際運作
實際運作過程中,需要結(jié)合自己的業(yè)務(wù)來吊趾,比如說利用 Redis宛裕,用 orderId 作為唯一鍵。只有成功插入這個支付流水论泛,才可以執(zhí)行實際的支付扣款揩尸。
要求是支付一個訂單,必須插入一條支付流水屁奏。order_id 建 unique key岩榆。用戶在支付一個訂單之前,先插入一條支付流水坟瓢,order_id 就已經(jīng)進去了勇边。系統(tǒng)就可以寫一個標(biāo)識到 Redis 里面去,set order_id payed折联,下一次重復(fù)請求過來了粒褒,先查 Redis 的 order_id 對應(yīng)的 value,如果是 payed 就說明已經(jīng)支付過了诚镰,用戶就可以避免重復(fù)支付了奕坟。
三、解決方案
1??前端 js 提交禁止按鈕:可以用一些js組件
2??使用Post/Redirect/Get模式
在提交后執(zhí)行頁面重定向清笨,這就是所謂的 Post-Redirect-Get (PRG) 模式月杉。簡言之,當(dāng)用戶提交了表單后抠艾,去執(zhí)行一個客戶端的重定向苛萎,轉(zhuǎn)到提交成功信息頁面。這能避免用戶按F5導(dǎo)致的重復(fù)提交检号,而且也不會出現(xiàn)瀏覽器表單重復(fù)提交的警告首懈,也能消除按瀏覽器前進和后退導(dǎo)致的同樣問題。
3??在 session 中存放一個特殊標(biāo)志
在服務(wù)器端谨敛,生成一個唯一的標(biāo)識符,將它存入 session滤否,同時將它寫入表單的隱藏字段中脸狸,然后將表單頁面發(fā)給瀏覽器,用戶錄入信息后點擊提交,在服務(wù)器端炊甲,獲取表單中隱藏字段的值泥彤,與 session 中的唯一標(biāo)識符比較,相等說明是首次提交卿啡,就處理本次請求吟吝,然后將 session 中的唯一標(biāo)識符移除;不相等說明是重復(fù)提交颈娜,就不再處理剑逃。
4??其他借助使用 header 頭設(shè)置緩存控制頭 Cache-control 等方式
比較復(fù)雜,不適合移動端 APP 的應(yīng)用官辽。
5??借助數(shù)據(jù)庫
insert 使用唯一索引蛹磺。update 使用樂觀鎖 version 法。這種在大數(shù)據(jù)量和高并發(fā)下效率依賴數(shù)據(jù)庫硬件能力同仆,可針對非核心業(yè)務(wù)萤捆。
6??借助悲觀鎖
使用 select … for update 這種和 synchronized 鎖住先查再 insert or update 一樣,但要避免死鎖俗批,效率也較差俗或。針對單體應(yīng)用,請求并發(fā)不大岁忘,可以推薦使用辛慰。
四、自定義注解@RreventReSubmit
在傳統(tǒng)的 web 項目中臭觉,防止重復(fù)提交昆雀,通常做法是:后端生成一個唯一的提交令牌(uuid),并存儲在服務(wù)端蝠筑。頁面提交請求攜帶這個提交令牌狞膘,后端驗證并在第一次驗證后刪除該令牌,保證提交請求的唯一性什乙。
思路沒有問題挽封,但是需要前后端都稍加改動,如果在業(yè)務(wù)開發(fā)完再加這個的話臣镣,改動量未免有些大了辅愿。無需前端配合,純后端處理忆某,是最清爽的点待。設(shè)計思路如下:
自定義注解@RreventReSubmit
標(biāo)記所有 Controller 中的提交請求。通過 AOP 對所有標(biāo)記@RreventReSubmit
的方法攔截弃舒。在業(yè)務(wù)方法執(zhí)行前癞埠,獲取當(dāng)前用戶的 token(或者JSessionId)+ 當(dāng)前請求地址状原,作為一個唯一 KEY,去獲取 Redis 分布式鎖(如果此時并發(fā)獲取苗踪,只有一個線程會成功獲取鎖)颠区。當(dāng)有請求調(diào)用接口時,到 Redis 中查找相應(yīng)的 key通铲,如果能找到毕莱,則說明重復(fù)提交,如果找不到颅夺,則執(zhí)行操作朋截。業(yè)務(wù)方法執(zhí)行后,釋放鎖碗啄。
1??導(dǎo)入aop依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
2??自定義注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RreventReSubmit {
}
3??定義切面類:切面類需要使用@Aspect和@Component這兩個注解做標(biāo)注质和。
@Aspect
@Component
@Slf4j
public class UserAspect {
@Resource
private RedisUtil redisUtil;
@Value("${user.session.key}")
private String userSessionKey;
@Pointcut(value = "@annotation(com.xxp.annotation.RreventReSubmit )")
public void annotationPointCut() {
}
@Around("annotationPointCut()")
public Object NoReSubmit(ProceedingJoinPoint joinPoint) {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
//獲取request
HttpServletRequest request = attributes.getRequest();
HttpSession session = request.getSession();
//從session中獲取登錄的user對象,如果為null,則要求重新登錄
Object sessionUser = session.getAttribute(userSessionKey);
if (sessionUser == null) {
return Response.FAIL("頁面超時,請重新登錄");
}
User user = (User) sessionUser;
Integer userId = user.getId();
//獲取接口的請求參數(shù),如果時Article類型乖仇,則保存為Article對象栋操,使用Article對象里的title屬性
Object[] args = joinPoint.getArgs();
Article article = null;
for (Object object : args) {
if (object instanceof Article) {
article = (Article) object;
}
}
if (args == null) {
return Response.FAIL("請求參數(shù)錯誤");
}
//組裝redis key 從redis中獲取對應(yīng)的值
String key = userId + "_" + article.getTitle();
Object flag = redisUtil.getStr(key);
//如果redis中不存在對應(yīng)的值,則執(zhí)行原有的代碼邏輯(插入文章操作)
if (flag == null) {
//redis設(shè)置key,value值為1
redisUtil.setStr(key, "1");
//設(shè)置有效期為5分鐘
redisUtil.strSetExpireSeconds(key, 5 * 60L);
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
redisUtil.delStr(key);
return Response.FAIL("系統(tǒng)錯誤,請聯(lián)系管理員!");
}
} else {
//如果redis中存在對應(yīng)的值,則證明重復(fù)提交国夜,返回對應(yīng)的信息
log.info("{}:重復(fù)提交", key);
return Response.FAIL("重復(fù)提交");
}
}
}
在想要防止重復(fù)提交的接口上添加注解即可使用。