PostgreSQL優(yōu)雅的進行遞歸查詢的實戰(zhàn)指南
在實際開發(fā)中,我們經(jīng)常會遇到樹形結(jié)構(gòu)或圖結(jié)構(gòu)的數(shù)據(jù)需求,比如:
- 組織架構(gòu)(部門 → 子部門)
- 商品分類(一級類目 → 二級類目 → …)
- 評論回復(fù)(評論 → 回復(fù) → 回復(fù)的回復(fù))
- 權(quán)限繼承(角色 → 子角色)
- 路徑查找(最短路徑、依賴關(guān)系)
這些場景的核心問題是:如何高效查詢具有層級/遞歸關(guān)系的數(shù)據(jù)?
PostgreSQL 提供了強大的 WITH RECURSIVE(公共表表達式遞歸) 功能,是處理此類問題的標(biāo)準(zhǔn) SQL 解決方案。本文將從基礎(chǔ)到實戰(zhàn),手把手教你掌握遞歸查詢的精髓。
一、遞歸查詢基礎(chǔ):CTE 與WITH RECURSIVE
1.1 什么是 CTE(Common Table Expression)?
CTE 是一種臨時結(jié)果集,可被主查詢引用,語法如下:
WITH cte_name AS (
-- 查詢語句
)
SELECT * FROM cte_name;
優(yōu)點:提升 SQL 可讀性、避免重復(fù)子查詢、支持遞歸
1.2 遞歸 CTE 的基本結(jié)構(gòu)
WITH RECURSIVE cte_name AS (
-- 1. 初始查詢(錨點成員 Anchor Member)
SELECT ... FROM table WHERE ...
UNION [ALL]
-- 2. 遞歸查詢(遞歸成員 Recursive Member)
SELECT ... FROM table, cte_name WHERE ...
)
SELECT * FROM cte_name;
核心三要素:
| 部分 | 作用 | 注意事項 |
|---|---|---|
| 初始查詢 | 定義遞歸起點(如根節(jié)點) | 必須能終止遞歸 |
| UNION [ALL] | 合并結(jié)果集 | UNION 去重,UNION ALL 保留重復(fù)(性能更高) |
| 遞歸查詢 | 引用自身 CTE,向下/向上遍歷 | 必須有連接條件,避免無限循環(huán) |
1.3 遞歸查詢的建議
| 場景 | 推薦方案 |
|---|---|
| 標(biāo)準(zhǔn)樹形查詢(上下級) | WITH RECURSIVE + UNION ALL |
| 防循環(huán) | 記錄訪問路徑 ARRAY[id] + != ALL(path) |
| 限制深度 | 添加 depth 字段 + WHERE depth < N |
| 高性能讀 | 物化路徑 / 閉包表(寫少讀多) |
| 返回樹形 JSON | 自底向上聚合 + jsonb_build_object |
| Python 集成 | 直接執(zhí)行原生 SQL(SQLAlchemy 支持 CTE) |
終極建議:
“90% 的樹形查詢,一個精心設(shè)計的 WITH RECURSIVE 就夠了。”
只有在性能成為瓶頸時,才考慮物化路徑等復(fù)雜模型。
二、經(jīng)典場景實戰(zhàn):組織架構(gòu)查詢
假設(shè)有一張部門表 departments:
CREATE TABLE departments (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INTEGER REFERENCES departments(id)
);
-- 插入示例數(shù)據(jù)
INSERT INTO departments (name, parent_id) VALUES
('總公司', NULL),
('技術(shù)部', 1),
('產(chǎn)品部', 1),
('前端組', 2),
('后端組', 2),
('iOS組', 2),
('設(shè)計組', 3);
2.1 查詢“技術(shù)部”及其所有子部門(向下遞歸)
WITH RECURSIVE dept_tree AS (
-- 錨點:找到“技術(shù)部”
SELECT id, name, parent_id, 0 AS level
FROM departments
WHERE name = '技術(shù)部'
UNION ALL
-- 遞歸:找子部門
SELECT d.id, d.name, d.parent_id, dt.level + 1
FROM departments d
INNER JOIN dept_tree dt ON d.parent_id = dt.id
)
SELECT
LPAD('', level * 4, ' ') || name AS hierarchy, -- 縮進顯示層級
id, parent_id, level
FROM dept_tree
ORDER BY level;
輸出結(jié)果:
hierarchy | id | parent_id | level
-----------------|----|-----------|------
技術(shù)部 | 2 | 1 | 0
前端組 | 4 | 2 | 1
后端組 | 5 | 2 | 1
iOS組 | 6 | 2 | 1
技巧:LPAD('', level * 4, ' ') 生成縮進,直觀展示樹形結(jié)構(gòu)
2.2 查詢“后端組”的完整上級路徑(向上遞歸)
WITH RECURSIVE dept_path AS (
-- 錨點:從“后端組”開始
SELECT id, name, parent_id, 0 AS level
FROM departments
WHERE name = '后端組'
UNION ALL
-- 遞歸:找父部門
SELECT d.id, d.name, d.parent_id, dp.level + 1
FROM departments d
INNER JOIN dept_path dp ON d.id = dp.parent_id
WHERE dp.parent_id IS NOT NULL -- 避免 NULL 連接
)
SELECT
REPEAT(' → ', level) || name AS path_from_root
FROM dept_path
ORDER BY level DESC; -- 從根到當(dāng)前節(jié)點
輸出結(jié)果:
path_from_root --------------------------- 總公司 → 技術(shù)部 → 后端組
三、高級技巧:控制遞歸深度與防環(huán)
3.1 限制遞歸深度(防止無限循環(huán))
WITH RECURSIVE dept_limited AS (
SELECT id, name, parent_id, 1 AS depth
FROM departments
WHERE parent_id IS NULL -- 從根開始
UNION ALL
SELECT d.id, d.name, d.parent_id, dl.depth + 1
FROM departments d
INNER JOIN dept_limited dl ON d.parent_id = dl.id
WHERE dl.depth < 3 -- 最多查3層
)
SELECT * FROM dept_limited;
3.2 檢測并避免循環(huán)引用(圖結(jié)構(gòu)必備)
如果數(shù)據(jù)存在循環(huán)(如 A→B→C→A),遞歸會無限進行。解決方案:記錄訪問路徑。
WITH RECURSIVE graph_traversal AS (
-- 錨點
SELECT
id,
name,
parent_id,
ARRAY[id] AS path, -- 記錄已訪問節(jié)點
1 AS depth
FROM departments
WHERE name = '技術(shù)部'
UNION ALL
-- 遞歸
SELECT
d.id,
d.name,
d.parent_id,
gt.path || d.id, -- 追加當(dāng)前節(jié)點
gt.depth + 1
FROM departments d
INNER JOIN graph_traversal gt ON d.parent_id = gt.id
WHERE
d.id != ALL(gt.path) -- 關(guān)鍵:當(dāng)前節(jié)點不在已訪問路徑中
AND gt.depth < 10 -- 安全兜底
)
SELECT * FROM graph_traversal;
d.id != ALL(gt.path) 確保不重復(fù)訪問節(jié)點,徹底解決循環(huán)問題
3.3 反向應(yīng)用:扁平數(shù)據(jù)轉(zhuǎn)樹形 JSON
PostgreSQL 支持將遞歸結(jié)果直接轉(zhuǎn)為 嵌套 JSON,適合 API 返回。如使用 jsonb_build_object 構(gòu)建樹
WITH RECURSIVE tree AS (
-- 葉子節(jié)點(無子節(jié)點)
SELECT
id,
name,
parent_id,
jsonb_build_object('id', id, 'name', name, 'children', '[]'::jsonb) AS node
FROM categories c1
WHERE NOT EXISTS (
SELECT 1 FROM categories c2 WHERE c2.parent_id = c1.id
)
UNION ALL
-- 非葉子節(jié)點(聚合子節(jié)點)
SELECT
p.id,
p.name,
p.parent_id,
jsonb_build_object(
'id', p.id,
'name', p.name,
'children', jsonb_agg(t.node)
) AS node
FROM categories p
INNER JOIN tree t ON t.parent_id = p.id
GROUP BY p.id, p.name, p.parent_id
)
SELECT node
FROM tree
WHERE parent_id IS NULL; -- 返回根節(jié)點
輸出 JSON:
{
"id": 1,
"name": "電子產(chǎn)品",
"children": [
{
"id": 2,
"name": "手機",
"children": [
{"id": 3, "name": "iPhone", "children": []},
{"id": 4, "name": "華為", "children": []}
]
},
{
"id": 5,
"name": "電腦",
"children": [
{"id": 6, "name": "筆記本", "children": []}
]
}
]
}
此方法利用 自底向上聚合,天然避免循環(huán),但要求數(shù)據(jù)為嚴(yán)格樹形(無環(huán))
四、實戰(zhàn)案例:商品分類樹
4.1 場景:電商商品分類(多級類目)
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INTEGER REFERENCES categories(id),
is_leaf BOOLEAN DEFAULT false -- 是否葉子節(jié)點
);
-- 插入數(shù)據(jù)
INSERT INTO categories (name, parent_id, is_leaf) VALUES
('電子產(chǎn)品', NULL, false),
('手機', 1, false),
('iPhone', 2, true),
('華為', 2, true),
('電腦', 1, false),
('筆記本', 5, true);
4.2 查詢“電子產(chǎn)品”下所有葉子類目(帶完整路徑)
WITH RECURSIVE category_tree AS (
-- 錨點:根類目
SELECT
id,
name,
parent_id,
name::TEXT AS full_path, -- 路徑字符串
1 AS level
FROM categories
WHERE name = '電子產(chǎn)品'
UNION ALL
-- 遞歸:拼接路徑
SELECT
c.id,
c.name,
c.parent_id,
ct.full_path || ' > ' || c.name, -- 路徑拼接
ct.level + 1
FROM categories c
INNER JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT
full_path,
id,
level
FROM category_tree
WHERE is_leaf = true; -- 只查葉子節(jié)點
輸出:
full_path | id | level ----------------------------|----|------ 電子產(chǎn)品 > 手機 > iPhone | 3 | 3 電子產(chǎn)品 > 手機 > 華為 | 4 | 3 電子產(chǎn)品 > 電腦 > 筆記本 | 6 | 3
4.3 Python + SQLAlchemy 實戰(zhàn)
在 Python 中使用遞歸查詢:
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
def get_dept_tree(session, root_name):
query = text("""
WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id, 0 AS level
FROM departments
WHERE name = :root_name
UNION ALL
SELECT d.id, d.name, d.parent_id, dt.level + 1
FROM departments d
INNER JOIN dept_tree dt ON d.parent_id = dt.id
)
SELECT * FROM dept_tree ORDER BY level;
""")
result = session.execute(query, {"root_name": root_name})
return result.fetchall()
# 使用
with Session() as session:
tree = get_dept_tree(session, "技術(shù)部")
for row in tree:
print(f"{' ' * row.level}{row.name}")
五、性能優(yōu)化:索引與執(zhí)行計劃
5.1 必建索引
-- 對 parent_id 建索引(遞歸連接的關(guān)鍵) CREATE INDEX idx_departments_parent_id ON departments(parent_id); -- 如果常按 name 查詢根節(jié)點 CREATE INDEX idx_departments_name ON departments(name);
5.2 查看執(zhí)行計劃
EXPLAIN (ANALYZE, BUFFERS) WITH RECURSIVE ... ; -- 你的遞歸查詢
關(guān)鍵觀察點:
- 是否使用了
Index Scan(而非Seq Scan) - 遞歸深度是否合理
- 內(nèi)存使用(
Buffers)
5.3 大數(shù)據(jù)量優(yōu)化建議
| 問題 | 解決方案 |
|---|---|
| 遞歸太深(>100層) | 限制 depth < N,業(yè)務(wù)上通常不需要過深層級 |
| 數(shù)據(jù)量大(百萬級) | 分頁查詢(先查ID再關(guān)聯(lián))、物化路徑(見下文) |
| 頻繁查詢 | 使用 物化路徑(Materialized Path) 或 閉包表(Closure Table) |
六、替代方案對比:何時不用遞歸?
雖然 WITH RECURSIVE 很強大,但在某些場景下,其他模型更高效:
6.1 物化路徑(Materialized Path)
在每條記錄中存儲完整路徑:
ALTER TABLE categories ADD COLUMN path TEXT; -- 如 "/1/2/3/" -- 查詢“手機”下所有子類目 SELECT * FROM categories WHERE path LIKE '/1/2/%';
? 優(yōu)點:查詢極快(走索引)
? 缺點:移動節(jié)點時需更新大量 path
6.2 閉包表(Closure Table)
額外建一張表存儲所有祖先-后代關(guān)系:
CREATE TABLE category_closure (
ancestor_id INT,
descendant_id INT,
depth INT
);
-- 查詢“手機”(id=2)的所有后代
SELECT c.*
FROM categories c
JOIN category_closure cl ON c.id = cl.descendant_id
WHERE cl.ancestor_id = 2;
? 優(yōu)點:查詢快,支持任意深度
? 缺點:寫操作復(fù)雜,存儲空間大
選擇建議:
- 讀多寫少 + 深度固定 → 物化路徑
- 頻繁查詢?nèi)窂?/strong> → 閉包表
- 通用場景 + 中小數(shù)據(jù)量 → WITH RECURSIVE
七、常見陷阱與避坑指南
陷阱 1:忘記WHERE條件導(dǎo)致無限循環(huán)
-- 錯誤:缺少終止條件 SELECT ... FROM table, cte WHERE table.parent_id = cte.id -- 如果存在循環(huán)引用,永遠停不下來!
? 解決:始終加上 depth < N 或路徑檢測
陷阱 2:使用UNION而非UNION ALL
UNION會去重,但遞歸中通常不需要(父子ID唯一)- 性能損失高達 30%+
? 解決:除非明確需要去重,否則用 UNION ALL
陷阱 3:在遞歸部分使用聚合函數(shù)
-- 錯誤:遞歸成員不能包含聚合 SELECT ..., COUNT(*) FROM ... JOIN cte ...
? 解決:先遞歸,再在外層聚合
以上就是PostgreSQL優(yōu)雅的進行遞歸查詢的實戰(zhàn)指南的詳細內(nèi)容,更多關(guān)于PostgreSQL進行遞歸查詢的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
PostgreSQL三種自增列sequence,serial,identity的用法區(qū)別
這篇文章主要介紹了PostgreSQL三種自增列sequence,serial,identity的用法區(qū)別,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-02-02
postgresql數(shù)據(jù)庫執(zhí)行計劃圖文詳解
了解PostgreSQL執(zhí)行計劃對于程序員來說是一項關(guān)鍵技能,執(zhí)行計劃是我們優(yōu)化查詢,驗證我們的優(yōu)化查詢是否確實按照我們期望的方式運行的重要方式,這篇文章主要給大家介紹了關(guān)于postgresql數(shù)據(jù)庫執(zhí)行計劃的相關(guān)資料,需要的朋友可以參考下2024-01-01
PostgreSQL向數(shù)據(jù)庫表中添加行數(shù)據(jù)的操作
這篇文章主要介紹了PostgreSQL向數(shù)據(jù)庫表中添加行數(shù)據(jù)的操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-02-02
PostgreSQL數(shù)據(jù)庫中窗口函數(shù)的語法與使用
這PostgreSQL中提供了窗口函數(shù),一個窗口函數(shù)在一系列與當(dāng)前行有某種關(guān)聯(lián)的表行上進行一種計算。下面這篇文章主要給大家介紹了關(guān)于PostgreSQL數(shù)據(jù)庫中窗口函數(shù)的語法與使用的相關(guān)資料,需要的朋友可以參考下2019-03-03

