库存扣减失败但没报错,大概率是误用get+set或未用decrby原子操作;应以Redis为准,用Lua脚本打包校验与扣减,并保障DB更新失败可告警重试。

库存扣减失败但没报错,大概率是没用对 decrby 或漏了事务边界
ThinkPHP 自带的数据库扣减(比如 $model->where(...)->setDec('stock', 1))在并发下会丢库存,不是因为写法错,而是 MySQL 行锁只在语句执行期间生效,两个请求同时读到 stock=100,各自减 1 后都写回 99。Redis 的 decrby 才是真原子——它不依赖客户端判断,命令进队列就执行,结果立刻返回。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 别用
get+set模拟扣减,这是经典幻读陷阱 - 用
decrby直接操作,返回值就是扣完后的剩余量,必须检查返回值是否 ≥ 0,负数说明超卖了 - 如果业务需要扣减前校验状态(比如商品是否上架),得把校验逻辑也挪到 Lua 脚本里,否则 Redis 和 DB 状态又不同步
- ThinkPHP 用
Cache::store('redis')->decrby($key, $num)即可,注意 store 名要和配置一致,别默认用 file
为什么不能只靠 setnx 加锁再扣库?
setnx 是单点原子设值,但它只保证“能抢到锁”,不保证“锁住后操作一定成功”。常见翻车场景:进程 A 抢到锁 → 查询库存 → 判断够 → 扣减 DB → 网络抖动卡住 → 锁自动过期 → 进程 B 抢到锁 → 又扣一次。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 加锁后所有操作必须在同一个 Redis 连接中完成,且用 Lua 脚本打包,例如:先
exists校验商品状态,再decrby,再get返回结果,三步不可拆 - 锁的过期时间别设太短(比如 500ms),要覆盖完整业务耗时;也别太长(比如 30s),否则故障时积压严重
- ThinkPHP 中避免用
Cache::lock()做库存锁——它底层是setnx+expire,两步非原子,有竞态窗口
Redis 库存 key 设计不当,会导致缓存击穿或误共享
用固定 key(如 stock:common)扣所有商品库存,看着省事,实际一商品热销就把其他商品的扣减全堵住;用动态 key 但没加业务隔离(比如 stock:{$goods_id}),又可能被恶意刷大量不存在的 ID,打穿 Redis。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- key 必须含业务维度,标准格式:
stock:goods:{$goods_id},别省略冒号或用下划线 - 初始化库存时,用
setnx设置初始值,并显式设过期时间(比如 24h),防止冷数据长期占内存 - 前端提交时校验
$goods_id是否合法,后端接口第一行就做白名单过滤,别让非法 ID 进 Redis 流程 - 监控
redis_keyspace中expired和evicted指标,突增说明 key 设计或过期策略有问题
DB 和 Redis 库存不一致,不是同步时机问题,是根本没做最终一致性保障
很多人以为“下单成功后异步更新 Redis”就够了,但异步失败不会重试,DB 扣了 1,Redis 还剩 100,下次请求直接从 Redis 扣,就超卖。真正靠谱的做法是:以 Redis 为准,DB 更新失败要告警+人工修复,而不是反过来。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 下单流程终点不是“写 DB 成功”,而是“Redis 扣减成功 + DB 写入发起(哪怕只是发 MQ)”
- DB 更新失败时,记录日志并触发告警(比如企业微信机器人),同时把失败订单 ID 推到延迟队列,10 分钟后重试
- 每天跑一个对账脚本,比对 Redis key 和 DB
stock字段,差值 > 0 的商品人工介入;差值 - 别在 ThinkPHP 的模型事件(如
afterSave)里同步 Redis——模型可能被批量调用,上下文混乱,容易漏更新
Redis 原子性只解决单命令执行不中断,但库存业务从来不是单命令的事。从 key 命名、Lua 封装、异常分支、到最终对账,每层少一个兜底,高并发下就漏一个库存。最常被跳过的其实是日志埋点——没记录每次 decrby 的返回值和对应订单号,出问题时连哪次扣错了都查不到。


















