库存扣减必须用数据库行锁,不能依赖php变量或缓存;正确做法是事务内select ... for update加锁后检查并更新库存;redis分布式锁需用set nx ex加锁和lua脚本解锁;预扣减+异步核销可平衡一致性与性能;超卖兜底靠日志、监控与告警。

库存扣减必须用数据库行锁,不能靠 PHP 变量或缓存计数
PHP 本身是无状态的,每次请求都是新进程/协程,$_SESSION、static 变量、甚至 apcu_store() 都无法跨请求保证原子性。高并发下靠 PHP 层“先查后减”必然超卖——两个请求同时读到库存=1,都判断通过,然后都写入0,实际卖出2件。
正确做法是把扣减逻辑下沉到数据库层,依赖事务 + 行级锁:
- 用
SELECT ... FOR UPDATE锁住目标商品行(需 InnoDB + 主键或唯一索引) - 在同一个事务里完成「检查库存」+「更新库存」,避免锁释放后被其他事务插队
- 务必检查
UPDATE影响行数,为 0 说明库存不足,不是 SQL 报错才算失败
START TRANSACTION; SELECT stock FROM products WHERE id = 123 FOR UPDATE; -- 检查 stock > 0,再执行: UPDATE products SET stock = stock - 1 WHERE id = 123 AND stock >= 1; -- 查看 mysqli_affected_rows() 或 PDO::rowCount() 是否为 1 COMMIT;
Redis 实现分布式锁要防死锁和误删,推荐 set nx ex 原子指令
纯数据库锁有性能瓶颈,尤其秒杀场景。Redis 是常用补充方案,但直接用 SET key value + DEL 极易出问题:进程崩溃没删锁、过期时间设太短导致锁提前释放、A 进程误删 B 进程的锁。
安全做法只用 Redis 原生命令组合,不依赖客户端逻辑:
立即学习“PHP免费学习笔记(深入)”;
- 加锁必须用
SET key random_value NX EX seconds,NX保证原子性,random_value用于后续校验防止误删 - 解锁必须用 Lua 脚本,先比对 value 再
DEL,避免 A 删除 B 的锁:EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock_key abc123 - 锁过期时间要明显大于业务最大执行时间(比如下单逻辑预估 500ms,设 3s),且业务内必须设超时兜底
库存预扣减 + 异步核销,适合订单创建与支付分离的场景
用户下单成功 ≠ 支付成功。如果等支付回调才扣库存,中间窗口期可能被恶意占单;如果下单即扣库,支付失败又得回滚,增加复杂度。
更合理的分阶段处理:
- 下单时用 Redis
DECR预扣减,key 为stock:123,值为剩余可售数;返回成功即锁定该库存额度 - 支付成功后,异步消息(如 RabbitMQ / Kafka)触发最终扣减 MySQL 库存,并更新订单状态
- 支付超时(比如 15 分钟)未支付,用定时任务或 Redis 过期监听(Keyspace Notifications)自动
INCR回滚预扣库存
注意:预扣减的 Redis key 必须设置 EX 过期时间,否则超时未清理会导致库存永久冻结。
超卖兜底必须有,日志和告警要比“技术完美”优先级更高
再严密的锁机制也扛不住极端情况:主从延迟导致从库读到旧库存、Redis 故障降级到 DB 但没切回、Lua 脚本执行异常……线上必须接受“小概率超卖”,重点放在快速发现和人工干预。
上线前至少配好三件事:
- 所有库存变更操作记录完整日志,包含订单号、商品 ID、操作前/后库存、时间戳、操作人(或服务名)
- 每小时跑一次
SELECT * FROM orders WHERE status = 'paid' AND item_id = ? AND NOT EXISTS (SELECT 1 FROM inventory_logs WHERE order_id = orders.id)找漏记日志的订单 - 监控 Redis
stock:*key 的 TTL 和数值趋势,MySQL 库存字段突变为负数立即短信告警
真正难的不是写出不超卖的代码,而是让超卖发生时,你能在 5 分钟内定位到哪一行日志、哪个服务节点、哪次部署引入了偏差。











