层级树状数据广泛存在于各类业务系统中,比如商品分类、部门架构、评论回复等场景,这类数据通常通过父节点ID关联形成树形结构。当需要对树状数据进行批量更新时,比如调整某个节点的层级位置、修改整棵子树的属性,多事务并发操作很容易因为加锁顺序不一致引发死锁。自底向上的加锁策略是解决这类问题的有效方案,通过固定从叶子节点到根节点的加锁顺序,避免循环等待的死锁条件。

层级树状数据的常见存储结构
最常见的树状数据存储方式是邻接表模型,通过parent_id字段记录节点的父节点,结构示例如下:
-- 树状节点表结构
CREATE TABLE tree_node (
id INT PRIMARY KEY AUTO_INCREMENT,
node_name VARCHAR(50) NOT NULL,
parent_id INT DEFAULT NULL,
node_level INT NOT NULL,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 外键关联父节点,可选
FOREIGN KEY (parent_id) REFERENCES tree_node(id)
);
假设我们有如下树形结构:根节点A(id=1),子节点B(id=2)、C(id=3),B的子节点D(id=4)、E(id=5),这就是典型的邻接表存储的层级树。
死锁产生的核心原因
当多个事务同时更新同一棵树的节点时,如果加锁顺序不一致,就会产生死锁。比如事务1先更新节点B(id=2),再尝试更新节点A(id=1);事务2先更新节点A(id=1),再尝试更新节点B(id=2)。此时事务1持有B的锁等待A的锁,事务2持有A的锁等待B的锁,就会形成循环等待,触发死锁。
死锁的四个必要条件是互斥、占有且等待、不可剥夺、循环等待,自底向上的加锁策略就是破坏循环等待条件,让所有事务都按照相同的顺序加锁。
自底向上加锁的实现逻辑
自底向上加锁的核心思路是:更新某个节点时,先锁定该节点的所有子节点,再逐层向上锁定父节点,直到根节点。这样所有事务的加锁顺序都是从叶子节点到根节点,不会出现循环等待。
步骤1:获取目标节点的所有子节点ID
首先需要递归查询出目标节点的所有后代节点ID,包括自身。不同数据库递归查询语法略有差异,MySQL 8.0+可以使用CTE实现:
-- 查询id=2的节点(B节点)的所有后代节点ID,包含自身
WITH RECURSIVE sub_tree AS (
SELECT id, parent_id FROM tree_node WHERE id = 2
UNION ALL
SELECT t.id, t.parent_id FROM tree_node t
INNER JOIN sub_tree st ON t.parent_id = st.id
)
SELECT id FROM sub_tree;
上述查询会返回节点2、4、5,也就是B节点及其所有子节点。
步骤2:按ID升序加锁
获取到所有需要更新的节点ID后,按照ID从小到大排序,依次对这些行加排他锁。因为ID通常是自增的,子节点的ID一般大于父节点,按ID升序加锁就相当于自底向上加锁:
-- 开启事务
START TRANSACTION;
-- 查询并锁定所有后代节点,按ID升序加锁
WITH RECURSIVE sub_tree AS (
SELECT id, parent_id FROM tree_node WHERE id = 2
UNION ALL
SELECT t.id, t.parent_id FROM tree_node t
INNER JOIN sub_tree st ON t.parent_id = st.id
)
SELECT id, node_name, node_level FROM sub_tree ORDER BY id ASC FOR UPDATE;
-- 执行更新操作,比如更新所有子树的层级
UPDATE tree_node SET node_level = node_level + 1
WHERE id IN (
WITH RECURSIVE sub_tree AS (
SELECT id FROM tree_node WHERE id = 2
UNION ALL
SELECT t.id FROM tree_node t
INNER JOIN sub_tree st ON t.parent_id = st.id
)
SELECT id FROM sub_tree
);
-- 提交事务
COMMIT;
步骤3:处理跨树的更新场景
如果需要同时更新多个不相关的树节点,需要先对所有目标节点的根节点ID排序,再逐个处理每个节点的子树,避免不同子树之间的加锁顺序冲突。
策略的注意事项
- 递归查询的性能:如果树层级很深、节点数量很多,递归查询可能会有性能问题,可以结合冗余的层级路径字段优化查询,比如增加
path字段存储从根节点到当前节点的路径,如/1/2/4。 - 锁的持有时间:尽量缩短事务长度,在加锁后尽快完成更新操作并提交,减少锁的持有时间,提升并发性能。
- 隔离级别选择:建议使用可重复读或者串行化隔离级别,避免幻读导致漏加锁的问题,不同数据库的默认隔离级别不同,需要根据实际情况调整。
- 避免长事务:如果事务中包含大量节点的更新,会持有大量行锁,容易阻塞其他事务,建议批量操作时分批次处理。
不同数据库的适配说明
上述示例基于MySQL 8.0+,如果是其他数据库,递归查询语法需要调整:
| 数据库类型 | 递归查询语法 |
|---|---|
| PostgreSQL | 使用WITH RECURSIVE,语法和MySQL类似 |
| SQL Server | 使用WITH RECURSIVE或者CTE递归查询 |
| Oracle | 使用CONNECT BY PRIOR语法,比如SELECT id FROM tree_node START WITH id=2 CONNECT BY PRIOR id = parent_id |
加锁的核心逻辑是一致的,都是先获取所有后代节点,再按固定顺序加锁,不同数据库只是查询语法有差异。
策略效果验证
我们可以通过模拟两个并发事务来验证死锁是否被避免:
-- 事务1:更新节点2的子树
START TRANSACTION;
WITH RECURSIVE sub_tree AS (
SELECT id FROM tree_node WHERE id = 2
UNION ALL
SELECT t.id FROM tree_node t INNER JOIN sub_tree st ON t.parent_id = st.id
)
SELECT id FROM sub_tree ORDER BY id ASC FOR UPDATE;
-- 此时事务1持有2、4、5的锁
-- 事务2:更新节点1的子树(包含节点2)
START TRANSACTION;
WITH RECURSIVE sub_tree AS (
SELECT id FROM tree_node WHERE id = 1
UNION ALL
SELECT t.id FROM tree_node t INNER JOIN sub_tree st ON t.parent_id = st.id
)
SELECT id FROM sub_tree ORDER BY id ASC FOR UPDATE;
-- 事务2会尝试先锁1,再锁2,但2已经被事务1锁定,事务2会等待事务1释放锁,不会死锁
可以看到,两个事务都按照ID升序加锁,不会出现循环等待,有效避免了死锁问题。