從零搭建開(kāi)發(fā)腳手架 保證服務(wù)的冪等性和防止重復(fù)請(qǐng)求
- 什么是冪等?
- 重復(fù)請(qǐng)求原因
- 解決方案
- 方案一:前端同步阻塞按鈕置灰
- 方案二:前后端搭配干活,預(yù)生成訂單號(hào)
- 方案三:通用方案,鎖模式
- 實(shí)現(xiàn)
- 自定義注解限制重復(fù)提交
- 自定義切面攔截過(guò)濾處理
- 使用示例
什么是冪等?
多次執(zhí)行的結(jié)果和一次執(zhí)行的結(jié)果相同,例如查詢(xún)操作天然就是冪等的。
重復(fù)請(qǐng)求原因
我們以電商場(chǎng)景中的下單來(lái)舉例,造成下單重復(fù)一般有以下幾個(gè)原因:
- 用戶(hù)手抖點(diǎn)快了,導(dǎo)致多次重復(fù)下單。
- 網(wǎng)絡(luò)抖動(dòng)導(dǎo)致失敗或者超時(shí)重傳,例如nginx、Fegin、RPC框架等
解決方案
方案一:前端同步阻塞按鈕置灰
前端同步阻塞按鈕置灰,用戶(hù)點(diǎn)擊“發(fā)布”按鈕后,在網(wǎng)絡(luò)請(qǐng)求沒(méi)有返回,或者超時(shí)之前,用戶(hù)都不可以繼續(xù)點(diǎn)擊“發(fā)布按鈕”,界面可以將按鈕置灰或者轉(zhuǎn)圈。
優(yōu)點(diǎn):實(shí)現(xiàn)成本極低
缺點(diǎn):
- 只能防御用戶(hù)手抖的誤操作。
- 確防不住遠(yuǎn)程調(diào)用的重試以及惡意重放。
方案二:前后端搭配干活,預(yù)生成訂單號(hào)
可以通過(guò)預(yù)先生成訂單號(hào)(在進(jìn)入下單頁(yè)面的時(shí)候生成訂單號(hào)),然后利用數(shù)據(jù)庫(kù)中訂單號(hào)的唯一約束這個(gè)特性,避免重復(fù)寫(xiě)入訂單。
時(shí)序圖如下:
細(xì)節(jié)如下:
訂單號(hào)生成時(shí)機(jī)
是在進(jìn)入訂單頁(yè)面,而不是提交訂單的時(shí)候 。
訂單號(hào)生成規(guī)則
- 小規(guī)模系統(tǒng)完全可以用MySQL的Sequence或者Redis來(lái)生成。大規(guī)模系統(tǒng)也可以采用類(lèi)似雪花算法之類(lèi)的方式分布式生成GUID。
- 訂單號(hào)中最好包含一些品類(lèi)、時(shí)間等信息,便于業(yè)務(wù)處理,它不能是一個(gè)單純自增的ID,否則別人很容易根據(jù)訂單號(hào)計(jì)算出你大致的銷(xiāo)量,所以訂單號(hào)的生產(chǎn)算法在保證不重復(fù)的前提下,一般都會(huì)加入很多業(yè)務(wù)規(guī)則在里面。
訂單號(hào)是否是主鍵
方式一:使用訂單號(hào)做主鍵
如果訂單號(hào)不是遞增的可能造成頻繁頁(yè)分裂,導(dǎo)致并發(fā)高的時(shí)候性能降低,所以要保證訂單號(hào)全局遞增。
方式二:有自增主鍵和訂單號(hào)列并設(shè)置唯一索引
因?yàn)橛唵翁?hào)不是主鍵,所以根據(jù)訂單號(hào)查詢(xún)會(huì)多一次回表操作,且如果訂單號(hào)不遞增二級(jí)訂單號(hào)索引也會(huì)有頁(yè)分裂。
訂單號(hào)可以由前端生成嗎
不可以,訂單號(hào)一定是在后端生成,后端生成可以保證全局唯一,且可以用于做安全認(rèn)證,不是后端頒發(fā)的訂單號(hào)不予處理。
提交訂單的時(shí)候,一種是先拿著訂單號(hào)去查庫(kù),讓業(yè)務(wù)代碼校驗(yàn)是否存在,另一種是直接利用庫(kù)表主鍵唯一約束拋異常,這兩種處理方式哪種性能更好?
選后者,等查完庫(kù)確定不存在再插入的時(shí)候,可能數(shù)據(jù)已經(jīng)變化了,訂單存在了,還是要拋異常,檢查意義不大。
方案三:通用方案,鎖模式
使用鎖來(lái)控制一段時(shí)間內(nèi)的重復(fù)請(qǐng)求,注意: 鎖的粒度為用戶(hù)+業(yè)務(wù)。
請(qǐng)求流程如下:
- 1.請(qǐng)求接口時(shí),獲取一個(gè)鎖 鎖的粒度 :同一用戶(hù)的同一操作邏輯 鎖名稱(chēng)規(guī)則:業(yè)務(wù)名稱(chēng)+用戶(hù)ID
- 2.給鎖設(shè)置過(guò)期時(shí)間10秒,防止業(yè)務(wù)邏輯執(zhí)行錯(cuò)誤,用戶(hù)一直被鎖住
- 3.如果被鎖了,返回“正在處理,請(qǐng)勿重復(fù)提交”
- 4.沒(méi)有被鎖,執(zhí)行正常邏輯,在邏輯結(jié)束后,刪掉鎖
實(shí)現(xiàn)
針對(duì)方案三實(shí)現(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 "";
- /**
- * 是否用戶(hù)隔離,默認(rèn)啟用
- */
- boolean userLimit() default true;
- /**
- * 鎖時(shí)間 默認(rèn)10s
- */
- int time() default 10;
- }
自定義切面攔截過(guò)濾處理
- @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("請(qǐng)勿重復(fù)訪問(wèn)!");
- }
- 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) {
- }
請(qǐng)求示例:http://localhost:8080/api/v1/tokenInfo?name=123
鎖粒度為:taokeninfo:1:123
防重效果:
- {
- code: "500",
- msg: "請(qǐng)勿重復(fù)訪問(wèn)!"
- }
參考:
后端存儲(chǔ)實(shí)踐課