不是“哪里做”而是“都做各司其职”一、核心结论前端和后端都要做但职责不同层级核心目标解决什么问题前端提升用户体验防止用户手抖、误操作导致重复提交后端保证数据一致性防止恶意请求、网络重放、并发攻击一句话前端防君子后端防小人。二、前端防重复提交体验层2.1 常见实现方式方式一按钮禁用最常用template el-button :loadingsubmitting :disabledsubmitting clickhandleSubmit {{ 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 RedisTemplateString, 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 ResultString 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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 锁 幂等表 消息队列异步处理开放 APIToken 机制 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 } } /script5.2 后端统一防重注解Spring BootTarget(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface PreventDuplicate { String prefix() default ; int expireSeconds() default 5; } Aspect Component public class PreventDuplicateAspect { Autowired private RedisTemplateString, 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 机制、幂等表、唯一约束核心原则前端是体验的第一关让用户感觉流畅不要因为手抖而重复提交后端是数据的最后防线无论前端怎么防后端必须有兜底机制防御要分层没有一层是万能的多层防护才能保证万无一失幂等性是最终方案任何接口设计都应该考虑幂等性一句话总结前端做体验优化防君子后端做数据保障防小人两者缺一不可。
防重复提交:前后端职责划分与最佳实践
发布时间:2026/5/26 1:25:04
不是“哪里做”而是“都做各司其职”一、核心结论前端和后端都要做但职责不同层级核心目标解决什么问题前端提升用户体验防止用户手抖、误操作导致重复提交后端保证数据一致性防止恶意请求、网络重放、并发攻击一句话前端防君子后端防小人。二、前端防重复提交体验层2.1 常见实现方式方式一按钮禁用最常用template el-button :loadingsubmitting :disabledsubmitting clickhandleSubmit {{ 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 RedisTemplateString, 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 ResultString 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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 锁 幂等表 消息队列异步处理开放 APIToken 机制 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 } } /script5.2 后端统一防重注解Spring BootTarget(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface PreventDuplicate { String prefix() default ; int expireSeconds() default 5; } Aspect Component public class PreventDuplicateAspect { Autowired private RedisTemplateString, 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 机制、幂等表、唯一约束核心原则前端是体验的第一关让用户感觉流畅不要因为手抖而重复提交后端是数据的最后防线无论前端怎么防后端必须有兜底机制防御要分层没有一层是万能的多层防护才能保证万无一失幂等性是最终方案任何接口设计都应该考虑幂等性一句话总结前端做体验优化防君子后端做数据保障防小人两者缺一不可。