☰
秒杀系统架构设计:高并发防超卖与数据一致性实践
2026/10/28 2:50:18 网站建设 项目流程

秒杀系统架构设计这件事,我前后参与过几个不同量级的项目,从日订单几千的小平台到峰值 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 zone=seckill_ip:10m rate=5r/s; server { location /api/seckill/submit { limit_req zone=seckill_ip burst=10 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 数据库条件更新:最稳的兜底防线

不管前面用多少缓存,数据库这一层必须有兜底。我用了很多年的写法是条件更新,把判断和扣减合并成一条 SQL:

UPDATE 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 DefaultRedisScript<Long> 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) ) ENGINE=InnoDB;

为什么要用本地事务表而不是直接发消息?因为“写库成功但发消息失败”这个组合是真实存在的。网络抖动、消息中间件短暂不可用,都会导致消息丢失,而订单已经建了。本地消息表把这两件事绑在一起,要么都成功,要么都失败,不会出现单边。

重试策略我一般用指数退避: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) ) ENGINE=InnoDB;

处理逻辑:先尝试插入幂等记录,插入成功说明是首次请求,继续业务;插入报唯一键冲突说明重复,直接返回上次结果或返回成功。

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 对账与兜底修复:最后一道保险

再严密的系统也会有漏单。活动结束后必须跑对账,把缓存扣减记录、订单表、支付流水三方拉齐。

对账逻辑分三类:

  1. 缓存已扣减但订单不存在:说明消息丢失或消费失败,需要补单或回补库存。
  2. 订单存在但库存未扣减:说明扣减和下单出现了错位,需要补扣库存。
  3. 订单已支付但库存回补了:说明超时释放逻辑有 bug,需要修正并人工介入。

这三类里第一类最常见。我一般要求活动结束后 30 分钟内跑完对账,输出差异清单,差异率超过万分之五就告警排查。对账脚本要能重复执行且结果一致,不能跑两次出现两套补单。

5. 实操落地:一套可以照着搭的最小可用方案

5.1 组件清单与部署形态

最小可用方案的组件清单如下,单机环境用容器编排起一套就能跑压测。

组件规格建议关键配置
应用服务2 核 4G,部署 2 实例线程池核心 20、最大 50、队列 200
Redis4G 内存,主从关闭持久化或调低频率,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、扣减前后库存值。出问题时这三个字段能还原出完整的执行时序,比看代码猜快得多。日志量大的话采样打,但失败请求必须全量打。

最后分享一个小技巧。活动开始前我会用一个小流量脚本做“预演”,只发几十个请求,走完整链路,检查订单、库存、消息、日志四个地方的数据是否都对得上。这个预演大概花五分钟,但能提前发现配置错误、字段缺失、权限问题这类低级错误。真正的大流量压测反而发现不了这些问题,因为它只看总量指标,不看单条数据的正确性。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询