前言

Github:https://github.com/HealerJean

博客:http://blog.healerjean.com

一、基本介绍

列式存储能够减少数据扫描范围,数据按列组织,数据库可以直接获取查询字段的数据。而按行存逐行扫描,获取每行数据的所有字段,再从每一行数据中返回需要的字段,虽然只需要部分字段还是扫描了所有的字段,按列存储避免了多余的数据扫描。

1、存储与查询效率

Clickhouse 采用列式存储,数据按列进行组织,属于同一列的数据会被保存在一起,这是后续实现秒级查询的基础。在存储数据达到20万亿行的情况下,也能做到 90% 的查询能够在1秒内返回结果。列式存储压缩率高,数据在网络中传输更快,对网络带宽和磁盘 IO 的压力更小。

2、实时聚合能力

Clickhouse 能够实现实时聚合,一切查询都是动态、实时的,用户发起查询的那一刻起,整个过程需要能做到在一秒内完成并返回结果。与多种业务场景契合。。

功能完整性Clickhouse支持完整的 DBMS。支持动态创建、修改或删除数据库、表和视图,可以动态查询、插入、修改或删除数据。除了完整的 DBMS、列式存储外,还支持在线实时查询、拥有完善的 SQL 支持和 函数、拥有多样化的表引擎满足各类业务场景。

场景适用性:正因为 Clickhouse 的这些特性,在它适合的场景下能够实现动态、实时的秒级别查询。

3、适合场景

读写特性:读多于写。数据一次写入,多次查询,从各个角度对数据进行挖掘,发现数据的价值。

数据处理模式:针对大宽表,通过选取少量维度列与指标列进行聚合计算,输出少量结果集,满足海量数据下的高效分析需求。。

数据更新需求:数据批量写入,不需要经常更新、删除。数据写入完成后,相关业务不要求经常对数据更新或删除,主要用于查询分析数据的价值。

应用领域Clickhouse 适合用于商业智能领域,广泛应用于广告流量、App 流量、物联网等众多领域。借助 Clickhouse 可以实时计算线上业务数据,如资源位的点击情况,以及并对各资源位进行 bi 预警。

二、MergeTree

MergeTree ClickHouse 最强大的基础存储引擎,其设计理念围绕高效数据存储、索引和查询优化展开。下面从分区、排序、索引和数据读取流程四个方面详细解析,并结合实例说明:

1、ReplacingMergeTree

继承 MergeTree, 在建表时设置 ORDER BY 排序字段作为判断重复数据的唯一键,在合并分区的时候会触发删除重复数据,能够一定程度上解决数据重复的问题。

2、AggregatingMergeTree

继承 MergeTree, 在合并分区的时候按照定义的条件聚合数据,将需要聚合的数据预先计算出来,在聚合查询时直接使用结果数据,以空间换时间的方法提高查询性能。该引擎需要使用 AggregateFunction 类型来处理所有列。

1、分区(Partitioning

分区是将表数据按指定规则拆分为多个独立物理目录的机制。

  • 分区规则:支持多列组合分区(如PARTITION BY (date, region)),但单字段分区效率最高。
  • 物理存储:每个分区对应独立目录,写入时属于同一分区的数据最终会合并到该目录,不同分区数据永不合并。
  • 查询优化:合理分区可大幅减少查询时扫描的数据文件数量。

示例:假设某电商订单表按日期分区:

  • 当查询 2023 年 10 月数据时,ClickHouse 直接定位到202310分区目录,无需扫描其他月份的数据文件。
CREATE TABLE orders (
    order_id UInt64,
    order_date Date,
    user_id UInt64,
    amount Decimal(10, 2)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(order_date)  -- 按月分区
ORDER BY (order_date, user_id);

2、排序(Ordering

排序决定了数据在分区内的物理存储顺序,通过 ORDER BY 子句定义。

ClickHouse 中,排序(Ordering索引(Index) 是紧密协作的两个核心机制,共同决定了数据的物理存储结构和查询效率。理解它们的关系对优化查询性能至关重要,以下是详细解析:

1)排序:定义数据的物理存储顺序

  • 作用:排序决定了数据在磁盘上的存储顺序,通过 ORDER BY 子句声明。
  • 物理存储:数据按排序键(Sorting Key)逐行存储,相同键值的数据连续存放。
  • 与索引的关联:排序键是构建索引的基础,主键索引默认使用排序键的字段。
CREATE TABLE logs (
    event_date Date,
    user_id UInt64,
    event_time DateTime,
    event_type String
) ENGINE = MergeTree()
ORDER BY (event_date, user_id);  -- 按日期和用户ID排序
  • 数据先按 event_date 升序排列,日期相同的记录再按 user_id 排序。
  • 这种排序方式使同一日期、同一用户的记录在物理上连续存储,加速范围查询。

2)排序与索引的协同工作原理

  • 排序是基础:决定数据的物理布局,影响所有查询的性能。
  • 索引是加速器:基于排序键构建,针对特定查询模式优化。
  • 设计关键:让排序键与高频查询的过滤条件匹配,使索引能够最大程度减少数据扫描范围。

a、数据写入时

  • 数据按排序键顺序写入磁盘,形成有序的数据片段(Part)。
  • 索引文件根据排序键生成,记录每个数据块的起始键值和偏移量。

b、数据查询时

  • 索引过滤:通过索引快速定位可能包含目标数据的数据块(基于排序键的有序性)。
  • 范围扫描:在过滤后的数据块内,按物理顺序扫描数据(无需随机 IO)。
特性 排序(Ordering) 索引(Index)
作用 定义数据物理存储顺序 加速数据定位
声明方式 ORDER BY (col1, col2) PRIMARY KEY (col1, col2)INDEX
物理影响 直接决定数据在磁盘上的排列 额外创建索引文件(.idx、.mrk)
查询优化 减少磁盘随机读,适合范围扫描 减少扫描的数据量
适用场景 所有查询(基于数据有序性) 特定条件查询(如 WHERE、JOIN)

3、索引

1)主键索引(Primary Key

主键索引是 MergeTree 引擎的核心索引,通过 PRIMARY KEY 子句声明。它与排序键(ORDER BY)紧密相关:

  • 默认规则:若未显式声明 PRIMARY KEY,则使用 ORDER BY 的字段作为主键。
  • 稀疏索引主键索引采用稀疏存储,默认每 8192 行(由 index_granularity 参数控制)记录一次索引项。

示例 1:显式声明主键

CREATE TABLE logs (
    event_date Date,
    event_time DateTime,
    user_id UInt64,
    event_type String
) ENGINE = MergeTree()
PRIMARY KEY (event_date, user_id)  -- 主键由日期和用户ID组成
ORDER BY (event_date, user_id, event_time);  -- 排序键包含额外字段

示例 2:省略主键(默认使用 ORDER BY)

CREATE TABLE sales (
    date Date,
    product_id UInt64,
    amount Decimal(10, 2)
) ENGINE = MergeTree()
ORDER BY (date, product_id);  -- 主键隐式为 (date, product_id)

2) 二级索引(Skip Index

二级索引(跳过索引)用于加速范围查询,通过 INDEX 子句声明。它基于主键索引进一步筛选数据,减少扫描范围。

INDEX index_name expr TYPE index_type GRANULARITY N
  • expr:索引表达式(如列名、函数)。
  • index_type:索引类型(如 minmaxset(N)ngrambf_v1 等)。
  • GRANULARITY:索引粒度,表示每隔多少个主索引项创建一个二级索引项。

示例:使用 minmax 索引加速日期范围查询

CREATE TABLE visits (
    visit_date Date,
    user_id UInt64,
    pageviews UInt8,
    INDEX date_index visit_date TYPE minmax GRANULARITY 16  -- 每16个主索引项创建一次日期范围索引
) ENGINE = MergeTree()
PRIMARY KEY (user_id)
ORDER BY (user_id, visit_date);

3)特殊索引类型

ClickHouse 提供多种特殊索引类型,适用于不同场景:

1、MinMax 索引:记录数据块的最小值和最大值,用于快速排除不满足条件的数据块。

INDEX date_range visit_date TYPE minmax GRANULARITY 8;

2、Set 索引:记录列中频繁出现的值,适用于等值查询。

INDEX user_set user_id TYPE set(1000) GRANULARITY 4;  -- 最多记录1000个不同值

3、ngram 索引:用于字符串前缀匹配,支持模糊查询。

INDEX url_ngram url TYPE ngrambf_v1(3, 256, 2, 0) GRANULARITY 1;  -- 3-gram,256个桶

4、MergeTree 存储结构

一张 MergeTree 表的完整物理结构包含:

  • 数据表目录:包含所有分区目录。
  • 分区目录:如 202310_1_10_0(表示 2023 年 10 月,第 1-10 个数据片段)。
  • 分区内文件
    • 一级索引文件(.idx):存储稀疏索引,记录数据区间位置,记录每个数据块的起始键值和偏移量,通过 ORDER BYPRIMARY KEY 声明,使用少量的索引能够记录大量数据的区间位置信息,内容生成规则跟排序字段有关,且索引数据常驻内存,取用速度快。借助稀疏索引,可以排除主键范围外的数据文件,从而有效减少数据扫描范围,加速查询速度。
    • 列压缩文件(.bin):每列独立存储,支持高效压缩(如 LZ4ZSTD),存储每一列的数据,每一列字段都有独立的数据文件。
    • 列标记文件(.mrk):记录压缩文件中数据块的偏移量,建立索引与数据的映射。每一列都有对应的标记文件,保存了列压缩文件中数据的偏移量信息,与稀疏索引对齐,又与压缩文件对应,建立了稀疏索引与数据文件的映射关系。不能常驻内存,使用LRU 缓存策略加快其取用速度

索引原理:稀疏索引通过少量索引项定位大量数据。例如,对于 100 万行数据,索引可能每 8192 行记录一次主键范围,只需扫描索引即可排除无关数据块。

5)数据存储和查询流程

a、数据存储流程

数据写入。每批数据的写入,都会生成一个新的分区目录,后续会异步的将相同分区的目录进行合并。按照索引粒度,会分别生成一级索引文件、每个字段的标记和压缩数据文件。写入过程如下图:

image-20250527212716512

b、数据读取流程

读取数据需通过标记文件建立的映射关系,分两步完成:

  1. 读取压缩数据块:通过索引定位数据区间 → 通过标记文件找到对应压缩文件的偏移量 → 加载压缩块到内存。
  2. 解压并读取数据:解压数据块 → 根据查询条件过滤数据 → 返回结果。

示例

`SELECT SUM(amount) FROM orders WHERE order_date = '2023-10-15' AND user_id = 123`
  1. 索引过滤:稀疏索引定位到 2023-10-15 所在的数据区间。
  2. 标记文件映射:通过.mrk 文件找到 order_date user_id 列对应的数据块偏移量。
  3. 数据读取:加载并解压对应列的数据块,过滤出 user_id=123 的记录,计算amount总和。

image-20250527212517465

注意:查询语句如果没有匹配到任务索引,会扫描所有分区目录,这种操作给整个集群造成较大压力。

6)优化建议

  1. 合理分区:按日期、地域等高频查询维度分区(如PARTITION BY toYYYYMM(date))。
  2. 排序键设计:将高频查询的过滤条件(如时间、用户 ID)作为排序键。
  3. 稀疏索引优化:通过 index_granularity 参数调整索引粒度(默认 8192 行),平衡索引大小与查询效率。
  4. 避免跨分区查询:分区过多会增加元数据扫描开销,建议单个表分区数不超过 10 万。

ContactAuthor