Hyperf中裸用SELECT FOR UPDATE扣库存易致死锁,根本原因是事务加锁顺序不一致、未走主键/唯一索引、事务过长;须统一主键升序加锁、显式超时、开启innodb_print_all_deadlocks日志定位,并优先用Redis+Lua替代。

Hyperf 3.1 中用数据库悲观锁(如 SELECT ... FOR UPDATE)扣库存,本身不是错,但直接在协程高并发下裸用,极易触发死锁——这不是锁用得不对,而是没管住事务边界、索引路径和执行顺序。
统一加锁顺序 + 主键驱动是防死锁的底线
死锁最常见原因:多个事务以不同顺序访问同一组行。比如 A 先锁商品再锁订单,B 先锁订单再锁商品,循环等待即死锁。
- 所有涉及多表或同表多行的更新,必须严格按**固定顺序**加锁,推荐统一按主键升序排列后批量锁定
- 避免
WHERE name = ?这类条件加锁——若name无索引,InnoDB 会升级为表锁,所有并发请求全卡住 - 扣库存务必走主键或唯一索引(如
WHERE sku_id = ?),确保只锁目标行,不波及无关数据
显式控制事务生命周期,绝不让锁“挂太久”
协程轻量,但 DB::transaction() 块内哪怕只有一条 FOR UPDATE,只要没提交,锁就一直占着。协程并发一多,锁等待链迅速变长。
- 事务块内只做必要操作:查库存 → 判断是否充足 → 扣减 → 提交,中间别掺杂 Redis 调用、HTTP 请求、日志写入等耗时逻辑
- 设置显式超时:
DB::transaction(fn () => { ... }, 5)(单位秒),超时自动回滚,避免锁长期滞留 - 禁用长连接空闲超时以外的任何“自动续期”,MySQL 的
wait_timeout不影响事务锁持有时间
开启死锁日志,让 MySQL 主动告诉你哪里错了
Hyperf 默认不暴露死锁细节,靠业务层捕获 Deadlock found 异常只能知道“发生了”,不知道“怎么发生的”。
- 在 MySQL 配置中启用:
innodb_print_all_deadlocks = ON和log_error_verbosity = 3 - 复现后立刻查
/var/log/mysql/error.log,日志里会明确写出两个事务各自持有什么锁、等待什么锁、执行哪条 SQL - 重点看
*** (1) TRANSACTION和*** (2) TRANSACTION段,比对它们的 WHERE 条件和索引使用是否一致
高并发扣库存,优先用 Redis+Lua 替代数据库行锁
悲观锁本质是串行化,扛不住秒杀级并发。真要强一致性+高性能,应把库存热点从 MySQL 搬到 Redis。
- 用 Lua 脚本原子执行“读库存 → 判断 ≥1 → 扣减 → 写回”,三步不可拆分,无竞态
- Hyperf 的
redis->eval()支持原生 Lua,注意禁用 pipeline 模式(否则EVAL会被拦截) - 库存扣完后,可异步回写 MySQL 做最终持久化,用 Saga 或本地消息表保证最终一致


















