1. 问题现象与业务背景
在电商系统中,库存管理是最核心的模块之一。我们团队最近遇到一个典型的并发问题:当用户下单后系统预扣减库存,但如果用户未在限定时间内支付,理论上应该自动释放库存。然而实际运行中,部分库存却像被"幽灵"锁定一样无法释放,导致超卖和库存不一致。
这种情况通常出现在大促期间,当并发量达到峰值时尤为明显。从业务角度看,这直接影响了转化率——真实库存被无效订单占用,新用户无法下单,而运营人员看到的库存数据又与实际可售数量不符。
2. 技术原理深度解析
2.1 传统库存扣减流程
典型的PHP库存扣减流程是这样的:
php复制// 下单时预扣减
UPDATE products SET stock = stock - 1 WHERE id = 123 AND stock >= 1;
// 支付超时后释放
UPDATE products SET stock = stock + 1 WHERE id = 123;
这个方案在低并发时没有问题,但在高并发场景下会出现多个致命问题:
- 扣减和释放不是原子操作
- 缺乏事务边界控制
- 没有版本控制机制
- 超时判定与库存释放存在时间差
2.2 问题根因分析
通过日志分析,我们发现"幽灵锁"主要发生在以下场景时序中:
- 用户A下单,库存从100扣减到99(剩余99)
- 用户A未支付,系统触发释放逻辑
- 同时用户B下单,库存从99扣减到98
- 用户A的释放逻辑执行,库存从98增加到99
- 此时用户C下单,理论上可以购买,但系统却提示库存不足
根本原因是释放操作基于当前库存值进行+1,而不是基于原始扣减值回滚。这种设计在并发场景下必然导致数据不一致。
3. 解决方案设计与实现
3.1 方案选型对比
我们评估了三种主流解决方案:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 预扣记录表 | 单独记录扣减明细 | 可精准回滚 | 需要联表查询 | 中小型系统 |
| 版本号控制 | 每次更新校验版本 | 实现简单 | 需要重试机制 | 读多写少 |
| 分布式锁 | 强一致性控制 | 可靠性高 | 性能损耗大 | 高并发场景 |
最终选择预扣记录表+版本号控制的混合方案,在保证精度的同时兼顾性能。
3.2 具体实现代码
库存预扣表设计:
sql复制CREATE TABLE inventory_holds (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id INT NOT NULL,
order_id VARCHAR(32) NOT NULL,
quantity INT NOT NULL,
status TINYINT DEFAULT 1 COMMENT '1-预扣 2-确认 3-释放',
version INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expired_at TIMESTAMP,
KEY idx_product (product_id),
KEY idx_order (order_id),
KEY idx_expire (expired_at, status)
);
优化后的库存操作:
php复制// 预扣减
function deductInventory($productId, $orderId, $quantity) {
DB::transaction(function() use ($productId, $orderId, $quantity) {
// 检查实际库存
$product = DB::table('products')
->where('id', $productId)
->lockForUpdate()
->first();
if ($product->available_stock < $quantity) {
throw new Exception('库存不足');
}
// 创建预扣记录
DB::table('inventory_holds')->insert([
'product_id' => $productId,
'order_id' => $orderId,
'quantity' => $quantity,
'status' => 1,
'expired_at' => now()->addMinutes(15)
]);
// 更新可用库存
DB::table('products')
->where('id', $productId)
->where('version', $product->version)
->update([
'available_stock' => $product->available_stock - $quantity,
'version' => $product->version + 1
]);
});
}
// 释放库存
function releaseInventory($orderId) {
DB::transaction(function() use ($orderId) {
$hold = DB::table('inventory_holds')
->where('order_id', $orderId)
->where('status', 1)
->lockForUpdate()
->first();
if (!$hold) return;
$product = DB::table('products')
->where('id', $hold->product_id)
->lockForUpdate()
->first();
DB::table('inventory_holds')
->where('id', $hold->id)
->update(['status' => 3]);
DB::table('products')
->where('id', $hold->product_id)
->where('version', $product->version)
->update([
'available_stock' => $product->available_stock + $hold->quantity,
'version' => $product->version + 1
]);
});
}
3.3 超时处理优化
我们引入了延迟队列处理超时订单:
php复制// 下单时发送延迟消息
RabbitMQ::publish('order.timeout.check', [
'order_id' => $orderId
], ['x-delay' => 900000]); // 15分钟延迟
// 消费者处理
function checkOrderPayment($orderId) {
$order = getOrder($orderId);
if ($order->status == 'unpaid') {
releaseInventory($orderId);
cancelOrder($orderId);
}
}
4. 性能优化与注意事项
4.1 关键优化点
-
索引优化:
- inventory_holds表的product_id、order_id、status复合索引
- 添加expired_at索引用于快速查找超时订单
-
批量处理:
php复制// 批量释放超时库存 function batchReleaseExpired() { $limit = 100; do { $holds = DB::table('inventory_holds') ->where('status', 1) ->where('expired_at', '<', now()) ->limit($limit) ->pluck('order_id'); foreach ($holds as $orderId) { releaseInventory($orderId); } sleep(1); // 防止数据库压力过大 } while (!empty($holds)); } -
缓存策略:
- 使用Redis缓存商品可用库存
- 采用Lua脚本保证原子性:
lua复制local key = KEYS[1] local change = tonumber(ARGV[1]) local stock = tonumber(redis.call('GET', key)) if stock >= change then redis.call('DECRBY', key, change) return 1 else return 0 end
4.2 必须避免的坑
-
不要依赖PHP的session:库存操作必须基于数据库事务,PHP的session机制在并发下不可靠
-
避免双重释放:释放操作必须检查状态,防止定时任务和用户取消同时触发
-
注意锁粒度:
- 商品维度的行锁足够,不需要表锁
- 锁的持有时间要尽可能短
-
事务隔离级别:
php复制DB::transaction(function() { // 使用REPEATABLE READ防止幻读 DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ'); // ...业务逻辑 }, 3); // 最多重试3次 -
监控指标:
- 库存操作耗时
- 预扣失败率
- 释放操作成功率
- 库存数据一致性校验
5. 验证与测试方案
5.1 并发测试脚本
使用Apache Benchmark模拟并发:
bash复制ab -n 1000 -c 100 "http://api.example.com/order/create?product_id=123"
同时运行库存检查脚本:
php复制while (true) {
$stock = getRealStock(123);
$cacheStock = getCacheStock(123);
if ($stock != $cacheStock) {
alert("库存不一致: DB=$stock, Cache=$cacheStock");
}
sleep(1);
}
5.2 测试用例设计
-
正常流程测试:
- 下单扣减 → 支付 → 库存确认
- 下单扣减 → 超时 → 自动释放
-
异常场景测试:
- 库存不足时下单
- 重复释放同一订单
- 高并发下库存准确性
- 网络超时后的重试机制
-
边界条件测试:
- 最后一个库存的抢占
- 批量操作时的部分成功
- 系统崩溃后的数据恢复
6. 线上部署策略
6.1 灰度发布方案
- 先对非核心商品启用新逻辑
- 对比新旧系统的库存数据
- 逐步扩大范围直至全量
6.2 回滚机制
保留旧代码的同时:
php复制if (config('inventory.new_version')) {
// 新逻辑
} else {
// 旧逻辑
}
出现问题时可通过配置秒级切换回旧版。
6.3 数据迁移
对于已有"幽灵库存"的处理:
sql复制-- 找出实际库存与预扣记录不符的商品
UPDATE products p
SET available_stock = (
SELECT p.stock - IFNULL(SUM(h.quantity), 0)
FROM inventory_holds h
WHERE h.product_id = p.id AND h.status = 1
)
WHERE EXISTS (
SELECT 1 FROM inventory_holds h
WHERE h.product_id = p.id AND h.status = 1
);
这套方案上线后,我们的库存不一致问题从每天的50+次降为0,超卖投诉减少了98%。最大的收获是认识到:在高并发场景下,任何简单的"读-改-写"模式都需要额外的并发控制机制。