开始前,会这些就够了
- 能读懂 SELECT、JOIN 和 UNION 的基本用途。
- 知道有向边 1→2 表示可以从一走到二。
- 能运行 Python 标准库 sqlite3。
- 需要补基础时:先补顶点、边与遍历
部门关系或依赖图里混进一个环:一指向二,二指向三,三又指回一。你把 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 时,整行是否仍相同。
| 第几行 | 整行身份 | 当前路径 |
|---|---|---|
| 1 | 1 | 此模式不检查路径 |
| 2 | 2 | 此模式不检查路径 |
| 3 | 3 | 此模式不检查路径 |
下一候选整行 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 指定。不要根据其他图上偶然出现的顺序,推断数据库对未声明排序的查询永远使用某种遍历顺序。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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 触发后的部分结果当作完整图。
- 用不带边界的字符串搜索判断节点一是否已经访问。
自己试一小步
在一张同时有环和菱形的图上,分别输出“可达节点”与“最多四步的简单路径”,你需要两种怎样的去重范围?
- 自己画至少五个节点,包含一条回边和两条通往同一节点的路。
- 先手算两个输出契约各允许保留什么。
- 分别改写 node-only 查询和 path 查询,并为实验设置明确的生成上限。
- 保存节点列表、路径列表及是否被保护上限截断的标记。
做到这些,就算完成
- 重复到达同一节点与在同一路径中重访,必须有不同的记录。
- 若超过限制,结果不能标为完整。
- 至少增加一个两位数节点,检查编号边界。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- SQLite:WITH 的递归执行规则 (新标签页)↗
核对 UNION 的整行去重、递归内部 LIMIT 和未声明队列顺序的边界。
- PostgreSQL 17:递归查询的环检测 (新标签页)↗
支持 depth 导致整行不再重复,以及按路径检测重访的因果解释。
- SQLite:instr 与字符串函数 (新标签页)↗
核对 instr 找不到子串时返回零,支持整数路径的边界检查。