Java案例如何实现防重提交?

wen python案例 2

本文目录导读:

Java案例如何实现防重提交?

  1. 核心思路
  2. 方案一:基于令牌(Token)机制
  3. 方案二:基于Redis + 最小粒度锁(自旋锁 / 幂等性标记)
  4. 方案三:数据库唯一索引 / 状态机
  5. 方案四:前端防抖 + 禁用按钮(非服务端防重)
  6. 总结:如何选择?

在Java开发中,防重提交(防止重复提交表单或重复请求)是一个非常常见的需求,如果不做处理,用户快速点击按钮、网络延迟导致的重复请求、或者恶意攻击,都可能导致数据重复插入、订单重复创建等严重问题。

下面我会介绍几种主流的Java防重提交实现方案,从简单到复杂,并给出具体的代码案例。


核心思路

防重提交的核心是:在服务端识别出“同一个请求”被重复发送了,通常通过一个唯一的标识(Token)或请求的特征(如URL + 参数 + 用户ID的Hash)来判断。

基于令牌(Token)机制

这是最经典、最常用的方式,核心思想是:提交前先获取一个唯一令牌,提交时需要带上这个令牌,服务端校验并销毁该令牌(一次一密)

流程

  1. 客户端:请求服务端获取Token(通常由后端生成UUID)。
  2. 服务端:生成Token,将其存入Redis(或Session),并返回给客户端。
  3. 客户端:将Token放在表单隐藏域或请求头中,随业务请求提交。
  4. 服务端:拦截器或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。

流程

  1. 客户端请求携带业务唯一标识(订单号、业务流水号)。
  2. 服务端收到请求后,使用SETNX(Set if Not Exists)命令尝试在Redis中写入该标识。
  3. 如果写入成功,说明这是首次请求,执行后续业务逻辑,并在业务完成后删除该Key(或设置自动过期)。
  4. 如果写入失败,说明已有相同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业务类型_业务主键
  • 原子性SETNXEXPIRE是两条命令,不是原子操作,如果加锁成功但设置过期时间失败,可能导致死锁,可以使用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) 非常高
前端控制 配合后端使用,作为辅助手段 低(不可单独依赖)

最佳实践建议

  1. 必须:后端要做防重,前端也要做防抖禁用。
  2. 推荐组合
    • 关键业务(支付、下单)Token机制 (方案一) + 前端控制
    • 幂等性API(更新、修改)Redis锁 (方案二) + SpEL提取参数唯一标识
    • 插入数据数据库唯一索引 (方案三) 作为最后防线。

方案均针对常见的单体或分布式架构,如果是高并发场景,建议使用成熟的高性能分布式锁框架(如Redisson),其内部实现了锁的续期、可重入等高级特性。

抱歉,评论功能暂时关闭!