04 · 多跳关系和声明式查询
目标:用关系遍历表达多跳问题,并判断查询语言与存储模型之间的关系。
“朋友参加的活动”多了什么
上一课的连接次数固定。如果现在要求“找到朋友的朋友推荐的活动”,关系的路径成为查询的核心。图模型把实体表示成顶点,把关系表示成边;属性图还可以在顶点和边上保存属性,例如关注日期。三元组模型用主语、谓语、宾语表达事实,也能形成图。
图数据不必存进图数据库。关系表 follows(source, target) 也能表达边,递归查询可以沿边前进。真正的选择在于:路径查询是否频繁、查询语言是否自然,以及索引和执行方式是否适合。SQL、Cypher、SPARQL、Datalog 分别提供不同的表达方法;只看“支持图”不能判断执行代价。
声明式查询说明要什么结果,系统决定怎样执行。下面的 Python 遍历则明确写出前沿如何扩展,目的是看见递归查询背后的机制。它不模拟数据库优化器。
控制多跳查询的边界
保存 query_models.py。先预测环 c → a 是否导致无限循环,d 是否在两跳之内。
edges = {"a": {"b"}, "b": {"c", "d"}, "c": {"a"}, "d": set()}
def reachable(start, max_hops):
seen = {start}
frontier = {start}
for _ in range(max_hops):
following = set()
for node in frontier:
following.update(edges.get(node, set()))
frontier = following - seen
seen.update(frontier)
return seen - {start}
result = reachable("a", 2)
assert result == {"b", "c", "d"}
assert reachable("a", 10) == result
print(sorted(result))
uv run --python 3.12 --no-project query_models.py
预期输出:
['b', 'c', 'd']
frontier 是这一轮新发现的顶点;seen 防止环使相同节点反复扩展。结果包含一跳与两跳可达者,不是“恰好两跳”,也不包含不同路径的数量。建模时要把这些区别写进需求。
引导修改:① 将跳数改为 1;② 对应断言改为 {"b"};③ 输出应只有 b。再添加 d → e,两跳仍不包含 e,三跳才会包含。若只是把所有边扫一次,无法表达这种层次。
查询接口不是数据库承诺
GraphQL 允许客户端声明需要哪些字段和嵌套对象,但不意味着底层一定使用图数据库,也不保证只发出一次数据库查询。若每个活动再单独查询组织者,20 个活动可能触发 21 次查询,这就是常说的 N+1 问题。批量加载或连接能减少往返,具体方式需要看实际数据源。
DataFrame 则适合对表状数据做列运算、过滤和聚合;矩阵把索引位置和数值运算放在中心。把用户 ID 映射为矩阵行时必须保存映射,否则计算结果失去业务含义。第 20 课会用明确的表连接来演示这种“数据形状转换”,不要求安装新的分析工具。
独立练习:修改上述函数,返回“恰好第二轮首次发现的人”,而不是两轮累计结果,并给出 a 的答案。
参考修改
把最后一行 return seen - {start} 替换为 return frontier。对于 max_hops=2 返回 {"c", "d"};对于 1 返回 {"b"}。为避免 0 跳返回自己,在函数开头增加 if max_hops == 0: return set()。这里的“首次发现”对应最短距离为两跳;若业务要所有长度为二的路径终点,即使已有一跳捷径也计入,就不能沿用同一个去重定义。
输出顺序变化时应比较集合,展示再排序;遍历意外扩大时先检查跳数和边的方向。在 design.md 中明确推荐关系的方向、最大跳数与去重口径;若主流程无需推荐,写成可选查询,避免为它改造报名事实模型。