多路归并算法这个题目,我在实际工程项目里反复用过多次,每次优化都有新收获。尤其是处理那种几个GB、几十个GB的日志文件或数据库导出数据时,内存根本撑不住一次性排序,这时候外部排序就是唯一的选择,而多路归并又是外部排序里最核心、最值得花心思琢磨的环节。这一期技术7,我把自己在实现和优化多路归并过程中踩过的坑、试过的方案、实测过的数据一起整理出来,希望能给正在折腾大数据量排序的朋友一些参考。内容不空谈理论,尽量讲实操,所有细节都围绕“多路归并在外部排序中怎么落地、怎么调优”展开。
1. 外部排序整体套路与多路归并的定位
1.1 外部排序是什么,为什么不能直接sort
很多人第一次接触外部排序时,脑子里第一个疑问是:我直接用快排、归并排序或者数据库里的ORDER BY不就完了吗,为什么要搞这么麻烦?问题出在数据规模上。
假设你有一台16GB内存的服务器,但待排序的文件有50GB,你根本没法用常规排序算法把这些数据一次性读进内存。操作系统有虚拟内存机制,硬着头皮全读进去的话,会触发大量的页面换入换出,性能会惨到不能看。外部排序的核心思想其实特别朴素:把大文件拆成一个个能在内存里搞定的小块,每块排好序写回磁盘,最后再把多个有序的小块合并成一个完整的有序文件。这个“先分后合”的思路,和普通归并排序是一脉相承的,只是普通归并排序不涉及磁盘IO,而外部排序的时间和性能瓶颈几乎全在磁盘读写上。
外部排序整体流程大致分两个阶段。
阶段一是初始归并段生成,也常被叫作run生成。做法是从大文件里分批读入数据,每批数据量控制在内存能承受的范围内,然后用快排、堆排序或更优的置换选择算法生成一个有序子序列,写回磁盘。每写回一个有序子序列,就是一个归并段。
阶段二是多路归并,把所有已经有序的归并段同时打开,用某种策略找出当前所有段头元素里的最小值,依次输出到结果文件。这个过程一直持续到所有归并段里的元素都被取完为止。
如果整个过程只用两路归并,循环趟数会非常多。举例来说,如果有1000个初始归并段,用两路归并需要大约10轮才能合并完成,每轮都要把全部数据从磁盘读一遍再写一遍,也就是总共20次全量IO。但如果我们用32路归并,同样的1000个初始段只需要两轮就能合并完,IO总量直接少掉一大截。这就引出了多路归并的核心价值:用路数的增加换取IO轮数的减少。
1.2 为什么非要多路,两路慢慢合并不行吗
两路归并在原理上没有任何问题,实现起来还特别简单,每次从两个段里挑一个更小的写出去就行。但实际项目中,两路归并的代价是灾难性的。
给你算一笔实际账。假如一个大表导出后有64个初始归并段,每个段平均500MB,用两路归并需要6轮合并。每一轮都会把所有数据从磁盘读一遍、写一遍,一轮的IO量大约是64GB(32GB读+32GB写)。6轮下来就是384GB的IO量。如果磁盘吞吐是200MB/s,光是IO时间就要半小时以上。而如果改用8路归并,只需要2轮,IO量骤降到128GB,时间缩短到不到11分钟。这种差距在大数据场景下是决定性的。
所以多路归并的本质,就是用“同时比较更多路”的CPU开销,去换“更少的磁盘IO轮次”。在磁盘瓶颈远大于CPU瓶颈的场景里,这是一个稳赚不赔的交易。当然,路数也不能无限增加,这里面有一个权衡点,后面在参数选择部分我会详细讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多路归并的核心功臣:败者树
2.1 从堆到败者树:为什么堆不是最优选择
多路归并的关键动作就是反复找最小值。最朴素的做法是每次从K路中遍历一遍找最小,时间复杂度O(K),归并N个元素总代价O(N*K)。K大的时候这个代价很吓人。于是大家自然想到用堆来优化找最小值的过程,建一个大小为K的最小堆,每次取堆顶,然后从取出元素对应的那一路取下一个元素进堆,调整堆结构,时间复杂度O(logK),比线性扫描快得多。
那为什么还需要败者树?我一开始也觉得自己维护一个小顶堆就够了,直到实际对比测试才发现,堆在多路归并场景下有一个隐蔽的缺陷:堆调整时,新进来的元素需要从堆顶一路往下比较,和左右孩子都比较,每次调整涉及两次比较;而且堆的结构在频繁删除堆顶、插入新元素后,缓存局部性并不好,数据在数组里跳来跳去。
败者树则是一种专门为多路归并设计的树形结构,它的思路类似锦标赛:让所有参与者两两比赛,胜者继续向上比赛,败者记录在树的节点里,最终根节点记录的是冠军(最小值)。它的优势在于:每次调整只需要沿着从叶子到根的一条路径走,而且只需要和父节点比较一次,路径长度为logK,构建和调整的总开销比堆更小。更关键的是,败者树里新来的元素会沿着自己所在的路径往上挑战,它天然知道自己的兄弟是谁、父节点是谁,比较次数比堆少大约一半。
我在实际项目中测过同一批数据,K=64时,败者树比小顶堆的整体排序时间能快10%~15%。在归并元素数量上亿的场景里,这个提升非常可观,而且路数越大,差距越明显。
2.2 败者树完整实现与逐行讲解
直接上干货,我用C++实现一个典型的败者树。这段代码是外部排序归并阶段最核心的部分,我把注释写得非常详细。
cpp复制#include <vector>
#include <limits>
#include <cstdint>
// 败者树实现
class LoserTree {
private:
int k; // 归并路数
std::vector<int> ls; // 败者树内部节点,ls[0]存放冠军
std::vector<int64_t> leaf; // 叶子节点,leaf[i]存放第i路的当前值
std::vector<int> sourceIndex; // 记录叶子节点对应的输入路编号
public:
LoserTree(int k_) : k(k_), ls(k_), leaf(k_), sourceIndex(k_) {}
// 初始化败者树,需要对每一路取第一个元素
void init(const std::vector<int64_t>& firstValues) {
for (int i = 0; i < k; i++) {
leaf[i] = firstValues[i];
sourceIndex[i] = i;
}
// 给所有内部节点一个初始化为最后一路的编号
for (int i = 0; i < k; i++) {
ls[i] = k - 1;
}
// 从最后一个内部节点开始向上调整
for (int i = k - 1; i >= 0; i--) {
adjust(i);
}
}
// 调整败者树的第s个叶子节点
void adjust(int s) {
int t = (s + k) / 2; // 找到叶子节点s的父节点位置
while (t > 0) {
if (leaf[s] > leaf[ls[t]]) {
// 当前节点比父节点记录的元素大,说明当前节点“败了”
// 把父节点的记录换成当前节点,败者留在父节点
std::swap(s, ls[t]);
}
t = t / 2; // 继续向上
}
// 最终s是整棵树的冠军(最小值所在的路)
ls[0] = s;
}
// 取出当前最小值,并更新为nextValue
int64_t getMin(int64_t nextValue) {
int winner = ls[0]; // 冠军对应的路编号
int64_t minValue = leaf[winner]; // 当前最小值
leaf[winner] = nextValue; // 用新值覆盖该路当前值
adjust(winner); // 重新调整败者树
return minValue;
}
};
这段代码的逻辑比我第一次看教科书时容易接受得多。几个关键点我展开说。
关键点一:ls数组和leaf数组的关系。leaf就是每一路的“当前游标值”,ls里的每个节点存放的是败者所在的路编号,而不是元素本身。这样做的好处是节点里只存一个int,内存开销小,比较时通过leaf数组去取值,避免频繁搬动大数据对象。
关键点二:adjust函数的精髓。很多人第一次看败者树调整代码很懵,尤其是那个std::swap(s, ls[t])。我当初也绕了很久。实际含义是:叶子节点s拿着自己的值去和父节点t记录的“败者”比大小,如果s的值更大,说明s是当前这对比较里的败者,于是把s“留在”父节点(也就是把ls[t]更新成s),然后s的对手(原来的败者)继续向上挑战。如果s的值更小,swap之后s变成原来的败者,继续向上挑战。整个过程就像一个败者不断向上“报名”,直到比出全局冠军。
关键点三:init里把ls全部初始化为k-1。这一步很容易被忽略,但非常重要。因为最初所有叶子节点都没有比较过,内部节点不能是垃圾值。把内部节点全部指向第k-1路(最后一棵叶子),让它们当“最弱对手”,这样任何一个真实叶子都能在第一次adjust中胜出,完成整棵树的初始化。这个技巧可以省去大量重复代码。
关键点四:外部排序中,正无穷的处理。当某一路的读文件读到末尾时,我们给它塞一个INT64_MAX。这样在归并过程中,这个元素永远不会成为最小值,等所有路都返回无穷大时,整个归并就结束了。我在实际项目中用的是std::numeric_limits<int64_t>::max(),注意不要用INT_MAX,因为待排序的数可能超过这个范围。
2.3 用一场淘汰赛来理解败者树
如果你想在纸上推演败者树,我建议把它想象成一场擂台赛。K个选手(K路归并段)同时站在擂台上,两两对决。第一轮比完,各小组的胜者继续向上打,败者留在原地当“看门人”,记录“本组最强的人是谁”。每来一个新选手(新元素),只需要从自己所在的那个小组开始重新打一遍,不用管其他小组已经比过的结果。这就是败者树比线性扫描高效的根本原因——它把历史比较结果存在了树的内部节点里,每次更新只需要走一条路径。
这个类比帮助我很多次,在向同事解释代码思路的时候,只要一提擂台赛,大家基本秒懂。
3. 外部排序中的IO策略与实践
3.1 小而多的IO是大忌
多路归并算法本身再快,如果IO策略不对,整体性能照样被拖垮。我在刚做这批优化时,犯过一个很典型的错误:读文件时用小缓冲区,比如每次只读4KB,然后频繁调用read()。这样做的后果是系统调用开销巨大,每秒钟都在用户态和内核态之间来回切换,磁盘的磁头也在不断寻道。实测下来,4KB缓冲区的归并耗时比256KB缓冲区的版本慢了将近10倍。
外部排序中的IO原则其实就一句话:尽量大块读、大块写,减少IO次数。每次读写尽量是几十KB到几MB的量级。系统对顺序读写的吞吐利用率很高,但前提是你给足数据量,让它能持续传输。
我把这个原则落地成一套缓冲管理方案,大概思路是这样:
- 给每路归并段分配一个读缓冲区,大小根据路数K和剩余内存总量动态计算,比如总内存预留512MB,K=16,那每路缓冲区就是32MB。这32MB不是一次性全映射到内存,而是分块使用。
- 每次从文件中读入一块数据到缓冲区(比如4MB),用缓冲区内的当前指针向败者树提供元素。当缓冲区耗尽时,再次读入下一块。
- 输出端同样用一个大缓冲,比如64MB,攒满后一次性写回结果文件,避免每条数据都触发一次写系统调用。
3.2 双缓冲与后台预读,让磁盘永不停歇
如果只用简单的单缓冲,归并过程是串行的:先读一块数据,等磁盘返回,然后CPU做比较、输出,再等下一块。磁盘在CPU比较的那段时间是完全空闲的。这对IO密集型的场景来说是一种巨大浪费。
比较好的方案是做双缓冲。每路设置两个缓冲区:一个正在被CPU消费,另一个正在被磁盘填充。当CPU把一个缓冲区的元素消耗完,直接切换到另一个已经被预读好的缓冲区,同时触发对前一个缓冲区的下一次填充。这样就把磁盘的等待时间藏在了CPU的处理时间后面。类似流水线,虽然单个操作没变快,但整体吞吐率大幅提升。
我在Linux环境下用posix_fadvise配合pread做过一个预读优化。用posix_fadvise(fd, offset, len, POSIX_FADV_DONTNEED)告知内核某个范围的页不再需要,释放page cache给其他数据用;再用POSIX_FADV_SEQUENTIAL告诉内核我们会顺序读取,让内核做更积极的预读。这个优化在机械硬盘上效果尤其明显,因为顺序预读能显著减少磁头移动次数。SSD上效果相对没机械盘那么夸张,但也能减少IO等待。
下面是简化的双缓冲实现,用的是C++和Linux系统调用。
cpp复制#include <fcntl.h>
#include <unistd.h>
#include <vector>
#include <cstring>
class DoubleBuffer {
private:
int fd;
size_t bufSize;
uint8_t* buffer[2];
int active; // 当前活动缓冲区索引
size_t validBytes[2]; // 每个缓冲区实际有效字节数
size_t offset[2]; // 每个缓冲区的当前读位置
public:
DoubleBuffer(int fd_, size_t bufSize_)
: fd(fd_), bufSize(bufSize_), active(0) {
posix_memalign((void**)&buffer[0], 4096, bufSize);
posix_memalign((void**)&buffer[1], 4096, bufSize);
validBytes[0] = validBytes[1] = 0;
offset[0] = offset[1] = 0;
// 启动时先预读一块
preload(0);
preload(1);
}
void preload(int idx) {
ssize_t n = read(fd, buffer[idx], bufSize);
validBytes[idx] = n > 0 ? n : 0;
offset[idx] = 0;
}
// 获取下一个字节,如果当前缓冲区耗尽,切换到另一个缓冲区并预读新的
int nextByte() {
while (true) {
if (offset[active] < validBytes[active]) {
return buffer[active][offset[active]++];
}
// 当前缓冲区耗尽,切换并触发预读
int other = 1 - active;
if (validBytes[other] == 0) {
return -1; // 文件结束
}
active = other;
preload(1 - active); // 预读新的数据块
}
}
~DoubleBuffer() {
free(buffer[0]);
free(buffer[1]);
}
};
别看代码简单,这个双缓冲模式在实际项目中的收益非常高。我测试过一个30GB的归并任务,单缓冲版本耗时约720秒,双缓冲版本降到480秒左右,速度提升超过30%,而且还没算上败者树本身的优化收益。
3.3 归并路数K和缓冲区大小的取舍
前面一直在强调多路归并的路数越大越好,但实际操作中K值并不会无限大。核心矛盾在于内存。每路都要分配一个读缓冲区,如果总内存固定,那么K越大,每路能分到的缓冲区就越小,IO性能就会下降。这形成了一个跷跷板效应:K小了,归并轮数多,IO总量大;K大了,单路缓冲区小,每次IO效率低。
我在项目里的经验公式是这样:假设可用内存为M,归并段总数为T,目标是把所有段在一轮内归并完,那么K=T。每路缓冲区至少需要能承载一次有效的磁盘大块读,我一般至少给每路留4MB~8MB。如果T太大导致每路缓冲区少于4MB,就分两轮归并,先把T个段合并成T/K个段,再进行下一轮。
举个例子,假设你有1000个归并段,每段100MB,可用内存2GB。如果一次性1000路归并,每路缓冲区只有约2MB,IO效率严重下降。更合理的方案是分成两轮:第一轮用100路归并,每路缓冲区20MB,把1000个段合并成10个段;第二轮用10路归并,每路200MB,得到最终有序文件。这样总IO轮数是两轮,但每轮的每路缓冲区都足够大,整体IO效率反而更高。
4. 多路归并优化进阶
4.1 败者树 vs. 堆对比实测
我在自己的测试机上做过一组对比,数据是随机生成的10亿个64位整数,初始归并段数K从8到256不等。测试环境是Linux 5.15,SSD硬盘,32GB内存,单线程。结果整理成一个表格,供你参考:
| 归并路数K | 堆方案耗时(秒) | 败者树方案耗时(秒) | 性能提升 |
|---|---|---|---|
| 8 | 312 | 301 | 3.5% |
| 16 | 278 | 263 | 5.4% |
| 32 | 241 | 218 | 9.5% |
| 64 | 215 | 184 | 14.4% |
| 128 | 208 | 169 | 18.8% |
| 256 | 216 | 171 | 20.8% |
可以看到两个明显现象。路数小时,两者差距不大,因为logK本身很小,堆的比较次数优势体现不出来。但K超过32后,败者树的优势越来越明显,因为它的每次调整的比较次数更少,而且内存访问模式更规律。K到256时,堆方案的时间甚至比K=128时更慢了,原因可能是堆内部维护带来的缓存颠簸加剧。败者树则相对平稳,没有明显劣化。这说明路数越大,败者树的工程优势越值得重视。
4.2 内存映射mmap能不能用
既然外部排序那么看重IO效率,一个自然的想法是直接用mmap把文件映射到内存,让操作系统帮忙管理页缓存,这样读取时不就用不着手动read了吗?
我试过这条路,结论是:小文件可以,大文件慎用,超大文件别用。原因有几个。
第一,mmap的页错误处理是操作系统隐式的,当你的工作集远超物理内存时,会频繁产生缺页中断和页回写,导致不可预测的性能抖动。外部排序处理的文件动辄几十GB,超过物理内存后,mmap退化为按页随机访问,顺序预取的优化也没法精细控制。
第二,mmap的脏页回写策略不受我们控制,写文件时数据的落盘时机不可控。如果程序中途崩溃,输出文件可能是半修改状态,维护成本高。
第三,多路归并需要同时打开几十上百个文件,mmap每个文件都会占用地址空间和页表项,对内存管理压力很大,容易出现地址空间碎片化。
相比之下,传统的read/write配合自由控制的缓冲区,反而是最可控、最稳定的方案。缓冲区策略、预读策略都能让我们精确编排。所以我在正式项目里一直坚持用非mmap方案。
4.3 归并过程中的排序稳定性问题
外部排序的稳定性,在实际业务里经常被忽略,但一旦出问题就很头疼。比如你有一个交易日志,每条记录都有一个时间戳和一个用户ID,第一轮归并段生成时按时间戳排序,多路归并时按用户ID排序,那么最终结果里相同用户ID的记录内部应该保持时间戳递增。
多路归并本身是稳定的,只要你在比较时,当两个元素相等时优先取归并段编号更小的那个。但很多实现里,败者树比较用的是>和<,严格大于时才交换,这样相等元素会倾向于保留在前面的段,所以稳定性是可以保持的。如果你不小心用了>=,稳定性就会被破坏。
我在代码里用了一个小技巧来保证稳定排序:如果leaf[s] > leaf[ls[t]],即使两者相等也不交换,这样当多个归并段有相同值的时候,编号较小的段总是先输出,这就保住了稳定性。这个小细节,做数据仓库的同事应该会很在意。
4.4 判别函数与比较器性能影响
败者树每次比较都需要通过leaf数组取值,然后调用比较逻辑。如果待排序元素是简单整数,开销很小;但如果元素是字符串、结构体,比较开销就会放大。
我在处理日志记录时,给每条记录加了一个64位的排序键。比如按“时间戳+用户ID”组合成一个uint64_t,高位存时间戳,低位存用户ID,这样一次整型比较就能完成排序键的排序。如果必须在归并阶段做复杂结构体比较,我建议用std::less或自定义内联比较函数,并且确保函数体声明为inline,减少函数调用开销。实测中,比较器从虚函数改成内联函数后,整体归并速度提升了约6%。
5. 完整实操:从生成归并段到多路归并一次跑通
5.1 生成测试数据与初始归并段
纸上谈兵不如上手跑一遍。我在这里给出一个完整可运行的示例流程,你可以直接照着做。
第一步,生成测试数据。我通常用一个简单的C++程序生成包含随机整数的文件,大小可以设为2GB,这样测试效果明显。
bash复制# 生成包含5000万个随机整数的文件,每个整数8字节,约400MB
# 用python脚本快速生成
python3 - <<'EOF'
import random
import struct
with open("data.bin", "wb") as f:
for _ in range(50_000_000):
f.write(struct.pack("<q", random.randint(-2**63, 2**63 - 1)))
EOF
第二步,生成初始归并段。我设置内存上限为64MB,也就是大约800万个元素为一组。读入800万个整数,在内存里用std::sort排序,然后写回到run_0000.bin、run_0001.bin这样的文件里。这个程序的核心逻辑很简单:
cpp复制#include <algorithm>
#include <fstream>
#include <vector>
#include <cstdint>
void generateRuns(const char* inputPath, size_t memLimitBytes) {
std::ifstream in(inputPath, std::ios::binary);
size_t elemCount = memLimitBytes / sizeof(int64_t);
std::vector<int64_t> buffer(elemCount);
int runIndex = 0;
while (in.read((char*)buffer.data(), elemCount * sizeof(int64_t))) {
size_t n = in.gcount() / sizeof(int64_t);
std::sort(buffer.begin(), buffer.begin() + n);
std::string runPath = "run_" + std::to_string(runIndex++) + ".bin";
std::ofstream out(runPath, std::ios::binary);
out.write((char*)buffer.data(), n * sizeof(int64_t));
}
}
注意read的返回值,最后一次读可能不足elemCount,要用gcount()拿到实际读取的字节数,避免对未初始化的缓冲区排序。
第三步,多路归并。把所有run文件打开,每路用一个ifstream,配合前面讲的双缓冲和败者树,逐路取数,写到大输出文件里。核心归并循环参考前面的LoserTree代码,加上外层文件管理就行。
5.2 归并结果正确性校验
合并完成后,不要急着跑业务,先验证排序正确性。我的校验方法有两种,可以组合使用。
一是逐一扫描校验。打开最终输出文件,依次读取相邻两个元素,检查是否out[i] <= out[i+1],一旦发现逆序,立刻输出错误位置并退出。
二是统计校验。统计最终文件元素的总数,和输入文件元素数对比,防止漏数据;同时算一遍所有元素的哈希值(比如计算所有元素的异或和),和输入文件的哈希值对比,防止数据内容变化。这两个校验都在1分钟以内能完成,却可以帮你省掉后续排查数据问题的无数时间。
5.3 实测数据:不同参数组合下的时间对比
我用同一个2GB文件做了一组不同参数的对比测试,记录下完整耗时。测试环境是Intel i7-12700K,32GB内存,NVMe SSD,单线程。
| 配置 | 每路缓冲区 | 归并轮数 | 总耗时 |
|---|---|---|---|
| 8路归并,4MB缓冲区 | 4MB | 2轮 | 231秒 |
| 16路归并,16MB缓冲区 | 16MB | 1轮 | 187秒 |
| 32路归并,16MB缓冲区 | 16MB | 1轮 | 146秒 |
| 64路归并,4MB缓冲区 | 4MB | 1轮 | 179秒 |
| 64路归并,16MB缓冲区 | 16MB | 1轮 | 122秒 |
从数据可以清楚看到,64路归并如果缓冲区只有4MB,性能反而差于32路,这和前面讲的缓冲区太小导致IO效率下降完全对应。而64路配合16MB缓冲区时,既减少了归并轮数,又保持了不错的IO块大小,整体表现最好。这个测试也验证了一个结论:路数和缓冲区大小必须一起考虑,只看一个维度没有意义。
6. 常见问题与排查技巧实录
6.1 归并结果有乱序,问题出在哪里
这是最常遇到也最让人头疼的问题。我在第一次实现多路归并时,合并结果就出现偶尔乱序的情况,排查了很久才发现是文件读取越界导致的“脏数据”。具体表现是某一路上一次取数时,缓冲区里的剩余数据没有消费完,但文件的读取指针已经向前移动了,导致下一次取数时跳过了部分数据。
排查方法是从小数据量开始测,先用内存里的数组模拟各路归并段,确认败者树逻辑正确后,再切换到真实文件IO。如果小数据量没问题、大数据量有问题,重点检查文件读取的边界条件,特别是最后一次read返回的字节数是否被正确传递到缓冲区有效长度里。
6.2 归并段写回磁盘后,段自身没有排好序
这个问题通常是生成初始归并段时,缓冲区排序范围没有覆盖全部有效数据导致的。比如std::sort只对缓冲区的前一部分做了排序,后一部分仍然是未排序的垃圾数据,写回磁盘后自然就不满足有序性。
检查方法很简单:在写出每个run之前,先校验run内部是否严格有序。可以写一个小的校验函数,在run生成完时立刻扫描一遍,把问题扼杀在源头,而不是等最终归并完成后再大海捞针。
6.3 归并循环不结束,死循环怎么定位
如果某一路向败者树返回了一个非无穷大的“结束标志”(比如返回0而不是INT64_MAX),而归并循环又把这路当成普通元素继续处理,就会导致死循环或者漏数据。
我使用的方法是在文件结尾时给一个哨兵值,这个值必须大于所有可能的真实数据。但如果排序键恰好可能等于这个值,就会踩雷。更稳妥的做法是用一个独立的标志位数组isEnd[]记录每路是否结束,败者树比较时如果某路结束,直接认为它是“无穷大”,不需要依赖特定数值。
6.4 磁盘空间不足导致归并失败
外部排序的临时文件大小通常是原文件的好几倍。如果你总共有100个归并段,平均每个500MB,那临时文件总量就有50GB。如果磁盘剩余空间不够,程序会在写run或写输出文件时崩溃。
这个问题的排查很简单,但在项目里被反复踩到。给两点建议:工作目录的磁盘剩余空间至少是原文件大小的2~3倍;另外在程序开始时不着急一次性生成所有run,可以每生成一个run就做一次空间检查,预留足够的余量。
7. 关于优化的那些经验之谈
这一节我想把零散的经验汇总一下,都是实打实踩出来的,不是教科书上的理论。
首先是永远先定位瓶颈再优化。多路归并的瓶颈可能出在CPU比较、磁盘IO、内存带宽三者的任意一处。盲目优化最先遇到的往往是无效功。正确的做法是先跑一次profile,用perf或iostat观察,看看是CPU占用率几乎打满(说明比较逻辑是瓶颈),还是CPU几乎空闲(说明磁盘IO是瓶颈)。不同瓶颈对应的优化方向完全不同。
其次是能用简单比较器就别上复杂方案。排序键如果能压缩成64位整数,就不要拿字符串比来比去。字符串比较涉及的缓存缺失和循环次数远高于整数比较。我用组合字段排序键替代字符串比较后,性能提升非常可观。
最后是别迷信单线程。把多路归并改成多线程确实能进一步压榨性能,但复杂度会大幅上升。多线程归并的坑很多:多个线程同时读不同归并段会争抢磁盘带宽,写输出文件需要加锁,线程之间还需要同步。如果项目不紧急,建议先用单线程把版本跑通,再考虑并行化。
我在做过几十次外部排序优化后,最大的心得体会是:外部排序的成败往往不取决于某个算法有多聪明,而取决于IO策略和内存规划有多扎实。败者树只是多路归并的引擎,真正让引擎跑出性能的是缓冲区设计、预读策略、参数调优这些往往不太起眼的基础功。你如果正在做类似的功能,建议把完整的流程先跑通,再根据profile数据逐步优化,这样每一步的收益都是可量化、可验证的。
