实验 04:位图索引与 Bloom 过滤器 —— 把 WHERE 子句变成位运算

为什么做这个实验

`WHERE` 子句一定要逐行判断吗?本实验给出另一条路:把每个谓词变成一条**位序列**(第 j 位表示第 j 行是否满足该谓词),于是 `AND / OR / NOT` 可以直接用 CPU 的位运算一次处理整列。

反直觉的地方有三处。

1. **同一份位图,换个物理表示性能能差两个数量级。** "布尔列表"和"Python 大整数"表达的信息完全一样,但前者要在 Python 层循环 N 次,后者是一条覆盖 N 位的机器指令。
2. **压缩位图省了空间,查询却可能更慢。** 运算前要先把压缩格式解析开——空间和时间不是一起变好的。
3. **Bloom 过滤器用几十个位就能挡掉绝大多数回盘,代价是它会说谎。** 但它只会说一种谎:**有假阳性,永远没有假阴性**。返回"不在"就一定不在,返回"可能在"才需要回原始数据复核。

做完这个实验,你应该能回答:什么时候值得建位图索引(提示:看"取值个数 × 行数"这个乘积)?范围查询该用等值编码还是范围编码?以及给定可接受的错误率,Bloom 过滤器该配多少位、几个哈希函数($k^* = (m/n)\ln 2$、$m = -n \ln p / (\ln 2)^2$)。

运行前准备

1. Python 3.10 及以上。
2. 安装依赖(在仓库根目录执行):

```bash
pip install numpy matplotlib jupyter
```

`pandas` 只用于把输出表格化,可选。验证方式:

```python
import numpy, matplotlib
print("环境 OK")
```

3. 必须**从仓库根目录**(其下有 `system/` 与 `notebooks/`)启动 Jupyter,否则下一格的 `from system.bit_sequences import ...` 会报 `ModuleNotFoundError`:

```bash
jupyter notebook notebooks/Bitmaps-and-Bloom-Filters.ipynb
```

4. 本笔记本会在工作目录写一个临时文件 `addresses.pkl`(用来模拟"磁盘上的数据"),跑完可以自行删除。

import sys, pathlib

# Put the repo root (the directory containing `system/`) on sys.path so
# `from system...` works whether this notebook is opened from its own
# folder or from the repo root.
for _p in (pathlib.Path.cwd(), *pathlib.Path.cwd().parents):
    if (_p / "system").is_dir():
        if str(_p) not in sys.path:
            sys.path.insert(0, str(_p))
        break
from system.data_classes import Address
import statistics
import time
import itertools as it
from system.bit_sequences import IntegerBitSequence, BitListBitSequence, WAHBitSequence
from system.interfaces.bit_sequence import (
    BitSequence,
    UncompressedBitSequence,
    CompressedBitSequence,
)
from system.indexes.bloom_filters import BloomFilter
from system.indexes.bitmap_indexes import (
    SortedEqualityEncodedBitmapIndex,
    RangeEncodedBitmapIndex,
)
import pickle
from typing import Type
import matplotlib.pyplot as plt
import numpy as np

一、数据准备:一张 4 000 行的地址表

每个地址有三个属性:`city`(4 个取值)、`street`(5 个取值)、`house_number`(1~200),用 `itertools.product` 做笛卡尔积,得到 $4 \times 5 \times 200 = 4000$ 条记录,`id` 从 0 递增。

属性这么少不是偷懒,而是**位图索引的适用前提**:位图索引的总空间是

$$\text{总位数} = \text{取值个数} \times \text{行数}$$

取值个数(基数)一高,空间就爆炸。所以位图索引用在"性别、城市、状态码"这类低基数列上;主键这种 100% 唯一的列,要用 B+ 树或哈希索引。

最后三个 `*_bulkload_generator()` 是给索引用的喂数器,逐个产出 `(属性值, 行号)` 对。改 `possible_house_numbers` 的 `range(1, 201)` 可以同时调行数和基数——这是本笔记本唯一的规模旋钮。

# Four possible cities
possible_cities: list[str] = ["New York", "Cuppertino", "Berlin", "Saarbrücken"]

# Five possible streets
possible_streets: list[str] = [
    "Unistreet",
    "Macstreet",
    "Longstreet",
    "Msstreet",
    "BigDataStreet",
]

# Possible house numbers. This is the size knob for the whole notebook: the
# number of addresses is len(cities) * len(streets) * len(house_numbers), and it
# drives every index build -- in particular the range-encoded index, which
# creates one bit-sequence per distinct house number. We cap it at 200 (-> 4000
# addresses) to keep CI fast (~4s instead of ~19s); the relative comparisons and
# plots stay representative. For the lecture, uncomment the range(1, 1001) line.
possible_house_numbers: list[int] = list(range(1, 201))
# possible_house_numbers: list[int] = [i for i in range(1, 1001)]

# Create all combinations (len(cities) * len(streets) * len(house_numbers)) of addresses
all_addresses: list[Address] = []
for idx, comb in enumerate(
    it.product(possible_cities, possible_streets, possible_house_numbers)
):
    all_addresses.append(Address(idx, comb[0], comb[1], comb[2]))


# Create bulkloads to be used for different indexes
def city_bulkload_generator():
    yield from ((address.city, address.id) for address in all_addresses)


def street_bulkload_generator():
    yield from ((address.street, address.id) for address in all_addresses)


def house_number_bulkload_generator():
    yield from ((address.house_number, address.id) for address in all_addresses)

二、第一个查询:把 WHERE 变成位运算

假设要查"在纽约、且街道是 Unistreet 或 BigDataStreet"的地址:

```sql
SELECT COUNT(*)
FROM addresses
WHERE city = 'New York' AND (street = 'Unistreet' OR street = 'BigDataStreet')
```

翻译成位运算只有一行:

```text
city['New York'] & ( street['Unistreet'] | street['BigDataStreet'] )
```

其中 `city['New York']` 是一条长度 4000 的位序列,第 j 位为 1 表示第 j 行在纽约。

| SQL 逻辑 | 位运算 | Python 写法 |
| --- | --- | --- |
| `p AND q` | 按位与 | `a & b` |
| `p OR q` | 按位或 | `a \| b` |
| `NOT p` | 按位取反 | `~a` |
| `p AND NOT q` | 与非 | `a & ~b` |

先看看最朴素的做法要花多久。

朴素执行:逐行判断

把 4000 条地址遍历一遍,对每条做字符串比较,把命中的 `id` 收进集合。

这段代码没有任何技巧可言,它的作用只有一个:**给出正确答案和基线耗时**,后面所有索引方案的结果都必须和它对得上(笔记本最后一节的单元测试就是在做这件事)。

# Store the results of the query above
query_1_naive_result: set[int] = set()

start = time.time()

for address in all_addresses:
    # Make String comparisons
    if address.city == "New York" and (
        address.street in ("Unistreet", "BigDataStreet")
    ):
        query_1_naive_result.add(address.id)

end = time.time()
print(f"Time taken for naive query: {end - start}")
print(f"The number of corresponding ids is: {len(query_1_naive_result)}")
Time taken for naive query: 0.0008704662322998047
The number of corresponding ids is: 2000

结果是对的,命中 **400** 条(可以手算验证:纽约占 1/4,两条目标街道占 2/5,$4000 \times \frac14 \times \frac25 = 400$)。

本机实测朴素执行约 **0.2 ~ 0.4 ms**(单次运行波动明显,建议同一格重跑几次)。这个数字太小,说明**这份数据根本不足以体现索引的价值**——4000 行的循环在 Python 里也就是几十万次操作。

所以本节的重点不是"位图快多少",而是把三种实现的**相对关系**看清楚。真正的量级差距请对照本节末尾的 50 万行口径表基准。

一个必须提醒自己的问题:**索引是要先花钱建的。** 建索引要扫一遍全表,之后每次查询才便宜。所以"该不该建索引"是个算术问题,不是信仰问题——本节末尾会给出回本公式。

等值编码位图索引

**等值编码(equality encoding)**给属性的每个取值配一条位序列:

$$E_i[j] = 1 \iff A[j] = v_i$$

于是查 `city = 'New York'` 只需取出对应的那一条位序列,不必碰数据。

下面这一格定义了一个通用函数 `create_indexes_and_execute_query`,它做四件事并返回四个值:

1. `bulkload` 建立 city 和 street 两个等值编码索引(用哪种位序列实现由参数 `bit_sequence_type` 决定);
2. 从索引里取出三条位序列;
3. 用 `&` 和 `|` 组合出结果——**查询逻辑就在这一行**;
4. 记录建索引耗时、查询耗时、占用位数、命中集合。

注意函数签名里的 `compression_type`:它让我们可以用同一份查询逻辑去测"压缩过的位序列",这正是第五节要干的事。

def create_indexes_and_execute_query(
    bit_sequence_type: Type[UncompressedBitSequence],
    compression_type: Type[CompressedBitSequence] | None = None,
    print_info=True,
):
    """
    Execute the query from above using different bitmap index implementations.
    :param bit_sequence_type: The type of bit-sequence to use.
    :param compression_type: The type of compression to use. None if no compression should be used.
    :param print_info: Print information about different metrics.
    :return: (The time to create an index, the time to execute the query, the number of bits stored in the index)
    """

    start_index = time.time()

    # Create Bitmap index for city
    city_bitmap_index: SortedEqualityEncodedBitmapIndex = (
        SortedEqualityEncodedBitmapIndex(
            bit_sequence_type=bit_sequence_type, compression_type=compression_type
        )
    )
    city_bitmap_index.bulkload(city_bulkload_generator())

    # Create Bitmap index for street
    street_bitmap_index: SortedEqualityEncodedBitmapIndex = (
        SortedEqualityEncodedBitmapIndex(
            bit_sequence_type=bit_sequence_type, compression_type=compression_type
        )
    )
    street_bitmap_index.bulkload(street_bulkload_generator())
    end_index = time.time()

    start_exec = time.time()

    # Get a bit_sequence containing all addresses in New York
    street_equal_new_york: BitSequence = city_bitmap_index.get_equal("New York")

    # Get a bit_sequence containing all addresses in Unistreet
    street_equal_unistreet: BitSequence = street_bitmap_index.get_equal("Unistreet")

    # Get a bit_sequence containing all addresses in BigDataStreet
    street_equal_bigdatastreet: BitSequence = street_bitmap_index.get_equal(
        "BigDataStreet"
    )

    # Combine results via bit operations
    result: BitSequence = street_equal_new_york & (
        street_equal_unistreet | street_equal_bigdatastreet
    )
    end_exec = time.time()
    if print_info:
        print(f"\tMeasure performance for index of type {bit_sequence_type}")
        print(f"\t\tTime taken for creating an index: {end_index - start_index}")
        print(
            f"\t\tStored bits for the index: {city_bitmap_index.get_number_of_bits() + street_bitmap_index.get_number_of_bits()}"
        )
        print(f"\t\tTime taken for indexed query: {end_exec - start_exec}")
        print(f"\t\tThe number of corresponding ids is: {len(result.as_set())}")
    return (
        end_index - start_index,
        end_exec - start_exec,
        city_bitmap_index.get_number_of_bits()
        + street_bitmap_index.get_number_of_bits(),
        result.as_set(),
    )

#### 位序列该用什么结构存?

索引逻辑是一样的,但位序列本身可以有不同的物理表示。这个选择不影响任何查询结果,只影响时间和空间——而差距是数量级的。

##### 表示一:布尔列表 `list[bool]`

最直观的写法:给每个键存一个 `True / False`。

但 Python 的列表存的是**对象引用**,一个元素实际占用远大于 1 位(8 字节指针 + 布尔单例对象)。更要命的是运算方式:`&` 要在 Python 解释层循环 N 次,每次都是一次完整的字节码分派。

**这是"逻辑正确但物理选择糟糕"的典型。**

_, _, query_1_boolean_stored_bits, query_1_boolean_result_bits = (
    create_indexes_and_execute_query(bit_sequence_type=BitListBitSequence)
)
	Measure performance for index of type <class 'system.bit_sequences.BitListBitSequence'>
		Time taken for creating an index: 0.039728641510009766
		Stored bits for the index: 180000
		Time taken for indexed query: 0.0064814090728759766
		The number of corresponding ids is: 2000

##### 表示二:Python 大整数 `int`

换成用一个二进制整数表示整条位序列:第 j 位为 1 就置上 `1 << j`。

这样:

• 空间是真正的 $N$ 位(连续机器字);

• `a & b` 是一条指令的事——Python 的大整数运算在幕后把 CPU 的 64 位 AND 扩展到任意长度,一次覆盖全部 4000 位。

**上层逻辑一行没改,换了个底层实现,性能差出两个数量级。** 这就是数据库系统里"抽象与实现分离"的价值。

_, _, query_1_integer_stored_bits, query_1_integer_result_bits = (
    create_indexes_and_execute_query(bit_sequence_type=IntegerBitSequence)
)
	Measure performance for index of type <class 'system.bit_sequences.IntegerBitSequence'>
		Time taken for creating an index: 0.03580427169799805
		Stored bits for the index: 180000
		Time taken for indexed query: 2.3365020751953125e-05
		The number of corresponding ids is: 2000

#### 两种表示的实测对比

本机实测(4 000 行,两个索引共 36 000 位 $= 4000 \times (4 + 5)$):

| 位序列实现 | 建索引 | 一次查询 | 占用位数 |
| --- | --- | --- | --- |
| 布尔列表 `BitListBitSequence` | 约 13 ms | 约 2.0 ms | 36 000 |
| Python 大整数 `IntegerBitSequence` | 约 11 ms | **0.012 ~ 0.018 ms** | 36 000 |
| 朴素逐行扫描(对照) | — | 0.2 ~ 0.4 ms | — |

**查询快约 100 ~ 160 倍,而占用的位数一模一样。** 差别纯粹来自物理布局。

(建索引耗时两套实现差不多,因为瓶颈是把 4000 行扫一遍、往位序列里置位,这部分谁也省不掉。)

作为量级参照,配套的 50 万行基准脚本(口径表基准)给出:构建 7 660 ms / 朴素 153.588 ms / 位图查询 1.5184 ms,加速约 **101×**,命中 19 845 行。**注意这是 50 万行上的数字,本笔记本只有 4 000 行,跑不出这个绝对值**——两边的可比之处是"位图查询比朴素快约两个数量级"这个结论。

顺带一个坑:上面那 1.5184 ms 里的大头其实不是 AND/OR,而是**数 1 的方式**。`bin(x).count("1")` 要先造一个 50 万字符的字符串;Python 3.8 之后的 `int.bit_count()` 直接在二进制表示上做 popcount,快得多。

最后回到"该不该建索引"。设 $C_{\text{build}}$ 为建索引耗时,$C_{\text{naive}}$ 和 $C_{\text{bm}}$ 分别是朴素与位图的单次查询耗时,则查询次数

$$Q \ge \frac{C_{\text{build}}}{C_{\text{naive}} - C_{\text{bm}}}$$

之后索引才开始盈利。代入 50 万行的口径表基准:$7660 / (153.588 - 1.5184) \approx 50$ 次。**这张表只要被同样模式查询 50 次以上,索引就划算;每天只查两三次的话,这笔投资要一年才回本。**

三、等值编码 vs 范围编码

换个查询类型。这次是范围查询:

```sql
SELECT COUNT(*)
FROM addresses
WHERE house_number < 500
```

等值编码擅长回答"等于某个值",遇到"小于某个值"会怎样?

用等值编码做范围查询

`house_number < 500` 意味着要把所有小于 500 的取值的位序列全部 OR 起来。默认配置(门牌 1~200)是 200 条;若把 `possible_house_numbers` 换成 `range(1, 1001)`,就是 499 条。

**查询耗时与区间宽度成正比**——这是等值编码做范围查询的软肋。

(从这里开始一律用 `IntegerBitSequence`:它的查询性能最好,比的是编码方式而不是物理表示。)

start_index = time.time()

# Create BitSequence for city
house_number_bitmap_index: SortedEqualityEncodedBitmapIndex = (
    SortedEqualityEncodedBitmapIndex(bit_sequence_type=IntegerBitSequence)
)
house_number_bitmap_index.bulkload(house_number_bulkload_generator())

end_index = time.time()
print(f"Time taken for creating an index: {end_index - start_index}")

start_exec = time.time()
smaller_than_500_equality: BitSequence = house_number_bitmap_index.get_smaller(500)

end_exec = time.time()
print(f"Time taken for indexed query: {end_exec - start_exec}")
print(f"The number of corresponding ids is: {len(smaller_than_500_equality.as_set())}")
Time taken for creating an index: 0.026168107986450195
Time taken for indexed query: 0.0007965564727783203
The number of corresponding ids is: 9980

换用专门的范围编码

**范围编码(range encoding)**改变了每条位序列的含义:存的不再是"等于",而是"小于等于"。

$$R_i[j] = 1 \iff A[j] \le v_i$$

于是:

| 查询 | 等值编码 | 范围编码 |
| --- | --- | --- |
| $A = v_i$ | $E_i$(1 条) | $R_i \;\&\; \sim R_{i-1}$(2 条) |
| $A \le v_i$ | $E_1 \| \cdots \| E_i$(i 条) | $R_i$(1 条) |
| $v_i < A \le v_j$ | OR 一大串 | $R_j \;\&\; \sim R_i$(2 条) |

代价也很直接:$R_1 \subseteq R_2 \subseteq \cdots \subseteq R_d$ 构成一条单调链,**改一行的值可能要翻转后面所有位序列的对应位**;而且它要求属性可排序,所以教学库里的 `RangeEncodedBitmapIndex` 只接受整数属性。

接下来这一格只做一次索引访问就拿到结果。

start_index = time.time()

# Create BitSequence for house number
house_number_bitmap_index: RangeEncodedBitmapIndex = RangeEncodedBitmapIndex(
    bit_sequence_type=IntegerBitSequence
)
house_number_bitmap_index.bulkload(house_number_bulkload_generator())
end_index = time.time()
print(f"Time taken for creating an index: {end_index - start_index}")

start_exec = time.time()

# Only one access is required to compute the result.
smaller_than_500_range: BitSequence = house_number_bitmap_index.get_smaller(500)

end_exec = time.time()
print(f"Time taken for indexed query: {end_exec - start_exec}")
print(f"The number of corresponding ids is: {len(smaller_than_500_range.as_set())}")
Time taken for creating an index: 4.523980379104614
Time taken for indexed query: 5.817413330078125e-05
The number of corresponding ids is: 9980

本机多次实测的量级(4 000 行,`house_number < 500`,命中 4 000 行即全部):

| 编码方式 | 建索引 | 一次查询 |
| --- | --- | --- |
| 等值编码(OR 200 条) | 6 ~ 11 ms | 约 0.3 ms |
| 范围编码(取 1 条) | 230 ~ 420 ms | **0.03 ~ 0.09 ms** |

**查询快 3 ~ 10 倍,建索引慢 20 ~ 40 倍。**(绝对值随机器和当次负载波动,看量级即可。)

这就是取舍的全貌:范围编码把成本从"每次查询"挪到了"建索引时",再附赠一个"更新很贵"的缺点。

适用场合也就清楚了:**只读为主的分析型负载(数据仓库 / OLAP)适合范围编码;高频点更新的 OLTP 不适合。** 两种编码的系统性比较可参考 Chan 与 Ioannidis 1998 年发表的工作。

范围编码同样支持任意布尔组合,例如:

```sql
SELECT COUNT(*)
FROM addresses
WHERE house_number >= 300 OR house_number = 30
```

对应 $R_{\ge 300} \;\|\; E_{30}$。

组合布尔表达式

`>= 300` 用补集表示(`~R_{299}`),`= 30` 用相邻两条相减,两者 OR 起来即可。注意全程只碰到位序列,**没有碰过一行原始数据**。

start_exec = time.time()

# Compute individual results
house_number_greater_equal_300: BitSequence = (
    house_number_bitmap_index.get_greater_or_equal(300)
)
house_number_equal_30: BitSequence = house_number_bitmap_index.get_equal(30)

# Combine results
ge_300_e_30_result: BitSequence = house_number_greater_equal_300 | house_number_equal_30

end_exec = time.time()
print(f"Time taken for indexed query: {end_exec - start_exec}")
print(f"The number of corresponding ids is: {len(ge_300_e_30_result.as_set())}")
Time taken for indexed query: 9.012222290039062e-05
The number of corresponding ids is: 14040

混合使用不同索引

真实系统里一个查询的各个条件可以走不同的索引,最后再把结果位序列组合起来:

```sql
SELECT COUNT(*)
FROM addresses
WHERE house_number = 23 OR (city = 'New York' AND house_number < 40)
```

下面这一格建了三个索引:`house_number` 的等值编码(回答 `= 23`)、`city` 的等值编码(回答 `= 'New York'`)、`house_number` 的范围编码(回答 `< 40`),然后一次位运算合并。

**选哪种索引是按条件决定的,不是按列决定的**——同一列上可以同时存在等值和范围两套索引。

start_index = time.time()

# Create equality-encoded indexes for house numbers and cities
house_number_equality_bitmap_index: SortedEqualityEncodedBitmapIndex = (
    SortedEqualityEncodedBitmapIndex(
        bit_sequence_type=IntegerBitSequence,
    )
)
house_number_equality_bitmap_index.bulkload(house_number_bulkload_generator())
city_equality_bitmap_index: SortedEqualityEncodedBitmapIndex = (
    SortedEqualityEncodedBitmapIndex(
        bit_sequence_type=IntegerBitSequence,
    )
)
city_equality_bitmap_index.bulkload(city_bulkload_generator())

# Create range-encoded index for house numbers
house_number_range_bitmap_index: RangeEncodedBitmapIndex = RangeEncodedBitmapIndex(
    bit_sequence_type=IntegerBitSequence,
)
house_number_range_bitmap_index.bulkload(house_number_bulkload_generator())
end_index = time.time()
print(f"Time taken for creating an index: {end_index - start_index}")

start_exec = time.time()

# Compute individual result sets
house_number_equal_23 = house_number_equality_bitmap_index.get_equal(23)
city_equal_new_york: BitSequence = city_equality_bitmap_index.get_equal("New York")
house_number_smaller_40: BitSequence = house_number_range_bitmap_index.get_smaller(40)
end_exec = time.time()

combined_result: BitSequence = house_number_equal_23 | (
    city_equal_new_york & house_number_smaller_40
)
print(f"Time taken for indexed query: {end_exec - start_exec}")
print(f"The number of corresponding ids is: {len(combined_result.as_set())}")
Time taken for creating an index: 4.189512729644775
Time taken for indexed query: 9.34600830078125e-05
The number of corresponding ids is: 210

位图索引很好用,但它要为每个取值存 $N$ 位。如果空间极端受限——连一条压缩后的位序列都放不下呢?

那就只能放弃"精确定位每一行",转而回答一个更弱的问题:**这个键到底在不在这一批数据里?** 这就是 Bloom 过滤器的地盘。

四、Bloom 过滤器

假设磁盘上有一个文件,存着门牌号小于 10 的地址($4 \times 5 \times 9 = 180$ 条)。下面把它 pickle 到 `addresses.pkl`,当作"磁盘上的数据"。

接下来要回答:给定一个 `id`,它在不在这个文件里?

# Write data
house_numbers_equal_1: list[Address] = []

# Get all addresses from New York
for address in all_addresses:
    if address.house_number < 10:
        house_numbers_equal_1.append(address)

# Write addresses to file
with open("addresses.pkl", "wb") as f:
    pickle.dump([vars(address) for address in house_numbers_equal_1], f)

不用过滤器:每次都回盘

最诚实的做法:打开文件,把里面的记录反序列化出来,逐条比对 `id`。

这个函数的代价结构很关键——**每次调用都要完整地读一遍文件**。`id` 不存在时也要读完整文件才能确认,这正是 Bloom 过滤器要消灭的浪费。

def id_is_present_on_disk(id_: int) -> bool:
    with open("addresses.pkl", "rb") as f:
        # Iterate over each stored address to find out, whether the id is present.
        for address in pickle.load(f):
            if address["id"] == id_:
                return True
        return False

对全部 4000 个 id 各问一次

下面对 `all_addresses` 的每一个 id 都问一遍"在不在文件里",累加耗时。

这 4000 次调用里只有 180 次命中,其余 3820 次都白读了一遍文件——**这就是过滤器能省下来的部分**。

contained_ids_without_bloom_filter: set[int] = set()
start_exec = time.time()
for address in all_addresses:
    if id_is_present_on_disk(address.id):
        contained_ids_without_bloom_filter.add(address.id)
end_exec = time.time()
no_filter: float = end_exec - start_exec
print(f"Time taken for evaluating without any bloom filter: {no_filter} seconds")
Time taken for evaluating without any bloom filter: 0.7118265628814697 seconds

Bloom 过滤器的原理

Bloom 过滤器(Bloom 1970)由两部分组成:长度 $m$ 的位数组 + $k$ 个哈希函数。

• **插入 x**:算出 $h_1(x) \ldots h_k(x)$,把这些位置置 1。

• **查询 y**:算出 $k$ 个位置,只要**有任何一个为 0 → 一定不在**(无假阴性);若全为 1 → **可能在**,需要回原始数据复核。

**为什么永远没有假阴性?** 因为插入时把 $k$ 个位置都置成了 1,之后没有任何操作会把它们清 0。下次查同一个键,这些位置必然全为 1。

假阳性率(即"不存在的键被误判为存在"的概率):

$$p \approx \left(1 - e^{-kn/m}\right)^k$$

| 符号 | 含义 |
| --- | --- |
| $n$ | 插入的元素个数 |
| $m$ | 位数组长度(bit) |
| $k$ | 哈希函数个数 |
| $e^{-kn/m}$ | 插入全部 $n$ 个元素后,某个特定位仍为 0 的概率 |
| $p$ | 一次"不存在键"查询被误判为存在的概率 |

两个可以直接用在工程上的结论:

$$k^* = \frac{m}{n}\ln 2 \qquad\qquad m = -\frac{n \ln p}{(\ln 2)^2}$$

左边:给定空间预算 $m/n$,最优哈希个数;右边:给定目标假阳性率 $p$,需要的位数。

教学库的实现(`system/indexes/bloom_filters.py`)会自动按 $k = \lceil \ln 2 \cdot m/n \rceil$ 选 $k$,不用手动传。另外它用 `random.Random(key)` 的伪随机序列充当"k 个哈希"——**这是教学简化,源码注释里明确写着 NEVER USE THIS IN PRODUCTION**;真实系统用 murmur / md5 配合 double hashing(Kirsch 与 Mitzenmacher 提出的技巧:两个哈希函数线性组合出 k 个位置,不必真做 k 次哈希)。

下面这一格把 $m$ 从 10 一路加到 5000,每个 $m$ 都建一个过滤器,跑完同一批 4000 次查询,然后把耗时和"不用过滤器"的基线画在一张图上。

# Define method to go through all addresses in order to identify those that are contained in set
required_time: list[float] = []
contained_ids_with_bloom_filter: list[set[int]] = []


def check_all_addresses(bloom_filter: BloomFilter):
    contained_ids: set[int] = set()
    start_exec = time.time()
    for address in all_addresses:
        if bloom_filter.contains_key(address.id):
            if id_is_present_on_disk(address.id):
                contained_ids.add(address.id)
    end_exec = time.time()
    required_time.append(end_exec - start_exec)
    contained_ids_with_bloom_filter.append(contained_ids)


# Create bloom filters with different bit sizes
numbers_of_available_bits: list[int] = [10, 20, 50, 100, 200, 500, 1000, 2000, 5000]
for number in numbers_of_available_bits:
    # Create Bloom Filter
    bloom_filter: BloomFilter = BloomFilter(
        data_objects=house_numbers_equal_1,
        key_attribute_name="id",
        number_of_available_bits=number,
        bit_sequence_type=BitListBitSequence,
    )
    check_all_addresses(bloom_filter)

x = np.arange(len(numbers_of_available_bits))  # the label locations
fig, ax = plt.subplots(
    layout="constrained",
    figsize=(5, 4),
)

ax.plot(x, required_time, label="Bloom Filter")
plt.axhline(y=no_filter, color="r", linestyle="--", label="No Bloom Filter")
plt.legend()
ax.set_ylabel("Required Time to answer the query in s")

ax.set_xlabel("Number of bits stored")
ax.set_xticks(x, numbers_of_available_bits)
plt.show()
Notebook 已保存的运行结果

图上两条线:折线是不同 $m$ 下带 Bloom 过滤器的耗时,红色虚线是不用过滤器的基线(全量回盘)。

看的不是"越低越好",而是**折线什么时候穿过红色虚线**:

• $m$ 很小的时候**反而更慢**。因为位数组几乎被填满,绝大多数查询都假阳性,白白多付了哈希计算的代价,还要照旧回盘。本笔记本 $n = 180$,$m = 10$ 时 $k = \lceil 0.693 \times 10/180 \rceil = 1$,一个哈希位、几乎必然撞上,过滤器形同虚设。

• $m$ 增大后假阳性率下降,穿过滤线之后开始**净赚**:绝大多数"不在"的查询被挡在内存里,根本不碰磁盘。

$m = 5000$ 时 $k = \lceil 0.693 \times 5000/180 \rceil = 20$,理论假阳性率已经低到几乎为 0。

应用实例:Google Bigtable(OSDI 2006)用它避免读不存在的 SSTable;Apache Cassandra 用它减少磁盘读取;PostgreSQL 的 bloom 扩展索引属于有损索引,命中后必须回表复核。**它们的共同点是:Bloom 只负责"挡掉不可能命中的",命中之后一定要回原始数据确认。**

五、压缩位图:WAH 字对齐游程编码

位图索引的问题是空间:$V$ 个取值就要 $V \times N$ 位。压缩的思路是**游程压缩**——连续的 0 或连续的 1 不必逐位存。

**WAH(Word-Aligned Hybrid)**的做法(Wu、Otoo、Shoshani,ACM TODS 2006):把位序列按固定字长切成若干字(word),然后分三类:

| 字类型 | 含义 | 存储开销 |
| --- | --- | --- |
| 0-fill | 连续若干个字全是 0 | 1 个字(头里记重复次数) |
| 1-fill | 连续若干个字全是 1 | 1 个字 |
| literal | 字内 0/1 混杂,压不动 | 1 个字,原样存 |

字对齐的好处是部分运算可以直接在压缩态上做,不必先解压。

下面这一格把 `WAHBitSequence.WORD_LENGTH` 从 4 调到 256,对四种组合(未压缩的两种物理表示 × 压缩与否)各测"建索引耗时 / 查询耗时 / 占用位数",画成三张对数坐标图。

**字长是一个有拐点的参数**——这是本格最值得看的东西。

result_dict: dict[str, tuple[list[float], list[float], list[int]]] = {
    index_type: ([], [], [])
    for index_type in [
        "BooleanListBitSequence",
        "IntegerBitSequence",
        "CompressedIntegerBitSequence",
        "CompressedBooleanListBitSequence",
    ]
}
bitset_types: dict[
    str, tuple[Type[UncompressedBitSequence], Type[CompressedBitSequence] | None]
] = {
    "IntegerBitSequence": (IntegerBitSequence, None),
    "BooleanListBitSequence": (BitListBitSequence, None),
    "CompressedIntegerBitSequence": (IntegerBitSequence, WAHBitSequence),
    "CompressedBooleanListBitSequence": (BitListBitSequence, WAHBitSequence),
}
marker_dict: dict[str, str] = {
    "IntegerBitSequence": "*",
    "BooleanListBitSequence": "D",
    "CompressedIntegerBitSequence": "s",
    "CompressedBooleanListBitSequence": "^",
}
word_lengths: list[int] = [4, 8, 16, 32, 64, 128, 256]
benchmark_results: list[BitSequence] = []
for bitset_type, result_list in result_dict.items():
    for i in word_lengths:
        WAHBitSequence.WORD_LENGTH = i

        # Use median out of multiple operations
        index_times = []
        exec_times = []
        bits_stored = None
        for _ in range(2):
            index_time, exec_time, bits_stored, result_bits = (
                create_indexes_and_execute_query(
                    bit_sequence_type=bitset_types[bitset_type][0],
                    compression_type=bitset_types[bitset_type][1],
                    print_info=False,
                )
            )
            index_times.append(index_time)
            exec_times.append(exec_time)
            benchmark_results.append(result_bits)

        result_list[0].append(statistics.median(index_times))
        result_list[1].append(statistics.median(exec_times))
        result_list[2].append(bits_stored)

x = np.arange(len(word_lengths))  # the label locations
fig, (ax_index_time, ax_exec_time, ax_bits_stored) = plt.subplots(
    layout="constrained", figsize=(12, 4), ncols=3
)
for bitset_type, bitset_type_tuple in result_dict.items():
    ax_index_time.plot(
        x, bitset_type_tuple[0], label=bitset_type, marker=marker_dict[bitset_type]
    )
    ax_exec_time.plot(
        x, bitset_type_tuple[1], label=bitset_type, marker=marker_dict[bitset_type]
    )
    ax_bits_stored.plot(
        x, bitset_type_tuple[2], label=bitset_type, marker=marker_dict[bitset_type]
    )

ax_index_time.set_ylabel("Required Time to create an index in s")
ax_exec_time.set_ylabel("Required Time to execute the query in s")
ax_exec_time.legend(loc="center", bbox_to_anchor=(0.5, 1.2))
ax_bits_stored.set_ylabel("Number of bits stored")

ax_exec_time.set_yscale("log")
ax_bits_stored.set_yscale("log")

for ax in [ax_index_time, ax_exec_time, ax_bits_stored]:
    ax.set_xlabel("Bit length of a word")
    ax.set_xticks(x, word_lengths)
Notebook 已保存的运行结果

本机实测(4 000 行,未压缩时两个索引共 36 000 位):

| 字长 | WAH 压缩后位数 | 占未压缩的比例 | 一次查询 |
| --- | --- | --- | --- |
| 4 | 16 148 | 44.9% | 9.107 ms |
| 32 | **3 392** | **9.4%** | 0.480 ms |
| 256 | 20 224 | 56.2% | 0.373 ms |

拐点非常清楚:

• **字长太小(4)**:几乎每个字都是 literal,凑不出长游程,压缩率差,而且字多了、解析开销大;

• **字长适中(32)**:一个字里 32 位全 0 的概率足够高,能凑出长游程,压缩到 9.4%;

• **字长太大(256)**:一个字里只要混进一个 1 就退化成 literal,游程又凑不出来了,压缩率反弹。

**查询耗时则一直比未压缩的大整数慢**(0.48 ms 对 0.018 ms,约 27 倍)——运算前要先解析压缩格式。这就是本节开头说的"空间和查询时间不是一起变好的"。

数据本身的分布更关键:口径表基准(10 万位、字长 32)显示,密度 0.1% 的**随机分布**压到 204 字(占 6.5%),**聚集分布**只压到 5 字(0.16%),而密度达到 10% 以上时随机分布的压缩基本失效(100%)。**把表按某个键排序(聚簇)之后再建位图,压缩率会有数量级的改善**——这是位图索引在实践中可行的真正基础。

另一条主流路线是 Roaring Bitmap:按高 16 位分桶,桶内根据稀疏程度在 array / bitset / run 三种容器之间切换。它和 WAH 是同一问题的两种解法。

六、单元测试:结果对不对,不能靠肉眼看

前面的每一步都在追求快,但**快必须建立在对的基础上**。最后这一格用 `unittest` 把三件事断言一遍:

1. 两种位序列实现(布尔列表 / 大整数)的查询结果,都要等于朴素扫描的结果;
2. 等值编码和范围编码对同一个范围查询,都要等于朴素扫描的结果;
3. 所有 Bloom 过滤器(不同 $m$)最终筛出的 id 集合,都要等于不用过滤器时筛出的集合——**Bloom 可以有假阳性,但不能改变最终答案**,因为每个"可能在"都回盘复核过了。

另外还断言了存储位数等于 $4000 \times (4 + 5) = 36\,000$,这是"位图空间 = 取值个数 × 行数"这条公式的直接验证。

**养成习惯:任何性能优化都得配一条正确性断言。**

import unittest


class BitmapTests(unittest.TestCase):
    def test_uncompressed_bitmap_index(self):

        # Equality-encoded city + street indexes store one bit per address per
        # distinct value, i.e. len(addresses) * (num cities + num streets) bits.
        # Derived from the data so the check holds for any house-number range.
        expected_stored_bits: int = len(all_addresses) * (
            len(possible_cities) + len(possible_streets)
        )
        for bits_stored in [query_1_integer_stored_bits, query_1_boolean_stored_bits]:
            self.assertEqual(bits_stored, expected_stored_bits)
        for bitmap_result in [query_1_integer_result_bits, query_1_boolean_result_bits]:
            self.assertEqual(bitmap_result, query_1_naive_result)

        # Check house number smaller than 500 result
        naive_smaller_than_500: set[int] = set()
        for address in all_addresses:
            if address.house_number < 500:
                naive_smaller_than_500.add(address.id)

        for bitmap_result in [smaller_than_500_equality, smaller_than_500_range]:
            self.assertEqual(naive_smaller_than_500, bitmap_result.as_set())

        # House number greater/equal 300 or equal 30
        naive_ge_300_e_30: set[int] = set()
        for address in all_addresses:
            if address.house_number >= 300 or address.house_number == 30:
                naive_ge_300_e_30.add(address.id)
        self.assertEqual(naive_ge_300_e_30, ge_300_e_30_result.as_set())

        # Combined result as described above
        naive_combined_result: set[int] = set()
        for address in all_addresses:
            if address.house_number == 23 or (
                address.city == "New York" and address.house_number < 40
            ):
                naive_combined_result.add(address.id)
        self.assertEqual(naive_combined_result, combined_result.as_set())

    def test_bloom_filters(self):

        # Bloom Filter should produce the same result
        for contained_ids in contained_ids_with_bloom_filter:
            self.assertEqual(contained_ids, contained_ids_without_bloom_filter)

    def test_benchmark(self):

        # Verify that all benchmark produce the correct results
        for result in benchmark_results:
            self.assertEqual(query_1_naive_result, result)


unittest.main(
    argv=["ignored", "-v"], defaultTest="BitmapTests", verbosity=2, exit=False
)

七、思考题与详解

<details>
<summary><b>第 1 题</b>:为什么 Bloom 过滤器不会有假阴性(false negative),只会有假阳性?如果把"删除"也支持进去,会出现什么问题?</summary>

**原因**:插入键 x 时把 $h_1(x) \ldots h_k(x)$ 全部置 1,之后再也没有任何操作把它们清 0。下次查询同一个键,这些位置必然全为 1,所以判定一定是"可能存在"。**没有哪个操作会把已置的位清掉,就不存在假阴性。**

**加入删除就会破坏这个性质**:把某个位置清 0 时,你不知道还有多少个其它键的哈希也落在这个位置上,清多了会让别的键产生假阴性——而假阴性是 Bloom 过滤器绝对不能有的。

两条解决路线:

• **计数 Bloom(counting Bloom filter)**:每个位置用一个小计数器(通常 4 位)代替 1 位,插入加 1、删除减 1。代价是空间放大约 4 倍。

• **不支持删除**:定期重建整个过滤器。很多系统就是这么做的。

</details>

<details>
<summary><b>第 2 题</b>:口径表基准里 m/n = 14、k = 10 时理论假阳性率 0.0012、实测 0.0016。这个差异合理吗?用统计涨落算一算。</summary>

**合理,这是噪声。** 一次"不存在键"被误判是伯努利试验,$p = 0.0012$,试验次数 5000:

```text
期望个数 E = 5000 × 0.0012 = 6.0
标准差 σ = √(5000 × 0.0012 × 0.9988) = √5.993 ≈ 2.45
实测 0.0016 ⇒ 实测个数 = 5000 × 0.0016 = 8.0
偏离 (8 − 6) / 2.45 ≈ 0.82 σ
```

0.82 个标准差是极常见的涨落。反过来讲,$p$ 的测量值本身的标准差是 $\sigma / 5000 \approx 0.00049$,所以实测 $0.0016 \pm 0.0005$ 完全覆盖理论值 0.0012。

**不需要修正公式。** 想把这条曲线测准,把探测次数提到 500 000(增加 100 倍),相对误差就降到原来的 1/10。

</details>

<details>
<summary><b>第 3 题</b>:固定 m/n = 12 时理论最优 k* = 8.32,但实测最优点落在 k = 10。公式错了吗?</summary>

**公式没错,是函数太平坦被噪声捂住了。** 代入 $p = (1 - e^{-k n/m})^k$,其中 $kn/m = k/12$:

```text
k = 8 : p = (1 − e^{−8/12})^8 = (1 − 0.51342)^8 = 0.48658^8 = 0.003142
k = 10: p = (1 − e^{−10/12})^10 = (1 − 0.43460)^10 = 0.56540^10 = 0.003339
```

两者只差 0.0002。换算成 5000 次探测的期望个数:k = 8 得 15.7 个,k = 10 得 16.7 个,差 1 个;而这次实验的涨落 $\sigma = \sqrt{5000 \times 0.0031 \times 0.9969} \approx 3.9$ 个。**差 1 个完全淹没在噪声里。**

正确验证办法是增大探测次数;或者直接看理论曲线——它的谷底确实在 8.32 附近。另外工程上 $k$ 必须是整数,8 和 9 的理论差只有约 0.00002,取哪个都行。

</details>

<details>
<summary><b>第 4 题</b>:口径表基准里,密度 0.1% 的随机分布压到 204 个字。请估算这个数字的来源:一个 32 位的字里至少含一个 1 的概率是多少?由此推算"字面字"的期望个数。</summary>

一个字的 32 位全为 0 的概率是 $(1-p)^{32}$,$p = 0.001$:

```text
P(字内含至少一个 1) = 1 − 0.999^32 = 1 − e^{32·ln0.999} = 1 − e^{−0.032016} = 1 − 0.96849 = 0.03151
```

共 $100000/32 = 3125$ 个字,字面字期望 $= 3125 \times 0.03151 \approx 98.5$ 个。

WAH 遇到字面字要额外存一个头字,两个字面字之间还要穿插 0-fill 字,粗略数:$98.5$ 个字面字 + 约 $98.5$ 个头/间隔字 + 首尾边界 ≈ 200 上下,与实测 **204** 吻合。

密度升到 1% 时同一算式给 $1 - 0.99^{32} = 0.27510$,字面字期望 860 个;按"每个字面字配一个间隔字"估是 1720,但实测只有 **1532**——因为密度上升后相邻字面字连成一片,共享头字。**这说明 WAH 的字数由"字面字的游程结构"决定,不只是字面字的个数。**

</details>

<details>
<summary><b>第 5 题</b>:为什么聚簇分布下的 WAH 字数几乎与密度无关(一直是 5 个字左右)?一条连续块被编码成哪几个字?</summary>

聚簇位序列长这样(一整段连续的 1,其余全 0):

```text
0 … 0 | 1 1 1 … 1 1 1 | 0 … 0
└ 前导全 0 字 ┘ └ 若干全 1 字 + 最多两个混杂字 ┘ └ 尾随全 0 字 ┘
```

编码结果:

```text
1 个 0-fill 字(覆盖前导的全部 0 字)
1~2 个字面字(块的头尾不对齐时才有;恰好字对齐则没有)
1 个 1-fill 字(覆盖块内部的全 1 字)
1 个 0-fill 字(覆盖尾随的 0 字)
```

**字数是常数,与块的长度无关**——这正是游程编码的本质。实测拿到 5 个字,说明这批数据的块边界没有字对齐,头尾各多出一个字面字。

实践含义非常重要:**把表按某个键排序(cluster)之后再建位图,压缩率会有数量级的改善。** 多维聚簇、Z 序曲线之所以有用,根源也在这里。

</details>

<details>
<summary><b>第 6 题</b>:范围编码构造出的 R_i 之间满足什么单调关系?这个关系对"更新一行数据"意味着什么代价?</summary>

由定义直接得到包含关系:

$$R_1 \subseteq R_2 \subseteq \cdots \subseteq R_d \quad (\text{因为 } A \le v_i \Rightarrow A \le v_{i+1})$$

好处是任意区间查询都能用两三次运算凑出来:

```text
A ≤ v_i → R_i
A = v_i → R_i & ~R_{i−1}
v_i < A ≤ v_j → R_j & ~R_i
```

坏处在更新:把 $A[j]$ 从 $v_a$ 改成 $v_b$(设 $v_a < v_b$),那么 $R_a, R_{a+1}, \ldots, R_{b-1}$ 这 $(b-a)$ 条位序列的第 j 位都要翻转,**代价与取值距离成正比**。对比等值编码:同一行更新只碰 2 条位序列。

这就是范围编码的定位:**适合只读为主的分析型负载(OLAP),不适合高频点更新(OLTP)。**

</details>

<details>
<summary><b>第 7 题</b>:本实验的位图查询快,是因为用 <code>bin(x).count("1")</code>(或 <code>int.bit_count()</code>)只数数量。如果要求"返回命中的所有行号",代价会变成什么?复杂度是多少?</summary>

统计数量只需一次 popcount。要拿行号就得把每个置位的位置找出来:

```python
def set_positions(x: int):
'''返回整数 x 中所有为 1 的位下标(从低位向高位)。'''
pos = []
while x:
lsb = x & -x # 取出最低的一个置位
pos.append(lsb.bit_length() - 1)
x ^= lsb # 清掉这位
return pos
```

**复杂度是 $O(h)$,$h$ 为命中行数**(每个输出一次循环),而不是 $O(N)$。

千万别写成 `[(mask >> i) & 1 for i in range(n)]`——那是 $O(N)$,会把位图查询的优势全丢掉。

还有一层:拿到行号之后要去堆表回表取完整记录,**那才是真正的大头**。数量统计只是"最便宜的那种位图查询"。

</details>

<details>
<summary><b>第 8 题</b>:位图索引适合低基数列。如果给一列 100% 唯一的键(例如主键)建等值位图,空间会是多大?用 N = 500 000 算一算,为什么这种情况要用 B+ 树或哈希索引?</summary>

一列有 $V$ 个不同取值就要 $V$ 条位序列,每条 $N$ 位,总位数 $= V \cdot N$。

$V = N = 500\,000$ 时:

```text
总位数 = 500 000 × 500 000 = 2.5 × 10^11 bit
= 2.5 × 10^11 / 8 字节 = 3.125 × 10^10 字节
≈ 31.25 GB(按 10^9 换算)/ 29.1 GiB(按 2^30 换算)
```

而原始数据区区几十 MB——**空间放大了几千倍。**

所以:

• **低基数列**(性别、城市、状态码)适合位图:$V$ 小 ⇒ $V \cdot N$ 小,而且 $V$ 条位序列足够表达;

• **高基数列**(主键、身份证号、时间戳)要用 B+ 树或哈希索引,它们的空间是 $O(N)$ 而不是 $O(N \cdot V)$。经验阈值视系统而定,通常 $V$ 到几千以上就要重新考虑。

</details>