起因
上一篇《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 条详情内容]
...压缩完成后,需要在同一个事务里完成下面三件事。
- 写入日志压缩表,得到
block_id。 - 更新日志索引表,记录每条日志对应的
block_id和序号。 - 删除日志详情表中已经归档的内容。
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。
| 指标 | JSON | MessagePack |
|---|---|---|
| 压缩前详情 | 约 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 辅助调整结构和表述。不同场景下的日志特征差异较大,落地时请根据自身情况灵活调整。
不同项目的日志结构、写入频率和查询习惯差别很大,落地时还是要按实际情况调整。能给大家做日志存储时提供一点参考,就很好了。