Spring Boot自定义注解实现防表单重复提交

发布时间:2026/7/18 1:48:30
Spring Boot自定义注解实现防表单重复提交 1. 为什么我们需要防止表单重复提交在Web开发中表单重复提交是一个常见但容易被忽视的问题。想象一下这样的场景用户在电商网站点击提交订单按钮后由于网络延迟或页面响应慢用户可能会多次点击同一个按钮导致同一个订单被重复创建。这不仅会造成数据混乱还可能引发财务问题。表单重复提交通常发生在以下几种情况用户快速多次点击提交按钮页面提交后刷新提交后点击浏览器后退按钮再次提交网络延迟导致用户误以为提交失败而重试在传统的Java Web应用中我们可能会在服务端使用Token机制来防止重复提交但在Spring Boot中我们可以通过更优雅的方式——自定义注解来实现这一功能。2. 自定义注解的核心设计思路2.1 注解的基本概念在Java中注解Annotation是一种元数据形式它提供了一种向代码添加元信息的方式。Spring框架大量使用了注解来简化配置和实现各种功能。自定义注解的核心优势在于声明式编程通过简单的注解标记就能实现复杂功能代码解耦业务逻辑与防重提交逻辑分离可重用性一次开发多处使用2.2 防重提交注解的设计我们需要设计的防重提交注解NoRepeatSubmit应该包含以下特性可以标记在Controller方法上能够设置锁的过期时间支持自定义错误消息能够区分不同用户的请求注解的初步设计如下Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface NoRepeatSubmit { /** * 锁的过期时间默认5秒 */ int lockTime() default 5; /** * 自定义错误消息 */ String message() default 请勿重复提交; }3. 实现防重提交的核心组件3.1 分布式锁的选择与实现在分布式环境下我们需要一个可靠的锁机制来防止重复提交。Redis是一个理想的选择因为它性能高支持原子操作可以设置过期时间在分布式环境中表现良好我们使用Spring Data Redis来实现Redis锁Component public class RedisLock { Autowired private StringRedisTemplate stringRedisTemplate; /** * 尝试获取锁 * param key 锁的key * param value 锁的value * param expire 过期时间(秒) * return 是否获取成功 */ public boolean tryLock(String key, String value, int expire) { Boolean result stringRedisTemplate.opsForValue() .setIfAbsent(key, value, expire, TimeUnit.SECONDS); return Boolean.TRUE.equals(result); } /** * 释放锁 * param key 锁的key * param value 锁的value */ public void unlock(String key, String value) { String currentValue stringRedisTemplate.opsForValue().get(key); if (currentValue ! null currentValue.equals(value)) { stringRedisTemplate.delete(key); } } }3.2 拦截器的实现我们需要一个拦截器来拦截带有NoRepeatSubmit注解的方法Component public class NoRepeatSubmitInterceptor implements HandlerInterceptor { Autowired private RedisLock redisLock; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod (HandlerMethod) handler; NoRepeatSubmit noRepeatSubmit handlerMethod.getMethodAnnotation(NoRepeatSubmit.class); if (noRepeatSubmit ! null) { String userId getUserId(request); // 获取用户ID的方法 String requestURI request.getRequestURI(); String key no_repeat_submit: userId : requestURI; String value UUID.randomUUID().toString(); if (!redisLock.tryLock(key, value, noRepeatSubmit.lockTime())) { throw new RuntimeException(noRepeatSubmit.message()); } request.setAttribute(no_repeat_submit_key, key); request.setAttribute(no_repeat_submit_value, value); } } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod (HandlerMethod) handler; NoRepeatSubmit noRepeatSubmit handlerMethod.getMethodAnnotation(NoRepeatSubmit.class); if (noRepeatSubmit ! null) { String key (String) request.getAttribute(no_repeat_submit_key); String value (String) request.getAttribute(no_repeat_submit_value); if (key ! null value ! null) { redisLock.unlock(key, value); } } } } private String getUserId(HttpServletRequest request) { // 实际项目中可以从session或token中获取用户ID // 这里简化处理返回固定值 return user1; } }3.3 注册拦截器为了让拦截器生效我们需要在Spring Boot配置中注册它Configuration public class WebConfig implements WebMvcConfigurer { Autowired private NoRepeatSubmitInterceptor noRepeatSubmitInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(noRepeatSubmitInterceptor) .addPathPatterns(/**); } }4. 实际应用与测试4.1 在Controller中使用注解现在我们可以在任何需要防止重复提交的Controller方法上使用NoRepeatSubmit注解RestController RequestMapping(/order) public class OrderController { PostMapping(/create) NoRepeatSubmit(lockTime 10, message 订单正在处理中请勿重复提交) public ResponseEntityString createOrder(RequestBody OrderDTO orderDTO) { // 模拟业务处理时间 try { Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return ResponseEntity.ok(订单创建成功); } }4.2 测试防重提交功能我们可以使用Postman或curl命令来测试这个功能第一次发送请求成功创建订单在10秒内我们设置的lockTime再次发送相同请求会收到订单正在处理中请勿重复提交的错误10秒后再次发送请求可以成功创建新订单4.3 前端配合实现虽然服务端已经实现了防重提交但为了更好的用户体验前端也应该做一些处理提交按钮点击后禁用显示加载状态请求完成后才恢复按钮状态示例前端代码使用jQuery$(#submitBtn).click(function() { var $btn $(this); $btn.prop(disabled, true); $btn.text(处理中...); $.ajax({ url: /order/create, method: POST, data: JSON.stringify(orderData), contentType: application/json, success: function(response) { alert(订单创建成功); }, error: function(xhr) { alert(xhr.responseText); }, complete: function() { $btn.prop(disabled, false); $btn.text(提交订单); } }); });5. 进阶优化与注意事项5.1 锁粒度的优化我们当前的实现是以用户ID请求URI作为锁的key这在大多数情况下已经足够。但在某些特殊场景下可能需要更细粒度的控制。例如对于同一个用户对同一个商品多次下单的情况可以在key中加入商品IDString productId orderDTO.getProductId(); String key no_repeat_submit: userId : productId;对于需要区分参数的场景可以计算参数的MD5作为key的一部分String paramsJson objectMapper.writeValueAsString(orderDTO); String paramsHash DigestUtils.md5DigestAsHex(paramsJson.getBytes()); String key no_repeat_submit: userId : requestURI : paramsHash;5.2 异常处理与事务回滚在实际项目中我们需要考虑异常情况下的处理如果业务逻辑抛出异常应该确保锁被正确释放PostMapping(/create) NoRepeatSubmit public ResponseEntityString createOrder(RequestBody OrderDTO orderDTO) { try { // 业务逻辑 return ResponseEntity.ok(成功); } catch (Exception e) { // 记录日志等 throw e; // 抛出异常让拦截器处理锁释放 } }如果使用数据库事务需要注意锁的释放时机Transactional PostMapping(/create) NoRepeatSubmit public ResponseEntityString createOrder(RequestBody OrderDTO orderDTO) { // 业务逻辑 return ResponseEntity.ok(成功); }在这种情况下拦截器的afterCompletion方法会在事务提交后执行这是合理的。但如果希望在事务回滚时也释放锁可以使用TransactionalEventListener监听事务事件。5.3 性能考虑在高并发场景下防重提交机制可能会成为性能瓶颈。我们可以考虑以下优化使用本地缓存作为第一层防护Redis作为第二层防护public boolean tryLock(String key, String value, int expire) { // 先检查本地缓存 if (localCache.getIfPresent(key) ! null) { return false; } // 再检查Redis Boolean result stringRedisTemplate.opsForValue() .setIfAbsent(key, value, expire, TimeUnit.SECONDS); if (Boolean.TRUE.equals(result)) { localCache.put(key, value); } return Boolean.TRUE.equals(result); }对于特别高频的接口可以考虑使用更轻量级的锁实现如基于内存的RateLimiter。5.4 测试注意事项在测试防重提交功能时需要注意以下几点并发测试使用JMeter等工具模拟多用户并发提交网络延迟测试模拟慢速网络环境下的多次提交分布式测试在多个服务实例上测试锁的有效性异常测试测试在服务重启、Redis宕机等情况下的行为6. 替代方案比较除了自定义注解的方式还有其他几种常见的防重提交方案6.1 前端Token方案原理服务端生成唯一Token并存储在Session中页面加载时将Token作为隐藏字段提交时带上Token服务端验证后删除Token优点实现简单不依赖外部存储缺点无法防止恶意绕过前端的重复提交在分布式环境下需要额外处理Session共享6.2 数据库唯一索引原理为业务表添加唯一索引重复提交时会抛出唯一约束异常优点实现简单绝对可靠缺点只适用于有唯一业务键的场景数据库压力大不友好的错误提示6.3 请求指纹方案原理计算请求参数用户标识的指纹短时间内相同指纹的请求视为重复优点可以防止参数相同的重复提交粒度更细缺点计算指纹有性能开销对于大请求体不友好相比之下我们的自定义注解方案结合了Redis分布式锁的优势既保证了分布式环境下的可靠性又通过注解实现了声明式编程是较为理想的解决方案。