数据湖让 Postgres 能读 S3 上的 Iceberg、Parquet 文件,聚合分析不再是问题。但一旦问题变成"沿着 referral 链路找到所有从网络内跳到网络外的路径,再算出涉及金额",纯 SQL 就开始力不从心——递归 CTE 写起来冗长,跑起来吃内存。Apache AGE 把 openCypher 图查询直接塞进 Postgres,图遍历和表聚合在同一条 SQL 里完成,数据不用搬家。
数据湖能聚合,但走不了关系链
假设你是一家医疗网络的数据工程师,数据以 Iceberg 表的形式落在 S3 上:claims、providers、patients、facilities、referrals、insurance_plans、regions。常规分析没问题:
-- 总账单金额按地区
SELECT r.name AS region, SUM(c.billed_amount)
FROM claims c
JOIN regions r ON c.region_id = r.id
GROUP BY r.name;
-- 平均索赔按专科
SELECT p.specialty, AVG(c.billed_amount)
FROM claims c
JOIN providers p ON c.provider_id = p.id
GROUP BY p.specialty;
这些是扁平聚合,Iceberg + pg_lake 搞得定。但如果业务问的是:
哪些网络内医生通过中间人把患者转介到了网络外医生?涉及金额多少?
这问题有两层:先沿 referral 链做图遍历(找路径),再对路径终点做金额聚合。纯图数据库做不了聚合,纯分析引擎走不了链路——你得两个一起用。
递归 CTE:能做,但代价不小
纯 SQL 的做法是递归 CTE 加环检测。下面是一个可运行的简化版本(假设 referrals 表有 from_provider_id、to_provider_id、referral_date、reason 四列,providers 表有 id、in_network 列):
WITH RECURSIVE chain AS (
-- 起点:网络内医生发出的转介
SELECT
r.from_provider_id AS src,
r.to_provider_id AS dst,
ARRAY[r.from_provider_id, r.to_provider_id] AS path,
FALSE AS has_out_of_network
FROM referrals r
JOIN providers p ON p.id = r.from_provider_id
WHERE p.in_network = TRUE
UNION ALL
-- 递归:沿链路继续走
SELECT
c.src,
r.to_provider_id AS dst,
c.path || r.to_provider_id,
c.has_out_of_network
OR (NOT po.in_network) AS has_out_of_network
FROM chain c
JOIN referrals r ON r.from_provider_id = c.dst
JOIN providers po ON po.id = r.to_provider_id
WHERE r.to_provider_id <> ALL(c.path) -- 环检测
),
filtered AS (
SELECT DISTINCT src, dst
FROM chain
WHERE has_out_of_network
)
SELECT
f.dst AS out_of_network_provider_id,
SUM(c.billed_amount) AS total_billed
FROM filtered f
JOIN claims c ON c.provider_id = f.dst
GROUP BY f.dst;
这段 SQL 有几个实际问题:
- 40+ 行,手写环检测、路径追踪,容易出错。
UNION ALL在每一层递归都会物化中间行;如果转介图的扇出高,内存会先炸再出结果。- 数据量稍大就可能超
work_mem或直接超时。
Apache AGE:Cypher 进 Postgres,图和表同一条查询
Apache AGE 是一个 Postgres 扩展,把 openCypher 查询语言嵌入 Postgres。和 Neo4j、Neptune 等独立图数据库相比,它的核心优势是——图就在 Postgres 里,和你的 Iceberg 表同一个数据库、同一个连接、同一个事务。
先加载扩展并建图:
-- 加载扩展(pg_lake 读写 Iceberg,AGE 提供图查询)
CREATE EXTENSION IF NOT EXISTS age;
LOAD 'age';
-- 创建图空间
SELECT create_graph('healthcare_network');
接下来,从 Iceberg 表里建节点和边。节点是"名词"——医生、患者、设施;边是"动词"——谁转介给了谁,边本身还能带属性(日期、原因)。
-- 从 providers Iceberg 表创建医生节点
SELECT *
FROM cypher('healthcare_network', $$
MATCH (n)
WITH n LIMIT 0
RETURN n
$$) AS (n agtype);
-- 实际建节点:逐行遍历 Iceberg 表
SELECT create_vlabel('healthcare_network', 'Provider');
INSERT INTO healthcare_network.Provider
SELECT cypher('healthcare_network', $$
CREATE (p:Provider {id: $id, name: $name, specialty: $specialty, in_network: $in_network})
RETURN p
$$,
-- 把 Iceberg 表的行映射到 Cypher 参数
row_to_json(providers)::agtype
) AS (p agtype)
FROM providers; -- providers 是 Iceberg 表,pg_lake 自动读取 S3 上的 Parquet 文件
边也一样,从 referrals Iceberg 表建 REFERRED_TO 关系:
SELECT create_elabel('healthcare_network', 'REFERRED_TO');
SELECT *
FROM cypher('healthcare_network', $$
MATCH (from:Provider {id: $from_id}), (to:Provider {id: $to_id})
CREATE (from)-[r:REFERRED_TO {referral_date: $date, reason: $reason}]->(to)
RETURN r
$$,
row_to_json(referrals)::agtype
) AS (r agtype)
FROM referrals;
注意:图数据存在 Postgres 本地磁盘上,只有节点和边需要的列被复制过来,其余列仍然留在 Iceberg 里不动。这不是全量 ETL,而是按需建索引式的轻量映射。
同一个问题,Cypher 四行搞定
现在用 AGE 回答同一个问题——找网络内到网络外的转介链路,再聚合金额:
WITH out_of_network_chains AS (
-- Cypher 子查询:遍历变长路径,找从网络内出发、终点在网络外的链路
SELECT *
FROM cypher('healthcare_network', $$
MATCH (src:Provider {in_network: true})
-[:REFERRED_TO*1..]->(dst:Provider {in_network: false})
RETURN src.id AS src_id, dst.id AS dst_id
$$) AS (src_id agtype, dst_id agtype)
)
-- 拿 Cypher 结果当普通 CTE,和 Iceberg claims 表做聚合
SELECT
(dst_id)::text AS out_of_network_provider_id,
SUM(c.billed_amount) AS total_billed
FROM out_of_network_chains oc
JOIN claims c ON c.provider_id = (oc.dst_id)::text
GROUP BY dst_id;
对比递归 CTE 版本:
| 递归 CTE | AGE Cypher | |
|---|---|---|
| 代码量 | 40+ 行,手写环检测 | 4 行 Cypher + 外层聚合 |
| 内存 | 每层物化,扇出大时易爆 | AGE 内部链表存储,遍历高效 |
| 环检测 | 手动 <> ALL(path) |
AGE 自动处理 |
| 结果集成 | 就是 SQL | 返回标准 Postgres 结果集,可直接 JOIN Iceberg 表 |
关键点:Cypher 查询返回的是普通 Postgres 行,可以被 WITH 包成 CTE,再和任何 Iceberg/Parquet 表做 JOIN。图输出只是另一个子查询。
落地前想清楚这几件事
什么时候值得建图? 不是所有数据湖问题都需要图。如果你的查询都是扁平聚合——地区金额、专科均值——Iceberg + SQL 就够了。只有当你需要沿关系链做变长遍历(转介链、社交传播、供应链层级),再叠加聚合时,AGE 才真正发挥价值。
数据同步成本。 节点和边的列会存到 Postgres 本地,存在一定冗余。好处是冗余量可控——只搬图遍历需要的字段,其余留在 S3。坏处是源表更新后,图需要重建或增量刷新。目前 AGE 没有自动增量同步,你得自己写定时任务刷新节点和边。
基础设施其实很轻。 整个栈就是 Postgres + Apache AGE + pg_lake + S3。本地 Docker 能跑,生产环境同架构扩容。一个数据库、一个连接、一套备份策略——运维复杂度没有翻倍。
一个快速检查清单:
- [ ] 查询里有没有"沿关系走 N 步再聚合"的模式?没有 → 不需要图。
- [ ] 递归 CTE 是否已经让你在内存或超时上踩坑?是 → AGE 值得试。
- [ ] 数据源是否已经在 Iceberg/Parquet 上?是 → pg_lake + AGE 直接读,零搬家。
- [ ] 能接受节点/边列的轻量本地冗余吗?能 → 建图;不能 → 先评估冗余量再决定。
Postgres 的可扩展性正在把过去需要三四个系统才能做的事拉到一个实例里。数据湖解决了存储和互操作,图查询补上了关系遍历的缺口——而 Apache AGE 让这件事不需要多一套数据库、多一条 ETL 管道、多一份运维负担。