用 Node 的 readline 逐行扫 2GB 日志,我为什么不用流拼接
This post is available in 简体中文 only — the original follows.
环境是 Node 18.17,Ubuntu 22.04,4 核 8G 的机器。要处理的是一批 Nginx access log,单文件 2.1GB,约 1200 万行,目标是统计每个上游服务的 p99 耗时。第一版我用 fs.createReadStream 加手动拼 buffer,按 \n 切,结果跑了一分多钟还偶发丢行,内存也涨到 800MB 左右。
后来改成 readline,直接贴在下面:
const fs = require('fs');
const readline = require('readline');
const rl = readline.createInterface({
input: fs.createReadStream('/data/access.log', { highWaterMark: 1 << 20 }),
crlfDelay: Infinity,
});
const buckets = new Map();
rl.on('line', (line) => {
const i = line.lastIndexOf(' rt=');
if (i === -1) return;
const svc = line.slice(line.indexOf(' upstream=') + 10, line.indexOf(' ', line.indexOf(' upstream=') + 10));
const rt = Number(line.slice(i + 4, line.indexOf(' ', i + 4)));
if (!Number.isFinite(rt)) return;
let arr = buckets.get(svc);
if (!arr) buckets.set(svc, (arr = []));
arr.push(rt);
});
rl.on('close', () => {
for (const [svc, arr] of buckets) {
arr.sort((a, b) => a - b);
console.log(svc, arr[Math.floor(arr.length * 0.99)]);
}
});
关键点有三个:highWaterMark 调到 1MB,减少 read 系统调用次数;crlfDelay: Infinity 让 \r\n 不被拆成两次 line 事件;解析用 lastIndexOf 和手工切片,不用正则。原来那版每个行跑一遍正则,1200 万次下来 CPU 全耗在回溯上。
为什么不用 split('\n') 拼字符串:V8 对超长字符串做 slice 会产生大量中间对象,GC 压力直接体现在 RSS 上。readline 内部也是按块切,但它复用了行缓冲,切出来的行是短字符串,生命周期短,年轻代就能回收。代价是它仍是逐行回调,单线程下吞吐上限就在那,我这台机器实测约 18 万行/秒,2GB 跑完 70 秒左右,比手写 buffer 版稳定,但没快多少。
会失效的地方:日志里如果有跨行的堆栈(比如应用把异常栈直接打进 access log),readline 会把它切成多条,统计就偏了。我踩过一次,某个服务的 p99 突然翻倍,最后发现是错误日志里的 at ... 行被当成了正常记录。还有一行超过默认缓冲的情况,readline 对超长行没有硬上限,但会把整行留在内存里,遇到几 MB 的单行(比如把 base64 打进日志)照样会抖。
真要再快,我会按字节偏移把文件切成 4 段,用 4 个 worker 各扫一段,段边界手动对齐到 \n,但那是另一个坑了。
你们线上做这种大文件统计时,是直接上 readline,还是先把文件切块再并行?切块时边界怎么对齐的?
0 comments
No comments yet — be the first.