摘要:秒杀不只是"扣库存"三个字——从 Redis Lua 原子操作到 Stream 削峰,从 Redisson 分布式锁到 MySQL 乐观锁兜底,再到延迟队列超时释放,每一层都在防不同的漏洞。本文用 O2O 平台真实秒杀链路,拆解五层防护的完整设计和代码实现,读完你能从架构视角讲清楚一条秒杀请求走过的全部路径。

前言
先问你一个问题。
一个秒杀券,100 个库存。1000 个人同时点"抢购"。库存扣到 0 的那一刻,系统有没有可能多扣一次,把第 101 个人的订单也生成了?
如果你的答案是"不会,我用 Redis 扣库存是原子的"——那再问一个。
Redis 扣库存成功,消息投递成功。但下游消费者写 MySQL 的时候,这条 INSERT 失败了(主键冲突)。Redis 里的库存已经少了 1,MySQL 里却没有这条订单。 库存黑洞。怎么补?
这两问,就是秒杀系统的核心难点。不是"能不能扣库存",而是每一层出问题,下一层怎么兜底。
这篇文章,我把 O2O 平台里秒杀链路的五层防护拆开,从第一行 Lua 代码讲到最后的定时对账。每一层都在防什么、怎么实现的、如果它失败了下一层怎么接——全部讲清楚。
一、秒杀到底难在哪?
先给问题画个边界。秒杀不是"高并发扣库存"六个字能概括的——它是一个多维度的防御战:
┌─ 超卖 ────────── 库存扣成负数
│
用户抢购 ──→ 秒杀系统 ─┼─ 重复下单 ────── 同一人同一券买多次
│
├─ 瞬时流量 ────── 1000 QPS 打崩 DB
│
├─ 库存黑洞 ────── Redis扣了 MySQL没写
│
└─ 库存浪费 ────── 用户抢到不支付,库存被锁死
五个问题,五种武器。这就是五层防护的由来:
用户请求
│
▼
┌─ 第一层:Redis Lua 原子操作 ──────── 防超卖 + 防重复 + 削峰前拦截 ─┐
│ │
├─ 第二层:Redis Stream + Consumer Group ─ 异步落库,削峰填谷 ──────┤
│ │
├─ 第三层:Redisson 分布式锁 ─────────── DB层二次去重 ───────────────┤
│ │
├─ 第四层:MySQL 乐观锁 ─────────────── 条件更新,最终兜底 ──────────┤
│ │
├─ 第五层:Redisson 延迟队列 ────────── 超时释放 + 候补补位 ─────────┤
└───────────────────────────────────────────────────────────────────┘
│
▼
定时对账(异步兜底:比对 Redis 和 MySQL 库存差异,回补)
下面逐层拆解。每一层都有完整代码,以及"如果它失败了,下一层怎么办"的设计逻辑。
二、第一层:Redis Lua —— 原子校验 + 扣减 + 投递,一气呵成
2.1 为什么必须是 Lua?
先看不用 Lua 会怎样:
// ❌ 三步分开执行,每一步之间都可能被其他请求插队
Long stock = redis.opsForValue().get("seckill:stock:10086"); // 读到 stock=1
// ← 另一个请求也读到了 stock=1
if (stock != null && stock > 0) {
redis.opsForValue().decrement("seckill:stock:10086"); // 两个请求都执行了扣减
// → 超卖!库存变成 -1
}
Redis 的单条命令(GET、DECR、SADD)是原子的,但多条命令的组合不是原子操作。两个请求可以在 GET 和 DECR 之间交错执行——这就是超卖的根源。
Lua 脚本的解决方案:把多步操作打包成一个脚本,Redis 单线程顺序执行,中间不处理任何其他命令。
2.2 完整 Lua 脚本
— KEYS[1]: 库存key 例 "seckill:stock:10086"
— KEYS[2]: 已购用户集合 例 "seckill:user:10086"
— KEYS[3]: 候补队列 例 "seckill:waiting:10086"
— KEYS[4]: 订单Stream 例 "seckill:order:stream"
— ARGV[1]: 用户ID
— ARGV[2]: 订单ID(雪花算法,业务层提前生成)
— ARGV[3]: 秒杀券ID(传递给下游消费者,用于库存扣减和去重)
— ===== 第一道防线:库存校验 =====
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
— 库存没了 → 进候补队列,按时间戳排序
redis.call('ZADD', KEYS[3], redis.call('TIME')[1], ARGV[1] .. ':' .. ARGV[2])
return {–1, 'no_stock_and_queued'}
end
— ===== 第二道防线:一人一单去重 =====
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return {0, 'already_bought'}
end
— ===== 第三道防线:扣库存 + 标记已购 + 投递消息 =====
— 这三步必须同时完成,不可拆分
— XADD 中携带 voucherId,下游消费者才能知道是哪个券的订单
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
redis.call('XADD', KEYS[4], '*',
'userId', ARGV[1],
'orderId', ARGV[2],
'voucherId', ARGV[3])
return {1, 'success'}
2.3 关键设计决策
校验放前面,不可逆操作放最后。 这是 Lua 脚本最重要的安全原则。Redis Lua 没有回滚——脚本里已经执行的命令不会被撤销。所以我把库存检查、用户去重全部放在前面,只有全部通过才执行 DECR 和 XADD。这样即使脚本跑到一半 Redis 挂了,也不会出现"库存扣了但订单没投递"的半成品状态。
用 DECR 而不是 GET+SET。 DECR 本身就是原子操作——一条命令完成"读取+减1+写回",不需要先 GET 再 SET。
XADD 放在脚本内,且携带 voucherId。 这保证了"扣库存成功 = 消息一定投递到了 Stream",同时下游消费者拿到消息后知道该扣哪个券的库存。不会出现库存扣了但消息没发的情况——这和两阶段事务追求的是同一个目标,但实现路径完全不同。
2.4 Java 调用代码
@Service
public class SeckillService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private Snowflake snowflake; // 雪花算法ID生成器
// Lua 脚本对象,启动时加载,避免每次 EVAL 都传脚本正文
private final RedisScript<List<Long>> seckillScript;
public SeckillService() {
DefaultRedisScript<List<Long>> script = new DefaultRedisScript<>();
script.setLocation(new ClassPathResource("lua/seckill.lua"));
script.setResultType(List.class);
this.seckillScript = script;
}
/**
* 秒杀入口 —— 第一层防护。
* 返回结果:1=成功 0=已购买 -1=无库存已进候补
*/
public int trySeckill(Long voucherId, Long userId) {
// 雪花算法生成全局唯一订单ID
long orderId = snowflake.nextId();
List<Long> result = redisTemplate.execute(
seckillScript,
Arrays.asList(
"seckill:stock:" + voucherId, // KEYS[1]
"seckill:user:" + voucherId, // KEYS[2]
"seckill:waiting:" + voucherId, // KEYS[3]
"seckill:order:stream" // KEYS[4]
),
userId.toString(), // ARGV[1]
String.valueOf(orderId), // ARGV[2]
voucherId.toString() // ARGV[3] ← 传给下游
);
return result.get(0).intValue(); // 1 / 0 / -1
}
}
到这,第一层防住了什么?
- ✅ 超卖:Lua 原子性 → 不可能两个请求同时扣库存
- ✅ 重复下单:SISMEMBER → 每人每券限购一次
- ✅ 库存不足:直接进候补,不浪费后续资源
第一层防不住的? 扣了 Redis 库存,消息投到 Stream 了——但下游消费失败,MySQL 没写进去。这就是第二层要解决的问题。
三、第二层:Redis Stream + Consumer Group —— 异步削峰,可靠消费
3.1 为什么不用 RabbitMQ?
秒杀链路已经重度依赖 Redis——库存、去重、候补全在 Redis 里。如果消息投递也走 Redis Stream,XADD 可以放在 Lua 脚本里,和其他操作原子完成。换成 RabbitMQ,消息发送就必须独立出来,打破了原子性。
另外,引入 RabbitMQ 就多了一个外部中间件要维护。对于秒杀链路——它本身已经是"高并发快速通道"——少一个外部依赖,就少一个故障点。
3.2 初始化:创建 Consumer Group
Consumer Group 需要在消费之前创建。放在 @PostConstruct 里,启动时自动执行,幂等安全(MKSTREAM 保证 key 不存在时自动创建 Stream):
@PostConstruct
public void initStream() {
try {
// XGROUP CREATE <key> <group> <id> MKSTREAM
// MKSTREAM:如果 Stream 不存在则自动创建,避免第一次消费报错
redisTemplate.opsForStream().createGroup(STREAM_KEY, GROUP_NAME);
} catch (RedisSystemException e) {
// BUSYGROUP → Consumer Group 已存在,忽略
log.info("Consumer Group 已存在,跳过创建");
}
}
3.3 Stream 的可靠性从哪来?
三个机制:
① Consumer Group:多个消费者实例共享一个消费组,Stream 自动负载均衡分配消息。消费者宕机 → 它持有的未确认消息自动分配给组内其他消费者。
② Pending 重试:消费者拿到消息后手动 ACK。如果消费失败不 ACK,消息留在 Pending 列表里。后台定时任务扫 Pending,超时未确认的消息重新投递。
③ 持久化:Stream 数据受 Redis 的 RDB/AOF 保护。Redis 重启后消息不丢。
3.4 消费者代码
@Slf4j
@Component
public class SeckillOrderConsumer {
@Autowired
private SeckillOrderService seckillOrderService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String STREAM_KEY = "seckill:order:stream";
private static final String GROUP_NAME = "order-consumer-group";
// 消费者名用固定标识,重启后保持不变,避免 Pending 消息被遗弃
// 生产环境可用主机名或实例ID,这里用固定值示意
private static final String CONSUMER_NAME = "consumer-01";
/**
* 消费秒杀订单消息 —— 异步落库。
* 每 100ms 拉一次,平衡延迟和 CPU 空转。
*/
@Scheduled(fixedDelay = 100)
public void consumeOrderMessages() {
try {
// XREADGROUP GROUP <group> <consumer> BLOCK 2000 STREAMS <key> >
// ">" 表示只读新消息,不读 Pending
List<MapRecord<String, Object, Object>> records =
redisTemplate.opsForStream().read(
Consumer.from(GROUP_NAME, CONSUMER_NAME),
StreamReadOptions.empty().count(10).block(Duration.ofSeconds(2)),
StreamOffset.create(STREAM_KEY, ReadOffset.from(">"))
);
if (records == null || records.isEmpty()) return;
for (MapRecord<String, Object, Object> record : records) {
Map<Object, Object> payload = record.getValue();
Long userId = Long.valueOf(payload.get("userId").toString());
Long orderId = Long.valueOf(payload.get("orderId").toString());
Long voucherId = Long.valueOf(payload.get("voucherId").toString());
try {
// → 进入第三层+第四层:分布式锁 + 乐观扣库存
seckillOrderService.createOrder(userId, voucherId, orderId);
// ACK:通知 Stream 这条消息消费成功
redisTemplate.opsForStream().acknowledge(STREAM_KEY, GROUP_NAME,
record.getId().getValue());
} catch (Exception e) {
// 不 ACK → 消息留在 Pending → Pending 重试兜底
log.error("秒杀订单消费失败 orderId={}", orderId, e);
}
}
} catch (Exception e) {
log.error("Stream 消费异常", e);
}
}
/**
* Pending 重试:每分钟扫一次未确认的消息,重新处理。
*/
@Scheduled(fixedDelay = 60000)
public void retryPendingMessages() {
// XREADGROUP … STREAMS <key> 0 → "0" 表示只读 Pending 列表
List<MapRecord<String, Object, Object>> pending =
redisTemplate.opsForStream().read(
Consumer.from(GROUP_NAME, CONSUMER_NAME),
StreamReadOptions.empty().count(50),
StreamOffset.create(STREAM_KEY, ReadOffset.from("0"))
);
if (pending == null || pending.isEmpty()) return;
log.warn("Stream Pending 重试,共 {} 条", pending.size());
for (MapRecord<String, Object, Object> record : pending) {
Map<Object, Object> payload = record.getValue();
Long userId = Long.valueOf(payload.get("userId").toString());
Long orderId = Long.valueOf(payload.get("orderId").toString());
Long voucherId = Long.valueOf(payload.get("voucherId").toString());
try {
seckillOrderService.createOrder(userId, voucherId, orderId);
redisTemplate.opsForStream().acknowledge(STREAM_KEY, GROUP_NAME,
record.getId().getValue());
} catch (Exception e) {
log.error("Pending 重试失败 orderId={}", orderId, e);
}
}
}
}
到这,第二层防住了什么?
- ✅ 削峰填谷:DB 不会被瞬时流量打垮,消息在 Stream 里排队
- ✅ 消费可靠:ACK + Pending 重试,消息不会丢
第二层防不住的? 消费者拿到了消息,成功写入了 DB——但如果同一个用户发了两个请求,第一层 Lua 没拦住(极端情况:未来架构升级成 Redis Cluster 后跨 slot 的并发写入),第二层也拦不住重复。这就是第三层要做的。
四、第三层:Redisson 分布式锁 —— DB 层二次去重
4.1 为什么需要第二道去重?
第一层的 SISMEMBER 已经做了去重。但分布式锁的目的是兜底:万一 Redis Set 和 DB 之间因为网络抖动导致的不一致,或是未来架构升级成 Redis Cluster 后跨 slot 的并发写入,第二道去重在 DB 层再拦一次。
锁的粒度是"用户ID + 秒杀券ID"——同一个用户同一张券,只放一个请求进 DB。
4.2 Redisson 实现
@Service
public class SeckillOrderService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private SeckillOrderMapper orderMapper;
@Autowired
private SeckillVoucherMapper voucherMapper;
@Autowired
private OrderTimeoutHandler timeoutHandler;
/**
* 创建秒杀订单 —— 第三层 + 第四层防护。
* @param userId 用户ID
* @param voucherId 秒杀券ID
* @param orderId 雪花算法生成的订单ID
*/
public void createOrder(Long userId, Long voucherId, Long orderId) {
// 锁粒度:用户+券,确保同一人同一券只进一次DB
String lockKey = "seckill:lock:" + userId + ":" + voucherId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 不等待:拿不到锁说明已经有请求在处理了,直接返回
if (!lock.tryLock(0, 10, TimeUnit.SECONDS)) {
log.info("获取锁失败,已有请求在处理 userId={}, voucherId={}", userId, voucherId);
return;
}
// ===== 第三层:DB层去重检查 =====
int count = orderMapper.countByUserAndVoucher(userId, voucherId);
if (count > 0) {
log.info("DB层去重命中,订单已存在 userId={}, voucherId={}", userId, voucherId);
return;
}
// ===== 第四层:乐观锁扣库存 =====
int affected = voucherMapper.deductStock(voucherId);
if (affected == 0) {
log.error("库存扣减失败,乐观锁检测到库存不足 voucherId={}", voucherId);
// TODO: 告警 + 回补 Redis 库存
return;
}
// 创建订单
SeckillOrder order = new SeckillOrder();
order.setId(orderId);
order.setUserId(userId);
order.setVoucherId(voucherId);
order.setStatus(0); // 0=待支付
order.setCreateTime(new Date());
orderMapper.insert(order);
// 投递 15 分钟超时检查任务(第五层)
timeoutHandler.submitTimeoutCheck(voucherId, orderId);
} catch (Exception e) {
log.error("创建秒杀订单异常", e);
throw e;
} finally {
// 只有自己持有的锁才释放
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
4.3 为什么用 Redisson 而不是手写 SETNX?
| 锁过期 | 手动设 TTL,业务跑久了锁自动释放 → 并发问题 | WatchDog 自动续期(每 10 秒续到 30 秒) |
| 解锁安全 | 直接 DEL 可能误删别人的锁 | Lua 脚本校验持有者再删 |
| 可重入 | 要自己实现计数器 | 内置,同一线程可多次获取 |
| 故障恢复 | 主节点宕机锁丢失 | 红锁(RedLock)可选,但一般不用 |
核心区别是 WatchDog:拿到锁后启动后台定时任务自动续期——只要 JVM 不挂,锁永远不过期。SETNX 设 10 秒 TTL,万一线程 GC 停顿了 11 秒,锁在第 10 秒自动释放,另一个请求拿到了锁——并发冲突就来了。
面试被追问"Redisson 怎么实现可重入":底层用 Redis Hash,hset lockKey threadId 1。同一线程再次加锁时 hincrby lockKey threadId 1,计数器 +1。释放时计数器 -1,归零后才真正 DEL。这和 JUC 的 ReentrantLock 思想一样——state 计数器 + 持有者线程记录。
五、第四层:MySQL 乐观锁 —— 条件更新,最终兜底
5.1 为什么 Redis 扣了还要 MySQL 再兜底?
前三层都在 Redis 和 Java 层。但库存的"真相"在哪里?MySQL。
如果 Redis 和 MySQL 的库存产生了差异(Redis 多扣了、Stream 消息丢了、消费者崩了没 ACK),最终兜底的权威数据源必须是 MySQL。乐观锁不是性能优化的手段——它是数据一致性的最后一道防线。
5.2 条件更新 SQL
@Mapper
public interface SeckillVoucherMapper {
/**
* 乐观锁扣库存 —— 第四层防护。
* WHERE stock > 0 是关键:库存为 0 时 affected_rows = 0,不会扣成负数。
* MySQL 行锁保证了这条 UPDATE 的原子性。
*/
@Update("UPDATE seckill_voucher " +
"SET stock = stock – 1 " +
"WHERE voucher_id = #{voucherId} AND stock > 0")
int deductStock(@Param("voucherId") Long voucherId);
// 返回 affected_rows:1 = 扣减成功 0 = 库存不足
}
stock > 0 这个条件是整个秒杀系统的最后防线。 即使前面的 Lua、去重、分布式锁全部被绕过了,这行 SQL 不会让库存变成负数。
5.3 意外发生了怎么回补?
四种情况对应四种回补策略:
| Redis 扣了,MySQL 没扣 | Redis 库存 < MySQL 库存 | 释放 Redis 多扣的库存 |
| Redis 没扣,MySQL 扣了 | MySQL 库存 < Redis 库存 | 释放 MySQL 多扣的库存(罕见) |
| 用户 15 分钟不支付 | 库存被锁 | 第五层延迟队列自动释放 |
| 候补队列里的人没被通知 | 有人排队但没拿到 | 第五层释放库存后自动补位 |
这个回补逻辑不要求实时——定时对账任务每分钟跑一次,比对差异后补偿。秒杀业务允许"少卖了几张券后补回来",不允许"多卖了券收不回来"。
六、第五层:Redisson 延迟队列 —— 超时释放 + 候补补位
6.1 解决的问题
用户抢到秒杀券,15 分钟内没支付。库存已经减了 1——这笔库存被"锁死"了。如果一直不释放,后面的用户看到库存为 0 就放弃了。
延迟队列的做法:下单成功后,往队列里丢一个"15 分钟后检查"的任务。到时间了检查支付状态——没支付就释放库存,同时通知候补队列里的第一个人。
6.2 延迟队列的实现
Redisson 延迟队列底层是 ZSet + PUB/SUB。ZSet 的 score 存到期时间戳,定时轮询 score <= now 的元素,通过 PUB/SUB 推送。
@Component
public class OrderTimeoutHandler {
@Autowired
private RedissonClient redissonClient;
@Autowired
private SeckillOrderMapper orderMapper;
@Autowired
private SeckillVoucherMapper voucherMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 下单成功后,投递一个 15 分钟后触发的延迟任务。
* 在 SeckillOrderService.createOrder() 中调用。
*/
public void submitTimeoutCheck(Long voucherId, Long orderId) {
RBlockingQueue<String> blockingQueue =
redissonClient.getBlockingQueue("seckill:timeout:queue");
RDelayedQueue<String> delayedQueue =
redissonClient.getDelayedQueue(blockingQueue);
// 消息体:voucherId:orderId,handleTimeout 中解析
delayedQueue.offer(voucherId + ":" + orderId, 15, TimeUnit.MINUTES);
}
/**
* 监听延迟队列,处理超时释放。
*/
@PostConstruct
public void startTimeoutListener() {
new Thread(() -> {
RBlockingQueue<String> blockingQueue =
redissonClient.getBlockingQueue("seckill:timeout:queue");
while (true) {
try {
// take() 阻塞等待到期的元素
String msg = blockingQueue.take();
String[] parts = msg.split(":");
Long voucherId = Long.valueOf(parts[0]);
Long orderId = Long.valueOf(parts[1]);
handleTimeout(voucherId, orderId);
} catch (Exception e) {
log.error("超时释放处理异常", e);
}
}
}, "order-timeout-handler").start();
}
private void handleTimeout(Long voucherId, Long orderId) {
// 1. 查订单状态
SeckillOrder order = orderMapper.selectById(orderId);
if (order == null || order.getStatus() != 0) {
return; // 已支付或已取消,不处理
}
// 2. 标记订单为超时取消
orderMapper.updateStatus(orderId, –1); // -1=超时取消
// 3. 回补 MySQL 库存
voucherMapper.addStock(voucherId, 1);
// 4. 回补 Redis 库存
redisTemplate.opsForValue().increment("seckill:stock:" + voucherId);
// 5. 通知候补队列第一个人(ZSet 按 score 排序取第一个)
Set<String> waiting = redisTemplate.opsForZSet()
.range("seckill:waiting:" + voucherId, 0, 0);
if (waiting != null && !waiting.isEmpty()) {
// ZSet member 格式为 "userId:orderId",解析出 userId
String member = waiting.iterator().next();
Long waitingUserId = Long.valueOf(member.split(":")[0]);
// 推送通知:你排到了!(短信/推送,此处省略具体实现)
log.info("候补补位 voucherId={}, waitingUserId={}", voucherId, waitingUserId);
redisTemplate.opsForZSet()
.remove("seckill:waiting:" + voucherId, member);
}
}
}
七、全局视角:一次完整秒杀请求的时间线
把所有层串起来,一条请求从头到尾经历了什么:
⏰ T+0ms
用户点击"抢购" voucherId=10086
├─→ Java 层:雪花算法生成 orderId=987654321
└─→ Redis EVAL seckill.lua
├─ GET seckill:stock:10086 → 1(库存还有)
├─ SISMEMBER seckill:user:10086 1001 → 0(没买过)
├─ DECR seckill:stock:10086 → 0(库存归零)
├─ SADD seckill:user:10086 1001 → 加入已购集合
└─ XADD seckill:order:stream
{userId:1001, orderId:987654321, voucherId:10086}
→ 返回 "success" 给用户
⏰ T+50ms
其他 999 个并发请求全部被 Lua 拦截:
├─ 大部分收到 "no_stock_and_queued"(库存没了)
└─ 少数收到 "already_bought"(第二波请求赶到时已标记)
⏰ T+100ms
Stream Consumer 定时任务触发
├─ XREADGROUP → 拿到消息 {userId:1001, orderId:987654321, voucherId:10086}
└─→ SeckillOrderService.createOrder(1001, 10086, 987654321)
├─ Redisson tryLock("seckill:lock:1001:10086") → 拿到锁
├─ SELECT COUNT(*) WHERE user_id=1001 AND voucher_id=10086 → 0
├─ UPDATE stock = stock – 1 WHERE voucher_id=10086 AND stock > 0 → affected_rows=1
├─ INSERT INTO seckill_order (id, user_id, voucher_id, status)
│ VALUES (987654321, 1001, 10086, 0)
├─ submitTimeoutCheck(10086, 987654321) → 延迟队列投递
└─ Redisson unlock
→ XACK → 消息确认
⏰ T+15min(如果用户没支付)
延迟队列到期 → handleTimeout(10086, 987654321)
├─ SELECT * FROM seckill_order WHERE id=987654321 → status=0(未支付)
├─ UPDATE seckill_order SET status=-1 WHERE id=987654321
├─ UPDATE seckill_voucher SET stock=stock+1 WHERE voucher_id=10086
├─ INCR seckill:stock:10086 → Redis 库存 +1
└─→ 候补队列第一个人收到通知
⏰ 每天凌晨 3:00
定时对账任务:
└─→ 比对 Redis 和 MySQL 库存差异 → 回补
八、面试六连问速答
Q1:为什么用 Lua 而不用 Redis 事务(MULTI/EXEC)?
Redis 事务不支持条件判断——MULTI 后的命令不能依赖前面命令的返回值。if stock > 0 then DECR 这种逻辑,Redis 事务做不到。Lua 可以。
Q2:Lua 脚本执行到一半 Redis 挂了,会丢数据吗?
Lua 没有回滚机制!脚本里已执行的命令不会撤销。所以我把校验放前面,不可逆操作(DECR、XADD)放最后——挂了就挂了,不会出现"库存扣了但消息没发"的状态。如果追求更强的保证,需要引入 Outbox 模式(将事件写入 DB 事务中,更可靠但复杂度更高)。
Q3:为什么有了 Redisson 锁还要乐观锁?
Redisson 锁是 Java 进程级的。如果两个 JVM 实例之间的时间窗口没锁住(极端情况),乐观锁在 SQL 层兜底。WHERE stock > 0 是秒杀系统的最后一道门——无论如何不会让库存变成负数。
Q4:Stream 和 RabbitMQ 在这个场景怎么选?
秒杀链路已经重度依赖 Redis——把 Stream 的 XADD 放在 Lua 脚本里,和扣库存原子完成。RabbitMQ 做不到这一点。跨服务的业务消息(订单通知、积分发放)走 RabbitMQ 更合适——ACK + 死信队列 + 延迟交换机比 Stream 更适合做"业务消息总线"。
Q5:延迟队列的精度够吗?15分钟级别的超时释放能不能用?
Redisson 延迟队列底层是 ZSet + PUB/SUB,精度秒级。15 分钟的超时释放完全够用——用户不差那几秒。如果要毫秒级精度(比如竞价抢单),得用 RabbitMQ 延迟交换机或 Kafka 时间轮。
Q6:为什么不用 Seata 分布式事务一把梭?
秒杀的 QPS 决定了不能用强一致性方案。Seata AT 模式的全局锁在千级 QPS 下会成为系统瓶颈。而且秒杀业务允许"少卖后回补",不允许"超卖"——一致性要求是单向的(库存不能为负,但可以暂时多扣)。最终一致 + 定时对账就够了。
总结
秒杀系统,剥开所有细节,核心设计就一句话:
每一层都有自己的防守范围,且每一层都假设上一层可能失败。
| Lua 原子操作 | 超卖 + 重复 + 库存不足 | Stream 异步落库确保消息不丢 |
| Stream 削峰 | DB 被流量冲垮 | Pending 重试 + 手动 ACK |
| Redisson 锁 | DB 层重复写入 | 乐观锁在 SQL 层再拦一次 |
| MySQL 乐观锁 | 库存扣到负数 | 定时对账回补差异 |
| 延迟队列 | 库存被未支付订单锁死 | 候补补位 + 手动干预 |
工程上从来不是"防住所有问题",而是"每个问题都有人兜底"。 这是秒杀系统设计最核心的思想,也是面试官真正想听到的东西。
这篇文章拆解了 O2O 平台秒杀链路的完整设计。如果对你有帮助,欢迎点赞、收藏、评论区交流。有问题尽管问,每条都会回复。
我是程序员小鱼,持续输出 Java 后端实战系列,关注不迷路。🐟




