前言
最近在作的業務場景涉及到了數據庫的遞歸查詢。咱們公司用的 Oracle ,衆所周知,Oracle 自帶有遞歸查詢的功能,因此實現起來特別簡單。web
可是,我記得 MySQL 是沒有遞歸查詢功能的,那 MySQL 中應該怎麼實現呢?數據庫
因而,就有了這篇文章。微信
文章主要知識點:session
-
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 = 父節點idflex
select * from dept start with id='1001' connet by prior id=pid;
這裏,按照條件 id='1001' 對當前節點以及它的子節點遞歸查詢。查詢結果包含本身及全部子節點。url

第二種狀況:start with 父節點id= ' 查詢節點 ' connect by prior 子節點id = 父節點 idspa
select * from dept start with pid='1001' connect by prior id=pid;
這裏,按照條件 pid='1001' 對當前節點的全部子節點遞歸查詢。查詢結果只包含它的全部子節點,不包含本身。.net

其實想想也對,由於開始條件是以父節點爲根節點,且向下遞歸,天然不包含當前節點。
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 函數
1、字符串拼接函數中,最基本的就是 concat 了。它用於鏈接N個字符串,如,
select CONCAT('M','Y','S','Q','L') from dual;
結果爲 'MYSQL' 字符串。

2、concat 是以逗號爲默認的分隔符,而 concat_ws 則能夠指定分隔符,第一個參數傳入分隔符,如如下劃線分隔。

3、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 ;
(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條數據來拼接,如今不生效了。
不過,若是須要的話,能夠經過子查詢來實現,

若本文對你有用,歡迎關注我,給我點贊吧 ~
掃描二維碼
獲取更多精彩
煙雨星空

本文分享自微信公衆號 - 煙雨星空(mistyskys)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。