公司有个系统是管理员工技能的系统,Sprint Planning 时,业务方提出要在一个月内实现技能关系图查询功能。需求方案很明确:需要用 Graph Database(图数据库)处理员工技能关系网最合适,来支撑将来业务的增长。但问题来了 —— 全面切换到 Neo4j,一个月根本不够。

我们现有服务都基于 PostgreSQL(GCP Cloud SQL),如果引入 Neo4j 需要:申请新云服务实例、配置网络权限监控、团队学习 Cypher、数据迁移、集成测试……时间太赶了。

一个想法

Tech huddle 上我提出:能不能用 PostgreSQL 扩展实现图数据库功能?既满足图查询需求,又不用引入新基础设施。

Apache AGE —— PostgreSQL 的图数据库扩展,支持 OpenCypher 查询语言(和 Neo4j 一样)。如果可行:

  1. 短期:用 Apache AGE 快速实现功能
  2. 未来:业务稳定后迁移到 Neo4j,查询语句直接复用

所以我们需要调研可行性

Graph Database

Graph Database 专门为关系查询设计。它维护了类似于图之间的关系,想象一下 LinkedIn 的”你可能认识的人”功能 —— 查找朋友的朋友,这种多层关系查询在传统数据库里需要写很多 JOIN,每多一层 JOIN 性能就下降一次。

传统关系型数据库把数据存在表里,查询关系时需要 JOIN 多张表。图数据库反过来 —— 把关系作为一等公民(First-Class Citizen),关系和数据一样重要。在图数据库里,查询”朋友的朋友的朋友”就像在图上查找自然,且性能不会因为层级增加而显著下降。

核心概念

  • Node(节点):实体,如 Python、Ruby、JavaScript
  • Edge(边):关系,如 SIMILAR_TO、REQUIRES、CONTAINS
  • Property(属性):节点或边的属性,如技能名称、相似度分数

我们的场景是技能之间的Relationship:

  • Python 和 Ruby 相似(SIMILAR_TO)
  • React 需要 JavaScript(REQUIRES)
  • DevOps 包含 Docker、K8s(CONTAINS)

SQL vs Graph:存储方式对比

实现方式数据结构优势劣势
SQL(关系型)skills 表(id, name)
skill_relations 表(from_id, to_id, type)
数据结构清晰
ACID 事务强
聚合查询方便
多层关系查询复杂
JOIN 性能随层级下降
Graph(图数据库)Node 存技能
Edge 存关系
关系查询简洁
多层查询性能稳定
直观表达关系
学习成本高
聚合查询不如 SQL

查询语法对比

为了理解 SQL 和 Cypher 的差异,先看看数据在关系型数据库里是怎么存的。

skills 表

idnamecategory
1PythonProgramming
2RubyProgramming
3JavaScriptProgramming
4ReactFramework
Select * from skills s join skill_relations sr on sr.from_skill_id=s.id where s.name=python  

skill_relations 表

from_skill_idto_skill_idrelation_type
12SIMILAR_TO
13SIMILAR_TO
43REQUIRES

现在要查询”与 Python 相似的技能”,需要:

  1. 在 skills 表找到 Python(id=1)
  2. 在 skill_relations 表找到 from_skill_id=1 且 relation_type=‘SIMILAR_TO’ 的记录
  3. 再根据 to_skill_id 回到 skills 表查名字

SQL 查询

-- SQL 需要 JOIN 两张表
SELECT s2.name 
FROM skills s1                          -- 第一张表:所有技能
JOIN skill_relations sr                 -- 第二张表:技能关系
  ON s1.id = sr.from_skill_id           -- 关联条件
JOIN skills s2                          -- 再次 JOIN 获取目标技能
  ON sr.to_skill_id = s2.id
WHERE s1.name = 'Python'                -- 过滤条件
  AND sr.relation_type = 'SIMILAR_TO';  -- 关系类型

查询结果:Ruby, JavaScript

执行过程

  1. 从 skills 表找到 Python(id=1)
  2. JOIN skill_relations,匹配 from_skill_id=1 → 找到两条记录(to_skill_id=2 和 3)
  3. 再 JOIN skills 表,匹配 id=2 和 id=3 → 得到 Ruby 和 JavaScript

Cypher 查询(图数据库的查询语言):

-- Cypher 直接描述"图形"关系
MATCH (python:Skill {name: 'Python'})-[:SIMILAR_TO]->(similar:Skill)
RETURN similar.name;

Cypher 语法解释

  • MATCH:查找匹配的模式(相当于 SQL 的 SELECT + WHERE)
  • (python:Skill {name: 'Python'}):节点,标签是 Skill,name 属性是 Python
  • -[:SIMILAR_TO]->:有向边,关系类型是 SIMILAR_TO
  • (similar:Skill):目标节点,也是 Skill 类型
  • RETURN:返回结果

这就像用箭头画图:“找到名为 Python 的技能节点,沿着 SIMILAR_TO 关系,找到所有相似的技能”。

多层关系查询:差距巨大

单层关系看不出差别,但查询 “3 层以内能关联到 Python 的所有技能”

-- SQL: 需要递归 CTE(Common Table Expression)
WITH RECURSIVE skill_chain AS (
  -- 第一层:Python 自己
  SELECT id, name, 1 as depth
  FROM skills WHERE name = 'Python'
  
  UNION ALL
  
  -- 递归部分:找下一层
  SELECT s.id, s.name, sc.depth + 1
  FROM skills s
  JOIN skill_relations sr ON s.id = sr.to_skill_id
  JOIN skill_chain sc ON sr.from_skill_id = sc.id
  WHERE sc.depth < 3  -- 限制 3 层
)
SELECT DISTINCT name FROM skill_chain;
-- Cypher: 一行搞定
MATCH (python:Skill {name: 'Python'})-[*1..3]-(related:Skill)
RETURN DISTINCT related.name;

Cypher 语法解释

  • -[*1..3]-:可变长度的关系,1 到 3 层(* 表示任意层数,1..3 是范围)
  • 不需要箭头:- 表示不关心方向(双向查找)
  • 一行代码实现了 SQL 要写十几行的递归逻辑

Apache AGE:PostgreSQL 的图数据库扩展

Apache AGE 把图数据库功能”塞进” PostgreSQL,查询写法是 SQL 包裹 Cypher

Cypher vs OpenCypher

先解释一下两个概念:

  • Cypher:Neo4j 公司创造的图查询语言,最初是 Neo4j 专有的
  • OpenCypher:2015 年 Neo4j 开源的 Cypher 语法标准,类似”SQL 标准”

现在多个图数据库都支持 OpenCypher:

  • Neo4j:原生 Cypher,最完整的实现
  • Apache AGE:PostgreSQL 扩展,实现 OpenCypher 标准
  • AWS Neptune:部分支持 OpenCypher
  • RedisGraph:支持 OpenCypher

核心区别:OpenCypher 是标准,Cypher 是 Neo4j 的实现。就像 SQL 是标准,但 PostgreSQL 和 MySQL 的 SQL 语法有细微差异。

Apache AGE 查询写法

-- AGE 查询写法:外层是 SQL,内层是 Cypher
SELECT * FROM cypher('graph_name', $$
  MATCH (p:Python)-[:SIMILAR_TO]->(r:Ruby)
  RETURN p, r
$$) as (p agtype, r agtype);

语法解释

  • cypher('graph_name', $$...$$):调用 AGE 的 Cypher 函数,$$ 是 PostgreSQL 的字符串分隔符
  • 里面的 MATCH...RETURN 是标准 OpenCypher 语法
  • as (p agtype, r agtype):PostgreSQL 要求声明返回类型,agtype 是 AGE 的自定义类型

Neo4j 的纯 Cypher(不需要 SQL 外壳):

MATCH (p:Python)-[:SIMILAR_TO]->(r:Ruby)
RETURN p, r

核心差异:AGE 外面包了一层 SQL,但里面的 Cypher 语法完全一样。这意味着未来迁移到 Neo4j 时,只需要去掉 SQL 外壳,Cypher 查询可以直接复用。

迁移成本分析

假设用 Apache AGE 实现,未来迁移到 Neo4j 哪些能复用?

评估维度重用程度说明
Schema✅ 100%Node(Skill)和 Edge(Relation)结构直接复用
查询逻辑✅ 90%去掉 SQL 外壳,Cypher 完全通用
数据✅ 可复用导出 CSV → LOAD CSV 导入 Neo4j
Driver❌ 无法复用PostgreSQL Driver → Neo4j Driver
事务⚠️ 需部分修改当前只查询,无复杂事务

结论:如果用 Apache AGE,未来迁移成本极低 —— 查询逻辑 90% 可复用,只需去掉 SQL 外壳。

数据迁移示例

从 AGE 导出

-- 使用 Cypher 查询节点数据
SELECT * FROM cypher('skills_graph', $$
  MATCH (n:Skill)
  RETURN n.name, n.category
$$) as (skill_name text, category text);

导出为 CSV 后,导入 Neo4j:

// 导入节点
LOAD CSV WITH HEADERS FROM 'file:///skills.csv' AS row
CREATE (:Skill {name: row.skill_name, category: row.category});

Cypher 语法解释

  • LOAD CSV WITH HEADERS:从 CSV 文件读取数据,第一行是列名
  • FROM 'file:///...':文件路径(Neo4j 需要放在 import 目录)
  • AS row:每一行数据命名为 row
  • CREATE (:Skill {...}):创建节点,标签是 Skill,属性从 CSV 读取
  • row.skill_name:访问 CSV 的列

导入关系:

// 导入关系边
LOAD CSV WITH HEADERS FROM 'file:///relations.csv' AS row
MATCH (from:Skill {name: row.from_skill})       // 找起点节点
MATCH (to:Skill {name: row.to_skill})           // 找终点节点
CREATE (from)-[:SIMILAR_TO]->(to);              // 创建关系边

语法解释

  • 先用 MATCH 找到两个已存在的节点(根据 name 属性)
  • 再用 CREATE 在两个节点之间创建关系
  • (from)-[:SIMILAR_TO]->(to):从 from 指向 to 的 SIMILAR_TO 关系

致命问题:GCP Cloud SQL 不支持扩展

调研到这里,团队都很兴奋 —— 这个方案太完美了!但我去查 GCP 文档时,发现一个致命问题:

GCP Cloud SQL 的 支持扩展列表 里没有 Apache AGE。

要用 AGE,只能:

  • 自建 VM 维护 PostgreSQL
  • 或者迁移到支持自定义扩展的 GKE

这两个选项都违背了”不改变现有基础设施”的初衷。我们的服务都跑在 GCP Cloud SQL 上,不想为了一个功能改变整个云服务架构。

最终方案:原生 PostgreSQL 实现

既然 Apache AGE 走不通,我们回到原点:用 PostgreSQL Recursive CTE(递归查询)实现图关系查询。

WITH RECURSIVE skill_path AS (
  SELECT id, name, ARRAY[id] as path
  FROM skills WHERE name = 'Python'
  
  UNION
  
  SELECT s.id, s.name, sp.path || s.id
  FROM skills s
  JOIN skill_relations sr ON s.id = sr.to_skill_id
  JOIN skill_path sp ON sr.from_skill_id = sp.id
  WHERE NOT s.id = ANY(sp.path)  -- 避免循环
)
SELECT * FROM skill_path;

为什么可以接受?

  • 查询性能:200-500ms(当前数据量完全够用)
  • 技术栈熟悉:团队都会 SQL
  • 无额外成本:不引入新基础设施
  • 业务不稳定:需求还在快速变化

如果未来遇到性能瓶颈或业务模型稳定,再考虑迁移到 Neo4j。

探索的价值

虽然最终没用 Apache AGE,但这次调研很有价值:

  1. 验证了渐进式迁移的可行性:如果基础设施支持,AGE 确实是很好的中间方案
  2. 明确了迁移成本:未来真要切 Neo4j,知道哪些能复用、哪些要重写
  3. 团队学习了图数据库:Spike 过程中大家理解了 Cypher 语法和图查询思路

最重要的是,我们找到了当前最合适的方案 —— 不是技术上最优,而是在时间、成本、风险之间的平衡点。

什么时候该用 Graph Database

不是所有关系查询都需要图数据库。符合以下条件才值得考虑:

  • 多层关系查询频繁(3 层以上)
  • 关系复杂度高(多种关系类型、路径查询)
  • 性能成为瓶颈(SQL JOIN 查询 > 2s)
  • 业务模型稳定(Schema 不频繁变动)

我们的场景只满足前两点,但业务不稳定 + 性能够用,所以暂时不需要。

类似的真正适合图数据库的场景:社交网络(朋友推荐)、知识图谱(概念关联)、风控系统(欺诈链路追踪)。

技术决策的务实主义

  • Apache AGE 在技术上很完美 —— 既能用 Cypher 写图查询,又不用引入新基础设施,未来迁移成本还低。但现实是 GCP Cloud SQL 不支持,要用就得自建 VM 或迁移到 GKE,这违背了”不改变现有架构”的初衷。

  • Neo4j 很强大,但申请新服务、配置网络、团队学习、数据迁移……两周根本不够。最终选择 PostgreSQL Recursive CTE —— 不是技术上最优的方案,但是在时间约束、基础设施限制、团队能力、业务不确定性之间找到的平衡点。

两周后功能上线,查询性能稳定在 200-500ms。更重要的是,三个月内业务模型改了 4 次 Schema,庆幸没有引入新的基础设施。如果用了 Neo4j,每次改动都要同步修改图数据库的 Schema 和数据迁移脚本,反而拖慢了迭代速度。

技术选型不只是比较技术优劣,还要看时机是否成熟。当业务稳定、性能遇到瓶颈时,再切换到 Neo4j 也不迟。