用户连点按钮导致系统崩溃?教你用 Spring Boot 实现防重复提交!从自定义注解本地锁到 Redis 分布式锁,再到幂等性设计,3 步搞定从单机到分布式环境的重复提交问题,程序员必备教程。
前言:为什么你的按钮总被「疯狂连点」?
想象一下:用户对着「提交订单」按钮疯狂输出,像极了打街机游戏的少年——结果你的系统创建了 5 个一模一样的订单。客服电话被打爆,数据库多了 一堆废数据,而你凌晨两点被叫起来紧急回滚——这就是重复提交的「恐怖故事」。
重复提交问题本质是 网络延迟 + 用户急躁 的化学反应。防重提交不仅是技术需求,更是 拯救程序员睡眠质量 的人道主义行为!本文将用「本地锁→分布式锁」的递进方案,教你用 Spring Boot 搭建从青铜到王者的防重体系。

一、基础玩法:本地锁注解(单机版)
1. 自定义注解实现
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {int lockTime() default 3; // 默认锁 3 秒,建议比前端按钮禁用时间长
}
2. 切面编程实现锁逻辑
@Aspect
@Component
publicclass RepeatSubmitAspect {
// 使用 ConcurrentHashMap 作为本地锁容器(注意:分布式环境会失效!)privatestaticfinal ConcurrentHashMap<String, Object> LOCKS = new ConcurrentHashMap<>();
@Around("@annotation(noRepeatSubmit)")
public Object around(ProceedingJoinPoint point, NoRepeatSubmit noRepeatSubmit) throws Throwable {HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
// 生成唯一锁密钥:用户 ID+ 接口路径(实际业务需要更复杂规则)String lockKey = getUserId(request) + "-" + request.getServletPath();
// 如果已经存在锁,直接抛异常(建议自定义业务异常)if (LOCKS.putIfAbsent(lockKey, Boolean.TRUE) != null) {thrownew RuntimeException("手速太快了,慢一点!"); // 用户体验友好的提示
}
try {return point.proceed(); // 执行实际业务方法
} finally {
// 延迟释放锁(注意:要用 finally 确保释放!)Thread.sleep(noRepeatSubmit.lockTime() * 1000);
LOCKS.remove(lockKey);
}
}
// 实际项目要从 token 解析用户 ID,这里简单演示
private String getUserId(HttpServletRequest request) {return Optional.ofNullable(request.getHeader("Authorization"))
.orElse("anonymous");
}
}
3. 使用案例:订单创建接口
@RestController
public class OrderController {@NoRepeatSubmit(lockTime = 5) // 5 秒内禁止重复调用
@PostMapping("/createOrder")
public ResponseEntity createOrder(@RequestBody OrderDTO order) {
// 真实的订单创建业务逻辑(通常需要事务控制)orderService.create(order);
return ResponseEntity.ok("订单创建成功");
}
}
⚠️ 吐槽时刻:
这种方案在单机环境下能拦住 90% 的重复提交,但遇到集群部署就直接躺平——因为每个实例的 ConcurrentHashMap
都是 独立后宫,无法拦截其他实例的请求。接下来请看分布式环境解决方案!
二、进阶方案:Redis 分布式锁(集群版)
1. 引入 Redis 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2. 分布式锁工具类
@Component
publicclass RedisLockHelper {
@Autowired
private StringRedisTemplate redisTemplate;
/** * 加锁(设置过期时间防止死锁)* @param key 锁密钥 * @param expireTime 过期时间(秒) * @return 是否获取成功 */
public boolean lock(String key, int expireTime) {
// 使用 setIfAbsent 实现原子操作(旧版本用 setnx+expire 需要 lua 脚本保证原子性)Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "locked", expireTime, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
// 释放锁(直接删除即可)public void unlock(String key) {redisTemplate.delete(key);
}
}
3. 升级版分布式切面
@Aspect
@Component
publicclass DistributedRepeatSubmitAspect {
@Autowired
private RedisLockHelper redisLockHelper;
@Around("@annotation(noRepeatSubmit)")
public Object around(ProceedingJoinPoint point, NoRepeatSubmit noRepeatSubmit) throws Throwable {HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
// 生成分布式环境唯一密钥(增加客户端 IP 防不同用户冲突)String lockKey = "repeat_submit:" + getUserId(request) + ":" +
request.getServletPath() + ":" +
DigestUtils.md5DigestAsHex(request.getParameterMap().toString().getBytes());
// 尝试获取分布式锁
if (!redisLockHelper.lock(lockKey, noRepeatSubmit.lockTime())) {thrownew RuntimeException("您的操作太频繁了,请稍后再试");
}
try {return point.proceed();
} finally {
// 注意:不需要手动释放,依赖 Redis 过期自动删除
// 提前释放可能导致锁有效期内的重复请求
}
}
}
4. 特殊场景:处理幂等性 token
// 在进入表单页时生成 token
@GetMapping("/order/page")
public ResponseEntity getOrderPage() {String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("submit_token:" + token, "1", 10, TimeUnit.MINUTES);
return ResponseEntity.ok().header("submit-token", token).build();}
// 提交时校验 token
private boolean validSubmitToken(String token) {
String key = "submit_token:" + token;
Long count = redisTemplate.execute(new DefaultRedisScript<>("if redis.call('get', KEYS[1]) =='1'then" +
"return redis.call('del', KEYS[1])" +
"else" +
"return 0" +
"end",
Long.class), Collections.singletonList(key));
return count != null && count > 0;
}
三、避坑指南(血泪经验总结)
🚨 坑 1:锁密钥设计不合理
错误示范 : 只使用用户 ID
→ 不同功能接口会互相阻塞
正确做法 : 业务类型 + 用户 ID+ 接口 + 参数指纹
组合密钥
🚨 坑 2:忘记设置锁超时
恐怖后果 :如果实例崩溃, 锁永远不释放→系统逐渐瘫痪
解决方案 :必须设置过期时间,且 业务执行时间应远小于锁超时时间
🚨 坑 3:前端防重缺失
常见误区:以为后端防重就万事大吉
最佳实践:
- 按钮提交后立即禁用(最简单有效的前端防重)
- 添加加载动画降低用户重复点击欲望
- 单页应用使用 axios 拦截器自动取消重复请求
🚨 坑 4:网络超时导致重复请求
场景还原:第一次请求其实成功了,但网络超时让客户端重试
终极方案:
// 业务层幂等检查(最后防线)@Transactional
public void createOrder(OrderDTO dto) {
// 通过业务唯一标识检查是否已存在
if (orderRepository.existsByOrderNo(dto.getOrderNo())) {log.warn("重复订单请求:{}", dto.getOrderNo());
return; // 或直接返回已存在的结果
}
// 正常创建逻辑
}
总结与实战建议
🔑 核心要点
- 分层防御:前端防重 → 网络层防重 → 业务层幂等
- 密钥设计:保证同一用户同一操作在同一时间段内被拦截
- 锁时效:设置略大于业务处理时间的自动过期期限
🛠 方案选型指南
- 单机部署:本地锁注解(简单高效)
- 集群环境:Redis 分布式锁(必需方案)
- 金融级要求:Redis 锁 + 数据库唯一约束 + 业务幂等校验(三重保险)
🤔 灵魂拷问
你的防重系统能扛住「用户一边疯狂 F5 一边电话投诉说系统卡顿」的经典场景吗?
最后提醒 :防重提交本质是 用空间换时间 的技术方案,会根据业务场景有不同的实现变体。建议在测试环境用 Jmeter 模拟高并发重复请求,观察系统表现——别等到线上爆雷再连夜补锅!
正文完
发表至: 教程资源
2025-09-19