PHP 8.2 并发超卖本质是库存扣减逻辑缺乏原子性,需通过数据库 WHERE 原子更新、Redis Lua 脚本、预扣减缓存等方案修复,并经压测验证。

PHP 8.2 环境下出现并发超卖,本质不是 PHP 版本导致的,而是库存扣减逻辑缺乏原子性 —— 8.2 只是让旧代码里隐藏的竞态问题更早暴露(比如废弃函数报错、类型严格化中断流程),真正要查的是“读库存→判是否够→扣库存”这三步是否被多个请求交叉执行。
先确认是不是真超卖,还是日志/监控误导
很多“疑似超卖”其实是统计口径不一致造成的:比如 Redis 缓存库存还没同步到 DB、订单生成成功但支付失败未回滚、对账脚本漏查退款单。先做三件事:
- 查数据库最终状态:
SELECT id, stock, updated_at FROM goods WHERE id = ?,看 stock 是否真为负; - 比对订单表和库存变更日志:查
orders中该商品的下单量,再查所有扣减操作记录(如库存流水表或审计日志),看是否多扣; - 检查是否有未回滚事务:运行
SHOW PROCESSLIST和SELECT * FROM information_schema.INNODB_TRX,确认有没有长期卡在UPDATE或锁等待的事务。
重点排查四类典型漏洞代码
PHP 8.2 不会引发超卖,但会让以下写法在高并发下立刻出问题:
-
先 SELECT 再 UPDATE(无锁无事务):例如用
$stock = Db::value("SELECT stock FROM goods WHERE id = ?")判断后,再执行Db::execute("UPDATE goods SET stock = stock - 1 WHERE id = ?")—— 两个请求同时读到 stock=1,都执行减 1,结果变 -1; -
用了乐观锁但没校验影响行数:比如写了
UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock >= 1,却没检查affectedRows === 1,失败时静默跳过,后续逻辑仍继续走; -
Redis 库存用 GET + DECR 拆开调用:先
$stock = $redis->get('stock:1001'),再if ($stock > 0) $redis->decr('stock:1001')—— 中间有毫秒级窗口,必然超卖; -
ThinkPHP8 的 lock(false) 或事务未生效:比如用了
->lock(true)但没包在Db::startTrans()里,或 catch 异常后忘了Db::rollback(),导致锁释放失败或事务残留。
修复必须落地到原子操作层
不能只改 PHP 语法,得确保“判断+扣减”在数据库或 Redis 内部一次完成:
立即学习“PHP免费学习笔记(深入)”;
-
MySQL 推荐用 WHERE 条件更新:
UPDATE goods SET stock = stock - ? WHERE id = ? AND stock >= ?,执行后立刻用Db::getPDO()->lastInsertId()或受影响行数判断是否成功,为 0 就是库存不足,直接返回; -
Redis 必须用 Lua 脚本封装:把 GET、判断、DECRBY 写进一个脚本,用
EVAL原子执行,避免网络往返间隙; - 不要依赖 PHP 层重试:乐观锁失败后重试 3 次,可能让请求排队更久,用户感知卡顿;应前端限流 + 后端快速拒绝,把压力挡在入口;
-
加一层预扣减缓存:用 Redis 记录“已下单未支付”数量,每次下单前先 INCR 这个预占数,再比对
real_stock - pre_sold >= buy_num,减少 DB 压力。
上线前必须做的验证
光改代码没用,得用真实并发压测确认修复有效:
- 用
ab -n 200 -c 100 http://api/reduce?pid=1001&num=1模拟 100 并发抢 1 件库存,检查最终 stock 是否为 0,订单数是否为 1; - 故意 kill 一个消费者进程,看 Redis 队列或数据库事务是否自动清理(比如锁超时、预占数 TTL 过期);
- 开启 MySQL general_log,抓取实际执行的 SQL,确认每条 UPDATE 都带了
AND stock >= N条件,没有裸 UPDATE。



















