从一到无穷大 #62 ClickHouse 加速机制持久化格式拆解

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

本作品 (李兆龙 博文, 由 李兆龙 创作),由 李兆龙 确认,转载请注明版权。

文章目录

引言

Clickhouse中存在以下用于加速的结构,从时序数据的角度讲,没有耳目一新的结构,但是该有的都有,要说真缺,也就是一个真.倒排索引了(Projections并不是)。

类别 结构/机制 加速原理(核心点) 典型适用查询 代价/注意事项 资料
有序性/主索引 Primary index(由 ORDER BY/PRIMARY KEY 产生的稀疏主索引) 以 granule 为单位做范围跳读/数据裁剪 高选择性的范围过滤、按排序键前缀过滤、部分 TopN 模式 排序键选错会导致"几乎无法跳读";写入/合并成本与排序键相关 官方博客ClickHouse
数据裁剪 Partition pruning(PARTITION BY 分区裁剪) 先剪掉大量分区/parts,减少参与扫描的数据范围 时间序列按天/月、租户隔离、区域隔离等 分区过细会带来 parts 数膨胀与合并压力;过粗则裁剪弱
有序性/额外结构 Projections(投影) 为同一张表维护"另一套物理布局/排序或列子集",允许part级别于granule级别过滤,允许不存储排序键外的其他数据减少存储量(会增加IO数) 与原表排序不一致的常见查询模式;多维过滤/聚合的加速路径 会增加存储与写入/合并成本;需要观察 EXPLAIN 是否命中 投影文档([ClickHouse](https://clickhouse.com/docs/data-modeling/projections?utm_source=chatgpt.com "Projections
额外结构过滤 Data skipping indexes(跳数索引):minmax / set / bloom_filter / tokenbf_v1 / ngrambf_v1 等 给每个 granule 维护额外摘要(min/max、集合、布隆等),命中时跳过整块数据 非排序键上的过滤;存在性判断;子串/分词过滤(Bloom 系) 选择性不高时收益小;索引会占存储并增加写入/合并成本;新增后可需要 materialize/等 merge 才覆盖旧数据 跳数索引总览(ClickHouse);索引类型示例表([ClickHouse](https://clickhouse.com/docs/optimize/skipping-indexes/examples?utm_source=chatgpt.com "Data Skipping Index Examples
TopN 优化(与裁剪相关) Top-N granule 级跳读(利用 min/max 元数据) 对 TopN 模式(如 ORDER BY x LIMIT N)利用块级元数据提前跳过大量 granules 大表 TopN、排行榜、最近/最大/最小值类查询 依赖数据分布与可用元数据;需要观察读量变化 TopN 跳读官方博客(2026-01-19)(ClickHouse)
全文检索 Text Index(倒排索引 / Inverted index) token → posting list(行号列表),对文本检索从扫描变成索引检索 日志检索、关键词搜索、短语/分词检索 建索引与存储开销;要选好 tokenizer/配置;仍需看命中与召回 文档:Text indexes(inverted indexes)(ClickHouse);官方博客(ClickHouse)
向量检索 Vector similarity index(ANN,如 HNSW) 近似最近邻索引,把高维距离计算从全扫变为 ANN 搜索 embedding 检索、语义相似、推荐召回 TopK ANN 有召回/精度权衡;索引参数和 LIMIT 约束;需要与查询距离函数匹配 ANN 文档(ClickHouse);示例数据集建索引(ClickHouse)
预计算 Materialized Views(物化视图) 写入时增量计算/路由,把查询时的聚合/过滤前置 实时看板、按天/小时聚合、宽表加工 增加写入成本;回填历史要额外策略;要防止重复/延迟写入导致的口径问题 MV 官方博客(ClickHouse);rollup 示例(ClickHouse)
预聚合存储 AggregatingMergeTree(/Summing 等)配合聚合型 MV 合并阶段把同 key 的聚合 state 进一步合并,减少行数与聚合成本 指标类聚合查询、维度汇总层 需要用 AggregateFunction/SimpleAggregateFunction;查询要用 ...Merge 系函数 AggregatingMergeTree 文档([ClickHouse](https://clickhouse.com/docs/engines/table-engines/mergetree-family/aggregatingmergetree?utm_source=chatgpt.com "AggregatingMergeTree table engine
生命周期+降采样 TTL + GROUP BY Rollup(TTL 汇总/降采样) TTL merge 时按规则把明细"滚动汇总"为更粗粒度并保留 近细远粗、历史降采样、成本控制下保留趋势 依赖 TTL merge;GROUP BY 需要符合主键/排序键前缀等约束(实践上要验证) TTL 指南(含 rollup)(ClickHouse);(补充实践注意)(Altinity® Knowledge Base for ClickHouse®)
维表/映射加速 Dictionaries(字典)+ dictGet 把维表变成内存 KV 结构,查询时 O(1) 查字典,常用来替代部分 JOIN 用户属性映射、代码表、标签映射、在线富化 需要维护刷新策略;字典过大占内存;键设计要稳定 Dictionary 文档([ClickHouse](https://clickhouse.com/docs/dictionary?utm_source=chatgpt.com "Dictionary
JOIN 结构化加速 Join table engine(把右表预加载成 Join 结构) 避免每次查询都构建右表哈希/结构;适合"右表固定且重复 JOIN" 高频服务化查询、右表小且变化不频繁 Join 表数据常驻内存(RAM);需要关注内存与更新流程 Join 引擎文档([ClickHouse](https://clickhouse.com/docs/engines/table-engines/special/join?utm_source=chatgpt.com "Join table engine
结果缓存 Query cache(结果缓存) 相同 SELECT 只算一次,后续直接返回结果,降低延迟与资源 报表/看板重复刷新、同查询多用户复用 一致性通常是"可接受的不严格一致";需要设置启用与有效期策略 Query cache 文档([ClickHouse](https://clickhouse.com/docs/operations/query-cache?utm_source=chatgpt.com "Query cache
读路径缓存 mark_cache / uncompressed_cache 等 缓存 marks 与(可选)解压数据,减少随机 IO 与重复解压 热点表/热点范围反复查询 内存分配要平衡;uncompressed_cache 在一些场景未必收益明显 Cache types 文档([ClickHouse](https://clickhouse.com/docs/operations/caches?utm_source=chatgpt.com "Cache types
多级预计算 Cascading Materialized Views(级联 MV) 把加工/聚合拆成多层,逐层降低查询时计算量 多层指标体系、明细→中间层→服务层 链路更复杂;需要监控延迟与重算策略 级联 MV 文档([ClickHouse](https://clickhouse.com/docs/guides/developer/cascading-materialized-views?utm_source=chatgpt.com "Cascading Materialized Views

本文主要解析以下三个结构的持久化格式:

  1. Primary Index
  2. Projections
  3. Data skipping indexes

Primary Index

sql 复制代码
		CREATE TABLE %s (
				created_date    Date     DEFAULT today(),
				created_at      DateTime DEFAULT now(),
				time            String,
				tags_id         UInt32,
				%s,
				additional_tags String   DEFAULT ''
			) ENGINE = MergeTree()
			PARTITION BY toYYYYMM(created_date)
			ORDER BY (tags_id, created_at)
			SETTINGS index_granularity = 8192

使用tsbs导入clickhouse数据,一个part的磁盘结构如下:

这里存在一些公共文件

  1. checksums.txt 元数据 所有文件的 checksum,用于完整性校验
  2. columns.txt 元数据 列定义(名称、类型)
  3. columns_substreams.txt 元数据 每列的子流列表
  4. count.txt 元数据 投影 part 的总行数
  5. metadata_version.txt 元数据 元数据版本
  6. serialization.json 元数据 序列化信息
  7. default_compression_codec.txt 元数据 默认压缩编码
  8. primary.cidx 主键索引 压缩的主键稀疏索引,每个 granule 存该 granule 第一行的投影排序键

通过执行:
./clickhouse local --path /data1/exercise/ClickHouse/build/programs \ -q "SELECT part_name, mark_number, rows_in_granule, created_at, tags_id FROM mergeTreeIndex('benchmark', 'cpu', with_marks=0, with_minmax=0) ORDER BY part_name, mark_number LIMIT 50 FORMAT PrettyCompact"

可以获取主索引的值:

主索引载入过程如下:

Projections

sql 复制代码
CREATE DATABASE IF NOT EXISTS uk;

CREATE OR REPLACE TABLE uk.uk_price_paid_with_proj
(
    price UInt32,
    date Date,
    postcode1 LowCardinality(String),
    postcode2 LowCardinality(String),
    type Enum8(
      'terraced' = 1, 'semi-detached' = 2, 'detached' = 3, 'flat' = 4, 'other' = 0),
    is_new UInt8,
    duration Enum8('freehold' = 1, 'leasehold' = 2, 'unknown' = 0),
    addr1 String,
    addr2 String,
    street LowCardinality(String),
    locality LowCardinality(String),
    town LowCardinality(String),
    district LowCardinality(String),
    county LowCardinality(String),
    PROJECTION by_time (
        SELECT _part_offset ORDER BY date
    ),
    PROJECTION by_town (
        SELECT _part_offset ORDER BY town
    )
)
ENGINE = MergeTree
ORDER BY (postcode1, postcode2, addr1, addr2);
sql 复制代码
INSERT INTO uk.uk_price_paid_with_proj
(
    price, date, postcode1, postcode2, type, is_new, duration,
    addr1, addr2, street, locality, town, district, county
)
SELECT
    -- price: 50k ~ 2.05m 之间波动
    toUInt32(50000 + (cityHash64(number) % 2000000))                                     AS price,

    -- date: 最近 10 年内的日期
    toDate('2015-01-01') + toIntervalDay(toInt32(cityHash64(number) % 3650))             AS date,

    -- postcode1: 类似 "SW1", "E2", "M1"
    concat(
        arrayElement(['SW','E','W','N','SE','NW','NE','S','WC','EC','M','B','LS','L'], toInt32(cityHash64(number) % 14) + 1),
        toString(toInt32(cityHash64(number + 1) % 9) + 1)
    )                                                                                    AS postcode1,

    -- postcode2: 类似 "1AA", "2BB"
    concat(
        toString(toInt32(cityHash64(number + 2) % 9) + 1),
        arrayElement(['AA','AB','AD','AE','AF','BA','BB','BD','BE','BF','DA','DB','DG','EA','EB'], toInt32(cityHash64(number + 3) % 15) + 1)
    )                                                                                    AS postcode2,

    -- type: Enum8 ('terraced','semi-detached','detached','flat','other')
    arrayElement(['other','terraced','semi-detached','detached','flat'], toInt32(cityHash64(number + 4) % 5) + 1)
                                                                                         AS type,

    -- is_new: 0/1
    toUInt8(cityHash64(number + 5) % 2)                                                  AS is_new,

    -- duration: Enum8 ('freehold','leasehold','unknown')
    arrayElement(['unknown','freehold','leasehold'], toInt32(cityHash64(number + 6) % 3) + 1)
                                                                                         AS duration,

    -- addr1/addr2: 高基数字符串
    concat(toString(toInt32(cityHash64(number + 7) % 200) + 1), ' ', arrayElement(['Flat','Unit','Apt',''], toInt32(cityHash64(number + 8) % 4) + 1))
                                                                                         AS addr1,
    concat('Building ', toString(toInt32(cityHash64(number + 9) % 5000) + 1))             AS addr2,

    -- street/locality/town/district/county: 低基数维度
    arrayElement(['High Street','Station Road','Main Street','Church Lane','Park Road','Victoria Road','Green Lane','Manor Road'], toInt32(cityHash64(number + 10) % 8) + 1)
                                                                                         AS street,
    arrayElement(['Central','Riverside','Old Town','New Town','West End','East Side','North Quarter','South Bank'], toInt32(cityHash64(number + 11) % 8) + 1)
                                                                                         AS locality,
    arrayElement(['LONDON','MANCHESTER','BIRMINGHAM','LEEDS','LIVERPOOL','BRISTOL','SHEFFIELD','NEWCASTLE'], toInt32(cityHash64(number + 12) % 8) + 1)
                                                                                         AS town,
    arrayElement(['Camden','Westminster','Islington','Hackney','Salford','Leeds District','Bristol District','Sheffield District'], toInt32(cityHash64(number + 13) % 8) + 1)
                                                                                         AS district,
    arrayElement(['Greater London','Greater Manchester','West Midlands','West Yorkshire','Merseyside','Bristol','South Yorkshire','Tyne and Wear'], toInt32(cityHash64(number + 14) % 8) + 1)
                                                                                         AS county
FROM numbers(10000000);   -- 生成 1kw 行(你想要更多就改这里)

通过如上结构创建Projections,持久化结构如下,每个Projections都有一个独立的目录

每个目录中如下,这里在创表时选择了_part_offset,每个Projections在_parent_part_offset.bin中存储主表的part行号,这样通过查date主索引,就可以找到其在主表中的行号,虽然看起来很美好,甚至于官方博客将其称之为替代二级索引。实际并不总是那么有效,如果目标tag没有在主表中的排序键内,大概率数据是离散分布的,和全表扫区别不大,还是物化视图通用一些,当然Projections也可以选择携带一部分field,这样的话就不需要回主表查询了,IO也更加聚合,就是存储量和写入IO增加了。

  1. primary.cidx 投影的主键稀疏索引(按 date 排序)。每个 granule 存第一行的 date,用于按 date 二分查找 granules
  2. date.bin 投影的 date 列数据(压缩)
  3. date.cmrk2 date 的 marks,每条 mark 指向该 granule 在 date.bin 中的位置
  4. _parent_part_offset.bin 投影每行对应的主表 part 行号
  5. _parent_part_offset.cmrk2 _parent_part_offset 的 marks

可以执行如下语句观察_parent_part_offset.bin内数据

sql 复制代码
./clickhouse local --path /data1/exercise/ClickHouse/build/programs \
  -q "SELECT _part, _parent_part_offset, town
FROM mergeTreeProjection('uk', 'uk_price_paid_with_proj', 'by_town')
WHERE _parent_part_offset >= 0
LIMIT 100;"

Data skipping indexes

sql 复制代码
CREATE DATABASE IF NOT EXISTS demo;

CREATE TABLE demo.events
(
    created_date Date DEFAULT today(),
    created_at   DateTime DEFAULT now(),

    time         String,
    tags_id      UInt32,

    user_id      UInt64,
    event_type   LowCardinality(String),
    message      String,

    additional_tags String DEFAULT '',

    -- 1) minmax:常用于时间/数值范围过滤
    INDEX idx_created_at_minmax created_at TYPE minmax GRANULARITY 1,

    -- 2) set:适合低基数 IN 查询(例如 event_type 或者某些枚举字段)
    INDEX idx_event_type_set event_type TYPE set(1000) GRANULARITY 4,

    -- 3) bloom_filter:适合等值/IN(高基数 id)
    INDEX idx_user_id_bf user_id TYPE bloom_filter(0.01) GRANULARITY 4,

    -- 4) ngrambf_v1:适合 message LIKE '%timeout%' 这种任意子串
    INDEX idx_msg_ngram message TYPE ngrambf_v1(3, 10000, 3, 7) GRANULARITY 1,

    -- 5) tokenbf_v1:适合 token/词级(常见做法是 lower(message))
    INDEX idx_msg_token lower(message) TYPE tokenbf_v1(10000, 7, 7) GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_date)
ORDER BY (tags_id, created_at)
SETTINGS index_granularity = 8192;
sql 复制代码
INSERT INTO demo.events
(created_date, created_at, time, tags_id, user_id, event_type, message, additional_tags)
SELECT
    today() - (number % 10)                                           AS created_date,
    now()   - toIntervalSecond(number % 200000)                        AS created_at,
    toString(now() - toIntervalSecond(number % 200000))                AS time,
    toUInt32(number % 1000)                                            AS tags_id,
    toUInt64((number * 1103515245 + 12345) % 1000000)                  AS user_id,
    multiIf(number % 5 = 0, 'login',
            number % 5 = 1, 'logout',
            number % 5 = 2, 'pay',
            number % 5 = 3, 'timeout',
            'other')                                                   AS event_type,
    concat('user=', toString(user_id), ' msg=',
           multiIf(number % 13 = 0, 'timeout while calling upstream',
                   number % 17 = 0, 'payment failed: insufficient funds',
                   number % 19 = 0, 'login ok',
                   'misc event'))                                      AS message,
    if(number % 7 = 0, 'a=b,c=d', '')                                  AS additional_tags
FROM numbers(10000000);  -- 1kw行

持久化结构如下:

minmax

可以看到有两个minmax索引,一个是minmax_created_date.idx,一个是skp_idx_idx_created_at_minmax.idx2;

前者的作用是用作parttion的过滤,可以执行 od -An -t u2 minmax_created_date.idx 获取其内容;

后者则为列级别的过滤,其基本单位为granule。结构体也很简单

set

其基础结构为MergeTreeIndexAggregatorSet,在update时执行去重,并把数据写入block(for循环),在serializeBinary和deserializeBinary中是具体的序列化逻辑

这其实就是我朝思暮想的DistinctCache。

Bloomfilter

bloom_filter使用MergeTreeIndexGranuleBloomFilter,典型实现

tokenbf_v和ngrambf_v1使用MergeTreeIndexGranuleBloomFilterText,仅分词方式不一致,

  1. ngrambf_v1使用NgramsTokenExtractor;
  2. tokenbf_v1使用SplitByNonAlphaTokenExtractor;
  3. sparse_grams使用SparseGramsTokenExtractor;
索引类型 Bloom filter 里存的"元素" 最适合的查询形态 典型 SQL(能触发) 关键参数 / 调优点 主要限制
bloom_filter 列值本身(或表达式结果) 等值 / IN(needle-in-haystack) col = x / col IN (...) bloom_filter([false_positive_rate])(默认 0.025)(ClickHouse) 不支持 LIKE(官方函数支持表里 LIKE 对 bloom_filter 为 ✗)(ClickHouse);且 Bloom 类索引都无法优化"期望结果为 false"的条件(如 NOT LIKE!= 等)(ClickHouse)
tokenbf_v1 token(词元):按非字母数字分隔的序列 包含某个词(更像"词命中") hasToken(col, 'error')(支持表中对 tokenbf_v1 为 ✔)(ClickHouse) tokenbf_v1(size_bytes, hash_functions, seed)(ClickHouse) 对"任意子串"能力弱;大小写不敏感要对 lower(col) 建索引才有效(官方备注)(ClickHouse)
ngrambf_v1 固定长度 n-grams(滑动切片) 子串搜索 (尤其 LIKE '%...%' col LIKE '%timeout%'(支持表中为 ✔)(ClickHouse) ngrambf_v1(n, size_bytes, hash_functions, seed)(ClickHouse) 若查询常量长度 < n,无法用该索引优化(官方说明)(ClickHouse);同样受 Bloom "假阳性"与"不能优化 false 结果条件"限制(ClickHouse)
sparse_grams sparse grams tokens(稀疏 grams) :不是所有 n-gram,而是按 sparseGrams 算法选取的"更稀疏、可变长度"的 grams(减少常见片段带来的噪声/假阳性)(ClickHouse) 更偏"搜索/子串"场景:在很多文本中比固定 n-gram 更"挑剔"(通常更少 token → 更少误命中) 支持 LIKEmatchstartsWithendsWith(函数支持表中 sparse_grams 为 ✔,而 bloom_filter 对 LIKE/match 为 ✗)(ClickHouse) sparse_grams(min_ngram_length, max_ngram_length, min_cutoff_length, size_bytes, hash_functions, seed)(ClickHouse);其中 min_cutoff_length 会让返回的 grams 长度 ≥ cutoff(函数说明)([ClickHouse](https://clickhouse.com/docs/data-modeling/projections?utm_source=chatgpt.com "Projections ClickHouse Docs"))

结束语

过去几年里,ClickHouse几乎已经成了日志与分布式追踪数据存储的默认选择,并通过收购 Langfuse,进一步把自身延伸为模型训练与部署阶段的数据底座:既承担观测数据的沉淀,也覆盖面向应用与模型的监控能力。

与此同时,ClickHouse在 2024-09-03 推出 TimeSeriesEngine,开始以更细粒度的时间线存储能力补齐 metrics 版图;但无论是它还是国内的 GreptimeDB,当前的主要市场叙事仍然围绕 Prometheus 展开。问题在于,metrics 并不等同于 Prometheus:在公司级中台、车联网、物联网等场景里,metrics 往往是更广义、更强约束、更强业务化的数据形态,但是越来越多的metric开始变的基数更高,不再适合于传统的索引结构,需要向ClickHouse这样的架构演进,这也是Clickhouse、InfluxDB IOX、GrepTimedb等都将自己视为可观测性解决方案平台,而并不是垂类领域的解决方案系统。

从现实出发,ClickHouse的计算与存储引擎、数据导入导出链路、协议与生态适配都已极其成熟。若在有限资源下试图在日志与分布式追踪数据领域正面追赶它,我长期并不乐观。

参考:

  1. The ultimate guide to Open Source Observability in 2026: From silos to stacks
  2. ClickHouse vs Prometheus A detailed comparison Compare ClickHouse and Prometheus for time series and OLAP workloads
  3. TimeSeries table engine
  4. ClickHouse 营收与市值
  5. 中国社会各阶级的分析
  6. Apache Paimon: Streaming Lakehouse is Coming
  7. Apache Paimon: the Streaming Lakehouse
  8. Why continuous profiling is the fourth pillar of observability
  9. A practical introduction to primary indexes in ClickHouse
  10. Time Series Engine
相关推荐
=蜗牛=3 天前
Docker 简单部署 ClickHouse 超详细图文步骤
clickhouse·docker·容器·部署·图文
狼与自由4 天前
clickhouse log引擎
clickhouse
狼与自由5 天前
clickhouse AggregatingMergeTree
clickhouse
狼与自由5 天前
clickhouse ReplacingMergeTree
android·clickhouse
狼与自由6 天前
clickhouse中的分区
clickhouse
狼与自由6 天前
clickhouse 查询
clickhouse
狼与自由6 天前
clickhouse mergeTree
clickhouse
狼与自由7 天前
clickhouse建表
clickhouse
简简单单就是我_hehe7 天前
clickhouse内置函数和关键词总结
clickhouse
狼与自由7 天前
clickhouse引擎
clickhouse·c#·linq