PHP库存扣减并发安全

wen PHP项目 4

本文目录导读:

PHP库存扣减并发安全

  1. 1. 为什么你的库存扣减总出错?——并发问题的本质
  2. 2. 方案一:数据库悲观锁(SELECT FOR UPDATE)实战与坑
  3. 3. 方案二:乐观锁(版本号CAS)——高并发下的首选
  4. 4. 方案三:Redis + Lua脚本原子扣减——性能与安全兼得
  5. 5. 终极架构:预扣库存 + 定时对账补偿
  6. 6. 高频问答(FAQ):面试官最爱问的并发扣减5连问

PHP库存扣减并发安全终极指南:从悲观锁到Redis原子操作,彻底杜绝超卖**


目录导读

  1. 为什么你的库存扣减总出错?——并发问题的本质
  2. 数据库悲观锁(SELECT FOR UPDATE)实战与坑
  3. 乐观锁(版本号CAS)——高并发下的首选
  4. Redis + Lua脚本原子扣减——性能与安全兼得
  5. 终极架构:预扣库存 + 定时对账补偿机制
  6. 高频问答(FAQ):面试官最爱问的并发扣减5连问

为什么你的库存扣减总出错?——并发问题的本质

在PHP电商系统中,库存扣减是典型的“读-改-写”竞态条件(Race Condition),假设库存只剩1件,用户A和用户B同时发起请求,两者都读到库存=1,然后分别执行UPDATE stock SET num = num - 1,结果库存变成-1,这就是超卖。

核心矛盾: PHP进程(或FPM多实例)与MySQL之间的操作并非原子性,两个请求执行顺序交错时,后写覆盖先写,导致数据丢失更新。


方案一:数据库悲观锁(SELECT FOR UPDATE)实战与坑

实现逻辑:

BEGIN;
SELECT num FROM stock WHERE sku_id = 'A001' FOR UPDATE; -- 锁定该行
-- 业务判断库存 > 0
UPDATE stock SET num = num - 1 WHERE sku_id = 'A001';
COMMIT;

优点: 简单可靠,绝不超卖。 致命缺点: 行锁会阻塞其他事务,在高并发(如秒杀10万QPS)下,大量等待锁的请求会堆积,拖垮MySQL;且PHP长事务连接非常容易死锁或超时。


方案二:乐观锁(版本号CAS)——高并发下的首选

实现逻辑: 每次更新带上版本号,只有版本号匹配才更新。

// 查询带版本号
$row = $pdo->select("SELECT num, version FROM stock WHERE sku_id = 'A001'");
// 扣减操作,条件加version
$affected = $pdo->exec("UPDATE stock SET num = num - 1, version = version + 1 WHERE sku_id = 'A001' AND version = {$row['version']}");
if ($affected === 0) {
    // 重试机制,或者提示用户“抢购火爆”
}

优势: 没有数据库锁,高并发下吞吐量高。 风险: 需要处理重试(比如最多重试3次),如果更新失败率过高,说明并发冲突严重,用户体验变差。


方案三:Redis + Lua脚本原子扣减——性能与安全兼得

这是目前大型电商最常用的方案,将Redis作为库存扣减的第一道防线。

核心思路: Redis是单线程执行命令,利用Lua脚本保证原子性,避免多步操作被打断。

Lua脚本精髓:

local stock = tonumber(redis.call('get', KEYS[1]))
if stock <= 0 then
    return -1  -- 库存不足
end
if stock < tonumber(ARGV[1]) then
    return -1
end
redis.call('decrby', KEYS[1], ARGV[1])
return 1  -- 扣减成功

PHP调用代码(使用Predis/PhpRedis):

$script = <<<LUA
-- 上面那段Lua代码
LUA;
$result = $redis->eval($script, ['stock:1001', 1], 1);
if ($result === 1) {
    // 异步落库,发送消息队列(如RabbitMQ)保证最终一致性
}

关键细节: 库存预热(从MySQL加载到Redis)、设置库存过期时间防止Redis内存泄漏、扣减后通过MQ异步同步到MySQL。


终极架构:预扣库存 + 定时对账补偿

即便用了Redis,仍需考虑“Redis扣减成功,但MySQL更新失败”的数据不一致问题。

闭环方案:

  1. 预扣阶段: 在Redis中扣减库存,生成唯一订单号。
  2. 落地阶段: 发送消息队列,由消费者把订单写入MySQL,并更新MySQL库存(用乐观锁)。
  3. 对账补偿: 定时任务(如每5分钟)扫描Redis中已扣减但未落库的订单,触发重新处理;若超过30分钟未处理,则回滚Redis库存(恢复扣减数)。

高频问答(FAQ):面试官最爱问的并发扣减5连问

问题1:Redis都挂了怎么办?降级方案是什么? 答: 启用本地锁(如PHP的file_ockLOCK_EX)不现实,正确降级是:Redis故障时,直接走数据库悲观锁模式(设置锁超时短,如500ms),保证不超卖,但性能打折,更为稳妥的是开启Redis哨兵或集群模式,确保高可用。

问题2:乐观锁和悲观锁如何选? 答: 读多写少、允许偶尔重试用乐观锁;写多读少(秒杀)用Redis或悲观锁,但不要滥用悲观锁,容易死锁。

问题3:扣减成功,但支付超时取消订单,怎么恢复库存? 答: 需要设计“超时关单”机制,订单创建30分钟未支付,触发状态变更,需扣回库存(先加Redis,再异步更新MySQL),注意在MQ消息中带上唯一业务ID,防止重复加库存。

问题4:多SKU的购物车批量扣减,如何保证原子性? 答: 可以在Redis中利用MULTI事务或者一个Lua脚本里循环扣减,但要注意SKU的锁顺序,避免死锁(按SKU ID升序排序)。

问题5:PHP的Apache或Nginx多进程,如何共享锁? 答: 不能用本地文件锁,必须依赖外部服务(Redis、MySQL行锁、Memcached的ADD操作),PHP-FPM的进程间是不共享内存的,所以分布式锁必须放中间件。


库存扣减没有银弹,无论选择哪种方案,核心原则是“牺牲一定的性能,换取数据的绝对准确”,先用Redis扛住高并发流量,再用异步任务保证最终一致性,最后用对账兜底,这才是生产级稳定架构,在实际项目中,建议先压测确定QPS,再选型,切勿盲目追求“高级方案”。

抱歉,评论功能暂时关闭!