数据库算子与布隆过滤器,如何应用于检索?
摘要:数据库中的“哈希函数与布隆过滤器” 布隆过滤器的本质,就是一个有限长度的位数组(Bit Array)加上 (k) 个独立的哈希函数。 哈希函数决定了数据如何在这个位数组中留下“指纹”。 1. 核心映射关系:数据到比特位的“寻址器” 布隆
数据库中的“哈希函数与布隆过滤器”
布隆过滤器的本质,就是一个有限长度的位数组(Bit Array)加上 \(k\) 个独立的哈希函数。 哈希函数决定了数据如何在这个位数组中留下“指纹”。
1. 核心映射关系:数据到比特位的“寻址器”
布隆过滤器本身不存储实际的数据库记录(如 user_id = 'Alice'),它只是一排初始值为 0 的比特位。哈希函数的作用,就是充当寻址器,将任意长度、任意类型的数据映射为这个位数组中的具体索引位置。
写入阶段(Insertion):
当数据库要把一个值(例如 'Alice')加入布隆过滤器时,会将这个值分别输入到 \(k\) 个不同的哈希函数中。每个哈希函数都会计算出一个整数结果,对位数组的长度 \(m\) 取模后,得到 \(k\) 个位置索引。然后,将位数组中这 \(k\) 个位置的值全部置为 1。
查询阶段(Lookup / Probe):
当数据库要查 'Alice' 是否存在时,再次用同样的 \(k\) 个哈希函数计算出 \(k\) 个位置。
如果这 \(k\) 个位置中哪怕有一个是 0,说明 'Alice' 绝对没有被写入过(即 100% 不存在)。
如果这 \(k\) 个位置全是 1,说明 'Alice' 可能存在(因为这些 1 可能是其他数据写入时碰巧叠加留下的,这就是假阳性 False Positive 的来源)。
2. 为什么要用“多个”(\(k\) 个)哈希函数?
如果只用 \(1\) 个哈希函数,由于哈希碰撞(Hash Collision)的存在,两个不同的值很容易映射到同一个位置,导致极高的假阳性率。
引入 \(k\) 个不同的哈希函数,相当于让每个数据留下 \(k\) 个特征指纹。
制衡机制: 只有当要查询的数据的 \(k\) 个指纹与位数组中的 \(k\) 个 1 完全吻合时,才判定为“可能存在”。这就极大地降低了偶然碰撞的概率。
双刃剑: 但哈希函数也不是越多越好。如果 \(k\) 太大,每插入一个元素就会把太多的 0 变成 1。位数组很快就会被“填满”,导致后续所有的查询都返回“可能存在”,布隆过滤器就彻底失效了。
3. 数据库工程实现中的哈希函数选择
在数据库内核开发中,布隆过滤器对哈希函数的选择极其苛刻,这也是非常好的文档素材。
绝对不用加密哈希(No Cryptographic Hashes):
像 MD5、SHA-1 或 SHA-256 这样的加密哈希函数,虽然分布极其均匀且极难碰撞,但计算速度太慢。在数据库每秒数千万次的 Scan 或 Join 操作中,CPU 算力极其宝贵。
首选非加密的高速哈希(Non-Cryptographic Fast Hashes):
现代数据库(如 ClickHouse, RocksDB, StarRocks)通常采用 MurmurHash (如 MurmurHash3)、CityHash 或 xxHash。
这些哈希函数的设计目标就是快(低 CPU 周期),且具有极好的雪崩效应(Avalanche Effect,即输入改变一个 bit,输出改变一半以上的 bit),能保证位数组的分布足够均匀。
工程优化技巧(Kirsch-Mitzenmacher 优化):
在实际代码实现中,为了节省 CPU 开销,数据库内核通常不会真的去计算 \(k\) 个完全不同的哈希函数。标准的工程做法是:只计算两个基础哈希值(比如 \(h_1(x)\) 和 \(h_2(x)\)),然后通过线性组合来模拟生成剩余的哈希值:
\(g_i(x) = h_1(x) + i \cdot h_2(x) \pmod m\)
(其中 \(i\) 从 \(0\) 取到 \(k-1\))。这在不增加假阳性率的前提下,大幅降低了计算成本
哪些算子使用了布隆过滤器
1. 连接算子 (Hash Join 与 Runtime Filter)
这是现代分布式数据库(如 Doris, StarRocks, ClickHouse, Spark SQL)提升 Join 性能的杀手锏。
执行流程:
构建阶段 (Build Phase): 数据库读取小表(Build 端)的数据,将其拉取到内存中构建 Hash 表。与此同时,顺手把这些 Join Key 喂给一个布隆过滤器,生成一个很小的位图(Bitmap)。
下推传递 (Pushdown): 优化器将这个布隆过滤器发送(或广播)给负责扫描大表(Probe 端)的底层 Scan 算子。
探测与过滤 (Probe & Filter): * 大表开始扫描数据。在把数据向上层发送去进行昂贵的 Hash 计算之前,先用这个布隆过滤器过一遍。
如果命中“不存在”: 直接在底层丢弃该行数据。
如果命中“可能存在”: 将该行数据向上放行,交由上层的 Hash 表进行真正的精确比对。
收益: 大大减少了参与 Hash 探测的数据量,不仅省了 CPU 计算,如果是在分布式计算中,还省去了海量数据的网络传输(Shuffle)开销。
2. 扫描算子 (LSM-Tree 存储引擎查找)
在 RocksDB、Cassandra、HBase 等基于 LSM-Tree 的系统中,数据是按层级(Level)存储在磁盘上的多个 SSTable 文件中的。
执行流程:
点查触发: 用户发起类似 SELECT * FROM table WHERE id = 123 的查询。
内存未命中: 数据库先查内存(MemTable 和 Block Cache),如果没有找到,就需要去查磁盘上的 SSTable 文件。
查布隆过滤器: 每个 SSTable 文件在尾部或内存中都带着一个针对该文件主键的布隆过滤器。系统在打开并读取该文件的 Data Block 之前,先查对应的布隆过滤器。
决策:
如果说“不存在”: 直接跳过这个文件,继续查下一个文件。完全避免了磁盘 I/O。
如果说“可能存在”: 老老实实去磁盘读取对应的 Block,加载到内存中进行二分查找。
收益: 极大地缓解了 LSM-Tree 架构下“读放大”(Read Amplification)的问题,拦截了绝大多数无效的磁盘 I/O。
3. 聚合算子 (DISTINCT 去重)
在处理 COUNT(DISTINCT user_id) 这类聚合操作时,通常需要在内存中维护一个巨大的 Hash Set 来记录出现过的值。
执行流程:
双重结构: 算子在内存中同时维护一个“精确的 Hash Set”和一个“轻量级的布隆过滤器”。
数据流入: 新来一条数据 user_id,先去问布隆过滤器。
快速路径 vs 慢速路径:
如果说“不存在”: 那么它绝对是个新面孔!算子直接将其加入 Hash Set,更新计数器,并将该值加入布隆过滤器。(此过程无需在庞大的 Hash Set 中进行哈希冲突检查,速度极快)
如果说“可能存在”: 发生了假阳性或确实是重复值。此时走慢速路径,去 Hash Set 中做精确查找,判断到底是真是假。
收益: 当基数(Cardinality)非常大时,大部分新数据都能通过布隆过滤器走快速路径,显著降低了内存总线的压力和 CPU 缓存未命中(Cache Miss)的概率。
4. 半连接 / 反连接 (Semi-Join / Anti-Join)
在执行 NOT IN 或 NOT EXISTS 子查询时,布隆过滤器的特性简直是量身定制的。
以 Anti-Join (SELECT \* FROM A WHERE A.id NOT IN (SELECT id FROM B)) 为例:
构建过滤器: 把表 B 的结果集扫一遍,构建出布隆过滤器。
反向判断: 扫描表 A,拿着 A 的每一行去查布隆过滤器。
如果说“不存在”: 完美!说明这行数据绝对不在表 B 中。算子直接将其作为结果输出,根本不需要再去查表 B。
如果说“可能存在”: 此时无法确定是不是假阳性,只能去真正比对表 B 的 Hash 表来确认。
收益: 将极其耗时的嵌套循环或哈希比对,转化为了低成本的位图判断,能够瞬间秒杀大量不匹配的行。
这四个场景的核心逻辑都是 “用极小的 CPU 运算代价,换取极其昂贵的 I/O 或内存随机访问开销”。
连接算子 (Hash Join 与 Runtime Filter)
我们假设有一个典型的场景:大表 Orders(订单表,10亿行) 和 小表 Customers(高价值客户表,1万行) 进行等值连接。
SELECT * FROM Orders O JOIN Customers C ON O.customer_id = C.id;
如果没有布隆过滤器,数据库需要把 Orders 表的 10 亿行数据全部扫出来,甚至通过网络跨节点传输,再去和 Customers 表做比对。这简直是灾难。
引入布隆过滤器后,Hash Join 的完整生命周期会变成下面这样:
第一阶段:构建阶段 (Build Phase) —— “建立黑名单”
Hash Join 永远是从较小的表(Customers 表,即 Build Side)开始执行的。
双线操作: 数据库一边扫描 Customers 表,一边在内存中构建用于精确匹配的 Hash 表。与此同时,它会额外初始化一块很小的内存(比如几十 KB 到几 MB 的位图),这就是布隆过滤器。
填充位图: 对于 Customers 表中的每一个 C.id,数据库会使用几个不同的哈希函数计算出几个位置,并将布隆过滤器中这些位置的比特位(bit)置为 1。
完成构建: 当 Customers 表扫描完毕后,Hash 表构建完成,布隆过滤器也“饱满”了。这个布隆过滤器现在浓缩了所有小表 Join Key 的特征。
第二阶段:下推阶段 (Pushdown) —— “提前设卡”
这是最精妙的一步。数据库优化器不会把布隆过滤器留在 Join 算子里,而是把它 “下推”(Pushdown) 给底层负责扫描大表(Orders 表,即 Probe Side)的 Scan 算子。
在单机数据库中: 这只是一个内存地址的传递,底层扫描器直接拿到过滤器。
在分布式数据库中: 布隆过滤器会被序列化(因为它非常小,通常不到几兆),通过网络广播给所有正在扫描 Orders 表的计算节点。
第三阶段:探测阶段 (Probe Phase) —— “智能拦截”
现在,大表 Orders 的 Scan 算子开始狂扫 10 亿行数据。
预检: 每扫出一条订单数据,它首先提取 O.customer_id。
查布隆过滤器: 对这个 O.customer_id 进行同样的哈希计算,去查看布隆过滤器中对应的比特位是不是都是 1。
分流处理:
如果发现哪怕只有一个位是 0: 说明这个客户绝对不在那 1 万个高价值客户里!这行订单数据在最底层的 Scan 阶段就被无情丢弃,既不进 CPU 缓存,也不参与网络 Shuffle(在分布式架构下),更不会被送到上层的 Hash 表中。
如果所有的位都是 1: 过滤器判定它“可能存在”(有极小概率是假阳性)。此时,这行数据才会获得“通行证”,被向上层发送,进入真正的 Hash 表进行精确的相等比对。
为什么这样做收益极其巨大?
干掉网络 Shuffle 瓶颈: 在分布式数据库中,Join 通常需要把大表数据根据 Hash 值重新分发(Shuffle)到各个节点。网络 I/O 是最慢的。布隆过滤器在底层 Scan 时就干掉了 90% 以上的无效数据,网络传输量呈指数级下降。
减少 CPU 的 Hash 碰撞计算: 去 Hash 表里做探测(Probe),需要解决哈希冲突,涉及多次内存指针跳转(随机读),非常消耗 CPU 周期。布隆过滤器的位运算(位与)则完全是 CPU 缓存友好的极速操作。
减少内存占用: 向上层传递的数据量少了,中间计算过程所需的内存 Buffer 也就大幅减少。
⚠️ 避坑:什么时候布隆过滤器会帮倒忙?
虽然它很强,但如果出现以下情况,数据库反而会因为计算布隆过滤器而拖慢速度:
过滤率极低(大表里的数据几乎都能匹配上): 比如 订单表 关联 全国省份表。99.9% 的订单都能在布隆过滤器里查到“存在”,过滤器形同虚设,反而白白浪费了对大表每行数据进行哈希计算的 CPU 开销。
Build 表太大了: 如果“小表”其实一点也不小(比如 1 亿行),为了保证错误率,布隆过滤器会被撑得非常大。太大的布隆过滤器塞不进 CPU 的 L3 Cache,会导致频繁的 Cache Miss,拖慢整体速度。
现代成熟的数据库(比如 StarRocks、Doris)的优化器很聪明,它们会在执行时动态评估:如果发现布隆过滤器的拦截率低于某个阈值(比如低于 10%),就会动态关闭这个过滤器,及时止损。
Scan(扫描)算子层面
深入到 Scan(扫描)算子层面,布隆过滤器的形态发生了一个极其重要的转变:从“内存里临时生成的动态数据结构”变成了“持久化在磁盘文件里的静态元数据”(当然,上一问提到的 Runtime Filter 下推依然存在,但这里我们重点讲存储引擎本身自带的布隆过滤器)。
在 Table Scan 和 Index Scan 中,布隆过滤器的核心使命是:Data Skipping(数据跳过),最大限度地减少对磁盘的无用物理 I/O。
我们分这两种扫描算子来详细拆解:
1. Table Scan (表扫描 / 数据块扫描)
当你执行一个没有合适索引的查询,比如 SELECT * FROM events WHERE user_id = 'XYZ',传统的全表扫描会把磁盘上的所有数据块(Block/Page)都读进内存。这在海量数据下是不可接受的。
在现代列式存储格式(如 Parquet、ORC)或大数据系统(如 ClickHouse)中,Table Scan 是这样利用布隆过滤器的:
物理层面的切分: 表数据在磁盘上并不是一个整体,而是被切分成一个个的数据块或行组(Row Group)。例如,每 10 万行作为一个 Row Group。
构建静态元数据: 在数据写入磁盘时,存储引擎会为每个 Row Group 中的指定列(通常是经常用于 WHERE 过滤的列)生成一个布隆过滤器,连同最大值/最小值(Min/Max)一起存在文件的头部或尾部(元数据区)。
Table Scan 怎么用它:
算子开始扫描表,但它不先读真实数据,而是先去读极小体积的元数据(布隆过滤器)。
对于目标值 'XYZ',算子去查 Chunk 1 的布隆过滤器。如果过滤器说“不存在”,算子直接跳过这 10 万行数据的物理读取,甚至都不会向操作系统发起 read() 系统调用。
如果 Chunk 2 的过滤器说“可能存在”,算子才会真正把 Chunk 2 的数据从磁盘加载到内存进行逐行扫描。
收益: 将全表扫描(Full Table Scan)悄然降级为了“局部块扫描”,在查询稀疏数据时,能过滤掉 90% 以上的磁盘 I/O。
2. Index Scan (索引扫描 / 点查)
既然有了 B+ 树这样的精确索引,直接顺着树找就行了,为什么还需要布隆过滤器?
答案是:传统关系型数据库(如 MySQL 的 InnoDB)的 B+ 树确实很少直接用布隆过滤器。但对于现代分布式数据库和 NoSQL 广泛采用的 LSM-Tree 存储引擎(如 RocksDB, TiDB, Cassandra, HBase),布隆过滤器是 Index Scan 的“救命稻草”。
在 LSM-Tree 中,数据不是原地更新的,而是不断追加写入(Append-only),这导致同一个表的数据被分散在磁盘上的几十上百个 SSTable 文件中。
当执行类似 SELECT * FROM users WHERE id = 10086 的 Index Scan 时:
没有布隆过滤器的灾难(读放大): 算子不得不去挨个查找每一层、每一个 SSTable 文件内部的索引块,看看 id = 10086 在不在里面。这就需要发起几十次磁盘随机读,速度极慢。
Index Scan 怎么用布隆过滤器:
每个 SSTable 文件在生成时,都会为里面的 Key 生成一个布隆过滤器,保存在内存中(或文件的元数据块中)。
当算子要在众多 SSTable 中寻找 10086 时,它会先遍历这些文件的布隆过滤器。
过滤器拦截: 如果文件 A 的过滤器说“不在”,算子连文件 A 的门都不敲,直接跳过。
精确打击: 最终,算子可能只在一个文件的布隆过滤器中得到了“可能存在”的答复。此时,它才真正去读取那个文件的索引块(Index Block),再通过二分查找定位到数据块(Data Block)。
额外补充:特殊的 Bloom 索引
在 PostgreSQL 中,有一个专门的扩展叫 bloom index。如果你的表有很多列(比如 10 个属性列),用户的查询可能任意组合其中几个列(WHERE col2 = a AND col7 = b)。
建 10 个 B-Tree 索引太占空间,建一个联合索引又无法覆盖所有组合。此时可以建一个包含这 10 列的 Bloom Index,它将每一行的这 10 个值哈希成一个变长的位图。Index Scan 时通过位运算快速剔除不满足条件的行,最后再回表确认。
总结对比
算子场景
布隆过滤器的来源
核心作用
适用架构/格式
Table Scan
写入数据时持久化在文件元数据中
跳过无关的数据块/行组 (Data Skipping),减少顺序读量
列存格式 (Parquet/ORC), ClickHouse
Index Scan
写入 SSTable 时生成,常驻内存
避免对无关文件发起低效的磁盘索引探测,减少随机读次数
LSM-Tree 引擎 (RocksDB, TiDB, HBase)
聚合算子(Aggregation)中的布隆过滤器优化机制
在数据库内核(尤其是 OLAP 向量化执行引擎,如 ClickHouse、Doris、StarRocks)的实现中,聚合算子(Aggregation Operator)引入布隆过滤器,本质是为了解决高基数(High Cardinality)场景下,巨大哈希表带来的 CPU 缓存未命中(Cache Miss)和内存随机访问瓶颈。
1. 架构背景与痛点分析
在执行 Hash Aggregation(如 GROUP BY、COUNT(DISTINCT))时,算子需要在内存中维护一个状态哈希表(State Hash Table),用于存储 Group Key 及其对应的聚合中间状态(如 SUM, COUNT 的累计值)。
传统实现的痛点: 当 Group Key 的基数极大时,哈希表会急剧膨胀,其大小远超 CPU 的 L1/L2/L3 缓存容量。此时,对于每一条输入数据,去哈希表中进行探测(Probe)以寻找冲突或更新状态,都会引发极高昂的主存随机访问(Memory Random Access)和 CPU Cache Miss,导致查询性能断崖式下降。
2. 核心优化原理:双重数据结构与冷热路径分流
为了降低对庞大哈希表的探测频率,现代聚合算子在内存中引入了“双重结构”:
精确哈希表(State Hash Table): 存储完整的 Key 和聚合状态。
前置布隆过滤器(Front-line Bloom Filter): 一个极小的位数组(Bit Array),通常能驻留在 CPU 高速缓存中,用于记录哪些 Key 已经被插入到了哈希表中。
在此架构下,算子的处理流程被拆分为快速路径(Fast Path)*和*慢速路径(Slow Path):
2.1 快速路径(Fast Path)—— 命中“不存在”
当一条新数据(Tuple)流入聚合算子:
提取 Group Key,通过 \(K\) 个哈希函数计算出其在布隆过滤器中的位索引。
检查布隆过滤器。如果存在任意一个位为 0,即判定该 Group Key 绝对不存在于当前的哈希表中(100% 准确)。
操作: 算子无需在庞大的哈希表中执行探测(Probe)和冲突解决逻辑。直接在哈希表中开辟新的内存槽位(Slot),初始化该分组的聚合状态,并将该 Group Key 映射到布隆过滤器的位图置为 1。
收益: 整个判断过程仅涉及几次简单的位运算,且布隆过滤器常驻 CPU Cache,耗时在纳秒级别,完美避开了主存的随机访问。
2.2 慢速路径(Slow Path)—— 命中“可能存在”
如果布隆过滤器检查的所有位均为 1,说明该 Group Key 可能已经存在(包含真实存在与假阳性 False Positive 两种情况)。
操作: 算子必须回退到传统的处理逻辑,拿着 Group Key 去精确哈希表中进行完整的 Hash Probe(哈希探测)。
如果在哈希表中找到匹配的 Key,则执行聚合状态更新(如 count++)。
如果发生散列冲突且未找到匹配项(即布隆过滤器的假阳性),则在哈希表中新建分组。
3. 典型工程应用场景
3.1 COUNT(DISTINCT) 的无冲突插入优化
在执行 SELECT COUNT(DISTINCT user_id) FROM table 时,算子只需要知道一个 user_id 是否出现过,不需要累加值。
无 BF 时: 每次遇到一个新的 user_id,都要去哈希表中进行一次完整的遍历探测,确认没有冲突后才插入。
有 BF 时: 绝大多数首次出现的 user_id 都会在 Fast Path 被拦截,直接执行无冲突插入。只有重复出现的 user_id 才会触发昂贵的 Hash Probe。
3.2 聚合数据落盘(Spill to Disk)的前置过滤
在内存受限(Memory Limit)的场景下,当哈希表过大引发 OOM 风险时,数据库会将部分聚合数据溢写(Spill)到磁盘。
在持续读取新数据并准备写入磁盘的临时分区时,布隆过滤器可以充当“门神”。如果新数据的 Group Key 在布隆过滤器中显示已存在于内存哈希表中,就直接在内存中更新;只有被判定为“不存在”的新分组,才会被写入磁盘的 Spill 文件中。这大大减少了不必要的磁盘 I/O。
4. 工程实现考量与避坑指南(Trade-offs)
在编写技术方案设计时,必须评估该机制的副作用及自适应调整策略:
哈希计算开销 vs 内存访问开销: 布隆过滤器引入了额外的哈希计算(通常需要计算 2-4 个不同种子的 Hash 值)。如果 Group Key 的基数非常小(例如按“性别”分组,只有男女两项),所有的判定都会很快陷入 Slow Path。此时,计算 BF 的 CPU 开销纯属浪费。
自适应降级(Adaptive Disabling): 成熟的执行引擎(如 ClickHouse)会在运行时(Runtime)监控布隆过滤器的假阳性率(False Positive Rate)。如果发现 Slow Path 的命中率超过特定阈值(例如 15% - 20%),算子会动态关闭布隆过滤器,直接回退到纯哈希表探测模式,防止性能退化。
容量预估机制:
布隆过滤器的数组大小 \(m\) 严重依赖于预估的唯一键数量 \(n\)。如果优化器(Optimizer)给出的基数预估(Cardinality Estimation)偏差过大,会导致 BF 过早被填满,假阳性率飙升,失去过滤意义。
半连接(Semi-Join)和 反连接(Anti-Join)
因为相比于普通的 Inner Join(内连接),这两种连接的本质语义就是“存在性测试”(Existence Testing)—— 它们只关心右表(或者子查询)中“有没有”匹配的记录,而不需要把右表的实际数据拼接到结果集中。这种“只问有无”的语义,与布隆过滤器(Bloom Filter)的设计哲学简直是天作之合。
1. 语义背景与优化契机
在 SQL 表达中,半连接通常对应 IN 或 EXISTS 子查询,反连接对应 NOT IN 或 NOT EXISTS 子查询。
Semi-Join (半连接): 只要左表的数据在右表中找到至少一个匹配项,就输出左表数据。
Anti-Join (反连接): 只要左表的数据在右表中完全找不到匹配项,才输出左表数据。
在传统的 Hash Join 执行模型中,即使只需要测试存在性,底层引擎通常仍需构建完整的右表 Hash 表,并让左表的每一行都去执行昂贵的 Hash Probe(哈希探测)。引入布隆过滤器后,引擎能够建立一条极其高效的“短路求值(Short-circuit Evaluation)”路径。
2. Semi-Join (半连接) 的执行流:精准拦截
典型查询: SELECT * FROM Table_A WHERE id IN (SELECT id FROM Table_B)
执行步骤:
构建阶段(Build Phase): 扫描右表 Table_B,构建 Hash 表的同时,将其 id 列的值喂给布隆过滤器。
下推阶段(Pushdown): 将构建好的布隆过滤器下推到左表 Table_A 的 Scan 算子侧。
探测阶段(Probe Phase):
Fast Path(快速拒绝): Table_A 每扫出一行数据,先查布隆过滤器。如果返回“不存在(0)”,由于布隆过滤器没有假阴性,这说明该行绝对不在 Table_B 中。算子直接丢弃该行,避免了后续的一切操作(网络 Shuffle、CPU Hash 计算等)。
Slow Path(精确验证): 如果返回“可能存在(1)”,由于存在假阳性(False Positive)的风险,算子不能盲目输出该行,必须将其放行到上层的 Hash 表中进行精确的相等性验证。只有真正匹配上,才输出该行。
工程价值: 将海量“注定不匹配”的数据在最底层的 Scan 阶段提前干掉,极大地减轻了上层 Hash Join 算子的计算和内存压力。
3. Anti-Join (反连接) 的执行流:极速放行(高光时刻)
典型查询: SELECT * FROM Table_A WHERE id NOT IN (SELECT id FROM Table_B)
这是布隆过滤器在数据库执行计划中最具戏剧性和高效的场景!因为反连接需要的正是“不存在”的数据,而布隆过滤器最擅长的就是“100% 确认不存在”。
执行步骤:
构建阶段: 同上,基于 Table_B 构建 Hash 表和布隆过滤器。
探测阶段:
Fast Path(快速接受 —— 核心收益点): Table_A 扫出一条数据去查布隆过滤器。如果返回“不存在(0)”,说明该行数据绝对没有出现在 Table_B 中。此时,算子直接将该行作为正确结果输出!这行数据甚至都不需要见到 Hash 表的影子。
Slow Path(精确排查): 如果返回“可能存在(1)”,此时算子处于“薛定谔的匹配”状态(可能是真匹配导致不该输出,也可能是假阳性导致应该输出)。算子必须去探测 Hash 表:如果真在 Hash 表中,则丢弃该行;如果仅仅是假阳性,则输出该行。
工程价值: 在 Anti-Join 中,布隆过滤器不仅是“拦截器”,更是“绿灯放行通道”。对于那些两表重合度极低的反连接查询,几乎 99% 的数据都可以通过 Fast Path 直接输出,性能提升呈指数级。
4. 技术文档中的关键 Edge Cases (边界情况排坑)
在实现这部分引擎逻辑时,必须在文档中明确以下技术细节:
4.1 NULL 值的特殊处理语义
在 SQL 标准中,NOT IN 遇到 NULL 值时会产生“三值逻辑”(True, False, Unknown)。
陷阱: 如果右表(Table_B)中包含哪怕一个 NULL 值,NOT IN 的结果通常会变成空集(或者 Unknown)。
过滤器设计: 布隆过滤器只能处理确定的二进制位映射,无法表达 SQL 的 NULL。因此,在构建布隆过滤器前,引擎必须先判断并过滤掉 NULL,或者在算子级别增加一个 has_null_in_build_side 的布尔标记。如果标记为 True,在反连接时可能需要直接禁用布隆过滤器的 Fast Path,退回到严格的语义评估。
