高并发秒杀系统架构设计演进
引言
2024年双十一零点,某电商平台一款限量5000台的旗舰手机在开售后的第3秒就被抢购一空,峰值QPS冲到 82万。而就在半年前,同一个团队的系统在 5万 QPS 时就开始出现大面积超卖、Redis 连接池打满、数据库 CPU 100% 的连锁雪崩。
这不是一个"加机器就能解决"的问题。秒杀系统的本质,是在极短时间内面对远超系统承载能力的突发流量,同时还要保证库存扣减的绝对正确性。它考验的不只是单点技术,而是对缓存、队列、限流、分布式锁、最终一致性等一系列架构手段的综合调度能力。
这篇文章我会以"演进"为主线,把秒杀系统从单体到分布式的四个阶段拆开来讲:每个阶段的瓶颈在哪、为什么这么改、源码层面到底发生了什么。如果你正在做营销、票务、抢购类系统,或者只是想把高并发架构的核心思想吃透,这篇文章应该能给你一些真正落地的启发。
核心概念:先建立三个生活化类比
在进入代码之前,先把三个最容易混淆的概念讲清楚。
类比一:秒杀流量 vs 餐厅后厨
正常的业务流量像一家正常营业的餐厅——客人陆续来,后厨按节奏出餐,厨师(线程)忙而不乱。而秒杀流量像餐厅门口突然涌来一万个人同时点同一道菜。这时候如果还让每个客人直接冲到后厨下单,后厨瞬间瘫痪。正确的做法是:门口发号(限流)、点单台先记录(异步队列)、后厨按能力慢慢出餐(削峰)。
类比二:库存扣减 vs 银行取钱
库存扣减和银行取钱一样,是典型的并发写竞争。1万个人同时看到账户里有100块,如果每个人都"先查余额再扣款",就会出现超卖。银行的做法是加锁 + 原子操作,Redis 的 DECR 就是一把天然的、单线程的"柜员锁"。
类比三:消息队列 vs 快递分拣中心
秒杀成功后要写订单、扣库存、发通知、加积分,这些操作如果同步做完,一次请求要几百毫秒。消息队列就像快递分拣中心——用户下单只是把包裹丢进分拣中心(MQ),后续的派送(订单落库、通知)由分拣中心按自己的节奏处理,用户不用等。
技术定义上,秒杀系统的核心矛盾是:
| 维度 | 要求 | 冲突点 |
|---|---|---|
| 吞吐量 | 峰值 10万+ QPS | 数据库单机写只有几千 TPS |
| 一致性 | 绝对不能超卖 | 分布式环境下强一致代价极高 |
| 延迟 | 用户感知 < 200ms | 同步链路越多延迟越高 |
| 可用性 | 秒杀期间不能挂 | 单点故障会被流量放大 |
理解了这四个维度的拉扯,后面的架构演进就顺理成章了。
架构演进:四个阶段
阶段一:单体直连数据库(1万 QPS 以下)
最原始的版本,所有逻辑在一个 Spring Boot 应用里,直接 SELECT ... FOR UPDATE 扣库存。
这个阶段的问题非常明显:MySQL 的行锁在 1万并发下会形成锁等待队列,大量请求阻塞在 FOR UPDATE 上,连接池迅速耗尽。而且 SELECT ... FOR UPDATE 是悲观锁,读也要加锁,吞吐极低。
阶段二:Redis 预扣库存 + 异步落库(10万 QPS)
把库存预热到 Redis,用 DECR 原子扣减,扣减成功后发 MQ 异步落库。
关键点在于:Redis 是单线程执行命令的,DECR 天然原子,不用加分布式锁。但这里有个坑——如果只用 DECR,会出现"库存扣成负数"的问题,必须配合 Lua 脚本把"判断 + 扣减 + 记录用户"三步做成原子操作。
阶段三:分层过滤 + 多级缓存(50万 QPS)
在 Redis 前面再加一层本地缓存(Caffeine),用布隆过滤器拦截无效请求,用令牌桶做前置限流。
这一层的核心思想是层层过滤,把无效流量挡在越前面越好。80% 的请求其实在秒杀开始前就注定失败(库存已空、用户重复、未登录),这些请求不应该穿透到 Redis。
阶段四:单元化 + 多活(百万 QPS)
按用户 ID 哈希分片,每个单元独立部署一套 Redis + MQ + DB,单元内闭环。这是顶级电商的最终形态,本文不展开,但思路和分库分表一致。
源码级深度分析:Redis Lua 脚本为什么能保证原子性
很多人知道"用 Lua 脚本保证原子性",但不知道为什么。我们直接看 Redis 源码。
Redis 处理命令的入口在 server.c 的 processCommand 函数。当收到 EVAL 命令时,会调用 evalGenericCommand:
// redis/src/eval.c (简化)
void evalGenericCommand(client *c, int evalsha) {
// 1. 获取 Lua 脚本
lua_State *lua = lua_open();
// 2. 将脚本加载到 Lua 虚拟机
luaLoadLibraries(lua);
// 3. 执行脚本 —— 注意这里!
luaCallFunction(lua, argc-3, argv+3, 0);
// 4. 脚本执行期间,Redis 主线程被完全占用
// 其他客户端命令全部排队等待
}关键点:Redis 是单线程模型,evalGenericCommand 在主线程中同步执行 Lua 脚本。脚本执行期间,主线程不会处理任何其他客户端命令。所以 Lua 脚本里的"判断库存 + 扣减 + 记录"三步,相对于其他所有客户端来说,是一个不可分割的原子操作。
这和你用 MULTI/EXEC 事务有什么区别?区别在于:MULTI/EXEC 只是把命令打包,但中间不会执行其他命令——等等,其实这点是一样的。真正的区别是:
- Lua 支持条件逻辑:可以在脚本里写
if stock > 0 then ... end,而MULTI/EXEC不支持读取结果做判断。 - Lua 减少网络往返:一次 RTT 完成所有操作,
MULTI/EXEC需要多次。
但是!Lua 脚本有一个致命坑:脚本执行时间过长会阻塞整个 Redis。Redis 默认 lua-time-limit 是 5 秒,超过后会打印警告,但不会中断脚本(除非你执行 SCRIPT KILL)。所以秒杀脚本必须足够轻量,绝对不能在里面做网络调用或复杂计算。
实战代码
示例一:Redis Lua 原子扣减库存(带防重)
这是秒杀系统最核心的一段代码,必须做到:判断库存、扣减库存、记录用户、防止重复下单,四步原子。
-- seckill_stock.lua
-- KEYS[1]: 库存 key, 如 seckill:stock:1001
-- KEYS[2]: 已购用户集合 key, 如 seckill:users:1001
-- ARGV[1]: 用户 ID
-- ARGV[2]: 购买数量
-- 返回: 1 成功, 0 库存不足, -1 重复下单
-- 1. 先检查用户是否已经买过(用 Set 去重)
local bought = redis.call('SISMEMBER', KEYS[2], ARGV[1])
if bought == 1 then
return -1
end
-- 2. 检查库存是否充足
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local num = tonumber(ARGV[2])
if stock < num then
return 0
end
-- 3. 原子扣减库存
redis.call('DECRBY', KEYS[1], num)
-- 4. 记录用户已购买
redis.call('SADD', KEYS[2], ARGV[1])
return 1对应的 Java 调用代码:
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.Collections;
@Service
public class SeckillService {
private final StringRedisTemplate redisTemplate;
// 静态加载 Lua 脚本,避免每次传输脚本内容
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(
new org.springframework.core.io.ClassPathResource("lua/seckill_stock.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
public SeckillService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 执行秒杀扣减
* @return 0 成功, 1 库存不足, 2 重复下单, 3 系统异常
*/
public int doSeckill(long seckillId, long userId) {
String stockKey = "seckill:stock:" + seckillId;
String usersKey = "seckill:users:" + seckillId;
try {
Long result = redisTemplate.execute(
SECKILL_SCRIPT,
Arrays.asList(stockKey, usersKey),
String.valueOf(userId),
"1" // 购买数量
);
if (result == null) {
return 3;
}
if (result == 1L) {
return 0; // 扣减成功
} else if (result == 0L) {
return 1; // 库存不足
} else {
return 2; // 重复下单
}
} catch (Exception e) {
// 记录日志,返回系统异常
return 3;
}
}
}这段代码的坑:
SISMEMBER和SADD用的是 Set 结构,如果秒杀商品有几百万用户参与,这个 Set 会占用大量内存。生产环境通常改成布隆过滤器 + 数据库唯一索引双重防重。
- Lua 脚本中
ARGV[1]是字符串,SISMEMBER是精确匹配,没问题;但如果你用tonumber转换用户 ID 要小心精度丢失,用户 ID 建议用字符串存储。
示例二:基于 Redisson 的分布式锁(兜底方案)
Lua 脚本虽然原子,但在跨多个 Redis key 或需要复杂业务逻辑时,分布式锁更合适。Redisson 的锁实现值得深挖,我们看它的核心 tryLockInnerAsync:
// Redisson 源码简化版: RedissonLock.tryLockInnerAsync
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime,
TimeUnit unit, long threadId,
RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
// Lua 脚本: 原子性地尝试加锁
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()),
unit.toMillis(leaseTime), getLockName(threadId));
}设计精妙之处:
- 用 Hash 结构存储锁,key 是锁名,field 是
UUID:threadId,value 是重入次数——天然支持可重入。 - 加锁成功返回 nil,失败返回剩余 TTL,调用方根据返回值决定是否等待。
pexpire每次重入都刷新过期时间,避免业务没执行完锁就过期。
Redisson 的看门狗机制是另一个亮点:如果没指定 leaseTime,默认 30 秒,并且会启动一个后台线程每 10 秒续期一次。源码在 RedissonLock.renewExpiration:
private void renewExpiration() {
Timeout task = commandExecutor.getConnectionManager().newTimeout(
new TimerTask() {
@Override
public void run(Timeout timeout) {
// 续期 Lua: 只有锁还存在且属于当前线程才续期
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (res) {
// 递归调用,继续续期
renewExpiration();
}
});
}
},
internalLockLeaseTime / 3, // 10 秒后续期
TimeUnit.MILLISECONDS
);
}实战建议:秒杀场景下我不推荐用分布式锁。原因很简单——秒杀要的是高吞吐,而分布式锁把并发变成了串行,10万 QPS 全堵在锁上,RT 会飙升到几秒。分布式锁适合"库存扣减结果需要等待、且并发量不高"的场景,比如后台管理系统的库存调整。
示例三:基于令牌桶的 Sentinel 限流 + 预热
秒杀开始瞬间的流量是脉冲式的,必须做前置限流。用 Sentinel 的 WarmUpController 做冷启动预热:
import com.alibaba.csp.sentinel.Entry;
import com.alibaba.csp.sentinel.SphU;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.alibaba.csp.sentinel.slots.block.RuleConstant;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import com.alibaba.csp.sentinel.slots.block.flow.controller.WarmUpController;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
@Component
public class SeckillFlowRuleConfig {
@PostConstruct
public void initFlowRules() {
List<FlowRule> rules = new ArrayList<>();
FlowRule rule = new FlowRule();
rule.setResource("seckill_order"); // 资源名
rule.setGrade(RuleConstant.FLOW_GRADE_QPS); // QPS 限流
rule.setCount(3000); // 峰值 QPS
// 使用 WarmUpController: 冷启动预热
// 参数: 冷启动时长(秒), 冷启动时允许的 QPS 是峰值的 1/3
WarmUpController controller = new WarmUpController(3000, 10, 3);
rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP);
rule.setController(controller);
rules.add(rule);
FlowRuleManager.loadRules(rules);
}
/**
* 秒杀入口: 被 Sentinel 保护的资源
*/
public String seckill(long userId, long seckillId) {
Entry entry = null;
try {
entry = SphU.entry("seckill_order");
// 通过限流,执行真正的秒杀逻辑
return "success";
} catch (BlockException e) {
// 被限流,快速失败
return "rate_limited";
} finally {
if (entry != null) {
entry.exit();
}
}
}
}WarmUpController 的原理(源码级):它内部维护一个令牌桶,但桶的容量不是固定的,而是通过 warningToken 和 maxToken 两个阈值,在冷启动期内逐步放开。公式是:
斜率 = (maxToken - warningToken) / warmUpPeriodInSec
当前允许 QPS = warningToken + (now - startTime) * 斜率这样系统在秒杀开始的前 10 秒,允许的 QPS 从 1000 缓慢爬升到 3000,给 JVM 和连接池一个预热时间,避免刚启动就被打挂。
方案对比:三种库存扣减策略
| 方案 | 一致性 | 吞吐 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
数据库悲观锁 FOR UPDATE |
强一致 | 低(千级 TPS) | 低 | 库存少、并发低的后台场景 |
| Redis Lua 原子扣减 | 最终一致 | 高(10万+ QPS) | 中 | 秒杀、抢购主流程 |
| 分布式锁 + DB | 强一致 | 中(万级 QPS) | 高 | 金融级、不能容忍最终一致 |
为什么秒杀选"最终一致"? 因为秒杀的商品库存是有限且已知的,Redis 预扣成功后,即使消息丢失,也可以通过对账任务从 DB 反推修正。用户看到的是"秒杀成功,订单处理中",而不是"秒杀成功,订单已创建"——这个体验上的妥协,换来了 100 倍的吞吐提升。
最佳实践与避坑指南
1. 库存预热必须做,但要注意时机
秒杀开始前 5 分钟把库存从 DB 加载到 Redis。太早了,可能被其他操作污染;太晚了,预热请求本身会打挂 Redis。用 SETNX 做幂等,避免重复预热。
2. 不要用 DEL 删缓存,用 SET 覆盖
秒杀结束后,不要把 Redis 的库存 key 直接 DEL,而是 SET 成 0 或一个标记值。否则缓存击穿会让大量请求打到 DB。
3. MQ 消息一定要做幂等
Kafka/RocketMQ 都可能重复投递。订单服务的消费逻辑必须用 seckillId + userId 做唯一索引,重复消息直接丢弃。
4. 限流要分层,别只做一层
Nginx 层挡掉 70% 的无效流量(未登录、黑名单),应用层 Sentinel 挡掉 20%(超出承载),剩下 10% 才真正进 Redis。每一层都要有独立的监控告警。
5. 兜底方案:秒杀失败要有友好提示
秒杀失败不是异常,是常态。前端要给"手慢了,下次再来"的引导,而不是 500 错误页。同时记录失败原因(库存不足/被限流/系统繁忙),用于后续优化。
6. 常见坑:Redis Cluster 的 Lua 脚本跨槽问题
如果你的库存 key 和用户 key 在不同槽(slot),Lua 脚本会直接报 CROSSSLOT 错误。解决办法是用 {seckillId} 作为 hash tag,强制两个 key 落在同一槽:
seckill:{1001}:stock
seckill:{1001}:users7. 监控指标必须包括:Redis 慢查询、MQ 堆积量、DB 连接池活跃数
秒杀期间,任何一个指标异常都可能导致雪崩。特别是 Redis 的 slowlog,Lua 脚本执行超过 10ms 就要警惕。
总结
回顾一下秒杀系统架构演进的四个阶段:
- 单体直连 DB:简单但吞吐低,
FOR UPDATE是瓶颈。 - Redis 预扣 + MQ 异步:核心跃迁,吞吐提升 10 倍,引入最终一致性。
- 分层过滤 + 多级缓存:把无效流量挡在最外层,吞吐再提升 5 倍。
- 单元化多活:顶级形态,成本极高,按需选择。
核心思想其实就三条:
- 能异步的绝不同步(MQ 削峰)
- 能内存的绝不落库(Redis 预扣)
- 能前置的绝不后置(分层限流)
延伸思考:秒杀系统的很多设计,在抢红包、限量发售、票务抢座等场景都能复用。但要注意,每个场景的"库存"语义不同——秒杀是"总量扣减",票务是"座位锁定",抢红包是"金额分配"。理解业务语义,比套用架构模板更重要。
最后留一个问题给你:如果秒杀的商品需要跨多个仓库发货,库存该怎么设计?这是一个典型的分布式事务问题,欢迎在评论区讨论。
*本文代码基于 Spring Boot 3.x + Redis 7.x + Redisson 3.24 验证。生产环境请根据实际压测结果调整参数,不要照搬。*