某功能上线前,我们在UAT环境发现了数据库中竟然有商品为负数。限量 100 件的商品,卖出去了 127 件。因为上线迫在眉睫,所以我们加班加点需要解决这个问题。代码diff 发现下面的可疑的代码

重现事故

代码很简单,一个典型的下单接口:

async function createOrder(productId: string, userId: string) {
  const product = await db.products.findOne({ id: productId });
  
  if (product.stock <= 0) {
    throw new Error('库存不足');
  }
  
  await db.products.update(
    { id: productId },
    { stock: product.stock - 1 }
  );
  
  await db.orders.create({ productId, userId });
}

看起来没问题?跑压测,100 个并发请求同时下单,结果:

curl -X POST /api/orders -d '{"productId":"限量版","userId":"user1"}' &
curl -X POST /api/orders -d '{"productId":"限量版","userId":"user2"}' &
# ... 100 个并发请求
 
# 结果
库存: -27
订单数: 127

问题根因

核心问题是 Read-Check-Write 操作不是原子性的

时刻 1: 请求 A 读取库存:100
时刻 2: 请求 B 读取库存:100 (还没来得及减)
时刻 3: 请求 A 检查通过,减库存到 99
时刻 4: 请求 B 检查通过,减库存到 99 (重复扣减!)

这就是典型的 Race Condition:多个操作的执行顺序不确定,导致结果错误。

为什么最终选择 Redis 分布式锁

我们的业务场景是:

  • 多实例部署:3 个 Node.js 实例负载均衡
  • 中高并发:秒杀场景下,单商品 QPS 5000+
  • 用户体验优先:不能让用户频繁重试

排除其他方案

方案排除原因
应用层排队多实例部署下无效
乐观锁高并发下重试率 > 90%,用户体验差
悲观锁吞吐量低,秒杀场景撑不住
数据库原子操作业务逻辑复杂,需要查用户信息、计算优惠,无法在一条 SQL 完成

关于锁方案的详细对比和选择指南,参见 并发锁的几种实现方案

Redis 分布式锁的决定性优势

// 1. 细粒度锁:不同商品并发执行
await redlock.acquire([`lock:product:${productId}`], 1000);
 
// 2. 跨实例:所有实例共享 Redis 锁状态
// 3. 性能:内存操作,获取锁耗时 < 2ms
// 4. 安全:自动过期,防止死锁

审计表:锁不能解决的问题

锁解决了超卖,但没解决审计追踪。有用户投诉:明明下单成功了,为什么库存还是原来的数字?

排查发现,某个运营人员手动改了数据库库存,没有留下任何记录。

加了一张审计表:

// 审计表结构
interface StockAudit {
  beforeStock: number;
  afterStock: number;
  operation: 'order' | 'refund' | 'manual_adjust';
  operatorId: string;
}
 
async function createOrder(productId, userId) {
  const lock = await redlock.acquire([`lock:product:${productId}`], 1000);
  
  try {
    const product = await db.products.findOne({ id: productId });
    if (product.stock <= 0) throw new Error('库存不足');
    
    await db.transaction(async (trx) => {
      // 更新库存 + 写入审计记录
      await trx.products.update({ id: productId }, { stock: product.stock - 1 });
      await trx.stockAudits.insert({ /* 审计信息 */ });
    });
  } finally {
    await lock.release();
  }
  
  await db.orders.create({ productId, userId });
}

审计表的好处:

  • 追溯库存变化: 任何时候都能查询库存是怎么变成现在这个数字的
  • 检测异常, 如果库存突变,立刻报警.
  • 监管合规, 电商行业要求所有库存变动可追溯,审计表可以直接导出给监管部门。

最终方案

export async function createOrder(productId: string, userId: string) {
  // 1. 获取分布式锁(按商品 ID)
  const lock = await redlock.acquire([`lock:product:${productId}`], 1000);
  
  try {
    // 2. 检查库存
    const product = await db.products.findOne({ id: productId });
    if (product.stock <= 0) throw new Error('库存不足');
    
    // 3. 原子更新:库存 + 审计记录
    await db.transaction(async (trx) => {
      await trx.products.update({ id: productId }, { stock: product.stock - 1 });
      await trx.stockAudits.insert({
        productId,
        beforeStock: product.stock,
        afterStock: product.stock - 1,
        operation: 'order',
        operatorId: userId,
      });
    });
  } finally {
    await lock.release();
  }
  
  // 4. 创建订单(锁外操作)
  return await db.orders.create({ productId, userId });
}

核心要点:

  • Redis 分布式锁:保证多实例环境下的原子性
  • 锁粒度细化:按商品 ID 加锁,不同商品并行处理
  • 缩短锁持有时间:只锁库存扣减,订单创建放锁外
  • 审计表:所有库存变动可追溯,满足合规要求
  • 数据库事务:库存更新和审计记录原子性写入

部署后,再也没出现过超卖。高峰期(秒杀场景)压测,5000 并发请求,库存准确无误。

还能优化吗

当然可以。如果流量再大 10 倍(比如双 11),Redis 分布式锁会成为瓶颈。原因是:

5000 并发请求 -> 1 把锁 -> 串行执行
每个请求持锁 50ms -> 1 秒最多处理 20 个请求

这时需要更激进的优化。

1. 预扣库存 + 异步补偿

核心思想:先用 Redis Lua 脚本原子扣减,成功后异步写数据库,避免在锁内访问慢速的数据库。

const Redis = require('ioredis');
const redis = new Redis();
 
// 定义 Lua 脚本命令
redis.defineCommand('deductStock', {
  numberOfKeys: 1,
  lua: `
    local stock = redis.call('get', KEYS[1])
    if not stock or tonumber(stock) <= 0 then
      return 0
    end
    redis.call('decr', KEYS[1])
    return 1
  `
});
 
async function createOrder(productId: string, userId: string) {
  // 1. Redis Lua 原子扣减
  const result = await redis.deductStock(`stock:${productId}`);
  
  if (result === 0) {
    throw new Error('库存不足');
  }
  
  // 2. 异步写数据库(放到消息队列)
  await queue.add('sync-stock', { productId, userId, operation: 'order' });
  
  // 3. 立即返回,用户体验极快
  return { success: true };
}
 
// 后台 Worker 异步更新数据库
async function syncStockWorker(job) {
  const { productId, userId, operation } = job.data;
  
  await db.transaction(async (trx) => {
    await trx.products.update({ id: productId }, { stock: product.stock - 1 });
    await trx.stockAudits.insert({ /* 审计信息 */ });
    await trx.orders.create({ productId, userId });
  });
}

为什么用 Lua 脚本

  • 先判断再扣减:避免出现负库存
  • 一次原子操作:查询 + 判断 + 扣减在 Redis 单线程内完成,保证原子性
  • 无需回滚:不满足条件直接返回 0,不会污染数据

关于 Redis Lua 原子扣减的更多细节和架构设计,参见 三级缓存时效性:防超卖方案

优势

  • 吞吐量提升 10 倍以上(Redis Lua 耗时 < 1ms)
  • 用户无需等待数据库写入
  • 完全避免超卖问题

代价

  • 最终一致性(数据库可能延迟几秒才更新)
  • 需要处理异步失败(Worker 崩溃、消息丢失)

适用场景:秒杀、抢购等极高并发场景(详见 three-level-cache-timeliness

2. 分段锁(Segment Lock)

核心思想:把一个资源的锁拆成多把,降低锁竞争。类似超市开 10 个收银台,比只开 1 个快得多。

async function createOrder(productId: string, userId: string) {
  // 把商品库存拆成 10 段,每段独立加锁
  const segment = Math.floor(Math.random() * 10);
  const lock = await redlock.acquire(
    [`lock:product:${productId}:seg${segment}`],
    1000
  );
  
  try {
    // 每段维护自己的库存
    const stock = await db.query(
      'SELECT stock FROM product_segments WHERE product_id = $1 AND segment = $2',
      [productId, segment]
    );
    
    if (stock <= 0) throw new Error('库存不足');
    
    await db.query(
      'UPDATE product_segments SET stock = stock - 1 WHERE product_id = $1 AND segment = $2',
      [productId, segment]
    );
  } finally {
    await lock.release();
  }
}

工作原理

# 原来:100 个请求竞争 1 把锁
lock:product:A -> 串行执行
 
# 分段后:100 个请求分散到 10 把锁
lock:product:A:seg0 -> 10 个请求竞争
lock:product:A:seg1 -> 10 个请求竞争
...
lock:product:A:seg9 -> 10 个请求竞争
 
并发度提升 10

代价

  • 数据库设计复杂(需要 product_segments 表)
  • 查询总库存需要聚合所有段

适用场景:库存量大(> 1000)且并发极高

3. 热点商品识别

核心思想:监控商品访问频率,热门商品用独立的高性能 Redis 集群处理。

// 初始化两个 Redis 集群
const normalRedis = new Redis({ host: 'redis-normal' });
const hotRedis = new Redis({ host: 'redis-hot', maxRetriesPerRequest: 10 });
 
async function createOrder(productId: string, userId: string) {
  // 1. 判断是否为热点商品
  const accessCount = await normalRedis.incr(`access:${productId}`);
  const isHot = accessCount > 1000;  // 访问超过 1000 次认为是热点
  
  // 2. 热点商品用高性能集群
  const redis = isHot ? hotRedis : normalRedis;
  const redlock = new Redlock([redis]);
  
  const lock = await redlock.acquire([`lock:product:${productId}`], 1000);
  
  try {
    // 业务逻辑
  } finally {
    await lock.release();
  }
}

为什么有效

正常商品(95%)-> 普通 Redis(负载低)
热点商品(5%)  -> 专用 Redis(隔离,不影响其他商品)

优势

  • 热点商品不会拖垮整个系统
  • 可以为热点 Redis 配置更高的硬件资源

代价

  • 需要维护多个 Redis 实例
  • 热点识别逻辑可能误判

适用场景:电商大促、明星商品发售

对于大多数业务,Redis 分布式锁 + 审计表已经足够。这些优化都是锦上添花,只在遇到真实瓶颈时才需要。