🏠 总目录📚 本教程 06 · CTE 与递归查询 ← →
📑 本页目录(点开跳转)

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-018421339
2026-03-028433012
2026-03-038440188
2026-03-04(缺)
2026-03-05(缺)
2026-03-06(缺)
2026-03-07(缺)
2026-03-088511903
2026-03-098520447
2026-03-108531226

日历天数 : 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} 个不同节点")

实测输出:

流程图

① 有环 + UNION + 不带 depth→9 行(自己停住)
② 有环 + UNION + 带 depth(刹车 depth < 5)→11 行 / 9 个不同节点
② 有环 + UNION + 带 depth(刹车 depth < 10)→23 行 / 9 个不同节点
② 有环 + UNION + 带 depth(刹车 depth < 20)→45 行 / 9 个不同节点

⭐ 看第二组三行:节点数永远是 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 表同理:血缘表也是一张普通的表,不需要图数据库

✅ 检查点

  1. WITH 相比嵌套子查询,实际改变了什么、没有改变什么?
  2. 同一个 CTE 名字能被引用几次?嵌套子查询做得到吗?
  3. WITH RECURSIVE 的两段分别叫什么?递归什么时候停?
  4. 表里只有 6 天的数据,为什么 GROUP BY dt 数不出「缺了 4 天」?该怎么办?
  5. 血缘边表的两列各是什么含义?想问「这张表一改会影响谁」要怎么改这条查询?
  6. 那张菱形图上,train_v37 的上游有几个节点?加一列 path 之后结果变成几行、为什么?
  7. 有环的时候,UNION 能不能防住?带上 depth 列之后呢?实测 depth < 10 和 depth < 20 各跑出多少行、多少个节点?
  8. 安全写法里的三件事分别防的是什么?为什么「我们的图没有环」不能当理由?
  9. 加了回边之后,上游节点从 8 个变成 9 个,多出来的是谁?这说明什么?
👀 答案
  1. 改变的是可读性:从「括号最里层往外读」变成「从上往下读、每段有名字」。没有改变结果、语义,⚠️ 也不是性能优化 —— 数据库可能物化也可能展开(SQLite 3.35+ 可以用 MATERIALIZED / NOT MATERIALIZED 显式指定,本机 3.50.4 两种写法都能跑,但默认不要去指定)。
  2. 想引用几次都行(后面的 CTE 能引用前面的、外层能引用任意一个)。嵌套子查询做不到,只能把整段复制一遍。
  3. ① 种子查询(第一批行,不引用自己)② 递归步(JOIN 自己,拿上一批算下一批),中间用 UNION / UNION ALL 连接。直到某一轮不再产生新行就停。
  4. 因为缺的那 4 天在表里是不存在的行,聚合只能对已有的行分组,不存在的行不会以任何形式出现。办法是用 WITH RECURSIVE 造一根日历骨架,再 LEFT JOIN 事实表 —— 缺的天就变成 n_rows IS NULL 的行。实测:日历 10 天、缺 4 天、表里 6 行。
  5. child = 产出物,parent = 它的上游输入。⚠️ 方向反了查出来的是下游。想问「一改会影响谁」,把 child 和 parent 对调即可 —— 同一张表、同一个写法,一个是上游闭包,一个是下游闭包(改口径前的通知名单)。
  6. 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。
  7. 不带任何随轮次变化的列时,UNION 能防住(实测有环时 9 行就停)。⚠️ 一旦带上 depth 就防不住 —— 绕一圈回来 node 一样但 depth 已经 +1,整行是新的。实测:depth < 10 → 23 行 / 9 个节点,depth < 20 → 45 行 / 9 个节点。💀 真正的刹车是 WHERE up.depth < N,不是 UNION;去掉刹车这条查询不会返回。
  8. instr(path, …) = 0 防在环里打转;depth < 50 是兜底刹车(极端情况下也能返回);最后 GROUP BY node 取 MIN(depth) 保证每个节点只算一次。「我们的图没有环」不能当理由,因为这句话的保质期只到下次有人加一条边为止。
  9. 多出来的是 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

打卡记录保存在你的浏览器里,首页能看到总进度