日志处理(JSONL + zstd)技术栈详解

最后修改时间:2026-08-25 11:00

一、概述

在大规模日志、爬虫数据、LLM 训练数据集等场景中,单文件动辄几十 GB 甚至上百 GB。直接用 JSON 数组存储既占空间又无法流式处理。JSONL + zstd 是目前业界主流的"结构化 + 高效压缩"组合方案。

组成 角色 说明
JSONL 数据格式 JSON Lines,每行一个独立 JSON 对象,换行分隔
zstd 压缩算法 Zstandard,Facebook 开源,速度快、压缩率高

合起来就是:用 zstd 算法压缩后的 JSONL 文件,后缀常见 .jsonl.zst

为什么要压缩

zstd 压缩的目的:

  1. 减少磁盘 / 对象存储占用——大文件落地存储成本直接降低;
  2. 减少网络传输的数据量——加快上传下载速度、节省带宽流量。

本质是:用 CPU 算力开销,换取存储与 IO / 带宽收益

对于 JSONL 数据集,因为文本冗余高(字段名重复、结构相似),压缩收益非常可观,通常能压缩到原大小的 1/5 ~ 1/10

收益维度 说明
存储成本 磁盘 / 对象存储占用大幅下降
网络 IO 上传下载更快,带宽费用更低
代价 CPU 压缩 / 解压计算开销(zstd 速度极快,代价很小)

二、JSONL 是什么

JSON Lines,后缀 .jsonl

  • 每一行是一个独立完整的 JSON 对象,一行一条记录
  • 行与行之间没有逗号,不是数组,直接换行分隔

示例

{"id":1,"name":"a","ts":"2026-08-25T10:00:00Z"}
{"id":2,"name":"b","ts":"2026-08-25T10:01:00Z"}
{"id":3,"name":"c","ts":"2026-08-25T10:02:00Z"}

特点

  • 适合存储海量日志、数据集、爬虫数据
  • 可以按行读取,不用一次性把整个大文件加载进内存
  • 每行独立,损坏一行不影响其他行解析

与 JSON 数组对比

// ❌ JSON 数组:必须一次性读取全部,内存爆炸
[
  {"id":1,"name":"a"},
  {"id":2,"name":"b"},
  {"id":3,"name":"c"}
]

// ✅ JSONL:逐行读取,内存友好
{"id":1,"name":"a"}
{"id":2,"name":"b"}
{"id":3,"name":"c"}

三、zstd 是什么

Zstandard,Facebook(Meta)开源的压缩算法,命令行工具叫 zstd

核心优势

指标 zstd gzip lz4 bzip2
压缩速度 一般 极快
解压速度 极快 一般 极快
压缩率 一般 较低
大数据集流行度 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐ ⭐⭐

命令行操作

# 压缩 jsonl → jsonl.zst
zstd data.jsonl

# 解压 jsonl.zst → jsonl
zstd -d data.jsonl.zst

# 指定压缩级别(1-19,默认 3,越高压缩率越好但越慢)
zstd -19 data.jsonl

# 多线程压缩
zstd -T0 data.jsonl

如何判断文件是否是 zstd 压缩

  1. 看后缀.zst.jsonl.zst
  2. 看文件头魔数:zstd 文件以 0x28 0xB5 0x2F 0xFD 开头
  3. 命令行检测
file data.jsonl.zst
# 输出: data.jsonl.zst: Zstandard compressed data

四、zstd 压缩的 JSONL:.jsonl.zst

原理

  • 原始内容:多行 JSON(JSONL)
  • 外层:被 zstd 二进制压缩包裹
  • 不能直接用文本编辑器打开,看到的是乱码,必须先解压才能看到 JSON 文本

为什么不能直接按行读压缩文件

zstd 是整体二进制压缩,原始的 \n 换行标记已经被压缩编码混在二进制流里面,磁盘上的 .zst 文件本身不再有肉眼可见的换行符。

# ❌ 错误!不能直接 for line in fs.readFileSync("xxx.jsonl.zst")
# 读到的是压缩二进制碎片,不是原始 json 行

必须:一边流式解压,一边在解压出来的字节流里面找换行符

流式处理流程

磁盘 → 读取一小块二进制 → zstd 流式解压 → 输出解压后的原始字节流 → 识别 \n 切分行 → 逐行处理

不会把整个 100G 文件全部解压到内存,是小块流式解压 + 逐行切分

形象比喻

文件类型 比喻
.jsonl 一本打印好的书,每行文字直接印在纸上,看一行撕一行,不用抱整本书
.jsonl.zst 书被压缩成密封铅块,看不到文字,得一点点拆开,拆出一页看一页,看完丢掉再拆

常见使用场景

  • 大模型训练数据集(HuggingFace 大量数据集就是 xxx.jsonl.zst
  • 爬虫导出数据
  • 日志备份与归档
  • LLM 公开数据集分发

五、Node.js 实战

5.1 安装依赖

npm install @mongodb-js/zstd
# 或
npm install simple-zstd
# 或使用原生绑定
npm install @bokuweb/zstd-wasm

推荐使用 @mongodb-js/zstd,API 简洁且维护活跃:

npm install @mongodb-js/zstd

5.2 压缩 JSONL 文件

const fs = require('fs');
const readline = require('readline');
const { compress } = require('@mongodb-js/zstd');

async function writeJsonlZst(records, outputPath, level = 3) {
  // 1. 先把记录写成 JSONL 文本
  const jsonlContent = records
    .map((r) => JSON.stringify(r))
    .join('\n');

  // 2. 用 zstd 压缩
  const inputBuf = Buffer.from(jsonlContent, 'utf-8');
  const compressed = await compress(inputBuf, level);

  // 3. 写入文件
  fs.writeFileSync(outputPath, compressed);
  console.log(`✅ 已压缩 ${records.length} 条记录 → ${outputPath}`);
}

// 使用示例
const logs = [
  { id: 1, level: 'info', msg: '服务启动', ts: '2026-08-25T10:00:00Z' },
  { id: 2, level: 'error', msg: '数据库连接失败', ts: '2026-08-25T10:01:00Z' },
  { id: 3, level: 'info', msg: '重连成功', ts: '2026-08-25T10:02:00Z' },
];

writeJsonlZst(logs, 'logs.jsonl.zst', 3);

5.3 解压并逐行读取(流式)

const fs = require('fs');
const { decompress } = require('@mongodb-js/zstd');

async function readJsonlZst(filePath) {
  // 1. 读取压缩文件
  const compressed = fs.readFileSync(filePath);

  // 2. 解压成原始字节
  const decompressed = await decompress(compressed);

  // 3. 转字符串后按行切分
  const text = decompressed.toString('utf-8');
  const lines = text.split('\n').filter(Boolean);

  // 4. 逐行解析 JSON
  const records = [];
  for (const line of lines) {
    try {
      records.push(JSON.parse(line));
    } catch (e) {
      console.warn('⚠️ 跳过无法解析的行:', line.slice(0, 100));
    }
  }
  return records;
}

// 使用示例
readJsonlZst('logs.jsonl.zst').then((records) => {
  console.log(`共读取 ${records.length} 条记录`);
  records.forEach((r) => console.log(r));
});

5.4 大文件流式处理(不一次性加载到内存)

对于超大文件,使用流式解压 + 逐行读取:

const fs = require('fs');
const { ZstdCompress, ZstdDecompress } = require('simple-zstd');
const readline = require('readline');

async function streamReadJsonlZst(filePath, onRecord) {
  // 创建可读流 → zstd 解压流 → 按行切分
  const readStream = fs.createReadStream(filePath);
  const decompressStream = new ZstdDecompress();

  const rl = readline.createInterface({
    input: readStream.pipe(decompressStream),
    crlfDelay: Infinity,
  });

  let count = 0;
  for await (const line of rl) {
    try {
      const record = JSON.parse(line);
      await onRecord(record, count++);
    } catch (e) {
      console.warn(`⚠️ 第 ${count} 行解析失败:`, e.message);
    }
  }
  console.log(`✅ 处理完成,共 ${count} 条`);
}

// 使用示例:处理 100GB 日志,内存只占一行
streamReadJsonlZst('huge-logs.jsonl.zst', async (record, i) => {
  if (i % 100000 === 0) console.log(`进度: ${i}`);
  // 在这里处理每条记录,比如写入数据库、过滤等
  if (record.level === 'error') {
    // 只处理 error 级别日志
  }
});

5.5 流式压缩写入(边生成边压缩)

const fs = require('fs');
const { ZstdCompress } = require('simple-zstd');

async function streamWriteJsonlZst(records, outputPath, level = 3) {
  const writeStream = fs.createWriteStream(outputPath);
  const compressStream = new ZstdCompress(level);

  // 管道:数据 → zstd 压缩 → 写入文件
  compressStream.pipe(writeStream);

  for (const record of records) {
    compressStream.write(JSON.stringify(record) + '\n');
  }

  compressStream.end();
  return new Promise((resolve) => writeStream.on('finish', resolve));
}

// 使用示例:流式写入百万条日志
async function generateLogs(outputPath) {
  const records = [];
  for (let i = 0; i < 1000000; i++) {
    records.push({
      id: i,
      level: i % 100 === 0 ? 'error' : 'info',
      msg: `日志条目 ${i}`,
      ts: new Date().toISOString(),
    });
  }
  await streamWriteJsonlZst(records, outputPath, 3);
  console.log('✅ 流式压缩写入完成');
}

generateLogs('million-logs.jsonl.zst');

5.6 完整工具类封装

const fs = require('fs');
const readline = require('readline');
const { ZstdCompress, ZstdDecompress } = require('simple-zstd');

/**
 * JSONL + zstd 日志工具类
 * 提供压缩、解压、流式读写能力
 */
class JsonlZstd {
  /**
   * 流式压缩写入
   * @param {string} outputPath - 输出文件路径
   * @param {number} level - 压缩级别 1-19,默认 3
   * @returns {Object} { write(record), end() }
   */
  static createWriter(outputPath, level = 3) {
    const writeStream = fs.createWriteStream(outputPath);
    const compressStream = new ZstdCompress(level);
    compressStream.pipe(writeStream);

    return {
      /** 写入一条记录 */
      write(record) {
        compressStream.write(JSON.stringify(record) + '\n');
      },
      /** 结束写入 */
      end() {
        compressStream.end();
        return new Promise((resolve) => writeStream.on('finish', resolve));
      },
    };
  }

  /**
   * 流式逐行读取
   * @param {string} filePath - 输入文件路径
   * @param {Function} onRecord - 每条记录的回调 (record, index) => void | Promise
   */
  static async streamRead(filePath, onRecord) {
    const readStream = fs.createReadStream(filePath);
    const decompressStream = new ZstdDecompress();

    const rl = readline.createInterface({
      input: readStream.pipe(decompressStream),
      crlfDelay: Infinity,
    });

    let count = 0;
    for await (const line of rl) {
      try {
        const record = JSON.parse(line);
        await onRecord(record, count++);
      } catch (e) {
        console.warn(`⚠️ 第 ${count} 行解析失败:`, e.message);
      }
    }
    return count;
  }

  /**
   * 过滤读取:只保留满足条件的记录
   * @param {string} filePath - 输入文件路径
   * @param {Function} filter - 过滤函数 record => boolean
   * @returns {Array} 过滤后的记录数组
   */
  static async filterRead(filePath, filter) {
    const results = [];
    await this.streamRead(filePath, async (record) => {
      if (filter(record)) results.push(record);
    });
    return results;
  }
}

module.exports = { JsonlZstd };

使用示例:

const { JsonlZstd } = require('./jsonl-zstd');

// 写入日志
async function writeLogs() {
  const writer = JsonlZstd.createWriter('app-logs.jsonl.zst', 3);
  for (let i = 0; i < 10000; i++) {
    writer.write({
      id: i,
      level: i % 200 === 0 ? 'error' : 'info',
      msg: `事件 ${i}`,
      ts: new Date().toISOString(),
    });
  }
  await writer.end();
  console.log('✅ 写入完成');
}

// 读取并过滤 error 日志
async function readErrors() {
  const errors = await JsonlZstd.filterRead(
    'app-logs.jsonl.zst',
    (r) => r.level === 'error'
  );
  console.log(`共 ${errors.length} 条 error 日志`);
  errors.forEach((e) => console.log(e));
}

writeLogs().then(readErrors);

六、其他常见压缩算法对比

算法 后缀 压缩率 速度 适用场景
zstd .zst ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 大数据集、日志、LLM 训练数据
gzip .gz ⭐⭐⭐ ⭐⭐⭐ 通用压缩、Web 传输
bzip2 .bz2 ⭐⭐⭐⭐ ⭐⭐ 归档、高压缩率需求
lz4 .lz4 ⭐⭐ ⭐⭐⭐⭐⭐ 极速压缩、实时场景
xz .xz ⭐⭐⭐⭐⭐ 软件包分发、最高压缩率
snappy .snappy ⭐⭐ ⭐⭐⭐⭐⭐ 列式存储(Parquet/ORC)

选择建议

  • 追求综合性能(速度+压缩率):zstd ✅
  • 追求极致速度:lz4 / snappy
  • 追求极致压缩率:xz / bzip2
  • 兼容性优先:gzip

补充:lz-string(浏览器端轻量压缩)

LZ-String,JavaScript 环境下的轻量级字符串压缩库,基于 LZ 算法变体。

与 zstd 定位不同,lz-string 面向浏览器端、小到中等体积的字符串/JSON 压缩场景。

维度 lz-string zstd
实现 纯 JS,无原生依赖 C 实现,Node.js 用 WASM/原生绑定
压缩率 中等(文本约 2-5x) 高(文本约 5-10x)
速度 中等 极快(原生级)
输出 字符串 二进制
流式处理 ❌ 不支持 ✅ 支持流式压缩/解压
大文件 ❌ 全量加载到内存 ✅ 可逐行流式处理
适用场景 localStorage、URL 参数、小 JSON 压缩 大数据集、日志归档、LLM 训练数据
浏览器友好 ⭐⭐⭐⭐⭐ 开箱即用 ⭐⭐ 需引入 WASM

安装与基本用法

npm install lz-string
const LZString = require('lz-string');

// 压缩 JSON 字符串
const json = JSON.stringify({ id: 1, msg: 'hello', data: '...'.repeat(1000) });
const compressed = LZString.compress(json);
console.log(`原: ${json.length} → 压缩: ${compressed.length}`);

// 解压
const restored = LZString.decompress(compressed);
console.log(JSON.parse(restored));

// 压缩到 Base64(适合 URL 传输)
const base64 = LZString.compressToBase64(json);
const restored2 = LZString.decompressFromBase64(base64);

// 压缩到 UTF-16(适合 localStorage)
const utf16 = LZString.compressToUTF16(json);
const restored3 = LZString.decompressFromUTF16(utf16);

选择建议

  • 小数据、浏览器端、存 localStorage / URL:用 lz-string
  • 大文件、服务端、日志/数据集归档:用 zstd
  • 两者不冲突,可按场景搭配使用

七、zstd 两种压缩模式

模式 说明 流式处理
流式压缩(默认) 数据分帧压缩,可 stream_reader 小块解压 ✅ 支持逐行处理
单块压缩 全部内容一次性压缩成单块 ⚠️ 部分流式库不友好

HuggingFace 等数据集均使用流式压缩格式,可逐行处理。


八、常见问题

Q1: zstd 压缩的 JSONL 文件如何解压?

# 命令行
zstd -d data.jsonl.zst          # 解压为 data.jsonl
zstd -d data.jsonl.zst -o out   # 指定输出文件名

# Node.js
const { decompress } = require('@mongodb-js/zstd');
const buf = fs.readFileSync('data.jsonl.zst');
const text = (await decompress(buf)).toString('utf-8');

Q2: 如何判断文件是否是 zstd 压缩?

# 方法1: 看后缀 .zst / .jsonl.zst
# 方法2: file 命令
file data.jsonl.zst
# → data.jsonl.zst: Zstandard compressed data

# 方法3: 检查魔数
xxd data.jsonl.zst | head -1
# → 00000000: 28b5 2ffd ...  (魔数 0x28B52FFD)

Node.js 检测:

function isZstd(filePath) {
  const fd = fs.openSync(filePath, 'r');
  const buf = Buffer.alloc(4);
  fs.readSync(fd, buf, 0, 4, 0);
  fs.closeSync(fd);
  // zstd 魔数: 0x28 0xB5 0x2F 0xFD
  return buf[0] === 0x28 && buf[1] === 0xb5 && buf[2] === 0x2f && buf[3] === 0xfd;
}

Q3: 压缩级别怎么选?

级别 压缩率 速度 推荐场景
1-3 一般 极快 实时日志、高频写入
3-9(默认 3) 较好 通用推荐
10-19 归档、冷数据

九、一句话总结

zstd 压缩的 JSONL = 一条条 JSON 换行存放的文本文件,再用 zstd 做高效压缩,常用于存储大规模结构化数据。Node.js 中通过流式解压 + readline 即可实现低内存的逐行处理。