公司有个系统是管理员工技能的系统,Sprint Planning 时,业务方提出要在一个月内实现技能关系图查询功能。需求方案很明确:需要用 Graph Database(图数据库)处理员工技能关系网最合适,来支撑将来业务的增长。但问题来了 —— 全面切换到 Neo4j,一个月根本不够。
我们现有服务都基于 PostgreSQL(GCP Cloud SQL),如果引入 Neo4j 需要:申请新云服务实例、配置网络权限监控、团队学习 Cypher、数据迁移、集成测试……时间太赶了。
一个想法
Tech huddle 上我提出:能不能用 PostgreSQL 扩展实现图数据库功能?既满足图查询需求,又不用引入新基础设施。
Apache AGE —— PostgreSQL 的图数据库扩展,支持 OpenCypher 查询语言(和 Neo4j 一样)。如果可行:
- 短期:用 Apache AGE 快速实现功能
- 未来:业务稳定后迁移到 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 表:
| id | name | category |
|---|---|---|
| 1 | Python | Programming |
| 2 | Ruby | Programming |
| 3 | JavaScript | Programming |
| 4 | React | Framework |
Select * from skills s join skill_relations sr on sr.from_skill_id=s.id where s.name=python skill_relations 表:
| from_skill_id | to_skill_id | relation_type |
|---|---|---|
| 1 | 2 | SIMILAR_TO |
| 1 | 3 | SIMILAR_TO |
| 4 | 3 | REQUIRES |
现在要查询”与 Python 相似的技能”,需要:
- 在 skills 表找到 Python(id=1)
- 在 skill_relations 表找到 from_skill_id=1 且 relation_type=‘SIMILAR_TO’ 的记录
- 再根据 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
执行过程:
- 从 skills 表找到 Python(id=1)
- JOIN skill_relations,匹配 from_skill_id=1 → 找到两条记录(to_skill_id=2 和 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:每一行数据命名为 rowCREATE (: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,但这次调研很有价值:
- 验证了渐进式迁移的可行性:如果基础设施支持,AGE 确实是很好的中间方案
- 明确了迁移成本:未来真要切 Neo4j,知道哪些能复用、哪些要重写
- 团队学习了图数据库: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 也不迟。