PostgreSQL 做图数据库这件事,我在不同场合跟人聊过很多次。大多数人的第一反应是:正经图数据库不都该用 Neo4j 吗,为什么要在 PG 里折腾?这个问题问得没毛病,但只适用于“从零开始、且业务一定会以图分析为核心”的场景。实际项目里,大量数据已经躺在 PostgreSQL 里,业务也就偶尔做一次多层关系查询,为了这一个频率不高的需求引入一套独立图数据库,成本并不低——部署、同步、维护、学习成本全都得重新付一遍。
Apache AGE 走的是另一条路:它直接以 PostgreSQL 扩展的形式存在,在原有关系库里加入图模型和 Cypher 查询能力。这意味着你可以继续保留原有表结构,同时针对某个业务域建图、写 Cypher,甚至让 Cypher 和 SQL 在同一个查询里混着用。这篇东西我会把 AGE 的原理、安装、建模、性能优化和常见坑完整过一遍,适合两类人看:一类是 PostgreSQL DBA 想给业务补上图分析能力,另一类是 Python 或后端开发,项目里已经有 PG,不想再额外引一套图库。
1. 为什么要在 PostgreSQL 上做图能力
1.1 关系模型表达“关系”其实并不自然
关系型数据库的核心是表和外键。拿一个典型社交场景举例:user 表存用户,follow 表存关注关系。要查“我关注的人里谁还关注了我”,一条 SQL 靠两三次 JOIN 能写出来,但改成“我关注的人里,谁关注了我没关注的人三层以内的人”,SQL 就难受了——你不知道要 JOIN 几次,只能写递归 CTE,可读性和维护成本直线下降。
图数据库把这种查询变成了自然表达。节点、边、属性三要素一上来,“A 到 B 之间有多远”这类问题就变成了纯粹的路径遍历问题。问题是,一个只有这种低频需求的项目,真要为其引入一套独立存储吗?数据同步、两套查询语言、跨库一致性,每一样都是额外的运维负担。
我当时接手的项目就是这样。用户关系、订单、行为日志全在 PostgreSQL 里,业务提了个需求:识别有组织化特征的批量注册账号——本质就是发现关联密度异常高的用户子图。这个需求不是天天跑,但每周都要用。为它上 Neo4j,属实不划算。
1.2 在扩展与迁移之间做选择
市面上“把图能力做到数据库里”的方案并不多,常见路线有四条:
- 换一套支持图模型的数据库,比如 ArangoDB、NebulaGraph,或者直接用 Neo4j;
- 用 PostgreSQL 的递归 CTE 硬写,查询深度固定时勉强可以,深度不确定时就很痛苦;
- 基于 PostgreSQL 之上的独立图扩展,也就是 Apache AGE 这类;
- 用外部图计算框架,定期把 PG 里的数据导进图引擎做离线计算。
这四种方案我从实现成本、运维负担、实时性和开发效率四个维度比较过。换独立图数据库,意味着引入一个新的存储引擎,数据迁移、双写或多写方案、新语言的培训成本,对于一个以 PG 为数据中枢的团队来说是个不小的冲击。递归 CTE 方案则完全没有建模自由度,写出来的 SQL 极难维护。第三种方案 AGE 的好处在于,它不是一套新数据库,而是一个可以在现有 PG 实例中直接启用的扩展。你的数据依然存在原表里,只是需要同步到图空间时,可以靠查询或 ETL 把关系数据映射成图数据。
我最终选了 AGE 还有一个关键原因:它由 Apache 孵化器毕业,底层扩展机制用的是 PostgreSQL 的 Extension 体系,C 语言实现,扩展能力稳定,而且原厂支持被并入了 PostgreSQL 的插件体系。社区虽然比不了 Neo4j,但文档和版本迭代都比较正常,至少不会停更。
1.3 AGE 解决的四个问题
AGE 对我的实际帮助可以浓缩成四句话:
- 用 Cypher 表达多度关系查询,写起来比嵌套 JOIN 简单一个量级;
- 图和关系表共存于同一数据库,不需要独立部署、不需要额外同步链路;
- 它把图查询的结果封装成普通行返回,能继续 JOIN 回原表,不会形成数据孤岛;
- 对已有系统改动最小,要停用也容易——扩展不用了直接禁用相关 schema 就行,原表完全不受影响。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Apache AGE 的架构与核心概念
2.1 AGE 到底是什么
一句话说清楚:Apache AGE 是 PostgreSQL 的一个扩展模块,它把你熟悉的 CREATE EXTENSION 机制利用起来,在数据库内部实现了属性图数据模型。属性图模型包括节点、边和属性,AGE 用一个叫 agtype 的数据类型统一承载这些信息。
AGE 的图跟 Neo4j 的图有一个很不一样的地方:AGE 中的图,本质上是一组带有固定表结构的关系表。每创建一个图,AGE 会生成一套内部元数据表,其中最重要的是节点表和边表。你执行 create_graph 时,AGE 自动创建了一个 schema,并在其中维护该图的所有标签表和关系表。理解这一点很关键,因为这意味着你不仅可以使用 Cypher 查询,也可以直接用 SQL 去查看底层存储,甚至在 SQL 中对图数据操作。
2.2 agtype:AGE 的“超级 JSON”
我在 AGE 的官方文档里最初注意到的,不是它支持 Cypher,而是它定义了一种叫做 agtype 的数据类型。它看起来跟 PostgreSQL 原生的 jsonb 很像,但内部结构不同,专门为兼容 Cypher 的属性值设计,可以存储数字、字符串、布尔、数组、对象、顶点、边和路径等。
需要特别提醒的是,如果你在 PostgreSQL 里直接写 select '{"name": "a"}'::agtype; 这样去转类型,需要先确保 search_path 里包含 ag_catalog。因为 agtype 的输入输出函数定义在 ag_catalog schema 之下,不设置 search_path 的话会报 “type agtype does not exist”。AGE 官方文档和安装脚本里都要求你设置 search_path 包含 ag_catalog,原因就在这里。
2.3 标签本质上是表
在 Cypher 里,诸如 (:Person) 这种标签,到了 AGE 内部,会变成图 schema 下的一张表。这张表的实际名称是 _ag_label_vertex 或 _ag_label_edge,并且带有 owner 信息。比如你创建一个 Person 标签,内部会存在一个类似 graphname."Person" 的表,这个表里有个 id 列指向 _ag_label_vertex.id。
这意味着什么呢?意味着你可以直接在 SQL 中对标签表建立索引。这是 AGE 一个非常加分的点,后面讲性能优化时会详细展开。Cypher 里的属性,实际上变成标签表里一个 agtype 字段里的 key-value。AGE 可以自动建立一些基础的索引,但复杂查询仍需要手动优化。
3. 从装到跑:AGE 环境搭建与踩坑
3.1 版本匹配和编译准备
2019 年的时候 AGE 还叫 AgensGraph 的 PG 扩展版,后来捐给 Apache 之后改名为 AGE。当前 Apache AGE 的发布版与 PostgreSQL 的主版本是绑定的,你选 AGE 版本前必须先确认你本地 PG 的主版本号。
我用的环境是 Rocky Linux 9 + PostgreSQL 14,对应的 AGE 版本为 1.4.x 左右。如果你的 PG 是 12 或 13,需要找更早的 release。进入 Apache AGE 的 GitHub release 页面,找到后缀是 apache-age-1.4.0-src.tar.gz 的包,查看其 README 或 --with-pgsql 参数判断匹配的 PG 版本。
编译有几个硬依赖:gcc、make、bison、flex、postgresql-server-dev-14。如果系统没有这些,后面编译 AGE 会直接失败。PG 开发包尤其重要,AGE 需要 PG 的头文件,而默认安装 PG 的机器不一定装了这个开发包。
3.2 安装过程的七个关键步骤
下面是我实测可用的安装流程,每一步都有讲究,我标注了原因:
- 解压源码包,进入目录后按官方 README 执行
make && make install。这一步会把 AGE 编译成动态库,并安装到 PG 的 lib 和 extension 目录。 - 在 postgresql.conf 里加上一行:
shared_preload_libraries = 'age'。这行的作用是让 PostgreSQL 在启动时就加载 AGE 的动态库,这一步不做,后面重启实例也无法使用 AGE。 - 重启 PostgreSQL 服务。
- 在 psql 里执行
CREATE EXTENSION age;。这条语句创建了 ag_catalog 模式,以及一系列 AGE 数据类型和函数。 - 执行
LOAD 'age';。这个命令在当前会话里显式加载 AGE 扩展。很多教程会漏掉这一步,结果执行 Cypher 查询时报错说函数没有定义。 - 设置 search_path:
SET search_path = ag_catalog, "$user", public;。因为 AGE 的函数和类型都放在 ag_catalog 模式下。如果不加,后续查询里写入cypher()时会报找不到函数。 - 跑一个最简单的测试:
SELECT create_graph('test_graph');。看到返回结果就说明 AGE 已经工作了。
我在第一次安装时被第 5 步折腾了很久。CREATE EXTENSION 之后直接执行 create_graph 一直报错,日志里提示在 public 模式查不到 create_graph 函数。原因就是我漏掉了 LOAD 'age',AGE 的 SQL 函数虽然注册了,但是 C 函数的符号在当前后端进程里还没装载。
3.3 验证 AGE 是否正常工作
下面是我常用的验证语句:
sql复制-- 查看当前 AGE 版本
SELECT ag_catalog.ag_version();
-- 查看所有已创建的图
SELECT * FROM ag_catalog.ag_graph;
-- 创建一个新图
SELECT ag_catalog.create_graph('demo_graph');
-- 在图中创建节点标签 Person
SELECT ag_catalog.create_vlabel('demo_graph', 'Person');
-- 创建边标签 Follow
SELECT ag_catalog.create_elabel('demo_graph', 'Follow');
-- 执行一个最简单的 Cypher 查询
SELECT * FROM ag_catalog.cypher('demo_graph', $$
CREATE (n:Person {name: 'Alice'}) RETURN n
$$) AS (n ag_catalog.agtype);
能返回带 id 的结果,说明整套链路没问题。此时可以通过查看 demo_graph._ag_label_vertex 表来确认数据确实落到 PG 的普通表里了。这一步也验证了我前面讲的:AGE 图的底层存储就是标准的 PostgreSQL 表。
4. 图模型设计与 Cypher 实操
4.1 建模前的三个预判
AGE 虽然支持在 CREATE 时动态声明节点属性,但我还是建议模型先行。建模前想清楚三件事:
- 哪些业务实体要变成节点,哪些信息只适合放在原关系表里;
- 哪些关联要变成边,边是否需要属性;
- 图数据和原表数据的同步策略是实时还是定时。
以我之前做的一个账号风控场景为例,user 表保留账户基础信息,account 标签只存能力图查询需要的 user_id 和手机号;订单关系变成了 purchase 边,边属性记录了订单金额;登录行为抽象成 login_from 边,连接同一个 ip 地址节点。这种建模方式下,图空间不会无限膨胀,只有高价值关联数据进来。
4.2 载入数据与创建关系
数据入图有两种常见方式。一种是直接使用 Cypher 的 CREATE 语句,写入量小的时候很方便;另一种是通过 SQL SELECT 后拼接 Cypher 字符串动态生成。AGE 官方提供了一种从现有表导入的方式:先建标签表,然后把业务表的数据作为属性嵌入。我更多使用的是第二种,举例如下:
sql复制SELECT ag_catalog.cypher('demo_graph', $$
MATCH (a:Person), (b:Person)
WHERE a.email IS NOT NULL
AND b.email IS NOT NULL
CREATE (a)-[:Follow]->(b)
$$) AS (result ag_catalog.agtype);
但大批量导入时,这种 Cypher 循环逐条创建的性能并不理想。AGE 社区推荐的更优路径是直接把数据写入标签表,再用 SQL 构建边表。因为边表本身是一个 PG 表,你甚至可以在 ETL 任务中直接把 id 对插入到边表。
这种方法理论上是可行的,但实际操作时要知道边表字段结构:_ag_label_edge 表里有 id、start_id、end_id、properties 这几列。你需要先查询原点的 id,再插入终点 id。建议对 start_id 和 end_id 建联合索引。
4.3 Cypher 基础与常见模式
AGE 的 Cypher 语法基本兼容 openCypher,但有几个需要注意的差异:
- 变量返回时必须用
RETURN n.prop; - 字符串常量需要用双引号或转义单引号,我习惯在美元引号后面放 Cypher 语句体;
- 不支持
<-[:REL]-这种反向箭头写法吗?支持的,但写路径变量时要注意别名位置; - 属性访问用点号,比如
n.name; - 模式匹配中,关系类型大小写敏感。
下面这三个场景是 AGE 里最常遇到的。
查询一度、二度关系:
sql复制SELECT * FROM ag_catalog.cypher('demo_graph', $$
MATCH (a:Person {name: 'Alice'})-[:Follow]->(b:Person)-[:Follow]->(c:Person)
RETURN c.name, c.email
$$) AS (name ag_catalog.agtype, email ag_catalog.agtype);
查询共同邻居:
sql复制SELECT * FROM ag_catalog.cypher('demo_graph', $$
MATCH (a:Person)-[:Follow]->(x:Person)<-[:Follow]-(b:Person)
WHERE a.name = 'Alice' AND b.name = 'Bob'
RETURN x.name
$$) AS (common_friend ag_catalog.agtype);
存在性判断:
sql复制SELECT * FROM ag_catalog.cypher('demo_graph', $$
MATCH (a:Person {name: 'Alice'})-[:Follow]->(b:Person {name: 'Bob'})
RETURN a.name AS follower, b.name AS followee
$$) AS (follower ag_catalog.agtype, followee ag_catalog.agtype);
如果返回结果不为零行,就可以认为存在边。这种判断方式会比先跑 SQL 再数行高效一些,因为 DB 内部做了剪枝。
5. Cypher 与 SQL 混编:真正省心的高阶玩法
5.1 把 cypher 结果当普通表来用
AGE 的 cypher() 函数返回值本质上是一组行。这就打开了一个重要玩法:你可以在一条 SQL 里把 Cypher 的输出和其他 PG 里的业务表做 JOIN,这是在自定义函数或应用层代码里无法体验到的便利。
举个例子。图模型已经检测出 Alice 的疑似团伙账号,现在需要拉取这批账号最近一周的订单。订单全在 PostgreSQL 的 orders 表里,关联键是 user_id。可以用如下 SQL:
sql复制WITH suspect_group AS (
SELECT * FROM ag_catalog.cypher('demo_graph', $$
MATCH (a:Person {name: 'Alice'})-[:same_ip]-(s:Person)
RETURN s.user_id
$$) AS (user_id ag_catalog.agtype)
)
SELECT u.username, o.order_id, o.amount
FROM suspect_group sg
JOIN app_user u ON u.id = (sg.user_id::jsonb ->> 'user_id')::bigint
JOIN orders o ON o.user_id = u.id
WHERE o.created_at > now() - interval '7 days'
ORDER BY o.created_at DESC;
这段 SQL 里混编了两套逻辑:图的部分帮你找关系,关系型部分帮你查明细。中间的关键步骤是把 agtype 拆出来转成普通 int。sg.user_id::jsonb ->> 'user_id' 这个方法是我目前用过最稳妥的 agtype 转标量方式。
如果你提前做过属性类型设计,cypher 返回的属性可能是整数、字符串等。agtype 里数字会存储成 JSON 数字格式,转成 jsonb 后,->> 总是返回文本,所以还需要再包一层 ::bigint 或 ::int。
5.2 性能优化:关键是把索引建在内部表上
前面已经说过,AGE 的标签本质是 PG 表,所以能建索引。这个优势极其重要。实践中最常见的一条优化就是在 Cypher 查询的高频过滤属性上直接创建索引。
假设 Cypher 里频繁出现 MATCH (p:Person {email: '...'}),则可以在标签表上建立 GIN 索引:
sql复制CREATE INDEX idx_person_email
ON demo_graph."Person"
USING gin (properties jsonb_path_ops);
这个索引建立在 properties 这一 agtype 字段上。如果把它当成一个二进制 JSON,使用 jsonb_path_ops 的 GIN 索引可以加速等值查询。不过要再次提醒:age 1.x 中属性存储在 properties 列中,该列的数据类型实际是 agtype,你在建索引前可能需要先将表字段 properties 转成 jsonb 吗?不能直接转,但可以通过对表达式建索引实现。
实际项目中,我常用的是表达式索引:
sql复制CREATE INDEX idx_person_properties_email
ON demo_graph."Person"
USING btree (((properties ->> 'email')));
如果同一个标签的 Cypher 查询条件能收紧,用这种 plain btree 效果已经很不错。边表同理,经常按边的属性过滤时,可以给 _ag_label_edge 表建索引。如果 JOIN 时高效按起点查边,最好把 start_id 和 end_id 一起做成联合索引。
5.3 一个可以直接套用的复杂场景
场景:发现某台有风险的服务器 IP,需要找出所有连接过这个 IP 的用户,并统计其最近的消费行为。下面是完整可执行的混合查询:
sql复制WITH risky_ips AS (
SELECT * FROM ag_catalog.cypher('security_graph', $$
MATCH (ip:IpAddr {addr: '203.0.113.5'})<-[:CONNECT_TO]-(u:User)
RETURN u.uid AS uid, u.first_seen AS first_seen
$$) AS (uid ag_catalog.agtype, first_seen ag_catalog.agtype)
)
SELECT u.username, COUNT(o.id) AS order_cnt, SUM(o.amount) AS total_amount
FROM risky_ips r
JOIN app_user u ON u.uid = (r.uid::jsonb ->> 'uid')::int
LEFT JOIN orders o ON o.uid = u.uid
WHERE u.created_at < now() - interval '1 month'
GROUP BY u.username
HAVING COUNT(o.id) > 0
ORDER BY total_amount DESC;
这种模式在我接手过的风控需求里出现频率极高。图帮你划定嫌疑人范围,SQL 则负责后续的统计和审计。两部分都是熟的查询方式,没有为了图而图。
6. 典型问题与性能调优实录
6.1 常见问题速查表
下面是我在使用 AGE 过程中实际踩过、也被同事问过的问题,整理成速查表:
| 现象 | 原因 | 解决方案 |
|---|---|---|
LOAD 'age' 后仍未找到函数 |
search_path 里没加 ag_catalog | SET search_path = ag_catalog, "$user", public;,或每次调用带模式前缀 |
| CREATE EXTENSION 后 create_graph 报错找不到函数 | 没有重启 PG 实例,或未加 shared_preload_libraries | 检查 postgresql.conf 后重启,再 LOAD 'age' |
| Cypher 查询返回结果始终是字符串 | 没定义返回列的类型别名 | SELECT * FROM cypher(...) AS (name agtype) 必须声明返回列 |
| MATCH 查询响应特别慢 | 没有对属性列建索引 | 对标签表 properties 建 GIN 或表达式索引 |
| 批量导入时 Cypher 执行超时 | Cypher 逐条写入太重 | 改为直接操作底层标签表,或分批次 commit |
properties 截断或不可见 |
尝试用旧版函数直接查询标签表 | 直接查 demo_graph."Person".properties |
| 某些 openCypher 语法报错 | AGE 支持的是子集 | 用 COUNT(n) 替代 COUNT(*),注意某些 MATCH 写法改写 |
| 同一属性过滤时返回多行 | agtype 是嵌套结构,可能匹配到了隐藏属性 | 用 properties ->> 'key' 的方式查看实际 KEY 值 |
6.2 慢查询排查:一次完整的思路
有次线上一个 MATCH 查询始终在四五秒徘徊,业务上不可接受。我在 EXPLAIN 后发现,AGE 的执行计划里对节点标签表做了全表扫描。
解决办法是分两层:
- 在 Cypher 侧优化:先在 WHERE 中尽可能加限制条件,让 AGE 能把候选集合先收缩,比如先按
created_at过滤再匹配关联关系; - 在 PG 侧建索引:对
created_at创建 BTree 索引,对核心属性建 GIN 索引,然后重新跑 EXPLAIN。
优化后查询时间从 4.8 秒降到了 0.3 秒左右,效果显著。这说明一个问题:AGE 的图能力不能完全脱离 SQL 层的优化思维,你必须意识到它底层还是 PostgreSQL。慢查询的根源大多数不是 Cypher 翻译得差,而是你的表缺少适当的索引和统计信息。
另外一个容易踩的坑是 agtype 与 jsonb 转换时的歧义。我见过有人写 WHERE properties->>'email' = '1' 与 WHERE properties->>'email' = '"1"',这两种匹配出来的结果完全不同。因为 agtype 内部存储字符串时会带引号,而数字则不带。建议写查询时先 SELECT 几条数据,确认属性里实际存储的形态。
6.3 AGE 不等于 Neo4j:能力边界与替代方案
AGE 够用,但不是万能的。我把它和 Neo4j 做过一轮对比。AGE 不支持完整的存储过程、触发器与部分高级图算法,如果核心业务依赖 PageRank、社区发现等算法,AGE 的内置函数库目前还提供不了,通常要靠 PG 侧的 PL/pgSQL 或外部工具实现。
写路径查询时,AGE 在深层路径上的性能一般。查询 5 层以上的链路时,执行时间会明显上升。我在一次知识图谱测试中,跑 7 层路径遍历时,耗时已经到秒级以上。而 Neo4j 这类原生图库因为存储结构直接面向图遍历,处理这类路径优势更明显。
所以决策逻辑应该是:
- 核心业务已经是稠密图上的复杂遍历和算法,选 Neo4j 或 NebulaGraph 这类专用图数据库更合理;
- 以事务系统为主,偶尔需要图上分析,或者希望短期试点图能力而不引入新基础设施,AGE 是务实之选;
- 团队技术栈偏 PostgreSQL 生态,运维能力有限,不想再维护一套独立集群,AGE 可以让你以最小成本完成试点。
7. 运维层面的三个补充建议
环境版本一定要锁死。AGE 的变更是跟着 PG 主版本走的,升级 PG 大版本前必须确认目标 AGE 版本是否支持。生产环境千万不能拿 release 版去跑新的 PG 主版本,否则容易遇到奇怪的崩溃。我一般会在测试环境完整验证升级路径,主要检查已有图数据和索引是否能平滑迁移。
备份策略要清晰。AGE 的扩展本身不参与逻辑备份的某些操作,如果你用 pg_dump 备份带图数据的库,务必要把 ag_catalog 模式一起包含进去。恢复时也应该先恢复扩展,再恢复数据。个别版本里 pg_dump 不会把扩展中的某些函数定义完整 dump 出来,恢复时报错需要手动补 CREATE EXTENSION age 操作。有一个经验是,备份文件加 --no-owner 再导入,能减少权限不匹配的问题。
监控要覆盖底层表。因为 AGE 的顶点和边存到了 PG 标表,所以 PG 的表膨胀、索引膨胀、锁竞争等问题都会影响查询性能。建议周期性执行 VACUUM 和 ANALYZE,特别是边表频繁增删的场景。乐观锁和行锁冲突如果处理不好,会导致并发写图时单条写入串行化。
再说一下我对这个技术方向的整体判断。我把 AGE 放进实际项目里用了一年多,它确实不会替你解决所有图分析问题,但在“PG 里的数据已经够多、团队也够熟 SQL”的前提下,它提供了一个平滑过渡到图模型的路径。你不需要额外学一套数据库产品,也不用在架构图上加一个陌生的组件。把这个插件理解成 PostgreSQL 的一个能力扩展——需要图分析的模块用图思维建模,日常事务继续走关系模型,两种模式并存,这才是它最适用也是最有价值的状态。
