Neo4j 属性图:当邻居指针取代 JOIN
Neo4j Property Graphs: When Neighbor Pointers Replace Joins
把好友关系、供应链、分子键画成圆点和箭头,几乎人人都会。把这张图画进数据库,很多人的第一反应却是:再加一张关联表,用 JOIN 把两边拼回去。这条路能走通,也把图查询最贵的那一步藏进了优化器:每多走一跳,就要再做一次基于索引的查找。
属性图数据库把这个假设反过来。节点不只是一行带主键的记录,关系也不只是外键。每个节点在磁盘上直接保存指向邻居的指针。顺着一条边走过去,成本接近一次指针解引用,而不是一次 B 树查找。这个设计叫无索引邻接(index-free adjacency)。它不是营销口号,而是存储布局上的一次取舍。
Neo4j 是把这套布局做进生产系统、并让 Cypher 成为事实查询语言的那一支。1.0 版于 2010 年 2 月发布,此前已在内部和早期客户环境中使用。本文不把它写成产品目录。要问的是:属性图到底保存了什么,无索引邻接省下了哪一类工作,又在哪一类工作上必然失败。
破除误解:图数据库不是会递归的 SQL
递归公共表表达式可以把“朋友的朋友”写成一段 SQL。于是有人说:关系库已经能表示图,专门的图库只是语法糖。
这句话漏掉了物理层。关系库里,节点是表中的行,边是另一张表里的外键对。走一跳的典型路径是:拿起点主键去边表上做索引查找,再拿对端主键去节点表上做第二次查找。跳数增加时,优化器可以把若干次查找规划成哈希连接或嵌套循环,但每一次跳转仍然是一次按值查找。数据在页面里按主键或插入顺序排列,并不按图上的邻居关系排列。
无索引邻接把邻居列表写进节点自己的记录里。从 A 走到 B,引擎读的是 A 上已经存好的关系指针,而不是去一张全局边表里搜索 from = A。Marko Rodriguez 与 Peter Neubauer 在 2010 年的短文里把这个差别说得很硬:邻接成为记录的一部分,而不是靠二次索引事后拼出来的视图。
另一层误解是把图库和“NoSQL 不讲事务”捆在一起。Neo4j 从一开始就提供 ACID 事务。节点和关系的创建、属性修改、删除,可以包在同一组提交里。图模型改变的是访问路径,不是把一致性丢掉。
第三层误解是把属性图当成 RDF 三元组的别名。RDF 把世界拆成主语—谓语—宾语,查询走 SPARQL,标识倾向用 URI。属性图允许节点和关系各自携带任意键值属性,关系本身是一等对象,可以有方向、类型和自己的属性。两种模型都能表示图,物理代价与查询习惯并不相同。
磁盘上到底长什么样
可以把一次典型存储看成三类记录。
节点记录很小:一份指向第一条关系的指针,一份指向第一份属性的指针,再加标签一类的类型信息。节点标识是引擎分配的内部 id,不是业务层必须看见的自然键。
关系记录才是图的骨架。一条关系保存类型、方向、起点、终点,以及这条关系在起点邻接链表、终点邻接链表里的前后指针。于是一条“A—KNOWS→B”的边,同时挂在 A 的出边链和 B 的入边链上。从任一端都能沿链走到这条边,而不必扫描全图。
属性是键值对,挂在节点或关系后面。姓名、权重、时间戳、置信度,都不必再拆成一张属性表去做第二次连接。这正是“属性图”这个名字的来源:图的拓扑和载荷被放进同一套记录,而不是把载荷全部推回关系表。
查询语言 Cypher 把这套布局翻译成图案匹配。MATCH (a:Person)-[:KNOWS]->(b:Person) 描述的是局部形状,不是“先选出全部 Person 再连接”。执行时,引擎从已定位的节点出发,顺着关系链走;过滤条件尽量下推到遍历过程中,而不是等物化一张大宽表再 WHERE。
索引仍然存在,只是职责变了。属性索引用来找到遍历的起点:按邮箱找到某个人,按订单号找到某张单据。找到起点之后,多跳扩展不再反复问 B 树“下一跳在哪”。B 树与 LSM 在这里服务的是入口,不是整段路径。
可以把一次失败的建模写成反例。若把“谁买过什么”收成一张宽表,每一行既是用户又是商品又是时间,图的形状就消失了。若把每一种关系都拆成独立的关联表,形状回来了,但三跳查询要写三层自连接。属性图要求建模者先问:哪些东西是节点,哪些事情是边,哪些数字是边上的属性。问错一次,无索引邻接省下的查找会在错误的方向上被浪费。
Cypher 的图案匹配也有自己的陷阱。写 MATCH (a)--(b)--(c)--(d) 而不限制类型与方向,等于允许引擎沿所有边种散步。规划器仍会选 Expand,只是 Expand 的分支因子变成了整张图的平均度数。语言让形状可见,并不自动让形状变小。查询写得好,是因为建模时已经把不可能的边种删掉了。
和 JOIN 比,省下的是哪一类工作
考虑一个具体问题:从一名用户出发,找三度以内、并且最近 30 天有过互动的人。
在规范化的关系模式里,这通常是用户表、关系表、互动表的多次自连接,再加时间过滤。优化器可能选哈希连接,也可能把小表 Nested Loop 掉。无论哪种,中间结果的宽度都会随度数膨胀,而每一层都要按键回表。
在属性图里,同一问题更接近一次有界的图遍历:从起点沿 KNOWS 或 INTERACTED 走出最多三跳,在边上读时间戳,不满足就剪枝。中间状态是当前节点集合,不是一张越 lap 越宽的连接表。
这个优势有明确边界。它只在局部、有起点、跳数不大时成立。一旦查询变成“全图有多少个三角形”“按某属性给所有节点分组计数”,引擎仍要扫描,属性图并不自动比列存仓库更快。图库不是把分析型工作免费做掉的魔法。
和 PostgreSQL 的 MVCC 对照,也能看清另一件事。关系库把并发代价推迟到清理;图库把多跳查找的代价前置到写入时维护邻接链。插入一条边,要同时改两个节点的链表指针。删除一条边,要在两端把链缝回去。读路径变便宜,是因为写路径把拓扑指针维护好了。
什么时候这套设计会失效
列出失效场景,比列优点更有用。
- 超级节点。 一个被上亿条边指向的账户、一条被全网转发的帖子,邻接链表本身会变成热点。无索引邻接保证的是“走一条边便宜”,不保证“枚举一个度数为亿的节点也便宜”。这时需要分区、类型拆分,或把超高基数关系降回外部索引。
- 全局聚合与扫描。 每日活跃、全图分页、按属性排序的宽表报表,更适合列存或关系库。图引擎为遍历优化,不为向量化扫描优化。
- 需要二次索引才能定位的多跳过滤。 “找出所有住在某邮编、且三度以内认识某职业的人”,若起点集合本身很大,遍历会退化成几乎全图散步。属性索引只能缩小入口,不能拯救错误的遍历方向。
- 文档型或宽表型载荷。 一篇文章的正文、一帧图像的元数据,硬塞进节点属性,会让记录膨胀、缓存变差。图库保存的是连接,不是对象仓库。
- 跨数据中心的同步复制。 早期 Neo4j 的因果集群提供只读副本,但多主写入不是它的设计中心。若业务要的是 Spanner 那种全球同步时间线,属性图解决的是另一类问题。
和搜索引擎的分工也该说清。倒排索引 擅长“词 → 文档”的全局查找;属性图擅长“这个实体旁边是谁”。把全文检索硬做进图遍历,或把多跳关系硬做进倒排列表,都会买错数据结构。
历史位置也需要钉住,以免把 Neo4j 写成图数据库的唯一起点。网络模型与 CODASYL 在 1960–70 年代就已经用指针表达记录间联系;对象数据库在 1990 年代再次尝试把图状对象直接落盘。Neo4j 的贡献不是发明“记录可以指向记录”,而是把属性图做成一套可事务、可查询、以遍历为中心的产品形态,并让 Cypher 的图案语法进入工程习惯。1.0 在 2010 年 2 月发布,时间点要写对:那是社交媒体与推荐系统已经把“多跳邻居”变成日常负载之后,而不是 SQL 诞生之前。
RDF 三元组仓库走的是另一条工业史。它们为本体、推理和 URI 级互操作优化,查询是 SPARQL 的图模式,存储常常是巨大的三列表加多层索引。属性图把关系当成带类型和属性的一等记录,更贴近业务对象图。需要owl推理时,属性图不是替代品;需要从订单走到零件再走到批次时,三列表也不是更自然的形状。两种图都叫图,常数因子和生态位并不互换。
写入放大值得单独说一次。创建一条双向可遍历的关系,至少要写关系记录本身,还要改动两端邻接链的头指针或前后指针。页缓存里这是几次随机写。关系库插入一行外键,通常是边表追加加一次次级索引维护。图库并没有取消写;它把写花在拓扑指针上,好让读的时候不必再找。若工作负载是持续导入、很少查询,这个交换会亏。若工作负载是反复从同一批热节点走出两到四跳,这个交换才划算。
标签与关系类型是两套不同的索引。标签回答“这是不是一种人、一张订单、一个批次”;关系类型回答“这一跳叫认识、叫包含、叫供应”。把本该是关系类型的东西写成节点上的布尔属性,遍历时就要在邻居上做过滤,等于把剪枝从拓扑里拿掉。把本该是标签的东西写成一种自环关系,又会让最简单的“找出所有订单”变成一次图散步。建模错误不会报编译错,只会把无索引邻接的优势吃掉。
和 PostgreSQL 的分工可以写得很具体。账户余额、库存数量、需要二级索引的等值查询,关系库仍然更合适。需要反复问“这个批次经过了哪些仓、哪些仓又供应了哪些零售点”,图布局开始划算。把整个企业模型搬进 Neo4j,或把整个社交图硬塞进第三范式,都是在惩罚工作负载。系统选择是匹配访问形状,不是站队。
还有一种常见的失望来自把图库当成机器学习特征平台。从图上抽出“二度共同邻居数”当然可以,但那是一次离线作业,不是在线事务。在线路径要短、要有起点、要能被缓存。把每日全图嵌入训练写进同一套工作负载,会同时打满遍历和扫描,两种优化目标互相拆台。
跨域连接
- 图论:无索引邻接是邻接表的存储实现,不是一种新的数学对象。度数、连通分量、最短路的定义不因 Cypher 改变。图库改变的是常数因子:当邻接已经写在记录里,广度优先的每一步不再先付一次全表查找的账。
- 社会资本:关系本身携带属性,才能区分“点过头”和“共过事”。把社会网络收成一张无权重的好友表,等于把弱连接与强连接叠成同一个 0/1。属性图让边的载荷成为一等公民,社会学测量里一直存在的强度、方向、时效,终于不必再拆去另一张表里做第二次连接。
- 供应链:零件—工厂—港口是典型的有向多类型图。一次召回要问的不是“所有工厂的列表”,而是“从这个批次出发,三跳以内经过了哪些仓”。关系库能回答,但计划会变成多层 JOIN;图布局把这个问题还原成从批次节点向外走。超级供应商则是供应链版的超级节点,遍历策略必须先处理它,否则一次查询会把整张网拖进内存。
- 神经系统:突触是带权重和可塑性的边,不是两张神经元表之间的外键。计算神经科学用图走局部回路,医学影像用图走白质束,两者都依赖“从这里出发的下一步已经在结构里”。把脑网络塞进宽表,会把拓扑重新变成每次都要计算的 JOIN。
- 公地治理:邻接链是全体查询共享的结构。一条写事务如果长时间锁住超级节点的关系链,读路径会在同一点上排队。这不是图论问题,是共享可变结构的治理问题:超时、锁粒度和只读副本,决定了“便宜的一跳”会不会在高峰期变成全库的瓶颈。
参考文献
- Marko A. Rodriguez & Peter Neubauer, "Constructions from Dots and Lines", Bulletin of the American Society for Information Science and Technology 36(6), 2010. —— 无索引邻接与属性图作为存储构造的简明表述。
- Renzo Angles & Claudio Gutierrez, "Survey of Graph Database Models", ACM Computing Surveys 40(1), 2008. —— 图数据库模型的早期系统综述,区分图模型与 RDF。
- Renzo Angles et al., "Foundations of Modern Query Languages for Graph Databases", ACM Computing Surveys 50(5), 2017. —— Cypher、SPARQL 等图查询语言的形式基础。
- Ian Robinson, Jim Webber & Emil Eifrem, Graph Databases, 2nd ed., O'Reilly, 2015. —— 以 Neo4j 为主要实现讨论属性图建模与遍历代价。
延伸阅读
- 官方 Cypher 手册中关于模式匹配与规划器的章节。先看执行计划里
Expand与NodeIndexSeek的分工,再决定索引建在哪一类属性上。 - 关系库一侧可用递归 CTE 做对照实验:同一份社交图,三跳查询的缓冲命中与执行时间,能把“语法能表达”和“存储已为邻居排列”分开。