TT Lab
开始
学习 学习路径 课程

从日志里找原因

为看三分钟不必读完一整天

在 TT Lab 中继续学习

一句话总结

在按时间顺序累积的日志中,不必读取就能找到位置。只要时间的写法统一,字符串比较就等同于时间比较,所以只需对文件按字节做二分查找,先定位区间的起点,再只读它之后的部分即可。

为什么需要它

故障复盘中最常提出的请求是“只给我看出事的那 3 分钟”。但手最先做的,通常是 grep '03:5[89]' app.log,而这一行会把整个文件从头读到尾。如果文件是一天几个 GB,这一次操作就会用光磁盘带宽和内存,想要分析的 Pod 反而先停掉了。就拿本实验的 Pod 来说,也只有 2Gi 内存和 6Gi 临时磁盘。

更糟的是,这种方式还会给出错误的答案。如果区间跨越了轮转边界,只看 app.log 得到的结果就只有一半。事故的前半部分已经转到了 app.log.1,再往前的部分则压缩在 app.log.2.gz 里。只看一个文件就写下“那个时间点什么事也没发生”的复盘,就是这样产生的。

工作原理

前提只有一个——文件按时间顺序排列,且时间写法统一。RFC 3339 第 5.1 节明确写出了这个性质。如果日期和时间的各个组成部分按从粗到细的顺序排列,时区全都写成同一种字符串(例如全部是 Z),小数位数也全都相同,那么用 C 的 strcmp 对这些字符串排序,得到的也是时间顺序。所以比较两个 2026-04-12T03:58:30.000Z,就等同于比较两个时间。这个前提是否成立,可以先用 sort(1) 的 -c(只检查是否已排序,不做排序)来确认。

前提成立,就可以做二分查找了。把文件大小对半,对那个字节位置做 seek,由于它多半落在某一行的中间,所以读掉这一行,站到行边界上,再把下一行的时间与区间的起点比较。如果小,就把范围缩到后半部分;如果大于或等于,就缩到前半部分。对于 20MB 的文件,二十几次就结束了。

这里有两点必须做准确。第一,区间要取半开区间 [시작, 끝)(占位符依次为起点、终点)。这样,即使把相邻的区间并排取出,边界上的行也不会被数两次。第二,当同一时间的行有多条时,二分查找必须找到的是该时间的第一行。在每秒堆积几十行的服务中,同一毫秒里有多行并不是例外,而是常态,如果随便找到一行,再只读它之后的内容,前面的几行就会悄悄漏掉。写法要做到找的不是“任意一条满足条件的行”,而是“第一条满足条件的行”。

轮转文件要先按时间顺序排好。在默认的加序号方式中,数字越大越旧,没有扩展名的文件是当前正在写入的。启用 logrotate(8) 的 dateext 之后,名称会变成日期(默认的 dateformat 是 -%Y%m%d,hourly 是 -%Y%m%d%H),这时名称越小越旧,方向正好相反。同一个 man 页面明确要求日期格式必须能按字典序排序,原因很有意思——因为 logrotate 自己要把轮转文件的名称排序,才能弄清哪个文件更旧。让名称排序等同于时间排序,无论是在文件内部还是在文件名上,要领都是一样的。

只有压缩文件的情况不同。gzip 流的解压依赖前面的内容,所以无法跳到任意位置。Python 的 gzip 模块给出的文件对象虽然接受 seek,但往回走时会从头重新读取压缩流(亲自测量就会看到,它在重新读入原始字节)。所以对压缩文件做二分查找,就会变成一遍又一遍地解压同一个位置。答案很简单——只从头扫描一遍,但一过区间的终点就停下。区间越靠近文件的前部,这样节省得越多。

如果是每行都是完整记录的 JSON Lines 格式,这一切都可以直接使用。因为按行切出来的字节区间本身就是有效的文档。整体的 JSON 数组则无法从中间截取。

在现场相遇的样子

前提一旦被打破,二分查找会悄悄出错。如果多个进程往同一个文件里写,行的顺序会出现细微的错位,采集器也会把迟到的行追加到后面。在这样的文件上,二分查找不会报错,而是悄悄漏掉几行。所以在第一次处理新文件时,要先确认是否已排序,如果有错位,就放弃二分查找,或者把区间放宽错位的幅度。

“很快”不是证明。引入快速方法时,必须同时做的是与慢速方法的对照。把整个文件扫描一遍取出同一个区间,比较行数和哈希是否相同。这种对照只需做一次,如果没有这一次,即使在边界上漏了行,也没有人知道。

下一项实验要做什么

做出已经完成轮转的六小时日志,测出每个文件包含的区间,并从最旧的开始排好。与事故区间不重叠的文件干脆不打开,对剩下的文件用二分查找定位起始字节和终止字节,只读取两者之间的部分。压缩文件用不了同样的办法,所以从头扫描,但要提前停下。最后与整体扫描的慢速方法对照,证明结果相同,并把方法和依据写成报告。