qinghuan
← 回到知识探索

后端现场 / 可下载小项目

递归查询已经用了 UNION,为什么还是绕着同一圈跑?

在三节点环上只添加一列深度,就会改变去重对象;用有限的真实 SQLite 查询区分节点身份、整行身份与当前路径。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 把递归查询看成不断取出候选、再产生候选
  4. 添加深度后,重复的是节点,新的却是整行
  5. 需要保留路径信息时,就在当前路径上检查是否重访
  6. 防止一条路绕圈,不等于全局只保留一个节点
  7. 运行三个有限查询,再检查菱形与十一号节点
  8. 把能终止、结果完整和成本可承受分别验证
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 能读懂 SELECT、JOIN 和 UNION 的基本用途。
  • 知道有向边 1→2 表示可以从一走到二。
  • 能运行 Python 标准库 sqlite3。
  • 需要补基础时:先补顶点、边与遍历
遇到不熟悉的词,先看这 1 个解释
递归查询的去重对象
递归查询用 UNION 时,重复比较的是结果中的整行。把深度也放进结果,会让同一个节点在不同深度对应不同的行。
打开完整术语小抄 ↗

部门关系或依赖图里混进一个环:一指向二,二指向三,三又指回一。你把 UNION ALL 改成 UNION,查询终于停了。后来为了显示层数,加了一列 depth,为什么又开始不断生成结果?

本篇真正要追问的是:数据库在判断“重复”时比较了哪些列。我们保留同一张三节点图,先只输出节点,再输出节点与深度;错误版本设置内部八行上限,所以运行不会一直卡住。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压到独立练习文件夹,在含 demo.py 的目录打开终端运行。SQLite三节点环、带深度内部LIMIT8、按路径防环及菱形/编号边界5项检查

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

多记一个深度,去重对象就变了

固定有向环:1 → 2 → 3 → 1。UNION 比较结果中的整行;切换到“节点与深度”,看看第二次到达节点 1 时,整行是否仍相同。

每次进入结果的行
第几行整行身份当前路径
11此模式不检查路径
22此模式不检查路径
33此模式不检查路径

下一候选整行 1 已出现过,UNION 去重后不再继续。

页面按这个只有一条后继边的小图计算,未运行 SQL。完整 SQLite 实验在正文和下载包内;展示行数上限只是截断保护。按路径防环也不等于全局节点去重:在有分叉的图里,同一节点仍可能沿不同路径到达。

把递归查询看成不断取出候选、再产生候选

起点是编号一。每取出一个候选节点,就沿 edge 表里的出边找到下一个候选。1→2→3→1 形成一个环,若每次都原样接受,队列就永远还有下一项。递归语法本身不会证明这张关系图没有环。

第一条查询的中间结果只有一列 node,并用 UNION 连接起点与递归部分。候选 1、2、3 已经出现过以后,再次生成 1 会被当作相同的整行丢弃,不再产生新的工作。这次得到的可达节点是 1、2、3。

这个结果回答“有哪些节点能到达”,没有回答“沿哪条路到达”或“经过几步”。在增加输出列之前,先确认你是否仍想用同一套身份规则判断重复。

添加深度后,重复的是节点,新的却是整行

第二条查询把中间结果改成 (node, depth)。三步后回到编号一,行内容是 (1,3),而最初保存的是 (1,0)。两行的深度不同,所以 UNION 并不认为它们重复。继续绕圈会生成 (2,4)、(3,5) 和 (1,6)。

UNION 没有失效。它一直按查询所产生的整行去重,只是我们改变了整行包含的信息。路径文字、累计成本和时间戳也可能造成相同问题:同一个业务节点能够不断伴随新值出现。

脚本把 LIMIT 8 写在 SQLite 的递归 CTE 内部,使这个反例最多产生八行。达到上限只说明保护开关阻止继续生成,不表示图已经遍历完整。不要把截断后的前八行标成完整依赖清单。

需要保留路径信息时,就在当前路径上检查是否重访

第三条查询保留 path 字符串。起点路径是 ',1,';走向节点二前,先查路径里有没有 ',2,'。没有才追加,最终得到 ',1,2,3,';再走向一时发现 ',1,' 已存在,于是停止这一条路径。

两边加逗号是为了比较整个编号。若只搜索字符 '1',编号十一也包含它,可能被错误当成访问过。代码因此另外用 1→11→1 验证边界,确保十一可以进入路径,而重复的一被挡住。

这是一种适合小型整数教学图的表示。若节点名允许逗号,或路径很长,不能直接套用这一字符串拼法。应选择目标数据库支持的结构化路径表示,或把遍历交给能明确保存节点集合的程序。

防止一条路绕圈,不等于全局只保留一个节点

第二张图是菱形:1→2→4 与 1→3→4。两条路径都没有重访自己的节点,所以第三条查询会保留两行节点四,路径分别为 ',1,2,4,' 和 ',1,3,4,'。这是按路径检查的预期结果,不是去重失败。

如果业务只需要去重后的可达节点,第一种仅保留 node 的 UNION 更贴近问题。如果需要每条简单路径,按路径防环可能合适;但一个无环图也可能拥有很多条不同路径,有限不代表规模小。

因此输出契约必须先选定:节点集合、全部路径、最短路径,还是某种受限路径。它们不能仅通过在最终 SELECT 上随手加 DISTINCT 互相替换;最终去重也不能阻止前面的递归先生成过多候选。

运行三个有限查询,再检查菱形与十一号节点

完整脚本用内存 SQLite 建表,不访问外部数据库。第一张图固定为三条有向边 (1,2)、(2,3)、(3,1),第二条查询内部最多八行;其他查询通过有限图和路径约束结束。

本次 Python 3.14.0、SQLite 3.50.4 实跑完成五项检查。输出顺序由最终 ORDER BY 指定。不要根据其他图上偶然出现的顺序,推断数据库对未声明排序的查询永远使用某种遍历顺序。

展开收起代码 · 小实验python · 78 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""All recursive queries are finite. The depth counterexample has an inner LIMIT."""
from contextlib import closing
import sqlite3


NODES = """
WITH RECURSIVE reach(node) AS (
  VALUES(1)
  UNION
  SELECT edge.dst FROM edge JOIN reach ON edge.src = reach.node
)
SELECT node FROM reach ORDER BY node
"""

DEPTH_LIMITED = """
WITH RECURSIVE reach(node, depth) AS (
  VALUES(1, 0)
  UNION
  SELECT edge.dst, reach.depth + 1
  FROM edge JOIN reach ON edge.src = reach.node
  LIMIT 8
)
SELECT node, depth FROM reach ORDER BY depth, node
"""

PATH_SAFE = """
WITH RECURSIVE walk(node, depth, path) AS (
  VALUES(1, 0, ',1,')
  UNION ALL
  SELECT edge.dst, walk.depth + 1, walk.path || edge.dst || ','
  FROM edge JOIN walk ON edge.src = walk.node
  WHERE instr(walk.path, ',' || edge.dst || ',') = 0
)
SELECT node, depth, path FROM walk ORDER BY depth, node, path
"""


def main():
    checks = 0

    def check(condition, message):
        nonlocal checks
        if not condition:
            raise AssertionError(message)
        checks += 1

    with closing(sqlite3.connect(":memory:", isolation_level=None)) as db:
        db.execute("CREATE TABLE edge(src INTEGER NOT NULL, dst INTEGER NOT NULL, PRIMARY KEY(src,dst))")
        db.executemany("INSERT INTO edge VALUES(?,?)", [(1, 2), (2, 3), (3, 1)])
        nodes = [row[0] for row in db.execute(NODES)]
        depth_rows = db.execute(DEPTH_LIMITED).fetchall()
        paths = db.execute(PATH_SAFE).fetchall()
        check(nodes == [1, 2, 3], "node-only UNION should terminate with three identities")
        check(depth_rows == [(i % 3 + 1, i) for i in range(8)], "depth makes each row different")
        check(paths == [(1, 0, ',1,'), (2, 1, ',1,2,'), (3, 2, ',1,2,3,')], "path prevents revisits")
        print(f"cycle_node_only={nodes}")
        print(f"cycle_with_depth_limited={depth_rows}")
        print(f"cycle_path_safe={paths}")

        # Different paths to one node are legitimate; this is not global dedup.
        db.execute("DELETE FROM edge")
        db.executemany("INSERT INTO edge VALUES(?,?)", [(1, 2), (1, 3), (2, 4), (3, 4)])
        diamond = db.execute(PATH_SAFE).fetchall()
        paths_to_four = [row[2] for row in diamond if row[0] == 4]
        check(paths_to_four == [',1,2,4,', ',1,3,4,'], "two routes must remain distinguishable")
        print(f"diamond_paths_to_4={paths_to_four}")

        # Delimiters prevent node 1 from being confused with node 11.
        db.execute("DELETE FROM edge")
        db.executemany("INSERT INTO edge VALUES(?,?)", [(1, 11), (11, 1)])
        delimiter_rows = db.execute(PATH_SAFE).fetchall()
        check([r[0] for r in delimiter_rows] == [1, 11], "whole-token path membership")
        print(f"delimiter_nodes={[r[0] for r in delimiter_rows]}")
    print(f"PASS checks={checks}")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中执行 python -B demo.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。SQLite三节点环、带深度内部LIMIT8、按路径防环及菱形/编号边界5项检查

运行后对照结果
cycle_node_only=[1, 2, 3]
cycle_with_depth_limited=[(1, 0), (2, 1), (3, 2), (1, 3), (2, 4), (3, 5), (1, 6), (2, 7)]
cycle_path_safe=[(1, 0, ',1,'), (2, 1, ',1,2,'), (3, 2, ',1,2,3,')]
diamond_paths_to_4=[',1,2,4,', ',1,3,4,']
delimiter_nodes=[1, 11]
PASS checks=5

把能终止、结果完整和成本可承受分别验证

一个生产查询可以同时设行数、深度或时间方面的保护,但被保护截断后应明确报告未完成,而不是悄悄当作完整答案。路径数爆炸时,增加上限未必是合适的修复,可能要回到业务真正需要哪一种结果。

PostgreSQL 文档提供 CYCLE 等相关语法,可作为进一步学习;本篇运行的是 SQLite,字符串路径代码并不是 PostgreSQL CYCLE 的实现验证,也不构成所有数据库递归执行顺序的承诺。

记住这三点

  • UNION 按输出整行去重,添加 depth 会改变整行身份。
  • 当前路径防环可以保留同一节点的多条合法路径。
  • 有限截断、完整结果和可接受成本需要分别说明。

别把例子用过头

  • 认为换成 UNION 就能阻止任何递归查询绕圈。
  • 把最后 SELECT DISTINCT 当成递归过程的防环措施。
  • 把 LIMIT 触发后的部分结果当作完整图。
  • 用不带边界的字符串搜索判断节点一是否已经访问。

自己试一小步

在一张同时有环和菱形的图上,分别输出“可达节点”与“最多四步的简单路径”,你需要两种怎样的去重范围?

  1. 自己画至少五个节点,包含一条回边和两条通往同一节点的路。
  2. 先手算两个输出契约各允许保留什么。
  3. 分别改写 node-only 查询和 path 查询,并为实验设置明确的生成上限。
  4. 保存节点列表、路径列表及是否被保护上限截断的标记。

做到这些,就算完成

  • 重复到达同一节点与在同一路径中重访,必须有不同的记录。
  • 若超过限制,结果不能标为完整。
  • 至少增加一个两位数节点,检查编号边界。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。