本文目录导读:

在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 库存(用定时任务补偿)
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播