实验 10:共享扫描(Shared Scan)
> 三个查询都要扫同一张 40 万行的表,是扫三遍,还是扫一遍把三个查询的结果一起挑出来?
一、实验目标
完成本实验后,你应该能够:
1. 说出全表扫描(full table scan)的代价构成,并区分**读取元组次数**与**墙钟时间**两个不同的指标。
2. 定义选择性(selectivity),并用它估算扫描结果的大小。
3. 实现四种扫描做法:每查询各扫一遍、共享扫描 + 每个查询一份结果列表、共享扫描 + 单个列表打标签。
4. 用计数器实证"共享扫描把 I/O 量降到 `1/Q`"(Q 是查询个数)。
5. 解释本实验最反直觉的现象:**在本机 Python 实现下,共享扫描的墙钟时间未必更短,但它仍然有意义**。
6. 说明真实数据库中 I/O 与 CPU 的相对地位,以及为什么真实系统会采用共享扫描。
二、预备知识
2.1 全表扫描
关系 `stuff` 有 `n` 条元组。查询:
```sql
SELECT * FROM stuff WHERE a = 7
```
如果没有索引,数据库只能把每一条元组读出来,逐个检查谓词 `a = 7`。这就是全表扫描。
```
元组 0 读 → 检查 → 丢弃
元组 1 读 → 检查 → 丢弃
元组 2 读 → 检查 → 命中 → 加入结果
...
元组 n-1 读 → 检查 → 丢弃
```
读取元组次数 = `n`,与谓词命中多少无关。
2.2 选择性(selectivity)
选择性 σ(sigma)是一个无量纲的比例,取值在 `[0, 1]` 之间:
```
σ_q = |结果_q| / n
```
含义:查询 `q` 命中的元组占全表的比例。`σ_q` 越小,谓词越"挑剔"。
本实验:`a` 与 `b` 都在 `[0, 100]` 上均匀取值(共 101 个可能值),所以 `a = 7` 的期望选择性是:
```
σ = 1 / 101 ≈ 0.0099
```
`n = 400,000` 时,期望命中条数:
```
400,000 × (1/101) ≈ 3,960
```
三个查询的实测结果都在 3,900 ~ 4,100 之间,与估算吻合。
2.3 为什么 I/O 才是真实数据库的瓶颈
数据库的表在磁盘上,读取一个页面(page,通常 4 KB ~ 16 KB)的成本远大于在内存里做一次整数比较。
一个简化的代价模型:
```
Cost(扫描) = P × (t_seek + t_transfer) + n × t_cpu
```
符号含义与单位:
| 符号 | 含义 | 典型量级(需自行实测确认) |
| --- | --- | --- |
| `P` | 扫描的页面数 | 个 |
| `t_seek` | 单次寻道 + 旋转延迟 | 毫秒级(机械盘)/ 微秒级(SSD) |
| `t_transfer` | 传输一页的时间 | 微秒级 |
| `n` | 元组条数 | 条 |
| `t_cpu` | 每元组 CPU 处理时间 | 纳秒 ~ 微秒级 |
数量级对比:`t_seek` 比 `t_cpu` 大 **3 到 6 个数量级**。所以只要 `P` 足够大,第一项完全主导,第二项可以忽略。
**这正是共享扫描的立论基础**:如果能让 `P` 从"扫 Q 遍"降到"扫 1 遍",第一项就降到 `1/Q`,哪怕第二项(CPU)稍微变大,总成本仍然下降。
2.4 缓冲池(buffer pool)
真实数据库不会真的读 Q 遍磁盘。它在内存里有一块缓冲池,第一遍扫描把页面读进来,第二遍可能命中缓存。
这是否让共享扫描变得没必要?不是。原因:
1. 表往往远大于缓冲池,第二遍扫描时第一遍的页面早就被淘汰了。
2. 即便全部命中缓存,扫描本身仍然要把 `n` 条元组从页面里解析(deserialize)成内存对象,这部分 CPU 开销也不小。
3. 缓冲池命中率是概率性的,不能作为优化的依据。
共享扫描的价值在于:**确定性**地把读取量降到 `1/Q`,而不是指望运气。
2.5 共享扫描的三种组织方式
设同时有 `Q` 个查询 `q₁, ..., q_Q` 要扫同一张表。
```
做法一:每查询各扫一遍(independent scan)
for q in queries:
for tup in table: ← 扫 Q 遍
if pred_q(tup): result_q.append(tup)
做法二:共享扫描 + 每个查询一份结果列表
results = {q: [] for q in queries}
for tup in table: ← 只扫 1 遍
for q in queries:
if pred_q(tup): results[q].append(tup)
做法三:共享扫描 + 单个列表 + 打标签(tagging)
for tup in table: ← 只扫 1 遍
tags = [q for q in queries if pred_q(tup)]
if tags: result.append(Result(tup, tags))
```
做法三与做法二的差别在**结果的存储形态**:
• 做法二:一条元组若同时命中 2 个查询,会被存 2 份。
• 做法三:一条元组只存 1 份,用标签列表指明它属于哪些查询。
当查询之间结果重叠度高时,做法三节省内存;重叠度低时,做法三还要额外存标签,反而更占。
三、实验原理
3.1 读取量的对比
设 `Q` 为查询个数,`n` 为元组条数。
| 做法 | 读取元组次数 | 谓词求值次数 | 结果列表条目数 |
| --- | ---: | ---: | ---: |
| 一、独立扫描 | `n · Q` | `n · Q` | `Σ_q \|结果_q\|` |
| 二、共享扫描 + 分列表 | `n` | `n · Q` | `Σ_q \|结果_q\|` |
| 三、共享扫描 + 打标签 | `n` | `n · Q` | `\|∪_q 结果_q\|` |
关键观察:
• **读取元组次数**:从 `n·Q` 降到 `n`,即降到 `1/Q`。这是共享扫描的全部收益来源。
• **谓词求值次数**:三种做法**完全一样**,都是 `n·Q`。共享扫描一点都没省 CPU。
• **结果条目数**:做法三只在有重叠时才更省,因为 `|∪_q 结果_q| ≤ Σ_q |结果_q|`,等号成立于各查询结果互不相交时。
本实验 `Q = 3`、`n = 400,000`:
```
做法一读取量 = 400,000 × 3 = 1,200,000
做法二/三读取量 = 400,000
比值 = 1,200,000 / 400,000 = 3 ← 正好等于 Q
```
3.2 本实验的反直觉现象(必须理解)
在**纯 Python** 实现下测墙钟时间,你会看到共享扫描**不比**独立扫描快,甚至更慢。原因:
1. **独立扫描的内层循环极简**:`for tup in data: if tup.a == 7: ...`,每元组一次属性访问 + 一次比较。
2. **共享扫描的内层循环多了两层**:`for tup in data: for q in queries: ...`,而且要把谓词表达成 `el.__dict__[attr] == const` 这样的**动态属性查找**,每元组要查 `Q` 次字典。
3. 在 Python 里,一次 `obj.__dict__[key]` 加一次函数调用的开销,远大于一次直接的属性比较。于是共享扫描"省掉的读取"根本抵不过"多出来的动态查找"。
**但结论仍然是共享扫描有意义**,因为:
• 本实验的"读取"是遍历一个 Python 列表,成本极低,不能代表数据库的 I/O。真实数据库的"读取一次元组"包含页面调度、磁盘/网络传输、元组解析,成本高几个数量级。
• 本实验实测:共享扫描把**读取元组次数**从 1,200,000 降到 400,000,即 **1/3**。这个收益是确定性的、与实现语言无关的。
• 真实系统里 I/O 主导总成本,所以节省 `2/3` 的 I/O 是决定性的。
一句话:**看指标要看对,本实验正确的观测指标是"读取元组次数",不是墙钟时间。**
3.3 怎么让共享扫描在 CPU 上也赢
想让共享扫描在 Python 里也快,办法是消除动态查找:
1. **把谓词编译成闭包**:`preds = [(lambda t, k=7: t.a == k) for ...]`,避免 `__dict__[attr]`。
2. **用实验 09 的代码生成**:为每个查询生成专用代码,把 `Q` 个谓词全部内联进同一个循环体。这样每元组仍是 `Q` 次比较,但没有函数调用与字典查找。
3. **用位图标签**:把 `Q` 个布尔结果打包成一个整数的 `Q` 个位,用位运算一次算出标签。
4. **按属性分组**:把同一属性上的多个等值谓词合并成一次字典查找(例如 `b=32` 与 `b=1` 都查 `b`,可以一次取值比较两次)。
扩展任务 9.2、9.3 会做其中两项。
3.4 参考图件
| 图件 | 内容 |
| --- | --- |
| `../figures/fig_shared_scan.png` | 独立扫描与共享扫描的读取元组次数对比 |
四、实验环境与准备
4.1 环境要求
| 项目 | 要求 |
| --- | --- |
| Python | 3.10 及以上 |
| 必装库 | 无,只用标准库 `random` / `dataclasses` / `time` / `typing` |
| 选装库 | `matplotlib`(步骤 7 画图) |
| 参考实现 | `notebooks/Shared-Scan.ipynb` |
4.2 数据
`n = 400,000` 条元组,每条有两个整数属性 `a` 与 `b`,都在 `[0, 100]` 上均匀取值。
查询有三个:
```sql
SELECT * FROM stuff WHERE a = 7; -- q1
SELECT * FROM stuff WHERE b = 32; -- q2
SELECT * FROM stuff WHERE b = 1; -- q3
```
> 关于结果条数:`a`、`b` 各有 101 个可能取值,所以每个查询的期望命中数是 `400,000 / 101 ≈ 3,960`。本机某次实测得到 `[4006, 3992, 3927]`;本指导书代码用 `random.seed(42)` 时给出 `[3982, 3979, 3940]`。两者都在期望值附近的小范围内波动,属正常抽样误差。**你的数字以实际运行为准**,重要的是三个算法给出的结果彼此一致。
五、实验步骤
步骤 1:生成数据
```python
import random
from dataclasses import dataclass
from typing import Dict, List
n = 400_000
@dataclass
class Stuff:
"""一条元组,两个整数属性。"""
a: int
b: int
random.seed(42)
data: List[Stuff] = [
Stuff(random.randint(0, 100), random.randint(0, 100))
for _ in range(n)
]
print("元组条数:", len(data))
print("前 5 条:", data[:5])
```
步骤 2:定义查询与读取计数器
计数器是本实验的核心观测工具。每次"从表里取出一条元组"就加一。
```python
tuples_read = 0 # 全局计数器:读取了多少条元组
def reset_counter():
global tuples_read
tuples_read = 0
queries_dict: Dict[str, Dict[str, object]] = {
"q1": {"attribute": "a", "constant": 7},
"q2": {"attribute": "b", "constant": 32},
"q3": {"attribute": "b", "constant": 1},
}
```
步骤 3:算法一——每查询各扫一遍
```python
def scan(data: List[Stuff], attribute: str, constant: int) -> List[Stuff]:
"""扫描全表,返回属性等于常量的所有元组。"""
global tuples_read
res: List[Stuff] = []
for el in data:
tuples_read += 1 # 每读一条元组计一次
if el.__dict__[attribute] == constant:
res.append(el)
return res
reset_counter()
result_independent: Dict[str, List[Stuff]] = {
key: scan(data, entry["attribute"], entry["constant"])
for key, entry in queries_dict.items()
}
print("读取元组次数:", tuples_read)
print("各查询结果条数:", {k: len(v) for k, v in result_independent.items()})
```
预期输出(`random.seed(42)`):
```
读取元组次数: 1200000
各查询结果条数: {'q1': 3982, 'q2': 3979, 'q3': 3940}
```
`1,200,000 = 400,000 × 3`,正好是 `n · Q`。
步骤 4:算法二——共享扫描,每个查询一份结果列表
```python
def shared_scan(data: List[Stuff],
queries_dict: Dict[str, Dict[str, object]]
) -> Dict[str, List[Stuff]]:
"""只扫一遍,为每个查询维护一份结果列表。"""
global tuples_read
res: Dict[str, List[Stuff]] = {k: [] for k in queries_dict}
for el in data:
tuples_read += 1 # 整表只扫一遍
for key, entry in queries_dict.items():
if el.__dict__[entry["attribute"]] == entry["constant"]:
res[key].append(el)
return res
reset_counter()
result_shared = shared_scan(data, queries_dict)
print("读取元组次数:", tuples_read)
print("各查询结果条数:", {k: len(v) for k, v in result_shared.items()})
```
预期输出:
```
读取元组次数: 400000
各查询结果条数: {'q1': 3982, 'q2': 3979, 'q3': 3940}
```
读取量降到 `1/3`,结果条数**完全不变**。
步骤 5:算法三——共享扫描,单个列表 + 打标签
```python
@dataclass
class Result:
"""一条结果元组,附一个标签列表,指明它属于哪些查询。"""
result: Stuff
relevant_for_queries: List[str]
def shared_scan_with_tagging(data: List[Stuff],
queries_dict: Dict[str, Dict[str, object]]
) -> List[Result]:
"""只扫一遍,命中的元组只存一份,用标签标明归属。"""
global tuples_read
res: List[Result] = []
for el in data:
tuples_read += 1
tags: List[str] = []
for key, entry in queries_dict.items():
if el.__dict__[entry["attribute"]] == entry["constant"]:
tags.append(key)
if len(tags) > 0: # 只有至少命中一个查询才存
res.append(Result(el, tags))
return res
def join_result_list_with_bit_lists(tagged: List[Result]
) -> Dict[str, List[Stuff]]:
"""把打标签的结果还原成 {查询: 结果列表} 的字典,便于对比。"""
out: Dict[str, List[Stuff]] = {}
for item in tagged:
for key in item.relevant_for_queries:
out.setdefault(key, []).append(item.result)
return out
reset_counter()
tagged = shared_scan_with_tagging(data, queries_dict)
result_tagged = join_result_list_with_bit_lists(tagged)
print("读取元组次数:", tuples_read)
print("结果列表条目数:", len(tagged))
print("各查询结果条数:", {k: len(v) for k, v in result_tagged.items()})
```
预期输出:
```
读取元组次数: 400000
结果列表条目数: 11825
各查询结果条数: {'q1': 3982, 'q2': 3979, 'q3': 3940}
```
`11825` 是三个查询结果**并集**的大小;而 `3982 + 3979 + 3940 = 11901` 是"分列表"版本的条目总数。两者之差 `11901 − 11825 = 76`,说明有 76 条元组同时命中了两个查询。
估算验证:`a` 与 `b` 独立,所以
```
P(a=7 且 b=32) = (1/101) × (1/101) ≈ 9.8 × 10⁻⁵
P(a=7 且 b=1) = (1/101) × (1/101) ≈ 9.8 × 10⁻⁵
期望重叠条数 = 400,000 × 2 × 9.8 × 10⁻⁵ ≈ 78
```
实测 76,与估算吻合。(`q2` 与 `q3` 都作用在 `b` 上,`b=32` 与 `b=1` 互斥,所以它们之间没有重叠。)
步骤 6:三种算法的结果一致性
```python
def sort_key(lst):
"""把结果列表转成可比较的规范形式(按 a、b 排序)。"""
return sorted((t.a, t.b) for t in lst)
ok = True
for key in queries_dict:
a = sort_key(result_independent[key])
b = sort_key(result_shared[key])
c = sort_key(result_tagged[key])
same = (a == b == c)
ok = ok and same
print(f"{key}: 独立={len(a)} 共享={len(b)} 标签={len(c)} 一致={same}")
print("三种算法结果完全一致:", ok)
```
预期:三个查询都是 `一致=True`。
> 为什么比较前要排序?共享扫描的内层是按 `queries_dict` 的顺序遍历,而独立扫描是按查询逐个执行,产生结果列表的**顺序**可能不同;但集合内容必须相同。关系是无序集合,只有排好序才能逐项比较。
步骤 7:测量墙钟时间(会看到反直觉的结果)
```python
import time
def measure(fn, repeat=5):
out = []
for _ in range(repeat):
t0 = time.perf_counter()
fn()
out.append((time.perf_counter() - t0) * 1000)
return sorted(out)[len(out) // 2]
t_ind = measure(lambda: {k: scan(data, e["attribute"], e["constant"])
for k, e in queries_dict.items()})
t_sha = measure(lambda: shared_scan(data, queries_dict))
t_tag = measure(lambda: shared_scan_with_tagging(data, queries_dict))
print(f"独立扫描 {t_ind:8.2f} ms")
print(f"共享扫描 {t_sha:8.2f} ms")
print(f"共享扫描+标签 {t_tag:8.2f} ms")
```
请把结果填进 6.3 表。在多数 Python 环境里你会看到 `t_sha` 与 `t_ind` 接近,甚至 `t_sha > t_ind`。**这是正常的**,原因见 3.2 节。
把两种指标并排写出来,你就能看清矛盾所在:
```python
print(f"独立扫描: 读取 {400_000 * 3:,} 条,耗时 {t_ind:.2f} ms")
print(f"共享扫描: 读取 {400_000:,} 条,耗时 {t_sha:.2f} ms")
print(f"读取量降到 1/3,但耗时比为 {t_sha / t_ind:.2f}")
```
步骤 8:让 I/O 占主导,重做对比
把"读一条元组"人为变贵——模拟一次真实的 I/O(例如加一段微小的固定开销),再看共享扫描是否反超。
```python
IO_COST_PER_TUPLE = 0.0 # 每条元组的模拟 I/O 开销(秒)
def simulated_io():
"""模拟读一条元组的 I/O 开销。用一段不会被优化掉的计算占位。"""
if IO_COST_PER_TUPLE <= 0:
return
deadline = time.perf_counter() + IO_COST_PER_TUPLE
while time.perf_counter() < deadline:
pass
def scan_io(data, attribute, constant):
res = []
for el in data:
simulated_io()
if el.__dict__[attribute] == constant:
res.append(el)
return res
def shared_scan_io(data, queries_dict):
res = {k: [] for k in queries_dict}
for el in data:
simulated_io()
for key, entry in queries_dict.items():
if el.__dict__[entry["attribute"]] == entry["constant"]:
res[key].append(el)
return res
for io_cost in (0.0, 2e-7, 1e-6):
IO_COST_PER_TUPLE = io_cost
ti = measure(lambda: {k: scan_io(data, e["attribute"], e["constant"])
for k, e in queries_dict.items()}, repeat=3)
ts = measure(lambda: shared_scan_io(data, queries_dict), repeat=3)
print(f"每条元组 I/O 开销 {io_cost * 1e6:5.2f} μs -> "
f"独立 {ti:8.1f} ms 共享 {ts:8.1f} ms 共享/独立 = {ts / ti:.2f}")
```
> 说明:`IO_COST_PER_TUPLE` 是一个模块级全局变量,改它就能调节模拟的 I/O 成本。数值 `2e-7` 秒 = 0.2 微秒,`1e-6` 秒 = 1 微秒。选多大的值、以及你机器上共享扫描在哪个值上反超,需自行实测确认。
本环境实测的一组结果:
```
每条元组 I/O 开销 0.00 μs -> 独立 168.5 ms 共享 245.1 ms 共享/独立 = 1.46
每条元组 I/O 开销 0.20 μs -> 独立 640.4 ms 共享 312.2 ms 共享/独立 = 0.49
每条元组 I/O 开销 1.00 μs -> 独立 1604.7 ms 共享 720.0 ms 共享/独立 = 0.45
```
读这张表:
• `io_cost = 0` 时比值 **> 1**:纯 CPU 场景,共享扫描更慢,与 3.2 节一致。
• `io_cost` 增大后比值降到 **远小于 1**:I/O 开始主导,共享扫描反超。
• 比值没有立刻降到 `1/3`,因为 CPU 那部分(`n·Q·t_cpu`)在两种做法里是**一样的**,无法被摊掉。继续增大 `io_cost`(例如 10 μs),比值会进一步趋近 `1/3`——这是理论下界。
这就是"真实数据库里 I/O 主导,所以共享扫描有意义"的直接演示。**你的数字需自行实测确认**,重点看比值随 `io_cost` 单调下降这个趋势。
步骤 9:单元测试(可选)
用多组随机查询做端到端一致性测试:
```python
import unittest
class SharedScanTest(unittest.TestCase):
def test_scans_end_to_end(self):
rnd = random.Random(7)
test_data = [Stuff(rnd.randint(0, 20), rnd.randint(0, 20))
for _ in range(2000)]
for _ in range(5):
qs = {f"q{i}": {"attribute": "a" if rnd.random() < 0.5 else "b",
"constant": rnd.randint(0, 20)}
for i in range(10)}
r1 = {k: scan(test_data, e["attribute"], e["constant"])
for k, e in qs.items()}
r2 = shared_scan(test_data, qs)
r3 = join_result_list_with_bit_lists(
shared_scan_with_tagging(test_data, qs))
for k in qs:
self.assertEqual(sort_key(r1[k]), sort_key(r2[k]))
self.assertEqual(sort_key(r1[k]), sort_key(r3[k]))
unittest.main(argv=["ignored", "-v"], exit=False)
```
六、观测记录表
6.1 基本信息
| 项目 | 值 |
| --- | --- |
| 元组条数 `n` | 400,000 |
| 查询个数 `Q` | 3 |
| 属性 `a` / `b` 的取值范围 | 0 ~ 100(共 101 个值) |
| 每个查询的理论期望命中数 | `400,000 / 101 ≈ 3,960` |
6.2 读取元组次数(核心指标)
| 做法 | 理论值 | 实测值 | 相对独立扫描的倍数 |
| --- | ---: | ---: | ---: |
| 独立扫描(扫 3 遍) | `n·Q = 1,200,000` | | 1.00 |
| 共享扫描(分列表) | `n = 400,000` | | |
| 共享扫描(打标签) | `n = 400,000` | | |
本机实测:独立扫描读取 **1,200,000** 次,共享扫描读取 **400,000** 次,正好是 `1/3`。
6.3 墙钟时间(会让人意外的指标)
| 做法 | 实测 ms | 你的实测值 | 相对独立扫描的倍数 |
| --- | ---: | ---: | ---: |
| 独立扫描 | | | 1.00 |
| 共享扫描(分列表) | | | |
| 共享扫描(打标签) | | | |
结论填写:
```
共享扫描的墙钟时间 ______(更短 / 接近 / 更长),
但读取元组次数降到 ______。
原因是:______________________________________________
```
6.4 结果一致性
| 查询 | 独立扫描条数 | 共享扫描条数 | 打标签条数 | 三者一致 |
| --- | ---: | ---: | ---: | --- |
| q1 (`a=7`) | | | | |
| q2 (`b=32`) | | | | |
| q3 (`b=1`) | | | | |
本机实测的条数为 `[4006, 3992, 3927]`;本指导书代码(`seed=42`)给出 `[3982, 3979, 3940]`。三种算法给出的结果**完全一致**。
6.5 打标签版本的内存占用
| 指标 | 值 | 说明 |
| --- | ---: | --- |
| `Σ_q \|结果_q\|` | | 分列表版本的条目总数 |
| `\|∪_q 结果_q\|` | | 打标签版本的条目总数 |
| 两者之差 | | 等于"被多个查询共同命中的元组"的重复计数 |
6.6 模拟 I/O 开销的影响
| 每条元组 I/O 开销 | 独立扫描 ms | 共享扫描 ms | 共享/独立 |
| --- | ---: | ---: | ---: |
| 0.0 μs | | | |
| 0.2 μs | | | |
| 1.0 μs | | | |
| 你测出的反超点 | | | |
七、思考题
1. 本实验中,三个查询的选择性各是多少?用实测条数算出来(保留 4 位小数)。
2. 为什么共享扫描把读取元组次数降到 `1/Q`,但**谓词求值次数一点没省**?请用公式说明。
3. 打标签版本(算法三)在什么情况下比分列表版本(算法二)更省内存?什么情况下更费?给出判据。
4. 本实验测出共享扫描的墙钟时间不比独立扫描短。请列出至少三条具体原因。
5. 既然如此,为什么真实数据库仍然采用共享扫描?请结合 2.3 节的代价公式说明。
6. 如果三个查询的选择性分别是 0.5、0.5、0.5(各命中一半),共享扫描还划算吗?请分别就"读取量"和"结果内存"两个维度讨论。
7. 把共享扫描与实验 09 的代码生成结合起来会怎样?描述生成的代码长什么样。
8. 如果这 `Q` 个查询不是同时到达,而是在扫描过程中陆续到达(第 100,000 条时才来第 4 个查询),共享扫描该怎么处理?这带来什么新问题?
9. `scan()` 用 `el.__dict__[attribute]` 做动态属性查找。改成什么样能让它更快?给出代码并说明代价。
八、思考题答案与解析
8.1 题 1
选择性定义 `σ_q = |结果_q| / n`,`n = 400,000`。
用本指导书代码(`seed=42`)的实测条数 `[3982, 3979, 3940]`:
```
σ(q1) = 3982 / 400000 = 0.0099550
σ(q2) = 3979 / 400000 = 0.0099475
σ(q3) = 3940 / 400000 = 0.0098500
```
用本机另一次实测 `[4006, 3992, 3927]`:
```
σ(q1) = 4006 / 400000 = 0.0100150
σ(q2) = 3992 / 400000 = 0.0099800
σ(q3) = 3927 / 400000 = 0.0098175
```
理论期望 `1/101 ≈ 0.0099010`。两组实测都在它附近波动,偏差来自随机抽样。
用代码直接算:
```python
for k, v in result_independent.items():
print(k, len(v), round(len(v) / n, 6))
```
8.2 题 2
读取元组次数:
• 独立扫描:外层 `Q` 次,每次内层 `n` 次 → `n · Q`。
• 共享扫描:只有一层 `n` 次的循环 → `n`。
比值 `n / (n·Q) = 1/Q`。
谓词求值次数:
• 独立扫描:每条元组对每个查询判一次 → `n · Q`。
• 共享扫描:每条元组在内层 `for q in queries` 里对每个查询判一次 → `n · Q`。
**完全一样**。因为"要判断这条元组属于哪些查询"这个工作量,与"分几遍扫"无关:`Q` 个查询 × `n` 条元组 = `n·Q` 次判断,这是信息论意义上的下界(除非用索引或谓词间的蕴含关系做剪枝)。
所以共享扫描省的是**数据读取**,不是**计算**。理解这一点,就能理解为什么在 CPU 主导的场景(本实验的 Python 实现)里它不快。
8.3 题 3
记:
• `S = Σ_q |结果_q|`:分列表版本的条目总数(含重复)。
• `U = |∪_q 结果_q|`:打标签版本的条目总数(去重)。
• 每条标签的开销为 `c`(一个字符串引用的列表)。
分列表版本占用 ≈ `S`。
打标签版本占用 ≈ `U + U·c`(每条元组一份,外加标签列表)。
判据:
• **更省**:当查询之间结果重叠大,即 `U << S`。极端情况:三个查询结果完全相同,`U = S/3`,打标签版本省 `2/3`。
• **更费**:当查询结果几乎不相交,`U ≈ S`,此时打标签版本还要多付 `U·c` 的标签开销,反而更占。
• 临界条件:`U · (1 + c) < S`,即重叠度 `S/U > 1 + c`。
本实验实测:`S = 11901`,`U = 11825`,`S/U ≈ 1.0064`,远小于 `1 + c`。所以**本实验里打标签版本的内存优势微乎其微**,因为三个查询分别作用在 `a` 和 `b` 两个不同属性上,结果几乎不相交(只有 76 条元组同时命中)。
想看到打标签版本的优势,构造重叠的查询集合,例如 `a=7`、`a=7`、`a=7`(三个相同的查询),此时 `S = 3 × 3982 = 11946`,`U = 3982`,比值 3.0,省 `2/3`。
8.4 题 4
至少四条:
1. **动态属性查找。** 共享扫描的内层是 `el.__dict__[entry["attribute"]] == entry["constant"]`,每条元组每个查询要做三次字典查找:`entry["attribute"]` 一次、`el.__dict__[...]` 一次、`entry["constant"]` 一次。独立扫描里 `el.a == 7` 是一次直接属性访问加一个字面常量。
2. **多一层循环。** 共享扫描是嵌套两层 `for`,内层 `for key, entry in queries_dict.items()` 每元组要建立一次迭代器并做一次元组解包。
3. **读取本身太便宜。** 本实验的"读取一条元组"就是遍历 Python 列表取一个元素,成本约几十纳秒;而共享扫描多出来的动态查找成本是同等量级的几倍。省下的读取根本抵不过多出的查找。
4. **结果 append 的对象不同。** 打标签版本还要为每条命中元组新建一个 `Result` 对象并建一个标签列表,多两次对象分配。分列表版本虽然不用建新对象,但内层循环本身的开销已经吃掉了收益。
用代码验证第 1 点:
```python
import timeit
el = data[0]
entry = queries_dict["q1"]
number = 300_000
for label, fn in [
("直接属性 + 字面常量 ", lambda: el.a == 7),
("__dict__ + 字面常量 ", lambda: el.__dict__["a"] == 7),
("完整动态查找 ", lambda: el.__dict__[entry["attribute"]]
== entry["constant"]),
]:
t = timeit.timeit(fn, number=number)
print(f"{label} {t / number * 1e9:6.1f} ns/次")
```
在本环境(CPython 3.13 + Windows)实测,三者依次约为 43 ns、72 ns、95 ns 每次,完整动态查找约为直接访问的 2 倍多。**你的数字需自行实测确认**,重点看相对关系。
注意:第一次调用 `timeit` 常带有预热效应,若结果反常(例如动态比直接还快),把 `number` 调大或调换测量顺序再试。
8.5 题 5
把 2.3 节的代价公式写开:
```
Cost(独立扫描) = Q · P · (t_seek + t_transfer) + n·Q · t_cpu
Cost(共享扫描) = 1 · P · (t_seek + t_transfer) + n·Q · t_cpu
```
两式相减:
```
Cost(独立) − Cost(共享) = (Q − 1) · P · (t_seek + t_transfer) > 0
```
**第二项(CPU)完全相同,被抵消了。** 也就是说:只要页面数 `P > 0`,共享扫描一定不亏;`P` 越大越赚。
而 `t_seek + t_transfer` 比 `t_cpu` 大 3 到 6 个数量级,所以第一项的节省是决定性的。
本实验之所以看不出这个优势,是因为本实验的"读取"没有 `(t_seek + t_transfer)` 这一项——数据已经在内存里了。步骤 8 人为加上这一项后,就能看到共享扫描反超。
这也解释了为什么共享扫描在真实系统(MySQL、PostgreSQL、SQL Server 的 scan sharing / QPop 类优化)里是标准配置,而在内存型教学 DBMS 里看不出效果。
8.6 题 6
设 `n = 1,000,000`,`Q = 3`,三个查询各命中一半(`σ = 0.5`),且结果相互独立随机。
**读取量维度**:
• 独立扫描:`n·Q = 3,000,000`。
• 共享扫描:`n = 1,000,000`。
• 读取量仍然降到 `1/3`。**共享扫描在读取维度上永远划算**,与选择性无关——因为选择性只影响"输出多少",不影响"要读多少"。
**结果内存维度**:
• 分列表版本:`S = 3 × 500,000 = 1,500,000` 个元组引用。
• 打标签版本:`U = |∪|`。三个各占一半的随机集合,并集比例为 `1 − (1−0.5)³ = 1 − 0.125 = 0.875`,即 `U = 875,000`。
• 比值 `S/U = 1,500,000 / 875,000 ≈ 1.71`。此时打标签版本明显更省。
结论:**选择性越高(结果越大、重叠越多),打标签版本的内存优势越明显;选择性越低,两种版本内存接近,分列表版本反而更简单。**
另外要注意:选择性高也意味着结果总量接近 `n`,此时真正的问题变成"结果太大放不下",共享扫描省下的 I/O 收益会被结果物化的开销吃掉。真实系统在这种情况下会考虑流水线执行而非物化。
8.7 题 7
把 `Q` 个谓词全部内联进同一个循环体。生成的代码大致是:
```python
result_q1 = list()
result_q2 = list()
result_q3 = list()
for tup in data:
if tup.a == 7:
result_q1.append(tup)
if tup.b == 32:
result_q2.append(tup)
if tup.b == 1:
result_q3.append(tup)
```
对比解释执行:
• 没有 `queries_dict` 的字典查找,常量 `7`、`32`、`1` 都是字面量;
• 没有 `el.__dict__[...]` 的动态查找,`tup.a` 是编译期确定的属性访问;
• 没有 `for q in queries` 这一层循环,`Q` 个 `if` 被展开。
这正是实验 09 里 `compile(emit)` 的套路:把父算子的代码片段往下传,`Filter` 把自己的谓词包成一个 `if`。多个查询就是多个 sink,各自把 `result_qN.append(tup)` 作为 `emit` 传下去,最终融合成一个循环。
按这条路走,共享扫描在 CPU 上也能真正赢过独立扫描。扩展任务 9.3 就做这件事。
8.8 题 8
这就是所谓的**动态共享扫描 / QPop** 问题。三种处理策略:
| 策略 | 做法 | 代价 | 问题 |
| --- | --- | --- | --- |
| 不加入 | 新查询等下一轮扫描 | 延迟高 | 简单,但要等很久 |
| 立即加入 | 新查询从当前位置开始参与,只拿到后半段结果 | 结果不完整 | 必须由上层补扫前 `k` 条 |
| 回绕补扫 | 新查询加入后,扫描到表尾再回头补扫前 `k` 条 | 多读 `k` 条 | 需要记住起点,实现复杂 |
带来的新问题:
1. **结果不完整**是最难处理的:调用者拿到的是"部分结果",需要额外的机制(补扫、或声明只返回部分)来保证语义正确。
2. **谓词集合动态变化**:如果查询可以在扫描中途加入或退出,代码生成就不能在扫描开始前一次性完成,需要支持增量重编译或回退到解释执行。
3. **公平性**:晚到的查询只享受了部分共享,如何计费/调度是调度器层面的问题。
4. **事务隔离**:不同查询可能在不同的快照(snapshot)上跑,能否共享扫描取决于 MVCC 版本是否一致。
真实系统(如 QPop)会估算"新查询加入的收益 vs 补扫的代价",动态决定是否加入。
8.9 题 9
改成**编译期绑定属性的闭包**:
```python
def make_pred(attribute: str, constant: int):
"""返回一个绑定了属性名与常量的谓词闭包。"""
if attribute == "a":
return lambda el: el.a == constant
if attribute == "b":
return lambda el: el.b == constant
raise ValueError("unknown attribute: " + attribute)
def scan_fast(data, pred):
res = []
for el in data:
if pred(el):
res.append(el)
return res
preds = {k: make_pred(e["attribute"], e["constant"])
for k, e in queries_dict.items()}
result_fast = {k: scan_fast(data, p) for k, p in preds.items()}
```
为什么更快:属性名 `attribute` 在 `make_pred` 里被"固化"成 `el.a` 或 `el.b` 这种直接访问,循环里不再查字典;`constant` 变成闭包里的局部变量,比字典查找快。
**代价**:
1. 要预先枚举所有可能的属性名(上面的 `if attribute == "a"`),属性多了就难维护。可以用 `operator.attrgetter(attribute)` 自动生成一次 getter:
```python
from operator import attrgetter
def make_pred2(attribute: str, constant: int):
get = attrgetter(attribute) # 只构造一次
return lambda el: get(el) == constant
```
`attrgetter` 是 C 实现,比 `el.__dict__[attribute]` 快,但仍然是一次函数调用。
2. 闭包本身是一次 Python 函数调用,仍有调用开销。要彻底消除,只能上代码生成(见 8.7 题)。
3. 牺牲了灵活性:`make_pred` 只支持已知的属性名,动态属性(例如 schema 变更)需要重建闭包。
九、扩展任务(选做)
9.1 改变查询个数 Q
把 `Q` 从 3 改成 10、50,重跑步骤 3–5,验证"读取量比值恒等于 `Q`"。同时观察墙钟时间比值如何变化——`Q` 越大,共享扫描的 CPU 劣势越明显(内层循环变长),但读取优势也越大。
9.2 用位图标签
把标签列表换成一个整数,第 `i` 位表示是否命中第 `i` 个查询:
```python
def shared_scan_bitmap(data, queries):
"""queries: [(attribute, constant), ...],返回 [(tuple, bitmask), ...]"""
res = []
for el in data:
mask = 0
for i, (attr, const) in enumerate(queries):
if el.__dict__[attr] == const:
mask |= (1 << i)
if mask:
res.append((el, mask))
return res
def decode_bitmap(tagged, nq):
out = {i: [] for i in range(nq)}
for el, mask in tagged:
for i in range(nq):
if mask & (1 << i):
out[i].append(el)
return out
```
比较它与原版打标签实现的内存占用(`sys.getsizeof`)与耗时。位图版本在 `Q ≤ 64` 时打包成一个整数,标签开销极小。
9.3 用代码生成融合多个谓词
按 8.7 题的思路,写一个 `compile_shared_scan(queries)`,生成源码后 `exec`:
```python
def compile_shared_scan(queries):
lines = ["def run(data):"]
for i in range(len(queries)):
lines.append(f" r{i} = list()")
lines.append(" for tup in data:")
for i, (attr, const) in enumerate(queries):
lines.append(f" if tup.{attr} == {const}:")
lines.append(f" r{i}.append(tup)")
lines.append(" return [" + ", ".join(f"r{i}" for i in range(len(queries))) + "]")
return "\n".join(lines)
src = compile_shared_scan([("a", 7), ("b", 32), ("b", 1)])
print(src)
ns = {}
exec(src, ns)
res = ns["run"](data)
print([len(r) for r in res])
```
与算法一、二、三对比耗时,看它是否能在 CPU 上也赢。
9.4 按属性分组优化
`q2` 与 `q3` 都作用在属性 `b` 上。可以先把 `b` 取出来一次,再比较两次:
```python
def shared_scan_grouped(data, queries_by_attr):
res = {k: [] for k in queries_by_attr}
for el in data:
for attr, consts in queries_by_attr.items():
v = el.__dict__[attr] # 每个属性只取一次
for key, c in consts:
if v == c:
res[key].append(el)
return res
```
`queries_by_attr = {"a": [("q1", 7)], "b": [("q2", 32), ("q3", 1)]}`。当同一属性上有多个谓词时,这样能省掉重复的属性查找。
9.5 增加规模
把 `n` 提到 `4,000,000`,重复步骤 3–7,看墙钟时间的比值是否变化(理论上不会,因为比值由每条元组的相对开销决定,与 `n` 无关)。用实测验证。
十、常见错误与排查
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| 三种算法的结果"不一致" | 直接比较列表,但生成顺序不同 | 比较前排序(步骤 6 的 `sort_key`) |
| `读取元组次数` 不是 1,200,000 / 400,000 | 计数器忘了 `reset_counter()`,或加在了错误的位置 | 每次算法前 `reset_counter()`;计数器加在**取元组**的循环里,不是谓词成立时 |
| 计数器加了但数值不变 | `tuples_read` 在函数内被当成局部变量 | 在函数开头写 `global tuples_read` |
| 打标签版本的条目数与 `Σ` 相等 | 三个查询结果不相交,这是正常的 | 见 8.3 题;想看到差异就构造重叠查询 |
| 共享扫描比独立扫描慢,以为代码错了 | 正常现象,见 3.2 节 | 改看"读取元组次数"这个指标;或跑步骤 8 加上模拟 I/O |
| `RecursionError` 或内存不足 | `n` 太大,`Stuff` 对象开销高 | 减小 `n`;或改用元组列表 `[(a, b), ...]` 代替 dataclass |
| 步骤 8 的循环跑不完 | `IO_COST_PER_TUPLE` 设得太大,总时间 = `n·Q·io_cost` | 从 `2e-7`(0.2 微秒)起步;先估算 `400000 × 3 × io_cost` |
| `NameError` 于步骤 8 的辅助函数 | 函数定义在调用之后就执行了代码 | 按步骤顺序从上到下执行;辅助函数要定义在使用之前 |
| `TypeError` 于 `Stuff` 的构造 | 属性顺序或类型不对 | `Stuff(a, b)` 两个位置参数都是 `int` |
| 每次运行结果条数不同 | 没固定随机种子 | `random.seed(42)`;单元测试里用独立的 `random.Random(7)` 实例 |
| `unittest.main` 阻塞或报错 | 在 Jupyter 里默认参数会读取 `sys.argv` | 用 `unittest.main(argv=["ignored", "-v"], exit=False)` |
| 误以为缓冲池让共享扫描失去意义 | 见 2.4 节 | 表大于缓冲池时命中率极低;共享扫描提供的是确定性收益 |
| 用 `el.__dict__[attr]` 时属性名拼错 | 运行时不会报错,只会返回空结果 | 打印 `data[0].__dict__.keys()` 核对属性名 |
附:本实验引用的概念与出处
| 条目 | 说明 |
| --- | --- |
| 全表扫描 | 无索引时逐条读取并判定谓词,读取元组次数 = `n` |
| 选择性 σ | `σ_q = \|结果_q\| / n`,无量纲,取值 `[0, 1]` |
| 共享扫描 | 多个查询共用一次表扫描,读取元组次数从 `n·Q` 降到 `n` |
| 打标签(tagging) | 命中的元组只存一份,用标签列表/位掩码标明它属于哪些查询 |
| 代价模型 | `Cost = P·(t_seek + t_transfer) + n·t_cpu`;`t_seek` 比 `t_cpu` 大 3~6 个数量级 |
| 缓冲池 | 内存中的页面缓存;表大于缓冲池时重复扫描无法靠缓存避免 |
| 动态共享扫描 | 查询在扫描中途加入的处理问题,需补扫或声明部分结果 |