标题: 重复提交还在手动防?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
愿每次提交都安全可靠! ✨