某功能上线前,我们在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 分布式锁 + 审计表已经足够。这些优化都是锦上添花,只在遇到真实瓶颈时才需要。