📑 本页目录(点开跳转)
06 · CTE 与递归:把「这份数据的全部上游」查出来
⏱ 80 分钟 | ⭐ 血缘图就是一张边表,「全部上游」是一条递归查询
🎯 一句话
WITH 把套了三层的子查询摊成一条流水线;WITH RECURSIVE 让一条查询能自己接着自己跑,于是「顺着边一直往上走」不再需要写 Python。
前者是可读性,后者是能力 —— 没有它,「这份训练数据的全部上游是谁」这个问题在纯 SQL 里根本表达不出来。
🧭 一、先划清和《数据这一关 17》的分工
数据这一关 · 17 数据版本与血缘 owns「血缘该记什么、为什么要记」: run manifest 里要有上游表 + 分区 + 每个上游的指纹 + 行数 + 数据截止时间,缺一项不许发版。
本章 owns「记下来之后怎么查」。 两件事完全不重叠,但缺了后者,前者只是一堆躺着的 JSON。
⭐ 17 章有三处白纸黑字在等这一章:
| 17 章那里写着 | 它需要的查询 |
|---|---|
| 第一节六项自测的 ③「是哪段 SQL 产出的?改过吗」、④「过滤掉了哪些行」 | 这两条要读 SQL,而三层嵌套的 SQL 没人读得下去 —— 第二节 |
第四节 manifest 里的 "inputs": [{table, partition, fingerprint, n_rows}, …] |
⭐ 这就是一张边表,一条边一行;「全部上游」是它的传递闭包 —— 第三节 |
| 第四节那句叹息:「过滤条件往往写在 SQL 里,但没人会去读三个月前的 SQL」 | CTE 正是让那段 SQL 可读的工具,而 17 章从头到尾没提过它 |
⚠️ 反过来也成立:本章不重讲指纹怎么算、backfill 为什么危险、那场信贷事故,那些是 17 章的正题。
🧩 二、WITH:把嵌套读成流水线
先看一个真问题:manifest 都记下来了,怎么发现「分区名没变、内容却变了」? (这正是 17 章的「死法 B」,它说没有指纹就无法被发现 —— 有了指纹,发现它是一条查询。)
CREATE TABLE run_inputs (
run_id TEXT, tbl TEXT, part TEXT, fingerprint TEXT, n_rows INTEGER);
嵌套写法(只有两层就已经要从里往外读了):
SELECT tbl, part, k FROM (
SELECT tbl, part, COUNT(DISTINCT fingerprint) AS k
FROM run_inputs GROUP BY tbl, part
) WHERE k > 1 ORDER BY tbl, part;
CTE 写法(从上往下读,每一段有名字):
WITH per_part AS (
SELECT tbl, part, COUNT(DISTINCT fingerprint) AS k
FROM run_inputs GROUP BY tbl, part
), changed AS (
SELECT * FROM per_part WHERE k > 1
)
SELECT tbl, part, k FROM changed ORDER BY tbl, part;
整段可跑:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("""CREATE TABLE run_inputs (
run_id TEXT, tbl TEXT, part TEXT, fingerprint TEXT, n_rows INTEGER)""")
db.executemany("INSERT INTO run_inputs VALUES (?,?,?,?,?)", [
("train_v37_0131", "dw.user_profile_wide", "dt=2024-01-31", "3a91f0c7", 8421339),
("train_v37_0131", "dw.repay_label", "dt=2024-01-31", "77c0be14", 3110284),
("train_v38_0430", "dw.user_profile_wide", "dt=2024-01-31", "b5e2149c", 8421339), # ⭐ 同分区,指纹变了
("train_v38_0430", "dw.repay_label", "dt=2024-01-31", "77c0be14", 3110284),
("train_v39_0731", "dw.user_profile_wide", "dt=2024-01-31", "b5e2149c", 8421339),
("train_v39_0731", "dw.repay_label", "dt=2024-07-31", "0d4471ff", 3517190),
])
nested = """
SELECT tbl, part, k FROM (
SELECT tbl, part, COUNT(DISTINCT fingerprint) AS k
FROM run_inputs GROUP BY tbl, part
) WHERE k > 1 ORDER BY tbl, part
"""
cte = """
WITH per_part AS (
SELECT tbl, part, COUNT(DISTINCT fingerprint) AS k
FROM run_inputs GROUP BY tbl, part
), changed AS (
SELECT * FROM per_part WHERE k > 1
)
SELECT tbl, part, k FROM changed ORDER BY tbl, part
"""
print("嵌套写法 :", db.execute(nested).fetchall())
print("CTE 写法 :", db.execute(cte).fetchall())
print("两者相同 :", db.execute(nested).fetchall() == db.execute(cte).fetchall())
实测输出:
要点
嵌套写法 : [('dw.user_profile_wide', 'dt=2024-01-31', 2)]
CTE 写法 : [('dw.user_profile_wide', 'dt=2024-01-31', 2)]
两者相同 : True
⭐ 一行结果就是 17 章那句「分区名没变、行数没变、schema 没变」的实证:dt=2024-01-31 这个分区被两次训练读到过 2 个不同的指纹。
WITH 的三条值得记住的性质:
| 性质 | 说明 |
|---|---|
| 它是命名,不是新表 | CTE 只在这条语句里存在,语句跑完就没了。⚠️ 不要以为它写进了库 |
| ⭐ 一次定义,多处引用 | 同一个 CTE 名字可以在后面被 JOIN 两次、在两个子查询里各用一次;嵌套子查询做不到,只能整段复制 |
| 后面的 CTE 能引用前面的 | 所以它读起来是流水线:per_part → changed → 最终 SELECT,⚠️ 但不能反过来引用后面的(除非是递归,见下一节) |
⭐ 判据:一旦你开始从括号最里层往外读,就该换成 CTE。 它不改变结果、不改变语义,改变的是三个月后还有没有人看得懂 —— 而这正是 17 章第四节那句叹息的解法。
⚠️ CTE 不是性能优化。 数据库可能把它算完存下来(物化),也可能把它展开进外层。
SQLite 3.35 起可以用 WITH x AS MATERIALIZED (...) / NOT MATERIALIZED 显式要求(本机 3.50.4 两种写法都跑通了),
但⭐ 默认不要去指定它 —— 先让它可读,慢了再回到 09 章看计划。
🌲 三、WITH RECURSIVE:两段式结构
普通 CTE 只能引用前面定义好的 CTE。RECURSIVE 解开了这条限制:CTE 可以在自己的定义里引用自己。
结构永远是两段,中间用 UNION 或 UNION ALL 连起来:
WITH RECURSIVE 名字(列, …) AS (
种子查询 -- ① 第一批行,不引用自己
UNION [ALL]
递归步 … JOIN 名字 … -- ② 引用自己,拿上一批算出下一批
)
SELECT … FROM 名字;
⭐ 执行模型只有一句话:拿种子当作「当前这一批」,反复代进递归步算出新的一批,直到某一轮不再产生新行为止。
最小的例子:造一根日历骨架。 这在数据活里天天要用 —— 表里只有「有数据的那些天」,
而你要回答的是「哪几天根本没跑」,缺的那几天在表里是不存在的行,GROUP BY 永远数不出来:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE runs (dt TEXT, n_rows INTEGER)")
db.executemany("INSERT INTO runs VALUES (?,?)", [
("2026-03-01", 8421339), ("2026-03-02", 8433012), ("2026-03-03", 8440188),
("2026-03-08", 8511903), ("2026-03-09", 8520447), ("2026-03-10", 8531226),
]) # ⚠️ 03-04 ~ 03-07 这四天的任务根本没跑
q = """
WITH RECURSIVE cal(d) AS (
SELECT '2026-03-01' -- ① 种子
UNION ALL
SELECT date(d, '+1 day') FROM cal WHERE d < '2026-03-10' -- ② 递归步 ⭐ 这里就是刹车
)
SELECT cal.d, r.n_rows
FROM cal LEFT JOIN runs r ON r.dt = cal.d -- ⭐ 左连接:没跑的那几天变成 NULL
ORDER BY cal.d
"""
for d, n in db.execute(q):
print(f" {d} {'(缺)' if n is None else n}")
missing = db.execute(f"SELECT COUNT(*) FROM ({q}) WHERE n_rows IS NULL").fetchone()[0]
print("日历天数 :", db.execute(f"SELECT COUNT(*) FROM ({q})").fetchone()[0],
"| 缺的天数 :", missing,
"| 表里的行数 :", db.execute("SELECT COUNT(*) FROM runs").fetchone()[0])
实测输出:
| 日期 | 记录数 |
|---|---|
| 2026-03-01 | 8421339 |
| 2026-03-02 | 8433012 |
| 2026-03-03 | 8440188 |
| 2026-03-04 | (缺) |
| 2026-03-05 | (缺) |
| 2026-03-06 | (缺) |
| 2026-03-07 | (缺) |
| 2026-03-08 | 8511903 |
| 2026-03-09 | 8520447 |
| 2026-03-10 | 8531226 |
日历天数 : 10 | 缺的天数 : 4 | 表里的行数 : 6
⭐ 这十行里最值钱的是那四行 (缺):表里只有 6 行,任何 GROUP BY dt 都只能数出 6 天,
缺的四天不会以任何形式出现在结果里。骨架 LEFT JOIN 事实表,是把「不存在」变成「一行 NULL」的标准手法。
⚠️ 这个骨架在 08 章还会再用一次 —— 那里要处理的正是「数据有缺口时,ROWS 和 RANGE 不是一回事」。
🕸️ 四、血缘:inputs 数组就是一张边表
17 章的 manifest 里,inputs 每一项是「这次产出用了哪张上游表」。把它摊平,就是一张两列的边表:
CREATE TABLE edges (child TEXT, parent TEXT); -- child 的上游是 parent
⚠️ 列名要认死:child 是产出物,parent 是它的输入。方向搞反了,你查出来的是下游不是上游。
一张有菱形的血缘图(src.crm 有两条路能到达 —— 这在真实数仓里是常态,不是特例):
| child(产出) | parent(上游) |
|---|---|
train_v37 |
feat_user_wide |
train_v37 |
feat_label |
feat_user_wide |
dw.user_profile_wide |
feat_user_wide |
dim_city |
feat_label |
dw.repay_label |
dw.user_profile_wide |
src.crm |
dw.user_profile_wide |
src.app_log |
dw.repay_label |
src.crm ⭐ 菱形汇点 |
dim_city |
src.geo |
「train_v37 的全部上游」就是一条递归查询:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE edges (child TEXT, parent TEXT)")
db.executemany("INSERT INTO edges VALUES (?,?)", [
("train_v37", "feat_user_wide"),
("train_v37", "feat_label"),
("feat_user_wide", "dw.user_profile_wide"),
("feat_user_wide", "dim_city"),
("feat_label", "dw.repay_label"),
("dw.user_profile_wide", "src.crm"),
("dw.user_profile_wide", "src.app_log"),
("dw.repay_label", "src.crm"), # ⭐ 菱形:src.crm 有两条路可以到达
("dim_city", "src.geo"),
])
q = """
WITH RECURSIVE up(node) AS (
SELECT parent FROM edges WHERE child = 'train_v37' -- ① 直接上游
UNION
SELECT e.parent FROM edges e JOIN up ON e.child = up.node -- ② 上游的上游
)
SELECT node FROM up ORDER BY node
"""
rows = db.execute(q).fetchall()
print("上游节点数 :", len(rows))
for r in rows:
print(" ", r[0])
print("src.crm 出现次数 :", sum(1 for r in rows if r[0] == "src.crm"))
实测输出:
对照
上游节点数 : 8
dim_city
dw.repay_label
dw.user_profile_wide
feat_label
feat_user_wide
src.app_log
src.crm
src.geo
src.crm 出现次数 : 1
⭐ 这 8 行就是 17 章那句「保留期 ≥ 监管追溯期」要盯的清单 —— 它不是两张表,是八张。 只盯 manifest 里直接列出的两个上游,剩下六个里任何一个被覆盖,你照样复现不出来。
⭐ 换个方向问「这张表一改会影响谁」,只要把
child和parent对调。 同一张边表、同一个写法,一个是上游闭包(我依赖谁),一个是下游闭包(谁依赖我)。 后者就是改口径前必须发的那份通知名单。
🛑 读到这里可以停 —— 前半章讲完了(约 34 分钟)。 后半章还有:三个坑:
UNION不是万能防环 · 可以照抄的安全写法 回来的时候不用重读,直接从下一节接着看就行。
💀 五、三个坑:UNION 不是万能防环
上面那条查询能停下来,靠的是 UNION 去重:绕回已经见过的节点时不产生新行,递归自然收敛。
⚠️ 但这个保护比你以为的脆弱得多,三处都亲手跑过。
坑 ①:UNION 去的是「整行」的重,不是「节点」的重
给递归多带一列 path(很自然的需求:想知道每个上游是怎么被依赖上的),去重立刻失效:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE edges (child TEXT, parent TEXT)")
db.executemany("INSERT INTO edges VALUES (?,?)", [
("train_v37", "feat_user_wide"), ("train_v37", "feat_label"),
("feat_user_wide", "dw.user_profile_wide"), ("feat_user_wide", "dim_city"),
("feat_label", "dw.repay_label"),
("dw.user_profile_wide", "src.crm"), ("dw.user_profile_wide", "src.app_log"),
("dw.repay_label", "src.crm"), ("dim_city", "src.geo"),
])
q_path = """
WITH RECURSIVE up(node, path) AS (
SELECT parent, 'train_v37>' || parent FROM edges WHERE child = 'train_v37'
UNION
SELECT e.parent, up.path || '>' || e.parent FROM edges e JOIN up ON e.child = up.node
)
SELECT node, path FROM up ORDER BY node, path
"""
rows = db.execute(q_path).fetchall()
print("带 path 的行数 :", len(rows))
for n, p in rows:
print(" ", n, "|", p)
print("src.crm 出现次数 :", sum(1 for n, _ in rows if n == "src.crm"))
q_all = """
WITH RECURSIVE up(node) AS (
SELECT parent FROM edges WHERE child = 'train_v37'
UNION ALL
SELECT e.parent FROM edges e JOIN up ON e.child = up.node
)
SELECT node FROM up
"""
rows2 = db.execute(q_all).fetchall()
print("UNION ALL 行数 :", len(rows2),
"| src.crm 出现次数 :", sum(1 for r in rows2 if r[0] == "src.crm"))
实测输出(只摘关键几行):
对照
带 path 的行数 : 9
src.crm | train_v37>feat_label>dw.repay_label>src.crm
src.crm | train_v37>feat_user_wide>dw.user_profile_wide>src.crm
src.crm 出现次数 : 2
UNION ALL 行数 : 9 | src.crm 出现次数 : 2
⭐ 8 行变 9 行:src.crm 那两行的 node 一模一样,但 path 不同,于是它们是两个不同的整行,UNION 全都留下。
⚠️ 如果你拿这个结果去 COUNT(*) 报「上游有几个」,你会报出 9 —— 而真实答案是 8。
菱形结构在数仓里到处都是,这是最容易发生的一次多算。
坑 ②:UNION ALL 在菱形上必然重复计数
上面同一段代码的最后两行就是:不带 path、只换成 UNION ALL,照样是 9 行、src.crm 两次。
⭐ 判据很简单:UNION ALL 一条边都不去重,图里有几条不同的路径,汇点就被算几遍。
坑 ③:💀 加一列 depth,UNION 的防环就没了
真实血缘图会有环(有人把模型打分写回了 CRM 表,就是一条回边)。
很多人以为「UNION 会去重,所以有环也不怕」—— ⚠️ 只在你没带任何随轮次变化的列时才成立:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE edges (child TEXT, parent TEXT)")
db.executemany("INSERT INTO edges VALUES (?,?)", [
("train_v37", "feat_user_wide"), ("train_v37", "feat_label"),
("feat_user_wide", "dw.user_profile_wide"), ("feat_user_wide", "dim_city"),
("feat_label", "dw.repay_label"),
("dw.user_profile_wide", "src.crm"), ("dw.user_profile_wide", "src.app_log"),
("dw.repay_label", "src.crm"), ("dim_city", "src.geo"),
("src.crm", "train_v37"), # ⚠️ 一条回边:有人把模型打分写回了 CRM,成环
])
q_plain = """
WITH RECURSIVE up(node) AS (
SELECT parent FROM edges WHERE child = 'train_v37'
UNION
SELECT e.parent FROM edges e JOIN up ON e.child = up.node
)
SELECT COUNT(*) FROM up
"""
print("① 有环 + UNION + 不带 depth →", db.execute(q_plain).fetchone()[0], "行(自己停住)")
for cap in (5, 10, 20):
q_depth = f"""
WITH RECURSIVE up(node, depth) AS (
SELECT parent, 1 FROM edges WHERE child = 'train_v37'
UNION
SELECT e.parent, up.depth + 1 FROM edges e JOIN up ON e.child = up.node
WHERE up.depth < {cap}
)
SELECT COUNT(*), COUNT(DISTINCT node) FROM up
"""
n, d = db.execute(q_depth).fetchone()
print(f"② 有环 + UNION + 带 depth(刹车 depth < {cap})→ {n} 行 / {d} 个不同节点")
实测输出:
流程图
⭐ 看第二组三行:节点数永远是 9,行数却随刹车线一路涨到 45。
因为绕一圈回来时 node 虽然一样,depth 已经 +1,整行是新的,UNION 拦不住。
💀 真正让它停下来的是 WHERE up.depth < N 这个刹车,不是 UNION。 把刹车去掉,这条查询不会返回。
⚠️ 顺带看第一行:没有环的时候是 8 个节点,加了那条回边变成 9 个 ——
多出来的正是 train_v37 自己。⭐ 「一个产出物出现在自己的上游列表里」是环的报警信号,
血缘查询里值得单独加一条断言。
🧯 六、可以照抄的安全写法
把三个坑一次堵上:path 判环 + depth 兜底刹车 + 最后按节点收敛。
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE edges (child TEXT, parent TEXT)")
db.executemany("INSERT INTO edges VALUES (?,?)", [
("train_v37", "feat_user_wide"), ("train_v37", "feat_label"),
("feat_user_wide", "dw.user_profile_wide"), ("feat_user_wide", "dim_city"),
("feat_label", "dw.repay_label"),
("dw.user_profile_wide", "src.crm"), ("dw.user_profile_wide", "src.app_log"),
("dw.repay_label", "src.crm"), ("dim_city", "src.geo"),
("src.crm", "train_v37"), # 回边,成环
])
q = """
WITH RECURSIVE up(node, depth, path) AS (
SELECT parent, 1, '>' || parent || '>'
FROM edges WHERE child = 'train_v37'
UNION ALL
SELECT e.parent, up.depth + 1, up.path || e.parent || '>'
FROM edges e JOIN up ON e.child = up.node
WHERE instr(up.path, '>' || e.parent || '>') = 0 -- ⭐ 判环:这条路上来过就不再走
AND up.depth < 50 -- ⭐ 兜底刹车,永远要有
)
SELECT node, MIN(depth) AS d FROM up GROUP BY node ORDER BY d, node -- ⭐ 按节点收敛
"""
for node, d in db.execute(q):
print(f" 深度 {d} {node}")
print("节点总数 :", db.execute(f"SELECT COUNT(*) FROM ({q})").fetchone()[0])
实测输出:
对照
深度 1 feat_label
深度 1 feat_user_wide
深度 2 dim_city
深度 2 dw.repay_label
深度 2 dw.user_profile_wide
深度 3 src.app_log
深度 3 src.crm
深度 3 src.geo
深度 4 train_v37
节点总数 : 9
三件事各由谁负责:
| 想要的 | 靠什么 | ⚠️ 不能靠什么 |
|---|---|---|
| 不在环里打转 | instr(path, …) = 0 逐路径判环 |
❌ UNION 去重 —— 带了 depth/path 就失效 |
| 极端情况下也能返回 | ⭐ depth < 50 兜底 |
❌ 「我们的图没有环」 —— 这句话的保质期是到下次有人加一条边 |
| 每个节点只算一次 | 最后 GROUP BY node 取 MIN(depth) |
❌ 递归里去重 —— 那正是坑 ① |
⭐
MIN(depth)顺带给了一个免费的读法:深度 1 是直接上游,深度大的是「隔了几层才影响到我」。 排查「哪张表挂了导致我今天没数据」时,从深度小的往大的看。
⚠️ 本章只解决「边表已经有了」之后的那一半。 边表怎么来(inputs 数组怎么落盘、指纹怎么算、
临时表和跨库依赖怎么记)全部在 17 章 —— 没有那一半,这一章的查询无处可跑。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 数据这一关 17 数据版本与血缘 | ⭐ 成对的另一半:那边讲血缘该记什么(manifest / 指纹 / 保留期),这一章讲记下来之后怎么查。它第四节那个 inputs 数组,就是本章第四节那张 edges 表 |
| 05-子查询的三种形状.html | CTE 是子查询的可读形态。IN / EXISTS 那一套语义在 CTE 里一个字都没变,只是从括号里挪到了上面 |
| 07-窗口函数.html | 下一章。CTE 和窗口函数是天然搭档:先用 CTE 把数据整形,再在最后一段开窗 —— 因为窗口函数不能写在 WHERE 里,想按排名过滤就必须先 CTE 再筛 |
| 08-窗口帧.html | 本章那根日历骨架在那里会再用一次:数据有缺口时,「最近 3 行」和「最近 3 天」不是一回事 |
| 02-JOIN的几种语义.html | 日历骨架靠 LEFT JOIN 才能把「不存在的那几天」变成 NULL 行;⚠️ 那一章也讲了扇出 —— 血缘边表一旦有重复边,递归会连带放大 |
| AI全栈 06 关系数据库 | 那一章讲 messages / conversations 怎么建表、怎么迁移。⭐ 本章的 edges 表同理:血缘表也是一张普通的表,不需要图数据库 |
✅ 检查点
WITH相比嵌套子查询,实际改变了什么、没有改变什么?- 同一个 CTE 名字能被引用几次?嵌套子查询做得到吗?
WITH RECURSIVE的两段分别叫什么?递归什么时候停?- 表里只有 6 天的数据,为什么
GROUP BY dt数不出「缺了 4 天」?该怎么办? - 血缘边表的两列各是什么含义?想问「这张表一改会影响谁」要怎么改这条查询?
- 那张菱形图上,
train_v37的上游有几个节点?加一列path之后结果变成几行、为什么? - 有环的时候,
UNION能不能防住?带上depth列之后呢?实测depth < 10和depth < 20各跑出多少行、多少个节点? - 安全写法里的三件事分别防的是什么?为什么「我们的图没有环」不能当理由?
- 加了回边之后,上游节点从 8 个变成 9 个,多出来的是谁?这说明什么?
👀 答案
- 改变的是可读性:从「括号最里层往外读」变成「从上往下读、每段有名字」。没有改变结果、语义,⚠️ 也不是性能优化 —— 数据库可能物化也可能展开(SQLite 3.35+ 可以用
MATERIALIZED/NOT MATERIALIZED显式指定,本机 3.50.4 两种写法都能跑,但默认不要去指定)。 - 想引用几次都行(后面的 CTE 能引用前面的、外层能引用任意一个)。嵌套子查询做不到,只能把整段复制一遍。
- ① 种子查询(第一批行,不引用自己)② 递归步(
JOIN自己,拿上一批算下一批),中间用UNION/UNION ALL连接。直到某一轮不再产生新行就停。 - 因为缺的那 4 天在表里是不存在的行,聚合只能对已有的行分组,不存在的行不会以任何形式出现。办法是用
WITH RECURSIVE造一根日历骨架,再LEFT JOIN事实表 —— 缺的天就变成n_rows IS NULL的行。实测:日历 10 天、缺 4 天、表里 6 行。 child= 产出物,parent= 它的上游输入。⚠️ 方向反了查出来的是下游。想问「一改会影响谁」,把child和parent对调即可 —— 同一张表、同一个写法,一个是上游闭包,一个是下游闭包(改口径前的通知名单)。- 8 个(
feat_user_wide/feat_label/dw.user_profile_wide/dim_city/dw.repay_label/src.crm/src.app_log/src.geo)。加path后变成 9 行,因为src.crm有两条路径可达,两行的node相同但path不同 ——UNION去的是「整行」的重,不是「节点」的重。⚠️ 拿它COUNT(*)会报出 9,真实答案是 8。 - 不带任何随轮次变化的列时,
UNION能防住(实测有环时 9 行就停)。⚠️ 一旦带上depth就防不住 —— 绕一圈回来node一样但depth已经 +1,整行是新的。实测:depth < 10→ 23 行 / 9 个节点,depth < 20→ 45 行 / 9 个节点。💀 真正的刹车是WHERE up.depth < N,不是UNION;去掉刹车这条查询不会返回。 instr(path, …) = 0防在环里打转;depth < 50是兜底刹车(极端情况下也能返回);最后GROUP BY node取MIN(depth)保证每个节点只算一次。「我们的图没有环」不能当理由,因为这句话的保质期只到下次有人加一条边为止。- 多出来的是
train_v37自己(它经由回边成了自己的上游)。「一个产出物出现在自己的上游列表里」是环的报警信号,值得在血缘查询里单独加一条断言。
🛑 可以停在这里
⚡ 走神救援
⭐
WITH是可读性,WITH RECURSIVE是能力。前者把「从括号最里层往外读」的嵌套子查询摊成一条从上往下读、每段有名字的流水线——⭐ 判据:一旦你开始从最里层往外读,就该换成 CTE。⚠️ 它不改结果、不改语义,也不是性能优化。它真正的好处是一次定义、多处引用,而嵌套子查询只能整段复制。
WITH RECURSIVE是两段式:种子(第一批行)+ 递归步(JOIN自己,拿上一批算下一批),直到某一轮不再产生新行就停。⭐ 最小用法是造日历骨架:
GROUP BY 日期永远数不出「缺了几天」,因为缺的那几天是不存在的行——先造出完整日历再左连接,缺口才会显形。⭐ 血缘图就是一张两列边表,「全部上游」= 传递闭包 = 一条递归查询。实测一个菱形依赖里,真正的上游是 8 个节点——⭐ 这 8 个才是「保留期 ≥ 追溯期」要盯的清单,而不是 manifest 里直接列的那 2 个。把两列对调就得到下游闭包,那是改口径前的通知名单。
💀 三个坑:①
UNION去的是「整行」的重,不是「节点」的重——多带一列path就会让同一个节点出现两次,拿去计数直接把上游数报多;②UNION ALL在菱形上必然重复计数;③ ⭐ 加一列depth,UNION的防环就失效了(绕一圈回来节点一样但 depth 已经 +1,整行是新的)——真正的刹车是WHERE depth < N,不是UNION。
下一节 👉 07-窗口函数.md