

一、引子:这条 SQL 到底哪里不够#
秒杀系统的方案几乎人人都能背:Redis 预扣库存、MQ 异步下单、限流降级、页面静态化。四件套,顺序都不会错。
但去年有人问了我一个更基础的问题:
扣减库存直接写一条 SQL 不就行了吗?
sqlUPDATE stock SET count = count - 1 WHERE item_id = ? AND count > 0;InnoDB 的行锁会把并发请求排成队,
count > 0这个条件保证了不会扣成负数,返回的影响行数就是抢没抢到。哪来的超卖?为什么还需要 Redis?
我当时的回答是「扛不住高并发」。对方追问:扛不住是什么意思?是这条 SQL 会算错,还是会变慢?
我答不上来。
因为这两件事的性质完全不同。如果是算错,那是正确性问题,加多少机器都没用;如果是变慢,那是性能问题,和超卖没有任何关系。 我把两者混在一句「扛不住」里,说明我根本没分清秒杀系统在解决什么。
后来我认真推了一遍,结论有点反直觉:
这条 SQL 是对的。 它在任何并发下都不会超卖。行锁 + count > 0 的组合是一个完整的原子检查-更新,这是数据库最基本的保证。见过的所有「超卖」事故,用的都不是这个写法——而是先 SELECT 查库存、在应用层判断、再 UPDATE 扣减。那个写法确实会超卖,但错的是它,不是数据库。
所以秒杀系统的四件套,没有一件是为了解决超卖的。它们解决的是别的问题,而这些问题在「超卖」这个词的遮蔽下,很少被单独讲清楚。
这篇文章想做的,是把三句口号拆开:
| 口号 | 问题出在哪 |
|---|---|
| 用 Redis 抗住流量 | Redis 快不是重点,把失败前置才是 |
| 用 MQ 异步削峰 | 削掉的不是峰值,是同步等待的责任 |
| 加限流保护系统 | 保护的不是「系统」,是某个具体的、有限的资源 |
二、Redis 预扣:快不是重点#
2.1 先算一笔账#
先把「扛不住」这个模糊的说法量化。
假设一场秒杀:1000 件商品,100 万人同时点击。数据库是一主两从的 MySQL,主库能承受的写 QPS 大约 5000(这个数字取决于硬件和事务大小,但量级是对的)。
如果所有请求都打到那条 UPDATE:
- 前 1000 个请求扣减成功;
- 剩下 99.9 万个请求,每一个都要拿到行锁、执行条件判断、发现
count = 0、返回影响行数 0。
注意最后这点:失败的请求和成功的请求,消耗的资源几乎一样多。 都要建立连接、解析 SQL、进入事务、竞争同一行的行锁、写 undo log。唯一省下的是没有真正修改数据。
于是 100 万个请求排队争抢同一行的锁。行锁是串行的,假设每个请求持锁 1 毫秒,100 万个请求需要 1000 秒。而连接池只有几百个连接,剩下的请求全部堆在连接队列里等待,直到超时。
结果不是超卖,是雪崩。 数据库连接被占满,同一个库上其他业务的查询全部拿不到连接,整个服务不可用。秒杀活动搞垮了主站——这才是真实发生过的事故形态。
2.2 那 Redis 解决的是什么#
现在看引入 Redis 之后发生了什么。
-- 预扣库存的 Lua 脚本
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
return -1 -- 库存不足
end
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -2 -- 重复下单
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return 1 -- 预扣成功lua标准解释是「Redis 是内存操作,比数据库快,所以能抗住」。
这个解释不完整。Redis 单实例大约 10 万 QPS,比 MySQL 的 5000 高 20 倍——但面对 100 万并发,20 倍依然不够。如果只是「更快」,那不过是把雪崩推迟了几秒。
真正的变化是失败的成本。
在 Redis 这一层,一个注定失败的请求消耗的是:一次 GET、一次比较、返回 -1。没有事务、没有行锁、没有日志、没有磁盘。而且失败判定发生在整条链路的最前端——请求根本不会往后走,不会占用数据库连接,不会污染下游。
这才是关键:
一旦这么理解,很多设计细节就能自己推出来了。
2.3 由此推出的几件事#
为什么预扣要用 Lua 脚本?
因为「查库存 → 判断 → 扣减」这三步必须原子。如果拆成三次 Redis 调用,两个客户端可能同时读到 stock = 1,同时判断通过,同时扣减——Redis 层就超卖了。
Redis 是单线程执行命令的,一个 Lua 脚本在执行期间不会被其他命令打断。这是它能提供原子性的原因。
为什么要在 Redis 里查重?
因为「一人限购一件」这个规则如果放到数据库用唯一索引来保证,就等于让所有请求都必须走到数据库——预扣挡在前面的意义就没了。查重必须和预扣在同一层完成。
为什么预扣成功之后还要在数据库里扣一次?
这是最容易被忽略的一点。Redis 的预扣是不可靠的:
- Redis 可能宕机,内存数据丢失;
- 主从切换时,未同步的写会丢失(Redis 的复制是异步的);
- 预扣成功后,后续的下单流程可能失败。
所以 Redis 里的库存是一个乐观的、可能偏离真相的副本,数据库里的才是最终账本。数据库那条 UPDATE ... WHERE count > 0 依然要写,它是最后一道防线。
只不过此时到达数据库的请求只有 1000 个左右,而不是 100 万个。
100 万请求
↓ 网关限流(拦掉大部分)
10 万
↓ Redis 预扣(99% 在这里失败返回)
1000
↓ 数据库最终扣减(真正的账本)
1000 单text每一层的职责是把量级降一个数量级,而不是把正确性往后推。 最后一层依然要独立保证正确。
2.4 一个容易搞错的地方:预扣和回补#
预扣成功但下单失败,库存要还回去。这个回补逻辑是秒杀系统里最容易出 bug 的地方。
用户 A 预扣成功(Redis 库存 1000 → 999)
↓
创建订单失败(比如数据库超时)
↓
回补库存(Redis 库存 999 → 1000)text看起来很直接。问题在于:回补和预扣之间存在竞态。
假设回补执行的瞬间,Redis 正好被重置或者主从切换了,那这次 INCR 就加到了一个错误的基数上,库存会凭空多出来。更常见的情况是回补重复执行(比如重试机制),库存被多加了几次。
所以回补必须是幂等的。常见做法是不用 INCR,而是维护一个「已回补订单集合」:
-- 幂等回补
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 0 then
return 0 -- 这个用户根本没预扣成功过,不回补
end
redis.call('SREM', KEYS[2], ARGV[1])
redis.call('INCR', KEYS[1])
return 1lua先从「已下单集合」里移除,移除成功才 INCR。第二次执行时 SISMEMBER 返回 0,直接退出。
2.5 小结#
「用 Redis 抗住流量」这句话里,「抗」字掩盖了真正的机制。
Redis 不是靠快扛住的——快只是必要条件。它靠的是把失败判定前置到成本最低的位置,让 99.9% 注定失败的请求在触及任何昂贵资源之前就返回。
理解了这一点,下面这些就不用单独记:
- 为什么预扣要用 Lua(多步骤需要原子性);
- 为什么数据库那层还要判断(Redis 是不可靠副本);
- 为什么回补要幂等(重试是常态);
- 为什么很多系统干脆不回补(少卖优于多卖)。
三、MQ 削的不是峰#
3.1 「削峰填谷」这个比喻的问题#
MQ 在秒杀里的作用,标准说法是「削峰填谷」,通常还配一张图:一条尖锐的流量曲线,经过 MQ 之后变成一条平缓的曲线。
这张图有误导性。它暗示 MQ 把请求「压平」了——好像总工作量不变,只是摊到更长的时间里做完。
但仔细想:请求量并没有减少,工作也一件都没少做。 100 万个请求进来,MQ 里就堆 100 万条消息,消费端还是要一条条处理。如果说峰值 QPS 是 10 万而消费端只能处理 1000 QPS,那这些消息要消费 1000 秒。
那 MQ 到底改变了什么?
它改变的是「谁在等待」。
同步链路里,用户的 HTTP 连接从请求开始一直挂到数据库返回。10 万并发意味着 10 万个连接同时占用着 Tomcat 线程、数据库连接、内存缓冲区。这些资源是有限且昂贵的。
异步之后:
同步:
用户 ──持有连接 800ms──► Tomcat线程 ──► DB连接 ──► 返回
异步:
用户 ──持有连接 5ms──► 写入 MQ ──► 立即返回「排队中」
↓
消费端按自己的节奏处理text用户的连接在 5 毫秒后就释放了。Tomcat 线程立刻可以服务下一个请求。消费端的处理速度和前端的请求速度解耦了。
所以更准确的说法不是「削峰」,而是:
3.2 代价:用户体验的重构#
这个转换不是免费的。它把一个同步的、有确定结果的交互,变成了异步的、结果未知的交互。
同步下单,用户点击后等待 800 毫秒,然后看到「抢购成功」或「已售罄」。结果是确定的。
异步下单,用户点击后立刻看到「排队中,请稍候」。然后呢?
- 前端要轮询一个查询接口,问「我的订单处理好了吗」;
- 或者用 WebSocket / SSE 等服务端推送;
- 用户可能在等待期间关掉页面,回来后要能查到结果;
- 如果最终失败了(比如库存被别人抢完),要有一个地方告诉他。
这些都是异步化引入的新工作量,而且全部落在前端和产品设计上。
我见过的最常见的错误,是后端做了异步化,前端还是同步逻辑——点击后转圈等待,然后写死一个 3 秒超时。这等于把 MQ 的好处全丢掉了:用户的连接还是挂着,只是挂在轮询上。
3.3 消息可靠性:三个必须回答的问题#
一旦引入 MQ,就要面对分布式系统的老问题。这里有三个必答题。
会。丢失可能发生在三个环节:
生产端丢失:应用把消息发出去了,但 broker 没收到(网络问题),而应用以为成功了。解决办法是发送确认(RocketMQ 的同步发送、Kafka 的 acks=all),拿到 broker 的 ACK 才算成功。
broker 丢失:消息进了 broker 的内存,还没刷盘就宕机了。解决办法是同步刷盘,代价是吞吐量下降一个数量级。或者靠多副本:写入至少 N 个副本才确认。
消费端丢失:消费者拿到消息,还没处理完就崩溃了,但 offset 已经提交。解决办法是处理完成后再提交 offset。
这三处每一处都是在「性能」和「可靠性」之间选位置,没有免费的方案。
会,而且一定会。
原因很简单:网络超时的语义是不确定的。生产者发消息超时了,它不知道 broker 到底收没收到。重试就可能重复,不重试就可能丢失。
在「至少一次」(at-least-once)和「至多一次」(at-most-once)之间,绝大多数系统选择前者——宁可重复,不可丢失。因为重复可以在消费端用幂等来解决,丢失则无法挽回。
所以消费端必须幂等。秒杀场景下的常见做法:
-- 用唯一索引兜底
CREATE UNIQUE INDEX uk_user_item ON orders (user_id, item_id);
-- 消费时直接插入,靠数据库拒绝重复
INSERT INTO orders (user_id, item_id, ...) VALUES (?, ?, ...);
-- 捕获 DuplicateKeyException,视为处理成功sql这个做法的好处是不需要额外的状态存储,唯一性由数据库保证。坏处是靠异常控制流程,代码不太好看。
分区内有序,全局无序。
Kafka 和 RocketMQ 都是这个模型:同一个分区(队列)内的消息严格按写入顺序消费,不同分区之间没有顺序保证。
秒杀场景下这通常不是问题,因为每个下单请求是独立的,不依赖顺序。但如果业务里有「先扣款后发货」这类顺序依赖,就必须把相关消息路由到同一个分区(按订单 ID 哈希)。
需要注意的是,一旦要求顺序,就放弃了并行消费的能力——同一个分区只能由一个消费者串行处理。顺序和吞吐在这里是直接冲突的。
3.4 最终一致性下,什么可以不一致#
异步化之后,系统进入最终一致状态。中间某个时刻,Redis 里的库存、MQ 里堆积的消息、数据库里的订单,三者是不一致的。
关键问题不是「怎么消除不一致」,而是**「哪些不一致可以接受,接受多久」**。
| 不一致现象 | 可接受吗 | 为什么 |
|---|---|---|
| Redis 显示已售罄,数据库订单还没落 | 可以 | 用户看到「已售罄」是正确的最终结果 |
| 用户看到「排队中」持续 30 秒 | 勉强 | 体验差,但结果正确 |
| 数据库订单数 < Redis 扣减数(少卖) | 可以 | 损失有限,可对账补偿 |
| 数据库订单数 > 库存总数(超卖) | 不可以 | 直接的资金损失和信誉损失 |
这张表才是异步方案的设计依据。最后一行是硬约束,前三行是可以谈判的。
于是数据库那条 UPDATE ... WHERE count > 0 的地位就清楚了:它是唯一不能被绕过的一环。前面所有的 Redis、MQ、限流,都是在为它减负,但没有一个能替代它。
3.5 小结#
「MQ 削峰填谷」这个说法丢掉的东西:
- 丢掉了「峰值没有消失,只是转移了存储介质」,于是无法解释为什么消费端依然要扩容;
- 丢掉了交互形态的改变,于是异步方案配同步前端,白折腾一场;
- 丢掉了「至少一次」的必然性,于是幂等被当成可选项;
- 丢掉了最终一致性的谈判空间,于是分不清哪些不一致要拼命消除、哪些可以接受。
MQ 真正给的是时间上的解耦,代价是状态上的复杂。这笔交易划不划算,取决于你的交互形态能不能容忍异步。
四、限流保护的不是「系统」#
4.1 一个配不出来的阈值#
「加限流保护系统」是四件套里最没有争议的一条。没人会反对限流。
但真要落地就会卡在一个问题上:阈值填多少?
我第一次配限流的时候,选的是 QPS 5000。理由是压测的时候单机能跑到 6000,留一点余量。上线之后被打穿了——不是因为 5000 太高,而是因为那 5000 个请求里有一部分会走到一个我没注意的下游接口,而那个接口的配额是每秒 200。
限流器忠实地放过了 5000 QPS,然后下游把我们拒了。
这件事让我意识到「保护系统」这个说法有多空。「系统」不是一个可以被保护的对象,它没有容量,没有上限,没有一个可测量的数字。真正有上限的,永远是某个具体的东西:
| 资源 | 典型上限 | 打满之后的表现 |
|---|---|---|
| 数据库连接池 | 几十到几百个连接 | 请求排队等连接,超时 |
| Tomcat 工作线程 | 200~800 | 请求进入 accept 队列,延迟飙升 |
| Redis 单实例吞吐 | 约 10 万 QPS | 命令延迟上升,超时 |
| 下游服务配额 | 由对方规定 | 被对方限流拒绝 |
| 出口带宽 | 由带宽规格决定 | 丢包,重传 |
| JVM 堆内存 | 由 -Xmx 决定 | Full GC 频繁,最终 OOM |
配阈值的正确姿势是先找出这条链路上最小的那个上限,再往前倒推。
4.2 令牌桶和漏桶:教科书划分的失真#
限流算法的标准介绍是四个:固定窗口、滑动窗口、漏桶、令牌桶。前两个讲计数,后两个讲整流,通常还会说「令牌桶允许突发,漏桶不允许」。
这个划分本身没错,但它有两处失真。
第一处:固定窗口的问题不在「不精确」,而在临界点。
固定窗口的常见批评是「不够精确」。但真实的故障形态更具体:
限流规则:每秒 100 次
时间轴: |---- 第 1 秒 ----|---- 第 2 秒 ----|
请求: 100 次 ↑ 100 次
(0.99s) (1.01s)
在 0.99s ~ 1.01s 这 20 毫秒内,通过了 200 次请求text窗口边界处可以通过两倍的额定流量。如果你的阈值是按资源上限配的,那这一瞬间就会打穿。
这不是精度问题,是结构性缺陷:它在特定时刻必然失效,而不是偶尔偏差一点。
第二处:令牌桶和漏桶的差别,不是「允不允许突发」。
这个说法太笼统。真正的差别在于它们对超额请求的处理方式:
桶里以固定速率放入令牌,请求来了取一个令牌,取到就通过,取不到就立即拒绝。
容量 100,速率 10/秒
空闲一段时间后 → 桶里攒了 100 个令牌
→ 瞬间来 100 个请求,全部通过(突发)
→ 之后只能每秒通过 10 个text关键特征:桶的容量决定了能容忍多大的突发。攒起来的令牌就是突发额度。
适合场景:下游能够承受短时突发,比如自己的服务集群——瞬间多打一点,靠队列缓冲一下就过去了。
请求先进入桶(队列),然后以固定速率漏出被处理。桶满了之后新请求被拒绝。
容量 100,漏出速率 10/秒
瞬间来 100 个请求 → 全部进桶排队
→ 以每秒 10 个的速度处理
→ 第 100 个请求要等 10 秒才被处理text关键特征:输出速率绝对恒定,代价是请求要排队等待,延迟不可控。
适合场景:下游对速率有硬性要求,比如第三方 API 明确规定「每秒最多 200 次,超过就封禁」。这时必须严格整流,不能突发。
所以选择依据不是「要不要允许突发」,而是:
下游的限制是「平均速率」还是「瞬时并发」?
- 是平均速率限制(第三方 API 配额)→ 漏桶,严格整流;
- 是瞬时并发限制(连接池、线程池)→ 令牌桶或直接用信号量控制并发数。
4.3 被忽略的第三种:并发数限流#
前面四种算法都在限制速率(每秒多少次)。但很多时候真正需要限制的是并发数(同时有多少个在处理)。
这两者不等价。考虑一个下游接口:
场景 A:QPS 100,每个请求耗时 10ms
→ 平均并发数 = 100 × 0.01 = 1
场景 B:QPS 100,每个请求耗时 2000ms
→ 平均并发数 = 100 × 2 = 200text同样的 QPS,并发数差了 200 倍。如果下游的连接池只有 50 个连接,场景 B 会直接把它打满,而 QPS 限流器完全看不出问题——它只数请求个数,不看请求活了多久。
这个关系就是利特尔法则(Little’s Law):
其中 是系统内的平均请求数(并发数), 是到达速率(QPS), 是平均处理时间(响应时间)。
这个公式解释了限流器最常见的失效方式:你按正常情况下的 配了 ,但故障时 会暴涨。
下游变慢的时候, 从 10 毫秒涨到 2 秒, 就从 1 涨到 200。QPS 一点没变,并发数涨了 200 倍。这就是级联故障的数学形式——一个下游的延迟上升,会以并发数的形式向上游传导,把上游的线程池占满,然后上游对它的上游也变慢。
4.4 单机限流在集群里会失真#
还有一个非常实际的坑。
假设集群有 10 台机器,希望整体 QPS 不超过 1000。最直接的做法是每台机器配 100。
这个算法在流量均匀的前提下成立。但真实的负载均衡从来不完全均匀:
- 长连接场景下,连接一旦建立就固定在某台机器上,连接数分布不均就导致请求分布不均;
- 某台机器 GC 停顿时,LB 会把流量切给其他机器,造成瞬时倾斜;
- 机器规格不一致(混合部署的常态),处理能力本来就不一样;
- 扩缩容期间,机器数量在变,每台的配额却是写死的。
结果就是:整体 QPS 只有 600,但某台机器已经被打穿了。
解决方向有两个:
集中式限流:用 Redis 做全局计数器,所有机器共享一个配额。
-- 简化的滑动窗口计数
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
return 0 -- 超限
end
return 1lua准确,但每个请求多一次 Redis 往返(约 1 毫秒),而且 Redis 成了新的单点。
自适应单机限流:不配固定阈值,而是根据本机的实时状态(响应时间、线程池使用率、CPU 负载)动态调整。
这是 Sentinel 的自适应模式和 Netflix 的 concurrency-limits 走的路。好处是不需要预先算阈值,也不受集群规模变化影响;坏处是行为不好预测,调试困难。
4.5 降级:一个需要提前决定的事#
限流是拒绝请求,降级是用一个更差但可用的结果代替正常结果。
秒杀里常见的降级:
- 商品详情页的「实时库存数字」降级成「有货 / 无货」,甚至干脆隐藏——省掉一次 Redis 查询;
- 推荐模块、评论区、猜你喜欢,直接不展示——这些和秒杀主流程无关;
- 下单成功后的短信通知、积分发放,转异步或直接丢弃——不影响核心交易。
降级的技术实现不难,难的是决策必须提前做。
活动进行中的时候没有人有精力去讨论「要不要关掉评论区」。所有降级开关必须在活动之前就:
- 明确列出来(哪些功能可以降级,降级成什么样);
- 做成配置开关(能在不发版的情况下切换);
- 演练过(确认关掉之后系统真的正常,而不是抛异常);
- 定好触发条件(什么指标到什么值就关,谁有权限决定)。
第 4 条最容易被跳过,结果就是活动期间大家在群里讨论「现在要不要降级」,等讨论完故障已经过去了。
4.6 小结#
「加限流保护系统」这句话,问题在于「系统」这个词是空的。
把它换成可操作的版本:
- 枚举链路上的稀缺资源,找出上限最小的那个;
- 判断限制的是速率还是并发,速率用令牌桶/漏桶,并发用信号量隔离;
- 确认资源在集群内还是集群外,决定用单机限流还是集中式限流;
- 提前定好降级开关和触发条件,并且演练兜底路径。
四步里没有一步是「选一个限流算法」。算法是最后才需要关心的,而八股文里通常只讲算法。
五、把全链路串起来#
三节拆完了,现在从上到下走一遍,看每一层在做什么。
关键是给每一层标注它拦掉了什么、以及为什么这一层适合拦它:
| 层级 | 手段 | 拦掉的请求 | 为什么放在这一层 |
|---|---|---|---|
| 客户端 | 按钮置灰、倒计时 | 重复点击、提前请求 | 成本为零,但完全不可信 |
| CDN | 静态资源、页面缓存 | 所有静态内容请求 | 不回源,离用户最近 |
| 网关 | 集中式限流、黑名单 | 超出总配额的请求 | 请求还没进业务系统 |
| 应用 | Redis 预扣、查重 | 库存不足、重复下单 | 一次内存操作即可判定 |
| MQ | 异步下单 | (不拦,转移等待) | 让前端连接尽早释放 |
| 数据库 | 条件更新 | 最终的正确性兜底 | 唯一可信的账本 |
有几条规律:
规律一:越往前拦成本越低,但可信度也越低。
客户端置灰按钮的成本是零,但用户可以直接调 API 绕过。数据库的条件更新绝对可信,但成本最高。所以链路的形状是固定的:前面用低成本手段过滤大部分,后面用高成本手段保证正确。
规律二:只有最后一层保证正确性,前面所有层都是「尽力而为」。
这是最容易搞错的地方。Redis 预扣可能因为宕机而失效,MQ 消息可能丢,限流器可能配错——这些都不能导致超卖。超卖只由数据库那一行 WHERE count > 0 来防。
如果一个方案里,正确性依赖于 Redis 不丢数据,那它是错的。
规律三:每一层都要能独立降级。
Redis 挂了怎么办?如果答案是「整个秒杀不可用」,那还算可以接受(至少没有超卖)。但如果答案是「所有请求直接打到数据库」,那就是灾难——这等于回到了第二节开头那个雪崩场景。
所以 Redis 不可用时的正确行为是直接拒绝所有请求,而不是穿透到数据库。这和普通的缓存降级思路正好相反(普通缓存挂了会回源查库),因为这里 Redis 承担的不是缓存加速,而是准入控制。
六、方法论:怎么读一个「方案」#
秒杀是典型的「方案题」——网上有无数篇文章给出几乎相同的架构图和技术选型清单。背下来很容易,理解很难。
这次拆解让我总结出几条读方案的方式,主要给以后的自己看。
6.1 先问「这个方案在解决什么」,而不是「这个方案是什么」#
我最初的错误是把「Redis 预扣库存」当成一个做法记住的:秒杀要用 Redis 预扣,然后 Lua 脚本,然后回补。
正确的记法是把它当成一个问题的答案:
问题:99.9% 的请求注定失败,但失败的成本和成功一样高。 答案:把失败判定前移到成本最低的位置。 实现:Redis + Lua。
这样记的好处是,当场景变化时你知道该不该用它。比如:库存有 100 万件而只有 1000 人抢——这时候没有「大量注定失败的请求」,Redis 预扣就是纯粹的复杂度,不需要。
方案是问题的函数。 只记方案不记问题,就无法判断它在新场景下适不适用。
6.2 分清正确性和性能#
这是引子里那个问题给我的最大教训。「扛不住」这三个字混淆了两件性质完全不同的事:
- 正确性问题:结果算错了。加机器没用,改配置没用,只能改逻辑。
- 性能问题:结果对但太慢/资源不够。加机器有用,改配置有用。
秒杀四件套全部是性能手段,没有一件是正确性手段。真正的正确性保证只有一条 SQL。
拿到任何方案,都可以先做这个分类:
| 手段 | 类型 | 移除之后会怎样 |
|---|---|---|
| 数据库条件更新 | 正确性 | 会超卖 |
| 唯一索引防重 | 正确性 | 会重复下单 |
| Redis 预扣 | 性能 | 数据库被打穿,但不会超卖 |
| MQ 异步 | 性能 | 连接被占满,但不会超卖 |
| 限流 | 性能 | 资源打满,但不会超卖 |
| 页面静态化 | 性能 | 带宽和 CPU 上升 |
「移除之后会怎样」这一列是最有信息量的。能区分出「会算错」和「会变慢」,就已经比大部分方案讨论清晰了。
6.3 找出每个数字的来源#
方案文章里充满了数字:QPS 5000、连接池 200、限流阈值 1000、超时 3 秒。这些数字如果不知道来源,就全是迷信。
每个数字都应该能回答「它是怎么来的」:
- 压测得出的:说明它绑定特定的硬件和数据量,换环境就要重测;
- 官方文档规定的:比如第三方 API 的配额,这是硬约束;
- 算出来的:比如用利特尔法则从并发数和响应时间推出的 QPS;
- 抄来的:那就是没有依据,必须重新验证。
我那次限流配错,根因就是 5000 这个数字是从「单机压测能到 6000」来的,而它约束的是应用本身的处理能力——完全没考虑下游那个 200 配额的接口。数字对了,但约束的对象错了。
6.4 假设每个组件都会挂#
方案图上的每个框都会挂。挨个问一遍:
| 组件挂了 | 应该发生什么 | 常见的错误行为 |
|---|---|---|
| Redis | 拒绝所有秒杀请求 | 穿透到数据库,雪崩 |
| MQ | 拒绝新请求或转同步 | 消息丢失且无感知 |
| 数据库主库 | 秒杀停止,等切换 | 应用不断重试,放大故障 |
| 下游服务 | 降级或熔断 | 无限重试,级联故障 |
| 配置中心 | 用本地缓存的配置 | 兜底逻辑读配置,一起挂 |
第一行和第五行是我认为最容易错的两个。它们的共同点是:兜底路径依赖了一个可能同时失效的东西。
6.5 让业务参与技术取舍#
第二节末尾那个「不回补库存」的例子,是这次拆解里我最喜欢的一点。
纯技术视角下,预扣成功但下单失败必须回补,否则库存就丢了。于是要写幂等回补、要处理竞态、要加对账。
但如果去问运营:「1000 件商品最后卖了 998 件,能接受吗?」答案很可能是「当然可以,比超卖好一万倍」。
一句业务确认,省掉了一整条链路的复杂度。
这类取舍在技术方案模板里从来不会出现,因为模板不知道你的业务能容忍什么。可以主动去问的几个问题:
- 少卖能接受吗?能接受多少?(决定回补要不要做)
- 用户等待多久算可接受?(决定要不要异步)
- 抢购结果能延迟公布吗?(决定异步方案的交互形态)
- 哪些功能在高峰期可以关掉?(决定降级清单)
这四个问题的答案,比任何架构图都更能决定方案的形状。
6.6 最后#
秒杀之所以是经典面试题,是因为它把高并发系统的所有典型矛盾压缩在了一个足够小的场景里:注定失败的大量请求、有限的稀缺资源、不能出错的核心约束、可以谈判的用户体验。
但也正因为它太经典,标准答案被压缩成了四件套清单,而清单丢掉了它们各自要解决的问题。
回到引子那个问题:那条 SQL 到底哪里不够?
答案是:它哪里都够,只要请求量不超过数据库的承受能力。 秒杀系统的全部复杂度,都来自「请求量远超真实需求」这一个事实。理解了这一点,四件套就不是要背的清单,而是可以现推的结论。
参考#
- Little, J.D.C. A Proof for the Queuing Formula: L = λW. Operations Research, 1961.
- Nygard, Michael T. Release It! Design and Deploy Production-Ready Software, 2nd ed. Pragmatic Bookshelf, 2018.(舱壁、熔断、超时的经典论述)
- Kleppmann, Martin. Designing Data-Intensive Applications, 第 8~9 章. O’Reilly, 2017.(分布式系统的故障模型与一致性)
- Redis 官方文档, Scripting with Lua 与 EVAL command.
- Alibaba Sentinel 文档, 流量控制 与 系统自适应保护.
- Netflix, concurrency-limits: 基于利特尔法则的自适应并发限流实现.
本站相关: