秒杀促销(限量商品抢购、优惠券秒杀、红包雨)是高并发场景的典型代表,也是系统设计面试中几乎必考的题目。它们的共同特征是库存有限、瞬时流量尖峰、超卖零容忍——只有极少数用户能抢到,但系统要承受全部并发流量。
本文以一个经典的"秒杀三板斧"方案为主线,系统梳理高并发秒杀系统的完整架构:缓存(Redis)承担核心高并发流量,消息队列(MQ)削峰填谷,数据库(DB)保证最终一致性,并延伸到排行榜设计与亿级数据量下的分层计算架构。全文以抢红包为贯穿实例——它是秒杀的一个典型变体(数量与金额预先确定、金额随机拆分),所有思路可直接迁移到商品秒杀、优惠券抢购等通用场景。
业务特征与核心挑战
- 秒杀活动的共同特征:
- 库存预先确定: 发布时即确定限量库存(如 1000 件商品、1000 个红包),抢购过程只是"分配"而非"计算"。
- 瞬时流量尖峰: 热度集中在活动开启的头几分钟,请求量可达平时的百倍以上。
- 一致性要求严格: 库存不能超卖、同一用户不能重复抢、最终账目必须精确对账。
- 三大核心挑战:
- 高并发读写: 大量用户同时请求,单靠数据库必然打挂。
- 资源竞争: 库存扣减必须原子,不能出现"超卖"或"负库存"。
- 最终一致性: Redis 扣减与 DB 落库异步执行,消息不能丢、消费不能重复。
总体架构:Redis 扛流量 · MQ 削峰 · DB 兜底
设计思想一句话总结:Redis 承担核心流量,MQ 削峰,DB 兜底。
各层职责:
| 层次 | 组件 | 核心职责 |
|---|---|---|
| 接入层 | Nginx / API Gateway | 粗粒度限流、防刷、鉴权路由 |
| 业务层 | 秒杀服务 | 令牌桶精控、Lua 原子脚本、事务消息 |
| 缓存层 | Redis | 库存预加载、原子扣减、实时排行、幂等记录 |
| 消息层 | RocketMQ | 事务消息、削峰填谷、死信队列 |
| 存储层 | MySQL | 活动/商品主表、秒杀明细表、本地消息表(兜底) |
秒杀准备:库存预分配
核心思想:库存(数量或金额)在活动发布那一刻就全部确定,抢购时只需要"弹出",不需要实时计算。
- 两种库存形态:
- 红包/随机券码型:每个红包或券码的价值不同,需用二倍均值法预先拆好所有金额,压入 Redis List,抢购时
RPOP弹出即得; - 标准商品型:库存只是纯数量(如 1000 件商品),无需预拆分——初始化一个计数 Key,扣减时原子递减即可,是更通用的形态。
- 红包/随机券码型:每个红包或券码的价值不同,需用二倍均值法预先拆好所有金额,压入 Redis List,抢购时
- 二倍均值法(红包变体):每次随机拆出一个金额,范围在
[0.01, 剩余金额 / 剩余数量 × 2]之间,最后一个红包取剩余金额,保证金额随机且总和精确。 - Redis 预分配结构(以红包为例):
red_packet:{id}:amounts→ List,按序压入拆分好的金额;red_packet:{id}:count→ String,剩余红包数;red_packet:{id}:users→ Set,已抢用户(防重复抢)。
- DB 初始化:写入活动主表记录(库存/总金额、剩余数、版本号),用于最终对账。
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
}
秒杀:核心链路
前置限流:网关粗筛 + 令牌桶精控
- 网关层(Nginx / Gateway):做粗粒度限流,把大部分无效/重复请求挡在门外(全局限流、IP 维度、验证码、风控)。
- 业务层令牌桶:针对单个活动/商品 ID 做令牌桶限流,防止某个爆款商品或超级红包耗尽全部系统资源;只有拿到令牌的请求才能进入抢购环节。
为什么不能只用 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 事务消息(半消息) 的典型应用场景:
- 先发送一条对消费者不可见的"半消息";
- 执行本地事务(执行 Lua 脚本原子扣减库存);
- 本地事务成功 →
COMMIT_MESSAGE,半消息转为对消费者可见; - 本地事务失败 →
ROLLBACK_MESSAGE,消息被丢弃; - 若 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;
}
秒杀完整时序:
消费端异步落库
MQ 把耗时的数据库操作异步化,消费端负责把"抢到商品/红包"这件事真正落到数据库。
幂等消费
MQ 可能重复投递(网络抖动、ACK 丢失、消费者 Rebalance),消费端必须具备幂等能力:
- 消费前执行
SETNX consume:seckill:{activityId}:{userId} 1 EX 86400,设置成功才继续处理; - 处理成功并落库后再向 Broker 返回 ACK,切忌"拿到消息即 ACK"。
乐观锁更新库存
在 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};
失败重试与死信队列
- 消费失败进入重试队列,指数退避重试;
- 超过最大重试次数进入死信队列(DLQ),记录日志等待人工介入,避免阻塞正常消费。
降级与兜底方案
系统不能假设所有组件永远可用,必须准备降级路径:
- Redis 降级:Redis 不可用时降级为直接操作数据库(性能骤降,但核心功能可用)。
- 本地消息表补偿:作为事务消息的兜底方案——生产端在 DB 记录消息的"待发送 / 已发送 / 已确认"状态,定时任务扫描超时未确认的消息,重试发送或回调确认。
- 限流熔断:流量超过系统承载阈值时直接返回"已抢完",保证系统整体不被打挂。
排行榜设计
以红包为代表的秒杀活动通常附带"手气榜",其他场景(助力榜、消费榜)同理。针对"几千万条记录查排名"这类需求,绝对不能直接在数据库用 ORDER BY ... LIMIT 实时查询——会触发全表文件排序(Using filesort),直接打挂数据库。
按实时性要求与内存成本权衡,有三种方案:
方案一:Redis ZSET 实时精确排名(推荐)
- 写入:抢到那一刻,在 Lua 脚本中同步执行
ZADD rank:{activityId} {amount} {userId}(与扣减在同一个原子脚本内,无额外开销); - 查询:
ZREVRANK rank:{activityId} {userId},时间复杂度 O(log N),2000 万数据毫秒级返回; - 内存优化(关键):
- 只存活跃期:活动结束立即导出到 DB 并删除 Key(过期后没人关心实时排名);
- 整数 User ID:value 用整型,不要存 JSON 字符串,可节省大量内存;
- 分桶兜底:只需要展示 Top N 榜单时,只维护前 N 名明细,其余用户走分桶近似。
方案二:DB 分区 + 覆盖索引(降级兜底)
Redis 内存紧张、必须依赖 DB 时,利用联合索引 + 分区把扫描范围缩小到单个活动:
SELECT COUNT(1) + 1
FROM red_packet_records
WHERE red_packet_id = ? AND amount > #{myAmount};
- 建立联合索引
(red_packet_id, amount DESC, user_id),red_packet_id作为第一条件走索引范围扫描; - 单个活动参与量极大时,按
user_id哈希分区拆小索引树; - 代价是仍需扫描半个分区,响应在几十到几百毫秒,仅适合后台非核心查询,不适合用户实时查看。
方案三:Redis 分桶近似排名(超大规模)
数据量极大(单活动数亿人参与)时,精确 ZSET 内存爆炸,可以牺牲极小精度换取内存指数级下降:
- 在 Redis 维护一个 Hash:
rank:bucket:{activityId},field 为金额区间(如10-20),value 为该区间人数; - 每次抢到执行
HINCRBY rank:bucket:{id} {区间} 1; - 查询排名 = 金额大于我的区间总人数 + 我所在区间内更高的人数(区间内可遍历小范围 List 或 DB Count);
- 内存从 GB 级降到 KB 级,返回"您大约排在 18,234 名,击败了 95% 的用户"——这是微信红包、拼多多助力等超大规模活动的业界通行做法。
三种方案对比:
| 方案 | 实时性 | 精确度 | 内存/存储成本 | 适用规模 |
|---|---|---|---|---|
| ZSET 精确排名 | 毫秒级 | 精确 | GB 级(2000 万约 1.5~2GB) | 百万 ~ 千万级 |
| DB 分区 + 覆盖索引 | 百毫秒 ~ 秒 | 精确 | 依赖 DB 索引 | 单个活动数十万级 |
| 分桶近似排名 | 毫秒级 | 近似 | KB 级 | 亿级 |
并列金额处理:若要求"金额相同按时间先后排名",ZSET 的 Score 可设计为
金额 + 时间戳倒数(如金额.999999-时间戳),或将两个维度拆开存储。
更大数据量:分层计算架构
当数据量从 2000 万膨胀到亿级、十亿级,单点 Redis ZSET 会面临两个致命问题:
- 内存爆炸:亿级成员内存直奔 10~20GB,且 RDB 持久化时主线程卡顿;
- 大 Key 阻塞:ZSET 过大,
ZADD/ZREVRANK的跳表查找与网络传输显著变慢。
正确解法是 时间分片 + 冷热分离,演进为"热数据实时(Redis)+ 温数据准实时(OLAP)+ 冷数据离线(Hive)“三层架构:
- 热数据(实时):只把最近 1 小时/1 天的记录存入 Redis ZSET,Key 带时间戳后缀(如
rank:act:123:20260809)+ TTL,成员数被限定在百万级以内,内存可控、查询极快; - 温数据(准实时):过期明细经 MQ 流入 Flink 流式计算(若要求秒级精确更新),聚合结果与历史明细写入 ClickHouse / Doris 列式存储,单表亿级
COUNT控制在秒级以内; - 冷数据(离线):每日 T+1 用 Spark 批处理对全量数据做全局排序,生成"最终排行榜快照”(如"昨日手气王 Top 100")存入 Hive,供历史榜单展示与最终校正;
- 查询逻辑:应用层并行查询——Redis 拿实时排名(毫秒级)+ ClickHouse 拿历史累计(秒级),再合并返回;
- Flink 准实时:MQ 消息同时流入 Flink,维护以"活动 ID + 金额"为 Key 的状态累加计数,每 1~5 秒批量写一次 Redis,把计算压力从 Redis/DB 转移给流计算,做到准实时(延迟 5 秒以内)且支持亿级数据。
核心结论:数据量再大,也不能只依赖离线计算支撑线上实时查询(那会导致用户体验极差)。正确策略是:牺牲长尾用户的绝对精确性,换取核心头部用户的实时性——线上走实时/准实时路径,离线只做 T+1 归档与最终精确校正。
总结
- 秒杀是高并发系统架构的标准范式:预分配 → 限流 → 原子扣减 → 异步落库 → 分层排行;
- Redis 用 Lua 脚本保证原子性,RocketMQ 用事务消息保证一致性,DB 用乐观锁 + 幂等保证最终一致性,层层递进、各司其职;
- 排行榜按数据规模选型:百万级用 ZSET 精确排名,千万级用 分桶近似 + DB 兜底,亿级用"热实时 + 温准实时 + 冷离线“三层架构;
- 任何高并发系统都要预留降级与兜底路径(Redis 降级、本地消息表补偿、限流熔断),保证极端情况下核心功能可用。