三级缓存上线后,性能提升明显,P99 响应时间从 500ms 降到 50ms。
但客服来找我们:用户投诉商品明明有库存,页面却显示”已售罄”。
排查后发现:用户 A 购买了最后一件商品,数据库库存变成 0。但缓存里还是旧数据(剩余 1 件),5 分钟后才过期。用户 B 看到”剩余 1 件”,下单时才发现没货。
缓存与数据库不一致:这是缓存最大的问题。
架构全景
整个系统分两层:缓存层处理低频数据(商品信息),库存层处理高频数据(实时库存)。
但为什么要分两层?这取决于数据的变化频率和及时性要求。
为什么要区分数据类型?
促销场景下,不同数据的变化频率差异巨大,需要不同的缓存策略。
| 数据类型 | 变化频率 | 及时性要求 | 缓存策略 |
|---|---|---|---|
| 商品基础信息(名称、图片) | 低(几天才改一次) | 低 | L1 1s + L2 5min,够用 |
| 商品价格 | 中(促销时改动) | 中 | L1 1s + L2 1min |
| 商品剩余库存 | 高(每秒几百次变化) | 高 | 不能用传统缓存 |
库存为什么不能缓存:
- L1 TTL 1 秒,8 个实例各自缓存,用户可能看到 8 份不同的库存数据
- L2 TTL 5 分钟,库存早就卖完了,缓存还显示有货
- 用户体验差,投诉率高
解决方案:Redis 内存扣减 + 消息队列异步落库
最开始我们想用读写分离,查询打从库。但很快发现两个问题:
- 代码改动太大:要改所有的查询逻辑,区分主库写、从库读
- 主从延迟无解:PostgreSQL 主从同步有延迟(100ms-1s),用户刚下单,立刻刷新还是看到旧库存
我们换了个思路:库存完全不走数据库查,直接用 Redis 扣减,扣完异步发消息落库。
核心架构:
用户请求 → 本地内存拦截 → Redis Lua 原子扣减 → RocketMQ 异步落库 → PostgreSQL 订单表
为什么要 MQ:
- 解耦:下单和落库分离,下单接口秒级返回
- 削峰:高峰期 5000 QPS 扣减,数据库只需要处理 500 QPS 落库
- 可靠:网络抖动不影响下单,MQ 保证消息不丢
这个架构简单直接,但实际落地会遇到三个关键问题。
问题 1:高并发如何防超卖?
场景:1000 个用户同时抢购最后 1 件商品,如何保证只有 1 个人成功?
影响:超卖会导致库存为负,实际无货但订单已生成,用户投诉。
解决方案:本地内存售罄标记 + Lua 脚本原子扣减
const Redis = require('ioredis');
const redis = new Redis({ host: 'redis-cluster' });
let isSoldOut = false;
// 定义 Lua 脚本:先判断库存,再扣减
redis.defineCommand('deductStock', {
numberOfKeys: 1,
lua: `
local currentStock = tonumber(redis.call('get', KEYS[1]) or "0")
if currentStock >= tonumber(ARGV[1]) then
redis.call('decrby', KEYS[1], ARGV[1])
return 1
else
return 0
end
`
});
async function handleSeckill(userId, itemId) {
// 本地售罄标记:避免无效 Redis 请求
if (isSoldOut) {
return { success: false, msg: '已售罄' };
}
const stockKey = `seckill:stock:${itemId}`;
const result = await redis.deductStock(stockKey, 1);
if (result === 1) {
await sendOrderMessage(userId, itemId);
return { success: true, msg: '抢购成功' };
}
// 扣减失败,设置售罄标记
isSoldOut = true;
return { success: false, msg: '已售罄' };
}为什么用 Lua 脚本:
- 先判断再扣减:
if currentStock >= quantity先检查库存,避免负库存 - 原子性保证:查询 + 判断 + 扣减在 Redis 单线程内一次性完成,中间不会被打断
- 性能优秀:一次网络请求完成所有操作,耗时 < 1ms
- 绝对可靠:Redis 单线程执行 Lua,不存在并发问题
关键点:
- Lua 脚本:Redis 单线程执行 Lua,保证”查库存”和”扣减”的原子性
- 本地标记:售罄后该实例不再访问 Redis,虽然多实例各自独立,但能减少大量无效请求
关于并发锁的更多方案对比,参见 并发锁的几种实现方案。
问题 2:MQ 挂了或网络抖动,消息丢失怎么办?
场景有两种:
- MQ 服务宕机:Redis 扣减成功,但 MQ 不可用,消息发送失败
- 网络抖动:消息发出去了,但网络断开,不确定 MQ 是否收到
影响:库存已扣减,订单永久丢失,用户付款但没订单。
解决方案:同步等待 MQ ACK + Redis 补偿队列降级
const { Producer, Message } = require('apache-rocketmq');
const producer = new Producer('SECKILL_PRODUCER_GROUP');
producer.setSendMsgTimeout(3000);
producer.setRetryTimesWhenSendFailed(3);
async function sendOrderMessage(userId, itemId) {
const orderId = generateOrderId(userId, itemId);
const msgContent = JSON.stringify({ orderId, userId, itemId });
const msg = new Message('SECKILL_ORDER_TOPIC', 'TAG_NORMAL', msgContent);
msg.setKeys(orderId);
try {
// 同步发送:等待 Broker 返回 ACK
const sendResult = await producer.send(msg);
if (sendResult.sendStatus !== 'SEND_OK') {
throw new Error('MQ_SEND_FAILED');
}
} catch (error) {
console.error('MQ 投递失败,写入补偿队列', error);
await redis.lpush('seckill:compensation:failed', msgContent);
}
}
function generateOrderId(userId, itemId) {
return `order_${userId}_${itemId}_${Date.now()}`;
}关键点:
- 同步发送:
producer.send()默认是同步模式,会阻塞等待 Broker 返回 ACK,确认消息已持久化 - 补偿队列:MQ 不可用时,消息写入 Redis,定时任务重新发送
- 降级不影响用户:用户仍然看到”抢购成功”,补偿队列保证订单最终落库
MQ 挂了会怎样?
-
短期(MQ 恢复前):
- Redis 库存正常扣减,用户下单成功
- 订单暂存在补偿队列,未落库
- 定时任务每 10 秒尝试重新发送
-
长期(MQ 长时间不可用):
- 补偿队列积压,需要监控告警
- MQ 恢复后,定时任务会把积压订单全部发送
- 消费者处理后落库,订单不会丢失
补偿队列 vs 死信队列:
- 补偿队列:消息根本没进 MQ(MQ 挂了、网络断了),在生产端用 Redis 暂存,定时重发
- 死信队列:消息已在 MQ 中,但消费端处理失败,MQ 重试 16 次后放入死信队列,人工排查
补偿队列处理逻辑:
// workers/compensationWorker.js
async function processCompensationQueue() {
setInterval(async () => {
const msgContent = await redis.rpop('seckill:compensation:failed');
if (!msgContent) return;
try {
const orderData = JSON.parse(msgContent);
await sendOrderMessage(orderData.userId, orderData.itemId);
// 发送成功,不用再放回队列
} catch (error) {
// 发送失败,放回队列尾部,下次继续重试
await redis.lpush('seckill:compensation:failed', msgContent);
}
}, 10000); // 每 10 秒处理一次
}
// app.listen(3000, () => processCompensationQueue());问题 3:MQ 重复投递,如何防止订单重复?
重复场景有三种:
- 网络重传:消费者处理完发送 ACK,但网络抖动 ACK 丢失,MQ 认为消费失败重新投递
- 消费者重启:消费者处理到一半挂了(已插入数据库但未 ACK),重启后 MQ 重新投递
- MQ 保证:RocketMQ 至少一次投递(at-least-once),可能重复但不会丢失
影响:同一个订单插入两次,用户被扣两次钱。
解决方案:PostgreSQL 唯一索引物理拦截 + 手动 ACK
// workers/orderConsumerWorker.js
const { PushConsumer } = require('apache-rocketmq');
const { Pool } = require('pg');
const pgPool = new Pool({ /* ... */ });
async function startConsumer() {
const consumer = new PushConsumer('SECKILL_CONSUMER_GROUP');
consumer.subscribe('SECKILL_ORDER_TOPIC', 'TAG_NORMAL');
consumer.on('message', async (msg, ack) => {
const orderData = JSON.parse(msg.body.toString('utf8'));
try {
// CREATE UNIQUE INDEX idx_order_id ON orders(order_id)
await pgPool.query(
'INSERT INTO orders (order_id, user_id, item_id, created_at) VALUES ($1, $2, $3, NOW())',
[orderData.orderId, orderData.userId, orderData.itemId]
);
ack.done();
} catch (error) {
if (error.code === '23505') {
// 唯一键冲突 = 重复投递,幂等处理
ack.done();
} else {
ack.reconsumeLater();
}
}
});
await consumer.start();
}
// 在 Express 启动时调用
// app.listen(3000, () => startConsumer());关键点:
- 唯一索引:数据库层面物理拦截,两个消费者同时插入也不会重复
- 手动 ACK:只有成功插入或确认重复后才 ACK,数据库异常时请求 MQ 重试
- 为什么不用 SELECT 判断:SELECT 判断有并发问题,两个消费者同时查都没查到,都插入就重复了
完整链路
下面用流程图展示生产端和消费端的完整处理流程:
生产端:下单流程
graph TB User((用户下单)) User --> LocalCheck{本地内存<br/>售罄标记?} LocalCheck -->|YES| Return1(返回已售罄) LocalCheck -->|NO| RedisDeduct(Redis Lua<br/>原子扣减) RedisDeduct -->|成功| MQ(发送 MQ 消息) RedisDeduct -->|失败| SetFlag(设置售罄标记) SetFlag --> Return2(返回已售罄) MQ -->|成功| Return3(返回抢购成功) MQ -->|失败| Compensation(写入 Redis<br/>补偿队列) Compensation --> Return3 style User fill:#dbeafe,stroke:#3b82f6,stroke-width:2px style LocalCheck fill:#d3f9d8,stroke:#2f9e44,stroke-width:2px style RedisDeduct fill:#ffe8cc,stroke:#d9480f,stroke-width:2px style MQ fill:#e5dbff,stroke:#5f3dc4,stroke-width:2px style Compensation fill:#fff4e6,stroke:#e67700,stroke-width:2px
消费端:落库流程
graph TB MQConsumer(MQ 消费者<br/>接收消息) --> Parse(解析订单数据) Parse --> DBInsert(数据库插入<br/>唯一索引拦截) DBInsert -->|成功| ACK1(ack.done<br/>确认消费) DBInsert -->|唯一键冲突| Check{判断错误类型} Check -->|重复投递| ACK2(ack.done<br/>幂等处理) Check -->|系统异常| Retry(ack.reconsumeLater<br/>请求重试) style MQConsumer fill:#e5dbff,stroke:#5f3dc4,stroke-width:2px style Parse fill:#c5f6fa,stroke:#0c8599,stroke-width:2px style DBInsert fill:#f3d9fa,stroke:#862e9c,stroke-width:2px style Check fill:#d3f9d8,stroke:#2f9e44,stroke-width:2px style ACK1 fill:#d3f9d8,stroke:#2f9e44,stroke-width:2px style ACK2 fill:#fff4e6,stroke:#e67700,stroke-width:2px style Retry fill:#ffe3e3,stroke:#c92a2a,stroke-width:2px
从这两个流程图可以看出:
- 生产端:重点是快速响应,Redis 扣减成功就返回,MQ 失败有补偿
- 消费端:重点是可靠落库,唯一索引防重复,异常时重试
这套方案核心是:查询走缓存,扣减走 Redis,落库走 MQ 异步。库存数据完全不查数据库,从根本上解决了及时性问题。
三道防线总结
| 问题 | 典型场景 | 解决方案 | 关键技术 |
|---|---|---|---|
| 超卖 | 1000 人抢最后 1 件 | 本地内存拦截 + Lua 原子扣减 | Redis 单线程执行 Lua 保证原子性 |
| 消息丢失 | MQ 宕机、网络抖动 | 同步等待 ACK + Redis 补偿队列 | Producer 重试 + 降级方案 |
| 订单重复 | 网络重传、消费者重启、MQ at-least-once | PostgreSQL 唯一索引 + 手动 ACK | 数据库物理拦截 + 幂等处理 |
架构演进历程
从系统崩溃到稳定运行,我们经历了三次迭代:
| 阶段 | 方案 | 解决的问题 | 遗留的问题 |
|---|---|---|---|
| 第一次:限流与熔断 | Cloud Armor + 应用层限流 + 熔断器 | 系统不再崩溃 | 响应慢,数据库压力大 |
| 第二次:三级缓存 | L1 内存 + L2 Redis + L3 数据库 | 响应从 500ms 降到 50ms | 缓存数据不及时 |
| 第三次:缓存时效性 | Redis 扣减 + MQ 异步落库 | 库存实时准确 | 架构复杂度增加 |
最终架构:
用户请求
→ Cloud Armor IP 限流
→ 应用层用户限流
→ L1 内存缓存(商品信息)
→ L2 Redis 缓存(商品信息)
→ Redis Lua 原子扣减(库存)
→ RocketMQ 异步落库
→ 数据库(订单数据)
每一层都有明确的职责:
- Cloud Armor:防 DDoS,挡恶意流量
- 应用层限流:保护自己,防止过载
- L1/L2 缓存:加速查询,降低数据库压力
- Redis 扣减:高并发扣库存,防超卖
- MQ 异步:削峰填谷,解耦下单和落库
- 唯一索引:物理拦截,保证幂等
这套架构适合什么场景?
适合:
- 库存变化频繁:秒杀、限量商品、热门演出门票
- 并发量高:几千 QPS 以上的抢购场景
- 对时效性要求极高:不能容忍几秒的库存延迟
- 有运维能力:团队能维护 Redis、MQ 等中间件
不适合:
- 低频商品:每天几十单的普通商品,直接数据库就够了
- 对一致性要求不高:允许偶尔库存不准确的场景
- 小团队:没有运维能力维护 MQ,架构复杂度带来的成本 > 收益
- 低并发:几十 QPS 的场景,数据库 + 简单缓存就能搞定
经验教训
回头看,如果重新设计这个系统,我会:
前期规划:
- 压测找到系统瓶颈,而不是上线后才发现
- 设计时就考虑限流和熔断,而不是事后补救
- 预估流量峰值,提前扩容
技术选型:
- 对于高频变化的数据(库存),一开始就用 Redis + MQ,不要先缓存再发现不行
- 对于低频变化的数据(商品信息),L1 + L2 三级缓存够用
- 布隆过滤器不是必需品,缓存命中率高时不用加
监控告警:
- 每一层都要有监控:限流次数、缓存命中率、MQ 积压、数据库 QPS
- 告警阈值要合理:不能太敏感(频繁误报),也不能太迟钝(发现时已经挂了)
复杂度控制:
- 不要一开始就搞复杂架构,先用最简单的方案
- 遇到瓶颈再优化,每次只加一层,验证效果后再继续
- 复杂度是有成本的:代码维护、运维部署、故障排查都会变难
保持简单:能用两层解决的问题,就不要加第三层。工程不是炫技,而是用最小的成本解决问题。