本文目录导读:

- 方案一:MySQL 行锁 + 事务(适合中小型项目,推荐作为首选)
- 方案二:MySQL 乐观锁(适合读多写少,冲突不严重的场景)
- 方案三:Redis 分布式锁(适合高并发、分布式架构)
- 方案四:使用消息队列(MQ,如 RabbitMQ / Kafka)
- 综合建议
- 关键补充:库存锁定的“生命周期”
这是一个非常经典的电商/交易系统设计问题,在PHP项目中实现商品库存锁定,核心目标是在高并发场景下防止超卖(卖出的商品数量超过实际库存)。
不能简单地用 if($stock > 0) { $stock--; } 这样的逻辑,因为在高并发下,多个请求可能同时读到库存为1,然后同时减去,导致库存变为负数。
以下是几种从简单到复杂、从低并发到高并发的实现方案:
MySQL 行锁 + 事务(适合中小型项目,推荐作为首选)
这是最经典、可靠且容易理解的方法,利用数据库自身的锁机制来保证库存扣减的原子性。
核心原理: 在开始扣减库存前,使用 SELECT ... FOR UPDATE 锁定该商品的行记录,直到事务提交才释放,其他请求要操作同一行时,必须等待锁释放。
实现步骤(PHP + MySQL + InnoDB):
-
创建订单表 (
orders) 和 库存表 (inventory)。inventory表结构:id,product_id,sku(库存单位,如颜色/尺码),quantity(当前可用库存),locked_quantity(锁定库存,可选),version(乐观锁备用)。
-
PHP 代码逻辑(关键部分):
<?php // 1. 确保数据库使用 InnoDB 引擎 // 2. 开始数据库事务 $db = getConnection(); $db->beginTransaction(); try { $productId = 100; // 商品ID $quantityToLock = 1; // 要锁定的数量 // 3. 关键步骤:使用 FOR UPDATE 锁定该商品的行 // 这会阻止其他事务读取或修改这一行,直到当前事务提交或回滚 $sql = "SELECT quantity FROM inventory WHERE product_id = :pid FOR UPDATE"; $stmt = $db->prepare($sql); $stmt->execute([':pid' => $productId]); $inventory = $stmt->fetch(); // 4. 检查库存是否足够 if ($inventory['quantity'] < $quantityToLock) { throw new \Exception('库存不足'); } // 5. 扣减库存(原子性操作) $updateSql = "UPDATE inventory SET quantity = quantity - :qty WHERE product_id = :pid AND quantity >= :qty"; $updateStmt = $db->prepare($updateSql); $affectedRows = $updateStmt->execute([ ':qty' => $quantityToLock, ':pid' => $productId ]); // 6. 检查是否更新成功(防止超卖) if ($updateStmt->rowCount() === 0) { throw new \Exception('库存更新失败,可能已被扣除'); } // 7. 插入订单记录(或其他业务逻辑) $orderSql = "INSERT INTO orders (product_id, user_id, status) VALUES (:pid, :uid, 'pending')"; $orderStmt = $db->prepare($orderSql); $orderStmt->execute([':pid' => $productId, ':uid' => 123]); // 8. 事务提交,释放行锁 $db->commit(); echo "订单创建成功,库存已锁定"; } catch (\Exception $e) { // 9. 回滚事务,库存自动恢复 $db->rollBack(); echo "锁定失败:" . $e->getMessage(); }
优缺点:
- 优点: 实现简单,数据强一致,保证不超卖。
- 缺点: 并发性能较低,大量请求等待行锁时,数据库压力大,不适合秒杀等极高并发场景。
MySQL 乐观锁(适合读多写少,冲突不严重的场景)
不锁行,通过版本号(或条件)来防止超卖。
核心原理: 在更新库存时,加上一个条件 WHERE version = :old_version,如果版本号变了,说明有其他请求先更新了,则本次更新失败。
<?php
// 1. 先读取当前库存和版本号
$sql = "SELECT quantity, version FROM inventory WHERE product_id = :pid";
$stmt = $db->prepare($sql);
$stmt->execute([':pid' => 100]);
$row = $stmt->fetch();
$currentQuantity = $row['quantity'];
$currentVersion = $row['version'];
// 2. 检查库存
if ($currentQuantity < 1) {
die('库存不足');
}
// 3. 尝试更新,带上版本号条件
$updateSql = "UPDATE inventory SET quantity = quantity - 1, version = version + 1
WHERE product_id = :pid AND quantity >= 1 AND version = :old_version";
$stmt = $db->prepare($updateSql);
$stmt->execute([
':pid' => 100,
':old_version' => $currentVersion
]);
if ($stmt->rowCount() === 0) {
// 说明版本号变了,有其他请求先改了,需要重试或返回失败
die('操作失败,请重试');
}
// 更新成功,继续后续流程...
优缺点:
- 优点: 没有行锁,性能比方案一高。
- 缺点: 在极端高并发下,大量请求会因版本冲突而失败,需要客户端重试机制。
Redis 分布式锁(适合高并发、分布式架构)
使用 Redis 的原子操作(如 DECR 或 Lua 脚本)或 SETNX 实现锁。
1 使用 Redis 原子操作(最简单,适合纯扣减)
利用 Redis 的 DECR 命令是原子性的,天然防止超卖。
<?php
$redis->decr('stock:product:100'); // 库存减1
// 但要注意,如果库存为0,它仍然会减为-1,所以需要前置检查。
2 使用 Lua 脚本(推荐方式,保证原子性)
Redis 的 Lua 脚本是原子执行的,可以同时完成检查和更新。
<?php
// Lua 脚本
$lua = <<<LUA
local key = KEYS[1] -- 库存的Redis key
local needed = tonumber(ARGV[1]) -- 需要锁定的数量
local stock = tonumber(redis.call('GET', key))
if stock and stock >= needed then
redis.call('DECRBY', key, needed)
return 1 -- 成功
else
return 0 -- 库存不足
end
LUA;
$result = $redis->eval($lua, ['stock:product:100', 1], 1); // 1表示有1个key
if ($result === 1) {
// 库存锁定成功,可以继续执行数据库操作
echo "锁库存成功";
} else {
echo "库存不足";
}
3 使用 RedLock 等分布式锁
更复杂但更严谨的方案,在设置锁时,给予一个过期时间(TTL),防止死锁,适用于强一致性要求的分布式系统。
优缺点:
- 优点: 性能极高,适合秒杀。
- 缺点: 需要搭建 Redis 集群,数据在 Redis 中,Redis 宕机且数据没持久化,会丢失库存信息,需要写回数据库做最终一致性。
使用消息队列(MQ,如 RabbitMQ / Kafka)
将库存锁定请求放入队列,消费者串行化处理,这是解决高并发的经典思路。
流程:
- PHP Web 端 将
{ user_id, product_id, quantity }发给消息队列。 - 消费者 Worker 从队列取出消息,单线程 或 分片锁 的方式处理库存扣减。
- 关键点: MQ 的消费端必须能保证消息不丢失、不重复消费(或业务上幂等)。
优缺点:
- 优点: 极高的吞吐量,削峰填谷,将瞬时高并发平摊到长时间。
- 缺点: 架构复杂,引入了 MQ 中间件,库存锁定不是实时的(有延迟),用户需要轮询结果。
综合建议
| 项目规模/并发量 | 推荐方案 | 理由 |
|---|---|---|
| 小型项目 (< 200 QPS) | MySQL 行锁 + 事务 | 简单,可靠,不需要额外组件。 |
| 中型项目 (< 2000 QPS) | 方案一 + 缓存预热,或 Redis Lua | 用 Redis 做流量过滤,真正扣库存还是用 MySQL 事务,或者完全Redis。 |
| 大型项目/秒杀 | Redis Lua + 消息队列 | Redis 抗峰值流量,MQ 保证最终一致性。 |
| 任何场景 | 避免使用 SELECT ... FOR UPDATE 后不立即更新 |
锁行后要尽快操作,不要做大量的业务逻辑(如调外部API),否则会严重拖慢性能。 |
关键补充:库存锁定的“生命周期”
库存锁定后,如果用户不支付怎么办?需要解锁或释放。
- 支付成功: 将“可售库存”真正扣减为已售。
UPDATE inventory SET quantity = quantity - 1, sold = sold + 1 WHERE ... - 超时未支付/取消订单: 需要将锁定的库存加回去。
UPDATE inventory SET quantity = quantity + 1 WHERE ...需要有定时任务或延迟队列(如 Redis 的 Keyspace Notification 或 MQ 的延迟队列)来扫描超时订单并释放库存。
对于大多数 PHP 项目,从方案一(MySQL事务+行锁)开始 是最稳妥的,当业务量增长到瓶颈时,再逐步引入 Redis 作为缓存层或库存主力,最后再考虑 MQ。不要一开始就追求最复杂的架构,根据真实业务流量演进。