返回首页

ClickHouse中的MergeTree:颗粒、部分和稀疏索引

ClickHouse中MergeTree引擎的深入技术指南。解释内部结构:部分(8192行的颗粒)、标记(.mrk中的标记)、物理文件.bin和.mrk。涵盖后台合并过程,为什么ORDER BY决定物理顺序和稀疏索引,而PRIMARY KEY只是前缀。展示分区(toYYYYMM)如何切割整个目录,如何读取EXPLAIN indexes=1,命令SHOW/DROP/DETACH/ATTACH PARTITION,以及何时真正需要OPTIMIZE TABLE。使用正确和错误ORDER BY的出价表示例。

MergeTree:ClickHouse如何在磁盘上存储数据并加速查询
Advertisement 728x90

ClickHouse中的MergeTree:引擎如何将分析数据切分为颗粒并合并分区

七年的痛苦、三次生产事故,以及一块架构牌匾

当我第一次听说MergeTree时,心想:“又是一个花哨名字的引擎。”后来在生产环境中,一张包含5亿条投注记录的表开始拖慢原本飞快的查询。我们查看EXPLAIN,看到Read 250000 granules。那时,我还不知道granule是什么。

结果发现,我创建表时用了错误的ORDER BY。每次查询扫描了80%的数据,尽管只过滤了一个列。

MergeTree不仅仅是一个引擎。它是一种架构,决定了数据如何存储在磁盘上、如何压缩,最重要的是——ClickHouse如何决定读取哪些数据块、跳过哪些。理解其内部机制拯救了我的三个项目。下面是我五年来一直遵循的路线图。

Google AdInline article slot

1. 分区、颗粒、标记:磁盘上的套娃

ClickHouse不会将表存储为单个文件。它将数据拆分为分区,每个分区内再拆分为颗粒,并通过标记进行导航。

表bets的磁盘结构:
/var/lib/clickhouse/data/betting/bets/
├── 202401_1_1_0/          # 分区 #1(2024年1月)
│   ├── user_id.bin        # user_id列(二进制数据)
│   ├── user_id.mrk        # user_id的标记
│   ├── created_at.bin
│   ├── created_at.mrk
│   ├── amount.bin
│   ├── amount.mrk
│   └── ...
├── 202401_2_2_0/          # 分区 #2
└── 202402_3_3_0/          # 分区 #3(2月)

分区——MergeTree管理的最小单元。每次插入都会创建一个分区,然后在后台与相邻分区合并。

颗粒——index_granularity行(默认8192)的数据块。ClickHouse按整个颗粒读取数据。你不能读取单行——只能读取整个颗粒。

Google AdInline article slot

标记——指向.bin文件中颗粒位置的指针。.mrk文件包含偏移量:颗粒在磁盘上的起始位置及其偏移量。

为什么这很重要: 当你运行SELECT amount FROM bets WHERE user_id = 123时,ClickHouse使用稀疏索引确定哪些颗粒可能包含这个user_id,并只读取这些颗粒。它甚至不会打开其他颗粒。

2. 合并分区:为什么ClickHouse不会被百万次小插入搞垮

每次INSERT都会在磁盘上创建一个新分区。如果你插入100条记录10,000次——你会有10,000个分区。这是一场灾难:一个查询必须打开10,000个文件。

Google AdInline article slot

ClickHouse如何拯救:

后台的合并过程将小分区粘合为更大的分区。例如:

  • 10个1GB的分区 → 1个10GB的分区

我在生产环境中调整的参数:

<merge_tree>
    <min_rows_for_wide_part>100000</min_rows_for_wide_part>
    <max_bytes_for_merge>100000000000</max_bytes_for_merge>  <!-- 100 GB -->
    <merge_with_ttl_timeout>3600</merge_with_ttl_timeout>
</merge_tree>

专业提示: 如果你进行大插入(100万行以上),该分区不会与其他分区合并,直到出现相邻分区。ClickHouse按键的升序存储分区,所以INSERT ... ORDER BY会有所帮助。

我踩过的坑: 我们通过Kafka以每秒10-50条记录的速度流式传输投注数据。一周后,我们有30万个分区。查询变慢,因为每个查询都要打开所有文件。我们通过将min_rows_for_wide_part提高到50万,并将max_insert_block_size增加到100万来修复。数据流必须在Kafka中缓冲,但分区数量减少到了500个。

3. PRIMARY KEY vs ORDER BY:最常见的初学者错误

在MySQL中,PRIMARY KEY是唯一标识符。在ClickHouse中,并非如此。

-- 我经常看到这个
CREATE TABLE bets (
    user_id UInt64,
    created_at DateTime,
    amount Decimal(18,2)
) ENGINE = MergeTree()
PRIMARY KEY (user_id)      -- ← 错误
ORDER BY (user_id);        -- ← 也是错误

真相:

  • ORDER BY 决定了行在磁盘上的物理顺序。必填。
  • PRIMARY KEY 如果未指定,则与ORDER BY相同。但它可以是ORDER BY的前缀。

正确做法:

ORDER BY (created_at, user_id)   -- 时间优先,然后用户
PRIMARY KEY (created_at)         -- 仅对时间建立索引

发生了什么: ClickHouse基于ORDER BY构建稀疏索引。PRIMARY KEY只告诉它使用ORDER BY的哪一部分进行过滤。

我们生产环境中的真实示例:

-- 错误(慢)
ORDER BY (user_id, created_at)
-- 查询:查找过去一小时的投注。索引没有帮助,我们扫描所有数据。

-- 正确(快)
ORDER BY (created_at, user_id)
-- 查询:通过索引跳转到所需日期,然后在其中按user_id过滤

4. 稀疏索引:8192行如何变成一个索引条目

ClickHouse不会为每一行建立索引。它取一个颗粒(8192行),并在索引中写入:

  • 该颗粒中ORDER BY的最小值
  • 最大值

仅此而已。不是B树,不是哈希表——只是一个简单的min-max对数组。

查询如何加速:

-- 查找5分钟内的投注
SELECT * FROM bets WHERE created_at BETWEEN '2024-03-15 14:00:00' AND '2024-03-15 14:05:00';

-- (稀疏)索引检查每个颗粒:
-- 颗粒1:min='2024-03-15 13:00:00' max='2024-03-15 14:00:00' → 不匹配(max < 14:05?)
-- 颗粒2:min='2024-03-15 14:00:00' max='2024-03-15 15:00:00' → 匹配(min <= 14:05)
-- 颗粒3:min='2024-03-15 15:00:00' max='2024-03-15 16:00:00' → 不匹配(min > 14:05)

为什么快: 索引占用(颗粒数) * 16字节。对于10亿行,大约是190万个颗粒 → 30MB的索引。整个索引可以放入内存。

5. 分区:跳转到正确的月份

PARTITION BY是ClickHouse将分区放置到磁盘上不同目录的规则。

PARTITION BY toYYYYMM(created_at)  -- 按月分区

在磁盘上:

/var/lib/clickhouse/data/betting/bets/
├── 202401/   # 2024年1月
├── 202402/   # 2024年2月
└── 202403/   # 2024年3月

如何加速查询:

SELECT * FROM bets WHERE created_at >= '2024-02-01' AND created_at < '2024-03-01';
-- ClickHouse直接进入文件夹202402/,甚至不打开其他分区

分区何时无帮助:

  • 小分区(按天分区,每天1亿行 → 365个分区,每个300MB → 大量文件)
  • 过滤条件不在分区键上

我的选择: 每月1000万到1亿行使用toYYYYMM(),每天10亿行以上使用toYYYYMMDD()(但那时你需要集群)。

6. .bin和.mrk格式:数据如何存储在磁盘上

我曾经查看过一个表目录,看到:

$ ls -la /var/lib/clickhouse/data/betting/bets/202401_1_1_0/
-rw-r----- 1 clickhouse clickhouse 1.2G  user_id.bin
-rw-r----- 1 clickhouse clickhouse  12M  user_id.mrk
-rw-r----- 1 clickhouse clickhouse 900M  created_at.bin
-rw-r----- 1 clickhouse clickhouse  12M  created_at.mrk
-rw-r----- 1 clickhouse clickhouse 2.1G  amount.bin
-rw-r----- 1 clickhouse clickhouse  12M  amount.mrk
  • .bin — 实际的列数据,使用LZ4(或ZSTD,如果配置)压缩
  • .mrk — 标记:每个颗粒在.bin中的位置

如何读取:

  1. 查询需要user_id=123amount
  2. 稀疏索引说:这个user_id可能在颗粒#45、#46、#47中
  3. ClickHouse打开user_id.mrk,获取颗粒#45的偏移量
  4. 跳转到user_id.bin的该偏移量,读取8192个值
  5. 找到所需user_id的行,记住行号
  6. 使用行号,计算amount.mrk中的位置,只从amount.bin读取需要的字节

结论: 物理上,只读取所需列和所需颗粒的数据。其他都是元数据。

7. 投注表的正确ORDER BY示例

错误的ORDER BY(我犯过的错):

CREATE TABLE betting.bets_wrong
(
    user_id UInt64,
    created_at DateTime64(3),
    amount Decimal(18,2)
)
ENGINE = MergeTree()
ORDER BY (user_id, created_at);   -- 先按用户索引

问题:我们项目中90%的查询是“显示过去一小时的投注”(按时间过滤)。索引没有帮助,因为user_id变化比时间快。ClickHouse扫描所有分区。

正确的ORDER BY:

CREATE TABLE betting.bets_correct
(
    user_id UInt64,
    created_at DateTime64(3),
    amount Decimal(18,2),
    sport LowCardinality(String),
    outcome Enum8('win'=1,'loss'=2)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (created_at, user_id);   -- 先按时间索引

现在:

  • 按日期范围查询:立即跳转到正确的颗粒
  • 在日期内,可以按user_id过滤
  • 可选地,可以添加SECONDARY INDEX(但那是另一个故事)

8. EXPLAIN indexes = 1:查看实际读取了多少颗粒

最有用的调试工具:

EXPLAIN indexes = 1
SELECT user_id, sum(amount)
FROM betting.bets
WHERE created_at >= '2024-03-01' AND created_at < '2024-04-01'
  AND user_id = 100500
GROUP BY user_id;

输出:

Expression (Projection)
  Aggregating
    Expression
      ReadFromMergeTree (betting.bets)
        Indexes:
          Partition key:   partition_idx   (1/3 partitions, 1 read)
          Primary key:     created_at      (42/500 granules, 42 read)
          MinMax:          created_at      (0 skipped, 1 read)

我们看到: 分区中500个颗粒,只读取了42个。过滤程度——8%。如果没有正确的ORDER BY,将是500/500。

9. 管理分区的命令

查看所有分区:

SELECT 
    partition,
    name,
    rows,
    bytes_on_disk,
    modification_time
FROM system.parts
WHERE table = 'bets' AND active = 1;

删除旧分区(比DELETE快):

ALTER TABLE betting.bets DROP PARTITION '202401';

立即清除磁盘。DELETE FROM逐行删除,然后合并——相差数小时。

分离分区(不删除数据):

ALTER TABLE betting.bets DETACH PARTITION '202402';
-- 数据移动到detached/

重新附加:

ALTER TABLE betting.bets ATTACH PARTITION '202402';

将分区复制到另一个表(真实案例):

ALTER TABLE betting.bets_archive REPLACE PARTITION '202401' FROM betting.bets;

10. OPTIMIZE TABLE——何时不需要(以及何时突然需要)

OPTIMIZE TABLE强制手动合并分区。

坏消息: 大多数文章建议定期运行它。好消息: 在99%的情况下,你不需要。ClickHouse在后台自动合并。

我实际使用OPTIMIZE的情况:

  • 加载大量历史数据后(一次插入1亿行)——这样其他分区不必等待计划合并
  • 备份前,减少表中的文件数量
  • 测试——查看压缩后的实际大小

如何安全执行:

OPTIMIZE TABLE betting.bets PARTITION '202403' FINAL;

FINAL将该分区的所有部分合并为一个。没有FINAL——只合并已经准备好的部分。

我的建议: 不要在自动化脚本中使用OPTIMIZE。后台合并已经调优得很好。如果分区没有合并,检查max_bytes_to_merge和可用磁盘空间。

下一步

MergeTree是ClickHouse的心脏。现在你知道它如何跳动了。下一篇文章——关于高级索引:跳过索引、物化列和投影。


上一篇:
下一篇: 将数据加载到 ClickHouse:如何告别逐行插入,将数据摄入速度提升 500 倍

— Editorial Team

Advertisement 728x90

继续阅读