SpringBoot实现接口防重复提交的最佳实践
作者:(farerboy)

在前端网络抖动、用户手抖连击,或者微服务重试机制下,同一个接口可能在极短时间内被多次调用。如果接口不是幂等的(如支付扣款、创建订单),就会导致数据错乱。本文将深入讲解如何利用 Redis + AOP + 自定义注解 构建一套优雅的防重复提交方案。
一、 什么是接口幂等性?
幂等性 (Idempotency) 是指任意多次执行所产生的影响均与一次执行的影响相同。
在 Web 开发中,常见的非幂等场景包括:
- 用户提交订单时连点两次“支付”,导致扣款两次。
- 前端网络超时重试,导致插入两条相同的记录。
- 消息队列(MQ)消费者重试,导致同一条消息被处理多次。
为了保证系统的数据一致性,我们需要对关键接口进行防重复提交处理。
二、 常见解决方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 数据库唯一索引 | 利用唯一约束拦截重复插入 | 最底层,兜底保障 | 仅适用于插入场景,无法拦截所有业务逻辑 | 所有涉及唯一数据的场景 |
| Token 机制 | 提交前请求 Token,提交时校验并删除 | 通用性强,逻辑清晰 | 需要前端配合,多了一步交互 | 表单提交、关键操作 |
| 分布式锁 | 基于请求参数或用户 ID 加锁 | 实现简单,无需前端改代码 | 锁粒度控制复杂,容易误伤正常请求 | 简单防重,非强一致性要求 |
| 状态机控制 | 通过记录状态流转(如订单状态) | 业务层面防重 | 侵入业务代码 | 订单、审批流 |
本文重点实现:Token 机制 + AOP + Redis,这是目前企业中最通用、体验最好的方案。
三、 核心思路:Token 机制
流程如下:
- 用户进入页面或点击提交前,前端调用
/api/token获取一个唯一的 Token。 - 后端将 Token 存入 Redis,设置过期时间(如 5 分钟)。
- 前端在提交业务数据时,将 Token 放在 Header 中。
- 后端通过 AOP 拦截请求,检查 Redis 中是否存在该 Token:
- 存在:删除 Token,放行请求。
- 不存在:说明是重复请求或 Token 已失效,拒绝请求。
为什么先存后删? 利用 Redis 的 del 操作是原子性的,结合 setnx (Set if Not Exists) 可以保证只有一个请求能成功拿到 Token。
四、 实战编码
1. 定义自定义注解
我们希望防重逻辑对业务代码无侵入,只需加一个注解即可。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoRepeatSubmit {
/**
* 锁过期时间,默认 5 秒
*/
long expireTime() default 5;
/**
* 提示信息
*/
String message() default "请勿重复提交";
}
2. Redis 工具类封装
我们需要利用 Redis 的原子操作。这里假设你已经集成了 RedisTemplate。
@Component
public class RedisUtil {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String PREFIX = "no_repeat_submit:";
/**
* 尝试获取锁 (对应 Redis SETNX)
* @param key Key
* @param expireTime 过期时间 (秒)
* @return 如果 key 不存在则设置成功返回 true,否则返回 false
*/
public boolean tryLock(String key, long expireTime) {
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(PREFIX + key, "1", expireTime, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
/**
* 删除锁 (验证通过后删除)
* 注意:Token 机制通常是获取到就删除,或者验证通过就删除。
* 如果是分布式锁模式,则不需要这步,依靠过期时间自动释放。
* 本方案采用 Token 模式:请求来时检查是否有,有则删除并放行。
*/
public boolean releaseLock(String key) {
// 这里为了简化,直接删除。实际 Token 机制中,tryLock 返回 true 即代表获取成功。
// 如果是“检查并删除”的原子操作,需要 Lua 脚本。
// 但 Token 模式通常是:前端拿 token -> Redis set token -> 提交带 token -> 后端 check and delete token
// 下面 AOP 中我们将演示“检查并删除”的逻辑
return Boolean.TRUE.equals(redisTemplate.delete(PREFIX + key));
}
}
3. 编写 AOP 切面
这是最核心的部分。
@Slf4j
@Aspect
@Component
public class NoRepeatSubmitAspect {
@Autowired
private RedisUtil redisUtil;
@Pointcut("@annotation(com.example.annotation.NoRepeatSubmit)")
public void pointcut() {}
@Around("pointcut()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
// 1. 获取注解信息
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
NoRepeatSubmit annotation = method.getAnnotation(NoRepeatSubmit.class);
// 2. 生成 Key (策略:User ID + 请求 URI + 参数 Hash)
// 这样防止不同接口之间冲突,也防止同一接口不同参数冲突
HttpServletRequest request = getRequest();
String userId = getCurrentUserId(); // 假设从 SecurityContext 获取
String uri = request.getRequestURI();
String paramsHash = getParamsHash(joinPoint.getArgs());
String lockKey = userId + ":" + uri + ":" + paramsHash;
// 3. 检查并删除 Token (原子操作)
// 注意:RedisTemplate 默认没有“检查并删除”的原子方法,建议使用 Lua 脚本
// 这里为了演示清晰,使用简单的 exists + delete,实际高并发推荐 Lua
if (redisUtil.releaseLock(lockKey)) {
log.info("重复提交校验通过: key={}", lockKey);
return joinPoint.proceed();
} else {
log.warn("重复提交拦截: key={}", lockKey);
throw new BizException(400, annotation.message());
}
}
// ... 辅助方法:getRequest(), getCurrentUserId(), getParamsHash()
// getParamsHash 可以通过计算 args 的 MD5 或 JSON HashCode 实现
}
修正:Token 模式 vs 分布式锁模式
- Token 模式:前端先请求
/token,后端redis.set(token, 1)。提交时,后端redis.del(token)。如果删除成功(返回 1),说明是第一次提交;如果删除失败(返回 0),说明是重复提交。这种方式更精准,不会误杀并发请求。 - AOP 锁模式:如上文 AOP 代码所示,直接拦截。如果两个请求几乎同时到达,
del操作可能产生竞争。为了严谨,AOP 防重提交通常采用 Redis Lua 脚本 保证 “Get and Delete” 的原子性,或者使用 Token 模式。
4. 进阶:基于 Lua 脚本的原子删除
为了在 AOP 中安全地实现“检查并删除”,我们使用 Lua 脚本。
-- Lua script: if key exists, delete it and return 1, else return 0
if redis.call("exists", KEYS[1]) == 1 then
return redis.call("del", KEYS[1])
else
return 0
end在 Java 中封装:
public boolean checkAndDelete(String key) {
String script = "if redis.call('exists', KEYS[1]) == 1 then return redis.call('del', KEYS[1]) else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Long result = redisTemplate.execute(redisScript, Collections.singletonList(PREFIX + key));
return result != null && result > 0;
}
将 AOP 中的判断逻辑替换为 if (redisUtil.checkAndDelete(lockKey)) 即可。
五、 方案落地总结
- 前端配合:如果是 Token 模式,需要前端在进入页面时请求 Token 并缓存在变量中,提交时携带。
- Key 的生成:Key 必须包含用户标识,防止 A 用户提交的 Token 被 B 用户复用。
- 过期时间:一定要设置过期时间,防止 Redis 内存泄漏。
- 粒度控制:如果希望“同一用户同一接口不管参数如何都不能并发”,Key 中可以去掉参数 Hash;如果希望“不同参数可以并发”,则必须带上参数 Hash。
六、 常见坑点
- 误杀正常请求:如果 AOP 中使用简单的
lock(key)(如setnx+expire),当请求处理时间超过过期时间,锁自动释放,第二个请求就会进来。这适合短时间防抖,但不适合 Token 模式。Token 模式必须是 “一次性的”。 - 事务问题:如果方法内有
@Transactional,AOP 的执行顺序很重要。确保防重校验在事务开启之前执行(默认 AOP 顺序符合)。 - 分布式环境:必须使用 Redis 或 ZooKeeper 等共享存储,不能使用
ConcurrentHashMap本地锁。
七、 总结
通过 自定义注解 + AOP + Redis,我们将防重复提交的逻辑从业务代码中剥离,实现了开箱即用的效果。业务开发人员只需在 Controller 方法上加一行 @NoRepeatSubmit,即可轻松解决并发提交导致的数据脏乱问题,极大地提升了系统的健壮性。
到此这篇关于SpringBoot实现接口防重复提交的最佳实践的文章就介绍到这了,更多相关SpringBoot接口防重复提交内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
