Java案例如何防止重复提交?从原理到实战的完整指南
目录导读
- 重复提交问题的本质与危害
- 前端防重:用户体验的“第一道防线”
- 后端防重:幂等性与Token机制详解
- 分布式场景下的终极方案:Redis+Token实战
- 问答环节:高频问题与避坑指南
- 总结与最佳实践
重复提交问题的本质与危害
在Web应用中,用户因网络延迟、误操作或恶意攻击,多次点击提交按钮导致同一请求被多次处理,这就是典型的“重复提交”问题。其本质是“非幂等操作被重复执行”,

- 订单重复创建
- 数据库重复插入记录
- 资金重复扣减
危害:轻则数据混乱,重则造成严重的经济损失或系统崩溃,曾有案例因未防重复提交,导致同一订单被生成3次,库存扣减3次,直接引起财务对账异常。
前端防重:用户体验的“第一道防线”
1 按钮置灰+遮罩层(最基础)
// 提交时立即禁用按钮,防止二次点击
document.getElementById('submitBtn').disabled = true;
局限:用户可通过浏览器刷新、禁用JS等绕过。
2 请求拦截器+加载动画(进阶)
使用Axios等库,在请求发送后显示loading,拦截后续相同请求:
let pending = false;
submitWithLock() {
if (pending) return;
pending = true;
axios.post('/api/submit', data).finally(() => pending = false);
}
3 唯一标识符(UUID)预生成
前端提交时携带一个预生成的UUID,后端校验UUID是否已被使用。
前端防护只能防“君子”,无法防“恶意攻击”或“网络重传”,必须结合后端方案。
后端防重:幂等性与Token机制详解
1 幂等性设计:最高级解决方案
幂等性指一次或多次请求具有相同的副作用。
- 数据库唯一索引:
订单号做唯一索引,重复插入直接报错。 - 乐观锁(版本号):
UPDATE goods SET stock=stock-1, version=version+1 WHERE id=1 AND version=2 -- 若version不匹配,更新0行,即为重复请求
- 状态机:订单状态从
待付款→已付款,若已为已付款则拒绝。
2 传统Token方案(同步场景)
流程:
- 客户端请求
/api/token获取一个唯一token。 - 服务端将token存入Session或数据库。
- 客户端提交请求时携带token。
- 服务端校验token存在则删除(原子操作),处理请求;若token不存在或已被删除,判定为重复提交。
代码示例(Spring Boot):
// 生成token
@RequestMapping("/getToken")
public String getToken(HttpSession session) {
String token = UUID.randomUUID().toString();
session.setAttribute("submitToken", token);
return token;
}
// 校验token
@RequestMapping("/submit")
public Result submit(@RequestParam String token, HttpSession session) {
String sessionToken = (String) session.getAttribute("submitToken");
if (!token.equals(sessionToken)) {
return Result.error("重复提交");
}
session.removeAttribute("submitToken"); // 删除token
// 执行业务...
}
致命缺陷:Session无法在分布式集群中共享!必须改用Redis。
分布式场景下的终极方案:Redis+Token实战
1 核心思路:Redis原子性“存-删”
利用SETNX或SETEX命令,将token作为key,设置过期时间。
- 存入:
SET token_key "value" NX EX 60(NX:不存在才存入;EX:60秒过期) - 删除/校验:使用Lua脚本保证原子性
-- Lua脚本:校验并删除token if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
2 完整Java实现(RedisTemplate)
@Service
public class TokenService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 预生成token
public String generateToken(String userId) {
String token = UUID.randomUUID().toString();
String key = "submit:" + userId; // 每个用户独立key
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, token, 60, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(success)) {
return token;
}
throw new RuntimeException("令牌生成失败");
}
// 校验token
public boolean checkToken(String userId, String token) {
String key = "submit:" + userId;
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key), token);
return result != null && result == 1L;
}
}
3 关键优化点
- Key设计:按用户维度隔离,如
submit:userId:orderId,避免全局并发竞争。 - 超时机制:防止token未使用而永远占用(设置合理的过期时间,如30秒)。
- 二次校验:业务层再基于数据库唯一索引做二次拦截,稳如泰山。
问答环节:高频问题与避坑指南
Q1:为什么一定要用Redis?不能用数据库表吗?
A:数据库操作有并发写锁,高并发下性能瓶颈严重,Redis单线程模型+内存操作,原子性保证性能。
Q2:Token被劫持怎么办?
A:必须配合HTTPS加密传输,另外可增加签名验证(如HMAC),防止token被篡改。
Q3:如果业务处理时间超过token过期时间怎么办?
A:有两种方案:
- 使用分布式锁替代Token,如Redisson的可重入锁,业务完成后释放。
- 或增加Token续期机制,但复杂度升高。
Q4:前后端都需要做防重吗?
A:必须双重保障!前端提升用户体验,后端保证数据绝对安全。
Q5:什么情况下不适合Token方案?
A:当业务天然支持幂等性时(如“如果存在则更新”),无需token,Token多用于“只能执行一次”的场景。
总结与最佳实践
防止重复提交的黄金法则:
- 前端兜底:按钮禁用+请求拦截,避免99%的误操作。
- 后端核心:幂等性设计(唯一索引/乐观锁) + Redis Token防重。
- 分布式扩展:高并发场景使用Lua脚本保证原子性。
- 监控与审计:记录重复请求日志,便于问题排查。
推荐组合方案:
前端防点击 + 后端Redis Token + 数据库唯一索引,三者叠加可抵御99.99%的重复提交风险。
切记:没有100%完美的方案,但通过多层防御,足以在绝大多数场景下杜绝重复提交,实际项目请根据业务场景(同步/异步、单机/分布式)灵活选择,建议先从小范围验证开始。