三级缓存上线后,性能提升明显,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 内存扣减 + 消息队列异步落库

最开始我们想用读写分离,查询打从库。但很快发现两个问题:

  1. 代码改动太大:要改所有的查询逻辑,区分主库写、从库读
  2. 主从延迟无解: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 脚本

  1. 先判断再扣减if currentStock >= quantity 先检查库存,避免负库存
  2. 原子性保证:查询 + 判断 + 扣减在 Redis 单线程内一次性完成,中间不会被打断
  3. 性能优秀:一次网络请求完成所有操作,耗时 < 1ms
  4. 绝对可靠:Redis 单线程执行 Lua,不存在并发问题

关键点

  • Lua 脚本:Redis 单线程执行 Lua,保证”查库存”和”扣减”的原子性
  • 本地标记:售罄后该实例不再访问 Redis,虽然多实例各自独立,但能减少大量无效请求

关于并发锁的更多方案对比,参见 并发锁的几种实现方案

问题 2:MQ 挂了或网络抖动,消息丢失怎么办?

场景有两种

  1. MQ 服务宕机:Redis 扣减成功,但 MQ 不可用,消息发送失败
  2. 网络抖动:消息发出去了,但网络断开,不确定 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 挂了会怎样

  1. 短期(MQ 恢复前)

    • Redis 库存正常扣减,用户下单成功
    • 订单暂存在补偿队列,未落库
    • 定时任务每 10 秒尝试重新发送
  2. 长期(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 重复投递,如何防止订单重复?

重复场景有三种

  1. 网络重传:消费者处理完发送 ACK,但网络抖动 ACK 丢失,MQ 认为消费失败重新投递
  2. 消费者重启:消费者处理到一半挂了(已插入数据库但未 ACK),重启后 MQ 重新投递
  3. 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-oncePostgreSQL 唯一索引 + 手动 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
  • 告警阈值要合理:不能太敏感(频繁误报),也不能太迟钝(发现时已经挂了)

复杂度控制

  • 不要一开始就搞复杂架构,先用最简单的方案
  • 遇到瓶颈再优化,每次只加一层,验证效果后再继续
  • 复杂度是有成本的:代码维护、运维部署、故障排查都会变难

保持简单:能用两层解决的问题,就不要加第三层。工程不是炫技,而是用最小的成本解决问题。