SpringBoot 防止接口重复提交

2024/1/28 SpringBoot 框架

# 1、幂等

# 1.1、幂等概念

  • 幂等是数学中的一个概念,表示进行1次变换和进行N次变换产生的效果相同。
  • 接口幂等性, 通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次。

# 1.2、常见场景

  • 订单接口, 不能多次创建订单。
  • 支付接口, 重复支付同一笔订单只能扣一次钱。
  • 支付宝回调接口, 可能会多次回调, 必须处理重复回调。
  • 普通表单提交接口, 因为网络超时,卡顿等原因多次点击提交, 只能成功一次等等。

# 2、幂等性解决方案

保证幂等性解决方案考虑

  • 前端层面:提交后按钮不可用或者隐藏状态。
  • 数据库层面:唯一索引防止脏数据。
  • 单机应用层面:考虑缓存,来防止重复提交。
  • 分布式层面:考虑token机制,分布式锁。

# 2.1、数据库层面

唯一索引

唯一索引防止新增脏数据,比如:注册时手机号唯一。

悲观锁

悲观锁获取数据的时候加锁(锁表或锁行)。

# 2.2、单机应用层面

token机制

token机制防止页面重复提交,实现接口幂等性校验,比如:商品下单前需要获取token令牌,提交订单时,token令牌一并提交。

乐观锁

基于版本号version实现, 在更新数据那一刻校验数据,比如:MyBatisPlus更新时可以设置版本号。

状态机

状态变更, 更新数据时判断状态。比如:通过原子类标记状态。

# 2.3、分布式层面

利用token机制+redis的分布式锁(jedis)来防止表单/请求重复提交。

# 3、单机案例-基于AOP加缓存

  • 采用【AOP解析自定义注解+google的Cache缓存机制】来解决表单/请求重复的提交问题。
  • google的Cache不太好用,我们直接换Hutool的Cacha工具来实现,也就是:【AOP+Hutool缓存】

参考:Hutool -【超时-TimedCache】 (opens new window)

# 3.1、引入依赖

<!-- aop -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
    <version>3.2.1</version>
</dependency>
<!--  hutool工具包  -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.16</version>
</dependency>

<!-- guava依赖包,Cache缓存 -->
<!-- <dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>32.1.3-jre</version>
</dependency> -->
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 3.2、自定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {

    // 缓存时间默认为10秒,请求完成自动清除,过期自动清除
    long cacheTime() default 10000;

}
1
2
3
4
5
6
7
8

# 3.3、AOP拦截防止重复提交

@Aspect
@Component
@Slf4j
public class RepeatSubmitAspect {

    /**
     * 缓存有效期为10秒
     */
    private static final TimedCache<String, Integer> cache = CacheUtil.newTimedCache(10000);

    @Around("@annotation(com.xygalaxy.annotation.RepeatSubmit)")
    public Object arround(ProceedingJoinPoint joinPoint) {
        // 是否重复请求异常
        Boolean isException10002 = false;
        try {
            // 获取请求数据作为key:sessionId+Path
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            String sessionId = attributes.getSessionId();
            HttpServletRequest request = attributes.getRequest();
            String key = sessionId + "-" + request.getServletPath();

            // 获取@RepeatSubmit注解
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            Method method = signature.getMethod();
            RepeatSubmit repeatSubmit = method.getAnnotation(RepeatSubmit.class);

            // 如果缓存中有这个url视为重复提交
            if (cache.get(key) == null) {
                // 缓存
                cache.put(key, 0, repeatSubmit.cacheTime());
                // 接着请求
                Object result = joinPoint.proceed();
                // 请求成功,移除缓存
                cache.remove(key);
                return result;
            } else {
                isException10002 = true;
            }
        } catch (Throwable e) {
            log.error("验证重复提交时出现未知异常:{}",e.getMessage());
        }
        if(!isException10002){
            throw new BusinessException("验证重复提交时出现未知异常", BusinessResponseStatus.HTTP_STATUS_10002.getResponseCode());
        }
        throw new BusinessException(BusinessResponseStatus.HTTP_STATUS_10002.getDescription(), BusinessResponseStatus.HTTP_STATUS_10002.getResponseCode());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

# 3.4、其他补充

  • BusinessResponseStatus枚举
@Getter
@AllArgsConstructor
public enum BusinessResponseStatus {

    SUCCESS("200", "success"),
    FAIL("500", "服务器异常"),
    HTTP_STATUS_200("200", "success"),
    HTTP_STATUS_400("400", "请求异常"),
    HTTP_STATUS_401("401", "no authentication"),
    HTTP_STATUS_403("403", "no authorities"),
    HTTP_STATUS_500("500", "server error"),
    HTTP_STATUS_10001("10001", "系统繁忙"),
    HTTP_STATUS_10002("10002", "重复请求,请稍后再试试");
    /**
     * 响应码
     */
    private final String responseCode;

    /**
     * 响应描述
     */
    private final String description;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  • BusinessException异常
@Getter
@NoArgsConstructor
public class BusinessException extends RuntimeException{

    private static final long serialVersionUID = 1L;

    /**
     * 响应码
     */
    private String status = BusinessResponseStatus.HTTP_STATUS_400.getResponseCode();

    /**
     * 结果信息
     */
    private String message = BusinessResponseStatus.HTTP_STATUS_400.getDescription();

    public BusinessException(String message) {
        this.message = message;
    }

    public BusinessException(String message,String status) {
        this.message = message;
        this.status = status;
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 3.5、控制层接口使用

睡眠一下,进行多次访问测试

@RestController
@RequestMapping("/repeatSubmit")
@Slf4j
@Tag(name = "防止重复提交控制层")
public class RepeatSubmitController {

    @SneakyThrows
    @Operation(description = "测试重复提交接口")
    @RepeatSubmit(cacheTime = 5)
    @ResponseResultApi
    @GetMapping(value = "/test")
    public String test(){
        Thread.sleep(2000);
        log.info("测试重复提交访问成功");
        return "测试重复提交访问成功";
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 3.6、测试效果

防止接口重复提交测试

# 4、分布式案例-基于Redis分布式锁加Token

  • 如果项目后面需要部署在分布式集群中,那么Cache机制就不能用了,Cache无法给多台服务器共享,就会出问题。
  • 解决方案:利用token机制+redis的分布式锁(redisson)来防止表单/请求重复提交。

# 4.1、Redisson介绍

Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。充分的利用了Redis键值数据库提供的一系列优势,基于Java实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。

Redisson是一个基于Redis实现的分布式工具,通讯基于Netty的综合的新型中间件,企业级开发中使用Redis的最佳范本。

# 4.2、新增依赖

需要引入Redis和Redisson依赖

<!--  redis  -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--  redisson  -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.25.2</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11

# 4.3、修改application.yml配置

增加Redis的配置

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      password: 123456
      database: 0
      timeout: 2000
1
2
3
4
5
6
7
8

# 4.4、配置RedissonConfig

@Configuration
public class RedissonConfig {

    @Value("${spring.data.redis.database}")
    private int database;

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private String port;

    @Value("${spring.data.redis.password}")
    private String password;

    @Value("${spring.data.redis.timeout}")
    private int timeout;

    /**
     * RedissonClient,单机模式
     * @return
     * @throws IOException
     */
    @Bean(destroyMethod = "shutdown")
    public RedissonClient redisson() {
        Config config = new Config();
        // 单机模式,如果时Redis集群,需要切换集群配置
        SingleServerConfig singleServerConfig = config.useSingleServer();
        singleServerConfig.setAddress("redis://" + host + ":" + port);
        singleServerConfig.setTimeout(timeout);
        singleServerConfig.setDatabase(database);
        if (password != null && !"".equals(password)) { //有密码
            singleServerConfig.setPassword(password);
        }
        return Redisson.create(config);
    }

    /**
     * redis集群配置
     * @return
     */
//    @Bean(destroyMethod = "shutdown")
//    public RedissonClient redissonHood() {
//        Config config = new Config();
//        config.useClusterServers()
//                .setScanInterval(2000) // 集群状态扫描间隔时间,单位是毫秒
//                //可以用"rediss://"来启用SSL连接
//                .addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001")
//                .addNodeAddress("redis://127.0.0.1:7002");
//        RedissonClient redisson = Redisson.create(config);
//        return Redisson.create(config);
//    }

    /**
     * 分布式锁实例化并交给工具类
     * @param redissonClient
     */
    @Bean
    public RedissonClient redissonLocker(RedissonClient redissonClient) {
        RedissonLockUtils.setRedissonClient(redissonClient);
        return redissonClient;
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

# 4.5、增加RedissonLockUtils

/**
 * Redisson分布式锁工具类
 */
public class RedissonLockUtils {

    private static RedissonClient redissonClient;


    public static void setRedissonClient(RedissonClient redissonClient) {
        RedissonLockUtils.redissonClient = redissonClient;
    }

    /**
     * 需要用其他RedissonClient方法可以自己调用
     * @return
     */
    public static RedissonClient getRedissonClient() {
       return RedissonLockUtils.redissonClient;
    }
    /**
     * 加锁
     * @param lockKey
     */
    public static void lock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock();
    }

    /**
     * 释放锁
     * @param lockKey
     */
    public static void unlock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.unlock();
    }

    /**
     * 加锁锁,设置有效期
     * @param lockKey key
     * @param leaseTime 有效时间,默认时间单位在实现类传入
     */
    public static void lock(String lockKey, int leaseTime) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(leaseTime, TimeUnit.MILLISECONDS);
    }

    /**
     * 加锁,设置有效期并指定时间单位
     * @param lockKey key
     * @param timeout 有效时间
     * @param unit    时间单位
     */
    public static void lock(String lockKey, int timeout, TimeUnit unit) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(timeout, unit);
    }

    /**
     * 尝试获取锁,获取到则持有该锁返回true,未获取到立即返回false
     * @param lockKey
     * @return true-获取锁成功 false-获取锁失败
     */
    public static boolean tryLock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        return lock.tryLock();
    }

    /**
     * 尝试获取锁,获取到则持有该锁leaseTime时间.
     * 若未获取到,在waitTime时间内一直尝试获取,超过watiTime还未获取到则返回false
     * @param lockKey   key
     * @param waitTime  尝试获取时间
     * @param leaseTime 锁持有时间
     * @param unit      时间单位
     * @return true-获取锁成功 false-获取锁失败
     */
    public static boolean tryLock(String lockKey, long waitTime, long leaseTime,
                           TimeUnit unit) throws InterruptedException {
        RLock lock = redissonClient.getLock(lockKey);
        return lock.tryLock(waitTime, leaseTime, unit);
    }

    /**
     * 锁是否被任意一个线程锁持有
     * @param lockKey
     * @return true-被锁 false-未被锁
     */
    public static boolean isLocked(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        return lock.isLocked();
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94

# 4.6、注解修改

@RepeatSubmit注解进行修改,兼容之前的单机缓存模式,并增加分布式锁所需配置。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {

    /**
     * STAND_ALONE:单机应用模式
     * HADOOP:分布式锁模式
     */
    public enum ModeEnum{ STAND_ALONE,HADOOP }

    /**
     * 缓存时间默认为10秒,请求完成自动清除,过期自动清除
     * @return
     */
    long cacheTime() default 10000;

    /**
     * 分布式锁等待锁时间,默认3秒
     * @return
     */
    long waitTime() default 3000;
    /**
     * 模式:默认单机缓存方式
     * @return
     */
    ModeEnum mode() default ModeEnum.STAND_ALONE;

    /**
     * 锁关键字,默认全部字段
     * @return
     */
    String keyParts() default "";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

# 4.7、AOP拦截处理

  • 兼容原来的单机缓存模式,并增加分布式锁方式,防止重复提交
  • token可以使用授权码+参数,或者用户id+参数
@Aspect
@Component
@Slf4j
public class RepeatSubmitAspect {

    /**
     * 缓存有效期为10秒
     */
    private static final TimedCache<String, Integer> cache = CacheUtil.newTimedCache(10000);

    @Around("@annotation(com.xygalaxy.annotation.RepeatSubmit)")
    public Object arround(ProceedingJoinPoint joinPoint) {
        // 获取@RepeatSubmit注解
        RepeatSubmit repeatSubmitAnnotation = ((MethodSignature) joinPoint.getSignature())
                .getMethod()
                .getAnnotation(RepeatSubmit.class);

        return repeatSubmitAnnotation.mode().equals(RepeatSubmit.ModeEnum.STAND_ALONE)?
                standAloneArround(joinPoint):hadoopArround(joinPoint);
    }

    /**
     * 单机模式下,通过缓存防止重复提交
     * @param joinPoint
     * @return
     */
    public Object standAloneArround(ProceedingJoinPoint joinPoint) {
        // 是否重复请求异常
        Boolean isException10002 = false;
        try {
            // 获取请求数据作为key:sessionId+Path
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            String sessionId = attributes.getSessionId();
            HttpServletRequest request = attributes.getRequest();
            String key = sessionId + "-" + request.getServletPath();

            // 获取@RepeatSubmit注解
            RepeatSubmit repeatSubmitAnnotation = ((MethodSignature) joinPoint.getSignature())
                    .getMethod()
                    .getAnnotation(RepeatSubmit.class);
            // 如果缓存中有这个url视为重复提交
            if (cache.get(key) == null) {
                // 缓存
                cache.put(key, 0, repeatSubmitAnnotation.cacheTime());
                // 接着请求
                Object result = joinPoint.proceed();
                // 请求成功,移除缓存
                cache.remove(key);
                return result;
            } else {
                isException10002 = true;
            }
        } catch (Throwable e) {
            log.error("验证重复提交时出现未知异常:{}",e.getMessage());
        }
        if(!isException10002){
            throw new BusinessException("验证重复提交时出现未知异常", BusinessResponseStatus.HTTP_STATUS_10002.getResponseCode());
        }
        throw new BusinessException(BusinessResponseStatus.HTTP_STATUS_10002.getDescription(), BusinessResponseStatus.HTTP_STATUS_10002.getResponseCode());
    }

    /**
     * 分布式,加分布式锁
     * @param joinPoint
     * @return
     */
    public Object hadoopArround(ProceedingJoinPoint joinPoint) {
        // 是否重复请求异常
        Boolean isException10002 = false;
        try {
            //当前线程名
            String threadName = Thread.currentThread().getName();
            log.info("线程{}------进入分布式锁aop------", threadName);
            //获取参数列表
            Object[] objs = joinPoint.getArgs();
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            //获取该注解的实例对象
            RepeatSubmit repeatSubmitAnnotation = signature.getMethod().getAnnotation(RepeatSubmit.class);
            // 拼接分布式锁key
            StringBuffer keyBuffer = new StringBuffer();
            // 没有参数时,锁方法
            if(objs.length==0){
                keyBuffer.append(signature.getDeclaringTypeName()+"#"+signature.getName());
            }else {
                //因为只有一个JSON参数,直接取第一个
                JSONObject param = (JSONObject) objs[0];
                //生成分布式锁key的键名,以逗号分隔
                String keyParts = repeatSubmitAnnotation.keyParts();
                if (StrUtil.isBlank(keyParts)) {
                    keyBuffer.append(param.toString());
                } else {
                    //生成分布式锁key
                    String[] keyPartArray = keyParts.split(",");
                    for (String keyPart : keyPartArray) {
                        keyBuffer.append(param.get(keyPart).toString());
                    }
                }
            }

            // 加上请求的sessionID,或者加上用户的授权码、用户id都可以
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            keyBuffer.append(attributes.getSessionId());

            // 使用参数/接口path+session最为token
            String token = keyBuffer.toString();
            log.info("线程{} 要加锁的key={}", threadName, token);
            //获取锁
            if (RedissonLockUtils.tryLock(token, repeatSubmitAnnotation.waitTime(), repeatSubmitAnnotation.cacheTime(), TimeUnit.MILLISECONDS)) {
                try {
                    log.info("线程{} 获取锁成功", threadName);
                    return joinPoint.proceed();
                } finally {
                    RedissonLockUtils.unlock(token);
                    log.info("线程{} 释放锁", threadName);
                }
            } else {
                log.info("线程{} 获取锁失败", threadName);
                isException10002 = true;
            }
        } catch (Throwable e) {
            log.error("验证重复提交时出现未知异常:{}",e.getMessage());
        }
        if(!isException10002){
            throw new BusinessException("验证重复提交时出现未知异常", BusinessResponseStatus.HTTP_STATUS_10002.getResponseCode());
        }
        throw new BusinessException(BusinessResponseStatus.HTTP_STATUS_10002.getDescription(), BusinessResponseStatus.HTTP_STATUS_10002.getResponseCode());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128

# 4.8、接口测试使用

@SneakyThrows
@Operation(description = "测试重复提交接口")
@RepeatSubmit(mode = RepeatSubmit.ModeEnum.HADOOP)
@ResponseResultApi
@GetMapping(value = "/test2")
public String test2(){
    Thread.sleep(5000);
    return "测试重复提交访问成功";
}
1
2
3
4
5
6
7
8
9

# 4.9、测试效果

分布式-防止接口重复提交测试