防重复提交实现方案:让每次提交都幂等安全!🛡️

19 阅读7分钟

标题: 重复提交还在手动防?Token+锁组合拳来了!
副标题: 从前端防抖到后端幂等,全方位防护


🎬 开篇:一次重复提交的惨痛教训

某电商平台下单功能:

用户:点击"提交订单" 
网络:慢...(3秒未响应)
用户:再点一下!💀

结果:
- 生成了2个订单
- 扣了2次库存
- 扣了2次钱
- 用户投诉 😡

问题分析:
- 前端未防抖
- 后端未做幂等
- 没有防重复提交机制

改造后(防重复提交):
- 前端:按钮置灰 + 防抖
- 后端:Token验证 + Redis锁
- 接口:幂等性设计

效果:
- 重复提交:0次 ✅
- 用户体验:提升 ✅
- 订单准确性:100% ✅

教训:防重复提交是必备功能!

🤔 什么是防重复提交?

想象一下:

  • 订单提交: 点击"立即购买"后,按钮置灰
  • 支付: 点击"确认支付"后,禁止再次点击
  • 评论发布: 发送后,按钮变为"发送中..."
  • 表单提交: 提交后,防止重复提交

防重复提交 = 前端防抖 + 后端幂等 + Token验证!


📚 知识地图

防重复提交方案
├── 🎯 前端方案
│   ├── 按钮置灰 ⭐⭐⭐
│   ├── 防抖/节流 ⭐⭐⭐⭐
│   ├── Loading遮罩 ⭐⭐⭐
│   └── 请求拦截 ⭐⭐⭐⭐
├── 🔐 后端方案
│   ├── Token机制 ⭐⭐⭐⭐⭐
│   ├── Redis分布式锁 ⭐⭐⭐⭐⭐
│   ├── 数据库唯一索引 ⭐⭐⭐⭐
│   ├── 接口幂等性 ⭐⭐⭐⭐⭐
│   └── 状态机 ⭐⭐⭐⭐
├── ⚡ 技术实现
│   ├── 请求Token
│   ├── Redisson锁
│   ├── AOP切面
│   └── 唯一键生成
└── 📊 适用场景
    ├── 订单提交
    ├── 支付请求
    ├── 表单提交
    └── 数据修改

🎯 方案1:Token机制(推荐)

1. 获取Token

/**
 * Token服务
 */
@Service
@Slf4j
public class SubmitTokenService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * ⚡ 生成提交Token
     */
    public String generateToken(Long userId) {
        // 生成唯一Token
        String token = UUID.randomUUID().toString().replace("-", "");
        
        // 存储到Redis(有效期5分钟)
        String key = "submit:token:" + userId;
        redisTemplate.opsForValue().set(key, token, 5, TimeUnit.MINUTES);
        
        log.info("生成提交Token:userId={}, token={}", userId, token);
        
        return token;
    }
    
    /**
     * ⚡ 验证并删除Token(一次性)
     */
    public boolean validateAndRemoveToken(Long userId, String token) {
        if (StringUtils.isBlank(token)) {
            return false;
        }
        
        String key = "submit:token:" + userId;
        
        // ⚡ Lua脚本保证原子性(验证+删除)
        String luaScript = 
            "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<>(luaScript, Long.class),
            Collections.singletonList(key),
            token
        );
        
        boolean valid = result != null && result == 1;
        
        log.info("验证提交Token:userId={}, token={}, valid={}", 
            userId, token, valid);
        
        return valid;
    }
}

/**
 * Controller获取Token
 */
@RestController
@RequestMapping("/api")
public class TokenController {
    
    @Autowired
    private SubmitTokenService tokenService;
    
    /**
     * ⚡ 获取提交Token
     */
    @GetMapping("/token/generate")
    public Result<String> generateToken() {
        Long userId = UserContextHolder.getUserId();
        String token = tokenService.generateToken(userId);
        return Result.success(token);
    }
}

2. 使用Token(注解+AOP)

/**
 * 防重复提交注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PreventResubmit {
    
    /**
     * 防重复提交类型
     */
    ResubmitType type() default ResubmitType.TOKEN;
    
    /**
     * 超时时间(秒)
     */
    int timeout() default 5;
    
    /**
     * 错误提示
     */
    String message() default "请勿重复提交";
}

/**
 * 防重复提交类型
 */
public enum ResubmitType {
    
    /**
     * Token验证
     */
    TOKEN,
    
    /**
     * Redis锁
     */
    LOCK,
    
    /**
     * 两者都用
     */
    BOTH
}

/**
 * 防重复提交切面
 */
@Aspect
@Component
@Slf4j
public class PreventResubmitAspect {
    
    @Autowired
    private SubmitTokenService tokenService;
    
    @Autowired
    private RedissonClient redissonClient;
    
    /**
     * ⚡ 环绕通知
     */
    @Around("@annotation(preventResubmit)")
    public Object around(ProceedingJoinPoint pjp, PreventResubmit preventResubmit) 
        throws Throwable {
        
        Long userId = UserContextHolder.getUserId();
        
        // 根据类型选择防重复提交方式
        ResubmitType type = preventResubmit.type();
        
        switch (type) {
            case TOKEN:
                return handleTokenValidation(pjp, preventResubmit, userId);
                
            case LOCK:
                return handleLockValidation(pjp, preventResubmit, userId);
                
            case BOTH:
                // 先验证Token
                if (!validateToken(userId)) {
                    throw new BusinessException(preventResubmit.message());
                }
                // 再加锁
                return handleLockValidation(pjp, preventResubmit, userId);
                
            default:
                return pjp.proceed();
        }
    }
    
    /**
     * ⚡ Token验证方式
     */
    private Object handleTokenValidation(ProceedingJoinPoint pjp, 
                                        PreventResubmit preventResubmit,
                                        Long userId) throws Throwable {
        
        // 1. 从请求中获取Token
        String token = getTokenFromRequest();
        
        if (StringUtils.isBlank(token)) {
            throw new BusinessException("提交Token不能为空");
        }
        
        // 2. ⚡ 验证并删除Token
        boolean valid = tokenService.validateAndRemoveToken(userId, token);
        
        if (!valid) {
            throw new BusinessException(preventResubmit.message());
        }
        
        // 3. 执行业务方法
        return pjp.proceed();
    }
    
    /**
     * ⚡ Redis锁方式
     */
    private Object handleLockValidation(ProceedingJoinPoint pjp,
                                       PreventResubmit preventResubmit,
                                       Long userId) throws Throwable {
        
        // 1. 生成锁Key
        String lockKey = buildLockKey(pjp, userId);
        
        // 2. ⚡ 获取Redis锁
        RLock lock = redissonClient.getLock(lockKey);
        
        // 3. 尝试加锁(不等待)
        boolean locked = lock.tryLock(0, preventResubmit.timeout(), TimeUnit.SECONDS);
        
        if (!locked) {
            throw new BusinessException(preventResubmit.message());
        }
        
        try {
            // 4. 执行业务方法
            return pjp.proceed();
            
        } finally {
            // 5. 释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    /**
     * 验证Token
     */
    private boolean validateToken(Long userId) {
        String token = getTokenFromRequest();
        return tokenService.validateAndRemoveToken(userId, token);
    }
    
    /**
     * 从请求中获取Token
     */
    private String getTokenFromRequest() {
        HttpServletRequest request = 
            ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
                .getRequest();
        
        // 从Header获取
        String token = request.getHeader("Submit-Token");
        
        // 从参数获取
        if (StringUtils.isBlank(token)) {
            token = request.getParameter("submitToken");
        }
        
        return token;
    }
    
    /**
     * 构建锁Key
     */
    private String buildLockKey(ProceedingJoinPoint pjp, Long userId) {
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        String methodName = signature.getDeclaringTypeName() + "." + signature.getName();
        
        return "resubmit:lock:" + methodName + ":" + userId;
    }
}

3. 使用示例

/**
 * 订单Controller
 */
@RestController
@RequestMapping("/order")
@Slf4j
public class OrderController {
    
    @Autowired
    private OrderService orderService;
    
    /**
     * ⚡ 提交订单(Token防重)
     */
    @PostMapping("/submit")
    @PreventResubmit(type = ResubmitType.TOKEN, message = "订单提交中,请勿重复提交")
    public Result<Order> submitOrder(@RequestBody OrderDTO orderDTO,
                                     @RequestHeader("Submit-Token") String token) {
        Order order = orderService.createOrder(orderDTO);
        return Result.success(order);
    }
    
    /**
     * ⚡ 支付订单(锁防重)
     */
    @PostMapping("/pay/{orderId}")
    @PreventResubmit(
        type = ResubmitType.LOCK, 
        timeout = 30,
        message = "支付处理中,请勿重复操作"
    )
    public Result<Void> payOrder(@PathVariable Long orderId) {
        orderService.payOrder(orderId);
        return Result.success();
    }
    
    /**
     * ⚡ 退款(Token+锁)
     */
    @PostMapping("/refund/{orderId}")
    @PreventResubmit(
        type = ResubmitType.BOTH,
        message = "退款处理中,请稍候"
    )
    public Result<Void> refundOrder(@PathVariable Long orderId,
                                    @RequestHeader("Submit-Token") String token) {
        orderService.refundOrder(orderId);
        return Result.success();
    }
}

🔐 方案2:数据库唯一索引

-- 订单表
CREATE TABLE `order` (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(50) NOT NULL COMMENT '订单号',
    user_id BIGINT NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL,
    status TINYINT NOT NULL,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    -- ⚡ 唯一索引(防止重复订单)
    UNIQUE KEY uk_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 支付流水表
CREATE TABLE payment_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_id BIGINT NOT NULL,
    transaction_id VARCHAR(100) NOT NULL COMMENT '交易流水号',
    amount DECIMAL(10, 2) NOT NULL,
    status TINYINT NOT NULL,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    -- ⚡ 唯一索引(防止重复支付)
    UNIQUE KEY uk_transaction_id (transaction_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
/**
 * 利用唯一索引防重
 */
@Service
public class OrderService {
    
    /**
     * ⚡ 创建订单(订单号唯一)
     */
    @Transactional(rollbackFor = Exception.class)
    public Order createOrder(OrderDTO dto) {
        // 生成唯一订单号
        String orderNo = generateOrderNo();
        
        Order order = new Order();
        order.setOrderNo(orderNo);
        order.setUserId(dto.getUserId());
        order.setTotalAmount(dto.getTotalAmount());
        order.setStatus(1);
        
        try {
            // ⚡ 插入订单(订单号重复会抛异常)
            orderMapper.insert(order);
            
        } catch (DuplicateKeyException e) {
            log.warn("订单号重复:orderNo={}", orderNo);
            throw new BusinessException("订单已存在,请勿重复提交");
        }
        
        return order;
    }
    
    /**
     * 生成唯一订单号
     */
    private String generateOrderNo() {
        // 使用雪花算法或时间戳+随机数
        return "ORD" + System.currentTimeMillis() + 
               RandomStringUtils.randomNumeric(4);
    }
}

🎯 方案3:接口幂等性设计

/**
 * 幂等性注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    
    /**
     * 幂等键(SpEL表达式)
     * 例如:#orderId
     */
    String key();
    
    /**
     * 超时时间(秒)
     */
    int timeout() default 300;
}

/**
 * 幂等性切面
 */
@Aspect
@Component
@Slf4j
public class IdempotentAspect {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private SpelExpressionParser spelParser;
    
    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
        // 1. 解析幂等键
        String idempotentKey = parseIdempotentKey(pjp, idempotent.key());
        
        // 2. ⚡ 检查是否已处理
        String lockKey = "idempotent:" + idempotentKey;
        
        Boolean success = redisTemplate.opsForValue().setIfAbsent(
            lockKey, "1", idempotent.timeout(), TimeUnit.SECONDS);
        
        if (success == null || !success) {
            log.warn("重复请求:key={}", idempotentKey);
            throw new BusinessException("请求正在处理中,请勿重复提交");
        }
        
        try {
            // 3. 执行业务方法
            return pjp.proceed();
            
        } catch (Exception e) {
            // 4. 失败时删除标记(允许重试)
            redisTemplate.delete(lockKey);
            throw e;
        }
    }
    
    /**
     * 解析幂等键
     */
    private String parseIdempotentKey(ProceedingJoinPoint pjp, String keyExpression) {
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        String[] paramNames = signature.getParameterNames();
        Object[] args = pjp.getArgs();
        
        // 构建SpEL上下文
        StandardEvaluationContext context = new StandardEvaluationContext();
        for (int i = 0; i < paramNames.length; i++) {
            context.setVariable(paramNames[i], args[i]);
        }
        
        // 解析表达式
        Expression expression = spelParser.parseExpression(keyExpression);
        return expression.getValue(context, String.class);
    }
}

/**
 * 使用示例
 */
@Service
public class PaymentService {
    
    /**
     * ⚡ 支付(幂等)
     */
    @Idempotent(key = "#orderId", timeout = 300)
    @Transactional(rollbackFor = Exception.class)
    public void pay(Long orderId) {
        // 查询订单
        Order order = orderMapper.selectById(orderId);
        
        // 检查订单状态
        if (order.getStatus() != 1) {
            throw new BusinessException("订单状态不正确");
        }
        
        // 调用支付
        paymentClient.pay(order);
        
        // 更新订单状态
        order.setStatus(2);
        orderMapper.updateById(order);
    }
}

🌐 前端防重复提交

// Vue 3示例
<template>
  <div>
    <!-- ⚡ 按钮置灰 -->
    <el-button 
      type="primary" 
      :loading="submitting"
      :disabled="submitting"
      @click="handleSubmit">
      {{ submitting ? '提交中...' : '提交订单' }}
    </el-button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { debounce } from 'lodash-es'
import { ElMessage } from 'element-plus'
import { submitOrder, generateToken } from '@/api/order'

const submitting = ref(false)
const submitToken = ref('')

// ⚡ 获取提交Token
const getSubmitToken = async () => {
  const { data } = await generateToken()
  submitToken.value = data
}

// ⚡ 防抖提交
const handleSubmit = debounce(async () => {
  if (submitting.value) {
    return
  }
  
  try {
    submitting.value = true
    
    // 获取Token
    if (!submitToken.value) {
      await getSubmitToken()
    }
    
    // 提交订单(携带Token)
    await submitOrder({
      ...formData,
      submitToken: submitToken.value
    })
    
    ElMessage.success('提交成功')
    
    // 清空Token
    submitToken.value = ''
    
  } catch (error) {
    ElMessage.error(error.message)
    
  } finally {
    submitting.value = false
  }
}, 300)

// 页面加载时获取Token
onMounted(() => {
  getSubmitToken()
})
</script>
// Axios拦截器(自动添加Token)
import axios from 'axios'

const request = axios.create({
  baseURL: '/api',
  timeout: 30000
})

// ⚡ 请求拦截器
request.interceptors.request.use(
  config => {
    // 对POST/PUT/DELETE请求自动添加Token
    if (['post', 'put', 'delete'].includes(config.method.toLowerCase())) {
      const submitToken = sessionStorage.getItem('submitToken')
      
      if (submitToken) {
        config.headers['Submit-Token'] = submitToken
        // 使用后删除
        sessionStorage.removeItem('submitToken')
      }
    }
    
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// ⚡ 响应拦截器
request.interceptors.response.use(
  response => {
    return response.data
  },
  error => {
    if (error.response?.data?.message === '请勿重复提交') {
      ElMessage.error('请勿重复提交')
    }
    return Promise.reject(error)
  }
)

✅ 最佳实践

防重复提交最佳实践:

1️⃣ 前端防护:
   □ 按钮置灰(点击后禁用)
   □ 防抖/节流(300ms)
   □ Loading遮罩
   □ 请求取消(取消重复请求)
   
2️⃣ 后端防护:
   □ Token验证(推荐)⭐⭐⭐⭐⭐
   □ Redis锁(高并发场景)
   □ 数据库唯一索引(最后防线)
   □ 接口幂等性设计
   
3️⃣ 技术选型:
   □ 表单提交:Token
   □ 支付请求:Token + 锁
   □ 数据修改:幂等性设计
   □ 高并发:分布式锁
   
4️⃣ 用户体验:
   □ 明确的提示信息
   □ 按钮状态反馈
   □ 错误提示友好
   □ 支持重试(失败时)
   
5️⃣ 监控告警:
   □ 重复提交次数统计
   □ Token验证失败告警
   □ 锁超时监控
   □ 异常提交检测

🎉 总结

防重复提交核心:

1️⃣ 前端防抖:按钮置灰+防抖函数
2️⃣ Token验证:一次性Token,用完即废
3️⃣ Redis锁:分布式锁防并发
4️⃣ 唯一索引:数据库层防重
5️⃣ 幂等设计:接口天然防重

记住:防重复提交是前后端共同的责任! 🛡️


文档编写时间:2025年10月24日
作者:热爱接口安全的幂等工程师
版本:v1.0
愿每次提交都安全可靠!