前言
当你的用户疯狂点击提交按钮时,你的系统准备好迎接这场“连击风暴”了吗?
在电商系统的实战中,我见过太多因重复提交导致的资损事故——用户一次点击,系统却创建了多个订单,导致库存错乱、用户重复支付、客服投诉爆棚。
有些小伙伴在工作中可能遇到过这样的场景:大促期间,用户反馈“明明只点了一次,为什么扣了两次款?”
开发同学查了半天日志,发现同一个用户请求在毫秒级内真的到达了服务器两次。
今天这篇文章就跟大家聊聊高并发下防止重复提交订单,希望对你会有所帮助。
01 为什么会重复提交?
在深入解决方案前,我们必须搞清楚重复提交是如何发生的。
常见的场景有:
用户无意识重复点击:网络延迟时,用户心急多次点击提交按钮
前端防抖失效:前端做了防抖处理,但被绕过或配置不当
网络超时重试:请求超时后,客户端或网关自动重试
恶意攻击:竞争对手或黑客故意重复提交
后端处理超时:第一个请求处理慢,客户端以为失败又发一次
来看一个典型的用户操作流程,以及其中可能发生重复的各个环节:
从图中可以看到,从用户点击到订单落库,几乎每个环节都可能成为重复提交的“案发现场”。
下面,我们就针对这些环节,层层布防。
02 第一道防线:前端防抖与按钮控制
这是最直观、成本最低的防护措施。
原则是:在用户交互层面尽量减少无效请求。
2.1 按钮状态控制
// 前端防抖实现示例(Vue + Element UI) <template> <el-button :loading="submitting" :disabled="submitting" @click="handleSubmitOrder" > {{ submitting ? '提交中...' : '提交订单' }} </el-button> </template> <script> export default { data() { return { submitting: false, submitToken: null // 用于标识当前提交的token } }, methods: { async handleSubmitOrder() { if (this.submitting) { this.$message.warning('正在提交,请勿重复点击') return } this.submitting = true try { // 生成唯一token,用于后端幂等性校验 this.submitToken = this.generateSubmitToken() const result = await this.$api.order.submit({ orderData: this.orderData, submitToken: this.submitToken }) this.$message.success('订单提交成功') this.$router.push(`/order/detail/${result.orderId}`) } catch (error) { this.$message.error(`提交失败: ${error.message}`) this.submitting = false // 失败后重置状态 } }, generateSubmitToken() { // 生成唯一标识,可以用UUID或时间戳+随机数 return `order_submit_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` } } } </script>2.2 请求防抖与拦截
// 使用axios拦截器实现请求防抖 import axios from'axios' // 存储正在进行的请求 const pendingRequests = newMap() // 生成请求key const generateReqKey = (config) => { const { method, url, params, data } = config return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&') } // 请求拦截器 axios.interceptors.request.use(config => { const key = generateReqKey(config) if (pendingRequests.has(key)) { // 请求已存在,取消当前请求 config.cancelToken = new axios.CancelToken(cancel => { cancel(`重复请求已被拦截: ${key}`) }) } else { // 新请求,添加到pending中 pendingRequests.set(key, config) } return config }) // 响应拦截器 axios.interceptors.response.use( response => { const key = generateReqKey(response.config) pendingRequests.delete(key) return response }, error => { if (axios.isCancel(error)) { console.log('请求被取消:', error.message) returnPromise.reject(error) } // 错误处理完成后,也要从pending中移除 if (error.config) { const key = generateReqKey(error.config) pendingRequests.delete(key) } returnPromise.reject(error) } )前端防护小结:
优点:实现简单,能拦截大部分用户无意识的重复点击
缺点:可被绕过(如直接调用API、禁用JS、使用Postman等工具)
结论:前端防护是必要但不充分的措施,绝不能作为唯一防线
03 第二道防线:后端接口幂等性设计
幂等性是解决重复提交的核心理念。
所谓幂等,就是同一个操作执行多次的结果与执行一次的结果相同。
3.1 什么是幂等性?
对于订单提交接口:
幂等:无论调用1次还是N次,都只创建一个订单
非幂等:调用N次可能创建N个订单
3.2 基于Token的幂等实现
这是最常用的幂等实现方案,流程如下:
客户端在提交前,先向后端申请一个唯一Token
提交订单时携带此Token
服务端检查Token是否已使用过
// 幂等性Token服务 @Service public class IdempotentTokenService { @Autowired private RedisTemplate<String, String> redisTemplate; private static final String IDEMPOTENT_PREFIX = "idempotent:token:"; private static final long TOKEN_EXPIRE_SECONDS = 300; // Token有效期5分钟 /** * 生成幂等性Token * / public String generateToken(String userId) { String token = UUID.randomUUID().toString(); String redisKey = IDEMPOTENT_PREFIX + userId + ":" + token; // 存储Token,设置过期时间 redisTemplate.opsForValue().set( redisKey, "1", TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS ); return token; } /** * 检查并消费Token * @return true: Token有效且消费成功; false: Token无效或已消费 */ public boolean checkAndConsumeToken(String userId, String token) { String redisKey = IDEMPOTENT_PREFIX + userId + ":" + token; // 使用Lua脚本保证原子性 String luaScript = """ if redis.call('get', KEYS[1]) == '1' then redis.call('del', KEYS[1]) return 1 else return 0 end """; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); redisScript.setScriptText(luaScript); redisScript.setResultType(Long.class); Long result = redisTemplate.execute( redisScript, Collections.singletonList(redisKey) ); return result != null && result == 1L; } } // 使用AOP实现幂等性校验 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { String key() default ""; // 幂等键,支持SpEL表达式 long expireTime() default 300; // 过期时间,秒 } @Aspect @Component public class IdempotentAspect { @Autowired private RedisTemplate<String, String> redisTemplate; @Around("@annotation(idempotent)") public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { // 1. 获取方法参数 Object[] args = joinPoint.getArgs(); MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); // 2. 解析幂等键(支持SpEL) String keyExpression = idempotent.key(); String redisKey = parseKey(keyExpression, method, args); // 3. 尝试获取分布式锁(防止并发请求同时通过检查) String lockKey = redisKey + ":lock"; boolean lockAcquired = false; try { // 尝试加锁 lockAcquired = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (!lockAcquired) { thrownew BusinessException("系统繁忙,请稍后重试"); } // 4. 检查Token是否已使用 Boolean exists = redisTemplate.hasKey(redisKey); if (Boolean.TRUE.equals(exists)) { // Token已使用,直接返回之前的处理结果(这里需要根据实际业务调整) throw new BusinessException("请勿重复提交订单"); } // 5. 执行业务逻辑 Object result = joinPoint.proceed(); // 6. 标记Token已使用 redisTemplate.opsForValue().set( redisKey, "processed", idempotent.expireTime(), TimeUnit.SECONDS ); return result; } finally { // 释放锁 if (lockAcquired) { redisTemplate.delete(lockKey); } } } private String parseKey(String expression, Method method, Object[] args) { // 这里实现SpEL表达式解析,获取实际的幂等键 // 例如可以从参数中提取userId+orderToken return "parsed:key:from:expression"; } } // 在订单提交接口上使用 @RestController @RequestMapping("/order") public class OrderController { @PostMapping("/submit") @Idempotent(key = "#request.userId + ':' + #request.submitToken", expireTime = 300) public ApiResponse<OrderSubmitResult> submitOrder(@RequestBody OrderSubmitRequest request) { // 这里是真正的订单创建逻辑 OrderSubmitResult result = orderService.createOrder(request); return ApiResponse.success(result); } }3.3 基于唯一业务标识的幂等
除了Token方案,还可以利用业务的自然唯一性实现幂等:
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Transactional public OrderSubmitResult createOrder(OrderSubmitRequest request) { // 方法1:先查询是否存在 Order existingOrder = orderMapper.selectByUniqueKey( request.getUserId(), request.getProductId(), request.getSubmitTime() ); if (existingOrder != null) { // 订单已存在,直接返回 return convertToResult(existingOrder); } // 方法2:利用数据库唯一约束 try { Order newOrder = buildOrder(request); orderMapper.insert(newOrder); return convertToResult(newOrder); } catch (DuplicateKeyException e) { // 捕获唯一键冲突异常 log.warn("订单重复提交,uniqueKey={}", request.getUniqueKey()); // 查询已创建的订单并返回 Order createdOrder = orderMapper.selectByUniqueKey( request.getUserId(), request.getProductId(), request.getSubmitTime() ); if (createdOrder == null) { throw new BusinessException("订单处理异常,请稍后重试"); } return convertToResult(createdOrder); } } // 订单表可添加唯一索引 // ALTER TABLE t_order ADD UNIQUE KEY uk_user_product_time (user_id, product_id, submit_time); }幂等性设计小结:
Token方案:通用性强,适合大多数场景
业务标识方案:更自然,但依赖业务的天然唯一性
关键点:所有幂等性检查必须在事务开始前完成,否则可能失效
04 第三道防线:数据库层防护
数据库是数据持久化的最后一道关卡,在这里设置防护至关重要。
4.1 唯一约束与乐观锁
-- 订单表设计示例 CREATE TABLE`t_order` ( `id`bigint(20) NOTNULL AUTO_INCREMENT COMMENT'主键', `order_no`varchar(32) NOTNULLCOMMENT'订单号,业务唯一', `user_id`bigint(20) NOTNULLCOMMENT'用户ID', `product_id`bigint(20) NOTNULLCOMMENT'商品ID', `quantity`int(11) NOTNULLCOMMENT'购买数量', `amount`decimal(10,2) NOTNULLCOMMENT'订单金额', `status`tinyint(4) NOTNULLDEFAULT'1'COMMENT'订单状态:1-待支付,2-已支付', `submit_token`varchar(64) DEFAULTNULLCOMMENT'提交Token,用于幂等', `version`int(11) NOTNULLDEFAULT'1'COMMENT'版本号,用于乐观锁', `create_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMP, `update_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY`uk_order_no` (`order_no`), -- 订单号唯一 UNIQUE KEY`uk_user_submit_token` (`user_id`, `submit_token`), -- 提交Token唯一 UNIQUE KEY`uk_user_product_time` (`user_id`, `product_id`, `create_time`), -- 业务维度唯一 KEY`idx_user_id` (`user_id`), KEY`idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';4.2 数据库层面的幂等实现
// 使用数据库事务+唯一约束保证最终一致性 @Service public class OrderServiceV2 { @Autowired private OrderMapper orderMapper; @Autowired private IdempotentTokenService tokenService; @Transactional(rollbackFor = Exception.class) public OrderSubmitResult submitOrderWithDBProtection(OrderSubmitRequest request) { String userId = request.getUserId(); String submitToken = request.getSubmitToken(); // 1. 检查幂等Token(在事务外先检查一次) if (!tokenService.checkAndConsumeToken(userId, submitToken)) { throw new BusinessException("请勿重复提交订单"); } try { // 2. 生成订单号(雪花算法等分布式ID生成器) String orderNo = generateOrderNo(); // 3. 创建订单对象 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setProductId(request.getProductId()); order.setQuantity(request.getQuantity()); order.setAmount(calculateAmount(request)); order.setSubmitToken(submitToken); // 4. 插入订单(这里依赖数据库唯一约束) orderMapper.insert(order); // 5. 更新库存等后续操作... updateProductStock(request.getProductId(), request.getQuantity()); returnnew OrderSubmitResult(orderNo, "订单创建成功"); } catch (DuplicateKeyException e) { // 6. 处理唯一约束冲突 log.warn("订单重复提交,userId={}, token={}", userId, submitToken); // 查询已创建的订单 Order existingOrder = orderMapper.selectBySubmitToken(userId, submitToken); if (existingOrder != null) { return new OrderSubmitResult( existingOrder.getOrderNo(), "订单已创建成功,请勿重复提交" ); } // 理论上不会走到这里,除非有极端情况 throw new BusinessException("订单处理异常,请稍后重试"); } } }05 第四道防线:分布式锁
在分布式环境下,多个实例可能同时处理同一个请求,需要分布式锁来保证只有一个实例执行核心逻辑。
5.1 基于Redis的分布式锁
@Component public class DistributedLockService { @Autowired private RedissonClient redissonClient; /** * 尝试获取分布式锁 * @param lockKey 锁的key * @param waitTime 等待时间(毫秒) * @param leaseTime 持有时间(毫秒) * @return 锁对象,获取失败返回null */ public RLock tryLock(String lockKey, long waitTime, long leaseTime) { RLock lock = redissonClient.getLock(lockKey); try { boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS); return acquired ? lock : null; } catch (InterruptedException e) { Thread.currentThread().interrupt(); returnnull; } } /** * 订单提交分布式锁 */ public RLock lockForOrderSubmit(String userId, String submitToken) { String lockKey = String.format("order:submit:lock:%s:%s", userId, submitToken); return tryLock(lockKey, 100, 5000); // 等待100ms,锁持有5秒 } } // 在订单服务中使用分布式锁 @Service public class OrderServiceV3 { @Autowired private DistributedLockService lockService; @Autowired private OrderMapper orderMapper; public OrderSubmitResult submitOrderWithDistributedLock(OrderSubmitRequest request) { String userId = request.getUserId(); String submitToken = request.getSubmitToken(); // 1. 获取分布式锁 RLock lock = lockService.lockForOrderSubmit(userId, submitToken); if (lock == null) { throw new BusinessException("系统繁忙,请稍后重试"); } try { // 2. 检查是否已处理 Order existingOrder = orderMapper.selectBySubmitToken(userId, submitToken); if (existingOrder != null) { return new OrderSubmitResult( existingOrder.getOrderNo(), "订单已创建成功,请勿重复提交" ); } // 3. 执行业务逻辑 return doCreateOrder(request); } finally { // 4. 释放锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } private OrderSubmitResult doCreateOrder(OrderSubmitRequest request) { // 实际的订单创建逻辑 // 这里已经保证了同一时刻只有一个线程在处理同一个提交请求 // ... } }5.2 分布式锁的注意事项
使用分布式锁时要注意:
锁粒度:不要太粗(影响性能)也不要太细(增加复杂度)
锁超时:必须设置合理的超时时间,防止死锁
锁续期:对于长时间操作,需要实现锁续期机制
可重入性:同一个线程可以重复获取锁
容错性:Redis集群故障时要有降级方案
06 第五道防线:异步处理与消息队列
对于高并发场景,可以采用异步处理模式,将同步请求转为异步任务。
实现代码示例:
// 异步订单处理实现 @Component public class AsyncOrderService { @Autowired private RocketMQTemplate rocketMQTemplate; @Autowired private RedisTemplate<String, String> redisTemplate; /** * 异步提交订单 */ public AsyncSubmitResult asyncSubmitOrder(OrderSubmitRequest request) { // 1. 生成唯一请求ID String requestId = generateRequestId(request.getUserId()); // 2. 快速验证(库存、用户状态等) quickValidate(request); // 3. 将请求ID与用户关联(用于查询结果) String pendingKey = "order:pending:" + request.getUserId() + ":" + requestId; redisTemplate.opsForValue().set(pendingKey, "processing", 10, TimeUnit.MINUTES); // 4. 发送到消息队列 OrderMessage message = new OrderMessage(); message.setRequestId(requestId); message.setRequest(request); message.setTimestamp(System.currentTimeMillis()); rocketMQTemplate.asyncSend( "ORDER_SUBMIT_TOPIC", message, new SendCallback() { @Override public void onSuccess(SendResult sendResult) { log.info("订单消息发送成功: {}", requestId); } @Override public void onException(Throwable throwable) { log.error("订单消息发送失败: {}", requestId, throwable); // 发送失败,更新状态 redisTemplate.opsForValue().set( pendingKey, "failed", 5, TimeUnit.MINUTES ); } } ); // 5. 立即返回,告知用户处理中 return new AsyncSubmitResult(requestId, "订单提交成功,正在处理中"); } } // 消息消费者 @Component @RocketMQMessageListener( topic = "ORDER_SUBMIT_TOPIC", consumerGroup = "order-submit-consumer-group" ) public class OrderSubmitConsumer implements RocketMQListener<OrderMessage> { @Autowired private OrderMapper orderMapper; @Override public void onMessage(OrderMessage message) { String requestId = message.getRequestId(); OrderSubmitRequest request = message.getRequest(); // 1. 幂等检查(基于requestId) Order existing = orderMapper.selectByRequestId(requestId); if (existing != null) { log.info("订单已处理,跳过: {}", requestId); return; } // 2. 创建订单 Order order = createOrder(request, requestId); try { orderMapper.insert(order); log.info("订单创建成功: {}", order.getOrderNo()); // 3. 更新处理状态 updateProcessingStatus(request.getUserId(), requestId, "success", order.getOrderNo()); } catch (DuplicateKeyException e) { log.warn("订单重复,requestId={}", requestId); // 查询已创建的订单 Order created = orderMapper.selectByRequestId(requestId); if (created != null) { updateProcessingStatus(request.getUserId(), requestId, "success", created.getOrderNo()); } } } }07 综合方案:多层次联合防护
在实际生产环境中,我们通常会采用多层次、立体化的防护策略。
以下是一个完整的综合方案流程图:
这个多层次方案中,每一层都有其特定作用:
前端层:用户体验优化,拦截大部分无意识重复
网关层:安全防护,防刷、限流
业务层:核心幂等逻辑,分布式锁保证并发安全
数据层:最终保障,唯一约束防止数据不一致
异步层:削峰填谷,提升系统吞吐量
08 实战:不同场景下的方案选择
不同的业务场景需要不同的防护策略,这里给出一些实践建议:
8.1 普通电商订单
// 普通电商订单推荐方案 @Service public class StandardOrderService { // 综合使用:前端防抖 + Token幂等 + 数据库唯一约束 public OrderSubmitResult submitStandardOrder(OrderSubmitRequest request) { // 1. 参数校验 validateRequest(request); // 2. 幂等Token检查(Redis) if (!idempotentCheck(request.getUserId(), request.getSubmitToken())) { return getExistingOrderResult(request.getUserId(), request.getSubmitToken()); } // 3. 分布式锁(防并发) RLock lock = acquireOrderLock(request.getUserId(), request.getProductId()); try { // 4. 库存检查等业务校验 checkInventory(request.getProductId(), request.getQuantity()); // 5. 创建订单(依赖数据库唯一约束) return createOrderInTransaction(request); } finally { lock.unlock(); } } }8.2 秒杀订单
// 秒杀订单需要更极致的优化 @Service public class FlashSaleOrderService { // 秒杀方案:异步处理 + 库存预扣 + 最终一致性 public FlashSaleSubmitResult submitFlashSaleOrder(FlashSaleRequest request) { // 1. 验证用户资格和活动状态(缓存中检查) if (!checkUserQualification(request.getUserId(), request.getActivityId())) { throw new BusinessException("您不具备参与资格"); } // 2. 预扣库存(Redis原子操作) boolean stockDeducted = preDeductStock( request.getActivityId(), request.getProductId(), request.getUserId() ); if (!stockDeducted) { throw new BusinessException("库存不足"); } // 3. 生成唯一请求ID String requestId = generateRequestId(request.getUserId(), request.getActivityId()); // 4. 发送到消息队列(快速返回) sendToMQ(request, requestId); // 5. 立即返回 return new FlashSaleSubmitResult(requestId, "秒杀请求已接受,处理中"); } // 消费者异步创建订单 @Transactional public void processFlashSaleOrder(FlashSaleRequest request, String requestId) { // 这里只需要处理真正的订单创建 // 因为库存已在Redis中预扣,只需保证最终一致性 try { createOrder(request, requestId); // 同步库存到数据库 syncStockToDB(request.getProductId(), request.getActivityId()); } catch (Exception e) { // 失败时回滚Redis库存 rollbackStockInRedis(request.getActivityId(), request.getProductId(), request.getUserId()); throw e; } } }10 总结
防止重复提交订单是一个系统工程,需要从前到后、多层次的防护。
让我们回顾一下关键点:
前端防护是体验,不是保障:按钮防抖、请求拦截能改善用户体验,但不能作为唯一防线。
幂等性是核心理念:无论是Token方案还是业务唯一标识,都要保证同一操作执行多次的结果一致。
分布式锁解决并发问题:在分布式环境下,防止多个实例同时处理同一请求。
数据库是最后防线:唯一约束、乐观锁等机制能在应用层防护失效时保证数据一致性。
异步处理提升吞吐:对于高并发场景,将同步请求转为异步处理,提高系统整体吞吐量。
监控告警必不可少:没有监控的系统就像没有仪表的飞机,无法发现问题和优化性能。
在实际架构设计中,我通常建议采用"前端防抖 + 网关限流 + Token幂等 + 分布式锁 + 数据库唯一约束"的综合方案,对于秒杀等极致场景再加入异步处理。
有些小伙伴可能会觉得这些防护措施太复杂,影响开发效率。
但请记住:预防的成本远低于修复的成本。
一次重复提交导致的资损事故,可能就需要整个团队加班数周来修复数据和安抚用户。
技术方案没有银弹,只有最适合业务场景的平衡。