PostgreSQL 18: 反连接 Anti Join 启用 Memoize

John Doe 九月 18, 2025

PostgreSQL 社区一直持续致力于优化器的改进。现在,又改进了反连接的查询执行。

image

特性提交日志

为 ANTI JOIN(反连接)启用 Memoize 功能支持。

目前,我们暂不支持为 SEMI JOIN(半连接)和 ANTI JOIN(反连接)使用 Memoize 功能,原因是:嵌套循环式的 SEMI JOIN 和 ANTI JOIN 不会完整扫描内层关系,这导致 Memoize 无法将缓存条目标记为 “已完成”。

有人可能会提出,或许可以在获取到第一条内层元组后,就将缓存条目标记为 “已完成”,但这种做法并不安全:若第一条内层元组与当前外层元组不满足连接条件,而存在第二条与参数匹配的内层元组时,该元组会发现缓存条目已被标记为 “已完成”,进而导致查询结果错误。

不过,若内层关系可证明具有唯一性(即针对任意外层参数,内层最多返回一条匹配记录),则上述问题不会出现,因为不存在第二条匹配的元组。尽管如此,这一逻辑对 SEMI JOIN 并无帮助:对于内层可证明唯一的 SEMI JOIN,优化器会通过reduce_unique_semijoins机制自动将其简化为普通内连接,因此无需为其单独启用 Memoize。

因此,本次提交中,我们针对 ANTI JOIN 场景,新增了 “内层关系是否可证明唯一” 的判断逻辑;当满足该条件时,便允许为其启用 Memoize 功能。

讨论:https://postgr.es/m/CAMbWs48FdLiMNrmJL-g6mDvoQVt0yNyJAqMkv4e2Pk-5GKCZLA@mail.gmail.com

示例

在 PostgreSQL 数据库的查询优化中,Memoize是一项重要的性能优化技术。它通过缓存内层查询结果,避免外层查询重复执行相同的内层计算,尤其适用于嵌套循环连接中“外层数据重复度高、内层查询开销大”的场景。但长期以来,Memoize仅支持普通内连接、外连接,无法用于 SEMI JOIN(半连接)和 ANTI JOIN(反连接),导致这类查询在面对重复数据时只能重复执行内层逻辑,性能受限。

通过本次提交 ,PostgreSQL 社区打破了这一限制:为满足“内层关系可证明唯一”条件的 ANTI JOIN 启用 Memoize 支持。这一优化让反连接查询在特定场景下性能大幅提升,例如查找在 A 表中存在但在 B 表中无匹配的记录时。

该特性无需额外配置,PostgreSQL 优化器会自动判断“内层是否唯一”并启用 Memoize,可以通过EXPLAIN ANALYZE验证优化是否生效。下面,让我们来进行一下测试。

创建包含重复数据的反连接测试表,并确保内层关系唯一:

-- 创建反连接测试表(外层表)
CREATE TABLE tab_anti (a int, b boolean);
-- 插入 100 行数据,a字段重复度高(取值为1、2、0循环)
INSERT INTO tab_anti
  SELECT i%3, false
    FROM generate_series(1, 100) i;
-- 收集统计信息,确保优化器识别数据分布
ANALYZE tab_anti;

编写包含“内层唯一”条件的 ANTI JOIN 查询,通过EXPLAIN ANALYZE查看执行计划中Memoize是否生效:

-- 执行反连接查询,内层用 DISTINCT ON 确保唯一
EXPLAIN ANALYZE
SELECT COUNT(*) 
FROM tab_anti t1 
LEFT JOIN LATERAL (
  -- 内层:DISTINCT ON (a) 确保每个a仅返回一条记录(唯一)
  SELECT DISTINCT ON (a) a, b, t1.a AS x 
  FROM tab_anti t2
) t2 ON t1.a + 1 = t2.a  -- 外层参数:t1.a+1(重复度高)
WHERE t2.a IS NULL;      -- 反连接条件:无匹配内层记录

若计划中包含Memoize节点,且Cache Key对应外层参数(如t1.a + 1),说明优化已生效。上面的查询执行计划输出如下:

 Aggregate (actual rows=1 loops=1)
   ->  Nested Loop Anti Join (actual rows=33 loops=1)
         ->  Seq Scan on tab_anti t1 (actual rows=100 loops=1)
         ->  Memoize (actual rows=0.67 loops=100)  -- Memoize 节点生效
               Cache Key: (t1.a + 1), t1.a  -- 缓存键:外层参数(t1.a+1)
               Cache Mode: binary  -- 缓存模式(二进制存储,高效)
               Hits: 97  Misses: 3  Evictions: 0  Overflows: 0  Memory Usage: 8kB
               -- 缓存统计:97次命中,仅3次未命中(重复度高,缓存效果显著)
               ->  Subquery Scan on t2 (actual rows=0.67 loops=3)
                     Filter: ((t1.a + 1) = t2.a)
                     Rows Removed by Filter: 2
                     ->  Unique (actual rows=2.67 loops=3)  -- 内层唯一保障(DISTINCT ON)
                           ->  Sort (actual rows=67.33 loops=3)
                                 Sort Key: t2_1.a
                                 Sort Method: quicksort  Memory: 25kB
                                 ->  Seq Scan on tab_anti t2_1 (actual rows=100 loops=3)

从计划中可提取关键信息:

  • 缓存命中率Hits: 97,说明 100 次外层循环中,97 次直接复用缓存,仅3次执行内层查询,内层计算量减少 97%;
  • 内层唯一保障Unique节点(由DISTINCT ON触发)确保内层结果唯一,满足 Memoize 启用条件;
  • 性能收益:若内层查询是更复杂的计算(如关联多表、聚合),97% 的缓存命中率可带来数十倍的性能提升。

非常不错的特性。感谢社区的所有相关人员。

参考

提交日志:https://git.postgresql.org/pg/commitdiff/0da29e4cb161f78a5ef534b3fb4467756a422e25