秒杀系统架构设计这件事我前后参与过几个不同量级的项目从日订单几千的小平台到峰值 QPS 十几万的大促场景都趟过一遍。很多人一上来就问“到底用缓存扛还是数据库扛”这个问题本身就问偏了——秒杀真正的难点从来不是某个中间件选型而是高并发、防超卖、数据一致性这三件事必须同时成立任何一个环节掉链子前面的优化全白做。库存扣成负数、订单重复生成、缓存和数据库对不上账这些事故我在复盘会上见过不止一次绝大多数都不是技术不够先进而是边界没想清楚。这篇文章想做的事很具体把秒杀系统从流量入口到最终落库的完整链路拆开讲清楚每一层为什么这么设计、参数怎么算、代码怎么写、坑在哪里。适合正在做电商大促、票务抢购、限量券发放这类场景的后端同学也适合刚接触高并发、想搞明白“超卖到底怎么防”的初中级开发者。全文基于常见工程实践整理涉及具体数字的地方都给了计算过程你可以直接按自己的业务量替换参数。1. 秒杀系统的本质与架构总览1.1 秒杀到底难在哪三个数字就能说清楚秒杀的业务模型极度简单有限库存在极短时间窗口内被海量请求争抢。难点全部藏在这三个数字的关系里。假设某商品库存 1000 件活动 20:00 开抢预热阶段预约人数 30 万开抢瞬间真实并发请求按预约人数的 30% 估算就是 9 万 QPS而活动窗口只有 3 秒。这三个数字放在一起意味着系统要在 3 秒内处理近 27 万次请求其中只有 1000 次是有意义的成功交易其余 26.9 万次都必须被快速、优雅地拒绝掉。传统 CRUD 写法在这个场景下会直接崩掉。一次请求打到数据库select ... for update加行锁单行锁的 TPS 上限通常只有几百到一千出头9 万 QPS 打进来连接池瞬间耗尽线程全部阻塞在锁等待上接着是超时、重试、雪崩。更糟的是重试会放大流量本来就扛不住的系统被自己的重试打穿。所以秒杀架构的第一性原理是在尽可能靠前的位置用尽可能低的成本拦截掉尽可能多的无效请求。这不是优化技巧是架构前提。1.2 四层漏斗模型把九万 QPS 收敛到数据库能承受的量级我习惯把这个系统画成一个漏斗从上到下四层每层承担不同的拦截职责。层级拦截手段目标拦截率出口流量估算前端层按钮置灰、倒计时同步、本地防抖60% 至 80%9 万降至 2 万接入层网关限流、黑白名单、验证码70% 至 85%2 万降至 3000应用层本地缓存标记、重复请求过滤50% 至 70%3000 降至 1000存储层原子扣减、条件更新兜底精确控制最终 1000 单这张表里的数字不是拍脑袋是我在一个实际项目里压测出来的量级。前端层为什么能有这么高的拦截率因为绝大多数用户点一次没反应会连点倒计时没同步会导致提前请求这些流量压根不该进后端。接入层的限流则是硬性闸门宁可拒绝也不能放进来把后面打垮。这里有个容易被忽略的点漏斗的每一层都要能独立工作。我见过一个项目前端防重复做得很好后端就偷懒没做幂等结果有人抓包写脚本直接打接口一秒钟重复下单 200 次库存扣穿。任何前端措施都只是体验优化真正的防线必须在服务端。1.3 技术选型清单与取舍逻辑选型这件事我的原则是“用团队最熟的技术而不是最时髦的技术”。秒杀场景对组件的核心要求只有两个高吞吐、可预测。下面是我常用的一套组合和理由。组件选型承担职责为什么选它缓存Redis 单机或主从库存预热、原子扣减、结果缓存单线程模型天然适合原子操作Lua 脚本保证多命令原子性消息队列Kafka 或 RocketMQ削峰、异步下单顺序写盘吞吐高积压能力强消费端可水平扩展数据库MySQL 主从订单落库、库存最终值事务保证是最终一致性的锚点网关Nginx 加自研网关限流、静态资源、路由成熟稳定限流模块开箱即用分布式锁Redis 或独立协调服务热点资源互斥秒杀场景锁粒度要极细粗锁等于自杀需要特别说明缓存和数据库的关系。很多人以为缓存是“数据库前面加一层”在秒杀场景里这个理解是错的。缓存是库存的唯一权威来源数据库是最终账本。活动期间所有扣减判断都在缓存里完成数据库只负责接收已经确定成功的订单。这个定位一旦搞反就会陷入“缓存和库怎么同步”的无尽泥潭。注意缓存作为权威来源成立的前提是库存预热必须准确且只做一次。预热脚本要幂等重复执行不能把库存重置。我一般给库存 key 加一个活动版本号前缀新活动用新 key旧 key 设过期时间自然淘汰比手动清理安全得多。2. 高并发入口层设计限流、削峰与流量染色2.1 前端与网关的第一道闸门前端能做的事情比很多人想象的多。页面静态化是第一步商品详情、活动规则这些内容在活动开始前就生成好推到 CDN活动期间不查库。按钮状态由服务端下发的活动时间控制本地倒计时只做展示点击前再和服务端校时一次避免用户本地时钟不准导致提前请求。真正有效的是请求合并与防抖。用户连点五次前端只发一次请求后面四次直接忽略。这个改动在我的经验里能砍掉 50% 以上的无效流量成本却几乎为零。网关层的限流是硬闸门。我一般用令牌桶因为秒杀允许一定突发漏桶太均匀反而不符合真实流量形态。参数怎么定假设后端应用集群能安全承载 3000 QPS那就把网关总限流设成 2500留 500 的余量给健康检查和监控探针。这里的关键是限流阈值要低于下游真实容量宁可早拒也不能让下游过载过载后的性能是断崖式下跌不是线性下降。# Nginx 层按 IP 限流防止单机脚本刷接口 limit_req_zone $binary_remote_addr zoneseckill_ip:10m rate5r/s; server { location /api/seckill/submit { limit_req zoneseckill_ip burst10 nodelay; limit_req_status 429; proxy_pass http://seckill_backend; } }这段配置的意思是单 IP 每秒最多 5 次请求允许 10 次突发。nodelay让突发请求立即处理而不是排队避免排队导致的响应时间抖动。返回 429 而不是 503是为了让前端能区分“被限流”和“服务不可用”前者可以提示用户稍后再试。2.2 库存预热与缓存结构设计预热的时机很讲究。太早库存可能被运营改太晚活动开始缓存还没写完。我一般定在活动开始前 10 分钟由定时任务触发写完做一次校验把缓存值和数据库值比对不一致就告警并中止活动开关。缓存结构我推荐用 Hash而不是简单的 String。一个 sku 对应一个 Hash字段拆开存# 库存 key 设计seckill:stock:{activityId}:{skuId} HSET seckill:stock:A2024001:1001 total 1000 available 1000 version 1 EXPIRE seckill:stock:A2024001:1001 7200为什么用 Hash 而不是多个 String因为total、available、version在业务上是一组原子数据放在同一个 key 里既能一次取出也方便后续扩展字段比如加一个frozen表示已锁定未支付的数量。String 类型每次只能取一个字段多字段场景下要发多次命令网络开销翻倍。版本号version的用途是防止旧活动污染。每次活动生成新版本扣减脚本里带上版本判断版本不匹配直接返回失败。这个小设计帮我避免过一次事故活动延期过一次运营重新预热时旧 key 没清干净如果没有版本校验新老库存会混在一起扣。注意库存 key 的过期时间必须大于活动时长加支付超时时间。我一般设活动结束后 2 小时过期覆盖掉最后一波支付回调过期太早会导致对账时拿不到数据。2.3 异步排队消息队列削峰的参数计算入口层过滤完剩下的请求依然可能超过数据库承载能力。这时候要把同步调用改成异步排队。链路是这样的用户请求进来先在缓存里做原子扣减扣减成功就发一条消息到队列立即返回“排队中”消费端从队列慢慢取一条一条落库生成订单。这样数据库面对的是稳定速率的消费流而不是脉冲式洪峰。参数怎么算还是用前面那组数字。缓存扣减成功后进入队列的请求大约是 1000 条等于库存数但如果做的是“先到先得、超出库存排队候补”的模式队列长度可能会到 3000 至 5000 条。消费端单实例落库能力按 500 TPS 估部署 4 个实例就是 2000 TPS消化 5000 条需要 2.5 秒。这个延迟用户能接受吗能因为前端显示的是“抢购结果确认中”2 到 3 秒出结果是正常体验。队列的堆积上限必须设不能让队列无限增长。我一般按“库存数乘以 5”设置消息 TTL 或队列长度上限超出的直接拒绝。原因很简单排队 20 秒才告诉你没抢到体验比立刻告诉你失败还差而且白白占用队列资源。// 入队伪代码扣减成功后异步落库 public SeckillResult submit(Long activityId, Long skuId, Long userId) { String stockKey buildStockKey(activityId, skuId); Long remain redisTemplate.opsForHash().increment(stockKey, available, -1); if (remain null || remain 0) { // 回补避免扣成负数并快速失败 redisTemplate.opsForHash().increment(stockKey, available, 1); return SeckillResult.fail(已售罄); } String orderNo generateOrderNo(userId, activityId); mqProducer.send(seckill_order_topic, buildMessage(orderNo, userId, skuId)); return SeckillResult.pending(orderNo); }这里有个细节值得说increment返回负数后要回补。为什么要回补因为并发下可能出现多个线程同时看到剩 1都执行了减 1实际变成负的。回补能让库存值保持在合理区间虽然对最终结果没影响负数的那些请求已经失败了但对监控指标很重要库存值长期是负数会让告警失真。3. 防超卖的核心实现从数据库到缓存的三种方案3.1 数据库条件更新最稳的兜底防线不管前面用多少缓存数据库这一层必须有兜底。我用了很多年的写法是条件更新把判断和扣减合并成一条 SQLUPDATE seckill_stock SET available available - 1, version version 1, updated_at NOW() WHERE sku_id 1001 AND activity_id A2024001 AND available 1;执行后看影响行数返回 0 就说明库存不足返回 1 才算扣减成功。这条语句的妙处在于判断和扣减在同一个原子操作里完成不存在“查完发现够、更新时已经不够”的窗口。InnoDB 会给这一行加行锁并发请求排队执行天然串行化。另一种写法是乐观锁版本号UPDATE seckill_stock SET available available - 1, version version 1 WHERE sku_id 1001 AND version #{expectedVersion};这两种写法我都用过更推荐第一种条件更新。原因乐观锁在冲突高的时候失败率高需要重试重试又放大流量条件更新失败就是失败不需要重试逻辑干净。乐观锁更适合冲突率低的场景比如后台改价、配置修改。注意条件更新的WHERE条件字段一定要有索引最好是(activity_id, sku_id)联合唯一索引。没有索引的话每条更新都会扫全表并锁大量行比不加锁还慢。我在一个项目里见过漏建索引压测时数据库 CPU 直接打满排查了半天才发现是全表扫描。3.2 Redis 原子扣减加 Lua 脚本扛住峰值的主力数据库兜底归兜底峰值流量必须由缓存挡住。Redis 单线程执行命令天然串行但多条命令组合就不原子了。所以要用 Lua 脚本把“读库存、判断、扣减”打包成一次执行。-- KEYS[1] 库存 hash key -- ARGV[1] 扣减数量 -- ARGV[2] 期望版本号 local version redis.call(HGET, KEYS[1], version) if version false then return -1 -- 库存未预热 end if version ~ ARGV[2] then return -2 -- 版本不匹配活动已切换 end local available tonumber(redis.call(HGET, KEYS[1], available)) local num tonumber(ARGV[1]) if available nil or available num then return 0 -- 库存不足 end redis.call(HINCRBY, KEYS[1], available, -num) return 1 -- 扣减成功脚本里所有判断和写操作都在一次执行中完成中间不会被其他命令插入这就是它比“先 GET 再 DECR”安全的原因。返回码设计成 -1、-2、0、1 四种调用方可以精确区分是没预热、版本错、库存不足还是成功排查问题的时候非常有用。调用方式Java 侧private static final DefaultRedisScriptLong SECKILL_SCRIPT new DefaultRedisScript(SCRIPT_TEXT, Long.class); public int tryDeduct(String stockKey, int num, String version) { Long result redisTemplate.execute( SECKILL_SCRIPT, Collections.singletonList(stockKey), String.valueOf(num), version); return result null ? -99 : result.intValue(); }这里有个性能细节脚本本身要短不要在里面做大循环或复杂计算。Redis 执行 Lua 是阻塞的脚本跑 10 毫秒整个实例就停 10 毫秒。我见过有人在脚本里做 JSON 解析和日志拼接结果 Redis 的 P99 延迟从 1 毫秒飙到 30 毫秒。3.3 分段库存与本地缓存的组合拳当单 key 的 QPS 超过 Redis 单实例处理能力一般 8 万到 10 万就需要考虑分片。分段库存的思路是把 1000 件库存拆成 10 个桶每个桶 100 件请求按用户 ID 取模落到不同桶上这样并发压力分散到 10 个 key。这个方案在超大规模场景下确实有效但代价是复杂度陡增库存可能某个桶空了而其他桶还有货需要处理“跨桶借货”扣减结果和用户绑定不能全局先到先得。我的建议是QPS 没到 5 万以上不要用。绝大多数业务量根本用不上强行上分片只会给自己挖坑。本地缓存是另一个优化点。活动开始前把库存快照加载到应用本地请求进来先看本地值本地显示已售罄就直接拒绝不打 Redis。这个做法的收益是能砍掉大部分“确定失败”的请求风险是本地值有延迟可能误拒。所以本地缓存只能用于“售罄判断”不能用于“扣减判断”真正的扣减永远走 Redis。这个区分必须写进代码注释不然接手的人很容易写错。3.4 三种防超卖方案对比方案吞吐能力一致性实现复杂度适用场景数据库条件更新单行 500 至 1000 TPS强一致低兜底、低并发活动Redis Lua 原子扣减单实例 5 万至 8 万 QPS缓存内强一致中主力方案覆盖多数场景分段库存加本地缓存可到 20 万 QPS 以上最终一致需处理跨桶高超大促、明星单品实际项目里这三者是叠加使用的关系不是三选一。请求先过本地缓存判断方案三的简化版再过 Redis 原子扣减方案二扣减成功的订单落库时再用数据库条件更新兜底方案一。三道关卡的拦截率依次递减最后到数据库的已经是确定能成功的请求。4. 数据一致性保障扣减、订单、支付的最终一致4.1 一致性模型选择别追求强一致秒杀场景有个很反直觉的结论不要追求强一致。库存缓存和数据库之间、订单和支付之间都存在天然的时延窗口。如果非要强一致只有两条路要么全程加分布式锁吞吐掉到几百要么用分布式事务性能更差还容易挂。两条路在大促场景下都不可行。正确的做法是明确一致性等级库存层面缓存内强一致Lua 保证缓存与数据库之间最终一致允许秒级延迟。订单层面扣减成功即可返回“排队中”落库失败要能补偿。支付层面以支付回调为准超时未支付自动回补库存。把这三层分开看问题就简单多了。库存层面只要保证不超卖、不少卖少卖通过回补解决订单层面只要保证最终都落库、不重复支付层面只要保证超时能释放。4.2 本地消息表加定时补偿最朴素也最可靠消息发出去但消费失败怎么办我试过几种方案最后回到最朴素的本地消息表。原理很简单业务操作和消息记录写在同一个数据库事务里事务提交后由定时任务扫表投递。投递成功标记完成失败按退避策略重试。CREATE TABLE local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_no VARCHAR(64) NOT NULL COMMENT 业务单号唯一, topic VARCHAR(64) NOT NULL COMMENT 消息主题, payload TEXT NOT NULL COMMENT 消息体, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待投递 1已投递 2失败, retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_no (biz_no), KEY idx_status_time (status, next_retry_time) ) ENGINEInnoDB;为什么要用本地事务表而不是直接发消息因为“写库成功但发消息失败”这个组合是真实存在的。网络抖动、消息中间件短暂不可用都会导致消息丢失而订单已经建了。本地消息表把这两件事绑在一起要么都成功要么都失败不会出现单边。重试策略我一般用指数退避1 秒、5 秒、30 秒、2 分钟、10 分钟最多 10 次超过就转人工告警。idx_status_time这个索引很关键扫表任务每次都按status 0 AND next_retry_time NOW()查没有索引在消息量大时会拖慢整个库。注意扫表任务必须多实例部署时加锁否则多个实例同时扫同一批数据会重复投递。可以用SELECT ... FOR UPDATE SKIP LOCKED也可以给每条消息加一个 15 秒的租约时间。我倾向于后者锁表时间短吞吐更高。4.3 幂等设计所有重复请求的终极防线消息队列的语义通常是“至少一次”意味着重复消费一定会发生。加上用户手动重试、网关重发同一个请求到达后端两次是常态而不是异常。所以每个写操作都必须幂等。幂等表是最通用的做法CREATE TABLE idempotent_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_key VARCHAR(128) NOT NULL COMMENT 业务唯一键, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_key (biz_key) ) ENGINEInnoDB;处理逻辑先尝试插入幂等记录插入成功说明是首次请求继续业务插入报唯一键冲突说明重复直接返回上次结果或返回成功。public void handleOrderMessage(OrderMessage msg) { String bizKey msg.getOrderNo(); try { idempotentMapper.insert(bizKey); } catch (DuplicateKeyException e) { log.info(重复消息直接跳过bizKey{}, bizKey); return; } // 首次处理执行真正的落库逻辑 orderService.createOrder(msg); }这段代码很简单但有个坑幂等记录的插入不能和业务操作分开在两个事务里也不能都不在事务里。正确的做法是幂等记录和业务数据在同一个事务中业务失败则一起回滚下次重试还能再次进入。我见过把幂等记录先提交再执行业务的写法结果业务失败后重试被判定为重复订单永久丢失。4.4 对账与兜底修复最后一道保险再严密的系统也会有漏单。活动结束后必须跑对账把缓存扣减记录、订单表、支付流水三方拉齐。对账逻辑分三类缓存已扣减但订单不存在说明消息丢失或消费失败需要补单或回补库存。订单存在但库存未扣减说明扣减和下单出现了错位需要补扣库存。订单已支付但库存回补了说明超时释放逻辑有 bug需要修正并人工介入。这三类里第一类最常见。我一般要求活动结束后 30 分钟内跑完对账输出差异清单差异率超过万分之五就告警排查。对账脚本要能重复执行且结果一致不能跑两次出现两套补单。5. 实操落地一套可以照着搭的最小可用方案5.1 组件清单与部署形态最小可用方案的组件清单如下单机环境用容器编排起一套就能跑压测。组件规格建议关键配置应用服务2 核 4G部署 2 实例线程池核心 20、最大 50、队列 200Redis4G 内存主从关闭持久化或调低频率maxmemory-policy设为noeviction消息队列单节点即可分区数 4消费组 4 并发MySQL4 核 8Ginnodb_flush_log_at_trx_commit 1连接池 30 至 50压测工具独立机器避免压测机和被测服务抢资源Redis 的noeviction这个配置特别提醒一句。默认策略是allkeys-lru内存满了会淘汰 key库存 key 万一被淘汰扣减直接报 -1活动就废了。秒杀用的 Redis 内存要预留足够同时设置noeviction宁可写失败也不淘汰数据。5.2 核心链路代码骨架完整链路按职责拆成三段入口控制、原子扣减、异步落库。RestController RequestMapping(/api/seckill) public class SeckillController { PostMapping(/submit) public Result submit(RequestBody SeckillRequest req) { // 1. 活动状态与参数校验走本地缓存不打 Redis if (!activityCache.isOpen(req.getActivityId())) { return Result.fail(活动未开始或已结束); } // 2. 单用户限频防止脚本刷 if (!rateLimiter.tryAcquire(req.getUserId())) { return Result.fail(操作过于频繁); } // 3. 幂等前置判断同一用户同一活动只允许一次 if (!seckillService.checkDuplicate(req.getActivityId(), req.getUserId())) { return Result.fail(您已参与过本次抢购); } // 4. Redis 原子扣减 int code seckillService.tryDeduct(req.getSkuKey(), 1, req.getVersion()); if (code ! 1) { return Result.fail(已售罄); } // 5. 异步落库立即返回排队结果 String orderNo seckillService.asyncCreateOrder(req); return Result.pending(orderNo); } }第 3 步的重复判断用 Redis Set 实现SADD seckill:users:{activityId} {userId}返回 1 表示首次。不要用数据库唯一索引做这个判断太慢而且秒杀场景下用户量大唯一索引插入冲突会锁表。消费端KafkaListener(topics seckill_order_topic, groupId order_group) public void onMessage(String payload, Acknowledgment ack) { OrderMessage msg JsonUtil.parse(payload, OrderMessage.class); try { orderService.createOrderWithIdempotent(msg); ack.acknowledge(); } catch (BizException e) { // 业务异常记录后确认避免无限重试 log.error(订单创建业务异常msg{}, payload, e); ack.acknowledge(); } catch (Exception e) { // 系统异常不确认交给重试 log.error(订单创建系统异常等待重试msg{}, payload, e); } }这里区分了业务异常和系统异常。业务异常比如参数不合法、库存已回补重试多少次都没用直接确认并告警系统异常数据库连接超时、死锁才值得重试。很多人不区分结果业务异常的消息在队列里反复重试把整个消费组堵死。5.3 压测与验证方法压测不是为了看一个漂亮的 QPS 数字是为了验证三个假设限流阈值是否合理、扣减是否精确、系统在过载时的表现是否可控。我习惯分三轮压第一轮单接口压测只压扣减接口不落库看 Redis 层的极限。这轮能压出 Redis 单 key 的吞吐上限一般 5 万左右。第二轮全链路压测用真实的库存数比如 1000 件发 5 万个请求验证最终成功订单数是否精确等于 1000。这一轮的判定标准只有一个不多不少。多一件就是超卖少一件就是漏单都要查。第三轮过载压测把流量加到设计容量的 2 倍观察系统表现。看三个指标错误率是否可控、响应时间是否在阈值内、服务是否自动恢复。这轮最容易被忽略但真实大促的流量永远超出预期。# 用 wrk 做简单的压测验证 wrk -t 8 -c 500 -d 30s --latency \ -s seckill_post.lua \ http://127.0.0.1:8080/api/seckill/submit-c 500表示 500 个并发连接-t 8是 8 个压测线程。并发数不要一次拉满按 50、200、500、1000 阶梯上升每个阶梯跑 30 秒稳定后再加。一上来就 1000 并发压出来的数据没参考价值因为系统还没进入稳态就被打崩了。6. 常见问题与排查实录6.1 典型故障速查表下面这些是我和团队实际遇到过的问题按现象、根因、处理方式整理。现象可能根因排查手段处理方式库存扣成负数扣减未用原子操作或缺少回补逻辑查看扣减日志与库存快照改用 Lua 脚本负数回补并告警订单数超过库存数缓存扣减成功但落库无幂等比对订单表与扣减记录加幂等表补单前先查重活动期间 Redis 超时单 key 热点脚本过长看 Redis 慢日志与 P99缩短脚本必要时分段库存消息大量积压消费异常未确认或消费能力不足看消费延迟指标与异常日志区分异常类型扩容消费实例数据库连接池耗尽同步落库限流失效看连接池活跃数与等待队列改异步收紧网关限流用户反映抢到了但没有订单消息丢失本地消息表未补偿核对消息表状态补投递对账补单这张表建议直接贴到值班手册里。出问题时最怕的不是不知道怎么修而是不知道从哪查有个对照表能省掉大量翻日志的时间。6.2 踩过的坑与避坑清单第一个坑是库存预热的时机和幂等。有次活动改期预热脚本跑了两次把已经扣掉的库存重置回初始值结果超卖了 200 多件。修复方式是给库存 key 带版本号脚本里判断版本已存在就不覆盖。这个改动很小但能彻底杜绝重复预热。第二个坑是限流限错了维度。早期按接口总 QPS 限流结果某个大 V 转发带来一波集中 IP 的流量把限流额度吃光正常用户全被拒。后来改成“按用户 ID 限频 按 IP 限频 全局总量限流”三层任意一层触发都拒绝正常流量就不会被单个来源挤占。第三个坑是超时时间设置不一致。网关超时 3 秒、应用超时 5 秒、数据库超时 10 秒结果网关早断开了应用还在处理用户看到失败但订单实际创建了。修复是把超时链路统一成“外层短、内层长”且逐层递减网关 2 秒、应用 3 秒、数据库 5 秒让超时从外向内传递避免孤儿请求。第四个坑是压测环境和生产环境配置不一致。压测时 Redis 是单机、数据库连接池 100上线后变成主从加连接池 30同样的流量直接打挂。后来强制要求压测前做配置比对把差异项列出来逐条确认这个流程现在是我们上线前的必检项。提示所有和库存相关的操作日志里必须带三个字段活动 ID、用户 ID、扣减前后库存值。出问题时这三个字段能还原出完整的执行时序比看代码猜快得多。日志量大的话采样打但失败请求必须全量打。最后分享一个小技巧。活动开始前我会用一个小流量脚本做“预演”只发几十个请求走完整链路检查订单、库存、消息、日志四个地方的数据是否都对得上。这个预演大概花五分钟但能提前发现配置错误、字段缺失、权限问题这类低级错误。真正的大流量压测反而发现不了这些问题因为它只看总量指标不看单条数据的正确性。