前言
最近在做的业务场景波及到了数据库的递归查问。咱们公司用的 Oracle,家喻户晓,Oracle 自带有递归查问的性能,所以实现起来特地简略。
然而,我记得 MySQL 是没有递归查问性能的,那 MySQL 中应该怎么实现呢?
于是,就有了这篇文章。
文章次要知识点:
- Oracle 递归查问,start with connect by prior 用法
- find_in_set 函数
- concat,concat_ws,group_concat 函数
- MySQL 自定义函数
- 手动实现 MySQL 递归查问
Oracle 递归查问
在 Oracle 中是通过 start with connect by prior 语法来实现递归查问的。
依照 prior 关键字在子节点端还是父节点端,以及是否蕴含以后查问的节点,共分为四种状况。
prior 在子节点端(向下递归)
第一种状况:start with 子节点 id = ‘ 查问节点 ‘ connect by prior 子节点 id = 父节点 id
select * from dept start with id='1001' connet by prior id=pid;
这里,依照条件 id=’1001′ 对以后节点以及它的子节点递归查问。查问后果 蕴含本人 及所有子节点。
第二种状况:start with 父节点 id= ‘ 查问节点 ‘ connect by prior 子节点 id = 父节点 id
select * from dept start with pid='1001' connect by prior id=pid;
这里,依照条件 pid=’1001′ 对以后节点的所有子节点递归查问。查问后果只蕴含它的所有子节点,不蕴含本人。
其实想一想也对,因为开始条件是以父节点为根节点,且向下递归,天然不蕴含以后节点。
prior 在父节点端(向上递归)
第三种状况:start with 子节点 id= ‘ 查问节点 ‘ connect by prior 父节点 id = 子节点 id
select * from dept start with id='1001' connect by prior pid=id;
这里依照条件 id=’1001′,对以后节点及其父节点递归查问。查问后果 包含本人 及其所有父节点。
第四种状况:start with 父节点 id= ‘ 查问节点 ‘ connect by prior 父节点 id = 子节点 id
select * from dept start with pid='1001' connect by prior pid=id;
这里依照条件 pid=’1001’,对以后节点的第一代子节点以及它的父节点递归查问。查问后果包含本人的第一代子节点以及所有父节点。(包含本人)
其实这种状况也好了解,因为查问开始条件是以 父节点
为根节点,且向上递归,天然须要把以后父节点的第一层子节点包含在内。
以上四种状况初看可能会让人蛊惑,容易记凌乱,其实不然。
咱们只须要记住 prior 的地位在子节点端,就向下递归,在父节点端就向上递归。
- 开始条件若是子节点的话,天然包含它自身的节点。
- 开始条件若是父节点的话,则向下递归时,天然不包含以后节点。而向上递归,须要包含以后节点及其第一代子节点。
MySQL 递归查问
能够看到,Oracle 实现递归查问十分的不便。然而,在 MySQL 中并没有帮咱们解决,因而须要咱们本人手动实现递归查问。
为了不便,咱们创立一个部门表,并插入几条能够造成递归关系的数据。
DROP TABLE IF EXISTS `dept`;
CREATE TABLE `dept` (`id` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`pid` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1000', '总公司', NULL);
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1001', '北京分公司', '1000');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1002', '上海分公司', '1000');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1003', '北京研发部', '1001');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1004', '北京财务部', '1001');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1005', '北京市场部', '1001');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1006', '北京研发一部', '1003');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1007', '北京研发二部', '1003');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1008', '北京研发一部一小组', '1006');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1009', '北京研发一部二小组', '1006');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1010', '北京研发二部一小组', '1007');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1011', '北京研发二部二小组', '1007');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1012', '北京市场一部', '1005');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1013', '上海研发部', '1002');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1014', '上海研发一部', '1013');
INSERT INTO `dept`(`id`, `name`, `pid`) VALUES ('1015', '上海研发二部', '1013');
没错,方才 Oracle 递归,就是用的这张表。
另外,在这之前,咱们须要温习一下几个 MYSQL 中的函数,后续会用到。
find_in_set 函数
函数语法:find_in_set(str,strlist)
str 代表要查问的字符串,strlist 是一个以逗号分隔的字符串,如 (‘a,b,c’)。
此函数用于查找 str 字符串在字符串 strlist 中的地位,返回后果为 1 ~ n。若没有找到,则返回 0。
举个栗子:
select FIND_IN_SET('b','a,b,c,d');
后果返回 2。因为 b 所在位置为第二个子串地位。
此外,在对表数据进行查问时,它还有一种用法,如下:
select * from dept where FIND_IN_SET(id,'1000,1001,1002');
后果返回所有 id 在 strlist 中的记录,即 id = ‘1000’,id = ‘1001’,id = ‘1002’ 三条记录。
看到这,对于咱们要解决的递归查问,不晓得你有什么启发没。
以向下递归查问所有子节点为例。我想,是不是能够找到一个蕴含以后节点和所有子节点的以逗号拼接的字符串 strlist,传进 find_in_set 函数。就能够查问出所有须要的递归数据了。
那么,当初问题就转化为怎么结构这样的一个字符串 strlist。
这就须要用到以下字符串拼接函数了。
concat,concat_ws,group_concat 函数
一、字符串拼接函数中,最根本的就是 concat 了。它用于连贯 N 个字符串,如,
select CONCAT('M','Y','S','Q','L') from dual;
后果为 ‘MYSQL’ 字符串。
二、concat 是以逗号为默认的分隔符,而 concat_ws 则能够指定分隔符,第一个参数传入分隔符,如以下划线分隔。
三、group_concat 函数更弱小,能够分组的同时,把字段以特定分隔符拼接成字符串。
用法:group_concat([distinct] 要连贯的字段 [order by 排序字段 asc/desc] [separator ‘ 分隔符 ’] )
能够看到有可选参数,能够对将要拼接的字段值去重,也能够排序,指定分隔符。若没有指定,默认以逗号分隔。
对于 dept 表,咱们能够把表中的所有 id 以逗号拼接。(这里没有用到 group by 分组字段,则能够认为只有一组)
MySQL 自定义函数,实现递归查问
能够发现以上曾经把字符串拼接的问题也解决了。那么,问题就变成怎么结构有递归关系的字符串了。
咱们能够自定义一个函数,通过传入根节点 id,找到它的所有子节点。
以向下递归为例。(解说自定义函数写法的同时,解说递归逻辑)
delimiter $$
drop function if exists get_child_list$$
create function get_child_list(in_id varchar(10)) returns varchar(1000)
begin
declare ids varchar(1000) default '';
declare tempids varchar(1000);
set tempids = in_id;
while tempids is not null do
set ids = CONCAT_WS(',',ids,tempids);
select GROUP_CONCAT(id) into tempids from dept where FIND_IN_SET(pid,tempids)>0;
end while;
return ids;
end
$$
delimiter ;
(1)delimiter $$,用于定义结束符。咱们晓得 MySQL 默认的结束符为分号,表明指令完结并执行。然而在函数体中,有时咱们心愿遇到分号不完结,因而须要临时把结束符改为一个随便的其余值。我这里设置为 $$,意思是遇到 $$ 才完结,并执行以后语句。
(2)drop function if exists get_child_list$$。若函数 get_child_list 曾经存在了,则先删除它。留神这里须要用 以后自定义的结束符 $$ 来完结并执行语句。因为,这里须要和下边的函数体独自辨别开来。
(3)create function get_child_list 创立函数。并且参数传入一个根节点的子节点 id,须要留神肯定要注明参数的类型和长度,如这里是 varchar(10)。returns varchar(1000) 用来定义返回值参数类型。
(4)begin 和 end 两头突围的就是函数体。用来写具体的逻辑。
(5)declare 用来申明变量,并且能够用 default 设置默认值。
这里定义的 ids 即作为整个函数的返回值,是用来拼接成最终咱们须要的以逗号分隔的递归串的。
而 tempids 是为了记录下边 while 循环中长期生成的所有子节点以逗号拼接成的字符串。
(6)set 用来给变量赋值。此处把传进来的根节点赋值给 tempids。
(7)while do … end while; 循环语句,循环逻辑蕴含在内。留神,end while 开端须要加上分号。
循环体内,先用 CONCAT_WS 函数把最终后果 ids 和 长期生成的 tempids 用逗号拼接起来。
而后以 FIND_IN_SET(pid,tempids)>0 为条件,遍历在 tempids 中的所有 pid,寻找以此为父节点的所有子节点 id,并且通过 GROUP_CONCAT(id) into tempids 把这些子节点 id 都用逗号拼接起来,并笼罩更新 tempids。
等下次循环进来时,就会再次拼接 ids,并再次查找所有子节点的所有子节点。周而复始,一层一层的向下递归遍历子节点。直到判断 tempids 为空,阐明所有子节点都曾经遍历完了,就完结整个循环。
这里,用 ‘1000’ 来举例,即是:(参看图 1 的表数据关系)
第一次循环:tempids=1000 ids=1000 tempids=1001,1002 (1000 的所有子节点)
第二次循环:tempids=1001,1002 ids=1000,1001,1002 tempids=1003,1004,1005,1013 (1001 和 1002 的所有子节点)
第三次循环:tempids=1003,1004,1005,1013
ids=1000,1001,1002,1003,1004,1005,1013
tempids=1003 和 1004 和 1005 及 1013 的所有子节点
...
最初一次循环,因找不到子节点,tempids=null,就完结循环。
(8)return ids; 用于把 ids 作为函数返回值返回。
(9)函数体完结当前,记得用结束符 $$ 来完结整个逻辑,并执行。
(10)最初别忘了,把结束符从新设置为默认的结束符分号。
自定义函数做好之后,咱们就能够用它来递归查问咱们须要的数据了。如,我查问北京研发部的所有子节点。
以上是向下递归查问所有子节点的,并且包含了以后节点,也能够批改逻辑为不蕴含以后节点,我就不演示了。
手动实现递归查问(向上递归)
绝对于向下递归来说,向上递归比较简单。
因为向下递归时,每一层递归一个父节点都对应多个子节点。
而向上递归时,每一层递归一个子节点只对应一个父节点,关系比拟繁多。
同样的,咱们能够定义一个函数 get_parent_list 来获取根节点的所有父节点。
delimiter $$
drop function if exists get_parent_list$$
create function get_parent_list(in_id varchar(10)) returns varchar(1000)
begin
declare ids varchar(1000);
declare tempid varchar(10);
set tempid = in_id;
while tempid is not null do
set ids = CONCAT_WS(',',ids,tempid);
select pid into tempid from dept where id=tempid;
end while;
return ids;
end
$$
delimiter ;
查找北京研发二部一小组,以及它的递归父节点,如下:
注意事项
咱们用到了 group_concat 函数来拼接字符串。然而,须要留神它是有长度限度的,默认为 1024 字节。能够通过 show variables like "group_concat_max_len";
来查看。
留神,单位是字节,不是字符。在 MySQL 中,单个字母占 1 个字节,而咱们平时用的 utf- 8 下,一个汉字占 3 个字节。
这个对于递归查问还是十分致命的。因为个别递归的话,关系层级都比拟深,很有可能超过最大长度。(只管个别拼接的都是数字字符串,即单字节)
所以,咱们有两种办法解决这个问题:
- 批改 MySQL 配置文件 my.cnf,减少
group_concat_max_len = 102400 #你要的最大长度
。 - 执行以下任意一个语句。
SET GLOBAL group_concat_max_len=102400;
或者SET SESSION group_concat_max_len=102400;
他们的区别在于,global 是全局的,任意关上一个新的会话都会失效,然而留神,曾经关上的以后会话并不会失效。而 session 是只会在以后会话失效,其余会话不失效。
共同点是,它们都会在 MySQL 重启之后生效,以配置文件中的配置为准。所以,倡议间接批改配置文件。102400 的长度个别也够用了。假如一个 id 的长度为 10 个字节,也能拼上一万个 id 了。
除此之外,应用 group_concat 函数还有一个限度,就是不能同时应用 limit。如,
原本只想查 5 条数据来拼接,当初不失效了。
不过,如果需要的话,能够通过子查问来实现,