秒杀促销(限量商品抢购、优惠券秒杀、红包雨)是高并发场景的典型代表,也是系统设计面试中几乎必考的题目。它们的共同特征是库存有限、瞬时流量尖峰、超卖零容忍——只有极少数用户能抢到,但系统要承受全部并发流量。

本文以一个经典的"秒杀三板斧"方案为主线,系统梳理高并发秒杀系统的完整架构:缓存(Redis)承担核心高并发流量,消息队列(MQ)削峰填谷,数据库(DB)保证最终一致性,并延伸到排行榜设计与亿级数据量下的分层计算架构。全文以抢红包为贯穿实例——它是秒杀的一个典型变体(数量与金额预先确定、金额随机拆分),所有思路可直接迁移到商品秒杀、优惠券抢购等通用场景。


业务特征与核心挑战

总体架构:Redis 扛流量 · MQ 削峰 · DB 兜底

D2 Diagram
qtopie.github.io

设计思想一句话总结:Redis 承担核心流量,MQ 削峰,DB 兜底。

各层职责:

层次组件核心职责
接入层Nginx / API Gateway粗粒度限流、防刷、鉴权路由
业务层秒杀服务令牌桶精控、Lua 原子脚本、事务消息
缓存层Redis库存预加载、原子扣减、实时排行、幂等记录
消息层RocketMQ事务消息、削峰填谷、死信队列
存储层MySQL活动/商品主表、秒杀明细表、本地消息表(兜底)

秒杀准备:库存预分配

核心思想:库存(数量或金额)在活动发布那一刻就全部确定,抢购时只需要"弹出",不需要实时计算。

public void preSplit(BigDecimal total, int count) {
    List<BigDecimal> amounts = new ArrayList<>(count);
    BigDecimal remain = total;
    int remainCount = count;
    for (int i = 0; i < count - 1; i++) {
        // 二倍均值法:随机范围 [0.01, 剩余均值 × 2]
        BigDecimal max = remain.divide(BigDecimal.valueOf(remainCount), 2, RoundingMode.DOWN)
                               .multiply(BigDecimal.valueOf(2));
        BigDecimal amount = randomAmount(new BigDecimal("0.01"), max);
        amounts.add(amount);
        remain = remain.subtract(amount);
        remainCount--;
    }
    amounts.add(remain); // 最后一个红包 = 剩余金额,保证总和精确
    // LPUSH red_packet:{id}:amounts ...  批量压入 List
}

秒杀:核心链路

前置限流:网关粗筛 + 令牌桶精控

为什么不能只用 SETNX

SETNX 只能解决"用户是否已抢"的记录,但高并发下存在致命缺陷:

Lua 脚本:原子化「检查 - 弹出 - 记录」

正确做法是使用 Redis Lua 脚本,在 Redis 服务端把多个操作封装成一个原子单元,天然避免并发竞争。下面以红包为例(标准商品型把 RPOP 换成 DECRBY 即可):

-- KEYS[1]: 红包金额 List   red_packet:{id}:amounts
-- KEYS[2]: 已抢用户 Set    red_packet:{id}:users
-- ARGV[1]: 用户 ID
-- ARGV[2]: 红包 ID
-- 返回: 金额(成功) / nil(抢完) / -1(已抢过)

if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
    return -1                          -- 已抢过
end
local count = redis.call('LLEN', KEYS[1])
if count == 0 then
    return nil                         -- 已抢完
end
local amount = redis.call('RPOP', KEYS[1])                        -- 弹出金额
redis.call('SADD', KEYS[2], ARGV[1])                              -- 记录用户
redis.call('ZADD', 'rank:' .. ARGV[2], amount, ARGV[1])           -- 同步维护排行
return amount

Redis 单线程执行脚本,脚本内所有操作一气呵成,绝无竞态

事务消息:保证「Redis 扣减」与「MQ 投递」一致

秒杀成功需要"先扣 Redis,再发 MQ”,两个动作要么都成功、要么都失败——这正是 RocketMQ 事务消息(半消息) 的典型应用场景:

  1. 先发送一条对消费者不可见的"半消息";
  2. 执行本地事务(执行 Lua 脚本原子扣减库存);
  3. 本地事务成功 → COMMIT_MESSAGE,半消息转为对消费者可见;
  4. 本地事务失败 → ROLLBACK_MESSAGE,消息被丢弃;
  5. 若 Broker 长时间未收到提交结果,会主动回查本地事务状态,保证最终一致。
// 1. 发送半消息(内部先落半消息,再回调本地事务执行器)
SendResult result = producer.sendMessageInTransaction(txMsg, null);

// 2. 本地事务执行器:执行 Lua 抢红包
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
    Long amount = grabInRedis(packetId, userId);   // 执行 Lua 脚本
    if (amount != null) {
        return LocalTransactionState.COMMIT_MESSAGE;   // 扣减成功 → 消息可见
    }
    return LocalTransactionState.ROLLBACK_MESSAGE;     // 未抢到 → 丢弃消息
}

// 3. 事务回查:Broker 发现状态未知时回调
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
    return userGrabbed(packetId, userId)
        ? LocalTransactionState.COMMIT_MESSAGE
        : LocalTransactionState.ROLLBACK_MESSAGE;
}

秒杀完整时序:

D2 Diagram
qtopie.github.io

消费端异步落库

MQ 把耗时的数据库操作异步化,消费端负责把"抢到商品/红包"这件事真正落到数据库。

幂等消费

MQ 可能重复投递(网络抖动、ACK 丢失、消费者 Rebalance),消费端必须具备幂等能力

乐观锁更新库存

在 MQ 消费端更新库存时,不要使用分布式锁——加锁/释放锁的串行化会成为吞吐瓶颈。应使用乐观锁:把剩余数量或版本号写进 WHERE 条件,影响行数为 0 说明数据已被并发修改,直接重试:

UPDATE seckill_activity
SET remain_count = remain_count - 1,
    version = version + 1
WHERE id = #{activityId}
  AND remain_count > 0
  AND version = #{oldVersion};

失败重试与死信队列

降级与兜底方案

系统不能假设所有组件永远可用,必须准备降级路径:

排行榜设计

以红包为代表的秒杀活动通常附带"手气榜",其他场景(助力榜、消费榜)同理。针对"几千万条记录查排名"这类需求,绝对不能直接在数据库用 ORDER BY ... LIMIT 实时查询——会触发全表文件排序(Using filesort),直接打挂数据库。

实时性要求内存成本权衡,有三种方案:

方案一:Redis ZSET 实时精确排名(推荐)

方案二:DB 分区 + 覆盖索引(降级兜底)

Redis 内存紧张、必须依赖 DB 时,利用联合索引 + 分区把扫描范围缩小到单个活动:

SELECT COUNT(1) + 1
FROM red_packet_records
WHERE red_packet_id = ? AND amount > #{myAmount};

方案三:Redis 分桶近似排名(超大规模)

数据量极大(单活动数亿人参与)时,精确 ZSET 内存爆炸,可以牺牲极小精度换取内存指数级下降

三种方案对比:

方案实时性精确度内存/存储成本适用规模
ZSET 精确排名毫秒级精确GB 级(2000 万约 1.5~2GB)百万 ~ 千万级
DB 分区 + 覆盖索引百毫秒 ~ 秒精确依赖 DB 索引单个活动数十万级
分桶近似排名毫秒级近似KB 级亿级

并列金额处理:若要求"金额相同按时间先后排名",ZSET 的 Score 可设计为 金额 + 时间戳倒数(如 金额.999999-时间戳),或将两个维度拆开存储。

更大数据量:分层计算架构

当数据量从 2000 万膨胀到亿级、十亿级,单点 Redis ZSET 会面临两个致命问题:

  1. 内存爆炸:亿级成员内存直奔 10~20GB,且 RDB 持久化时主线程卡顿;
  2. 大 Key 阻塞:ZSET 过大,ZADD / ZREVRANK 的跳表查找与网络传输显著变慢。

正确解法是 时间分片 + 冷热分离,演进为"热数据实时(Redis)+ 温数据准实时(OLAP)+ 冷数据离线(Hive)“三层架构:

D2 Diagram
qtopie.github.io

核心结论:数据量再大,也不能只依赖离线计算支撑线上实时查询(那会导致用户体验极差)。正确策略是:牺牲长尾用户的绝对精确性,换取核心头部用户的实时性——线上走实时/准实时路径,离线只做 T+1 归档与最终精确校正。

总结