秒杀这个场景,我在几个模拟项目里反复折腾过很多轮,从最早的"数据库扛一切"到后来的"Redis 扣减 + MQ 落库",中间踩的坑比写过的代码还多。秒杀系统架构设计这件事,说穿了就是三件事的平衡:高并发下系统不能塌、防超卖上库存不能错、数据一致性上账不能乱。三者互相拉扯——为了抗高并发把库存全放内存,一致性就变难;为了强一致每次都落库,并发又扛不住。这篇不聊虚的,就把这条链路上每一层为什么这么设计、参数怎么定、代码怎么写、哪里容易翻车,一次讲透。适合已经写过 CRUD、想搞清楚"流量洪峰"到底怎么接住的后端同学,也适合做技术方案评审前想快速补齐知识面的朋友。
1. 秒杀的真正难点不在"高并发"三个字,而在流量形态
很多人一听秒杀就条件反射地想到"QPS 高",然后开始堆机器、加缓存、上消息队列。方向没错,但只盯着 QPS 数字,往往会做出一个"平时压测很漂亮、大促当天第一秒就崩"的系统。问题出在:秒杀流量的形态和普通业务流量完全不是一回事,用处理平峰流量的思路去接洪峰,等于用桶去接瀑布。
1.1 瞬时洪峰与平峰请求的本质差异
普通电商的请求是均匀铺开的,一天几百万 PV,摊到每秒也就几十上百,系统有充足的缓冲时间做重试、做降级、做扩缩容。秒杀不一样,它把几乎全部流量压缩到开抢后的 1 到 3 秒内。一个 1 万件库存的活动,可能有 50 万人同时点"立即抢购",这 50 万请求里 99% 注定失败,但它们依然会真实地打到你的系统上。
这个差别带来的直接后果是:系统不是"持续过载",而是"瞬间被打穿"。持续过载你可以靠弹性扩容慢慢消化,瞬间打穿没有给你反应时间——连接池在第 200 毫秒就满了,线程池队列在第 500 毫秒就堆积到上限,数据库连接数在第 1 秒就爆了,然后所有请求开始超时,超时又触发重试,重试把流量再放大一倍。这就是典型的雪崩,而且是在你监控告警还没响起来的时候就已经发生了。
所以秒杀架构的第一个设计原则不是"提升处理能力",而是"减少进入核心链路的请求数量"。这个思路必须先立住,后面所有的限流、缓存、异步,本质都是在做减法。
1.2 普通 CRUD 架构在秒杀场景下的三处断裂点
我把常见的断裂点归成三类,做过压测的同学应该都眼熟。
第一处是数据库行锁争用。update stock set count = count - 1 where id = ? and count > 0这条语句本身没问题,逻辑上也防了超卖,但当几千个事务同时争抢同一行的写锁时,InnoDB 的行锁会迅速演变成排队,后面的请求全部阻塞在锁等待上,innodb_lock_wait_timeout一到就报错。行锁争用不是"慢",是"卡死"。
第二处是应用层连接与线程耗尽。Tomcat 默认 200 个工作线程,数据库连接池常见配置 20 到 50 个。当请求进来的速度远超处理速度,线程全被占住,新请求在 accept 队列里排队,队满了直接拒绝。这时候你去看 CPU,可能才 30%,但服务已经不可用了——瓶颈不在计算,在等待。
第三处是热点数据击穿缓存。活动开始前缓存被清空或者过期,第一个请求打到数据库,后面几千个请求同时发现缓存没有,一起打到数据库,这就是缓存击穿。秒杀场景下因为所有请求都集中在同一个 key 上,击穿的概率被无限放大。
下面这张表是我自己整理的三处断裂点与对应策略的对照,评审方案时可以直接拿去用:
| 断裂点 | 具体表现 | 根因 | 对应策略 |
|---|---|---|---|
| 数据库行锁争用 | 锁等待超时、事务堆积 | 单行热点写入 | 库存前置到 Redis,异步落库 |
| 连接与线程耗尽 | CPU 不高但服务无响应 | 请求速度超处理速度 | 网关限流、请求排队、快速失败 |
| 缓存击穿 | 瞬时大量请求直达 DB | 热点 key 失效 | 预热、逻辑过期、互斥重建 |
1.3 先把指标算清楚:QPS、库存量、超卖容忍度
动手设计前,有几个数必须先落到纸面上,否则后面所有的容量规划都是拍脑袋。
峰值 QPS 估算:用"预计参与人数 × 峰值集中系数 ÷ 峰值持续秒数"来估。假设某模拟活动预计 30 万人参与,其中 80% 会在开抢后 2 秒内点击,那就是 24 万请求挤在 2 秒内,平均 12 万 QPS,考虑到点击本身有抖动,实际瞬时峰值要按 1.5 倍预留,也就是 18 万 QPS 的容量目标。
单机承载能力:一台 4 核 8G 的应用节点,纯转发型接口(读缓存、做判断)大概能扛 3000 到 8000 QPS,具体看序列化方式和日志量。带 Redis 交互的接口要打对折。这些数字必须自己压出来,别信任何"参考值"。
超卖容忍度:这个词听起来奇怪,但它决定架构复杂度。绝大多数商品业务要求零超卖,那就必须有一道强一致的兜底;但对某些虚拟商品或者可候补的场景,"少卖"比"超卖"更可怕,这时候策略要反过来。先明确业务能接受哪种错误,再谈技术方案,这是我做过最多次返工的地方。
还有一个容易被忽略的指标是库存量级。100 件库存和 10 万件库存,架构选择完全不同。库存很少的时候,热点极度集中,几乎所有请求都会失败,此时更该做的是"提前拦截",比如用令牌或者抽签的方式先筛一遍;库存很多的时候,反而是成功请求多,落库压力大,要重点考虑异步写和批量提交。
2. 流量漏斗:把九成请求挡在核心链路之外
把秒杀链路想象成一个漏斗,最上面是用户点击,最下面是数据库落单。每一层都要尽可能多地"漏掉"请求,而且是越靠前漏掉越好——在 CDN 漏掉一个请求,成本几乎为零;在数据库层漏掉一个请求,可能已经消耗了几十个连接和锁。这一章讲的就是漏斗上面几层怎么设计。
2.1 页面静态化与 CDN 前置
秒杀页面的特点是:访问量极大、内容几乎不变、只有"是否开始""剩余数量"这类字段会变。这种页面天生适合静态化。把商品详情、活动规则这些不变的部分做成静态 HTML 推到 CDN,用户请求直接在边缘节点返回,根本不进你的机房。
真正要回源的只有两个接口:活动状态查询和下单。活动状态查询可以做成一个极轻量的接口,返回结构就几个字段,甚至可以在静态页面里内嵌一个倒计时,前端本地算时间,到点了才发起请求,这样连状态查询都能省掉一大半。
这里有个细节很多人会漏:按钮的禁用状态必须由服务端的时间为准,但前端要提前预判。如果完全等前端倒计时到点才允许点击,那么各端时钟偏差会导致请求提前或延后几秒集中打进来,反而形成二次洪峰。比较稳的做法是服务端返回一个"可点击时间戳",前端用本地时间差做补偿,同时服务端在开抢前 100 毫秒内收到的请求统一做"过早请求"快速拒绝,不做任何业务处理。
CDN 配置上还有两个实际会踩的点。一是缓存 key 要包含活动 ID 和版本号,活动配置变更时通过版本号刷新缓存,否则改了价格用户看到的还是旧的。二是回源带宽要单独评估,静态资源被缓存后回源量很小,但下单接口是不缓存的,这部分流量会百分之百回源,评估机房入口带宽时必须把它算进去。
2.2 网关层的限流与排队
请求进到机房后,第一道闸门是网关。网关层要做的事情很明确:按用户维度限流、按接口维度限流、超出的请求快速失败。
按用户维度限流是为了防刷。同一个用户 ID 在 1 秒内发起超过 5 次下单请求,后面的一律拒绝并打标记,正常用户不会这么点。这里要注意别用 IP 做维度,移动网络下大量用户共享出口 IP,按 IP 限流会误伤一大片。
按接口维度限流是为了保护后端。给下单接口配一个略高于预估峰值的阈值,比如预估 12 万 QPS,阈值配到 15 万,超过的直接返回"当前人数过多,请稍后再试"。这个阈值不能配得太保守,否则正常用户也被拦;也不能配太高,否则限流形同虚设。
关于"排队"还是"拒绝",这是个需要想清楚的选择。排队看起来体验更好,用户看到"正在排队第 3821 位",但实际上排队会带来两个问题:一是排队本身要占用连接和内存,队列越长,网关本身的资源消耗越大;二是队列里的请求最终还是要在后端处理,只是把洪峰往后平移了几秒,并没有真正削峰。我的经验是:只在活动刚开始的前两秒做短队列排队(队列长度限制在几千),超过就立即拒绝。绝大多数用户能接受"没抢到",但不能接受"页面卡住不动三十秒"。
网关限流的实现,用令牌桶或者滑动窗口都可以。滑动窗口更精确,但内存占用大;令牌桶实现简单,允许一定的突发。秒杀场景我倾向于用滑动窗口,因为突发正是我们要抑制的东西。伪代码大概是这样:
// 基于 Redis 的滑动窗口限流,窗口 1 秒,阈值 150000 public boolean tryAcquire(String key, int limit, long windowMs) { long now = System.currentTimeMillis(); String member = now + "-" + ThreadLocalRandom.current().nextInt(1000000); // 移除窗口外的记录 redis.zremrangeByScore(key, 0, now - windowMs); Long count = redis.zcard(key); if (count != null && count >= limit) { return false; } redis.zadd(key, now, member); redis.expire(key, windowMs / 1000 + 1); return true; }这段代码在生产环境要注意两个问题:一是zcard和zadd不是原子的,高并发下会有少量超额放行,可以接受;二是每次请求都要操作 Redis,网关到 Redis 的延迟会直接加到用户请求上,所以这个 key 要放在本地缓存加定期同步,或者用 Redis 的 Lua 脚本合并成一次调用。
2.3 缓存预热与热点隔离
过了网关,请求进入应用层。这一层做的第一件事是查库存够不够,这个查询必须走缓存,而且要保证缓存里一定有数据。
预热的时间点很关键。太早了,可能被其他操作覆盖或者过期;太晚了,第一个请求就是冷启动。我的做法是:活动开始前 10 分钟做一次全量预热,前 1 分钟再做一次校验性预热,两次都通过一个独立的管理接口触发,接口返回预热成功的 key 数量和值,人工确认后再放开流量。
预热的内容不只是库存数量,还包括:活动配置(限购数、开始结束时间)、商品基础信息、用户资格标记(如果有会员门槛)。把这些都塞进一个 Hash 结构,一次hgetall全部拿到,能省掉好几次网络往返。
热点隔离是另一个容易被忽略的点。如果同一台机器上既有秒杀活动的缓存又有普通商品的缓存,秒杀把 Redis 连接打满,普通业务也跟着遭殃。解决方案是给秒杀单独分配一组 Redis 实例,物理隔离,成本会增加,但对于大促这种"不能出事"的场景,这笔钱值得花。如果没有条件做物理隔离,至少要做连接池隔离——秒杀用的连接池和普通业务用的连接池分开配置,互不影响。
3. 防超卖的关键:库存扣减放在哪一层
这是整个秒杀系统里讨论最多、争议最大的一块。方案大致三种:纯数据库扣减、Redis 扣减 + 异步落库、Redis 扣减 + 同步落库。三种方案的差别不在"能不能防超卖",而在"扛不扛得住"和"错了怎么补"。
3.1 数据库直接扣减为什么扛不住
先把最朴素的方案摆出来,它的正确性没有问题:
UPDATE seckill_stock SET available = available - 1, version = version + 1 WHERE sku_id = #{skuId} AND available > 0 AND version = #{version};available > 0这个条件保证了不会扣成负数,影响行数为 0 就代表扣减失败。这条语句在单机、低并发下完美无缺,问题出在并发上。
InnoDB 对同一行的更新是要加排他锁的,事务提交前其他事务全部阻塞。假设单次更新加事务提交耗时 2 毫秒,那么单行每秒最多串行处理 500 次。要扛 12 万 QPS,理论上需要 240 个并发事务在跑——但锁是互斥的,并发度再高也是排队,实际吞吐就是 500 TPS 左右。这还没算网络往返、连接池等待、GC 停顿。
所以数据库扣减不是"不能用",而是"只能用在库存很少、参与人数不多的普通活动上"。库存 100 件、几千人参与,数据库直接扣完全够用,别为了秒杀而秒杀,过度设计同样是一种浪费。
3.2 Redis 原子扣减与 Lua 脚本的实现细节
当库存和参与量都上去了,就必须把扣减动作前置到 Redis。原因很简单:Redis 单线程执行命令,天然串行,单实例能扛 10 万级 QPS,而且内存操作没有锁等待。
核心逻辑用 Lua 脚本实现,保证"判断库存 + 扣减 + 记录用户"这三步的原子性:
-- KEYS[1] = 库存 key, KEYS[2] = 用户购买记录 key -- ARGV[1] = 用户ID, ARGV[2] = 购买数量, ARGV[3] = 限购数 local stock = tonumber(redis.call('HGET', KEYS[1], 'available')) if stock == nil then return -1 -- 库存未初始化 end local bought = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0') if bought + tonumber(ARGV[2]) > tonumber(ARGV[3]) then return -2 -- 超过限购 end if stock < tonumber(ARGV[2]) then return 0 -- 库存不足 end redis.call('HINCRBY', KEYS[1], 'available', -tonumber(ARGV[2])) redis.call('HINCRBY', KEYS[2], ARGV[1], tonumber(ARGV[2])) return 1 -- 扣减成功这段脚本有几处必须注意的地方,我在不同项目里都遇到过。
第一,返回值语义要设计清楚。上面用了 -1、-2、0、1 四个返回值,分别对应"库存未初始化""超限购""库存不足""成功"。如果只返回成功失败,日志里就没法区分是库存没了还是用户超买了,排查问题时两眼一抹黑。返回值语义定好之后,应用层要对每种情况给不同的用户提示,超限购和库存不足的文案是不一样的。
第二,Lua 脚本要尽量短。Redis 执行 Lua 是阻塞的,脚本越长,阻塞越久。这个脚本里只有几次哈希操作,执行时间是微秒级,没问题。但如果有人在里面加了循环、加了大量 key 遍历,就会把整个 Redis 实例卡住。我见过一个脚本里遍历了整个用户列表做校验,结果单次执行 20 毫秒,整个实例的 QPS 直接掉到 50。
第三,key 要用 Hash 而不是 String。库存和已售数量放在同一个 Hash 里,一次读取就能拿到全部信息,比两个 String 少一次往返。而且 Hash 在字段不多的时候用的是紧凑编码,内存效率也更好。
第四,别忘了设置过期时间。活动结束后的这些 key 如果不清,会一直占内存。但过期时间不能设得太短,至少要比活动结束时间晚几天,留出对账和补数据的窗口。
3.3 数据库兜底:乐观锁、唯一索引与库存流水
Redis 扣减解决了性能问题,但它引入了一个新问题:Redis 是内存,可能丢数据,也可能因为网络分区和数据库不一致。所以数据库这一层必须保留兜底,而且兜底逻辑要能独立保证正确性。
兜底有两道防线。
第一道是唯一索引防重复下单。建一张订单表,在(user_id, activity_id)上建唯一索引。用户重复下单时,数据库直接报唯一键冲突,应用层捕获这个异常,把 Redis 里多扣的库存加回去。这一道防的是"同一个用户下了多单",和库存扣减的正确性无关,但同样重要。
第二道是库存流水的最终校验。每次成功下单都往stock_log表插一条记录,字段包括 sku_id、数量、订单号、时间。活动结束后跑一个校验任务:sum(stock_log.quantity)必须等于初始库存 - 剩余库存。对不上的话,说明中间有丢单或者重复扣减,需要人工介入。
-- 校验 SQL:流水总和应等于已售数量 SELECT s.sku_id, s.total - s.available AS sold_in_db, COALESCE(SUM(l.quantity), 0) AS sold_in_log FROM seckill_stock s LEFT JOIN stock_log l ON s.sku_id = l.sku_id GROUP BY s.sku_id, s.total, s.available HAVING sold_in_db <> sold_in_log;这张表最好在活动进行中就定期跑(比如每 5 分钟一次),而不是等活动结束。一旦发现偏差,能及时止损;等结束再发现,用户该抢的都抢完了,处理起来非常被动。
注意:库存流水表的写入量在秒杀场景下非常大,不要给它建太多索引,主键加一个 sku_id 的普通索引就够。我见过给流水表建了五个索引的,写入 TPS 直接掉了一半。
3.4 三种扣减方案的适用边界对比
把三种方案摆在一起看,选择逻辑其实很清楚:
| 方案 | 单机吞吐 | 一致性保证 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 数据库直接扣减 | 约 500 TPS/行 | 强一致 | 低 | 库存少、参与人数少 |
| Redis 扣减 + 异步落库 | 10 万 QPS 以上 | 最终一致 | 高 | 大促、高并发活动 |
| Redis 扣减 + 同步落库 | 取决于数据库 | 较强一致 | 中 | 中等并发、不能容忍数据延迟 |
中间那种"Redis 扣减 + 同步落库"的方案,实际上是让 Redis 承担并发控制、数据库承担持久化,两者在同一个请求里串行完成。它的吞吐受限于数据库,但比纯数据库方案高,因为 Redis 那层把无效流量挡掉了——大部分请求在 Redis 就返回失败,根本走不到数据库。这个方案在很多中等规模的活动里其实是最优解,复杂度可控,一致性也好。
4. 数据一致性:异步落库之后的账怎么对
上一章说 Redis 扣减成功之后要异步落库,那异步就意味着有延迟、有失败、有可能顺序错乱。这一章讲的是怎么把"异步"造成的不一致控制在可接受范围内,以及在真的出问题时怎么把它查出来、补回去。
4.1 为什么强一致在秒杀里是伪需求
先说个可能不太中听的观点:秒杀场景下追求"Redis 和数据库强一致"是给自己找麻烦。
原因在于两者的角色不同。Redis 是并发控制层,它的职责是在毫秒级判断"这个请求该不该成功",并且保证不超卖;数据库是持久化层,它的职责是"把成功的结果可靠地记下来"。这两件事的时间尺度根本不同,硬要它们实时一致,就等于要求持久化层也要扛并发控制的量,前面所有的优化全部白费。
用户真正在乎的一致性是什么?是他抢到了,订单里能看到;是他没抢到,别给他生成订单。至于 Redis 里的库存数字和数据库里的库存数字差了几个,用户看不见,也不关心。业务上的一致性要求是"订单和库存对得上",不是"两个存储系统的每个字段实时相等"。
想清楚这一点,架构就顺了:Redis 保证"不超卖",数据库保证"订单不丢、不重"。中间的短暂不一致是设计的一部分,不是 bug。
4.2 消息队列削峰与本地消息表
异步落库的常见做法是:Redis 扣减成功后,往消息队列发一条"扣减成功"的消息,消费端收到后写数据库。这个链路有两个风险点必须处理。
风险一是消息发送失败。Redis 扣减成功了,但发消息的时候网络抖了一下,消息没发出去,数据库里就永远不会扣。这种情况必须有个补偿机制。最简单可靠的是本地消息表:在扣减成功后,先在本地数据库(和应用同一个库,但不同表)插一条待发送的消息记录,然后再发 MQ,发送成功后再把这条记录标记为已发送。如果应用在发消息前挂了,重启时扫表把未发送的补发出去。
CREATE TABLE local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_key VARCHAR(64) NOT NULL COMMENT '业务唯一键,如订单号', payload TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送', retry_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz (biz_key) );扫描任务的间隔不要太短,10 秒一次就够,扫描时只捞status = 0 AND next_retry_at <= now()的记录,限制每次最多取 500 条,避免一次拉太多把数据库打满。重试次数超过 10 次的单独捞出来告警,人工处理。
风险二是消息重复消费。MQ 大多保证 at-least-once,重复投递是常态。消费端必须做幂等,靠业务唯一键来判断——消费之前先查一下这个订单号是不是已经落库了,落了就直接 ack。这里用唯一索引比用select判断更可靠,因为select和insert之间有竞态窗口,唯一索引是数据库层面的原子保障。
4.3 对账与补偿:定时任务该盯哪些数
异步系统一定要有对账,而且对账的粒度要设计好。我一般分三级:
第一级是实时校验,在消费端落库的时候顺手做。落库前查一次数据库当前库存,如果发现数据库库存已经小于等于 0 但消息说要扣,说明有问题,直接把消息打进死信队列,不落库。
第二级是分钟级对账,每 5 分钟跑一次,比对 Redis 里各活动的剩余库存和数据库里的剩余库存,差值超过阈值(比如超过总库存的 0.1%)就告警。这一级主要用来发现"消息积压"或者"消费端卡住",因为正常情况下的延迟只有秒级。
第三级是日终对账,活动结束后跑全量校验,用前面提到的stock_log汇总来对。这一级是最终的数据修复依据。
发现不一致之后怎么补?分两种情况。如果是 Redis 扣多了(Redis 剩余比数据库少),说明有扣减没落库,这时候要把 Redis 的库存加回去,同时把对应的用户购买记录清掉,让用户重新抢——这一步要谨慎,因为用户可能已经看到"抢到了"的提示,所以更稳妥的做法是保留 Redis 的扣减结果,把数据库补上,宁可少卖也不让用户体验受损。
如果是 Redis 扣少了(Redis 剩余比数据库多),说明有重复扣减或者回滚没执行,这种情况相对安全,把 Redis 修正到数据库的值即可,多出来的库存可以再放出去,或者留到下一轮。
这套对账逻辑不复杂,但一定要写成脚本、写进 runbook,不能靠人临时发挥。大促当天谁都不想现场写 SQL。
4.4 用户侧的一致性:一人一单与幂等设计
前面讲的都是库存一致性,但用户感知最强的一致性其实是"我有没有抢到"。这里有几个坑。
第一个坑是重复提交。用户手抖点两次,或者网络超时后客户端自动重试,都可能产生两个请求。解决方案是前端在点击后立刻禁用按钮,同时服务端用(userId, activityId)做幂等——第一次请求进来先往 Redis 写一个SETNX的标记,key 是seckill:order:{activityId}:{userId},有效期覆盖整个活动周期。写成功才继续处理,写失败直接返回"您已参与过本次抢购"。这个标记不能只依赖库存扣减时的用户购买记录,因为那是按数量的,幂等标记是按"是否有过成功请求"的。
第二个坑是超时但实际成功。用户请求发出去了,服务端处理成功了,但响应在返回路上丢了,用户看到的是超时。这时候用户会重试,而重试会被幂等标记拦住,返回"已参与"。但用户不知道自己是成功了还是失败了。所以幂等拦截返回的结果必须是"查询订单状态",而不是简单的"重复提交"。具体做法是:幂等校验失败时,去查一下这个用户的订单,查到了就返回订单信息,查不到就返回"处理中,请稍后刷新"。
第三个坑是 Redis 扣减成功但落库失败。用户看到"抢到了",但订单表里没有记录,用户去订单列表里找不到,客服就会被投诉。这个问题的根治办法是让用户看到的"抢到"只是排队成功,真正的成功以订单落库为准,前端轮询订单状态,落库了才显示"抢购成功"。多了一次轮询,但避免了一堆客诉。这个取舍我认为非常值。
5. 压测与线上踩坑:设计图上看不见的那些问题
方案画得再漂亮,不上压测都是纸上谈兵。而且秒杀系统的压测特别容易做成"自欺欺人"——压测环境跑出 10 万 QPS,生产环境一上就崩。这一章把我遇到过的几类典型问题和排查思路摊开讲。
5.1 压测数据造假的三种典型
第一种是压测机自己先撑不住。用几台 8 核机器发压,每台能发出几千 QPS 就到顶了,实际压出来的数字是压测机的极限,不是被测系统的极限。判断方法很简单:看压测机的 CPU,如果已经 90% 以上,那测出来的数字就是假的。要么加压测机,要么用云上的压测服务,要么干脆用一台高配机器多开进程。
第二种是数据不真实。压测时用 100 个用户 ID 循环发请求,Redis 里的用户购买记录 key 只有 100 个,内存命中率接近 100%;生产环境是 30 万个不同的用户 ID,内存占用和 key 分布完全不同。压测一定要用接近真实基数的用户 ID,至少要几十万个,而且分布要打散。
第三种是链路不完整。压测只压了下单接口,没压上游的网关、没压下游的 MQ 消费。结果下单接口的 QPS 很好看,但 MQ 积压了几百万条消息,消费端根本追不上。压测必须全链路压,包括异步消费部分,并且要观察消息的堆积量随时间的变化。
5.2 热点 Key 与连接池耗尽
Redis 虽然单实例能扛十万 QPS,但那是分散在不同 key 上的情况。秒杀只有一个热点 key,所有请求都打在这一个 key 上,Redis 的分片(如果有的话)在这里完全失效,因为同一个 key 只能落在一个节点上。
这个问题没有银弹,只能缓解。方案一是库存分桶:把 1 万件库存拆成 10 个桶,每个桶 1000 件,用户请求按用户 ID 哈希到某个桶上扣减。这样热点被分散到 10 个 key 上,单 key 压力降到十分之一。代价是库存可能分配不均——某个桶扣完了但其他桶还有货,用户明明看到还有库存却抢不到。所以分桶要把桶做得足够小、数量足够多,同时库存总量要留一点余量,接受一定的"少卖"。
方案二是本地缓存加令牌。应用启动时从 Redis 一次性领一批库存到本地(比如每台机器领 100 个),用户在本地内存扣减,扣完了再向 Redis 领。这样 Redis 的压力降低到原来的百分之一。但这个方案的一致性很难保证,机器宕机时本地未使用的库存就丢了,而且不同机器的库存分布不均。除非是对少卖极度宽容的场景,否则不建议用。
连接池耗尽的问题更隐蔽。表现是:应用 CPU 不高、Redis 监控看 QPS 也不高,但接口响应时间突然飙到几秒。抓个线程栈就会发现一堆线程卡在JedisPool.getResource上。原因是连接池配小了,或者有的代码路径拿不到连接没释放。排查这类问题,第一步永远是 jstack 看线程卡在哪,比看任何监控都直接。
# 抓三次线程栈,间隔 5 秒,看哪些线程反复出现在同一位置 for i in 1 2 3; do jstack <pid> > /tmp/stack_$i.txt sleep 5 done grep -A 5 "JedisPool" /tmp/stack_1.txt | head -505.3 超时设置引发的连锁雪崩
超时配置是秒杀系统里最容易配错、又最致命的东西。默认值往往是无限等待或者几十秒,这在秒杀场景下等于自杀。
整条链路要分层设置超时,而且越靠上游超时越短:
| 链路环节 | 建议超时 | 说明 |
|---|---|---|
| 客户端 → 网关 | 3 秒 | 用户可接受的等待上限 |
| 网关 → 应用 | 1 秒 | 超过基本没救,快速失败 |
| 应用 → Redis | 200 毫秒 | 内网调用,超过说明有问题 |
| 应用 → 数据库 | 500 毫秒 | 写操作,超过就回滚 |
| 应用 → MQ | 300 毫秒 | 发送失败走本地消息表补偿 |
这张表的关键是层层递减。如果下游超时比上游还长,上游已经放弃了、下游还在执行,就会造成资源浪费和状态不一致。比如网关 1 秒就返回失败了,但应用还在等数据库 5 秒,这 5 秒里线程被占着,用户已经重试了三次,于是同一个用户在系统里同时有四个请求在跑,雪崩就这么来的。
还有一个细节:重试必须加退避和次数上限。数据库超时后立刻重试,在过载情况下只会加重负担。正确做法是重试前 sleep 一个随机时间(比如 50 到 200 毫秒),并且最多重试 2 次。超过就认为失败,走补偿流程。
5.4 大促期间真正该盯的指标
监控大盘上几十个指标,真出事的时候看不过来。我的经验是只盯这几个,其他的用告警兜底:
一是下单接口的成功率,不是 QPS。QPS 再高,成功率掉到 10% 也是灾难。成功率的基线要在压测时确定下来,大促期间跌破基线的 80% 就告警。
二是 Redis 的慢查询和连接数。慢查询数量突然增加,说明有长脚本或者大 key;连接数逼近上限,说明有泄漏或者请求堆积。这两个指标能在问题爆发前几分钟给出信号。
三是 MQ 的堆积量。堆积量持续上涨说明消费能力不足,这时候要么加消费者,要么先把部分非核心消息降级。堆积量是一个"趋势指标",看绝对值没意义,要看增速。
四是数据库的活跃连接数和慢 SQL 数。秒杀期间数据库不是主角,但它是最后的兜底,一旦它也扛不住,整个系统就彻底没救了。
五是 GC 时间和频率。秒杀期间对象创建速度极快,如果 GC 配置不当,一次 Full GC 停顿几秒,就等于这几秒内所有请求都超时。
这五个指标配到一个大盘上,一屏能看完,值班的人扫一眼就知道系统健康不健康。
6. 一些不在设计文档里的经验
写到这里,方案层面的东西基本讲完了,最后分享几个不太会写进设计文档、但实际很管用的经验。
第一个是降级开关必须提前做好。活动开始前,把"关闭限购校验""关闭用户资格校验""直接返回活动已结束"这几个开关做进配置中心,并且验证过可以实时生效。真的出事的时候,你不是去改代码发版,而是点一下开关。我见过太多团队大促时现场改代码,改完还没发上去活动已经结束了。
第二个是库存数字不要一次放完。如果活动是分批放库存的(比如每小时放一批),第一批只放总量的 30%,观察系统的实际表现再决定后面怎么放。这样即使架构有隐患,损失也是局部的。
第三个是日志要能定位到单个用户。秒杀的日志量巨大,不可能全打。但每个请求必须带一个 traceId,用户投诉的时候能顺着 traceId 把整条链路的日志串起来。日志里至少要有:用户 ID、活动 ID、Redis 扣减结果、MQ 发送结果、数据库落库结果。这五个字段齐了,百分之九十的客诉都能在五分钟内定位。
第四个是活动结束后的清理比活动本身更重要。活动结束后要把 Redis 里的临时 key 清掉、把预热数据删掉、把限流规则撤掉。我有一次忘了撤限流,第二天的常规活动一直被限,排查了半天才发现是上一场活动的配置还留着。所以清理也要做成一个 checklist,逐项打勾。