数据湖里的关系问题,一条 Cypher 就能搞定——在 Postgres 里用 Apache AGE 做图查询

2026-05-27 28 预计阅读时间:1 分钟
来源:postgr.es AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:9 分钟

数据湖让 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_idto_provider_idreferral_datereason 四列,providers 表有 idin_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 管道、多一份运维负担。


相关推荐