你学废了吗?一种避免递归查询所有子部门的 树数据表设计与实现 - 今日头条

本文由 简悦 SimpRead 转码, 原文地址 www.toutiao.com

你在用递归查询 Mysql 的树形结构吗? 通常树形结构的存储,是在子节点上存储父节点的编号来确定各节点的父子关系,例如这样的组织结构:

https://p6.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/1e081859b5124b98a31d7d19df724e18?from=pc

你在用递归查询 Mysql 的树形结构吗?

通常树形结构的存储,是在子节点上存储父节点的编号来确定各节点的父子关系,例如这样的组织结构:

https://p6.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/f367debaa722443789d1c034cd5cdeec?from=pc

与之对应的表数据 (department):

idnameparent_idlevel1 董事长 012 总经理 123 产品部 234 研发部 345 设计部 346 行政总监 237 财核部 648 会计 759 出纳 7510 行政部 64

部门表结构(department)

1
2
3
4
5
id          部门编号
name        部门名称
level       所在树层级
parent_id   上级部门编号
复制代码

这样的方式很不错,可以很直观的体现各个节点之间的关系,通常可以满足大多数需求。但是当业务需求变得多了,数据量庞大了,这样的方式就不再适合用于生产。

例如:PM 加了以下需求:

  1. 查出指定部门下所有子孙部门。
  2. 查询子孙部门总数。
  3. 判断节点是否叶子节点。

使用指定部门编号,一层一层使用递归往下查,可能是多数人会想到的方法。尽管在 mysql8.0 支持了 cte(公共表表达式),递归效率比传统递归方式有明显提升,但是查询效率仍会随着部门树层级深度的提高而变差。

另外一种方法,一次性查出所有数据,放入内存中处理(数据量少时,可以选用。数据量多,不怕挨打的人也可以选这种)~

递归查询每一层的数量,最后相加。

方法 1:可以加字段 isLeaf 的方式,来表示这个节点是否是叶子节点。

方法 2:直接通过查询 parent_id= 当前 id 的 count 是否大于 0,大于 0 表示不是叶子节点,等于 0 表示为叶子节点。

在日常中,可能会经常使用上述类似方法去解决类似的问题,但我觉得这样的方法在效率上不是最优解。于是乎开始查找更好的方案去解决这些问题。

直到后面查到国外一博客中,见到了所谓的《改进后的先序树遍历》文章(天哪,竟然是一篇 2003 年发表的文章)~

他具体是怎么做的呢?

还是回到刚刚的组织架构

https://p6.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/6babdec105cd4f77a4778134c62acb06?from=pc

我们从根节点开始,给董事长左值设为 1,下级部门总经理左值设为 2,以此类推地沿着边缘开始遍历,给每个节点加上左值,遇到叶子节点处给节点加上右值,再继续向上沿着边缘继续遍历,遍历结束回到根节点右侧, 你将得到类似这样的结构。

https://p6.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/e2954bdd16224f978e0b292581144428?from=pc

遍历完后每一个节点都有与之对应的左右值。这个时候可以去除 parent_id 字段,添加 lft,rgt,来存储左右值。

idnamelftrgtlevel1 董事长 12012 总经理 21923 产品部 3834 设计部 4544 研发部 6746 行政总监 91837 财核部 101548 出纳 111258 会计 1314510 行政部 16174

数据和结构准备完毕,我们来试试操作解决上面的需求~

根据当前表结构的规律,可以发现,要想查出所有子孙部门,只要查左值在 被查寻部门的左 \ 右数之间的节点,查出来都是他的子节点。例如:查询行政总监的所有子部门,行政总监的左右数是 9 和 18,因此只需要用 9 和 18 做 lft 字段的 between 查询,查询出的结果就是【被查部门本身数据和所有子孙部门】;

1
2
3
4
SET @lft := 9;
SET @rgt := 18;
SELECT * FROM department WHERE lft BETWEEN @lft AND @rgt ORDER BY lft ASC;
/*例子中用BETWEEN将被查部门本身也查了出来。实际中可以用大于小于*/

完美~

到这里可能会说,需求 1 都解决了,查总数自然也就解决了,直接上 select count 就可以了,确实没有错,但是没有那个必要,因为有个简单公式可以直接计算

公式:总数 = (右值 - 左值 - 1) / 2

例如:

1
2
3
4
5
6
7
行政总监的子孙部门数 = (18 - 9 - 1) / 2 = 4

董事长的子孙部门数 = (20 - 1 - 1) / 2 = 9

会计的子部门数 =  (14 - 13 - 1) / 2 = 0

可以数数看,确实没错哦~

通过有了上述计算公式算总数的经验后,现在判断是否叶子节点,有的小伙伴已经知道了怎么做,那就是:

右值 - 1 == 左值那他就是叶子节点,或者左值 + 1 == 右值那他就是叶子节点,反之则不是叶子节点。

例如:

设计部,5 - 1 == 4,因此他是叶子节点。

董事长,20 - 1 != 1,因此他不是叶子节点。

至此已经完美的解决了上述需求问题,接下来再尝试一下业务的基本操作。

我们创建了一个高质量的技术交流群,与优秀的人在一起,自己也会优秀起来,赶紧点击加群,享受一起成长的快乐。另外,如果你最近想跳槽的话,年前我花了 2 周时间收集了一波大厂面经,节后准备跳槽的可以点击这里领取!

当新增一个部门时,需要对新增节点位置的后续边缘进行加 2 操作,因为每一个节点有左右两个数值。这个操作通常需要放到事务中进行处理。例如:在研发部门下添加一个新部门:

https://p6.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/18ada86241a94ccaa7ba5d56184751bb?from=pc

对应 sql:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
SET @lft := 7;/*新部门的左值*/
SET @rgt := 8;/*新部门的左值*/
SET @level := 5;/*新部门的层级*/
begin;
/*将插入的后续边缘的节点左右数+2*/
UPDATE department SET lft=lft+2 WHERE lft > @lft;
UPDATE department SET rgt=rgt+2 WHERE rgt >= @lft;
/*插入数据*/
INSERT INTO department(name,lft,rgt,level) VALUES('新部门',@lft,@rgt,level);
/*新增影响行数为0时,必须回滚*/
commit;
/*rollback;*/

删除部门与新增部门类似,不同的是需要对删除节点的后续边缘节点减 2 操作。例如:删除刚刚添加的新部门:

https://p6.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/c8ef62281d2845f79c84772d499a2ea6?from=pc

对应 sql

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
SET @lft := 7;/*要删除的节点左值*/
SET @rgt := 8;/*要删除的节点右值*/
begin;
UPDATE department SET lft=lft-2 WHERE lft > @lft;
UPDATE department SET rgt=rgt-2 WHERE rgt > @lft;

/*删除节点*/
DELETE FROM department WHERE lft=@lft AND rgt=@rgt;
/*删除影响行数为0时,必须回滚*/
commit;
/*rollback*/

查询某部门的直接子部门(即不包含孙子部门),例如:查询总经理下的直接子部门。正常需要返回产品部和行政总监

https://p6.toutiaoimg.com/origin/tos-cn-i-qvj2lq49k0/0c41042e0e68404a92a2a944f4748ded?from=pc

对应的 sql

1
2
3
4
5
SET @level := 2;/*总经理的level*/
SET @lft := 2;/*总经理的左值*/
SET @rgt := 19;/*总经理的右值*/

SELECT * FROM department WHERE lft > @lft AND rgt < @rgt AND level = @level+1;

查询某部门的祖链路径。例如:查询产品部的祖链路径,正常需要返回董事长, 总经理

1
2
3
4
SET @lft := 3;/*产品部左值*/
SET @rgt := 8;/*产品部右值*/

SELECT * FROM department WHERE lft < @lft AND rgt > @rgt ORDER BY lft ASC;

查出的列表数据,转成树形结构,示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
let list = [//模拟sql查出来的列表。
    {id:1,name:'root',lft:1,rgt:8,level:1},
    {id:2,name:'child',lft:2,rgt:7,level:2},
    {id:3,name:'grandson',lft:3,rgt:4,level:3},
    {id:4,name:'grandson2',lft:5,rgt:6,level:3}
];
let rights = [] /*类似于一个栈结构(后进先出)*/
let mp = {}
//list.sort((a,b) => a.lft - b.lft)//如果你在sql中没有进行排序,需要在这里给他排序。
list.forEach(item => {
    if(rights.length > 0) {
        while(rights[rights.length-1] < item.rgt) {
            rights.splice(-1, 1)//从rights末尾去除
        }
    }
    let _level = rights.length;
    item._level = _level;
    mp[_level] = item.id
    item.parent_id = _level - 1 in mp ? mp[_level - 1] : null;//计算出上级部门编号
    item.is_leaf = item.lft === item.rgt - 1;//判断是否叶子部门
    rights.push(item.rgt)
})

/*上级部门计算出来了,和存parent_id的效果就一样了,后面只需要递归即可*/
/*递归函数 示例*/
let recursive = (_list, parent_id = null) => {
    let _tree = [];
    _list.forEach(item => {
        if(item.parent_id == parent_id) {
            let childs = recursive(_list, item.id)
            _tree.push({
                ...item,
                children: childs.length > 0 ? childs : (item.isLeaf ? null : [])
            })
        }
    })
    return _tree
}
console.log(recursive(list))

在我目前看来,这个方法的唯一缺点就是,每一次的新增或删除,操作节点的后续边缘走到的节点都要加 / 减 2 操作。

来源: juejin.cn/post/7076079848824766494