为看事故的三分钟,把一整天的日志读了个遍
目标
从已完成轮转的六小时日志中,只取出事故区间的 3 分钟。不重叠的文件不打开,对剩下的文件用二分查找定位字节区间,只读取两者之间的部分,压缩文件则从头扫描但提前停下。
为什么重要
对“只给我看 3 分钟”的请求,却把一整天的日志整个读一遍,这是日志工作中最常见的失误。但如果时间已固定成 RFC 3339 的写法,且文件按时间顺序排列,字符串比较就等同于时间比较,所以无需读取文件,用二分查找就能定位区间的起点。难的不是查找,而是边界。区间要取半开区间,相邻的区间才不会重叠;同一时间的行有多条时,要找到该时间的第一行,前面的几行才不会悄悄漏掉。而且光说“很快”什么也证明不了,所以必须与整体扫描的慢速方法对照一次。
步骤
- 创建并运行
/root/slice/gen_slice.py,在/root/slice/var/log/下生成 app.log、app.log.1、app.log.2.gz。 /root/slice/index.json——把轮转文件从最旧的开始排好,并写下每个文件包含的区间。/root/slice/plan.json——只选出与事故区间重叠的文件,其余的连同理由一起跳过。/root/slice/offsets.json——用二分查找定位区间的起始字节和终止字节。/root/slice/window_a.log——只读取定位好的字节区间,取出事故区间。/root/slice/proof.json——与慢速方法对照,证明结果相同。/root/slice/window_b.log和/root/slice/gz_scan.json——一边提前停止,一边取出压缩文件中的第二个区间。/root/slice/slice_report.md——把方法和依据写成报告。
参考
- 事故区间是从
2026-04-12T03:58:30.000Z到2026-04-12T04:01:30.000Z,第二个区间是从2026-04-12T00:20:00.000Z到2026-04-12T00:23:00.000Z。两者都是包含起点、不包含终点的半开区间。 - 每行的前 24 个字符是时间。因为写法统一,所以像
line[:24] >= key这样的字符串比较,就等同于时间比较。 - 在二分查找中,
seek到的位置几乎总是落在某一行的中间。先读掉一行,站到行边界上,再做判定。 - 常见错误:找到的不是同一时间中的第一行,把终止时间的行也放进去,漏掉轮转边界另一侧的文件,对压缩文件做二分查找。
- 现场的文件有几个 GB,本实验把同样的结构缩小到 20MB 来复现。产出物全部集中在
/root/slice/下,会话结束后会消失。
复现已完成轮转的日志
创建并运行 /root/slice/gen_slice.py,在 /root/slice/var/log/ 下生成 app.log(57600 行)、app.log.1(57600 行)、app.log.2.gz(解压后为 57600 行)。
这三个文件是同一个程序写的、格式相同,只是被轮转切开了。每秒八行,每个文件装两小时的量,并让每 30 秒有三行落在同一毫秒上。压缩文件用 Python 的 gzip.GzipFile 并指定 mtime=0 来写入,重新生成时得到的也是同样的文件。
把轮转文件按时间顺序排好,测出包含的区间
在 /root/slice/index.json 中写入 files(按从旧到新的顺序存放的数组,每一项含 file、compressed、bytes、first_ts、last_ts)和 rule(用一句话写出这样排序的规则,至少 20 个字符)。时间直接使用行的前 24 个字符。
带序号的轮转文件,数字越大越旧,没有扩展名的是当前正在写入的文件。如果使用 logrotate 的 dateext,名称会变成日期,方向正好相反,所以写规则时请一并指出。未压缩文件的最后一行,只需从文件末尾读取几 KB 就能取出——不要整个读取。材料是 /root/slice/var/log/ 下的三个文件。
不重叠的文件干脆不打开
在 /root/slice/plan.json 中写入 window(start 为 2026-04-12T03:58:30.000Z,end 为 2026-04-12T04:01:30.000Z)、open(按从旧到新的顺序存放与区间重叠的文件的数组)、skip(以 file、reason 存放其余文件的数组)。
只要有第 2 步测出的 first_ts 和 last_ts,不打开文件也能知道是否重叠。因为区间是半开的,所以重叠判定也是半开的——文件的起点早于区间的终点,且文件的终点不早于区间的起点,就是重叠。材料是 /root/slice/index.json。reason 请写至少 10 个字符。
用二分查找按字节定位区间的起点和终点
在 /root/slice/offsets.json 中写入 offsets(为第 3 步选出的每个未压缩文件存放 file、start_offset、end_offset 的数组,从旧到新)。start_offset 是时间大于等于区间起点的第一行的起始字节,end_offset 是大于等于区间终点的第一行的起始字节(如果没有,则为文件大小)。
把文件大小对半,对那个字节位置做 seek,几乎总是落在某一行的中间。读掉一行,站到行边界上,再用那一行的前 24 个字符来判定。同一时间的行有三条,所以要找的不是“任意一条满足条件的行”,而是“第一条满足条件的行”——满足条件时,把范围的右端拉到那一行的起点。材料是 /root/slice/plan.json。
只读取定位好的字节区间,取出事故区间
在 /root/slice/window_a.log 中,按时间顺序、原文原样写入事故区间 [2026-04-12T03:58:30.000Z, 2026-04-12T04:01:30.000Z) 内的行。文件按从旧到新的顺序连接。
把第 4 步定位的两个偏移量之间的内容原样读出来即可。end_offset 是区间之外第一行的起点,所以它前面的部分恰好就是半开区间。不要重新解析或修饰这些行——必须原样搬运原始字节,下一步的哈希对照才能成立。材料是 /root/slice/offsets.json。
与慢速方法对照,证明结果
在 /root/slice/proof.json 中写入 window、fast_lines、slow_lines、sha256_fast、sha256_slow、match(布尔值)、bytes_read_fast、bytes_read_slow。bytes_read_fast 是第 4 步两个偏移量之间的字节之和,bytes_read_slow 是把压缩文件也解压后、整个读取三个文件时的字节数。
慢速方法是把三个文件全部扫描一遍(压缩文件要先解压),收集区间内的行。不要测量时间——每台机器都不一样。而要比较行数和 sha256 哈希。哈希针对取出的行连接起来的字节计算,两种方法的值相同,match 才为真。材料是 /root/slice/window_a.log 和 /root/slice/offsets.json。
对压缩文件来说,提前停止才是答案
第二个问题是 [2026-04-12T00:20:00.000Z, 2026-04-12T00:23:00.000Z)。在 /root/slice/window_b.log 中原文原样写入该区间内的行,并在 /root/slice/gz_scan.json 中写入 window、file、total_lines、lines_read、lines_kept。lines_read 是直到停止时实际解压的行数。
这个区间在压缩文件中。gzip 流的解压依赖前面的内容,所以无法跳到任意位置,对它做二分查找,就会变成一遍又一遍地解压同一个位置。只从头扫描一遍,遇到越过区间终点的那一行时,就当场退出。total_lines 是为了写下节省了多少,所以必须整个数一遍。材料是 /root/slice/var/log/app.log.2.gz。
把方法和依据写成文档
在 /root/slice/slice_report.md 中分四节书写:## 무엇을 물었나、## 왜 통째로 읽지 않아도 되었나、## 압축본은 무엇이 달랐나、## 결과를 어떻게 증명했나(韩文,依次意为“问了什么”“为什么不必整个读取”“压缩文件有什么不同”“如何证明结果”)。必须以数字形式包含事故区间的行数、快速方法读取的字节数、从压缩文件中解压的行数。
读这份文档的人,是下一次被问到同样问题的人。他需要知道的不是命令,而是前提——为什么二分查找能够成立,哪些文件为什么没有打开,压缩文件有什么不同,结果与什么做了对照。数字不要编造,请从 /root/slice/proof.json 和 /root/slice/gz_scan.json 中取用。