内存装不下的文件
一句话总结
“全部读进来放进列表”,只有在文件很小时才是对的设计,而这个前提是否已经被打破,只有设定上限来试一试才能知道。
为什么需要它
客户发来了一个月的事件日志。在笔记本电脑上运行得好好的脚本,到了 Pod 里就崩溃了。日志里什么都没有,只有进程消失了。
原因通常只有一行:像 lines = open(path).read().splitlines() 这样把整个文件一次性读入的代码。这一行在文件小的时候不仅更短,而且更快,所以第一次写的时候没有任何理由怀疑它。而且,文件会变多大,由客户而不是我们来决定。
更麻烦的是,这种失败是悄无声息的。容器超过内存限制时,内核会杀掉进程。Python 的异常处理捕捉不到这种死亡,标准输出里也什么都不会留下。第二天早上能看到的只有“任务没有结束”。
工作原理
设计的标准只有一个:在任何时刻,手里拿着的东西有多少个。
汇总只需要一行。条数、合计、最小值、最大值,只要有刚读到的那一行就能更新。文件变成 10 倍,内存也不变。直接遍历 Python 的文件对象,就是一行一行地读:一旦调用 readlines() 或 read(),这个性质就消失了。
前 N 个只需要 N 个。把全部数据排序后取前面的部分,整个数据都会加载到内存;但如果维护一个大小为 N 的最小堆,只在新值比堆底更大时才推入,手里拿着的永远只有 N 个。heapq 的 heappushpop 一次就能完成这个操作。
统计唯一值则不同。即使一行一行地读,也必须记住“已经见过的值”,所以内存与值的种类数成正比。统计 17 个地区代码,与统计 50 万个会话 ID,是同样的流式处理代码,内存却完全不同。所以设计唯一值统计时,首先要问的不是文件大小,而是种类数。
排序要分开来做。外部排序是一种古老的方法:每次只读入能放得下的量,排好序后写成临时文件(拆分与排序),再从这些文件中逐行取出并合并(归并)。归并阶段手里拿着的,只是每个文件的一行。在 Python 中,由 heapq.merge 来完成这种合并。Unix 的 sort 也做同样的事:POSIX sort 的规范是以使用临时文件为前提的,在 GNU 的实现中,用 -S 指定缓冲区大小,用 -T 指定临时目录。临时空间不足的话,排序就会失败。
在现场相遇的样子
第一,把“我运行过了,可以啊”当作证明。这句话的意思是在那个时候、那个文件上可以。证明是设定上限并通过。Linux 对进程能占用的地址空间有上限,在 Python 中,可以用 resource 模块的 setrlimit 来设置 RLIMIT_AS。在上限之下,流式处理的版本可以通过,而整体读入的版本会因 MemoryError 而崩溃。这两个退出码,胜过十行文字。
第二,把中间产物堆起来再验证。“为了看排序得好不好”,把原始文件和结果都读出来做成列表来比较,流式排序的意义就没有了。取而代之的是,一边让数据流过,一边计算与顺序无关的指纹:给每一行算出哈希并全部相加(溢出的部分舍弃),顺序不同也会得到相同的值,而只要缺了一行或者重复了一行,就会不同。再同时看合计和条数,就足够了。
第三,忘了临时空间。外部排序是把内存换成磁盘的交易。Pod 的临时磁盘是 6Gi,而且这块空间还要共用。如果不删除数据块文件,排序进行到一半,磁盘会先被占满。
第四,凭感觉确定数据块的大小。数据块太大,会在内存里崩溃;太小,文件句柄会变成几百个,归并就变慢。数据块大小要通过上限测试来确认“这么大肯定放得下”,然后再定下来。
实际工作中真正重要的事
- 以手里拿着的东西的个数来设计。标准不是文件大小,而是同时存活的对象数量。
- 设定上限来证明。通过与失败都以退出码的形式留下,下一个人就不用再问。
- 统计唯一值时,先问种类数。“流式处理”这个说法,并不等于内存是恒定的。
- 验证也用流式来做。与顺序无关的指纹加上条数和合计就足够了。
下一项实验要做什么
亲手生成一份 30 万行的事件日志,并构建逐行读取并汇总的工具。用堆只保留前 N 条,并亲自测量统计唯一值时的最大内存如何随值的种类数变化。接着设定内存上限,把流式版本和整体读入版本并排运行,用退出码留下差异,再用外部排序对大文件排序,并用与顺序无关的指纹证明一行也没有丢失。评分器会在上限之下,用你的工具运行它自己生成的文件,并核对结果。