跳到主要内容
跳到主要内容

ClickHouse 中的压缩

ClickHouse 查询高性能的秘诀之一就是压缩。

磁盘上的数据越少,I/O 就越少,查询和插入就越快。在大多数情况下,任何压缩算法在 CPU 上的开销通常都会被 I/O 减少所抵消。因此,在确保 ClickHouse 查询足够快时,提高数据压缩率应当是首要关注点。

关于 ClickHouse 为什么能把数据压缩得如此出色,我们推荐阅读这篇文章。简单来说,作为一款列式数据库,数据是按列顺序写入的。如果这些值是经过排序的,相同的值就会彼此相邻。压缩算法会利用数据中这种连续模式。在此基础之上,ClickHouse 还提供了 codec 和更细粒度的数据类型,使用户可以进一步调优压缩技术。

ClickHouse 中的压缩会受到三个主要因素的影响:

  • 排序键
  • 数据类型
  • 使用的 codec

所有这些都通过表结构(schema)进行配置。

选择合适的数据类型以优化压缩

让我们以 Stack Overflow 数据集为例,比较 posts 表以下模式的压缩统计信息:

  • posts - 未进行类型优化且无排序键的模式。
  • posts_v3 - 类型优化模式,为每列设置了适当的类型和位大小,排序键为 (PostTypeId, toDate(CreationDate), CommentCount)

使用以下查询,我们可以测量每列当前的压缩和未压缩大小。让我们检查无排序键的初始模式 posts 的大小。

SELECT name,
   formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
   formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
   round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'posts'
GROUP BY name

┌─name──────────────────┬─compressed_size─┬─uncompressed_size─┬───ratio────┐
│ Body                  │ 46.14 GiB       │ 127.31 GiB        │ 2.76       │
│ Title                 │ 1.20 GiB        │ 2.63 GiB          │ 2.19       │
│ Score                 │ 84.77 MiB       │ 736.45 MiB        │ 8.69       │
│ Tags                  │ 475.56 MiB      │ 1.40 GiB          │ 3.02       │
│ ParentId              │ 210.91 MiB      │ 696.20 MiB        │ 3.3        │
│ Id                    │ 111.17 MiB      │ 736.45 MiB        │ 6.62       │
│ AcceptedAnswerId      │ 81.55 MiB       │ 736.45 MiB        │ 9.03       │
│ ClosedDate            │ 13.99 MiB       │ 517.82 MiB        │ 37.02      │
│ LastActivityDate      │ 489.84 MiB      │ 964.64 MiB        │ 1.97       │
│ CommentCount          │ 37.62 MiB       │ 565.30 MiB        │ 15.03      │
│ OwnerUserId           │ 368.98 MiB      │ 736.45 MiB        │ 2          │
│ AnswerCount           │ 21.82 MiB       │ 622.35 MiB        │ 28.53      │
│ FavoriteCount         │ 280.95 KiB      │ 508.40 MiB        │ 1853.02    │
│ ViewCount             │ 95.77 MiB       │ 736.45 MiB        │ 7.69       │
│ LastEditorUserId      │ 179.47 MiB      │ 736.45 MiB        │ 4.1        │
│ ContentLicense        │ 5.45 MiB        │ 847.92 MiB        │ 155.5      │
│ OwnerDisplayName      │ 14.30 MiB       │ 142.58 MiB        │ 9.97       │
│ PostTypeId            │ 20.93 MiB       │ 565.30 MiB        │ 27         │
│ CreationDate          │ 314.17 MiB      │ 964.64 MiB        │ 3.07       │
│ LastEditDate          │ 346.32 MiB      │ 964.64 MiB        │ 2.79       │
│ LastEditorDisplayName │ 5.46 MiB        │ 124.25 MiB        │ 22.75      │
│ CommunityOwnedDate    │ 2.21 MiB        │ 509.60 MiB        │ 230.94     │
└───────────────────────┴─────────────────┴───────────────────┴────────────┘
关于紧凑部分与宽部分的说明

如果您看到 compressed_sizeuncompressed_size 的值为 0,这可能是因为数据分区的类型为 compact 而非 wide(请参阅 system.parts 中对 part_type 的说明)。 数据分区格式由设置 min_bytes_for_wide_partmin_rows_for_wide_part 控制,这意味着如果插入的数据所生成的分区未超过上述设置的值,该分区将采用 compact 格式而非 wide 格式,此时您将无法看到 compressed_sizeuncompressed_size 的值。

演示如下:

-- 创建一个具有 compact 分区的表
CREATE TABLE compact (
  number UInt32
)
ENGINE = MergeTree()
ORDER BY number
AS SELECT * FROM numbers(100000); -- 数据量不足以超过 min_bytes_for_wide_part 的默认值 10485760

-- 检查分区的类型
SELECT table, name, part_type from system.parts where table = 'compact';

-- 获取 compact 表的压缩和未压缩列大小
SELECT name,
   formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
   formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
   round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'compact'
GROUP BY name;

-- 创建一个具有 wide 分区的表
CREATE TABLE wide (
  number UInt32
)
ENGINE = MergeTree()
ORDER BY number
SETTINGS min_bytes_for_wide_part=0
AS SELECT * FROM numbers(100000);

-- 检查分区的类型
SELECT table, name, part_type from system.parts where table = 'wide';

-- 获取 wide 表的压缩和未压缩大小
SELECT name,
   formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
   formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
   round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'wide'
GROUP BY name;
   ┌─table───┬─name──────┬─part_type─┐
1. │ compact │ all_1_1_0 │ Compact   │
   └─────────┴───────────┴───────────┘
   ┌─name───┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
1. │ number │ 0.00 B          │ 0.00 B            │   nan │
   └────────┴─────────────────┴───────────────────┴───────┘
   ┌─table─┬─name──────┬─part_type─┐
1. │ wide  │ all_1_1_0 │ Wide      │
   └───────┴───────────┴───────────┘
   ┌─name───┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
1. │ number │ 392.31 KiB      │ 390.63 KiB        │     1 │
   └────────┴─────────────────┴───────────────────┴───────┘

此处显示了压缩和未压缩两种大小,两者都很重要。压缩大小对应我们需要从磁盘读取的数据量——为了提高查询性能(和降低存储成本),我们希望将其最小化。这些数据在读取前需要解压缩。未压缩大小取决于此处使用的数据类型。最小化此大小将减少查询的内存开销以及查询需要处理的数据量,从而提高缓存利用率并最终缩短查询时间。

上述查询依赖于系统数据库中的 columns 表。该数据库由 ClickHouse 管理,是一个包含大量有用信息的宝库,从查询性能指标到后台集群日志应有尽有。我们向有兴趣的读者推荐"系统表与 ClickHouse 内部机制窗口"及其配套文章[1][2]

要汇总表的总大小,我们可以简化上述查询:

SELECT formatReadableSize(sum(data_compressed_bytes)) AS 压缩后大小,
    formatReadableSize(sum(data_uncompressed_bytes)) AS 未压缩大小,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS 压缩比
FROM system.columns
WHERE table = 'posts'

┌─压缩后大小─┬─未压缩大小─┬─压缩比─┐
│ 50.16 GiB       │ 143.47 GiB        │  2.86 │
└─────────────────┴───────────────────┴───────┘

在对经过类型和排序键优化的表 posts_v3 再次执行此查询后,我们可以看到未压缩和压缩后数据大小都有显著降低。

SELECT
    formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
    formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE `table` = 'posts_v3'

┌─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ 25.15 GiB       │ 68.87 GiB         │  2.74 │
└─────────────────┴───────────────────┴───────┘

完整的列级细分结果显示,通过在压缩前对数据进行排序并使用合适的数据类型,BodyTitleTagsCreationDate 列的存储空间实现了可观的节省。

SELECT
    name,
    formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
    formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE `table` = 'posts_v3'
GROUP BY name

┌─列名──────────────────┬─压缩_大小───────┬─未压缩_大小───────┬───压缩比─┐ │ 正文 │ 23.10 GiB │ 63.63 GiB │ 2.75 │ │ 标题 │ 614.65 MiB │ 1.28 GiB │ 2.14 │ │ 评分 │ 40.28 MiB │ 227.38 MiB │ 5.65 │ │ 标签 │ 234.05 MiB │ 688.49 MiB │ 2.94 │ │ 父问题 Id │ 107.78 MiB │ 321.33 MiB │ 2.98 │ │ Id │ 159.70 MiB │ 227.38 MiB │ 1.42 │ │ 已采纳回答 Id │ 40.34 MiB │ 227.38 MiB │ 5.64 │ │ 关闭日期 │ 5.93 MiB │ 9.49 MiB │ 1.6 │ │ 最后活动日期 │ 246.55 MiB │ 454.76 MiB │ 1.84 │ │ 评论数 │ 635.78 KiB │ 56.84 MiB │ 91.55 │ │ 所有者用户 Id │ 183.86 MiB │ 227.38 MiB │ 1.24 │ │ 回答数 │ 9.67 MiB │ 113.69 MiB │ 11.76 │ │ 收藏数 │ 19.77 KiB │ 147.32 KiB │ 7.45 │ │ 浏览数 │ 45.04 MiB │ 227.38 MiB │ 5.05 │ │ 最后编辑者用户 Id │ 86.25 MiB │ 227.38 MiB │ 2.64 │ │ 内容许可 │ 2.17 MiB │ 57.10 MiB │ 26.37 │ │ 所有者显示名 │ 5.95 MiB │ 16.19 MiB │ 2.72 │ │ 帖子类型 Id │ 39.49 KiB │ 56.84 MiB │ 1474.01 │ │ 创建日期 │ 181.23 MiB │ 454.76 MiB │ 2.51 │ │ 最后编辑日期 │ 134.07 MiB │ 454.76 MiB │ 3.39 │ │ 最后编辑者显示名 │ 2.15 MiB │ 6.25 MiB │ 2.91 │ │ 社区所有权日期 │ 824.60 KiB │ 1.34 MiB │ 1.66 │ └───────────────────────┴─────────────────┴───────────────────┴─────────┘

选择合适的列压缩编解码器(codec)

通过列压缩编解码器,我们可以更改用于对每一列进行编码和压缩的算法(及其设置)。

编码和压缩的工作方式略有不同,但目标相同:减小数据体积。编码会对数据应用一种映射,利用数据类型的特性基于某个函数来转换数值。相对地,压缩则使用通用算法在字节层面压缩数据。

通常,会先应用编码,然后再进行压缩。由于不同的编码和压缩算法在不同的值分布上效果不同,我们必须了解自己的数据。

ClickHouse 支持多种编码和压缩算法。下面是一些按重要性排序的建议:

建议说明
优先使用 ZSTDZSTD 压缩提供最佳压缩率。对大多数常见类型,ZSTD(1) 应作为默认选项。可以通过调整数值来尝试更高压缩率。但我们很少在大于 3 的取值上看到足以抵消更高压缩成本(写入更慢)的收益。
对日期和整数序列使用 Delta只要存在单调序列或相邻值之间差值较小,基于 Delta 的编解码器就会表现良好。更具体地说,只要差分后的结果仍然是较小的数值,Delta codec 就能很好工作。如果不是,值得尝试 DoubleDelta(如果 Delta 的一阶差分已经非常小,通常收益有限)。对于单调递增且增量固定的序列,例如 DateTime 字段,压缩效果会更好。
Delta 能提升 ZSTD 的效果ZSTD 对差分数据是非常有效的编解码器——反过来说,Delta 编码可以提升 ZSTD 的压缩效果。在使用 ZSTD 的情况下,其他编解码器很少还能带来进一步改进。
如有可能优先选 LZ4 而非 ZSTD如果在 LZ4ZSTD 之间能获得相近的压缩率,应优先选择前者,因为它解压更快且占用更少 CPU。不过在大多数场景下,ZSTD 的表现会比 LZ4 明显更好。在与 LZ4 组合使用时,部分编解码器可能运行更快,同时在压缩率上接近不带编解码器的 ZSTD。但这高度依赖具体数据,必须通过测试验证。
对稀疏或小范围数据使用 T64T64 在稀疏数据上,或在一个块中的取值范围较小时,可能会很有效。避免在随机数上使用 T64
对未知模式尝试 GorillaT64如果数据模式未知,可能值得尝试 GorillaT64
对 gauge 数据使用 GorillaGorilla 对浮点数据可能很有效,特别是表示 gauge 读数(例如随机尖峰)的数据。

更多选项参见此处

下面我们为 IdViewCountAnswerCount 指定 Delta 编解码器,假设它们与排序键近似线性相关,因此应该能从 Delta 编码中获益。

CREATE TABLE posts_v4
(
        `Id` Int32 CODEC(Delta, ZSTD),
        `PostTypeId` Enum('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8),
        `AcceptedAnswerId` UInt32,
        `CreationDate` DateTime64(3, 'UTC'),
        `Score` Int32,
        `ViewCount` UInt32 CODEC(Delta, ZSTD),
        `Body` String,
        `OwnerUserId` Int32,
        `OwnerDisplayName` String,
        `LastEditorUserId` Int32,
        `LastEditorDisplayName` String,
        `LastEditDate` DateTime64(3, 'UTC'),
        `LastActivityDate` DateTime64(3, 'UTC'),
        `Title` String,
        `Tags` String,
        `AnswerCount` UInt16 CODEC(Delta, ZSTD),
        `CommentCount` UInt8,
        `FavoriteCount` UInt8,
        `ContentLicense` LowCardinality(String),
        `ParentId` String,
        `CommunityOwnedDate` DateTime64(3, 'UTC'),
        `ClosedDate` DateTime64(3, 'UTC')
)
ENGINE = MergeTree
ORDER BY (PostTypeId, toDate(CreationDate), CommentCount)

这些列的压缩效果提升如下:

SELECT
    `table`,
    name,
    formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
    formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE (name IN ('Id', 'ViewCount', 'AnswerCount')) AND (`table` IN ('posts_v3', 'posts_v4'))
GROUP BY
    `table`,
    name
ORDER BY
    name ASC,
    `table` ASC

┌─table────┬─name────────┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ posts_v3 │ AnswerCount │ 9.67 MiB        │ 113.69 MiB        │ 11.76 │
│ posts_v4 │ AnswerCount │ 10.39 MiB       │ 111.31 MiB        │ 10.71 │
│ posts_v3 │ Id          │ 159.70 MiB      │ 227.38 MiB        │  1.42 │
│ posts_v4 │ Id          │ 64.91 MiB       │ 222.63 MiB        │  3.43 │
│ posts_v3 │ ViewCount   │ 45.04 MiB       │ 227.38 MiB        │  5.05 │
│ posts_v4 │ ViewCount   │ 52.72 MiB       │ 222.63 MiB        │  4.22 │
└──────────┴─────────────┴─────────────────┴───────────────────┴───────┘

6 rows in set. Elapsed: 0.008 sec

ClickHouse Cloud 中的压缩

在 ClickHouse Cloud 中,我们默认使用 ZSTD 压缩算法(默认压缩级别为 1)。该算法的压缩速度会随压缩级别而变化(级别越高压缩越慢),但在解压缩阶段始终保持较快速度(波动约 20%),并且还可以并行化处理。我们的历史测试也表明,该算法通常已经足够高效,甚至可以优于与其他编解码器配合使用的 LZ4。它在大多数数据类型和信息分布上都表现良好,因此是一个合理的通用默认选择,这也使得即便不做进一步优化,我们的初始压缩效果就已经非常出色。