不是“哪里做”,而是“都做,各司其职”
一、核心结论
前端和后端都要做,但职责不同:
| 层级 | 核心目标 | 解决什么问题 |
|---|---|---|
| 前端 | 提升用户体验 | 防止用户手抖、误操作导致重复提交 |
| 后端 | 保证数据一致性 | 防止恶意请求、网络重放、并发攻击 |
一句话:前端防君子,后端防小人。
二、前端防重复提交(体验层)
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 机制、幂等表、唯一约束 |
核心原则:
-
前端是体验的第一关:让用户感觉流畅,不要因为手抖而重复提交
-
后端是数据的最后防线:无论前端怎么防,后端必须有兜底机制
-
防御要分层:没有一层是万能的,多层防护才能保证万无一失
-
幂等性是最终方案:任何接口设计都应该考虑幂等性
一句话总结:前端做体验优化(防君子),后端做数据保障(防小人),两者缺一不可。

1422

被折叠的 条评论
为什么被折叠?



