本文目录导读:

- 核心思路
- 方案一:基于令牌(Token)机制
- 方案二:基于Redis + 最小粒度锁(自旋锁 / 幂等性标记)
- 方案三:数据库唯一索引 / 状态机
- 方案四:前端防抖 + 禁用按钮(非服务端防重)
- 总结:如何选择?
在Java开发中,防重提交(防止重复提交表单或重复请求)是一个非常常见的需求,如果不做处理,用户快速点击按钮、网络延迟导致的重复请求、或者恶意攻击,都可能导致数据重复插入、订单重复创建等严重问题。
下面我会介绍几种主流的Java防重提交实现方案,从简单到复杂,并给出具体的代码案例。
核心思路
防重提交的核心是:在服务端识别出“同一个请求”被重复发送了,通常通过一个唯一的标识(Token)或请求的特征(如URL + 参数 + 用户ID的Hash)来判断。
基于令牌(Token)机制
这是最经典、最常用的方式,核心思想是:提交前先获取一个唯一令牌,提交时需要带上这个令牌,服务端校验并销毁该令牌(一次一密)。
流程
- 客户端:请求服务端获取Token(通常由后端生成UUID)。
- 服务端:生成Token,将其存入Redis(或Session),并返回给客户端。
- 客户端:将Token放在表单隐藏域或请求头中,随业务请求提交。
- 服务端:拦截器或AOP拦截请求,校验Token是否存在且有效。
- 如果有效:删除Token(原子操作),执行业务逻辑。
- 如果无效:提示“重复提交”或抛异常。
代码实现(Spring Boot + Redis)
(1)生成Token接口(Controller)
@RestController
@RequestMapping("/token")
public class TokenController {
@Autowired
private StringRedisTemplate redisTemplate;
@GetMapping("/get")
public Result<String> getToken() {
String token = UUID.randomUUID().toString();
// 存入Redis,设置过期时间(例如5分钟),防止内存泄漏
redisTemplate.opsForValue().set(token, token, 5, TimeUnit.MINUTES);
return Result.success(token);
}
}
(2)自定义注解(用于拦截需要防重提交的方法)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {
// 可以添加锁的超时时间等参数
}
(3)AOP切面实现(核心逻辑)
@Aspect
@Component
public class RepeatSubmitAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(repeatSubmit)")
public Object around(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) throws Throwable {
// 1. 获取请求上下文(需要注入RequestContextHolder)
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
// 2. 从请求头或参数中获取Token(建议用请求头)
String token = request.getHeader("repeat-token");
if (StringUtils.isBlank(token)) {
throw new RuntimeException("缺少防重令牌");
}
// 3. 核心操作:原子性删除Token
// 如果删除成功(>0),说明Token存在且未被使用,可以执行
// 如果删除失败(0),说明Token已被删除,重复提交
Boolean deleted = redisTemplate.delete(token);
if (Boolean.FALSE.equals(deleted)) {
// 重复提交
return Result.error("操作过于频繁,请稍后再试");
}
// 4. 执行原方法
return pjp.proceed();
}
}
(4)业务方法使用
@RestController
public class OrderController {
@PostMapping("/createOrder")
@RepeatSubmit // 加上注解
public Result<Order> createOrder(@RequestBody OrderDTO orderDTO) {
// 业务逻辑...
return Result.success(order);
}
}
(5)前端调用示例(Vue)
// 1. 进入页面时获取token
const token = await axios.get('/token/get');
// 2. 提交时带上token
axios.post('/createOrder', data, {
headers: { 'repeat-token': token.data }
});
优缺点
- 优点:实现简单,安全性高,一次一密,能有效防止重复。
- 缺点:需要前后端配合,多一次获取Token的网络请求。
基于Redis + 最小粒度锁(自旋锁 / 幂等性标记)
适用于接口幂等性要求高,且不想增加额外Token请求的场景,利用请求的唯一标识(如订单号、用户ID+业务编号)作为Key。
流程
- 客户端请求携带业务唯一标识(订单号、业务流水号)。
- 服务端收到请求后,使用
SETNX(Set if Not Exists)命令尝试在Redis中写入该标识。 - 如果写入成功,说明这是首次请求,执行后续业务逻辑,并在业务完成后删除该Key(或设置自动过期)。
- 如果写入失败,说明已有相同Key存在,判断为重复提交,直接返回。
代码实现(AOP + Redis SETNX)
(1)自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatLock {
// 锁的key,支持SpEL表达式获取参数
String key();
// 锁的过期时间(秒)
int expireSeconds() default 10;
}
(2)AOP切面实现
@Aspect
@Component
public class RepeatLockAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(repeatLock)")
public Object around(ProceedingJoinPoint pjp, RepeatLock repeatLock) throws Throwable {
// 1. 创建锁的Key(这里简化为直接使用注解参数,实际可能需要拼接用户ID等)
String lockKey = "repeat_submit:" + repeatLock.key();
// 2. 使用SETNX尝试加锁(需要结合过期时间,防止死锁)
// setIfAbsent + expire 不是原子操作,推荐使用 setIfAbsent + expire 分步,或者使用更完善的分布式锁框架
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked");
if (Boolean.TRUE.equals(success)) {
// 加锁成功,设置过期时间
redisTemplate.expire(lockKey, repeatLock.expireSeconds(), TimeUnit.SECONDS);
try {
return pjp.proceed();
} finally {
// 业务完成后主动释放锁
redisTemplate.delete(lockKey);
}
} else {
// 加锁失败,说明重复提交
return Result.error("请求正在处理中,请勿重复提交");
}
}
}
(3)业务方法使用
@PostMapping("/pay")
@RepeatLock(key = "#dto.orderId") // 使用SpEL表达式获取参数中的订单号
public Result<Boolean> pay(@RequestBody PayDTO dto) {
// 支付逻辑...
return Result.success(true);
}
注意事项
- Key的选择:要保证全局唯一,
userId_orderId、业务类型_业务主键。 - 原子性:
SETNX和EXPIRE是两条命令,不是原子操作,如果加锁成功但设置过期时间失败,可能导致死锁,可以使用Redis的SET key value NX EX seconds命令一步完成。 - 锁的粒度:粒度越小越好(针对具体业务数据),避免锁住整个接口影响吞吐。
数据库唯一索引 / 状态机
对于插入型业务(如创建订单、注册用户),最严格的方式是利用数据库的唯一约束。
实现
- 在数据库表中,为业务唯一标识(如订单号、业务流水号)建立唯一索引。
- 当重复插入时,数据库会抛出
DuplicateKeyException异常。 - 服务层捕获该异常,返回“数据已存在”或“重复提交”。
代码示例
@Transactional
public void createOrder(String orderId, OrderData data) {
try {
orderMapper.insert(data);
} catch (DataIntegrityViolationException e) {
// 唯一索引冲突,重复提交
throw new BusinessException("订单已创建,请勿重复提交");
}
}
优缺点
- 优点:数据库硬约束,最可靠。
- 缺点:只适用于插入场景;数据库成为性能瓶颈;异常处理略显笨重。
前端防抖 + 禁用按钮(非服务端防重)
虽然这属于客户端控制,但作为第一道防线非常有效,能减轻服务端压力。
示例(Vue)
<template>
<el-button @click="submit" :disabled="loading">提交</el-button>
</template>
<script>
export default {
data() { return { loading: false } },
methods: {
async submit() {
this.loading = true;
try {
await axios.post('/api/submit', data);
} finally {
this.loading = false;
}
}
}
}
</script>
如何选择?
| 方案 | 适用场景 | 复杂度 | 可靠性 |
|---|---|---|---|
| Token机制 | 表单提交、重要操作(如支付、下单) | 中等 | 高 |
| Redis加锁(SETNX) | 幂等性接口、分布式系统 | 高(需处理锁超时、原子性) | 高 |
| 数据库唯一索引 | 插入数据,数据唯一性要求极高 | 低(依赖DB) | 非常高 |
| 前端控制 | 配合后端使用,作为辅助手段 | 低(不可单独依赖) | 低 |
最佳实践建议:
- 必须:后端要做防重,前端也要做防抖禁用。
- 推荐组合:
- 关键业务(支付、下单):
Token机制(方案一) +前端控制。 - 幂等性API(更新、修改):
Redis锁(方案二) +SpEL提取参数唯一标识。 - 插入数据:
数据库唯一索引(方案三) 作为最后防线。
- 关键业务(支付、下单):
方案均针对常见的单体或分布式架构,如果是高并发场景,建议使用成熟的高性能分布式锁框架(如Redisson),其内部实现了锁的续期、可重入等高级特性。