自底向上加锁能避免死锁,因其强制所有事务按唯一确定的物理顺序(叶子→根)获取锁,消除交叉等待;关键需基于真实父子关系递归查出完整路径并严格按深度倒序加锁,且全程在单事务内显式、逐个执行SELECT ... FOR UPDATE。

为什么自底向上加锁能避免死锁
死锁常发生在多个事务按不同顺序更新同一棵树的节点时。比如事务A先锁node_id=5再锁node_id=2,事务B反着来,就卡住。自底向上(从叶子往根)加锁,本质是强制所有事务按**唯一确定的物理顺序**获取锁——只要大家都先锁子节点、再锁父节点,就不会出现交叉等待。
关键点在于:树的层级关系必须可排序,且锁序与树结构严格一致。否则看似“自底向上”,实际仍可能因路径歧义(如多父节点、环、缓存延迟)导致顺序不一致。
- 必须用
SELECT ... FOR UPDATE显式加锁,不能依赖隐式锁或应用层判断 - 层级排序不能靠
ORDER BY level DESC猜测,得基于真实父子关系递归查出完整路径并排序 - MySQL 8.0+ 可用 CTE 拉取路径,但需注意 CTE 中
FOR UPDATE的限制(不支持递归 CTE 直接加锁)
如何安全获取从叶子到根的锁定顺序
不能靠前端传来的“当前节点ID”倒推,因为一个节点可能有多个父节点(非标准树),或路径被并发修改。必须在数据库内完成闭环查询和排序。
典型做法:用临时表或 CTE 先展开路径,再按深度倒序排列,最后逐行加锁。例如更新 node_id=7 及其所有祖先:
WITH RECURSIVE path AS ( SELECT id, parent_id, 1 AS depth FROM tree WHERE id = 7 UNION ALL SELECT t.id, t.parent_id, p.depth + 1 FROM tree t INNER JOIN path p ON t.id = p.parent_id ) SELECT id FROM path ORDER BY depth DESC;
拿到结果后,在应用中循环执行 SELECT * FROM tree WHERE id = ? FOR UPDATE,按输出顺序依次锁 id。注意:整个过程必须在同一个事务里,且中间不能有其他非锁操作打断。
- CTE 查询本身不加锁,只是生成 ID 列表;真正加锁靠后续的独立
SELECT ... FOR UPDATE - 若树深过大(>100),避免一次性拉全路径,可分段查,但每段仍要保证局部顺序一致
- PostgreSQL 支持
SELECT ... FOR UPDATE SKIP LOCKED配合排序,MySQL 不支持该组合,别误用
UPDATE 语句里混用锁和非锁操作的坑
常见错误是写成 UPDATE tree SET ... WHERE id IN (SELECT ...) —— 这种写法在 MySQL 中可能只对结果集加锁,不保证中间路径节点被锁,更不保证顺序。死锁风险照旧。
真正可控的方式,是把“查路径”和“加锁”拆开,且锁必须显式、逐个、有序。
- 禁止在
UPDATE的WHERE子句里嵌套子查询来“间接”锁祖先节点 - 避免在事务中先
SELECT读数据,再根据结果拼UPDATE—— 两次查询间可能有其他事务修改树结构 - 如果业务允许,优先用
UPDATE ... WHERE id = ?单行锁 + 应用层控制调用顺序,比复杂 SQL 更可靠
MySQL 5.7 和 8.0 在树形锁上的实际差异
MySQL 5.7 不支持递归 CTE,查路径得靠存储过程或应用层多次查询,容易漏锁或错序;MySQL 8.0+ 虽支持 CTE,但 FOR UPDATE 不能直接加在递归 CTE 上,仍得走“查ID列表 → 分别锁”两步。
另一个隐形差异:InnoDB 的 next-key lock 行为在不同版本对 ORDER BY 加锁范围影响不同。例如 SELECT id FROM tree WHERE parent_id = 2 ORDER BY id FOR UPDATE,在 5.7 可能锁住间隙,在 8.0 可能更精确——但这不是树形更新的重点,重点还是你是否真按路径顺序拿到了锁。
- 别迷信版本新就自动安全,8.0 的 CTE 只是让路径查询更简洁,不解决锁序问题
- 跨版本迁移时,检查所有树形更新逻辑是否仍满足“先子后父”的锁获取顺序,而不是只看 SQL 写法是否变短
- 测试死锁不能只靠单线程压测,得用至少两个并发事务,模拟真实更新路径交叉场景
事情说清了就结束。最麻烦的从来不是怎么写 SQL,而是怎么确保每次执行时,所有并发事务看到的“底”和“顶”是一致的——树结构一动,锁序就可能失效。










