PostgreSQL優(yōu)雅的進(jìn)行遞歸查詢的實(shí)戰(zhàn)指南
在實(shí)際開發(fā)中,我們經(jīng)常會(huì)遇到樹形結(jié)構(gòu)或圖結(jié)構(gòu)的數(shù)據(jù)需求,比如:
- 組織架構(gòu)(部門 → 子部門)
- 商品分類(一級(jí)類目 → 二級(jí)類目 → …)
- 評(píng)論回復(fù)(評(píng)論 → 回復(fù) → 回復(fù)的回復(fù))
- 權(quán)限繼承(角色 → 子角色)
- 路徑查找(最短路徑、依賴關(guān)系)
這些場景的核心問題是:如何高效查詢具有層級(jí)/遞歸關(guān)系的數(shù)據(jù)?
PostgreSQL 提供了強(qiáng)大的 WITH RECURSIVE(公共表表達(dá)式遞歸) 功能,是處理此類問題的標(biāo)準(zhǔn) SQL 解決方案。本文將從基礎(chǔ)到實(shí)戰(zhàn),手把手教你掌握遞歸查詢的精髓。
一、遞歸查詢基礎(chǔ):CTE 與WITH RECURSIVE
1.1 什么是 CTE(Common Table Expression)?
CTE 是一種臨時(shí)結(jié)果集,可被主查詢引用,語法如下:
WITH cte_name AS (
-- 查詢語句
)
SELECT * FROM cte_name;
優(yōu)點(diǎn):提升 SQL 可讀性、避免重復(fù)子查詢、支持遞歸
1.2 遞歸 CTE 的基本結(jié)構(gòu)
WITH RECURSIVE cte_name AS (
-- 1. 初始查詢(錨點(diǎn)成員 Anchor Member)
SELECT ... FROM table WHERE ...
UNION [ALL]
-- 2. 遞歸查詢(遞歸成員 Recursive Member)
SELECT ... FROM table, cte_name WHERE ...
)
SELECT * FROM cte_name;
核心三要素:
| 部分 | 作用 | 注意事項(xiàng) |
|---|---|---|
| 初始查詢 | 定義遞歸起點(diǎn)(如根節(jié)點(diǎn)) | 必須能終止遞歸 |
| UNION [ALL] | 合并結(jié)果集 | UNION 去重,UNION ALL 保留重復(fù)(性能更高) |
| 遞歸查詢 | 引用自身 CTE,向下/向上遍歷 | 必須有連接條件,避免無限循環(huán) |
1.3 遞歸查詢的建議
| 場景 | 推薦方案 |
|---|---|
| 標(biāo)準(zhǔn)樹形查詢(上下級(jí)) | WITH RECURSIVE + UNION ALL |
| 防循環(huán) | 記錄訪問路徑 ARRAY[id] + != ALL(path) |
| 限制深度 | 添加 depth 字段 + WHERE depth < N |
| 高性能讀 | 物化路徑 / 閉包表(寫少讀多) |
| 返回樹形 JSON | 自底向上聚合 + jsonb_build_object |
| Python 集成 | 直接執(zhí)行原生 SQL(SQLAlchemy 支持 CTE) |
終極建議:
“90% 的樹形查詢,一個(gè)精心設(shè)計(jì)的 WITH RECURSIVE 就夠了。”
只有在性能成為瓶頸時(shí),才考慮物化路徑等復(fù)雜模型。
二、經(jīng)典場景實(shí)戰(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è)計(jì)組', 3);
2.1 查詢“技術(shù)部”及其所有子部門(向下遞歸)
WITH RECURSIVE dept_tree AS (
-- 錨點(diǎn):找到“技術(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, -- 縮進(jìn)顯示層級(jí)
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, ' ') 生成縮進(jìn),直觀展示樹形結(jié)構(gòu)
2.2 查詢“后端組”的完整上級(jí)路徑(向上遞歸)
WITH RECURSIVE dept_path AS (
-- 錨點(diǎn):從“后端組”開始
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é)點(diǎn)
輸出結(jié)果:
path_from_root --------------------------- 總公司 → 技術(shù)部 → 后端組
三、高級(jí)技巧:控制遞歸深度與防環(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),遞歸會(huì)無限進(jìn)行。解決方案:記錄訪問路徑。
WITH RECURSIVE graph_traversal AS (
-- 錨點(diǎn)
SELECT
id,
name,
parent_id,
ARRAY[id] AS path, -- 記錄已訪問節(jié)點(diǎn)
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é)點(diǎn)
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é)點(diǎn)不在已訪問路徑中
AND gt.depth < 10 -- 安全兜底
)
SELECT * FROM graph_traversal;
d.id != ALL(gt.path) 確保不重復(fù)訪問節(jié)點(diǎn),徹底解決循環(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é)點(diǎn)(無子節(jié)點(diǎn))
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é)點(diǎn)(聚合子節(jié)點(diǎn))
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é)點(diǎn)
輸出 JSON:
{
"id": 1,
"name": "電子產(chǎn)品",
"children": [
{
"id": 2,
"name": "手機(jī)",
"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))
四、實(shí)戰(zhàn)案例:商品分類樹
4.1 場景:電商商品分類(多級(jí)類目)
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INTEGER REFERENCES categories(id),
is_leaf BOOLEAN DEFAULT false -- 是否葉子節(jié)點(diǎn)
);
-- 插入數(shù)據(jù)
INSERT INTO categories (name, parent_id, is_leaf) VALUES
('電子產(chǎn)品', NULL, false),
('手機(jī)', 1, false),
('iPhone', 2, true),
('華為', 2, true),
('電腦', 1, false),
('筆記本', 5, true);
4.2 查詢“電子產(chǎn)品”下所有葉子類目(帶完整路徑)
WITH RECURSIVE category_tree AS (
-- 錨點(diǎn):根類目
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é)點(diǎn)
輸出:
full_path | id | level ----------------------------|----|------ 電子產(chǎn)品 > 手機(jī) > iPhone | 3 | 3 電子產(chǎn)品 > 手機(jī) > 華為 | 4 | 3 電子產(chǎn)品 > 電腦 > 筆記本 | 6 | 3
4.3 Python + SQLAlchemy 實(shí)戰(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í)行計(jì)劃
5.1 必建索引
-- 對(duì) parent_id 建索引(遞歸連接的關(guān)鍵) CREATE INDEX idx_departments_parent_id ON departments(parent_id); -- 如果常按 name 查詢根節(jié)點(diǎn) CREATE INDEX idx_departments_name ON departments(name);
5.2 查看執(zhí)行計(jì)劃
EXPLAIN (ANALYZE, BUFFERS) WITH RECURSIVE ... ; -- 你的遞歸查詢
關(guān)鍵觀察點(diǎn):
- 是否使用了
Index Scan(而非Seq Scan) - 遞歸深度是否合理
- 內(nèi)存使用(
Buffers)
5.3 大數(shù)據(jù)量優(yōu)化建議
| 問題 | 解決方案 |
|---|---|
| 遞歸太深(>100層) | 限制 depth < N,業(yè)務(wù)上通常不需要過深層級(jí) |
| 數(shù)據(jù)量大(百萬級(jí)) | 分頁查詢(先查ID再關(guān)聯(lián))、物化路徑(見下文) |
| 頻繁查詢 | 使用 物化路徑(Materialized Path) 或 閉包表(Closure Table) |
六、替代方案對(duì)比:何時(shí)不用遞歸?
雖然 WITH RECURSIVE 很強(qiáng)大,但在某些場景下,其他模型更高效:
6.1 物化路徑(Materialized Path)
在每條記錄中存儲(chǔ)完整路徑:
ALTER TABLE categories ADD COLUMN path TEXT; -- 如 "/1/2/3/" -- 查詢“手機(jī)”下所有子類目 SELECT * FROM categories WHERE path LIKE '/1/2/%';
? 優(yōu)點(diǎn):查詢極快(走索引)
? 缺點(diǎn):移動(dòng)節(jié)點(diǎn)時(shí)需更新大量 path
6.2 閉包表(Closure Table)
額外建一張表存儲(chǔ)所有祖先-后代關(guān)系:
CREATE TABLE category_closure (
ancestor_id INT,
descendant_id INT,
depth INT
);
-- 查詢“手機(jī)”(id=2)的所有后代
SELECT c.*
FROM categories c
JOIN category_closure cl ON c.id = cl.descendant_id
WHERE cl.ancestor_id = 2;
? 優(yōu)點(diǎn):查詢快,支持任意深度
? 缺點(diǎn):寫操作復(fù)雜,存儲(chǔ)空間大
選擇建議:
- 讀多寫少 + 深度固定 → 物化路徑
- 頻繁查詢?nèi)窂?/strong> → 閉包表
- 通用場景 + 中小數(shù)據(jù)量 → WITH RECURSIVE
七、常見陷阱與避坑指南
陷阱 1:忘記WHERE條件導(dǎo)致無限循環(huán)
-- 錯(cuò)誤:缺少終止條件 SELECT ... FROM table, cte WHERE table.parent_id = cte.id -- 如果存在循環(huán)引用,永遠(yuǎn)停不下來!
? 解決:始終加上 depth < N 或路徑檢測
陷阱 2:使用UNION而非UNION ALL
UNION會(huì)去重,但遞歸中通常不需要(父子ID唯一)- 性能損失高達(dá) 30%+
? 解決:除非明確需要去重,否則用 UNION ALL
陷阱 3:在遞歸部分使用聚合函數(shù)
-- 錯(cuò)誤:遞歸成員不能包含聚合 SELECT ..., COUNT(*) FROM ... JOIN cte ...
? 解決:先遞歸,再在外層聚合
以上就是PostgreSQL優(yōu)雅的進(jìn)行遞歸查詢的實(shí)戰(zhàn)指南的詳細(xì)內(nèi)容,更多關(guān)于PostgreSQL進(jìn)行遞歸查詢的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
在docker上部署postgreSQL主從的超詳細(xì)步驟
使用Docker能夠更加高效地部署和管理應(yīng)用程序,提高開發(fā)和運(yùn)維的效率,下面這篇文章主要給大家介紹了關(guān)于在docker上部署postgreSQL主從的超詳細(xì)步驟,文中通過代碼及圖文介紹的非常詳細(xì),需要的朋友可以參考下2024-08-08
postgresql高級(jí)應(yīng)用之行轉(zhuǎn)列&匯總求和的實(shí)現(xiàn)思路
這篇文章主要介紹了postgresql高級(jí)應(yīng)用之行轉(zhuǎn)列&匯總求和的實(shí)現(xiàn)思路,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-05-05
PostgreSQL數(shù)據(jù)庫中to_timestamp函數(shù)用法示例
PostgreSQL 的 to_timestamp 函數(shù)可以將字符串或整數(shù)轉(zhuǎn)換為時(shí)間戳,這篇文章主要介紹了PostgreSQL數(shù)據(jù)庫中to_timestamp函數(shù)用法的相關(guān)資料,文中通過代碼介紹的非常詳細(xì),需要的朋友可以參考下2025-08-08
PostgreSQL regexp_matches替換like模糊查詢的操作
這篇文章主要介紹了PostgreSQL regexp_matches替換like模糊查詢的操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2021-01-01
PostgreSQL 數(shù)據(jù)庫性能提升的幾個(gè)方面
PostgreSQL提供了一些幫助提升性能的功能。主要有一些幾個(gè)方面。2009-09-09
PostgreSQL數(shù)據(jù)庫遷移部署實(shí)戰(zhàn)教程
這篇文章主要介紹了PostgreSQL數(shù)據(jù)庫遷移部署實(shí)戰(zhàn)教程,由于項(xiàng)目本身就是基于PostgreSQL數(shù)據(jù)庫構(gòu)建的,因此數(shù)據(jù)庫遷移將變得十分便捷,接下來,我將簡要介紹我們的遷移步驟,需要的朋友可以參考下2023-07-07

