
本文字数:11879;估计阅读时间:30 分钟
作者:Lionel Palacin & Dale McDiarmid
本文在公众号【ClickHouseInc】首发

TL;DR
列式存储为高效存储日志提供了新路径,既能大幅压缩数据体积,又能保持查询的高效与灵活。通过将原始日志结构化为列数据,选择合适的数据类型,并将相似的值聚集存储,我们可以实现超过 170 倍的压缩效果。
日志在大规模存储场景下是一项难题。
它们往往是非结构化的文本格式,天然不利于压缩。但其中蕴含着系统和应用行为的重要历史信息,这些内容对排查故障非常关键,不容忽视。
在可观测性系统中,日志与追踪和指标一起构成三大支柱。不同于日志,追踪和指标天生具备结构性和重复性,因此天然适合以列式格式压缩。例如时间戳(Timestamp)、服务名(ServiceName)和延迟(Latency)等字段,在列存中表现优异,压缩效率也很高。
那如果我们也能让日志具备类似的结构会怎样?通过识别日志消息中的可变部分,提取为独立列,采用最优数据类型,并在磁盘上将相似值相邻存储,日志也可以实现高效压缩。
这篇文章是我们首次探索这种思路,将原始日志转化为结构化数据,目标是实现 170 倍压缩。在接下来的博客中,我们还会进一步探讨日志聚类技术,如何将这一方法扩展至各种类型的日志,并自动优化其压缩比。
为什么日志压缩至关重要
压缩的意义不仅在于节省存储空间,它还直接影响系统的性能和资源利用效率。
更少的 I/O,查询更快
数据体积更小,读取速度自然更快。在列式数据库中,查询往往需要扫描大量数据,因此压缩后的数据块能显著降低磁盘 I/O 开销。这不仅缓解了磁盘带宽和网络资源的压力,也大大提升了查询的执行速度。
更低的存储成本
压缩能有效减少所需的存储空间,进而降低存储费用。即便使用如 Amazon S3 这样的对象存储,虽然单价较低,但压缩依然有价值——因为压缩数据可以缓存在本地的 NVMe 设备中,从而实现更快的访问。
更高的缓存命中率
压缩数据更容易装入内存和各级缓存,缓存命中率随之提升,减少了对磁盘读取的依赖,进一步提升系统整体性能。
用 Nginx 访问日志做压缩实验
Nginx 访问日志记录了服务器处理的每一条请求,是一个标准且广为人知的数据集,便于读者理解并自行复现实验过程。
一条典型的日志通常包含客户端 IP、时间戳、HTTP 方法、状态码、用户代理和响应时间等信息。下面是一个 nginx 日志示例。
185.161.113.50 - - [2019-02-04 23:40:49] "GET /filter/p62,b113?page=0 HTTP/1.1" 200 33948 "https://www.zanbil.ir/" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"
我们选择 nginx 日志作为实验对象,原因如下:
-
结构固定:Nginx 的日志格式定义清晰,结构一致。虽然具体字段值(如时间戳、IP 地址、URL、响应时间)会变化,但整体格式基本不变。
-
数据类型丰富:一条日志中包含 IP 地址、数值、字符串和日期,涵盖多种数据类型,非常适合用于展示如何针对不同类型的数据选择最优的压缩策略。
-
数据量大:生产环境中的访问日志增长迅速,短时间内就能累积数 GB 到数 TB 的数据量。这种规模下,压缩对存储成本和处理性能的影响尤为显著。
虽然 nginx 日志通常以纯文本方式保存,但其格式高度可预测。这虽然不一定代表所有应用日志的典型特征,我们对此也有所认识。但我们选择这个数据集,是为了首先建立压缩效率的“乐观上限”作为参考。在后续的文章中,我们还将进一步探索如何在结构不明显的日志中实现高效压缩。
建立压缩基线
要做任何压缩效果的基准测试,首先需要明确一个基线。在本实验中,我们的基线是:日志数据集在未压缩情况下,占用本地磁盘的空间是多少。
我们使用的测试数据集是一份包含 6600 万条 nginx 访问日志的文件。该文件已在后续命令中公开,便于大家复现实验。未压缩状态下,该日志文件约占 20GB 磁盘空间。
$ wc -l nginx-66.log
66747290 nginx-66.log
$ du -h nginx-66.log
20G nginx-66.log
由于 ClickHouse 在写入磁盘时会自动压缩数据,并支持多种压缩算法,因此我们也测试了几种通用压缩方式作为对比参考。
我们将原始日志文件分别用 GZIP、ZSTD(3) 和 LZ4 进行了压缩,比较它们的磁盘存储效果。可以看到,压缩效果相当显著,ZSTD(3) 已经可以实现约 38 倍的压缩比。
|
压缩方式 |
磁盘空间占用 |
压缩比 |
|
None |
20 GB |
1 |
|
LZ4 |
1 GB |
20x |
|
GZIP |
641 MB |
31x |
|
ZSTD(3) |
522 MB |
38x |
压缩比的计算方式为:压缩后大小 ÷ 原始未压缩大小。
现在我们已经得到了基线数据,接下来就可以将日志导入 ClickHouse,并开始应用结构化和优化策略。
日志写入 ClickHouse
首先需要创建一张表来存储原始日志,我们定义一个简单的表 nginx_raw,结构仅包含一个 String 类型字段,数据无特定排序。
CREATE TABLE nginx_raw
(
`Body` String
) ORDER BY ()
创建好表之后,就可以导入日志文件。以下示例展示了如何直接从 S3 插入数据。数据导入完成后,我们还可以校验是否全部导入成功。
INSERT INTO nginx_raw SELECT line As Body FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/http_logs/nginx-66.log.gz', 'LineAsString')
SELECT count() FROM nginx_raw
┌──count()─┐
│ 66747290 │ -- 66.75 million
└──────────┘
你也可以使用相同的命令将日志加载到自己的 ClickHouse 实例中。加载时间会根据本地 ClickHouse 配置和网络带宽有所不同(该文件大小约为 640MB)。
我们可以通过查询 system.parts (https://clickhouse.com/docs/operations/system-tables/parts)系统表来查看表在磁盘上的实际占用空间。该系统表记录了每个表分片的详细信息,包括压缩前后的磁盘大小。
SELECT
`table`,
formatReadableSize(SUM(data_uncompressed_bytes)) AS uncompressed_size,
formatReadableSize(SUM(data_compressed_bytes)) AS compressed_size
FROM system.parts
WHERE (database = 'logs_blog') AND (`table` = 'nginx_raw') AND active
GROUP BY `table`
ORDER BY `table` ASC
┌─table─────┬─uncompressed_size─┬─compressed_size─┐
│ nginx_raw │ 20.19 GiB │ 575.62 MiB │
└───────────┴───────────────────┴─────────────────┘
从结果来看,并没有意外,未压缩数据的大小与原始磁盘文件一致。由于 ClickHouse 默认采用 ZSTD(1) 压缩算法,因此最终压缩后的大小也与预期相符。
将 Nginx 日志转换成结构化日志(压缩比提升至 56 倍)
要实现 170 倍压缩,第一步是将原始的纯文本 Nginx 日志转换为结构化格式。我们将日志中的关键信息(如 IP 地址、请求方法、URL、状态码、用户代理等)提取出来,分别存储在独立的字段中。
与其将整条日志记录作为一个长字符串存储,我们可以将其解析为多个字段,如下所示:
|
列名 |
数据类型 |
示例值 |
|
remote_addr |
IPv4 |
185.161.113.50 |
|
remote_user |
String |
user |
|
time_local |
DateTime |
2025-10-13 10:00 |
|
request_type |
String |
GET |
|
request_path |
String |
/index.html |
|
status |
String |
HTTP/1.1 |
|
size |
UInt16 |
200 |
|
referrer |
String |
- |
|
user_agent |
String |
Mozilla/5.0 (...) |
Nginx 的日志格式是预定义的,因此可以方便地通过正则表达式函数对其进行字段解析。
我们先根据定义好的 nginx 日志 schema 创建一张新表。
CREATE TABLE nginx_column_tuple
(
`remote_addr` String,
`remote_user` String,
`time_local` DateTime,
`request_type` String,
`request_path` String,
`request_protocol` String,
`status` UInt64,
`size` UInt64,
`referer` String,
`user_agent` String
)
ORDER BY ()
然后从 nginx_raw 表中读取原始数据,通过正则表达式提取出每个字段的值,并将其写入这张新表中。
INSERT INTO nginx_column_tuple
SELECT
m[1],
m[2],
parseDateTimeBestEffortOrNull(m[3]),
m[4],
m[5],
m[6],
toUInt64OrZero(m[7]),
toUInt64OrZero(m[8]),
if(length(trim(m[9])) = 0, '-', m[9]),
m[10]
FROM (
SELECT arrayElement(extractAllGroups(
toValidUTF8(Body),'^(\S+) - (\S+) \[([^\]]+)\] "([A-Z]+)?\s*(.*?)\s*(HTTP\S+)?" (\d{3}) (\d+) "([^"]*)" "([^"]*)"'
), 1) AS m
FROM nginx_raw
);
接下来,我们再来看一下这张结构化表在磁盘上的占用空间。
SELECT
`table`,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size
FROM system.columns
WHERE `table` = 'nginx_column_tuple'
GROUP BY `table`
FORMAT VERTICAL
Row 1:
──────
table: nginx_column_tuple
compressed_size: 359.52 MiB
uncompressed_size: 18.48 GiB
需要注意的是,这次实验中的未压缩数据大小相比之前有所减少。这一变化主要得益于采用列式存储格式以及通过正则表达式过滤掉了日志中的冗余字符。
压缩后仅为 359.52MB,压缩比达到了 56 倍。仅仅是将日志结构化,把关键信息拆分成列存,就已经将压缩比从原来的 35 倍提升到了 56 倍。虽然日志不再以原始文本形式存在,但所有关键信息都被完整保留。如果需要,还可以随时还原回原始格式,这一点我们将在后续博客中展示。
优化数据类型(压缩率提升至 92 倍)
在列式数据库中,合理选择字段的数据类型至关重要。数据类型决定了数据库需要为该字段预留多少磁盘空间来容纳其可能的最大值。即使实际存储的内容很小,所占空间也取决于数据类型的定义。虽然较小的值更容易被压缩(因为它们包含大量可压缩的零序列),但在读取时解压仍然会占用额外内存。
我们在之前的实验中已经根据 nginx 日志中的字段特征,选择了匹配的 ClickHouse 数据类型。现在,我们希望在此基础上进一步优化。
一个关键优化手段是使用 ClickHouse 提供的 LowCardinality 类型。它通过字典编码压缩方式提升磁盘存储效率。不过这种方式更适用于字段值种类(基数)不太多的场景。实践经验表明,如果某列的不同值少于 10 万,使用 LowCardinality 类型能有效提升压缩效率。
我们先查看每个字段的基数。
SELECT * APPLY uniq
FROM nginx_column_tuple
FORMAT VERTICAL
Row 1:
──────
uniq(remote_addr): 258115
uniq(remote_user): 2
uniq(time_local): 2579628 -- 2.58 million
uniq(request_type): 5
uniq(request_path): 881124
uniq(request_protocol): 3
uniq(status): 15
uniq(size): 69712
uniq(referer): 103048
uniq(user_agent): 28344
结果显示,remote_user、request_type、request_protocol 和 user_agent 这几个 String 类型字段都非常适合使用 LowCardinality 类型。
除了优化数据类型,我们还可以结合 ClickHouse 支持的压缩算法(compression codec),进一步提升整体压缩效果。
接下来,我们使用 LowCardinality 类型结合不同压缩算法重新做一次实验。
CREATE TABLE nginx_column_tuple_optimized_types
(
`remote_addr` IPv4,
`remote_user` LowCardinality(String),
`time_local` DateTime CODEC(Delta(4), ZSTD(1)),
`request_type` LowCardinality(String),
`request_path` String CODEC(ZSTD(6)),
`request_protocol` LowCardinality(String),
`status` UInt16,
`size` UInt32,
`referer` String CODEC(ZSTD(6)),
`user_agent` LowCardinality(String)
)
ORDER BY ()
由于日志数据已经按列存储在 nginx_column_tuple 表中,我们可以直接从该表中读取数据进行重建。
INSERT INTO nginx_column_tuple_optimized_types SELECT * FROM nginx_column_tuple;
来看实验结果:
SELECT
`table`,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size
FROM system.columns
WHERE `table` = 'nginx_column_tuple_optimized_types'
GROUP BY `table`
FORMAT VERTICAL
Row 1:
──────
table: nginx_column_tuple_optimized_types
compressed_size: 218.42 MiB
uncompressed_size: 9.69 GiB
最终压缩后的表仅占用 218MB,压缩比提升至 92 倍。这一成果相当可观,距离我们目标的 170 倍又近了一步。不过,我们还有一个重要优化尚未实施——磁盘上的数据排序方式。
磁盘排序优化(压缩率突破 178 倍!)
大多数压缩算法在相似数据相邻存储时效果最佳。ClickHouse 允许通过设置主键(即排序键)来控制数据在磁盘上的写入顺序。因此,如果我们的目标是实现极致压缩,就可以有意识地选择有利于压缩的数据排序方式。
当然,排序键的设计也需要考虑查询的访问模式。通常排序键中靠前的字段在过滤条件中出现越早,查询性能越好。但在本次实验中,我们优先考虑压缩效果。在实际生产中,需要在查询性能与压缩效率之间做出平衡,当然更高的压缩率往往也意味着更快的查询。
要想进一步提升压缩比,我们需要找出一个排序键,使所有字段都能在该排序方式下获得更好的压缩效果。这里要综合考虑两个因素:字段所占空间大小和字段的基数(唯一值数量)。某些占用空间大的高基数字段,并不会从排序中获益太多,反而会影响其后的字段压缩表现。而如果能选用低基数、占比大的字段来排序,更容易形成连续的数据序列,提升压缩效率。

为此,我们首先分析表中各字段的空间占用情况,可以通过查询 system.columns(https://clickhouse.com/docs/operations/system-tables/columns) 表获得详细信息。
INSERT INTO nginx_column_tuple_optimized_types SELECT * FROM nginx_column_tuple;
接下来,我们查看当前表的磁盘占用情况。
SELECT
name,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size
FROM system.columns
WHERE `table` = 'nginx_column_tuple_optimized_types'
GROUP BY name
ORDER BY sum(data_uncompressed_bytes) DESC
┌─name─────────────┬─compressed_size─┬─uncompressed_size─┐
1. │ referer │ 13.69 MiB │ 4.96 GiB │
2. │ request_path │ 127.19 MiB │ 3.55 GiB │
3. │ size │ 39.37 MiB │ 254.62 MiB │
4. │ time_local │ 22.58 MiB │ 254.62 MiB │
5. │ remote_addr │ 10.76 MiB │ 254.62 MiB │
6. │ user_agent │ 589.91 KiB │ 129.05 MiB │
7. │ status │ 3.42 MiB │ 127.31 MiB │
8. │ request_type │ 722.43 KiB │ 63.78 MiB │
9. │ request_protocol │ 62.79 KiB │ 63.78 MiB │
10. │ remote_user │ 56.81 KiB │ 63.78 MiB │
└──────────────────┴─────────────────┴───────────────────┘
结合空间占用和字段基数的信息,我们可以进一步评估哪组排序键能带来最优压缩效果。
|
Columns |
Uncompressed size |
Cardinality |
|
referer |
4.96 GiB |
103048 |
|
request_path |
3.55 GiB |
881124 |
|
size |
254.62 MiB |
69712 |
|
time_local |
254.62 MiB |
2579628 |
|
remote_addr |
254.62 MiB |
258115 |
|
user_agent |
129.05 MiB |
28344 |
|
status |
127.31 MiB |
15 |
|
request_type |
63.78 MiB |
5 |
|
request_protocol |
63.78 MiB |
3 |
|
remote_user |
63.78 MiB |
2 |
结合这些数据以及字段基数,我们识别出几个排序键候选字段:referrer、remote_addr 和 user_agent。request_path 字段虽然占用空间大,但由于其基数过高,排序带来的压缩收益可能有限。
另一个重要因素是值的分布情况。即便字段基数较高,只要其中有少数值占据了大部分记录,它依然可以成为良好的排序字段。
我们统计了 referrer、remote_addr、user_agent 和 request_path 四个字段中最常见的前 20 个值,并计算它们的占比。
结果显示,referrer 和 user_agent 的分布明显偏斜。并且在前 12 个值之后,分布趋于平稳,没有必要继续分析长尾数据。基于这些分析,我们最终选择的排序键组合为:referrer、remote_user、user_agent、request_path。
排序策略如下:以 referrer 为首,虽然它基数高,但其高度偏斜的分布能提升压缩效果;接着是 remote_addr,这是一列低基数字段,有助于保持后续字段的数据聚集;随后加入 user_agent,其行为和 referrer 类似;最后是 request_path,为这一大字段带来额外的压缩空间。
我们基于此排序逻辑创建新表,并将数据重新导入。
-- Create table
CREATE TABLE nginx_column_tuple_optimized_types_sort
(
`remote_addr` IPv4,
`remote_user` LowCardinality(String),
`time_local` DateTime CODEC(Delta(4), ZSTD(1)),
`request_type` LowCardinality(String),
`request_path` String CODEC(ZSTD(6)),
`request_protocol` LowCardinality(String),
`status` UInt16,
`size` UInt32,
`referer` String CODEC(ZSTD(6)),
`user_agent` LowCardinality(String)
)
ORDER BY (referer, user_agent, remote_user, request_path);
-- Ingest data to new table
INSERT INTO nginx_column_tuple_optimized_types_sort SELECT * FROM nginx_column_tuple;
最后来看一下这张表的总体磁盘使用量。
SELECT
`table`,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size
FROM system.columns
WHERE `table` = 'nginx_column_tuple_optimized_types_sort'
GROUP BY `table`
FORMAT VERTICAL
Row 1:
──────
table: nginx_column_tuple_optimized_types_sort
compressed_size: 109.12 MiB
uncompressed_size: 9.70 GiB
最终压缩结果令人惊艳:日志文件从原始的 20GB 压缩到了仅 109MB,压缩比高达 178 倍!
回到现实(虽然有点失落)
虽然我们实现了惊人的压缩效果,但现实是,大多数情况下,nginx 日志查询并不会按 referer 或 user_agent 字段进行筛选。虽然这些字段在分析时可能会用到,但更多的查询场景是基于时间的,比如“查看最近一小时的访问日志”。我们来看看这种更贴近实际使用的场景下,压缩效果如何。
我们新建一张表,把 time_local 字段作为排序键的第一位。为了避免时间戳过高的基数影响压缩效果,我们将时间值进行粗粒度处理,比如按“天”来归整时间戳。
-- Create table
CREATE TABLE logs_blog.nginx_column_tuple_optimized_types_time_sort
(
`remote_addr` IPv4,
`remote_user` LowCardinality(String),
`time_local` DateTime CODEC(Delta(4), ZSTD(1)),
`request_type` LowCardinality(String),
`request_path` String CODEC(ZSTD(6)),
`request_protocol` LowCardinality(String),
`status` UInt16,
`size` UInt32,
`referer` String CODEC(ZSTD(6)),
`user_agent` LowCardinality(String)
)
ORDER BY (toStartOfDay(time_local), referer, user_agent, remote_user, request_path);
-- Ingest data
INSERT INTO logs_blog.nginx_column_tuple_optimized_types_time_sort SELECT * FROM logs_blog.nginx_column_tuple_optimized_types_sort;
-- Check size
SELECT
`table`,
formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size
FROM system.columns
WHERE `table` = 'nginx_column_tuple_optimized_types_time_sort'
GROUP BY `table`
FORMAT VERTICAL
Row 1:
──────
table: nginx_column_tuple_optimized_types_time_sort
compressed_size: 380.82 MiB
uncompressed_size: 9.76 GiB
最终的压缩效果就没那么惊艳了,这种设置下我们只达到了约 50 倍的压缩率。这也进一步说明了排序方式对压缩效率的影响有多么显著。
总结回顾
通过这一系列优化实验,我们最终将原始日志压缩到了原来的 1/178。以下是各个实验场景和结果的汇总表:
|
名称 |
存储方式 |
压缩方式 |
大小(字节) |
大小(可读) |
压缩比 |
|
nginx-66.log |
local |
None |
21237294480 |
20G |
1.00 |
|
nginx-66.log.gz |
local |
GZIP |
672053616 |
641M |
31.60 |
|
nginx_raw |
Clickhouse |
Uncompressed |
21673487121 |
20.19 GiB |
0.98 |
|
nginx_raw |
Clickhouse |
Compressed |
603582977 |
575.62 MiB |
35.19 |
|
nginx_column_tuple |
Clickhouse |
Compressed |
1387994151 |
1.29 GiB |
15.30 |
|
optimized_types |
Clickhouse |
Compressed |
229027241 |
218.42 MiB |
92.73 |
|
optimized_types_sort |
Clickhouse |
Compressed |
118984801 |
109.12 MiB |
178.49 |
|
optimized_types_sort_time |
Clickhouse |
Compressed |
402837184 |
380.82 MiB |
52.71 |
写在最后
实现高压缩比的日志存储并不轻松,但借助 ClickHouse 这样的列式数据库,我们可以将这一目标变为现实。只要将原始日志转换为结构化格式,采用合适的数据类型,并通过合理的字段排序将相似数据聚集在一起,就能获得极具优势的压缩效果。虽然这种存储布局不一定适用于所有查询场景,但当我们的目标是用最小的空间保留最大量的日志信息时,这无疑是一种高效、可行的方案。
本文展示了列式存储在日志压缩方面的强大能力,不仅节省存储空间,还提升了 I/O 效率,加快了查询速度。以 nginx 访问日志为例,我们最终实现了超过 170 倍的压缩率,证明这一方法具备出色的实践价值。
征稿启示
面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com

2304

被折叠的 条评论
为什么被折叠?



