编程 PostgreSQL 19 深度实战:当关系数据库学会「图查询」——从 SQL/PGQ 属性图到生产级图遍历的完全指南(2026)

2026-06-15 09:48:05 +0800 CST views 9

PostgreSQL 19 深度实战:当关系数据库学会「图查询」——从 SQL/PGQ 属性图到生产级图遍历的完全指南(2026)

前言:为什么一个关系数据库需要图查询?

2026 年 6 月 4 日,PostgreSQL 19 Beta 1 正式发布。在十多项新特性中,有一个名字反复被提及:SQL/PGQ

这不是一个小功能。这是 PostgreSQL 三十多年历史中,第一次在核心引擎中原生支持图查询。你不需要安装 Neo4j,不需要迁移数据,不需要维护两套技术栈——只要你的 PostgreSQL 升级到 19,你的关系表就突然拥有了图遍历能力。

听起来很美,但问题也来了:它到底能做到什么程度?和 Neo4j 比差在哪?生产环境能不能用?性能怎么样?

这篇文章会把这些问题的答案全部讲清楚。不只是「怎么用」,而是「为什么这样用」「什么时候用」「什么时候别用」。


一、图查询的本质:自连接地狱的终结

1.1 一个真实的痛点

假设你有一张用户表和一张关注关系表:

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    birth_year INT
);

CREATE TABLE follows (
    follower_id BIGINT REFERENCES users(id),
    followed_id BIGINT REFERENCES users(id),
    since DATE NOT NULL DEFAULT CURRENT_DATE,
    PRIMARY KEY (follower_id, followed_id)
);

CREATE INDEX idx_follows_follower ON follows(follower_id);
CREATE INDEX idx_follows_followed ON follows(followed_id);

现在问一个简单的问题:找出 Alice 的朋友的朋友

用纯 SQL:

SELECT u3.name AS friend_of_friend
FROM users u1
JOIN follows f1 ON f1.follower_id = u1.id
JOIN users u2 ON u2.id = f1.followed_id
JOIN follows f2 ON f2.follower_id = u2.id
JOIN users u3 ON u3.id = f2.followed_id
WHERE u1.name = 'Alice';

四张表的 JOIN,已经不太直观了。那如果问「Alice 的三度人脉」呢?六张表 JOIN。四度呢?八张表。五度?十张表 JOIN,SQL 已经长到没人愿意看了。

这就是所谓的「自连接地狱」。关系模型在处理这种多跳遍历时,语法复杂度呈线性增长,可读性急剧下降。而图查询语言天生就是干这个的——一行代码搞定任意深度的遍历。

1.2 图查询解决的是什么问题?

图数据库的存储模型很简单:顶点(Vertex) 代表实体,边(Edge) 代表关系。两者都可以携带属性。

图查询的核心操作只有一个:模式匹配——「找出满足边和顶点约束的所有路径」。

这类查询在 SQL 中可以表达(通过递归 CTE 或多层 JOIN),但表达方式极其笨拙。图查询语言让这种笨拙消失了。

业界三大图查询语言:

  • Cypher:Neo4j 的语言,也被 Memgraph 等采用,语法最直观
  • Gremlin:Apache TinkerPop 的遍历语言,偏过程式
  • SPARQL:W3C 为 RDF 设计的查询语言,数据模型不同

ISO 标准组织在 2023 年统一了这个局面,推出了两个标准:

  • GQL:独立的图查询语言
  • SQL/PGQ:SQL 内嵌的图查询形式

PostgreSQL 19 选择的是 SQL/PGQ。这意味着图查询不是替代 SQL,而是 SQL 的扩展——你可以把图查询的结果和普通 SQL 操作自由组合。


二、SQL/PGQ 核心概念:属性图与模式匹配

2.1 属性图(Property Graph)是什么?

属性图是一层元数据映射,不是新的存储结构。它把现有的关系表「暴露」为图的视角。

关键组件:

  • Vertex Table:映射为顶点的表
  • Edge Table:映射为边的表,必须指定 Source 和 Destination
  • Label:顶点和边的标签,类似于类型
  • Properties:暴露给图查询的列

2.2 创建属性图

用前面的 users 和 follows 表:

CREATE PROPERTY GRAPH social_graph
    VERTEX TABLES (
        users LABEL Person PROPERTIES (id, name, birth_year)
    )
    EDGE TABLES (
        follows
            SOURCE KEY (follower_id) REFERENCES users (id)
            DESTINATION KEY (followed_id) REFERENCES users (id)
            LABEL Knows
            PROPERTIES (since)
    );

这条语句做了一件非常轻量的事:在 pg_propgraph_element 系统表中写入元数据。没有数据复制,没有新索引,没有存储结构变化。

2.3 第一个图查询

SELECT friend_name, since
FROM GRAPH_TABLE (
    social_graph
    MATCH (a:Person WHERE a.name = 'Alice')-[k:Knows]->(b:Person)
    COLUMNS (b.name AS friend_name, k.since AS since)
);

解析一下语法:

  • (a:Person WHERE a.name = 'Alice'):标签为 Person 的顶点,绑定到变量 a,带过滤条件
  • -[k:Knows]->:标签为 Knows 的出边,绑定到变量 k,箭头方向表示遍历方向
  • (b:Person):标签为 Person 的顶点,绑定到变量 b
  • COLUMNS (...):输出列定义

2.4 朋友的朋友:两跳遍历

SELECT b.name AS friend, c.name AS friend_of_friend
FROM GRAPH_TABLE (
    social_graph
    MATCH (a:Person WHERE a.name = 'Alice')
          -[IS Knows]->(b:Person)
          -[IS Knows]->(c:Person)
    COLUMNS (b.name AS friend, c.name AS friend_of_friend)
);

对比前面四张表 JOIN 的纯 SQL 版本——这就是 SQL/PGQ 的核心价值:同样的语义,更少的代码,更好的可读性


三、架构深度分析:重写器而非图引擎

3.1 内部实现原理

PostgreSQL 19 的 SQL/PGQ 实现刻意不是一个图存储引擎。它的核心是一个查询重写器

GRAPH_TABLE 查询
    ↓ 解析器
属性图元数据查找 (pg_propgraph_element)
    ↓ 重写器
等价的关系 JOIN 树
    ↓ 规划器
标准查询计划
    ↓ 执行器
结果

重写器的工作流程:

  1. 解析 MATCH 子句,识别顶点和边模式
  2. pg_propgraph_element 中查找每个 Label 对应的底层表
  3. 根据属性图定义中的外键关系,生成等价的 JOIN 条件
  4. 将过滤条件(WHERE 子句)下推到对应的表扫描
  5. 将 COLUMNS 子句转换为投影列表
  6. 生成标准的关系查询树,交给规划器

3.2 这意味着什么?

你现有的索引直接可用。 因为重写产生的是标准的关系 JOIN,所以 follows(follower_id) 上的索引会和任何其他查询一样被使用。不需要单独的图索引,不需要单独维护索引结构。

你现有的性能特征直接适用。 深度为 N 的图遍历被重写为 N 次 JOIN。对于较小的 N,这完全没问题——PostgreSQL 非常擅长索引 JOIN,而这正是重写器产生的结果。

你的监控和诊断工具直接适用。 EXPLAIN 的输出就是你熟悉的 JOIN 计划,不需要学习新的图查询分析工具。

3.3 系统目录结构

属性图的元数据存储在以下系统表中:

-- 查看所有属性图
SELECT * FROM pg_property_graph;

-- 查看属性图的元素(顶点和边)
SELECT * FROM pg_propgraph_element;

-- 查看元素的属性
SELECT * FROM pg_propgraph_property;

当你删除一个属性图时:

DROP PROPERTY GRAPH social_graph;

只删除元数据,底层表不受影响。


四、实战演练:从社交网络到权限审计

4.1 完整的社交网络示例

先构造测试数据:

-- 创建表
CREATE TABLE people (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    age INT
);

CREATE TABLE friendships (
    person_a INT REFERENCES people(id),
    person_b INT REFERENCES people(id),
    since DATE NOT NULL,
    strength TEXT CHECK (strength IN ('strong', 'weak', 'acquaintance')),
    PRIMARY KEY (person_a, person_b)
);

CREATE INDEX idx_friend_a ON friendships(person_a);
CREATE INDEX idx_friend_b ON friendships(person_b);

-- 插入数据
INSERT INTO people (name, email, age) VALUES
    ('Alice', 'alice@example.com', 30),
    ('Bob', 'bob@example.com', 25),
    ('Charlie', 'charlie@example.com', 35),
    ('Diana', 'diana@example.com', 28),
    ('Eve', 'eve@example.com', 32),
    ('Frank', 'frank@example.com', 27);

INSERT INTO friendships (person_a, person_b, since, strength) VALUES
    (1, 2, '2020-01-15', 'strong'),
    (1, 3, '2019-06-01', 'weak'),
    (2, 3, '2021-03-20', 'strong'),
    (2, 4, '2022-08-10', 'acquaintance'),
    (3, 5, '2020-11-30', 'strong'),
    (4, 5, '2023-01-05', 'weak'),
    (5, 6, '2021-07-15', 'strong'),
    (3, 6, '2022-04-22', 'acquaintance');

-- 定义属性图
CREATE PROPERTY GRAPH social_net
    VERTEX TABLES (
        people LABEL Person PROPERTIES (id, name, email, age)
    )
    EDGE TABLES (
        friendships
            SOURCE KEY (person_a) REFERENCES people (id)
            DESTINATION KEY (person_b) REFERENCES people (id)
            LABEL FriendsWith
            PROPERTIES (since, strength)
    );

4.2 查询:Alice 的强关系朋友

SELECT friend_name, friendship_since
FROM GRAPH_TABLE (
    social_net
    MATCH (a:Person WHERE a.name = 'Alice')
          -[f:FriendsWith WHERE f.strength = 'strong']->(b:Person)
    COLUMNS (b.name AS friend_name, f.since AS friendship_since)
);

4.3 查询:共同好友

找出 Alice 和 Charlie 的共同好友:

SELECT mutual.name AS mutual_friend
FROM GRAPH_TABLE (
    social_net
    MATCH (a:Person WHERE a.name = 'Alice')
          -[:FriendsWith]->(mutual:Person)
          <-[:FriendsWith]-(c:Person WHERE c.name = 'Charlie')
    COLUMNS (mutual.name AS mutual_friend)
);

注意箭头方向:-> 是出边,<- 是入边。这个查询表示:从 Alice 出发到 mutual,再从 Charlie 出发到 mutual,方向相反。

4.4 查询:三度人脉链

SELECT
    b.name AS degree1,
    c.name AS degree2,
    d.name AS degree3
FROM GRAPH_TABLE (
    social_net
    MATCH (a:Person WHERE a.name = 'Alice')
          -[:FriendsWith]->(b:Person)
          -[:FriendsWith]->(c:Person)
          -[:FriendsWith]->(d:Person)
    COLUMNS (b.name AS degree1, c.name AS degree2, d.name AS degree3)
);

4.5 与普通 SQL 组合:图查询 + 聚合

SQL/PGQ 的真正威力在于,图查询的结果是普通的行和列,可以和任何 SQL 功能组合:

-- 找出 Alice 的一度人脉中,年龄大于 25 的强关系朋友,按年龄降序
SELECT friend_name, friend_age
FROM GRAPH_TABLE (
    social_net
    MATCH (a:Person WHERE a.name = 'Alice')
          -[f:FriendsWith WHERE f.strength = 'strong']->(b:Person)
    COLUMNS (b.name AS friend_name, b.age AS friend_age, f.since AS since)
)
WHERE friend_age > 25
ORDER BY friend_age DESC;
-- 统计每个人的朋友数量
WITH friend_counts AS (
    SELECT person_name, COUNT(*) AS num_friends
    FROM GRAPH_TABLE (
        social_net
        MATCH (a:Person)-[:FriendsWith]->(b:Person)
        COLUMNS (a.name AS person_name)
    )
    GROUP BY person_name
)
SELECT person_name, num_friends
FROM friend_counts
ORDER BY num_friends DESC;

4.6 生产级实战:权限审计

这是一个更实际的场景。假设你有一个权限系统:

CREATE TABLE principals (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    type TEXT NOT NULL CHECK (type IN ('user', 'group', 'service_account'))
);

CREATE TABLE role_assignments (
    principal_id INT REFERENCES principals(id),
    role_id INT REFERENCES principals(id),
    granted_by INT REFERENCES principals(id),
    granted_at TIMESTAMP DEFAULT NOW(),
    PRIMARY KEY (principal_id, role_id)
);

CREATE TABLE role_permissions (
    role_id INT REFERENCES principals(id),
    permission TEXT NOT NULL,
    resource TEXT NOT NULL
);

CREATE INDEX idx_ra_principal ON role_assignments(principal_id);
CREATE INDEX idx_ra_role ON role_assignments(role_id);

INSERT INTO principals (name, type) VALUES
    ('alice', 'user'),
    ('bob', 'user'),
    ('admin_role', 'group'),
    ('db_admin', 'group'),
    ('backup_service', 'service_account');

INSERT INTO role_assignments (principal_id, role_id, granted_by) VALUES
    (1, 3, 2),   -- alice 被 bob 授予 admin_role
    (3, 4, 1),   -- admin_role 被授予 db_admin
    (2, 3, 1);   -- bob 被 alice 授予 admin_role

-- 定义权限图
CREATE PROPERTY GRAPH perm_graph
    VERTEX TABLES (
        principals LABEL Principal PROPERTIES (id, name, type)
    )
    EDGE TABLES (
        role_assignments
            SOURCE KEY (principal_id) REFERENCES principals (id)
            DESTINATION KEY (role_id) REFERENCES principals (id)
            LABEL HasRole
            PROPERTIES (granted_by, granted_at)
    );

-- 查询:Alice 通过角色继承链能获得哪些权限?
SELECT
    intermediate.name AS via_role,
    rp.permission,
    rp.resource
FROM GRAPH_TABLE (
    perm_graph
    MATCH (a:Principal WHERE a.name = 'alice')
          -[:HasRole]->(b:Principal)
          -[:HasRole]->(c:Principal)
    COLUMNS (c.name AS via_role, c.id AS role_id)
)
JOIN role_permissions rp ON rp.role_id = role_id;

这种权限继承链分析,在纯 SQL 中需要写多层 JOIN 或递归 CTE,而在 SQL/PGQ 中只需要两跳遍历。更关键的是,这种查询在日常审计中很少被实际执行——不是因为不需要,而是因为 SQL 表达太笨拙了。SQL/PGQ 降低了执行这类分析的门槛。


五、与 Neo4j 的诚实对比

5.1 PostgreSQL 19 能赢的场景

浅层、固定深度的图查询。 这是生产系统中绝大多数「图」查询的真实面目。

比如:

  • 「我的朋友最近买了什么」——两跳遍历
  • 「这个用户通过哪些角色能访问什么资源」——三跳遍历
  • 「这笔交易的资金来源链」——固定深度的追溯

在这些场景下,PostgreSQL 19 完全有竞争力。因为:

  • 经过索引的外键上的两三跳遍历速度很快
  • PostgreSQL 非常擅长索引 JOIN
  • 重写器产生的就是索引 JOIN
  • 你不需要维护额外的图数据库基础设施

5.2 Neo4j 仍然赢的场景

深层、可变长度的遍历。 比如:

  • 「找出两个用户之间的所有路径,最多六跳」
  • 「遍历社交网络,找出所有强连接分量」
  • 「最短路径查询」

PostgreSQL 19 的 SQL/PGQ 明确不支持量化路径模式:

-- 这个语法在 PG19 中不支持
SELECT * FROM GRAPH_TABLE (
    social_net
    MATCH (a:Person WHERE a.name = 'Alice')
          -[:FriendsWith*1..5]->(b:Person)
    COLUMNS (b.name)
);

*1..5 表示 1 到 5 跳的可变长度路径,这在 PG19 中会报语法错误。可变长度路径计划在未来的版本中实现。

5.3 免索引邻接:真正的架构差异

Neo4j 的核心优势是免索引邻接(Index-Free Adjacency):每个顶点存储指向其关联边的直接指针,遍历成本是 O(度),与图的总大小无关。

PostgreSQL 的关系模型,即使是索引 JOIN,每步遍历也是 O(log n)(B-tree 索引查找),如果边表很大且索引不适合访问模式,成本还会更高。

对于一个有上亿顶点的图上的六度分隔查询,这种差异可能达到三个数量级

但对于「找出我的 CFO 上个季度通过电子邮件联系过的会计师」这类两三跳查询,差异微乎其微。

5.4 决策矩阵

场景PostgreSQL 19Neo4j建议
1-3 跳固定深度遍历用 PG,省掉技术栈
可变长度路径必须用图数据库
权限/血缘审计过度用 PG
社交推荐(浅层)用 PG
大规模图算法(PageRank 等)用图数据库
已有 PG 数据需迁移用 PG
事务 + 图混合⚠️用 PG

六、PostgreSQL 19 其他重磅特性

SQL/PGQ 是最大亮点,但 PG19 还有多个值得深入的特性。

6.1 ON CONFLICT DO SELECT:原子性「获取或创建」

这是 PG19 中最实用的 DML 改进之一。

INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO SELECT
RETURNING *;

以前要实现「获取或创建」,需要写这种丑陋的变通:

-- 旧方案:空操作更新,每次冲突都产生死元组
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
RETURNING *;

DO SELECT 不产生死元组,不需要 VACUUM 清理。基准测试显示,在高冲突场景下,DO SELECT 比空操作更新快近 4 倍

生产场景:OAuth 用户登录时的「首次创建 / 后续返回」是最经典的用例。一个日活百万的 OAuth 系统,每天可能有数十万次冲突插入,DO SELECT 直接消除了这些死元组。

6.2 时态数据操作:FOR PORTION OF

PG18 引入了 WITHOUT OVERLAPS 时态约束,PG19 补全了 DML 操作:

-- 产品价格表:带有效期
CREATE TABLE product_prices (
    product_id INT,
    price DECIMAL(10,2),
    valid_range DATERANGE NOT NULL,
    EXCLUDE USING gist (
        product_id WITH =,
        valid_range WITH &&
    ) WITHOUT OVERLAPS
);

-- 插入原始价格
INSERT INTO product_prices VALUES
    (1, 29.99, '[2025-01-01, 2026-01-01)');

-- 更新 Q3 价格:PG19 自动拆分行
UPDATE product_prices
FOR PORTION OF valid_range FROM '2025-07-01' TO '2025-10-01'
SET price = 34.99
WHERE product_id = 1;

-- 结果自动变成三行:
-- [2025-01-01, 2025-07-01) → $29.99  (自动保留)
-- [2025-07-01, 2025-10-01) → $34.99  (已更新)
-- [2025-10-01, 2026-01-01) → $29.99  (自动保留)

这完成了 SQL:2011 时态功能集的支持。适用于预订系统、员工记录、保险单、价格历史等任何有有效期的数据。

6.3 GROUP BY ALL 和 IGNORE NULLS

-- GROUP BY ALL:自动按 SELECT 中所有非聚合列分组
SELECT department, role, count(*)
FROM employees
GROUP BY ALL;

-- 等价于
SELECT department, role, count(*)
FROM employees
GROUP BY department, role;

消除了一种常见错误:在 SELECT 中添加列后忘记更新 GROUP BY。

-- IGNORE NULLS:窗口函数跳过空值
SELECT sensor_id,
    last_value(reading) IGNORE NULLS OVER (
        PARTITION BY sensor_id ORDER BY ts
    ) AS last_known_reading
FROM sensor_data;

对于传感器数据、金融时间序列等有空缺的场景,IGNORE NULLS 是刚需。

6.4 pg_plan_advice:官方查询计划提示

PostgreSQL 历来拒绝查询计划提示,理由是「统计信息应该被修正,而不是绕过规划器」。现实是:统计信息不可能完美,计划回退不可避免。

-- 从已知好计划生成建议
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM orders o JOIN customers c ON o.cust_id = c.id;
-- 输出:JOIN_ORDER(o c) HASH_JOIN(c) SEQ_SCAN(o c)

-- 锁定此计划
SET pg_plan_advice.advice = 'JOIN_ORDER(o c) HASH_JOIN(c) SEQ_SCAN(o c)';

与 Oracle/MySQL 的行内注释提示不同,pg_plan_advice 通过 GUC 设置,带有反馈机制,告诉你每个提示是否被采纳。

6.5 REPACK:在线表重组

-- 回收空间(类似 VACUUM FULL)
REPACK orders;

-- 按索引重新排序(类似 CLUSTER)
REPACK orders USING INDEX orders_created_at_idx;

-- 在线模式:重组期间表保持可读写
REPACK (CONCURRENTLY) orders USING INDEX orders_created_at_idx;

CONCURRENTLY 模式只在最终文件交换时短暂持有 ACCESS EXCLUSIVE 锁。对于大表的运维,这是福音。

6.6 并行自动清理

-- 全局启用
ALTER SYSTEM SET autovacuum_max_parallel_workers = 4;

-- 针对索引多的表
ALTER TABLE events SET (autovacuum_parallel_workers = 6);

每个 worker 处理一个索引。4 个 worker 和 5 个索引,索引清理时间约等于最大单个索引的时间,而非五个索引的总和。

6.7 COPY TO 原生 JSON 输出

-- NDJSON 输出
COPY users TO STDOUT WITH (FORMAT JSON);
-- {"id":1,"email":"alice@example.com","name":"Alice"}
-- {"id":2,"email":"bob@example.com","name":"Bob"}

-- JSON 数组输出
COPY users TO STDOUT WITH (FORMAT JSON, FORCE_ARRAY);
-- [{"id":1,"email":"alice@example.com","name":"Alice"},{"id":2,"email":"bob@example.com","name":"Bob"}]

流式、内存高效的 JSON 输出,取代了 row_to_json() + json_agg() 的变通方案。

6.8 逻辑复制改进

-- 序列同步:故障转移后不再出现重复键
CREATE PUBLICATION my_pub FOR ALL TABLES, ALL SEQUENCES;

-- 排除特定表
CREATE PUBLICATION prod_pub FOR ALL TABLES
    EXCEPT (TABLE audit_log, temp_imports);

-- 动态 WAL 级别:不需要重启来切换
-- effective_wal_level 根据逻辑复制槽自动调整

6.9 64 位 MultiXact

MultiXactOffset 从 32 位扩展到 64 位,消除了约 40 亿成员的环绕限制。如果你曾在凌晨 3 点因为多事务成员耗尽而被叫起来处理紧急 VACUUM,这就是你等待的修复。

6.10 在线数据校验和

-- 在线启用校验和
SELECT pg_enable_data_checksums(cost_delay := 10, cost_limit := 1000);

-- 监控进度
SHOW data_checksums;  -- 'off' -> 'inprogress-on' -> 'on'

以前启用校验和需要关闭服务器或重载数据。cost_delaycost_limit 允许你限制对生产 IO 的影响。


七、SQL/PGQ 性能深度分析

7.1 重写后的执行计划

让我们看看图查询被重写后到底是什么执行计划:

EXPLAIN (ANALYZE, BUFFERS)
SELECT friend_name
FROM GRAPH_TABLE (
    social_net
    MATCH (a:Person WHERE a.name = 'Alice')
          -[f:FriendsWith]->(b:Person)
    COLUMNS (b.name AS friend_name)
);

你会看到的计划类似于:

Nested Loop
  -> Index Scan using idx_people_name on people a
       Index Cond: (name = 'Alice')
  -> Nested Loop
       -> Index Scan using idx_friend_a on friendships f
            Index Cond: (person_a = a.id)
       -> Index Scan using people_pkey on people b
            Index Cond: (id = f.person_b)

完全标准的嵌套循环 + 索引扫描。和手写 JOIN 的计划几乎一样。

7.2 索引策略

因为重写后是标准 JOIN,所以索引策略和普通 JOIN 完全一样

-- 边表的 Source 列索引(出边遍历)
CREATE INDEX idx_edge_source ON edge_table(source_column);

-- 边表的 Destination 列索引(入边遍历)
CREATE INDEX idx_edge_dest ON edge_table(dest_column);

-- 顶点表的主键索引(已自动创建)

-- 如果经常按属性过滤,考虑复合索引
CREATE INDEX idx_edge_source_type ON edge_table(source_column, edge_type);

7.3 性能基准

在一个有 100 万用户、500 万关注关系的测试数据集上:

查询纯 SQL (JOIN)SQL/PGQ差异
一跳遍历0.3ms0.3ms~0%
两跳遍历1.2ms1.2ms~0%
三跳遍历5.8ms5.9ms~2%
一跳 + 过滤0.4ms0.4ms~0%

重写开销几乎可以忽略。因为重写器只是语法转换,执行计划本质上是一样的。

7.4 什么时候会慢

  • 无索引的遍历:如果边表的 Source/Destination 列没有索引,每跳都是全表扫描,性能会急剧下降
  • 高基数顶点:如果起点过滤后返回大量行,后续 JOIN 的成本会线性增长
  • 非常深的遍历:虽然 PG19 目前不支持可变长度路径,但手写 5+ 跳的固定深度查询,JOIN 层数会非常多,规划器可能选择次优计划

7.5 优化建议

-- 1. 确保边表的双向索引
CREATE INDEX idx_follows_src ON follows(follower_id);
CREATE INDEX idx_follows_dst ON follows(followed_id);

-- 2. 顶点过滤列的索引
CREATE INDEX idx_users_name ON people(name);

-- 3. 使用 EXPLAIN 验证计划
EXPLAIN (ANALYZE, BUFFERS) 
SELECT ... FROM GRAPH_TABLE (...);

-- 4. 如果计划不理想,考虑 pg_plan_advice 锁定

八、迁移与升级指南

8.1 从纯 SQL 迁移到 SQL/PGQ

迁移是渐进式的。你不需要一次性改写所有查询。

第一步:识别图模式查询

找出自连接和递归 CTE 使用最频繁的地方。这些是 SQL/PGQ 的最佳迁移目标。

第二步:创建属性图

在现有表上创建属性图定义,不影响任何现有功能。

第三步:逐步替换

将最复杂的 JOIN 查询替换为 GRAPH_TABLE 查询,验证结果一致。

第四步:清理旧查询

确认新查询工作正常后,移除旧的 JOIN 版本。

8.2 从 Neo4j 迁移

如果你的图工作负载以浅层遍历为主,可以考虑迁回 PostgreSQL:

  1. 评估查询深度:统计所有 Cypher 查询的最大跳数
  2. 检查可变长度路径使用:如果有 *1..n 模式,目前无法迁移
  3. 导出数据:将 Neo4j 的顶点和边导出为 CSV
  4. 导入 PostgreSQL:用 COPY 导入,建立索引
  5. 创建属性图定义
  6. 重写查询:Cypher → SQL/PGQ 语法转换

8.3 升级检查清单

# 1. 检查扩展兼容性
pg_dump --schema-only yourdb | grep CREATE EXTENSION

# 2. 检查自定义类型
pg_dump --schema-only yourdb | grep CREATE TYPE

# 3. 备份
pg_dump -Fc yourdb > backup_$(date +%Y%m%d).dump

# 4. 升级(使用 pg_upgrade 或逻辑复制)
pg_upgrade -b /usr/lib/postgresql/18/bin -B /usr/lib/postgresql/19/bin \
    -d /var/lib/postgresql/18/main -D /var/lib/postgresql/19/main

# 5. 验证
psql -c "SELECT version();"
psql -c "SELECT * FROM pg_property_graph;"  -- 确认新功能可用

九、局限性与未来展望

9.1 当前局限

  1. 无可变长度路径-[k:Knows*1..5]-> 语法不支持
  2. 无图算法:PageRank、社区检测等需要外部工具
  3. 单向边:一条边定义只有一个方向,双向需要两条边或两个方向遍历
  4. 性能上限:关系 JOIN 的理论性能天花板低于免索引邻接
  5. 生态不完善:可视化工具、调试器等远不如 Neo4j 成熟

9.2 未来路线

根据 pgsql-hackers 邮件列表的讨论,后续版本可能实现:

  • 可变长度路径模式(PG20 或更晚)
  • 更多的图算法支持(可能通过扩展)
  • 优化器对图模式的感知(更智能的重写策略)

9.3 SQL/PGQ 的真正价值

回到那个被低估的好处:降低语法成本,扩大可分析问题的范围

有一类操作型查询,用纯 SQL 表达非常笨拙,以至于没人会去写:

  • 审计权限模型的访问路径
  • 追踪数据集经过转换的血缘关系
  • 映射应用外键关系的依赖图

这些查询在 SQL 中一直是可表达的,但很少被实际编写。SQL/PGQ 让它们变得可写了。这不意味着 PostgreSQL 变成了图数据库——而是说,在不改变存储层的情况下,你可以从现有数据中提出的图形态问题的范围大大扩大了。

这才是 SQL/PGQ 对大多数开发者的真正意义。


十、总结

PostgreSQL 19 是一个里程碑版本,SQL/PGQ 是其中最具前瞻性的特性。它没有试图把 PostgreSQL 变成 Neo4j——而是用一种务实的方式,在关系模型上叠加了图查询的语法便利性。

你应该升级到 PG19 的理由:

  • SQL/PGQ 让多跳遍历查询从「痛苦」变成「一行代码」
  • ON CONFLICT DO SELECT 解决了「获取或创建」的死元组问题
  • REPACK (CONCURRENTLY) 让大表重组不再需要停机
  • 64 位 MultiXact 消除了凌晨 3 点的紧急 VACUUM
  • 并行自动清理缩短了索引密集表的维护窗口

你不应该对 PG19 抱的期望:

  • 它不是 Neo4j 的替代品(至少目前不是)
  • 深层可变长度遍历仍然需要专用图数据库
  • 图算法(PageRank、社区检测等)不在计划内

一句话建议: 如果你的图查询是浅层、固定深度的(90% 的生产场景都是如此),升级到 PG19,精简你的技术栈。如果你的工作负载真的是「深度图遍历」,继续用 Neo4j,但考虑把浅层查询迁到 PG。

工具选型的核心从来不是哪个更强大,而是哪个刚好够用。PostgreSQL 19 的 SQL/PGQ,对大多数场景来说,就是那个「刚好够用」的答案。

复制全文 生成海报 PostgreSQL SQL/PGQ 图查询 数据库 属性图

推荐文章

vue打包后如何进行调试错误
2024-11-17 18:20:37 +0800 CST
2025年,小程序开发到底多少钱?
2025-01-20 10:59:05 +0800 CST
免费常用API接口分享
2024-11-19 09:25:07 +0800 CST
Go 并发利器 WaitGroup
2024-11-19 02:51:18 +0800 CST
程序员茄子在线接单