防重复提交:前后端职责划分与最佳实践

不是“哪里做”,而是“都做,各司其职”

一、核心结论

前端和后端都要做,但职责不同:

层级核心目标解决什么问题
前端提升用户体验防止用户手抖、误操作导致重复提交
后端保证数据一致性防止恶意请求、网络重放、并发攻击

一句话:前端防君子,后端防小人。

二、前端防重复提交(体验层)

2.1 常见实现方式

方式一:按钮禁用(最常用)

<template>
  <el-button 
    :loading="submitting" 
    :disabled="submitting"
    @click="handleSubmit">
    {{ submitting ? '提交中...' : '提交' }}
  </el-button>
</template>

<script setup>
const submitting = ref(false)

const handleSubmit = async () => {
  if (submitting.value) return  // 二次防护
  submitting.value = true
  try {
    await api.submit(data)
  } finally {
    submitting.value = false
  }
}
</script>

方式二:请求拦截器(全局防抖)

// 全局请求防抖
let pendingRequests = new Map()

axios.interceptors.request.use(config => {
  const key = `${config.method}-${config.url}`
  if (pendingRequests.has(key)) {
    return Promise.reject({ message: '重复请求已拦截' })
  }
  pendingRequests.set(key, true)
  return config
})

axios.interceptors.response.use(
  response => {
    const key = `${response.config.method}-${response.config.url}`
    pendingRequests.delete(key)
    return response
  },
  error => {
    // 错误时也要清理
    if (error.config) {
      const key = `${error.config.method}-${error.config.url}`
      pendingRequests.delete(key)
    }
    return Promise.reject(error)
  }
)

方式三:提交后跳转/清空表单

const handleSubmit = async () => {
  await api.submit(formData)
  // 提交成功后清空表单,避免再次提交
  resetForm()
  // 或跳转页面
  router.push('/success')
}

2.2 前端的局限性

场景前端能否防御
用户快速双击✅ 能
网络慢时重复点击✅ 能
刷新页面后重复提交❌ 不能
脚本自动重复请求❌ 不能
抓包重放攻击❌ 不能

三、后端防重复提交(数据层)

3.1 方案一:数据库唯一约束(最可靠)

-- 业务唯一约束示例
ALTER TABLE `orders` ADD UNIQUE INDEX `uk_user_product` (`user_id`, `product_id`);
ALTER TABLE `payment_log` ADD UNIQUE INDEX `uk_transaction_id` (`transaction_id`);

-- 插入时使用 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE
INSERT INTO `orders` (order_no, user_id, product_id, amount) 
VALUES (?, ?, ?, ?) 
ON DUPLICATE KEY UPDATE id = id;  -- 如果重复,什么都不做

3.2 方案二:Redis 分布式锁(最常用)

@Service
public class OrderService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    public Result createOrder(OrderRequest request) {
        // 1. 生成唯一键(用户ID + 业务场景)
        String lockKey = "submit:order:" + request.getUserId() + ":" + request.getProductId();
        
        // 2. 尝试获取锁(setIfAbsent + 过期时间)
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
        
        if (!Boolean.TRUE.equals(locked)) {
            return Result.error("请勿重复提交");
        }
        
        try {
            // 3. 业务逻辑
            return doCreateOrder(request);
        } finally {
            // 4. 释放锁(需验证持有者,防止误删)
            redisTemplate.delete(lockKey);
        }
    }
}

3.3 方案三:Token 机制(适合前后端分离)

// 1. 获取 Token
@GetMapping("/submit-token")
public Result<String> getSubmitToken(@RequestParam Long userId) {
    String token = UUID.randomUUID().toString();
    String key = "submit:token:" + userId;
    redisTemplate.opsForValue().set(key, token, Duration.ofMinutes(5));
    return Result.success(token);
}

// 2. 提交时校验 Token
@PostMapping("/submit")
public Result submit(@RequestHeader("X-Submit-Token") String token, 
                      @RequestBody SubmitRequest request) {
    String key = "submit:token:" + request.getUserId();
    String cachedToken = redisTemplate.opsForValue().get(key);
    
    if (token == null || !token.equals(cachedToken)) {
        return Result.error("无效或重复提交");
    }
    
    // 删除 Token,确保只能用一次
    redisTemplate.delete(key);
    
    // 执行业务逻辑
    return doSubmit(request);
}

3.4 方案四:幂等表(适合高并发场景)

-- 幂等记录表
CREATE TABLE `idempotent_record` (
    `id` bigint PRIMARY KEY AUTO_INCREMENT,
    `business_key` varchar(128) NOT NULL COMMENT '业务唯一键',
    `business_type` varchar(32) NOT NULL COMMENT '业务类型',
    `result` text COMMENT '处理结果',
    `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_business_key` (`business_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@Transactional
public Result processWithIdempotent(String businessKey, Runnable business) {
    try {
        // 尝试插入幂等记录
        idempotentMapper.insert(IdempotentRecord.builder()
            .businessKey(businessKey)
            .businessType("ORDER_CREATE")
            .build());
    } catch (DuplicateKeyException e) {
        return Result.error("请勿重复提交");
    }
    
    // 执行业务逻辑
    business.run();
    return Result.success();
}

四、方案对比与选择

方案实现难度性能可靠性适用场景
前端禁用最高辅助手段
数据库唯一约束⭐⭐最高核心数据(订单、支付)
Redis 分布式锁⭐⭐通用场景
Token 机制⭐⭐⭐前后端分离、需要严格防重
幂等表⭐⭐⭐最高金融、支付等强一致性场景

选择建议

  • 普通表单提交:前端禁用 + Redis 分布式锁

  • 订单/支付:前端禁用 + 数据库唯一约束 + Redis 锁(双重保障)

  • 高并发秒杀:Redis 锁 + 幂等表 + 消息队列异步处理

  • 开放 API:Token 机制 + API 限流

五、完整最佳实践

5.1 前端代码(Vue3 示例)

<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

const submitting = ref(false)

const handleSubmit = async () => {
  // 第一道防线:本地状态判断
  if (submitting.value) {
    ElMessage.warning('正在提交,请勿重复操作')
    return
  }
  
  submitting.value = true
  
  try {
    const res = await api.submit(formData.value)
    
    // 第二道防线:后端返回的业务防重判断
    if (res.code === 429) {
      ElMessage.warning('操作过于频繁,请稍后再试')
      return
    }
    
    if (res.code === 0) {
      ElMessage.success('提交成功')
      // 清空表单,避免再次提交
      resetForm()
    }
  } catch (error) {
    ElMessage.error('提交失败')
  } finally {
    submitting.value = false
  }
}
</script>

5.2 后端统一防重注解(Spring Boot)

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicate {
    String prefix() default "";
    int expireSeconds() default 5;
}

@Aspect
@Component
public class PreventDuplicateAspect {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Around("@annotation(preventDuplicate)")
    public Object around(ProceedingJoinPoint point, PreventDuplicate preventDuplicate) throws Throwable {
        // 获取当前用户ID和请求参数
        Long userId = SecurityUtils.getCurrentUserId();
        String method = point.getSignature().toShortString();
        String paramJson = JSON.toJSONString(point.getArgs());
        
        // 生成唯一键
        String key = String.format("submit:%s:%s:%d", 
            preventDuplicate.prefix(), method, userId);
        
        // 使用 paramJson 的 MD5 作为更细粒度的判断
        String md5 = DigestUtils.md5DigestAsHex(paramJson.getBytes());
        key = key + ":" + md5;
        
        // 尝试加锁
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(key, "1", Duration.ofSeconds(preventDuplicate.expireSeconds()));
        
        if (!Boolean.TRUE.equals(locked)) {
            throw new BusinessException("请勿重复提交");
        }
        
        try {
            return point.proceed();
        } finally {
            redisTemplate.delete(key);
        }
    }
}

// 使用方式
@PostMapping("/order")
@PreventDuplicate(prefix = "order", expireSeconds = 5)
public Result createOrder(@RequestBody OrderRequest request) {
    // 业务逻辑
}

5.3 完整架构图

用户操作
    │
    ▼
┌─────────────────────────────────────────┐
│              前端防重                     │
│  ┌─────────────────────────────────┐    │
│  │ 1. 按钮禁用                       │    │
│  │ 2. 请求拦截器(相同请求去重)       │    │
│  │ 3. 提交后清空表单/跳转             │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘
    │
    │ HTTP 请求
    ▼
┌─────────────────────────────────────────┐
│              后端防重                     │
│  ┌─────────────────────────────────┐    │
│  │ 1. Redis 分布式锁(同一用户/操作)  │    │
│  │ 2. Token 机制(前后端分离)        │    │
│  │ 3. 数据库唯一约束(核心数据)       │    │
│  │ 4. 幂等表(金融级)                │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘
    │
    ▼
  数据库

六、总结

角色必须做的可选做的
前端按钮禁用、加载状态请求去重、提交后清理
后端Redis 分布式锁Token 机制、幂等表、唯一约束

核心原则

  1. 前端是体验的第一关:让用户感觉流畅,不要因为手抖而重复提交

  2. 后端是数据的最后防线:无论前端怎么防,后端必须有兜底机制

  3. 防御要分层:没有一层是万能的,多层防护才能保证万无一失

  4. 幂等性是最终方案:任何接口设计都应该考虑幂等性

一句话总结:前端做体验优化(防君子),后端做数据保障(防小人),两者缺一不可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

BlueSea 每日coding

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值