秒杀系统架构设计08:全链路架构
全链路异常处理:实现订单不超卖、不掉单、不重复
本文是「10Wqps 秒杀架构」系列的终章。前面的七篇文章分别拆解了分层架构、异步下单、动静分离、缓存、库存、MQ 和高可用。本篇将这些技术方案串联起来,构建端到端的全链路异常处理体系,最终实现"不超卖、不掉单、不重复下单"三大目标。
一、开篇
假设你的商城线上注册会员突破 6000 万,促销活动期间下单 QPS 峰值 20000+。面对这个量级,你的订单全链路设计方案需要回答三个致命问题:
- 如何防止超卖?——库存扣成负数,平台赔到破产
- 如何防止掉单?——库存扣了但订单丢了,用户投诉如潮
- 如何防止重复下单?——用户付了两遍钱,客服工单爆炸
这三个问题的共同解法不在某一个组件中,而在整个链路的全环节设计中。每个节点都有"正常路径"和"异常路径",异常路径的覆盖率决定了系统的可靠性。
二、全链路架构总览
创建订单的全链路划分为五大层级。每一层都有明确的职责、依赖和异常处理策略:
三、第一层:请求接入层异常处理
核心职责:负载均衡、流量管控、路由分发、前置过滤
异常矩阵:
| 异常场景 | 检测方式 | 处理策略 | 兜底方案 |
|---|---|---|---|
| 限流触发 | Sentinel 计数器 | 返回 HTTP 429 + “活动太火爆,请稍后再试” | 动态调整阈值(Nacos 实时下发) |
| 熔断触发 | Sentinel 熔断器状态 = OPEN | 返回降级响应 + 记录熔断日志 | HALF_OPEN 自动探测恢复 |
| Gateway 节点故障 | 健康检查失败 | K8s 自动摘除故障 Pod + 重启 | 多实例部署,故障转移 |
| 下游服务下线 | 服务发现(Nacos)心跳超时 | 路由表摘除故障实例 | 降级到兜底接口 |
| Token 无效/过期 | Gateway GlobalFilter 解析 | 返回 401,不向下游传递 | 记录异常请求日志 |
| Nginx 节点故障 | Keepalived 检测 | VIP 漂移到备用 Nginx | — |
// 统一异常捕获: Gateway GlobalFilter@ComponentpublicclassGlobalExceptionFilterimplementsGlobalFilter{@OverridepublicMono<Void>filter(ServerWebExchangeexchange,GatewayFilterChainchain){returnchain.filter(exchange).onErrorResume(e->{// 统一封装错误响应ErrorResponseerror=newErrorResponse();error.setRequestId(exchange.getRequest().getId());error.setTimestamp(System.currentTimeMillis());if(einstanceofBlockException){error.setCode(429);error.setMessage("活动太火爆,请稍后再试");}elseif(einstanceofConnectTimeoutException){error.setCode(504);error.setMessage("服务繁忙,请稍后重试");}else{error.setCode(500);error.setMessage("系统异常");}// 上报监控metricsCollector.recordException(e);returnwriteResponse(exchange,error);});}}四、第二层:业务校验层异常处理
核心职责:在核心业务执行前,快速拦截所有非法请求
校验链路(任一失败即返回):
验证码校验 → 活动状态校验 → 黑名单检查 → 用户状态检查 → 商品状态检查 → 库存预检查异常矩阵:
| 异常场景 | 检测方式 | 处理策略 |
|---|---|---|
| 验证码错误/过期 | 验证码服务返回 | 返回具体错误提示,不消耗 Redis/DB 资源 |
| 活动未开始/已结束 | Redis 查询活动时间窗口 | 返回"活动未开始"或"活动已结束" |
| 用户在黑名单 | Redis Set 查询 | 返回"操作受限",记录风控日志 |
| 用户状态异常(冻结) | Feign 调用用户中心 | 熔断降级:用户中心不可用时放行(依赖下游订单服务二次校验) |
| 商品不存在/下架 | Feign 调用商品服务 | 熔断降级:返回"商品不存在" |
| 远程调用超时 | Feign + Sentinel | 熔断触发 → 返回降级提示 |
快速失败原则:所有校验都在"业务校验层"完成,一旦失败立即终止请求处理,释放线程、数据库连接等资源。不把无效请求带到核心执行层。
@RestControllerAdvicepublicclassSeckillExceptionHandler{@ExceptionHandler(BusinessException.class)publicResulthandleBusinessException(BusinessExceptione){// 业务异常:返回友好提示returnResult.fail(e.getCode(),e.getMessage());}@ExceptionHandler(Exception.class)publicResulthandleSystemException(Exceptione){// 系统异常:隐藏异常栈,返回统一提示 + 记录完整日志log.error("系统异常: userId={}, seckillId={}, params={}",RequestContext.getUserId(),RequestContext.getSeckillId(),RequestContext.getParams(),e);returnResult.fail(500,"系统异常,请稍后再试");}}五、第三层:核心执行层异常处理
这是全链路最关键的一层。两个操作——Redis 库存预扣 和 MQ 消息发送——必须保持原子性。
异常矩阵:
| 异常场景 | 处理策略 | 兜底方案 |
|---|---|---|
| Redis 连接超时(> 300ms) | 重试 1 次,仍失败则返回"系统繁忙" | 记录超时日志,监控告警 |
| Lua 脚本执行失败 | 返回下单失败,记录完整异常日志 | 定期检查 Lua 脚本语法与逻辑 |
| 库存预扣成功,MQ 发送失败 | 事务消息确保原子性;回查接口兜底 | 立即回滚 Redis 库存 |
| 库存预扣成功,MQ COMMIT 超时 | Broker 回查 → checkLocalTransaction 返回真实状态 | 回查间隔缩短至 10s |
| Redis 主从切换(数据同步延迟) | 预扣操作走主节点;查询走从节点(允许短暂不一致) | 定时对账修复一致 |
库存回滚的双重保障:
// 机制一:即时回滚(MQ 发送失败时)publicvoidhandleMqSendFailure(SeckillOrderorder){// 通过 Lua 脚本原子回滚StringrollbackScript="redis.call('incrby', KEYS[1], 1) "+// 恢复库存"redis.call('del', KEYS[2])";// 删除预扣记录redis.eval(rollbackScript,Arrays.asList("stock:seckill:"+order.getSeckillId(),"pre:order:"+order.getOrderId()),Collections.emptyList());}// 机制二:定时回滚(每 1 分钟扫描超时预扣记录)@Scheduled(fixedDelay=60000)publicvoidscanAndRollbackStalePreDeductions(){Set<String>staleKeys=redis.keys("pre:order:*");longnow=System.currentTimeMillis();for(Stringkey:staleKeys){longcreateTime=Long.parseLong(redis.hget(key,"createTime"));if(now-createTime>600000){// 超过 10 分钟未确认StringseckillId=redis.hget(key,"seckillId");rollbackStock(seckillId,key);}}}六、第四层:数据落地层异常处理
这是订单数据的"终点站"。MQ 消费者将订单写入 MySQL,并确认库存扣减。消费成功后,订单才算真正完成。
消息处理流程(含所有异常分支):
@RocketMQMessageListener(topic="SECKILL_ORDER_TOPIC",consumerGroup="order-create")publicclassOrderCreateConsumerimplementsRocketMQListener<MessageExt>{@OverridepublicvoidonMessage(MessageExtmessage){StringorderId=message.getKeys();// Step 1: 消息校验if(StringUtils.isBlank(orderId)){log.error("消息格式错误: msgId={}",message.getMsgId());return;// 无效消息直接 ACK,不重试}// Step 2: 幂等校验if(orderMapper.existsByOrderId(orderId)){return;// 已处理,幂等跳过}// Step 3: 订单入库(事务)try{orderService.createOrderInTransaction(parseOrder(message));}catch(DuplicateKeyExceptione){// 数据库唯一索引兜底幂等log.warn("重复订单被唯一索引拦截: orderId={}",orderId);return;}catch(Exceptione){log.error("订单入库失败: orderId={}",orderId,e);thrownewRuntimeException("消费失败,触发重试",e);}}}消费者事务内部细节:
@Transactional(rollbackFor=Exception.class)publicvoidcreateOrderInTransaction(SeckillOrderorder){// 1. 订单入库orderMapper.insert(order);// 2. 库存确认(UPDATE stock SET stock = stock - 1 WHERE ...)intaffected=stockMapper.confirmDeduction(order.getProductId(),order.getQuantity());if(affected==0){thrownewBusinessException("库存确认失败,可能库存不足");}// 3. 删除 Redis 预扣记录(防止回滚任务误触发)redis.del("pre:order:"+order.getOrderId());// 4. 优惠券核销(积分更新同理)if(order.getCouponId()!=null){couponService.useCoupon(order.getCouponId(),order.getUserId());}// 任一步骤失败 → 整个事务回滚 → 消息重试}异常矩阵:
| 异常场景 | 处理策略 | 兜底方案 |
|---|---|---|
| 消息重复消费 | 订单号唯一索引拦截 | 分布式锁 + 查表 双重幂等 |
| 事务中某步骤失败 | Spring 事务回滚 + 消息重试(3 次) | 3 次失败入死信队列 |
| MySQL 死锁 | 捕获 DeadlockLoserDataAccessException,重试 2 次 | 重试也失败则抛异常触发消息重试 |
| 主从延迟(订单写入后查不到) | 1 分钟内用户查询强制走主库 | 1 分钟后走从库 + Redis 缓存兜底 |
| 数据库连接池耗尽 | Druid 连接池监控告警 | 临时扩容连接数 + 限流降级 |
七、三大核心问题的兜底方案
7.1 防止超卖:三重校验
第一重: Redis + Lua 原子扣减 ↓ 通过但后续失败 第二重: MySQL FOR UPDATE 行锁再次校验库存充足性 ↓ 极端情况下绕过 第三重: 定时对账(每 5 分钟)对比 Redis vs MySQL 库存 → 不一致时以 MySQL 为准修复 Redis对账逻辑:
@Scheduled(fixedDelay=300000)publicvoidreconcileStock(){for(Seckillseckill:activeSeckills){intredisStock=redis.get("stock:seckill:"+seckill.getId());intpreDeducted=redis.keys("pre:order:seckill:"+seckill.getId()+":*").size();intmysqlStock=mysql.query("SELECT stock FROM product WHERE id = ?",seckill.getProductId());if(redisStock+preDeducted!=mysqlStock){log.error("库存不一致: seckill={}, redis={}, pre={}, mysql={}",seckill.getId(),redisStock,preDeducted,mysqlStock);// 以 MySQL 为准修复redis.set("stock:seckill:"+seckill.getId(),mysqlStock-preDeducted);}}}7.2 防止掉单:四重保障
第一重: RocketMQ 事务消息(预扣 + 消息原子性) ↓ 极端情况下事务消息也失败 第二重: 消息重试(消费失败自动重试 3 次) ↓ 3 次都失败 第三重: 死信队列 + 人工处理 ↓ 消息根本没到 MQ(生产者本地故障) 第四重: 每日全量对账补单 → 对比 Redis 预扣记录 vs MQ 消息记录 vs 订单表 → 发现缺失自动触发补单逻辑掉单的可能环节和排查路径:
用户说"我没收到订单" → 排查路径: 1. Redis 有无预扣记录? 无 → 预扣就失败了,正常 2. MQ 有无该消息? 无 → 事务消息发送失败,需检查回查逻辑 3. 消费者有无消费日志? 无 → 消息在 MQ 但未被消费(Group 配置错误?) 4. 订单表有无记录? 无 → 消费者执行失败,查错误日志和死信队列 5. 什么都有? → 前端展示 Bug,系统正常7.3 防止重复下单:双重幂等
第一重: Redis 分布式锁(用户 ID + 商品 ID + 请求唯一标识) → 同一用户 3 秒内无法发起对同一商品的第二次秒杀 ↓ 极端情况下绕过(如用户切换设备) 第二重: 订单号唯一索引(uk_order_id) → 数据库层面拒绝重复插入 → 同时订单号以用户 ID + SKU ID + 时间戳通过雪花算法生成八、架构思维回顾
贯穿整个秒杀架构设计的,是六个核心架构思维:
| 思维 | 在秒杀架构中的体现 |
|---|---|
| 分层架构 | 五层全链路:接入 → 校验 → 执行 → 落地 → 通知。每层独立扩容,各司其职 |
| 微服务 | 订单/抢购/库存/用户/商品/支付 独立部署。抢购服务单独承载高并发,不影响其他 |
| 异步解耦 | 同步校验 + MQ 异步下单。同步部分 < 30ms,异步部分保障可靠性 |
| 数据一致性 | 事务消息 + 幂等设计 + 定时对账 + 库存回滚。不强依赖分布式事务,走最终一致 |
| 高可用 | 限流/熔断/降级 + 无状态设计 + 弹性伸缩 + 多 IDC 双活 |
| 容错兜底 | 每层都有"预防 → 处理 → 兜底"三段式异常处理,不信任任何单一组件 |
九、总结
- "不超卖"靠三重校验:Redis Lua 原子操作(第一重)+ MySQL FOR UPDATE 行锁(第二重)+ 定时对账修复(第三重)。
- "不掉单"靠四重保障:事务消息(第一重)+ 消费重试 + 死信队列(第二重)+ 本地消息表(第三重)+ 每日全量对账补单(第四重)。
- "不重复下单"靠双重幂等:Redis 分布式锁(应用层)+ 订单号唯一索引(数据库层)。
- 全链路异常处理的核心原则:每一层都独立拦截自己职责范围内的异常,不让异常向下渗透;每层都有兜底机制,不信任上游拦截是完美的。
- 架构的本质是在可靠性、性能和复杂度之间做权衡。本文的方案选择了"最终一致性"而非"强一致性",用异步和定时对账换取高并发能力——这是一个有意识、可解释、可验证的权衡。
