实验 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))
breakfrom 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()图上两条线:折线是不同 $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)本机实测(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>