本文目录导读:

在PHP项目中处理库存扣减与超卖,通常面临高并发和数据一致性两个核心挑战,下面将从底层原理分析到具体代码实现,给出完整的解决方案。
超卖的本质与常见场景
超卖:当多个用户同时请求购买同一商品,且库存只剩1件时,如果系统判断“库存 > 0”后扣减,但实际执行的扣减次数大于实际库存,就会卖出超出库存的商品。
常见的失误代码(典型超卖原因):
// ❌ 错误示例:并发下会超卖
$stock = $redis->get('product:100:stock');
if ($stock > 0) {
// 模拟耗时操作(网络延迟、DB查询等)
$redis->decr('product:100:stock'); // 扣库存
// 插入订单...
}
原因:get 和 decr 之间不是原子操作,多个请求可能同时读到 stock=1。
解决方案层级图(从简单到复杂)
| 方案 | 适用场景 | 技术核心 | 性能 |
|---|---|---|---|
| 数据库乐观锁 | 中小流量 | UPDATE ... WHERE stock > 0 |
较低(依赖DB) |
| Redis Lua 脚本 | 高并发 | 原子性扣减 | 高 |
| Redis 原子操作 + 队列 | 极高并发 | DECR + 异步订单 |
极高 |
| 消息队列(RabbitMQ/Kafka) | 超高并发 | 削峰填谷 | 最高 |
完整代码实现(最优方案:Redis + Lua)
这是目前 PHP + Redis 最推荐的方式,Lua脚本保证原子性,避免超卖。
环境准备
确保已安装:
- PHP 7.4+(推荐8.0+)
- Redis 扩展(
phpredis或Predis)
Redis 库存初始化
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// 商品ID假设为 100
$productId = 100;
$initialStock = 10; // 初始库存
// 初始化库存(只在第一次运行)
$redis->set("product:{$productId}:stock", $initialStock);
$redis->set("product:{$productId}:sold", 0); // 已售数量
核心方案:Lua 脚本原子扣减
/**
* 原子扣减库存
* @param int $productId 商品ID
* @param int $quantity 购买数量
* @return bool|string 成功返回订单号,失败返回 false
*/
function deductStock($productId, $quantity = 1) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// Lua 脚本(原子操作)
$lua = <<<LUA
-- 检查库存是否存在
if redis.call('EXISTS', KEYS[1]) == 0 then
return -1 -- 库存不存在
end
-- 获取当前库存
local stock = tonumber(redis.call('GET', KEYS[1]))
-- 检查库存是否充足
if stock < tonumber(ARGV[1]) then
return 0 -- 库存不足
end
-- 扣减库存(原子递减)
redis.call('DECRBY', KEYS[1], ARGV[1])
-- 增加已售数量
redis.call('INCRBY', KEYS[2], ARGV[1])
-- 生成唯一订单号
local order_id = 'ORD' .. ARGV[2] .. '_' .. redis.call('INCR', KEYS[3])
-- 将订单号存入集合(用于后续异步处理)
redis.call('RPUSH', KEYS[4], order_id)
return order_id
LUA;
// 执行 Lua 脚本
$keys = [
"product:{$productId}:stock", // KEYS[1] 库存键
"product:{$productId}:sold", // KEYS[2] 已售键
"order:seq", // KEYS[3] 订单序列号
"order:pending", // KEYS[4] 待处理订单队列
];
$args = [
$quantity, // ARGV[1] 扣减数量
$productId, // ARGV[2] 商品ID(拼订单号用)
];
$result = $redis->eval($lua, $keys, $args);
if ($result === 0) {
return false; // 库存不足
} elseif ($result === -1) {
throw new Exception("商品库存不存在");
}
return $result; // 返回订单号
}
完整下单流程(含订单写入)
function createOrder($userId, $productId, $quantity) {
// 步骤1:原子扣减库存
$orderId = deductStock($productId, $quantity);
if (!$orderId) {
return ['success' => false, 'message' => '库存不足'];
}
// 步骤2:异步处理订单(通过队列或直接入库)
// 简化:直接写入数据库,实际业务建议用MQ
$pdo = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
$pdo->beginTransaction();
try {
// 插入订单表
$stmt = $pdo->prepare("INSERT INTO orders (order_no, user_id, product_id, quantity, status) VALUES (?,?,?,?, 'paid')");
$stmt->execute([$orderId, $userId, $productId, $quantity]);
$pdo->commit();
return ['success' => true, 'order_no' => $orderId];
} catch (Exception $e) {
$pdo->rollBack();
// 订单写入失败需要回补库存
rollbackStock($productId, $quantity, $orderId);
return ['success' => false, 'message' => '订单创建失败'];
}
}
// 回补库存(订单失败时执行)
function rollbackStock($productId, $quantity, $orderId) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// 使用 Lua 保证原子回补(同时检查订单是否真是失败)
$lua = <<<LUA
local order_no = ARGV[1]
local product_stock_key = KEYS[1]
local sold_key = KEYS[2]
local pending_list = KEYS[3]
-- 检查订单是否不存在(防止重复回补)
if redis.call('SISMEMBER', pending_list, order_no) == 1 then
-- 回补库存
redis.call('INCRBY', product_stock_key, ARGV[2])
redis.call('DECRBY', sold_key, ARGV[2])
-- 删除待处理记录
redis.call('LREM', pending_list, 1, order_no)
return 1
end
return 0
LUA;
$redis->eval($lua, [
"product:{$productId}:stock",
"product:{$productId}:sold",
"order:pending"
], [$orderId, $quantity]);
}
高并发压测验证
// 压测脚本(模拟100个并发请求)
$productId = 100;
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->set("product:{$productId}:stock", 1); // 只有1件库存
// 模拟并发
$curlMulti = curl_multi_init();
$urls = [];
for ($i = 0; $i < 10; $i++) {
$ch = curl_init("http://your-server/buy.php?product={$productId}&qty=1");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($curlMulti, $ch);
}
$running = null;
do {
curl_multi_exec($curlMulti, $running);
} while ($running > 0);
// 检查最终库存
$finalStock = $redis->get("product:{$productId}:stock");
echo "最终库存: {$finalStock}\n"; // 应该是 0,绝不会出现负数
补充优化方案(根据流量选择)
方案A:数据库乐观锁(中小流量)
// 数据库表结构
// product 表 (id, stock, version)
// 扣减库存(乐观锁方式)
$sql = "UPDATE product
SET stock = stock - :qty, version = version + 1
WHERE id = :id AND stock >= :qty AND version = :version";
$stmt = $pdo->prepare($sql);
$stmt->execute([':qty' => $quantity, ':id' => $productId, ':version' => $currentVersion]);
if ($stmt->rowCount() === 0) {
// 库存不足或版本冲突,需重试
}
方案B:Redis 原子操作简化版(低并发场景)
// 原子递减(不会超卖)
$remaining = $redis->decr("product:{$id}:stock");
if ($remaining >= 0) {
// 成功
} else {
// 失败,回补
$redis->incr("product:{$id}:stock");
}
关键设计原则总结
- 原子性:务必使用 Redis Lua 脚本或数据库
UPDATE + WHERE条件保证原子性。 - 避免“先查后扣”:任何
get > check > decr的非原子操作都可能导致超卖。 - 订单与库存解耦:库存扣减成功后再生成订单;订单失败要回补。
- 监控与告警:监听 Redis 库存键,当库存
<=阈值时触发补货或警告。 - 超时与重试:在高并发下,数据库写订单可能超时,需设置合理的重试策略。
最终建议
| 项目阶段 | 推荐方案 |
|---|---|
| 创业初期(日流量 <1万) | 数据库乐观锁(方案A) |
| 成长阶段(日流量 <50万) | Redis + Lua(推荐方案) |
| 大型电商(日流量 >100万) | Redis Lua + 消息队列(消峰)+ 分库分表 |
最核心的代码就是上面的 Lua 脚本,它解决了99%的并发超卖问题,实际生产环境中,你还需要:
- 使用 Redis 集群 分散压力
- 设置 库存预热缓存 避免冷启动
- 定期 同步 Redis 与 MySQL 库存(用定时任务补偿)