表单重复提交是一个典型的分布式一致性问题。用户在前端快速点击两次提交按钮,或者因为网络抖动导致请求超时后重试,又比如浏览器在提交成功后用户点击后退再重新提交,这些行为都会让后端收到多个内容相同的请求。如果业务逻辑不是天然幂等,比如创建订单、扣减库存、发放优惠券,那么重复请求就会造成数据错误甚至资金损失。在开发Spring Boot应用时,我们不仅要在单实例场景下处理重复提交,还要考虑服务横向扩展后Session不共享、本地锁失效等问题。分布式唯一ID方案正是针对这些痛点产生的:它不依赖单一节点的内存状态,而是通过全局唯一的标识来标记每一次表单展示和提交,从而在服务端实现精确的幂等控制。

表单重复提交的典型场景与常见方案局限
重复提交通常发生在几个固定场景。第一个是用户操作层面的连点,前端按钮没有及时禁用,用户连续点击多次,每一次点击都会发出一个独立的HTTP请求。第二个是网络层面的重试,客户端因为超时或者响应丢失主动重发请求,这在移动网络或弱网环境下非常常见。第三个是浏览器行为,比如用户提交成功后刷新页面,浏览器会提示是否重新提交表单;或者用户点击后退按钮回到表单页面再次提交。这些场景的共同特点是后端无法仅凭请求参数判断是否重复,因为除了到达时间不同,内容可能完全一致。
传统的解决方案大致有三类。前端方案是在提交按钮上设置disabled属性,在请求返回前防止再次点击,但这种方式只能挡住正常操作,无法防御网络重试和刷新,更无法阻止绕过前端的直接调用。Session令牌方案是后端在渲染表单时生成一个随机token存入Session,提交时校验并删除,但Session默认基于单机内存,一旦服务做集群部署就需要粘性会话或共享Session,复杂度上升,而且Session本身不适合高并发场景。数据库唯一约束方案是在业务表上建立唯一索引,如订单号唯一,靠数据库拒绝重复数据,但这会侵入业务表结构,且很多重复提交并不一定对应唯一字段,通用性不足。因此我们需要一种跨节点、低侵入、可通用的幂等方案。
分布式唯一 ID 的选型与生成策略
分布式唯一ID的核心要求是全局唯一、生成效率高、趋势递增或完全随机均可。常见的候选方案包括UUID、数据库自增ID、Redis原子自增、雪花算法Snowflake以及基于号段模式的发号器。UUID生成简单,不需要中心节点,但字符串长度较长,作为数据库主键时无序性会影响索引性能;如果只是作为防重token使用,这个缺点并不明显,但UUID没有业务含义,排查问题时不够直观。数据库自增ID依赖单表或分布式数据库的全局自增,性能瓶颈明显,不适合高频生成。Redis的INCR命令可以原子地生成递增数字,结合前缀可以形成可读性较强的ID,但需要Redis高可用保障,否则单点故障会影响业务。
在表单防重复提交场景中,ID的作用是标记一次表单展示,提交时判断该ID是否已经被消费。因此ID的生成速度和唯一性比趋势递增更重要。雪花算法可以在无中心协调的情况下生成64位长整型ID,由时间戳、工作机器ID和序列号组成,适合服务多节点部署。如果不想引入额外的ID生成服务,也可以直接使用Redis的INCR配合UUID生成一个短token。具体选择取决于系统已有的基础设施:如果已经依赖Redis做缓存,那么用Redis生成token是最自然的;如果系统对Redis依赖较轻,可以考虑雪花算法。无论哪种方式,最终生成的ID都应当具备足够的随机性,避免被猜测,同时在存储时设置合理的过期时间。
Spring Boot 中基于自定义注解和拦截器实现防重
在Spring Boot工程中,我们可以把防重复提交的逻辑封装成一个自定义注解,比如@NoRepeatSubmit,标注在需要防重的Controller方法上。配合一个HandlerInterceptor拦截请求,在进入Controller之前完成token的校验和标记。具体流程是:进入表单页面时,后端生成一个分布式唯一ID,将其返回给前端,前端将该ID放在表单的隐藏字段或者请求头中;提交表单时,请求携带这个ID,拦截器从请求中取出ID,并调用Redis的SETNX命令尝试将该ID写入Redis;如果写入成功,说明这是第一次提交,放行请求;如果写入失败,说明该ID已经被消费过,拦截器直接返回重复提交提示,不再进入业务方法。
下面给出核心代码示例。首先定义注解:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
// 防重复提交的有效时间,单位秒
long expire() default 5;
}
然后实现拦截器,在preHandle方法中获取请求头中的token参数,这里假设前端将ID放在名为submitToken的请求头中:
@Component
public class NoRepeatSubmitInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
NoRepeatSubmit annotation = handlerMethod.getMethodAnnotation(NoRepeatSubmit.class);
if (annotation == null) {
return true;
}
String token = request.getHeader("submitToken");
if (token == null || token.isEmpty()) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{"code":400,"message":"缺少防重token"}");
return false;
}
// 使用SETNX保证原子性,key为submit_token:{token}
String key = "submit_token:" + token;
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofSeconds(annotation.expire()));
if (Boolean.FALSE.equals(success)) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{"code":409,"message":"请勿重复提交"}");
return false;
}
return true;
}
}
注意到上面使用了Redis的setIfAbsent方法,它对应Redis的SET key value NX EX seconds命令,能够保证在并发情况下只有一个请求能成功设置key。过期时间由注解参数控制,过期后token自动失效,避免Redis堆积过多无效key。接下来注册拦截器,并将表单页面生成token的逻辑放入一个工具方法中:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private NoRepeatSubmitInterceptor noRepeatSubmitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(noRepeatSubmitInterceptor)
.addPathPatterns("/**");
}
}
@Service
public class TokenService {
@Autowired
private StringRedisTemplate redisTemplate;
public String createSubmitToken() {
// 使用雪花算法或UUID生成全局唯一ID
String token = IdUtil.getSnowflakeNextIdStr();
// 可以提前预热一个占位,但主要校验在提交时完成
return token;
}
}
上面的IdUtil来自Hutool工具库,也可以自行实现雪花算法或者使用UUID.randomUUID().toString()。Controller中的使用方式非常简单,进入表单页面时调用tokenService.createSubmitToken()将token放入Model,提交方法上添加@NoRepeatSubmit注解并从请求头读取token。需要特别注意,token必须与表单页面绑定,用户每次刷新表单都应该重新生成一个新的token,而不是复用旧token,否则用户正常修改表单后再次提交也可能被误拦截。同时token的有效期要根据表单填写的大致时长来设置,例如5分钟,过短会导致用户填写超时后无法提交,过长则会增加Redis内存占用。
结合业务ID的强化方案与注意事项
仅靠submitToken只能防止同一表单页面产生的重复提交,但如果用户绕过页面直接调用接口,每次请求都携带一个新token,依然可能重复创建业务数据。因此更完整的方案是将分布式唯一ID与业务唯一标识结合起来。例如在创建订单时,前端提交的token由后端生成并与订单草稿绑定,后端在执行业务前先检查token,再根据订单草稿ID查询是否已经创建过正式订单。如果业务本身存在天然唯一键,如订单号、流水号,可以进一步利用数据库唯一约束做兜底,即使绕过token校验也无法插入重复数据。这样形成分层防御:前端禁用按钮是第一层,token防重是第二层,业务唯一约束是第三层。
在实际落地中还需要注意几个细节。第一,Redis的原子操作必须使用带NX参数的命令,不能先get再set,否则并发场景下会有竞态窗口。第二,如果防重逻辑需要区分用户,可以在key中加入用户ID,例如submit_token:userId:token,避免不同用户之间的token互相干扰,同时也防止恶意用户使用他人token。第三,对于超高并发场景,拦截器中的Redis操作会增加一次网络往返,可能影响性能,此时可以考虑将token校验放在业务层通过Lua脚本执行get和set的组合命令,减少往返次数。第四,如果Redis发生故障,防重功能会失效,需要评估是否在关键时刻降级为直接放行还是快速失败,通常涉及资金的操作宁可失败也不应跳过幂等控制。
除了Redis方案,还可以使用数据库表记录已消费的token,利用唯一索引来保证原子性,但性能相对较差,适合低频场景。雪花算法生成的ID本身是递增的,如果直接暴露给前端,可能会泄露系统的工作机器数量和时间信息,因此在实际传输时建议对ID做一次混淆或直接使用UUID字符串。对于已有分布式ID基础设施的系统,比如美团Leaf、百度UidGenerator,可以直接复用其生成的ID作为submitToken,既保证唯一性又便于追踪。总之,分布式唯一ID解决表单重复提交的本质是引入一个全局唯一的请求标识,配合原子性校验完成幂等控制,这是微服务架构下非常实用的工程实践。
通过以上方案,Spring Boot应用能够在多实例部署环境中有效避免表单重复提交,同时保持代码侵入最小。开发者可以根据自身业务场景灵活选择ID生成方式和校验存储介质,逐步构建健壮的幂等体系。
分布式唯一IDSpring Boot表单重复提交修改时间:2026-08-20 10:53:56