从零搭建开发脚手架 保证服务的幂等性和防止重复请求

开发 架构
前端同步阻塞按钮置灰,用户点击“发布”按钮后,在网络请求没有返回,或者超时之前,用户都不可以继续点击“发布按钮”,界面可以将按钮置灰或者转圈。
本文转载自微信公众号「Java大厂面试官」,作者laker。转载本文请联系Java大厂面试官公众号。
  •  什么是幂等?
  • 重复请求原因
  • 解决方案
    • 方案一:前端同步阻塞按钮置灰
    • 方案二:前后端搭配干活,预生成订单号
    • 方案三:通用方案,锁模式
  • 实现
    • 自定义注解限制重复提交
    • 自定义切面拦截过滤处理
    • 使用示例

什么是幂等?

多次执行的结果和一次执行的结果相同,例如查询操作天然就是幂等的。

重复请求原因

我们以电商场景中的下单来举例,造成下单重复一般有以下几个原因:

  • 用户手抖点快了,导致多次重复下单。
  • 网络抖动导致失败或者超时重传,例如nginx、Fegin、RPC框架等

解决方案

方案一:前端同步阻塞按钮置灰

前端同步阻塞按钮置灰,用户点击“发布”按钮后,在网络请求没有返回,或者超时之前,用户都不可以继续点击“发布按钮”,界面可以将按钮置灰或者转圈。

优点:实现成本极低

缺点:

  1. 只能防御用户手抖的误操作。
  2. 确防不住远程调用的重试以及恶意重放。

方案二:前后端搭配干活,预生成订单号

可以通过预先生成订单号(在进入下单页面的时候生成订单号),然后利用数据库中订单号的唯一约束这个特性,避免重复写入订单。

时序图如下:

细节如下:

订单号生成时机

是在进入订单页面,而不是提交订单的时候 。

订单号生成规则

  • 小规模系统完全可以用MySQL的Sequence或者Redis来生成。大规模系统也可以采用类似雪花算法之类的方式分布式生成GUID。
  • 订单号中最好包含一些品类、时间等信息,便于业务处理,它不能是一个单纯自增的ID,否则别人很容易根据订单号计算出你大致的销量,所以订单号的生产算法在保证不重复的前提下,一般都会加入很多业务规则在里面。

订单号是否是主键

方式一:使用订单号做主键

如果订单号不是递增的可能造成频繁页分裂,导致并发高的时候性能降低,所以要保证订单号全局递增。

方式二:有自增主键和订单号列并设置唯一索引

因为订单号不是主键,所以根据订单号查询会多一次回表操作,且如果订单号不递增二级订单号索引也会有页分裂。

订单号可以由前端生成吗

不可以,订单号一定是在后端生成,后端生成可以保证全局唯一,且可以用于做安全认证,不是后端颁发的订单号不予处理。

提交订单的时候,一种是先拿着订单号去查库,让业务代码校验是否存在,另一种是直接利用库表主键唯一约束抛异常,这两种处理方式哪种性能更好?

选后者,等查完库确定不存在再插入的时候,可能数据已经变化了,订单存在了,还是要抛异常,检查意义不大。

方案三:通用方案,锁模式

使用锁来控制一段时间内的重复请求,注意: 锁的粒度为用户+业务。

请求流程如下:

  • 1.请求接口时,获取一个锁 锁的粒度 :同一用户的同一操作逻辑 锁名称规则:业务名称+用户ID
  • 2.给锁设置过期时间10秒,防止业务逻辑执行错误,用户一直被锁住
  • 3.如果被锁了,返回“正在处理,请勿重复提交”
  • 4.没有被锁,执行正常逻辑,在逻辑结束后,删掉锁

实现

针对方案三实现如下:

自定义注解限制重复提交

  1. @Target(ElementType.METHOD) 
  2. @Retention(RetentionPolicy.RUNTIME) 
  3. @Documented 
  4. @Inherited 
  5. public @interface RepeatSubmitLimit { 
  6.     /** 
  7.      * 业务key,例如下单业务 order 
  8.      */ 
  9.     String businessKey(); 
  10.  
  11.     /** 
  12.      * 业务参数,用于做更细粒度锁,例如锁到具体 订单id #orderId 
  13.      */ 
  14.     String businessParam() default ""
  15.  
  16.     /** 
  17.      * 是否用户隔离,默认启用 
  18.      */ 
  19.     boolean userLimit() default true
  20.  
  21.     /** 
  22.      * 锁时间 默认10s 
  23.      */ 
  24.     int time() default 10; 

自定义切面拦截过滤处理

  1. @Component 
  2. @Aspect 
  3. @Slf4j 
  4. public class LimitSubmitAspect { 
  5.     LFUCache<Object, Object> LFUCACHE = CacheUtil.newLFUCache(100, 60 * 1000); 
  6.  
  7.     @Pointcut("@annotation(RepeatSubmitLimit)"
  8.     private void pointcut() { 
  9.     } 
  10.  
  11.     @Around("pointcut()"
  12.     public Object handleSubmit(ProceedingJoinPoint joinPoint) throws Throwable { 
  13.  
  14.  
  15.         Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); 
  16.         //获取注解信息 
  17.         RepeatSubmitLimit repeatSubmitLimit = method.getAnnotation(RepeatSubmitLimit.class); 
  18.         int limitTime = repeatSubmitLimit.time(); 
  19.         String key = getLockKey(joinPoint, repeatSubmitLimit); 
  20.         Object result = LFUCACHE.get(keyfalse); 
  21.         if (result != null) { 
  22.             throw new BusinessException("请勿重复访问!"); 
  23.         } 
  24.         LFUCACHE.put(key, StpUtil.getLoginId(), limitTime * 1000); 
  25.         try { 
  26.             Object proceed = joinPoint.proceed(); 
  27.             return proceed; 
  28.         } catch (Throwable e) { 
  29.             log.error("Exception in {}.{}() with cause = \'{}\' and exception = \'{}\'", joinPoint.getSignature().getDeclaringTypeName(), 
  30.                     joinPoint.getSignature().getName(), e.getCause() != null ? e.getCause() : "NULL", e.getMessage(), e); 
  31.             throw e; 
  32.         } finally { 
  33.             LFUCACHE.remove(key); 
  34.         } 
  35.     } 
  36.  
  37.     private static final ParameterNameDiscoverer NAME_DISCOVERER = new DefaultParameterNameDiscoverer(); 
  38.  
  39.     private static final ExpressionParser PARSER = new SpelExpressionParser(); 
  40.  
  41.     private String getLockKey(ProceedingJoinPoint joinPoint, RepeatSubmitLimit repeatSubmitLimit) { 
  42.         String businessKey = repeatSubmitLimit.businessKey(); 
  43.         boolean userLimit = repeatSubmitLimit.userLimit(); 
  44.         String businessParam = repeatSubmitLimit.businessParam(); 
  45.         if (userLimit) { 
  46.             businessKey = businessKey + ":" + StpUtil.getLoginId(); 
  47.         } 
  48.  
  49.         if (StrUtil.isNotBlank(businessParam)) { 
  50.             Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); 
  51.             EvaluationContext context = new MethodBasedEvaluationContext(null, method, joinPoint.getArgs(), NAME_DISCOVERER); 
  52.             String key = PARSER.parseExpression(businessParam).getValue(context, String.class); 
  53.             businessKey = businessKey + ":" + key
  54.         } 
  55.         return businessKey; 
  56.     } 

使用示例

  1. @RepeatSubmitLimit(businessKey = "tokenInfo", businessParam = "#name"
  2.   @GetMapping("/api/v1/tokenInfo"
  3.   public Response tokenInfo(String name) { 
  4.   } 

请求示例:http://localhost:8080/api/v1/tokenInfo?name=123

锁粒度为:taokeninfo:1:123

防重效果:

  1.  code: "500"
  2.  msg: "请勿重复访问!" 

参考:

后端存储实践课

 

责任编辑:武晓燕 来源: Java大厂面试官
相关推荐

2021-04-28 16:10:48

开发脚手架 Spring

2021-02-19 22:43:50

开发脚手架Controller

2021-05-13 17:02:38

MDC脚手架日志

2021-07-13 18:42:38

Spring Boot脚手架开发

2021-04-13 14:47:53

认证授权Java

2021-07-29 18:49:49

Spring开发脚手架

2020-08-19 08:55:47

Redis缓存数据库

2021-04-20 19:24:16

脚手架 Java微信

2021-09-01 10:07:43

开发零搭建Groovy

2021-03-09 17:11:09

数据库脚手架开发

2021-03-11 14:16:47

Spring Boo开发脚手架

2021-04-14 17:18:27

幂等性数据源MySQL

2016-08-10 14:59:41

前端Javascript工具

2023-11-21 17:36:04

OpenFeignSentinel

2021-01-07 05:34:07

脚手架JDK缓存

2023-09-01 15:27:31

2018-06-11 14:39:57

前端脚手架工具node.js

2018-08-30 16:08:37

Node.js脚手架工具

2014-08-15 09:36:06

2020-07-15 08:14:12

高并发
点赞
收藏

51CTO技术栈公众号