高并发秒杀系统架构设计演进

引言

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 扣库存。

graph TD A[客户端] --> B[Nginx 负载均衡] B --> C[单体应用集群] C --> D[(MySQL 主库)] C --> E[Redis 缓存]

这个阶段的问题非常明显:MySQL 的行锁在 1万并发下会形成锁等待队列,大量请求阻塞在 FOR UPDATE 上,连接池迅速耗尽。而且 SELECT ... FOR UPDATE 是悲观锁,读也要加锁,吞吐极低。

阶段二:Redis 预扣库存 + 异步落库(10万 QPS)

把库存预热到 Redis,用 DECR 原子扣减,扣减成功后发 MQ 异步落库。

graph TD A[客户端] --> B[Nginx + Lua 限流] B --> C[应用集群] C --> D{Redis 预扣库存} D -->|扣减成功| E[发送 MQ 消息] D -->|库存不足| F[返回秒杀失败] E --> G[MQ 削峰] G --> H[订单服务消费] H --> I[(MySQL 落库)]

关键点在于:Redis 是单线程执行命令的,DECR 天然原子,不用加分布式锁。但这里有个坑——如果只用 DECR,会出现"库存扣成负数"的问题,必须配合 Lua 脚本把"判断 + 扣减 + 记录用户"三步做成原子操作。

阶段三:分层过滤 + 多级缓存(50万 QPS)

在 Redis 前面再加一层本地缓存(Caffeine),用布隆过滤器拦截无效请求,用令牌桶做前置限流。

graph TD A[客户端] --> B[CDN 静态化] B --> C[Nginx 层限流] C --> D[应用层本地缓存 Caffeine] D -->|未命中| E[Redis Cluster] E --> F{库存 Lua 脚本} F -->|成功| G[Kafka 削峰] F -->|失败| H[快速失败] G --> I[订单服务] I --> J[(分库分表 MySQL)]

这一层的核心思想是层层过滤,把无效流量挡在越前面越好。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 只是把命令打包,但中间不会执行其他命令——等等,其实这点是一样的。真正的区别是:

  1. Lua 支持条件逻辑:可以在脚本里写 if stock > 0 then ... end,而 MULTI/EXEC 不支持读取结果做判断。
  2. 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));
}

设计精妙之处:

  1. 用 Hash 结构存储锁,key 是锁名,field 是 UUID:threadId,value 是重入次数——天然支持可重入。
  2. 加锁成功返回 nil,失败返回剩余 TTL,调用方根据返回值决定是否等待。
  3. 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}:users

7. 监控指标必须包括:Redis 慢查询、MQ 堆积量、DB 连接池活跃数

秒杀期间,任何一个指标异常都可能导致雪崩。特别是 Redis 的 slowlog,Lua 脚本执行超过 10ms 就要警惕。


总结

回顾一下秒杀系统架构演进的四个阶段:

  1. 单体直连 DB:简单但吞吐低,FOR UPDATE 是瓶颈。
  2. Redis 预扣 + MQ 异步:核心跃迁,吞吐提升 10 倍,引入最终一致性。
  3. 分层过滤 + 多级缓存:把无效流量挡在最外层,吞吐再提升 5 倍。
  4. 单元化多活:顶级形态,成本极高,按需选择。

核心思想其实就三条:

  • 能异步的绝不同步(MQ 削峰)
  • 能内存的绝不落库(Redis 预扣)
  • 能前置的绝不后置(分层限流)

延伸思考:秒杀系统的很多设计,在抢红包、限量发售、票务抢座等场景都能复用。但要注意,每个场景的"库存"语义不同——秒杀是"总量扣减",票务是"座位锁定",抢红包是"金额分配"。理解业务语义,比套用架构模板更重要。

最后留一个问题给你:如果秒杀的商品需要跨多个仓库发货,库存该怎么设计?这是一个典型的分布式事务问题,欢迎在评论区讨论。


*本文代码基于 Spring Boot 3.x + Redis 7.x + Redisson 3.24 验证。生产环境请根据实际压测结果调整参数,不要照搬。*