用 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
Approval 100% · 0 comments · 9 Reads

0 comments

Markdown supported · replies push in real time

No comments yet — be the first.