实验 11:外部归并排序
> 当一张表大到装不进内存时,如何用有限的缓冲页把它排好序,并把代价用"读写了多少页"算清楚。
一、实验目标
1. 理解为什么数据库的排序不能只靠内存排序算法(如快排、归并排序的数组版本)。
2. 掌握外部归并排序(external merge sort)的两个阶段:顺串生成(run generation)与多路归并(multi-way merge)。
3. 会用页数(page)而不是记录条数来估算 I/O 代价,能用公式算出归并趟数与总 I/O。
4. 能亲手实现一个带 I/O 计数的外部归并排序,并把实测值与理论公式对上。
5. 理解缓冲页数 B 对代价的影响:内存不是越多越线性变好,趟数是阶梯式下降的。
二、预备知识
2.1 页(page)、块(block)与缓冲页(buffer frame)
磁盘(含 SSD)与内存之间交换数据的最小单位叫**页**(page),也叫块(block)。一条记录可能只有几十字节,但读写一次至少是一整页。因此数据库里算 I/O 代价时,单位是"页",不是"条"。
• N:待排序数据占的页数(本实验 N = 500 页)。
• 页大小(page size):一页能放多少条记录(本实验每页 100 条)。
• B:排序可用的**缓冲页数**(buffer frames)。这是本实验里最稀缺的资源。
数据库管理系统(DBMS)会在内存中划出一小块区域作为缓冲区(buffer pool),排序只能使用其中的 B 页。超出这个额度就必须把中间结果写到磁盘上。
2.2 顺串(run)
**顺串**(run)指一段已经排好序的记录序列。顺串可以放在内存里,也可以写在磁盘上。外部排序的第一阶段就是把原始数据切成若干段,每段装进 B 页内存、排好、写回磁盘,得到若干个有序顺串。
原始数据(未排序):
```
[ 7 3 9 1 5 8 2 6 4 ] 共 9 条,假设 B 页内存一次只能装 3 条
```
切成 3 段各自排序后:
```
run1 = [1 3 7] run2 = [2 5 8] run3 = [4 6 9]
```
2.3 趟(pass)与归并路数(fan-in)
**趟**(pass):把全部数据从头到尾读一遍、再写一遍,算一趟。
**归并路数**(fan-in):一趟归并中能同时合并多少个顺串。归并时需要给每个输入顺串留一页输入缓冲,还要给输出留至少一页输出缓冲,所以:
```
fan_in = B − 1
```
这就是"留 1 页输出"的经典约定(出处:CMU 15-445 课程 "Sorting & Aggregations" 讲义)。
```
输入缓冲(B−1 页) 输出缓冲(1 页)
┌────┬────┬────┬────┐ ┌──────────┐
│run1│run2│run3│run4│ ── k 路归并 ──▶ │ 输出页 │ ── 写满一页就落盘
└────┴────┴────┴────┘ └──────────┘
B = 5 页 → fan_in = 4
```
2.4 k 路归并怎么做到"每趟只读一次"
k 路归并用一个大小 k 的**小顶堆**(min-heap):每路顺串取当前最小的一条入堆,反复弹出堆顶(全局最小)写入输出,再从对应顺串补一条进堆。Python 标准库 `heapq.merge` 就是这个算法的现成实现。
2.5 I/O 代价模型
本实验采用教学口径的计数规则:
• 从磁盘**读**一页记 1 次 I/O;
• 向磁盘**写**一页记 1 次 I/O;
• 顺序读写与随机读写在本实验中不做区分(真实系统里随机 I/O 贵得多,这也是排序优于"建索引后随机查找"的原因之一)。
一整趟归并的代价 = 读入全部 N 页 + 写出全部 N 页 = **2N**。
三、实验原理
3.1 两个阶段
**阶段 1:顺串生成(run generation)**
```
磁盘(N 页原始数据) 内存缓冲区(B 页) 磁盘(顺串文件)
┌────────────────────┐ ┌───┬───┬───┬───┐ ┌──────────────────┐
│ 页1 页2 … 页N │ 读▶ │ │ │ │ │ 排好写▶ │ run1 run2 … runK │
└────────────────────┘ └───┴───┴───┴───┘ └──────────────────┘
K = ⌈N / B⌉ 个顺串
```
每读满 B 页就排序并写回,得到 K = ⌈N/B⌉ 个顺串。这一阶段读写各一遍全部数据,代价 **2N**。
**阶段 2:多路归并(merge)**
每一趟把 fan-in = B−1 个顺串合并成 1 个,顺串数量按 (B−1) 倍缩减:
```
K → ⌈K/(B−1)⌉ → ⌈K/(B−1)²⌉ → … → 1
```
每趟代价 2N。
3.2 代价公式
归并趟数(不含阶段 1 的生成趟,通常把阶段 1 记作第 1 趟):
```
总趟数 P = 1 + ⌈ log_{B−1} ⌈N/B⌉ ⌉
总 I/O = 2N × P
```
符号说明:
| 符号 | 含义 | 单位 |
|---|---|---|
| N | 待排序数据的页数 | 页 |
| B | 排序可用缓冲页数 | 页 |
| B−1 | 归并路数 fan-in(留 1 页输出) | 个顺串/趟 |
| ⌈N/B⌉ | 阶段 1 生成的顺串个数 | 个 |
| P | 总趟数(含顺串生成那一趟) | 趟 |
| 2N | 一整趟的 I/O(读 N 页 + 写 N 页) | 页 |
出处:CMU 15-445 "Sorting & Aggregations" 讲义。
**公式的省略项**:如果排序结果是直接流式(streaming)交给上层算子(比如 Top-k、聚合),最后一趟的输出可以不必落盘,能省掉 N 次写。本实验为了与公式对齐,把最后一趟的写回也计算在内。
3.3 内存多大才算够
PostgreSQL 的 `work_mem` 参数(官方文档 19.4 Resource Consumption)默认 4 MB,它是每个排序/哈希操作在落盘前的内存上限。把它换算成页数就能直接套用上面的公式:内存越大 → 顺串越少 → 归并趟数越少 → I/O 越少。
3.4 本实验的实测结论
下图是本机实测结果:横轴是缓冲页数 B,左图对比"真实执行的 I/O 页数"与"公式值",右图是归并趟数随 B 的变化。可以看到两条柱子完全重合,说明教学模型与公式是自洽的。

四、实验环境与准备
• 操作系统:Windows 11(本指导书所有数字在 CPython 3.13 + Windows 上实测获得)
• Python ≥ 3.9;需要 `numpy`(造数据)、`heapq`(标准库,多路归并)、`math`(标准库,向上取整)
• 可选:`pandas`、`matplotlib`(画观测图)
• 本仓库目录(用于选做部分):`system/sorting.py` 提供 `RunGenerator`、`SingleStreamMerge`、`ExternalMergeSort` 三个类;`system/queues/` 提供 `external_queues.py`(落盘队列)、`list_queues.py`(内存队列)与 `queue_factories.py`(队列工厂)。
安装依赖(如已安装可跳过):
```bash
python -m pip install numpy pandas matplotlib
```
准备实验数据目录:把本指导书配套的代码存成一个 `.py` 文件,例如 `lab11_extsort.py`,在仓库根目录下运行,这样选做部分才能 `import system`。
五、实验步骤
步骤 1:造一份"大到装不下"的数据
本实验的规模:50,000 条整数,按每页 100 条计,共 **N = 500 页**。这个规模在 Python 里跑得快,同时又能体现出多趟归并。
```python
import numpy as np
PAGE_SIZE = 100 # 每页 100 条记录
N_PAGES = 500 # 数据共 500 页
rng = np.random.default_rng(0) # 固定随机种子,保证可复现
data = rng.integers(0, 10**9, N_PAGES * PAGE_SIZE).tolist()
print("记录条数:", len(data))
print("数据页数:", -(-len(data) // PAGE_SIZE))
```
步骤 2:算法 1 —— 内存内排序(对照组)
先看看"如果能全装进内存"该怎么做,作为外部排序的对照基准。
```python
sorted_in_memory = sorted(data)
print(sorted_in_memory[:5])
```
内存内排序的代价是 O(n log n) 次比较,几乎不产生磁盘 I/O;但它的前提是内存装得下。真实的大表排序(几十 GB)根本没有这个前提。
步骤 3:搭一个按页计数的 I/O 计数器
真实的磁盘读写我们用一个计数器模拟:读一页 `+1`,写一页 `+1`。
```python
import math
class IOCounter:
"""按页计数:读一页 +1,写一页 +1。"""
def __init__(self):
self.read_pages = 0
self.write_pages = 0
def read(self, n_tuples):
pages = math.ceil(n_tuples / PAGE_SIZE)
self.read_pages += pages
return pages
def write(self, n_tuples):
pages = math.ceil(n_tuples / PAGE_SIZE)
self.write_pages += pages
return pages
@property
def total(self):
return self.read_pages + self.write_pages
```
步骤 4:实现外部归并排序(带 I/O 计数)
关键点:阶段 1 按 B 页切块;阶段 2 每趟最多合并 `B−1` 个顺串。
```python
import heapq
def external_merge_sort(data, B, counter):
"""外部归并排序。返回 (排序结果, 归并趟数, 阶段1生成的顺串数)。
:param data: 待排序的记录列表
:param B: 可用缓冲页数
:param counter: IOCounter 实例
"""
# ------- 阶段 1:顺串生成 -------
runs = []
for start in range(0, len(data), B * PAGE_SIZE):
chunk = data[start:start + B * PAGE_SIZE]
counter.read(len(chunk)) # 读入这一块(B 页)
chunk.sort() # 在内存中排序
counter.write(len(chunk)) # 写回一个顺串
runs.append(chunk)
n_runs_initial = len(runs)
# ------- 阶段 2:多路归并 -------
fan_in = max(2, B - 1) # B=2 时兜底,保证至少能两两归并
passes = 1 # 阶段 1 记作第 1 趟
while len(runs) > 1:
new_runs = []
for i in range(0, len(runs), fan_in):
group = runs[i:i + fan_in]
for r in group:
counter.read(len(r)) # 读入待归并的顺串
merged = list(heapq.merge(*group)) # k 路归并(小顶堆)
counter.write(len(merged)) # 写回合并结果
new_runs.append(merged)
runs = new_runs
passes += 1
return (runs[0] if runs else []), passes, n_runs_initial
```
步骤 5:对不同的 B 跑一遍,填观测表
```python
Bs = [3, 5, 9, 17, 33, 65]
rows = []
for B in Bs:
counter = IOCounter()
out, passes, n_runs = external_merge_sort(list(data), B, counter)
assert out == sorted(data), "排序结果不正确!" # 正确性校验
N = math.ceil(len(data) / PAGE_SIZE)
formula = 2 * N * (1 + math.ceil(math.log(max(2, math.ceil(N / B)), max(2, B - 1))))
rows.append({
"缓冲页数 B": B,
"初始顺串数 ⌈N/B⌉": n_runs,
"fan-in (B−1)": max(2, B - 1),
"实测趟数": passes,
"公式趟数": 1 + math.ceil(math.log(max(2, math.ceil(N / B)), max(2, B - 1))),
"实测 I/O 页数": counter.total,
"公式 I/O 页数": formula,
})
for r in rows:
print(r)
```
本机实测输出(N = 500 页,每页 100 条):
| 缓冲页数 B | 初始顺串数 ⌈N/B⌉ | fan-in | 趟数 | 实测 I/O 页数 | 公式 I/O 页数 |
|---|---|---|---|---|---|
| 3 | 167 | 2 | 9 | 9000 | 9000 |
| 5 | 100 | 4 | 5 | 5000 | 5000 |
| 9 | 56 | 8 | 3 | 3000 | 3000 |
| 17 | 30 | 16 | 3 | 3000 | 3000 |
| 33 | 16 | 32 | 2 | 2000 | 2000 |
| 65 | 8 | 64 | 2 | 2000 | 2000 |
实测与公式完全一致。
步骤 6:把结果画出来(可选)
```python
import matplotlib.pyplot as plt
io_real = [r["实测 I/O 页数"] for r in rows]
io_formula = [r["公式 I/O 页数"] for r in rows]
passes = [r["实测趟数"] for r in rows]
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
fig, axes = plt.subplots(1, 2, figsize=(12, 4.6))
x = range(len(Bs))
w = 0.38
axes[0].bar([i - w / 2 for i in x], io_real, w, label="实测 I/O")
axes[0].bar([i + w / 2 for i in x], io_formula, w, label="公式 I/O")
axes[0].set_xticks(list(x))
axes[0].set_xticklabels([f"B={b}" for b in Bs])
axes[0].set_ylabel("I/O 页数")
axes[0].set_title("实测与公式一致")
axes[0].legend()
axes[0].grid(axis="y", ls=":", alpha=0.4)
axes[1].plot(Bs, passes, "o-", color="#C00000")
axes[1].set_xscale("log")
axes[1].set_xlabel("缓冲页数 B(对数轴)")
axes[1].set_ylabel("归并趟数")
axes[1].set_title("趟数随 B 阶梯式下降")
axes[1].grid(ls=":", alpha=0.4)
plt.tight_layout()
plt.show()
```
步骤 7:自己改参数再看一次
把 `PAGE_SIZE`、`N_PAGES`、`Bs` 各改一遍,重新跑步骤 5。重点观察两件事:
1. 实测 I/O 是否始终等于公式值;
2. 趟数是否出现"内存翻倍但代价不变"的平台(例如上表中 B=9 与 B=17 都是 3 趟)。
步骤 8(选做):用仓库里的真实实现跑一遍
仓库 `system/sorting.py` 里的 `ExternalMergeSort` 用的是真实的落盘队列(`system/queues/external_queues.py`),不再是我们这个"内存列表模拟磁盘"的教学模型。可以用内存队列(`list_queues.py`)先跑通,再换成外部队列观察行为差异。
```python
import sys, pathlib
for _p in (pathlib.Path.cwd(), *pathlib.Path.cwd().parents):
if (_p / "system").is_dir():
sys.path.insert(0, str(_p))
break
from system.sorting import ExternalMergeSort
from system.queues.external_queues import ExternalQueueFactory
number_of_tuples_in_main_memory 相当于"内存里能放多少条",
它除以每页条数就对应实验里的缓冲页数 B
sorter = ExternalMergeSort(
input_data=iter(data[:2000]),
queue_factory=ExternalQueueFactory(),
number_of_tuples_in_main_memory=300,
fan_in=2,
)
print(list(sorter)[:5])
```
六、观测记录表
请把自己机器上的运行结果填入下表(前两列已给出本指导书编写时的实测值作为参照)。
| B | 初始顺串数 ⌈N/B⌉ | fan-in (B−1) | 实测趟数 | 公式趟数 | 实测 I/O 页数 | 公式 I/O 页数 | 是否一致 |
|---|---|---|---|---|---|---|---|
| 3 | 167 | 2 | 9 | 9 | 9000 | 9000 | |
| 5 | 100 | 4 | 5 | 5 | 5000 | 5000 | |
| 9 | 56 | 8 | 3 | 3 | 3000 | 3000 | |
| 17 | 30 | 16 | 3 | 3 | 3000 | 3000 | |
| 33 | 16 | 32 | 2 | 2 | 2000 | 2000 | |
| 65 | 8 | 64 | 2 | 2 | 2000 | 2000 | |
| (自定 B=____) | | | | | | | |
换一组数据量之后再填一次:
| 数据量(页) | B | 趟数 | 实测 I/O | 公式 I/O | 差值 |
|---|---|---|---|---|---|
| | | | | | |
| | | | | | |
七、思考题
1. 已知 N = 500 页、B = 3,请手算:阶段 1 生成多少个顺串?归并需要几趟?总 I/O 是多少页?
2. 归并路数为什么是 B−1 而不是 B?如果强行用 B 路归并会发生什么?
3. 上表中 B=9 和 B=17 的实测 I/O 都是 3000 页,为什么内存几乎翻倍了代价却没变?
4. 如果排序结果直接流式交给上层算子(最后一趟输出不落盘),总 I/O 能省多少?请以 N=500、B=5 为例计算。
5. 假设页大小 8 KB,排序可用内存按 PostgreSQL `work_mem` 默认值 4 MB 计算:缓冲页数 B 是多少?在"只需 2 趟"(即 P = 2)的前提下,最多能排多少页、约合多少 GB 的数据?
6. 阶段 1 如果用"替换选择"(replacement selection)代替简单的"装满 B 页再排序",顺串的平均长度会变成多少?这对总趟数有什么影响?
7. 输入数据本身已经有序时,外部归并排序的代价是多少?数据库里有什么办法能少做这次排序?
8. 写出 B=2 时的趟数与总 I/O 表达式;此时 `max(2, B−1)` 这个兜底为什么是必要的?
八、思考题答案与解析
**第 1 题**
⌈N/B⌉ = ⌈500/3⌉ = 167 个顺串。fan-in = B−1 = 2。
归并趟数 = ⌈log₂ 167⌉ = ⌈7.38⌉ = 8 趟(167→84→42→21→11→6→3→2→1)。
总趟数 P = 1 + 8 = 9。
总 I/O = 2N × P = 2 × 500 × 9 = **9000 页**。与实测表第一行一致。
**第 2 题**
归并时每个输入顺串都要占一页输入缓冲,否则无法预读、无法做 k 路堆归并。剩下的一页必须留给输出:输出页写满才能整页落盘,否则就要对磁盘做小于一页的随机写,代价更高甚至硬件不支持。所以 fan-in = B−1。若强行用 B 路归并,就没有输出缓冲区,输出只能"来一条写一条",实际会退化为大量小块写,性能远差于整页写。
**第 3 题**
因为趟数是`向上取整`的对数,属于阶梯函数:
B=9:⌈500/9⌉ = 56 个顺串,⌈log₈ 56⌉ = ⌈1.94⌉ = 2 → P = 3;
B=17:⌈500/17⌉ = 30 个顺串,⌈log₁₆ 30⌉ = ⌈1.23⌉ = 2 → P = 3。
两者都停在 3 趟,所以总 I/O 都是 2 × 500 × 3 = 3000 页。结论:加内存要加到"跨过台阶"才有收益,这就是调 `work_mem` 时常见的"加了没效果"现象。
**第 4 题**
B=5 时 P = 5,总 I/O = 5000 页。最后一趟是纯输出,若流式消费掉,就省掉最后一趟的 N 次写 = 500 页。
省后代价 = 5000 − 500 = **4500 页**,节省 10%。一般形式:总 I/O = 2N×P − N(最后一趟不落盘)。
**第 5 题**
B = 4 MB ÷ 8 KB = 4096 KB ÷ 8 KB = **512 页**。
P = 2 要求 ⌈log_{B−1} ⌈N/B⌉⌉ = 1,即 ⌈N/B⌉ ≤ B−1 = 511,所以 N ≤ 512 × 511 = **261,632 页**。
折算:261,632 × 8 KB = 2,093,056 KB ≈ 2044 MB ≈ **2 GB**。
也就是说 4 MB 的 `work_mem` 大约能把 2 GB 的数据在两趟内排完(这是纯 I/O 模型下的上界,实际还要扣掉页表等开销)。
**第 6 题**
替换选择(replacement selection)用一个大小等于内存容量的堆持续吐出最小元素,平均能生成长度约为 **2B 页**的顺串(简单切块法生成的顺串长度是 B 页)。
顺串数从 ⌈N/B⌉ 降到约 ⌈N/(2B)⌉,归并趟数变为 1 + ⌈log_{B−1} ⌈N/(2B)⌉⌉。
以 N=500、B=9 为例:顺串从 56 降到约 28,⌈log₈ 28⌉ = ⌈1.60⌉ = 2,仍是 3 趟——所以替换选择省的是"顺串数量",能否少一趟还要看有没有跨过台阶。
**第 7 题**
若输入已有序,阶段 1 可以只做一次**验证扫描**(检查相邻记录是否有序):读 N 页、不写,代价 N。若确认有序就直接交给上层,代价接近 **N 页**(只扫描一遍)。
数据库里真正的规避办法是:让数据一开始就按需要的顺序存放(聚簇索引 clustered index / 索引组织表),或者让上层算子使用索引扫描(index scan)直接拿到有序流,从而不做排序。
**第 8 题**
B=2 时 ⌈N/B⌉ = ⌈N/2⌉ 个顺串,fan-in 用兜底值 2:
P = 1 + ⌈log₂ ⌈N/2⌉⌉,总 I/O = 2N × P。
以 N=500 为例:⌈500/2⌉ = 250,⌈log₂ 250⌉ = 8,P = 9,总 I/O = 9000 页。
`max(2, B−1)` 是必要的:B=2 时 B−1 = 1,若 fan-in 取 1,归并循环会"每次只合并 1 个顺串",顺串数量永不减少,程序陷入死循环。
九、扩展任务(选做)
1. **替换选择(replacement selection)**:把步骤 4 的阶段 1 换成堆式替换选择,统计生成的顺串个数与平均长度,与"装满再排"对比。
2. **最后一趟不落盘**:实现流式输出版本,实测总 I/O 是否比公式少 N 页。
3. **随机 I/O 对比**:在计数模型里区分顺序页与随机页(顺序 1 次、随机按 `random_page_cost / seq_page_cost` 折算),观察 B 很小时随机 I/O 占比的变化。
4. **真实落盘**:用 `system/sorting.py` 的 `ExternalMergeSort` 配合 `ExternalQueueFactory`,把 100 万条数据真写到磁盘文件上排序,用系统监视器观察磁盘写入量。
5. **双缓冲(double buffering)**:思考如何在 B 页中预读下一块,让 CPU 排序与磁盘 I/O 重叠。这是真实 DBMS 提升外排序吞吐的常用手段,可用两个线程模拟。
十、常见错误与排查
| 现象 | 原因 | 处理 |
|---|---|---|
| 实测 I/O 与公式差 500 或 1000 页 | 最后一块数据不足 B 页,向上取整方式与公式假设不同 | 统一用 `math.ceil(len(chunk)/PAGE_SIZE)` 计数,公式里 N 也取 `ceil(总条数/页大小)` |
| 程序卡死不退出 | B=2 时 fan-in = B−1 = 1,每次只"合并"1 个顺串,数量不减少 | 归并路数写成 `max(2, B - 1)` |
| 排序结果不对 | 阶段 1 的切片排序后忘了把结果放回 `runs`,或用了原地排序的返回值(Python 的 `list.sort()` 返回 `None`) | 用 `chunk.sort()` 后 `append(chunk)`,不要用 `runs.append(chunk.sort())` |
| 归并后结果长度变少 | 手写的 k 路归并里有某一路提前结束就被丢弃 | 用 `heapq.merge(*group)`,k 路归并必须处理"某一路耗尽"的情况 |
| `assert out == sorted(data)` 失败但看不出哪里错 | 造数据时用了可变对象并在多处复用同一引用 | 每次 `external_merge_sort(list(data), ...)` 传入 `data` 的拷贝 |
| 加内存(调大 B)后 I/O 完全不变 | 趟数是阶梯函数,没跨过台阶 | 打印每一步的顺串数,确认 `⌈log_{B−1}⌉` 是否真的变小 |
| 选做部分 `ModuleNotFoundError: No module named 'system'` | 运行目录不在仓库根,或仓库根没进 `sys.path` | 在仓库根目录运行,或照步骤 8 的代码把仓库根插入 `sys.path` |
| 中文标签在图里显示为方框 | 缺中文字体 | 设置 `plt.rcParams["font.sans-serif"]`,或用英文标签 |