数据库算子与布隆过滤器

数据库中的"哈希函数与布隆过滤器"

布隆过滤器的本质,就是一个有限长度的位数组(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)CityHashxxHash

    这些哈希函数的设计目标就是快(低 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 性能的杀手锏。

执行流程:

  1. 构建阶段 (Build Phase): 数据库读取小表(Build 端)的数据,将其拉取到内存中构建 Hash 表。与此同时,顺手把这些 Join Key 喂给一个布隆过滤器,生成一个很小的位图(Bitmap)。
  2. 下推传递 (Pushdown): 优化器将这个布隆过滤器发送(或广播)给负责扫描大表(Probe 端)的底层 Scan 算子。
  3. 探测与过滤 (Probe & Filter): * 大表开始扫描数据。在把数据向上层发送去进行昂贵的 Hash 计算之前,先用这个布隆过滤器过一遍。
    • 如果命中"不存在": 直接在底层丢弃该行数据。
    • 如果命中"可能存在": 将该行数据向上放行,交由上层的 Hash 表进行真正的精确比对。

收益: 大大减少了参与 Hash 探测的数据量,不仅省了 CPU 计算,如果是在分布式计算中,还省去了海量数据的网络传输(Shuffle)开销。


2. 扫描算子 (LSM-Tree 存储引擎查找)

在 RocksDB、Cassandra、HBase 等基于 LSM-Tree 的系统中,数据是按层级(Level)存储在磁盘上的多个 SSTable 文件中的。

执行流程:

  1. 点查触发: 用户发起类似 SELECT * FROM table WHERE id = 123 的查询。
  2. 内存未命中: 数据库先查内存(MemTable 和 Block Cache),如果没有找到,就需要去查磁盘上的 SSTable 文件。
  3. 查布隆过滤器: 每个 SSTable 文件在尾部或内存中都带着一个针对该文件主键的布隆过滤器。系统在打开并读取该文件的 Data Block 之前,先查对应的布隆过滤器。
  4. 决策:
    • 如果说"不存在": 直接跳过这个文件,继续查下一个文件。完全避免了磁盘 I/O。
    • 如果说"可能存在": 老老实实去磁盘读取对应的 Block,加载到内存中进行二分查找。

收益: 极大地缓解了 LSM-Tree 架构下"读放大"(Read Amplification)的问题,拦截了绝大多数无效的磁盘 I/O。


3. 聚合算子 (DISTINCT 去重)

在处理 COUNT(DISTINCT user_id) 这类聚合操作时,通常需要在内存中维护一个巨大的 Hash Set 来记录出现过的值。

执行流程:

  1. 双重结构: 算子在内存中同时维护一个"精确的 Hash Set"和一个"轻量级的布隆过滤器"。
  2. 数据流入: 新来一条数据 user_id,先去问布隆过滤器。
  3. 快速路径 vs 慢速路径:
    • 如果说"不存在": 那么它绝对是个新面孔!算子直接将其加入 Hash Set,更新计数器,并将该值加入布隆过滤器。(此过程无需在庞大的 Hash Set 中进行哈希冲突检查,速度极快)
    • 如果说"可能存在": 发生了假阳性或确实是重复值。此时走慢速路径,去 Hash Set 中做精确查找,判断到底是真是假。

收益: 当基数(Cardinality)非常大时,大部分新数据都能通过布隆过滤器走快速路径,显著降低了内存总线的压力和 CPU 缓存未命中(Cache Miss)的概率。


4. 半连接 / 反连接 (Semi-Join / Anti-Join)

在执行 NOT INNOT EXISTS 子查询时,布隆过滤器的特性简直是量身定制的。

以 Anti-Join (SELECT \* FROM A WHERE A.id NOT IN (SELECT id FROM B)) 为例:

  1. 构建过滤器: 把表 B 的结果集扫一遍,构建出布隆过滤器。
  2. 反向判断: 扫描表 A,拿着 A 的每一行去查布隆过滤器。
    • 如果说"不存在": 完美!说明这行数据绝对不在表 B 中。算子直接将其作为结果输出,根本不需要再去查表 B。
    • 如果说"可能存在": 此时无法确定是不是假阳性,只能去真正比对表 B 的 Hash 表来确认。

收益: 将极其耗时的嵌套循环或哈希比对,转化为了低成本的位图判断,能够瞬间秒杀大量不匹配的行。


这四个场景的核心逻辑都是 "用极小的 CPU 运算代价,换取极其昂贵的 I/O 或内存随机访问开销"

连接算子 (Hash Join 与 Runtime Filter)

我们假设有一个典型的场景:大表 Orders(订单表,10亿行)小表 Customers(高价值客户表,1万行) 进行等值连接。

sql 复制代码
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)开始执行的。

  1. 双线操作: 数据库一边扫描 Customers 表,一边在内存中构建用于精确匹配的 Hash 表 。与此同时,它会额外初始化一块很小的内存(比如几十 KB 到几 MB 的位图),这就是布隆过滤器
  2. 填充位图: 对于 Customers 表中的每一个 C.id,数据库会使用几个不同的哈希函数计算出几个位置,并将布隆过滤器中这些位置的比特位(bit)置为 1
  3. 完成构建:Customers 表扫描完毕后,Hash 表构建完成,布隆过滤器也"饱满"了。这个布隆过滤器现在浓缩了所有小表 Join Key 的特征。

第二阶段:下推阶段 (Pushdown) ------ "提前设卡"

这是最精妙的一步。数据库优化器不会把布隆过滤器留在 Join 算子里,而是把它 "下推"(Pushdown) 给底层负责扫描大表(Orders 表,即 Probe Side)的 Scan 算子。

  • 在单机数据库中: 这只是一个内存地址的传递,底层扫描器直接拿到过滤器。
  • 在分布式数据库中: 布隆过滤器会被序列化(因为它非常小,通常不到几兆),通过网络广播 给所有正在扫描 Orders 表的计算节点。

第三阶段:探测阶段 (Probe Phase) ------ "智能拦截"

现在,大表 Orders 的 Scan 算子开始狂扫 10 亿行数据。

  1. 预检: 每扫出一条订单数据,它首先提取 O.customer_id
  2. 查布隆过滤器: 对这个 O.customer_id 进行同样的哈希计算,去查看布隆过滤器中对应的比特位是不是都是 1
  3. 分流处理:
    • 如果发现哪怕只有一个位是 0 说明这个客户绝对不在那 1 万个高价值客户里!这行订单数据在最底层的 Scan 阶段就被无情丢弃,既不进 CPU 缓存,也不参与网络 Shuffle(在分布式架构下),更不会被送到上层的 Hash 表中。
    • 如果所有的位都是 1 过滤器判定它"可能存在"(有极小概率是假阳性)。此时,这行数据才会获得"通行证",被向上层发送,进入真正的 Hash 表进行精确的相等比对。

为什么这样做收益极其巨大?

  1. 干掉网络 Shuffle 瓶颈: 在分布式数据库中,Join 通常需要把大表数据根据 Hash 值重新分发(Shuffle)到各个节点。网络 I/O 是最慢的。布隆过滤器在底层 Scan 时就干掉了 90% 以上的无效数据,网络传输量呈指数级下降。
  2. 减少 CPU 的 Hash 碰撞计算: 去 Hash 表里做探测(Probe),需要解决哈希冲突,涉及多次内存指针跳转(随机读),非常消耗 CPU 周期。布隆过滤器的位运算(位与)则完全是 CPU 缓存友好的极速操作。
  3. 减少内存占用: 向上层传递的数据量少了,中间计算过程所需的内存 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 怎么用它:
    1. 算子开始扫描表,但它不先读真实数据,而是先去读极小体积的元数据(布隆过滤器)。
    2. 对于目标值 'XYZ',算子去查 Chunk 1 的布隆过滤器。如果过滤器说"不存在",算子直接跳过这 10 万行数据的物理读取 ,甚至都不会向操作系统发起 read() 系统调用。
    3. 如果 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 怎么用布隆过滤器:
    1. 每个 SSTable 文件在生成时,都会为里面的 Key 生成一个布隆过滤器,保存在内存中(或文件的元数据块中)。
    2. 当算子要在众多 SSTable 中寻找 10086 时,它会先遍历这些文件的布隆过滤器。
    3. 过滤器拦截: 如果文件 A 的过滤器说"不在",算子连文件 A 的门都不敲,直接跳过。
    4. 精确打击: 最终,算子可能只在一个文件的布隆过滤器中得到了"可能存在"的答复。此时,它才真正去读取那个文件的索引块(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 BYCOUNT(DISTINCT))时,算子需要在内存中维护一个状态哈希表(State Hash Table),用于存储 Group Key 及其对应的聚合中间状态(如 SUM, COUNT 的累计值)。

  • 传统实现的痛点:Group Key 的基数极大时,哈希表会急剧膨胀,其大小远超 CPU 的 L1/L2/L3 缓存容量。此时,对于每一条输入数据,去哈希表中进行探测(Probe)以寻找冲突或更新状态,都会引发极高昂的主存随机访问(Memory Random Access)CPU Cache Miss,导致查询性能断崖式下降。

2. 核心优化原理:双重数据结构与冷热路径分流

为了降低对庞大哈希表的探测频率,现代聚合算子在内存中引入了"双重结构":

  1. 精确哈希表(State Hash Table): 存储完整的 Key 和聚合状态。
  2. 前置布隆过滤器(Front-line Bloom Filter): 一个极小的位数组(Bit Array),通常能驻留在 CPU 高速缓存中,用于记录哪些 Key 已经被插入到了哈希表中。

在此架构下,算子的处理流程被拆分为快速路径(Fast Path)**和**慢速路径(Slow Path)

2.1 快速路径(Fast Path)------ 命中"不存在"

当一条新数据(Tuple)流入聚合算子:

  1. 提取 Group Key,通过 \(K\) 个哈希函数计算出其在布隆过滤器中的位索引。
  2. 检查布隆过滤器。如果存在任意一个位为 0,即判定该 Group Key 绝对不存在于当前的哈希表中(100% 准确)。
  3. 操作: 算子无需在庞大的哈希表中执行探测(Probe)和冲突解决逻辑。直接在哈希表中开辟新的内存槽位(Slot),初始化该分组的聚合状态,并将该 Group Key 映射到布隆过滤器的位图置为 1
  4. 收益: 整个判断过程仅涉及几次简单的位运算,且布隆过滤器常驻 CPU Cache,耗时在纳秒级别,完美避开了主存的随机访问。

2.2 慢速路径(Slow Path)------ 命中"可能存在"

如果布隆过滤器检查的所有位均为 1,说明该 Group Key 可能已经存在(包含真实存在与假阳性 False Positive 两种情况)。

  1. 操作: 算子必须回退到传统的处理逻辑,拿着 Group Key 去精确哈希表中进行完整的 Hash Probe(哈希探测)。
  2. 如果在哈希表中找到匹配的 Key,则执行聚合状态更新(如 count++)。
  3. 如果发生散列冲突且未找到匹配项(即布隆过滤器的假阳性),则在哈希表中新建分组。

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)

在编写技术方案设计时,必须评估该机制的副作用及自适应调整策略:

  1. 哈希计算开销 vs 内存访问开销: 布隆过滤器引入了额外的哈希计算(通常需要计算 2-4 个不同种子的 Hash 值)。如果 Group Key 的基数非常小(例如按"性别"分组,只有男女两项),所有的判定都会很快陷入 Slow Path。此时,计算 BF 的 CPU 开销纯属浪费。

  2. 自适应降级(Adaptive Disabling): 成熟的执行引擎(如 ClickHouse)会在运行时(Runtime)监控布隆过滤器的假阳性率(False Positive Rate)。如果发现 Slow Path 的命中率超过特定阈值(例如 15% - 20%),算子会动态关闭布隆过滤器,直接回退到纯哈希表探测模式,防止性能退化。

  3. 容量预估机制:

    布隆过滤器的数组大小 \(m\) 严重依赖于预估的唯一键数量 \(n\)​。如果优化器(Optimizer)给出的基数预估(Cardinality Estimation)偏差过大,会导致 BF 过早被填满,假阳性率飙升,失去过滤意义。

半连接(Semi-Join)和 反连接(Anti-Join)

因为相比于普通的 Inner Join(内连接),这两种连接的本质语义就是**"存在性测试"(Existence Testing)**------ 它们只关心右表(或者子查询)中"有没有"匹配的记录,而不需要把右表的实际数据拼接到结果集中。这种"只问有无"的语义,与布隆过滤器(Bloom Filter)的设计哲学简直是天作之合。

1. 语义背景与优化契机

在 SQL 表达中,半连接通常对应 INEXISTS 子查询,反连接对应 NOT INNOT 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)

执行步骤:

  1. 构建阶段(Build Phase): 扫描右表 Table_B,构建 Hash 表的同时,将其 id 列的值喂给布隆过滤器。
  2. 下推阶段(Pushdown): 将构建好的布隆过滤器下推到左表 Table_A 的 Scan 算子侧。
  3. 探测阶段(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% 确认不存在"**。

执行步骤:

  1. 构建阶段: 同上,基于 Table_B 构建 Hash 表和布隆过滤器。
  2. 探测阶段:
    • 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,退回到严格的语义评估。