- 什么是幂等?
- 重复请求原因
- 解决方案
- 方案一:前端同步阻塞按钮置灰
- 方案二:前后端搭配干活,预生成订单号
- 方案三:通用方案,锁模式
- 实现
- 自定义注解限制重复提交
- 自定义切面拦截过滤处理
- 使用示例
什么是幂等?
多次执行的结果和一次执行的结果相同,例如查询操作天然就是幂等的。
重复请求原因
我们以电商场景中的下单来举例,造成下单重复一般有以下几个原因:
- 用户手抖点快了,导致多次重复下单。
- 网络抖动导致失败或者超时重传,例如nginx、Fegin、RPC框架等
解决方案
方案一:前端同步阻塞按钮置灰
前端同步阻塞按钮置灰,用户点击“发布”按钮后,在网络请求没有返回,或者超时之前,用户都不可以继续点击“发布按钮”,界面可以将按钮置灰或者转圈。
优点:实现成本极低
缺点:
- 只能防御用户手抖的误操作。
- 确防不住远程调用的重试以及恶意重放。
方案二:前后端搭配干活,预生成订单号
可以通过预先生成订单号(在进入下单页面的时候生成订单号),然后利用数据库中订单号的唯一约束这个特性,避免重复写入订单。
时序图如下:
细节如下:
订单号生成时机
是在进入订单页面,而不是提交订单的时候 。
订单号生成规则
- 小规模系统完全可以用MySQL的Sequence或者Redis来生成。大规模系统也可以采用类似雪花算法之类的方式分布式生成GUID。
- 订单号中最好包含一些品类、时间等信息,便于业务处理,它不能是一个单纯自增的ID,否则别人很容易根据订单号计算出你大致的销量,所以订单号的生产算法在保证不重复的前提下,一般都会加入很多业务规则在里面。
订单号是否是主键
方式一:使用订单号做主键
如果订单号不是递增的可能造成频繁页分裂,导致并发高的时候性能降低,所以要保证订单号全局递增。
方式二:有自增主键和订单号列并设置唯一索引
因为订单号不是主键,所以根据订单号查询会多一次回表操作,且如果订单号不递增二级订单号索引也会有页分裂。
订单号可以由前端生成吗
不可以,订单号一定是在后端生成,后端生成可以保证全局唯一,且可以用于做安全认证,不是后端颁发的订单号不予处理。
提交订单的时候,一种是先拿着订单号去查库,让业务代码校验是否存在,另一种是直接利用库表主键唯一约束抛异常,这两种处理方式哪种性能更好?
选后者,等查完库确定不存在再插入的时候,可能数据已经变化了,订单存在了,还是要抛异常,检查意义不大。
方案三:通用方案,锁模式
使用锁来控制一段时间内的重复请求,注意: 锁的粒度为用户+业务。
请求流程如下:
- 1.请求接口时,获取一个锁 锁的粒度 :同一用户的同一操作逻辑 锁名称规则:业务名称+用户ID
- 2.给锁设置过期时间10秒,防止业务逻辑执行错误,用户一直被锁住
- 3.如果被锁了,返回“正在处理,请勿重复提交”
- 4.没有被锁,执行正常逻辑,在逻辑结束后,删掉锁
实现
针对方案三实现如下:
自定义注解限制重复提交
- @Target(ElementType.METHOD)
- @Retention(RetentionPolicy.RUNTIME)
- @Documented
- @Inherited
- public @interface RepeatSubmitLimit {
- /**
- * 业务key,例如下单业务 order
- */
- String businessKey();
- /**
- * 业务参数,用于做更细粒度锁,例如锁到具体 订单id #orderId
- */
- String businessParam() default "";
- /**
- * 是否用户隔离,默认启用
- */
- boolean userLimit() default true;
- /**
- * 锁时间 默认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("请勿重复访问!");
- }
- 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: "请勿重复访问!"
- }
参考:
后端存储实践课