|
| 1 | +# 将 NOT IN 子链接安全地转换为 ANTI JOIN |
| 2 | + |
| 3 | +## 引言 |
| 4 | + |
| 5 | +2026 年 2–3 月,Richard Guo 在 pgsql-hackers 邮件列表上提出并迭代了一个优化器补丁,用于在**语义安全**的前提下,将 |
| 6 | + |
| 7 | +```sql |
| 8 | +... WHERE expr NOT IN (SELECT subexpr FROM ...) |
| 9 | +``` |
| 10 | + |
| 11 | +自动转换为基于连接的 **ANTI JOIN**。`NOT IN` 与 `NULL` 相关的语义长期以来都非常棘手,稍有不慎就会改变查询结果,因此过去的尝试大多被搁置。 |
| 12 | + |
| 13 | +这一次,补丁充分利用了近年来在优化器中新增的基础设施:支持外连接可空信息的 `Var` 表达、全局的 not-null-attnums 哈希表,以及更智能的非空性推理。补丁从 v1 演进到 v6,期间 wenhui qiu、Zhang Mingli、Japin Li、David Geier 等多位开发者参与了审查和讨论。最终版本在 2026 年 3 月由 Richard 提交。 |
| 14 | + |
| 15 | +本文将解释为什么 `NOT IN` 难以优化、优化器如何证明“安全”、补丁从 v1 走到 v6 的关键变化,以及这对日常查询意味着什么。 |
| 16 | + |
| 17 | +## 为什么 NOT IN 很难 |
| 18 | + |
| 19 | +表面看起来,`NOT IN` 好像就是一个反连接: |
| 20 | + |
| 21 | +```sql |
| 22 | +SELECT * |
| 23 | +FROM users |
| 24 | +WHERE id NOT IN (SELECT user_id FROM banned_users); |
| 25 | +``` |
| 26 | + |
| 27 | +直觉上这代表“没有被封禁的用户”,理想的执行计划自然是对 `banned_users` 做一个哈希 ANTI JOIN。问题在于 **SQL 对 `NULL` 的定义**。 |
| 28 | + |
| 29 | +在 `NOT IN` 中,标量比较 `(A = B)` 的行为是: |
| 30 | + |
| 31 | +- `(A = B)` 为 `TRUE`:说明命中,`NOT IN` 条件失败。 |
| 32 | +- `(A = B)` 为 `FALSE`:当前元素不是匹配项,整体结果取决于其他元素。 |
| 33 | +- `(A = B)` 为 `NULL`:`NOT (NULL)` 仍然是 `NULL`,在 `WHERE` 中等价于 false,该行会被**丢弃**。 |
| 34 | + |
| 35 | +而在 ANTI JOIN 中,如果连接条件对某一行对返回 `NULL`,执行器往往会把它当作“没有匹配”来处理,从而可能保留外侧行。正如 Richard 在邮件中指出的,只要比较算子在某些“被视为合法等值比较”的输入上可能返回 `NULL`,`NOT IN` 和简单 ANTI JOIN 的语义就不一致。 |
| 36 | + |
| 37 | +历史上,正是这种语义差异让优化器一直避免把 `NOT IN` 子链接转换为 ANTI JOIN。想要连接计划的用户通常需要自己改写为 `NOT EXISTS` 等形式。 |
| 38 | + |
| 39 | +## 优化器基础设施:证明“不会为 NULL” |
| 40 | + |
| 41 | +近几个版本中,PostgreSQL 引入了一些可以更可靠、低成本地证明“表达式不会为 NULL”的基础设施: |
| 42 | + |
| 43 | +- **支持外连接可空信息的 `Var`**:记录某个变量是否可能被外连接置为 NULL。 |
| 44 | +- **not-null-attnums 哈希表**:跟踪表定义中的 `NOT NULL` 列(包括主键等隐含非空约束)。 |
| 45 | +- `expr_is_nonnullable()`:可以对复杂表达式(不仅是简单 `Var`/`Const`)进行非空性推理。 |
| 46 | +- `find_nonnullable_vars()`:可以从条件(例如 `col IS NOT NULL`,或严格算子的连接条件)中推导出被强制非空的变量。 |
| 47 | + |
| 48 | +在新补丁中,Richard 利用这些能力回答两个关键问题: |
| 49 | + |
| 50 | +1. **比较两边的表达式是否可能为 `NULL`?** |
| 51 | + - 利用表级 NOT NULL 信息和外连接可空的 `Var` 元数据,排除来自外连接“可空侧”的 `Var`。 |
| 52 | + - 结合 `find_nonnullable_vars()` 与安全的条件表达式,识别被 `WHERE`/`ON` 条件强制为非空的值。 |
| 53 | +2. **比较算子本身在非空输入上是否可能返回 `NULL`?** |
| 54 | + - 查询系统目录,限制只允许属于 **btree 或 hash 操作符族** 的算子,因为这些算子的行为必须满足“正常的全序或等值语义”。如果此类算子在非空输入上返回 `NULL`,依赖它的索引本身就会被破坏。 |
| 55 | + |
| 56 | +只有在**两侧表达式都被证明不会为 `NULL`,且比较算子被认为“不会在非空输入上返回 NULL”** 时,规划器才会考虑将 `NOT IN` 子链接改写为 ANTI JOIN。 |
| 57 | + |
| 58 | +## 从 v1 到 v6:不断收紧安全边界 |
| 59 | + |
| 60 | +补丁并不是一蹴而就的,邮件线程很详尽地记录了它的演进过程: |
| 61 | + |
| 62 | +- **v1**: |
| 63 | + - 实现了基本的转换逻辑。 |
| 64 | + - 重点使用已有工具证明比较两侧都是非空的。 |
| 65 | + - 但尚未检查“算子本身是否可能在非空输入上返回 `NULL`”。 |
| 66 | + |
| 67 | +- **围绕算子安全性的讨论**: |
| 68 | + - Richard 意识到仅要求操作数非空还不够,算子本身也可能返回 `NULL`。 |
| 69 | + - 他提出能否识别“在非空输入上永不返回 `NULL`”的算子,并建议把“属于 btree 操作符类”作为一个近似条件。 |
| 70 | + - David Geier 指出,执行器中大量代码假定比较算子不会返回 `NULL`——例如 `FunctionCall2()` 一旦拿到 `NULL` 返回值就会抛错。因此,把范围限定在内置的 B-tree / hash 算子是合理且安全的。 |
| 71 | + |
| 72 | +- **v2–v4**: |
| 73 | + - 增加了“算子必须是 B-tree 或 hash 操作符族成员”的检查。 |
| 74 | + - 明确和完善了注释,并补充更多回归测试,覆盖子查询输出来自外连接“可空侧”但又被 `WHERE` 条件强制非空的情况。 |
| 75 | + - 针对测试用例中的注释和一些边界情况做了小幅修正。 |
| 76 | + |
| 77 | +- **v5–v6**: |
| 78 | + - 进一步打磨内部辅助函数,包括用于检查 `SubLink` 测试表达式非空性的 `sublink_testexpr_is_not_nullable`。 |
| 79 | + - 改进对行比较表达式 (`RowCompareExpr`) 的支持,让多列 `NOT IN` 模式同样可以受益。 |
| 80 | + - 将多处 `foreach` 改写成 `foreach_ptr` / `foreach_node`,在开发构建中获得更强的类型检查。 |
| 81 | + - 修复了 `query_outputs_are_not_nullable()` 中一个细微但重要的问题:在对分组表达式和连接别名 Var 做“展开”时,先后顺序必须与解析器处理 FROM/JOIN 与 GROUP BY 的顺序一致。 |
| 82 | + - 补充和整理了回归测试后,进行了一轮自审,最后宣布准备提交。 |
| 83 | + |
| 84 | +到了 v6,被提交的版本已经在语义上足够保守、测试覆盖充分,并吸收了多轮审查反馈。 |
| 85 | + |
| 86 | +## 补丁到底做了什么? |
| 87 | + |
| 88 | +在高层上,当优化器看到一个标准 ANY/ALL 形式的 `NOT IN` 子链接时,会: |
| 89 | + |
| 90 | +1. **识别模式**:在 `SubLink` 及其 `testexpr` 中识别出 `expr NOT IN (SELECT ...)`。 |
| 91 | +2. **收集外层表达式**:即比较左侧(外查询)的表达式列表。 |
| 92 | +3. **检查算子安全性**: |
| 93 | + - 所有参与比较的算子都必须属于某个 B-tree 或 hash 操作符族。 |
| 94 | +4. **证明操作数非空**: |
| 95 | + - 借助表级 NOT NULL 信息、外连接可空 `Var` 元数据,以及从条件中推导出的“非空变量”集合,证明外层表达式与子查询输出都不会为 `NULL`。 |
| 96 | +5. **在且仅在上述条件全部满足时,将 NOT IN 子链接改写为 ANTI JOIN**。 |
| 97 | + |
| 98 | +完成改写之后,优化器就可以: |
| 99 | + |
| 100 | +- 把原本“像黑盒子一样的子计划”拉进全局连接树。 |
| 101 | +- 在整个连接顺序中自由移动该子查询。 |
| 102 | +- 根据代价选择最合适的连接算法(哈希 ANTI JOIN、归并 ANTI JOIN 等)。 |
| 103 | + |
| 104 | +对使用者而言,收益是:很多用 `NOT IN` 写出来的排除模式,现在可以自动得到与精心写成 `NOT EXISTS` 或显式 ANTI JOIN 类似的执行计划,而不需要手工改写 SQL。 |
| 105 | + |
| 106 | +## 示例:典型的排除查询 |
| 107 | + |
| 108 | +补丁主要面向如下“教科书式”的写法: |
| 109 | + |
| 110 | +```sql |
| 111 | +SELECT * |
| 112 | +FROM users |
| 113 | +WHERE id NOT IN (SELECT user_id FROM banned_users); |
| 114 | +``` |
| 115 | + |
| 116 | +以及: |
| 117 | + |
| 118 | +```sql |
| 119 | +SELECT * |
| 120 | +FROM users |
| 121 | +WHERE id NOT IN ( |
| 122 | + SELECT user_id |
| 123 | + FROM banned_users |
| 124 | + WHERE user_id IS NOT NULL |
| 125 | +); |
| 126 | +``` |
| 127 | + |
| 128 | +在设计良好的模式中,`users.id` 和 `banned_users.user_id` 通常都是 `NOT NULL`,并且使用标准的等号比较。在这种场景下,规划器可以证明: |
| 129 | + |
| 130 | +- 比较两侧都不可能为 `NULL`。 |
| 131 | +- 所使用的等号算子是标准 B-tree/hash 等值算子。 |
| 132 | + |
| 133 | +此时 `NOT IN` 子链接会被改写为 ANTI JOIN,执行计划就可以是: |
| 134 | + |
| 135 | +- 针对 `banned_users` 的 **Hash Anti Join**,或者 |
| 136 | +- 在有合适索引且代价模型更倾向合并策略时,使用 **Merge Anti Join**。 |
| 137 | + |
| 138 | +线程中还包含了 wenhui qiu 提供的大规模压测脚本,展示了在相关列被标记为 `NOT NULL` 之后,新优化如何在合成数据上自动产生高效的 ANTI JOIN 计划。 |
| 139 | + |
| 140 | +## 社区讨论与作用范围 |
| 141 | + |
| 142 | +邮件中也讨论了“优化应当走多远”的问题: |
| 143 | + |
| 144 | +- David Geier 描述了一些更激进的改写方式:在外层添加 `IS NOT NULL` 谓词,再加额外的 `NOT EXISTS` 子查询,从而覆盖“任一侧可为 NULL”更多情况。Richard 逐一给出反例,指出其中某些改写在子查询为空时会改变结果,并明确这些都**超出了本补丁的范围**。 |
| 145 | +- 邮件也简要提到,未来也许可以增加类似 Oracle 的**“感知 NULL 的 ANTI JOIN 执行节点”**,以执行层面的新算子来支持更多 `NOT IN` 场景,而不是完全依赖语法层改写。这被认为是后续可以探索的方向。 |
| 146 | +- 审查者们多次强调:必须采取**保守策略**——宁可错过一些理论上的优化机会,也不能冒着改变查询结果的风险。 |
| 147 | + |
| 148 | +最终版本刻意聚焦在**“高收益的基本形态”**:比较两边都可被证明非空,且使用标准 B-tree/hash 比较算子。这覆盖了绝大多数现实中的 `NOT IN` 排除查询,同时在代码复杂度和风险之间取得平衡。 |
| 149 | + |
| 150 | +## 当前状态 |
| 151 | + |
| 152 | +截至 2026 年 3 月中旬: |
| 153 | + |
| 154 | +- “Convert NOT IN sublinks to anti-joins when safe” v6 补丁已经被**提交**。 |
| 155 | +- 当优化器能够证明安全时,该优化会自动启用。 |
| 156 | + |
| 157 | +在实践中,这意味着:如果你在 `NOT NULL` 键上使用内置比较算子写出常见的 `NOT IN` 排除查询,PostgreSQL 现在可以自动为你生成 ANTI JOIN 计划。只要模式和约束准确反映了非空性,应用端 SQL 无需做任何修改。 |
| 158 | + |
| 159 | +## 对用户的启示 |
| 160 | + |
| 161 | +- **尽量正确声明 NOT NULL 和主键约束。** 模式越准确地表达“哪些列不允许为 NULL”,优化器就越有机会安全地应用此类优化。 |
| 162 | +- **在可为 NULL 的列上使用 `NOT IN` 仍然很危险。** PostgreSQL 在这些场景下仍会保守行事;如果你需要在包含 `NULL` 的数据上获得可预期的行为,`NOT EXISTS` 往往更适合。 |
| 163 | +- **对于典型的排除查询,不必再为了“拿到 ANTI JOIN 计划”而主动改写为 `NOT EXISTS`。** 在满足安全条件时,优化器会自动完成改写,你可以继续使用语义上更直观的 `NOT IN` 写法。 |
| 164 | + |
| 165 | +## 参考 |
| 166 | + |
| 167 | +- [讨论串:Convert NOT IN sublinks to anti-joins when safe](https://www.postgresql.org/message-id/CAMbWs495eF=-fSa5CwJS6B-BaEi3ARp0UNb4Lt3EkgUGZJwkAQ@mail.gmail.com) |
0 commit comments