MENU

SQLite Zstd进阶压缩-批量压缩

August 5, 2026 • Read: 23 • 编码,算法,Go

起因

上一篇《SQLite 压缩存储日志》写的是第一代方案。日志从 MySQL 独立出来,按月写入 SQLite 文件,单条日志超过 150 字节时使用 Zstd 压缩。

这套方案已经跑了一段时间,查询和磁盘占用都比原来好很多。不过日志量继续上来以后,逐条压缩的空间节省开始不太够用。

本文的测试数据和上一篇不同。上一篇使用 A、B 两个项目的日志,本文使用另一批 500 万条日志和单独的 SQLite 测试文件。两篇文章的数据来源不同,后面出现的体积数据只能分别参考,不能当成同一批数据的前后对比。

很多日志的字段名、状态和固定输出会反复出现,变化的可能只有 ID、时间和少量结果字段。

第一代方案会把每条日志分别交给 Zstd 压缩。每次压缩只看到当前这一条日志,前面已经出现过的字段名和固定输出,后面还得再压一次。把一批相似日志放在一起压缩,Zstd 才能把这些重复内容利用起来。

服务器 CPU 也是我考虑升级的一个原因。我们绝大多数系统的 CPU 都有余量,平时不用也是闲着。日志归档可以放到后台跑,用一些 CPU 换磁盘空间,这笔账能算得过来。

这个思路是看 ClickHouse 存储方式时想到的。数据先写进去,后面由后台慢慢做合并和压缩。日志没有必要照搬 ClickHouse 的实现,不过这个方向很适合历史日志。

问题

直接把多条日志拼起来压缩,看上去很简单,实际会遇到一个麻烦。

一条日志在写入过程中,内容可能还会继续补充。假设一个压缩包里已经有 1000 条详情,其中一条又多输出了几行内容,更新它需要先解压整个压缩包,改完后再压回去。一次很小的写入,结果变成了重写 1000 条日志。

所以日志需要分开存。任务还在执行时,详情继续按原来的方式写入。等任务结束,确认日志不会再变,再批量压缩归档。

改进

这次把一条完整日志拆成三部分保存。

  • 日志索引表保存时间、任务 ID、状态、列表摘要和压缩包定位,用于列表、筛选和统计。
  • 日志详情表保存还会继续追加的完整日志,用于任务执行期间写入。
  • 日志压缩表保存多条历史详情合并后的 Zstd 数据,用于长期保存。

为了方便理解,可以把日志压缩表里的每一行看成一个压缩包。一个压缩包里放几百条或者几千条已经结束的日志详情。

任务开始写日志时,先写入日志索引表和日志详情表。列表页需要的时间、任务 ID、状态这些字段放在索引表里,完整输出留在详情表。这样任务列表查询不会碰到大字段,也不需要解压日志。

任务结束后,后台归档任务会从日志详情表里取一批数据。为了在解压后还能区分每条日志的边界,我在每条详情前面写入长度,再把整段数据压缩。

[第 1 条详情长度][第 1 条详情内容]
[第 2 条详情长度][第 2 条详情内容]
[第 3 条详情长度][第 3 条详情内容]
...

压缩完成后,需要在同一个事务里完成下面三件事。

  1. 写入日志压缩表,得到 block_id
  2. 更新日志索引表,记录每条日志对应的 block_id 和序号。
  3. 删除日志详情表中已经归档的内容。

block_id 就是压缩包的 ID,序号表示这条日志排在压缩包里的第几条。归档任务下次执行时,只查 block_id 为空的记录,已经归档过的日志不会再被重复压缩。

这三个步骤一定要放在一个事务里。中间任意一步失败,整批数据回滚,详情表还在,下次可以继续归档。要是分开提交,就可能出现索引指向了不存在的压缩包,或者详情删掉了却找不到归档数据的情况。

查询方式

列表查询还是只查日志索引表,和第一代方案差不多。

打开某条完整日志时,先从索引表查 block_id

  • block_id 为空,说明任务刚结束或者还没归档,直接读取日志详情表。
  • block_id 有值,读取对应的压缩包,解压后按序号取出这条详情。

一个列表页里可能有多条日志落在同一个压缩包里。这种情况可以在请求里缓存解压结果,避免连续解压同一个包。

按月保存 SQLite 文件的做法继续保留。日志过期后直接删除对应月份的文件,索引、详情和压缩数据会一起清掉,维护起来比较省事。

效果

在一组约 500 万条、日志结构重复度比较高的样本里,测试结果大概如下。

存储方式占用空间
SQLite 明文行存约 4.27 GB
逐行 Zstd 压缩约 2.59 GB
三表 JSON 区块压缩约 1.01 GB
三表 MessagePack 区块压缩约 1.18 GB

从压缩包里随机打开一条详情,大概需要几毫秒。逐行压缩通常只要数十微秒。区块归档省下了不少空间,打开历史详情会慢一些,平时列表查询没有受到影响。

这个数据只适合做参考。日志内容越相似,区块压缩越有优势。索引数量、压缩包大小、数据库配置和机器性能都会影响测试结果。

补充说明

压缩包大小

压缩包放得越大,压缩率通常越好,索引表分摊到每条日志的数据也越少。不过读取详情时需要解压更多内容,内存占用和归档等待时间也会跟着增加。

我会先用 1000、5000、10000、20000 条几个档位做测试,再看压缩率、详情读取耗时和归档速度。这个值没有通用答案,和任务日志的大小、重复度关系很大。

JSON 和 MessagePack

我实际拿 MessagePack 做过一次对比。测试方法和 JSON 方案一样,都是三张表,每 1 万条详情合并后再交给 Zstd 压缩,区别只是详情的载体换成了 MessagePack。

指标JSONMessagePack
压缩前详情约 4027.69 MB约 3072.02 MB
Zstd 后详情约 83.37 MB约 259.46 MB
数据库总大小约 1.01 GB约 1.18 GB
写入速度约 31365 条/秒约 23699 条/秒
随机详情约 3.5ms约 4.6ms

表里的前两列只统计详情字段 flow,数据库总大小还包含三张表和保留的二级索引。

MessagePack 序列化后的原始详情确实更小,约 3072 MB,JSON 是约 4027 MB。但 JSON 里重复的字段名和固定文本很多,区块压缩可以利用这些重复内容。MessagePack 把这些字段名换成了更紧凑的二进制表示,原始数据少了,Zstd 能利用的重复内容也少了,压缩后的详情和整个数据库反而更大。

所以这里采用 JSON 区块 + Zstd。这个结论只针对当前测试样本,换成另一种日志结构还需要重新测试。比较时要看压缩后的体积、写入速度和详情读取耗时,不能只看序列化后的原始大小。

适用范围

如果日志会长时间追加,不同日志之间重复内容不多,或者用户经常打开完整详情,逐行压缩会更合适。

实际也可以混着用。最近几天的日志继续逐行压缩,时间更久、主要用来排查问题的日志再做区块归档。

技术栈

  • 开发语言 Go
  • 数据库 SQLite
  • 压缩库 github.com/klauspost/compress

本方案及测试数据均来自实际项目的实践。文章在整理时借助了 GPT 辅助调整结构和表述。不同场景下的日志特征差异较大,落地时请根据自身情况灵活调整。

不同项目的日志结构、写入频率和查询习惯差别很大,落地时还是要按实际情况调整。能给大家做日志存储时提供一点参考,就很好了。