做者:薛粲
受權許可:CC BY-NC 4.0html
最初是在 MySQL 官方網站上看到這篇名爲 Managing Hierarchical Data in MySQL 的文章(MySQL 隨 Sun 一塊兒被 Oracle 收購後,如今只能經過 archive.org 找回了),在原做者 Mike Hillyer 的我的網站上再次看到。node
這份筆記在 MySQL 5.7 環境中對原文進行了覆盤,調整了例子,同時SQL 語句根據我的習慣和 MySQL 版本的變化相比原文略有調整。mysql
咱們知道,關係數據庫的表更適合扁平的列表,而不是像 XML 那樣能夠直管的保存具備父子關係的層次結構數據。web
首先定義一下咱們討論的層次結構,是這樣的一組數據,每一個條目只能有一個父條目,能夠有零個或多個子條目(惟一的例外是根條目,它沒有父條目)。許多依賴數據庫的應用都會遇到層次結構的數據,例如論壇或郵件列表的線索、企業的組織結構圖、內容管理系統或商城的分類目錄等等。咱們以下數據做爲示例:算法
數據來源於維基百科的這個頁面,爲何挑了這幾個條目,以及是否準確合理在這裏就不深究了。sql
Mike Hillyer 考慮了兩種不一樣的模型——鄰接表(Adjacency List)和嵌套集(Nested Set)來實現這個層次結構。數據庫
咱們能夠很直觀的使用下面的方式來保存如圖所示的結構。數據結構
建立名爲 distributions
的表:函數
CREATE TABLE distributions ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(32) NOT NULL, parent INT NULL DEFAULT NULL, PRIMARY KEY (id) ) ENGINE = InnoDB DEFAULT CHARACTER SET = utf8;
插入數據:測試
INSERT INTO distributions VALUES (1, 'Linux', NULL), (2, 'Debian', 1), (3, 'Knoppix', 2), (4, 'Ubuntu', 2), (5, 'Gentoo', 1), (6, 'Red Hat', 1), (7, 'Fedora Core', 6), (8, 'RHEL', 6), (9, 'CentOS', 8), (10, 'Oracle Linux', 8);
執行:
SELECT * FROM distributions;
能夠看到表中的數據形如:
+----+--------------+--------+ | id | name | parent | +----+--------------+--------+ | 1 | Linux | NULL | | 2 | Debian | 1 | | 3 | Knoppix | 2 | | 4 | Ubuntu | 2 | | 5 | Gentoo | 1 | | 6 | Red Hat | 1 | | 7 | Fedora Core | 6 | | 8 | RHEL | 6 | | 9 | CentOS | 8 | | 10 | Oracle Linux | 8 | +----+--------------+--------+
使用連接表模型,表中的每一條記錄都包含一個指向其上層記錄的指針。頂層記錄(這個例子中是 Linux
)的這個字段的值爲 NULL
。鄰接表的優點是至關直觀和簡單,咱們一眼就能看出 CentOS
衍生自 RHEL
,後者又是從 Red Hat
發展而來的。雖然客戶端程序可能處理起來至關簡單,可是使用純 SQL 處理鄰接表則會遇到一些麻煩。
第一個處理層次結構常見的任務是顯示整個層次結構,一般包含必定的縮進。使用純 SQL 處理時一般須要藉助所謂的 self-join 技巧:
SELECT t1.name AS level1, t2.name as level2, t3.name as level3, t4.name as level4 FROM distributions AS t1 LEFT JOIN distributions AS t2 ON t2.parent = t1.id LEFT JOIN distributions AS t3 ON t3.parent = t2.id LEFT JOIN distributions AS t4 ON t4.parent = t3.id WHERE t1.name = 'Linux';
結果以下:
+--------+---------+-------------+--------------+ | level1 | level2 | level3 | level4 | +--------+---------+-------------+--------------+ | Linux | Red Hat | RHEL | CentOS | | Linux | Red Hat | RHEL | Oracle Linux | | Linux | Debian | Knoppix | NULL | | Linux | Debian | Ubuntu | NULL | | Linux | Red Hat | Fedora Core | NULL | | Linux | Gentoo | NULL | NULL | +--------+---------+-------------+--------------+
能夠看到,實際上客戶端代碼拿到這個結果也不容易處理。對比原文,咱們發現返回結果的順序也是不肯定的。在實踐中沒有什麼參考意義。不過能夠經過增長一個 WHERE
條件,獲取一個節點的完整路徑:
SELECT t1.name AS level1, t2.name as level2, t3.name as level3, t4.name as level4 FROM distributions AS t1 LEFT JOIN distributions AS t2 ON t2.parent = t1.id LEFT JOIN distributions AS t3 ON t3.parent = t2.id LEFT JOIN distributions AS t4 ON t4.parent = t3.id WHERE t1.name = 'Linux' AND t4.name = 'CentOS';
結果以下:
+--------+---------+--------+--------+ | level1 | level2 | level3 | level4 | +--------+---------+--------+--------+ | Linux | Red Hat | RHEL | CentOS | +--------+---------+--------+--------+
使用 LEFT JOIN
咱們能夠找出全部的葉節點:
SELECT distributions.id, distributions.name FROM distributions LEFT JOIN distributions as child ON distributions.id = child.parent WHERE child.id IS NULL;
結果以下:
+----+--------------+ | id | name | +----+--------------+ | 3 | Knoppix | | 4 | Ubuntu | | 5 | Gentoo | | 7 | Fedora Core | | 9 | CentOS | | 10 | Oracle Linux | +----+--------------+
使用純 SQL 處理鄰接表模型即使在最好的狀況下也是困難的。要得到一個分類的完整路徑以前咱們須要知道它的層次有多深。除此以外,當咱們刪除一個節點時咱們須要格外的謹慎,由於這可能潛在的在處理過程當中整個子樹成爲孤兒(例如刪除『便攜式小家電』則全部其子分類都成爲孤兒了)。其中一些限制能夠在客戶端代碼或存儲過程當中定位並處理。例如在存儲過程當中咱們能夠自下而上的遍歷這個結構以便返回整棵樹或一個路徑。咱們也可使用存儲過程來刪除節點,經過提高其一個子節點的層次並從新設置全部其它子節點的父節點爲這個節點,來避免整棵子樹成爲孤兒。
因爲使用純 SQL 處理鄰接表模型存在種種不便,所以 Mike Hillyer 鄭重的介紹了嵌套集(Nested Set)模型。當使用這種模型時,咱們把層次結構的節點和路徑從腦海中抹去,把它們想象爲一個個容器:
能夠看到層次關係沒有改變,大的容器包含子容器。咱們使用容器的左值和右值來創建數據表:
CREATE TABLE nested ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(32) NOT NULL, `left` INT NOT NULL, `right` INT NOT NULL, PRIMARY KEY (id) ) ENGINE = InnoDB DEFAULT CHARACTER SET = utf8;
須要注意 left
和 right
是 MySQL 的保留字,所以使用標識分隔符來標記它們。
插入數據:
INSERT INTO nested VALUES (1, 'Linux', 1, 20), (2, 'Debian', 2, 7), (3, 'Knoppix', 3, 4), (4, 'Ubuntu', 5, 6), (5, 'Gentoo', 8, 9), (6, 'Red Hat', 10, 19), (7, 'Fedora Core', 11, 12), (8, 'RHEL', 13, 18), (9, 'CentOS', 14, 15), (10, 'Oracle Linux', 16, 17);
查看內容:
SELECT * FROM nested ORDER BY id;
能夠看到:
+----+--------------+------+-------+ | id | name | left | right | +----+--------------+------+-------+ | 1 | Linux | 1 | 20 | | 2 | Debian | 2 | 7 | | 3 | Knoppix | 3 | 4 | | 4 | Ubuntu | 5 | 6 | | 5 | Gentoo | 8 | 9 | | 6 | Red Hat | 10 | 19 | | 7 | Fedora Core | 11 | 12 | | 8 | RHEL | 13 | 18 | | 9 | CentOS | 14 | 15 | | 10 | Oracle Linux | 16 | 17 | +----+--------------+------+-------+
咱們是如何肯定左編號和右編號的呢,經過下圖咱們能夠直觀的發現只要會數數便可完成:
回到樹形模型該怎麼處理,經過下圖,對數據結構稍有概念的人都會知道,稍加改動的先序遍歷算法便可完成這項編號的工做:
一個節點的左編號老是介於其父節點的左右編號之間,利用這個特性使用 self-join 連接到父節點,能夠獲取整棵樹:
SELECT node.name FROM nested AS node, nested AS parent WHERE node.`left` BETWEEN parent.`left` AND parent.`right` AND parent.name = 'Linux' ORDER BY node.`left`;
結果以下:
+--------------+ | name | +--------------+ | Linux | | Debian | | Knoppix | | Ubuntu | | Gentoo | | Red Hat | | Fedora Core | | RHEL | | CentOS | | Oracle Linux | +--------------+
可是這樣咱們丟失了層次的信息。怎麼辦呢?使用 COUNT()
函數和 GROUP BY
子句,能夠實現這個目的:
SELECT node.name, (COUNT(parent.name) - 1) AS depth FROM nested AS node, nested AS parent WHERE node.`left` BETWEEN parent.`left` AND parent.`right` GROUP BY node.name ORDER BY ANY_VALUE(node.`left`);
須要注意 MySQL 5.7.5 開始默認啓用了 only_full_group_by
模式,讓 GROUP BY
的行爲與 SQL92 標準一致,所以直接使用 ORDER BY node.`left`
會產生錯誤:
ERROR 1055 (42000): Expression #1 of ORDER BY clause is not in GROUP BY clause and contains nonaggregated column 'test.node.left' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
使用 ANY_VALUE()
是避免這個問題的一種的途徑。
結果以下:
+--------------+-------+ | name | depth | +--------------+-------+ | Linux | 0 | | Debian | 1 | | Knoppix | 2 | | Ubuntu | 2 | | Gentoo | 1 | | Red Hat | 1 | | Fedora Core | 2 | | RHEL | 2 | | CentOS | 3 | | Oracle Linux | 3 | +--------------+-------+
稍做調整就能夠直接顯示層次:
SELECT CONCAT(REPEAT(' ', COUNT(parent.name) - 1), node.name) AS name FROM nested AS node, nested AS parent WHERE node.`left` BETWEEN parent.`left` AND parent.`right` GROUP BY node.name ORDER BY ANY_VALUE(node.`left`);
結果至關漂亮:
+-----------------+ | name | +-----------------+ | Linux | | Debian | | Knoppix | | Ubuntu | | Gentoo | | Red Hat | | Fedora Core | | RHEL | | CentOS | | Oracle Linux | +-----------------+
固然客戶端代碼可能會更傾向於使用 depth
值,對返回的結果集進行循環,Web 開發人員能夠根據其增大或減少使用 <li>
/</li>
或 <ul>
/</ul>
等。
要獲取節點在子樹中的深度,咱們須要第三個 self-join 以及子查詢來將結果限制在特定的子樹中以及進行必要的計算:
SELECT node.name, (COUNT(parent.name) - ANY_VALUE(sub_tree.depth) - 1) AS depth FROM nested AS node, nested AS parent, nested AS sub_parent, ( SELECT node.name, (COUNT(parent.name) - 1) AS depth FROM nested AS node, nested AS parent WHERE node.`left` BETWEEN parent.`left` AND parent.`right` AND node.name = 'Red Hat' GROUP BY node.name, node.`left` ORDER BY node.`left` ) AS sub_tree WHERE node.`left` BETWEEN parent.`left` AND parent.`right` AND node.`left` BETWEEN sub_parent.`left` AND sub_parent.`right` AND sub_parent.name = sub_tree.name GROUP BY node.name ORDER BY ANY_VALUE(node.`left`);
結果是:
+--------------+-------+ | name | depth | +--------------+-------+ | Red Hat | 0 | | Fedora Core | 1 | | RHEL | 1 | | CentOS | 2 | | Oracle Linux | 2 | +--------------+-------+
使用鄰接表模型時這至關簡單。使用嵌套集時,咱們能夠在上面獲取子樹各節點深度的基礎上增長一個 HAVING
子句來實現:
SELECT node.name, (COUNT(parent.name) - ANY_VALUE(sub_tree.depth) - 1) AS depth FROM nested AS node, nested AS parent, nested AS sub_parent, ( SELECT node.name, (COUNT(parent.name) - 1) AS depth FROM nested AS node, nested AS parent WHERE node.`left` BETWEEN parent.`left` AND parent.`right` AND node.name = 'Red Hat' GROUP BY node.name, node.`left` ORDER BY node.`left` ) AS sub_tree WHERE node.`left` BETWEEN parent.`left` AND parent.`right` AND node.`left` BETWEEN sub_parent.`left` AND sub_parent.`right` AND sub_parent.name = sub_tree.name GROUP BY node.name HAVING depth = 1 ORDER BY ANY_VALUE(node.`left`);
結果:
+-------------+-------+ | name | depth | +-------------+-------+ | Fedora Core | 1 | | RHEL | 1 | +-------------+-------+
觀察帶編號的嵌套模型,葉節點的判斷至關簡單,右編號剛好比左編號多 1 的節點就是葉節點:
SELECT id, name FROM nested WHERE `right` = `left` + 1;
結果以下:
+----+--------------+ | id | name | +----+--------------+ | 3 | Knoppix | | 4 | Ubuntu | | 5 | Gentoo | | 7 | Fedora Core | | 9 | CentOS | | 10 | Oracle Linux | +----+--------------+
仍然是使用 self-join 技巧,不過如今無需顧慮節點的深度了:
SELECT parent.name FROM nested AS node, nested AS parent WHERE node.`left` BETWEEN parent.`left` AND parent.`right` AND node.name = 'CentOS' ORDER BY parent.`left`;
結果以下:
+---------+ | name | +---------+ | Linux | | Red Hat | | RHEL | | CentOS | +---------+
咱們添加一張 releases
表,來展現一下在嵌套集模型下的彙集(aggregate)操做:
CREATE TABLE releases ( id INT NOT NULL AUTO_INCREMENT, distribution_id INT NULL, name VARCHAR(32) NOT NULL, PRIMARY KEY (id), INDEX distribution_id_idx (distribution_id ASC), CONSTRAINT distribution_id FOREIGN KEY (distribution_id) REFERENCES nested (id) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE = InnoDB DEFAULT CHARACTER SET = utf8;
加入一些數據,假設這些數據是指某個軟件支持的發行版:
INSERT INTO releases (distribution_id, name) VALUES (2, '7'), (2, '8'), (4, '14.04 LTS'), (4, '15.10'), (7, '22'), (7, '23'), (9, '5'), (9, '6'), (9, '7');
那麼,下面的查詢能夠知道每一個節點下涉及的發佈版數量,若是這是一個軟件支持的發佈版清單,或許測試人員想要知道他們得準備多少種虛擬機吧:
SELECT parent.name, COUNT(releases.name) FROM nested AS node , nested AS parent, releases WHERE node.`left` BETWEEN parent.`left` AND parent.`right` AND node.id = releases.distribution_id GROUP BY parent.name ORDER BY ANY_VALUE(parent.`left`);
結果以下:
+-------------+----------------------+ | name | COUNT(releases.name) | +-------------+----------------------+ | Linux | 9 | | Debian | 4 | | Ubuntu | 2 | | Red Hat | 5 | | Fedora Core | 2 | | CentOS | 3 | +-------------+----------------------+
若是層次結構是一個分類目錄,這個技巧能夠用於查詢各個類別下有多少關聯的商品。
再次回顧這張圖:
若是咱們要在 Gentoo
以後增長一個 Slackware
,這個新節點的左右編號分別是 10 和 11,而原來從 10 開始的全部編號都須要加 2。咱們能夠:
LOCK TABLE nested WRITE; SELECT @baseIndex := `right` FROM nested WHERE name = 'Gentoo'; UPDATE nested SET `right` = `right` + 2 WHERE `right` > @baseIndex; UPDATE nested SET `left` = `left` + 2 WHERE `left` > @baseIndex; INSERT INTO nested (name, `left`, `right`) VALUES ('Slackware', @baseIndex + 1, @baseIndex + 2); UNLOCK TABLES;
使用以前掌握的技巧看一下如今的狀況:
SELECT CONCAT(REPEAT(' ', COUNT(parent.name) - 1), node.name) AS name FROM nested AS node, nested AS parent WHERE node.`left` BETWEEN parent.`left` AND parent.`right` GROUP BY node.name ORDER BY ANY_VALUE(node.`left`);
結果爲:
+-----------------+ | name | +-----------------+ | Linux | | Debian | | Knoppix | | Ubuntu | | Gentoo | | Slackware | | Red Hat | | Fedora Core | | RHEL | | CentOS | | Oracle Linux | +-----------------+
若是新增的節點的父節點原來是葉節點,咱們須要稍微調整一下以前的代碼。例如,咱們要新增 Slax
做爲 Slackware
的子節點:
LOCK TABLE nested WRITE; SELECT @baseIndex := `left` FROM nested WHERE name = 'Slackware'; UPDATE nested SET `right` = `right` + 2 WHERE `right` > @baseIndex; UPDATE nested SET `left` = `left` + 2 WHERE `left` > @baseIndex; INSERT INTO nested(name, `left`, `right`) VALUES ('Slax', @baseIndex + 1, @baseIndex + 2); UNLOCK TABLES;
如今,數據形如:
+-----------------+ | name | +-----------------+ | Linux | | Debian | | Knoppix | | Ubuntu | | Gentoo | | Slackware | | Slax | | Red Hat | | Fedora Core | | RHEL | | CentOS | | Oracle Linux | +-----------------+
刪除節點的操做與添加操做相對,當要刪除一個葉節點時,移除該節點並將全部比該節點右編碼大的編碼減 2。這個思路能夠擴展到刪除一個節點及其全部子節點的狀況,刪除左編碼介於節點左右編號之間的全部節點,將右側的節點編號所有左移該節點原編號寬度便可:
LOCK TABLE nested WRITE; SELECT @nodeLeft := `left`, @nodeRight := `right`, @nodeWidth := `right` - `left` + 1 FROM nested WHERE name = 'Slackware'; DELETE FROM nested WHERE `left` BETWEEN @nodeLeft AND @nodeRight; UPDATE nested SET `right` = `right` - @nodeWidth WHERE `right` > @nodeRight; UPDATE nested SET `left` = `left` - @nodeWidth WHERE `left` > @nodeRight; UNLOCK TABLES;
能夠看到 Slackware
子樹被刪除了:
+-----------------+ | name | +-----------------+ | Linux | | Debian | | Knoppix | | Ubuntu | | Gentoo | | Red Hat | | Fedora Core | | RHEL | | CentOS | | Oracle Linux | +-----------------+
稍加調整,若是對介於要刪除節點左右編號直接的節點對應編號左移 1,右側節點對應編號左移 2,則能夠實現刪除一個節點,其子節點提高一層的效果,例如咱們嘗試刪除 RHEL
但保留它的子節點:
LOCK TABLE nested WRITE; SELECT @nodeLeft := `left`, @nodeRight := `right` FROM nested WHERE name = 'RHEL'; DELETE FROM nested WHERE `left` = @nodeLeft; UPDATE nested SET `right` = `right` - 1, `left` = `left` - 1 WHERE `left` BETWEEN @nodeLeft AND @nodeRight; UPDATE nested SET `right` = `right` - 2 WHERE `right` > @nodeRight; UPDATE nested SET `left` = `left` - 2 WHERE `left` > @nodeRight; UNLOCK TABLES;
結果爲:
SELECT CONCAT(REPEAT(' ', COUNT(parent.name) - 1), node.name) AS name FROM nested AS node, nested AS parent WHERE node.`left` BETWEEN parent.`left` AND parent.`right` GROUP BY node.name ORDER BY ANY_VALUE(node.`left`);
+----------------+ | name | +----------------+ | Linux | | Debian | | Knoppix | | Ubuntu | | Gentoo | | Red Hat | | Fedora Core | | CentOS | | Oracle Linux | +----------------+
本文 2016-03-23 首次發表於 SegmentFault.com。