1. 库存秒杀架构设计
秒杀扣库存在不使用 Canal 情况下的线上最佳实践,整体思路是"Redis 做实时扣减扛并发,MQ 异步落库保证持久化,数据库乐观锁兜底防超卖,定时对账修复不一致"。
1.1 整体架构
text
用户请求 → 前端限流 → 网关限流 → 资格校验 → Redis Lua 原子扣减
│
扣减成功 ────┤─── 扣减失败 → 直接返回"已售罄"
│
发送 MQ 消息
│
消费者异步落库
│
UPDATE stock WHERE stock >= qty
│
成功 → 创建订单 失败 → 告警 + 补偿
核心原则:数据流是单向的 ------ Redis → MQ → 数据库,不做反向同步。Redis 是"可售库存"负责实时扣减,数据库是"账面库存"负责持久化记录和对账。
1.2 秒杀前:库存预热
活动开始前,通过定时任务将库存加载到 Redis:
java
// 活动开始前 1~5 分钟执行
public void warmUpStock(Long skuId, Integer stock) {
String key = "seckill:stock:" + skuId;
redisTemplate.opsForValue().set(key, stock);
// 可选:同时初始化去重集合和限购信息
redisTemplate.delete("seckill:dedup:" + skuId);
}
1.3 核心链路:Redis Lua 原子扣减
这是整个方案的核心,利用 Redis 单线程 + Lua 脚本原子性,将"查库存 → 判断 → 扣减 → 去重"打包为一个不可分割的操作:
lua脚本:
lua
-- KEYS[1]: 库存key seckill:stock:{skuId}
-- KEYS[2]: 去重key seckill:dedup:{skuId}
-- ARGV[1]: 扣减数量
-- ARGV[2]: 用户ID(用于一人一单校验)
-- ARGV[3]: 限购数量
-- 1. 一人一单校验
if redis.call('SISMEMBER', KEYS[2], ARGV[2]) == 1 then
return -2 -- 已购买过
end
-- 2. 库存判断 + 扣减
local stock = tonumber(redis.call('GET', KEYS[1]) or 0)
local qty = tonumber(ARGV[1])
if stock >= qty then
redis.call('DECRBY', KEYS[1], qty)
redis.call('SADD', KEYS[2], ARGV[2])
return 1 -- 扣减成功
else
return -1 -- 库存不足
end
-- 如果是可以购买大于1的库存,使用hash来判断用户购买数量
-- 用 Hash,field=userId,value=已购数量
local bought = tonumber(redis.call('HGET', KEYS[2], ARGV[2]) or 0)
local limit = tonumber(ARGV[3]) -- 限购数量
local qty = tonumber(ARGV[1]) -- 本次购买数量
if bought + qty > limit then
return -2 -- 超出限购
end
redis.call('HINCRBY', KEYS[2], ARGV[2], qty)
java
public SeckillResult deductStock(Long skuId, Long userId) {
String stockKey = "seckill:stock:" + skuId;
String dedupKey = "seckill:dedup:" + skuId;
Long result = redisTemplate.execute(
seckillScript,
Arrays.asList(stockKey, dedupKey),
"1", // 扣减数量
userId.toString(), // 用户ID
"1" // 限购数量
);
if (result == 1) {
return SeckillResult.SUCCESS;
} else if (result == -1) {
return SeckillResult.SOLD_OUT;
} else {
return SeckillResult.DUPLICATE;
}
}
- 为什么用 Lua 而不是 DECR? 因为 DECR 只做扣减,无法同时做"库存够不够"的判断和"是否重复购买"的校验。Lua 脚本把这些逻辑打包成原子操作,中间不会被其他请求打断。
- 此处秒杀用的一人一个库存,所以使用的是set来进行去重的,如果是一个人可以购买多件商品的话,这里可以用hash来限制购买数量,如skuId:userId:nums

1.4 异步落库:MQ 削峰
Redis 扣减成功后,发送消息到 MQ,消费者异步完成数据库操作:
1.4.1 消息体设计
java
public class SeckillMessage {
private String msgId; // 消息唯一ID(用于幂等)
private Long orderId; // 预生成的订单ID
private Long skuId; // 商品SKU
private Long userId; // 用户ID
private Integer quantity; // 购买数量
private Long timestamp; // 消息时间戳
}
1.4.2 发送消息(带本地消息表兜底)
java
@Transactional
public void afterDeductSuccess(Long skuId, Long userId) {
// 1. 插入订单
Order order = new Order();
order.setId(snowflakeId);
order.setUserId(userId);
order.setSkuId(skuId);
order.setStatus(OrderStatus.PENDING);
orderMapper.insert(order);
// 2. 构建业务消息体
SeckillMessage seckillMsg = new SeckillMessage();
seckillMsg.setMsgId(UUID.randomUUID().toString());
seckillMsg.setOrderId(order.getId());
seckillMsg.setSkuId(skuId);
seckillMsg.setUserId(userId);
seckillMsg.setQuantity(1);
seckillMsg.setTimestamp(System.currentTimeMillis());
// 3. 构建本地消息记录(把 SeckillMessage 序列化后塞进去)
LocalMessage localMsg = new LocalMessage();
localMsg.setMsgId(seckillMsg.getMsgId());
localMsg.setTopic("seckill-order-topic");
localMsg.setBody(JSON.toJSONString(seckillMsg)); // ← 序列化
localMsg.setStatus("PENDING");
localMessageMapper.insert(localMsg); // ← 插入的是 LocalMessage
// 4. 事务提交后发送 MQ
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
mqProducer.send(seckillMsg.getTopic(), seckillMsg);
localMessageMapper.updateStatus(seckillMsg.getMsgId(), "SENT");
} catch (Exception e) {
log.error("MQ发送失败, msgId={}", seckillMsg.getMsgId(), e);
}
}
}
);
}
1.4.3 消费者逻辑(幂等 + 乐观锁)
java
@RocketMQMessageListener(topic = "seckill-order-topic", consumerGroup = "seckill-consumer")
public class SeckillConsumer implements RocketMQListener<SeckillMessage> {
@Override
public void onMessage(SeckillMessage msg) {
// 1. 幂等校验:检查订单状态
Order order = orderMapper.selectById(msg.getOrderId());
if (order == null || order.getStatus() != OrderStatus.PENDING) {
return; // 已处理过,跳过
}
// 2. 数据库扣减库存(乐观锁兜底)
int affected = inventoryMapper.deductStock(msg.getSkuId(), msg.getQuantity());
// SQL: UPDATE inventory SET stock = stock - #{qty}
// WHERE sku_id = #{skuId} AND stock >= #{qty}
if (affected == 1) {
// 3. 扣减成功 → 更新订单状态
orderMapper.updateStatus(msg.getOrderId(), OrderStatus.CONFIRMED);
// 4. 更新本地消息表状态
localMessageMapper.updateStatus(msg.getMsgId(), "CONSUMED");
// 4. ★ 发送延迟消息:5分钟后检查是否已支付
SeckillTimeoutMessage timeoutMsg = new SeckillTimeoutMessage();
timeoutMsg.setOrderId(msg.getOrderId());
timeoutMsg.setSkuId(msg.getSkuId());
timeoutMsg.setUserId(msg.getUserId());
timeoutMsg.setQuantity(msg.getQuantity());
rocketMQTemplate.syncSend(
"seckill-timeout-topic",
MessageBuilder.withPayload(timeoutMsg).build(),
3000, // 发送超时
14 // 延迟级别 14 = 5分钟
);
} else {
// 6. 扣减失败(库存不足)→ 回滚 Redis 库存 + 告警
log.error("DB库存扣减失败, skuId={}, orderId={}", msg.getSkuId(), msg.getOrderId());
redisTemplate.opsForValue().increment("seckill:stock:" + msg.getSkuId(), msg.getQuantity());
redisTemplate.opsForValue().getOperations().delete(
Collections.singletonList("seckill:dedup:" + msg.getSkuId()),
msg.getUserId().toString()
);
orderMapper.updateStatus(msg.getOrderId(), OrderStatus.FAILED);
}
}
}
1.5 数据库层:乐观锁兜底
sql
-- 库存表
CREATE TABLE inventory (
id BIGINT PRIMARY KEY,
sku_id BIGINT NOT NULL,
stock INT NOT NULL DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_sku (sku_id)
);
-- 扣减 SQL(核心:stock >= qty 是防超卖的最后一道防线)
UPDATE inventory
SET stock = stock - #{quantity}
WHERE sku_id = #{skuId}
AND stock >= #{quantity};
为什么必须加 stock >= #{quantity}?
- MQ 消息可能乱序(回滚消息先到、扣减消息后到)
- 极端情况下幂等失效导致重复扣减
- 数据库是最终防线,即使上游全错,这里也不能超卖
1.6 超时未支付:库存回滚
用户抢到但没付款,需要把库存还回去:
java
// 消费者:检查订单是否已支付
@RocketMQMessageListener(topic = "seckill-timeout-topic", consumerGroup = "timeout-consumer")
public class SeckillTimeoutConsumer implements RocketMQListener<SeckillTimeoutMessage> {
@Override
public void onMessage(SeckillTimeoutMessage msg) {
// 1. 查询订单当前状态
Order order = orderMapper.selectById(msg.getOrderId());
// 2. 判断是否已支付
if (order == null) {
return; // 订单不存在,忽略
}
if (order.getPayStatus() == PayStatus.PAID) {
return; // 已支付,不需要处理
}
if (order.getStatus() == OrderStatus.CANCELLED) {
return; // 已取消(用户主动取消或其他原因),不重复处理
}
// 3. 未支付 → 取消订单 + 回滚库存
// 3.1 用乐观锁更新订单状态,防止并发(比如用户刚好在这一秒支付了)
int affected = orderMapper.cancelOrder(
msg.getOrderId(),
OrderStatus.WAIT_PAY, // 只有当前状态是 WAIT_PAY 才更新
OrderStatus.CANCELLED
);
// SQL: UPDATE orders SET status = 'CANCELLED'
// WHERE id = #{orderId} AND status = 'WAIT_PAY'
if (affected == 0) {
// 状态已变更(可能刚好支付了),不再回滚
return;
}
// 3.2 回滚 DB 库存
inventoryMapper.returnStock(msg.getSkuId(), msg.getQuantity());
// SQL: UPDATE inventory SET stock = stock + #{qty} WHERE sku_id = #{skuId}
// 3.3 回滚 Redis 库存(Lua 脚本保证幂等)
returnRedisStock(msg.getSkuId(), msg.getUserId(), msg.getQuantity(), msg.getOrderId());
}
}
回滚 Redis 的 Lua 脚本(带幂等):
bash
-- KEYS[1]: 库存key
-- KEYS[2]: 回滚标记key seckill:rollback:{orderId}
-- ARGV[1]: 回滚数量
-- ARGV[2]: 用户ID
-- 幂等检查:是否已回滚过
if redis.call('EXISTS', KEYS[2]) == 1 then
return 0
end
-- 回滚库存
redis.call('INCRBY', KEYS[1], ARGV[1])
-- 移除去重标记
redis.call('SREM', KEYS[1] .. ':dedup', ARGV[2])
-- 写入回滚标记(7天过期)
redis.call('SET', KEYS[2], 1, 'EX', 604800)
return 1
1.7 定时对账:修复不一致
这是不用 Canal 的情况下保证最终一致性的关键兜底:
java
@Scheduled(fixedRate = 300000) // 每 5 分钟执行一次
public void reconcile() {
// 1. 获取所有秒杀中的 SKU
List<Long> activeSkus = seckillActivityMapper.getActiveSkuIds();
for (Long skuId : activeSkus) {
// 2. 读取 Redis 库存
String redisStock = redisTemplate.opsForValue()
.get("seckill:stock:" + skuId);
// 3. 读取 DB 库存
Integer dbStock = inventoryMapper.getStock(skuId);
// 4. 计算已确认订单的总扣减量
Integer orderDeducted = orderMapper.sumConfirmedQuantity(skuId);
// 5. 校验:DB初始库存 - orderDeducted 应该等于 DB当前库存
Integer initialStock = seckillActivityMapper.getInitialStock(skuId);
Integer expectedDbStock = initialStock - orderDeducted;
if (!expectedDbStock.equals(dbStock)) {
// DB 库存不一致,以订单明细为准修正
log.warn("DB库存不一致, skuId={}, expected={}, actual={}",
skuId, expectedDbStock, dbStock);
inventoryMapper.forceUpdateStock(skuId, expectedDbStock);
}
// 6. 校验 Redis 与 DB 差值
if (redisStock != null) {
int diff = Integer.parseInt(redisStock) - dbStock;
if (Math.abs(diff) > 10) { // 差值超过阈值告警
log.error("Redis与DB库存差异过大, skuId={}, diff={}", skuId, diff);
// 可选:以 DB 为准修正 Redis
redisTemplate.opsForValue().set("seckill:stock:" + skuId, dbStock);
}
}
}
}
1.8 本地消息表补偿:MQ 发送失败的兜底
java
@Scheduled(fixedRate = 60000) // 每分钟扫描一次
public void compensateUnsentMessages() {
// 查找超过 30 秒仍未发送成功的消息
List<LocalMessage> pendingMessages = localMessageMapper
.selectPendingMessages(30);
for (LocalMessage msg : pendingMessages) {
if (msg.getRetryCount() >= 3) {
// 超过最大重试次数,标记失败,人工介入
localMessageMapper.updateStatus(msg.getMsgId(), "FAILED");
alertService.send("消息发送失败超过重试上限, msgId=" + msg.getMsgId());
continue;
}
try {
mqProducer.send("seckill-order-topic", msg.buildMessage());
localMessageMapper.updateStatus(msg.getMsgId(), "SENT");
} catch (Exception e) {
localMessageMapper.incrementRetryCount(msg.getMsgId());
log.error("补偿发送失败, msgId={}", msg.getMsgId(), e);
}
}
}
1.9 完整时序图
整体时序图
bash
用户 网关 应用服务 Redis MQ MySQL
│ │ │ │ │ │
│──秒杀请求──→│ │ │ │ │
│ │──限流校验──→│ │ │ │
│ │ │──Lua扣减──→│ │ │
│ │ │←─成功(1)───│ │ │
│ │ │──发送消息──────────────→│ │
│←─排队中─────│←───────────│ │ │ │
│ │ │ │ │──消费消息──→│
│ │ │ │ │ │──UPDATE stock
│ │ │ │ │ │ WHERE stock>=qty
│ │ │ │ │←─扣减成功──│
│ │ │ │ │ │──创建订单
│ │ │ │ │ │
│──查询结果──→│ │ │ │ │
│←─秒杀成功──│ │ │ │ │
订单相关时序图
bash
用户 消费者 MySQL RocketMQ 超时消费者
│ │ │ │ │
│ │←─MQ消息────│ │ │
│ │──扣减库存──→│ │ │
│ │←─成功──────│ │ │
│ │──创建订单──→│(status=WAIT_PAY) │
│ │←─成功──────│ │ │
│ │──发送延迟消息(5min)──────→│ │
│ │ │ │ │
│──支付──────────────────────→│ │ │
│ │ │←─5分钟后─────│ │
│ │ │ │──超时消息───→│
│ │ │ │ │──查询订单状态
│ │ │ │ │──已支付→跳过
│ │ │ │ │
│ │ │ │ │
│ (如果未支付) │ │ │ │
│ │ │ │──超时消息───→│
│ │ │ │ │──未支付→取消订单
│ │ │←─取消订单────│ │
│ │ │──回滚DB库存──│ │
│ │ │──回滚Redis库存 │
1.10 各层职责总结
| 层 | 职责 | 技术手段 |
|---|---|---|
| 前端层 | 拦截 80% 无效流量 | 按钮置灰、验证码、CDN 静态化 |
| 网关层 | 限流降级 | 令牌桶、IP/用户维度限流 |
| Redis 层 | 实时扣减,防超卖 | Lua 原子脚本、一人一单去重 |
| MQ 层 | 异步削峰 | 本地消息表 + 事务后发送 |
| MySQL 层 | 最终兜底,防超卖 | WHERE stock >= qty 乐观锁 |
| 对账层 | 修复不一致 | 定时对比 Redis/DB/订单明细 |
| 补偿层 | 兜底失败场景 | 延迟消息回滚库存、本地消息表重试 |
1.11 面试话术总结
我们的秒杀扣库存方案核心是"Redis 预扣 + MQ 异步落库 + DB 乐观锁兜底"三层架构:
第一层,秒杀前把库存预热到 Redis,请求进来后通过 Lua 脚本原子执行"库存判断 + 扣减 + 一人一单校验",保证不超卖,单节点可以扛 10 万 QPS;
第二层,Redis 扣减成功后发送 MQ 消息,消费者异步完成数据库库存扣减和订单创建,DB 层用 UPDATE WHERE stock >= qty 做最终兜底;
第三层,通过本地消息表保证消息一定能发出去,消费失败有重试和死信队列兜底,定时对账任务每 5 分钟对比 Redis、DB 和订单明细,发现差异自动修复或告警;
第四层,超时未支付的订单通过延迟消息触发库存回滚,回滚操作同样用 Lua 脚本保证幂等。
整个方案不依赖 Canal,数据流是单向的(Redis → MQ → DB),通过多层兜底实现最终一致性。