從零搭建開發(fā)腳手架 保證服務(wù)的冪等性和防止重復(fù)請求
- 什么是冪等?
- 重復(fù)請求原因
- 解決方案
- 方案一:前端同步阻塞按鈕置灰
- 方案二:前后端搭配干活,預(yù)生成訂單號
- 方案三:通用方案,鎖模式
- 實現(xiàn)
- 自定義注解限制重復(fù)提交
- 自定義切面攔截過濾處理
- 使用示例
什么是冪等?
多次執(zhí)行的結(jié)果和一次執(zhí)行的結(jié)果相同,例如查詢操作天然就是冪等的。
重復(fù)請求原因
我們以電商場景中的下單來舉例,造成下單重復(fù)一般有以下幾個原因:
- 用戶手抖點快了,導(dǎo)致多次重復(fù)下單。
- 網(wǎng)絡(luò)抖動導(dǎo)致失敗或者超時重傳,例如nginx、Fegin、RPC框架等
解決方案
方案一:前端同步阻塞按鈕置灰
前端同步阻塞按鈕置灰,用戶點擊“發(fā)布”按鈕后,在網(wǎng)絡(luò)請求沒有返回,或者超時之前,用戶都不可以繼續(xù)點擊“發(fā)布按鈕”,界面可以將按鈕置灰或者轉(zhuǎn)圈。
優(yōu)點:實現(xiàn)成本極低
缺點:
- 只能防御用戶手抖的誤操作。
- 確防不住遠(yuǎn)程調(diào)用的重試以及惡意重放。
方案二:前后端搭配干活,預(yù)生成訂單號
可以通過預(yù)先生成訂單號(在進(jìn)入下單頁面的時候生成訂單號),然后利用數(shù)據(jù)庫中訂單號的唯一約束這個特性,避免重復(fù)寫入訂單。
時序圖如下:
細(xì)節(jié)如下:
訂單號生成時機(jī)
是在進(jìn)入訂單頁面,而不是提交訂單的時候 。
訂單號生成規(guī)則
- 小規(guī)模系統(tǒng)完全可以用MySQL的Sequence或者Redis來生成。大規(guī)模系統(tǒng)也可以采用類似雪花算法之類的方式分布式生成GUID。
- 訂單號中最好包含一些品類、時間等信息,便于業(yè)務(wù)處理,它不能是一個單純自增的ID,否則別人很容易根據(jù)訂單號計算出你大致的銷量,所以訂單號的生產(chǎn)算法在保證不重復(fù)的前提下,一般都會加入很多業(yè)務(wù)規(guī)則在里面。
訂單號是否是主鍵
方式一:使用訂單號做主鍵
如果訂單號不是遞增的可能造成頻繁頁分裂,導(dǎo)致并發(fā)高的時候性能降低,所以要保證訂單號全局遞增。
方式二:有自增主鍵和訂單號列并設(shè)置唯一索引
因為訂單號不是主鍵,所以根據(jù)訂單號查詢會多一次回表操作,且如果訂單號不遞增二級訂單號索引也會有頁分裂。
訂單號可以由前端生成嗎
不可以,訂單號一定是在后端生成,后端生成可以保證全局唯一,且可以用于做安全認(rèn)證,不是后端頒發(fā)的訂單號不予處理。
提交訂單的時候,一種是先拿著訂單號去查庫,讓業(yè)務(wù)代碼校驗是否存在,另一種是直接利用庫表主鍵唯一約束拋異常,這兩種處理方式哪種性能更好?
選后者,等查完庫確定不存在再插入的時候,可能數(shù)據(jù)已經(jīng)變化了,訂單存在了,還是要拋異常,檢查意義不大。
方案三:通用方案,鎖模式
使用鎖來控制一段時間內(nèi)的重復(fù)請求,注意: 鎖的粒度為用戶+業(yè)務(wù)。
請求流程如下:
- 1.請求接口時,獲取一個鎖 鎖的粒度 :同一用戶的同一操作邏輯 鎖名稱規(guī)則:業(yè)務(wù)名稱+用戶ID
- 2.給鎖設(shè)置過期時間10秒,防止業(yè)務(wù)邏輯執(zhí)行錯誤,用戶一直被鎖住
- 3.如果被鎖了,返回“正在處理,請勿重復(fù)提交”
- 4.沒有被鎖,執(zhí)行正常邏輯,在邏輯結(jié)束后,刪掉鎖
實現(xiàn)
針對方案三實現(xiàn)如下:
自定義注解限制重復(fù)提交
- @Target(ElementType.METHOD)
- @Retention(RetentionPolicy.RUNTIME)
- @Documented
- @Inherited
- public @interface RepeatSubmitLimit {
- /**
- * 業(yè)務(wù)key,例如下單業(yè)務(wù) order
- */
- String businessKey();
- /**
- * 業(yè)務(wù)參數(shù),用于做更細(xì)粒度鎖,例如鎖到具體 訂單id #orderId
- */
- String businessParam() default "";
- /**
- * 是否用戶隔離,默認(rèn)啟用
- */
- boolean userLimit() default true;
- /**
- * 鎖時間 默認(rèn)10s
- */
- int time() default 10;
- }
自定義切面攔截過濾處理
- @Component
- @Aspect
- @Slf4j
- public class LimitSubmitAspect {
- LFUCache<Object, Object> LFUCACHE = CacheUtil.newLFUCache(100, 60 * 1000);
- @Pointcut("@annotation(RepeatSubmitLimit)")
- private void pointcut() {
- }
- @Around("pointcut()")
- public Object handleSubmit(ProceedingJoinPoint joinPoint) throws Throwable {
- Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
- //獲取注解信息
- RepeatSubmitLimit repeatSubmitLimit = method.getAnnotation(RepeatSubmitLimit.class);
- int limitTime = repeatSubmitLimit.time();
- String key = getLockKey(joinPoint, repeatSubmitLimit);
- Object result = LFUCACHE.get(key, false);
- if (result != null) {
- throw new BusinessException("請勿重復(fù)訪問!");
- }
- LFUCACHE.put(key, StpUtil.getLoginId(), limitTime * 1000);
- try {
- Object proceed = joinPoint.proceed();
- return proceed;
- } catch (Throwable e) {
- log.error("Exception in {}.{}() with cause = \'{}\' and exception = \'{}\'", joinPoint.getSignature().getDeclaringTypeName(),
- joinPoint.getSignature().getName(), e.getCause() != null ? e.getCause() : "NULL", e.getMessage(), e);
- throw e;
- } finally {
- LFUCACHE.remove(key);
- }
- }
- private static final ParameterNameDiscoverer NAME_DISCOVERER = new DefaultParameterNameDiscoverer();
- private static final ExpressionParser PARSER = new SpelExpressionParser();
- private String getLockKey(ProceedingJoinPoint joinPoint, RepeatSubmitLimit repeatSubmitLimit) {
- String businessKey = repeatSubmitLimit.businessKey();
- boolean userLimit = repeatSubmitLimit.userLimit();
- String businessParam = repeatSubmitLimit.businessParam();
- if (userLimit) {
- businessKey = businessKey + ":" + StpUtil.getLoginId();
- }
- if (StrUtil.isNotBlank(businessParam)) {
- Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
- EvaluationContext context = new MethodBasedEvaluationContext(null, method, joinPoint.getArgs(), NAME_DISCOVERER);
- String key = PARSER.parseExpression(businessParam).getValue(context, String.class);
- businessKey = businessKey + ":" + key;
- }
- return businessKey;
- }
- }
使用示例
- @RepeatSubmitLimit(businessKey = "tokenInfo", businessParam = "#name")
- @GetMapping("/api/v1/tokenInfo")
- public Response tokenInfo(String name) {
- }
請求示例:http://localhost:8080/api/v1/tokenInfo?name=123
鎖粒度為:taokeninfo:1:123
防重效果:
- {
- code: "500",
- msg: "請勿重復(fù)訪問!"
- }
參考:
后端存儲實踐課