ClickHouse——MergeTree




2021-01-05

blog_main_img

ClickHouse 好用,很大一部分是因为 `MergeTree` 系列表引擎够能打。它负责列式存储、高吞吐写入、后台合并、分区裁剪、稀疏主键索引、数据跳读索引,也撑起了很多生产分析场景。

MergeTree

MergeTree 可以理解成 ClickHouse 里最常用的“分析表底座”。官方文档里也把 MergeTree 家族描述为 ClickHouse 存储能力的核心,具备列式存储、自定义分区、稀疏主键索引、二级跳读索引等能力。

它的写入方式很有特点:

写入数据 -> 生成 part -> part 内排序 -> 后台合并 part

也就是说,插入不是每行慢慢维护一个传统 B 树索引,而是把一批数据组织成 part。每个 part 内部按排序键排好,再由后台任务逐步合并。这个模型非常适合大批量写入和分析查询。

MergeTree part granule marks

一张基础表先跑起来

先建一张事件明细表:

CREATE TABLE app_events
(
    tenant_id UInt64,
    app_id UInt32,
    user_id UInt64,
    event_name LowCardinality(String),
    event_day Date,
    event_ms UInt64,
    amount Decimal(18, 2),
    properties String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_day)
ORDER BY (tenant_id, app_id, event_name, event_day, user_id)
SETTINGS index_granularity = 8192;

这张表里最重要的不是字段类型,而是下面两行:

PARTITION BY toYYYYMM(event_day)
ORDER BY (tenant_id, app_id, event_name, event_day, user_id)

PARTITION BY 用来做粗粒度切分,ORDER BY 决定 part 内排序,也决定稀疏索引能不能帮查询少读数据。

ORDER BY 是 MergeTree 的灵魂

ClickHouse 的 ORDER BY 不是单纯为了查询结果排序。它是排序键,也是默认主键来源。

如果查询经常长这样:

SELECT
    event_name,
    count() AS total,
    sum(amount) AS revenue
FROM app_events
WHERE tenant_id = 10001
  AND app_id = 8
  AND event_name IN ('pay', 'refund')
  AND event_day >= {start_day:Date}
  AND event_day <= {end_day:Date}
GROUP BY event_name;

那排序键把 tenant_idapp_idevent_name 放在前面就很合理。查询条件命中排序键前缀,ClickHouse 才更容易用稀疏索引定位读取范围。

如果你把 user_id 放第一位,但大部分查询都是按租户、应用和事件名筛选,查询就可能读很多不必要的数据。

MergeTree ORDER BY PARTITION BY 设计

设计排序键时可以按这几个问题来想:

  • 查询最常等值过滤哪些列
  • 哪些列的过滤条件选择性强
  • 哪些列放一起能提升压缩效果
  • 排序键会不会太长,拖累写入和内存
  • 是否需要单独写 PRIMARY KEY

多数场景不用单独写 PRIMARY KEY,因为 ClickHouse 会把排序键当作主键。只有你想让主键比排序键更短时,才考虑单独指定,而且主键必须是排序键前缀。

稀疏索引和 granule

MergeTree 的主键索引不是一行一个索引项,而是按 granule 建 marks。默认 index_granularity8192,也就是大致每隔一批行记录一个 mark。

查询时,ClickHouse 根据排序键条件判断哪些 granule 值得读,哪些可以跳过。

这就是为什么它能扛巨大数据量:索引很小,常驻内存压力低,但依然能减少大量磁盘读取。

代价也很明确:它不是传统 OLTP 主键索引,不保证主键唯一,也不适合按任意单行随机查。你可以插入多行相同排序键的数据,这在明细分析里很常见。

PARTITION BY 别切太碎

很多人刚用 ClickHouse 时,会把分区当成“越细越快”。这很容易翻车。

官方文档也提醒:分区不是替代 ORDER BY 的性能按钮,通常不需要过细分区,更不要拿客户 id、姓名这种高基数字段做分区。

合理分区的目标是:

  • 方便批量删除或替换一批数据
  • 让查询能跳过明显无关的数据块
  • 控制 part 数量,不把后台合并压垮

常见写法:

PARTITION BY toYYYYMM(event_day)

如果数据量没那么大,也可以不写分区:

ENGINE = MergeTree
ORDER BY (tenant_id, app_id, event_name)

没有分区不丢人。乱分区才麻烦。

part 合并:后台默默干活

MergeTree 插入会产生 part。小批量频繁写入会产生很多小 part,后台合并压力就会上来。

更推荐批量写入:

INSERT INTO app_events
SELECT ...

或者客户端一次塞一批行,不要一行一个 insert。

查看 part 状态:

SELECT
    table,
    partition,
    name,
    rows,
    bytes_on_disk,
    active
FROM system.parts
WHERE database = currentDatabase()
  AND table = 'app_events'
ORDER BY rows DESC
LIMIT 20;

如果活跃 part 数量长期很多,通常要检查写入批次、分区粒度和后台合并配置。

数据跳读索引:不是越多越好

除了主键稀疏索引,MergeTree 还支持二级数据跳读索引。

比如日志表里经常按 trace_id 查:

CREATE TABLE app_logs
(
    service LowCardinality(String),
    level LowCardinality(String),
    log_day Date,
    trace_id String,
    message String,
    attrs String,
    INDEX idx_trace trace_id TYPE bloom_filter(0.01) GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(log_day)
ORDER BY (service, level, log_day);

bloom_filter 适合“判断某个值大概率不存在”的场景。它能帮忙跳过一批 granule,但不是万能索引。

跳读索引适合:

  • 条件列不适合放进排序键前缀
  • 过滤条件经常出现
  • 数据分布能让索引跳过足够多数据

不适合:

  • 低选择性列
  • 几乎每次都要全表扫的查询
  • 写入压力很高但索引收益很弱的表

建索引之前,先用真实查询验证收益。

ReplacingMergeTree:去重不是立刻发生

ReplacingMergeTree 常被用来处理“同一业务 id 多次写入,最终保留一条”的场景。

CREATE TABLE user_profile
(
    user_id UInt64,
    version UInt64,
    name String,
    city LowCardinality(String),
    updated_ms UInt64
)
ENGINE = ReplacingMergeTree(version)
ORDER BY user_id;

它会在 part 合并过程中,对相同排序键的数据保留版本更大的行。

重点来了:去重发生在后台合并中,不是插入后马上全局生效。查询如果想得到合并后的结果,可以用:

SELECT *
FROM user_profile FINAL
WHERE user_id = 10001;

FINAL 可能比较重,不建议在高频大查询里随手加。更稳的办法是把使用场景设计清楚:明细表保留原始写入,最终状态表用 ReplacingMergeTree,查询层按需要取舍。

SummingMergeTree 和 AggregatingMergeTree

如果你要做预聚合,可以看这两个引擎。

SummingMergeTree 会在合并时,把相同排序键下的数值列求和:

CREATE TABLE metric_daily
(
    tenant_id UInt64,
    metric_day Date,
    metric_name LowCardinality(String),
    value UInt64
)
ENGINE = SummingMergeTree
PARTITION BY toYYYYMM(metric_day)
ORDER BY (tenant_id, metric_day, metric_name);

它适合简单加和类指标。

更复杂的聚合状态,用 AggregatingMergeTree

CREATE TABLE uv_state
(
    tenant_id UInt64,
    stat_day Date,
    event_name LowCardinality(String),
    users AggregateFunction(uniqCombined64, UInt64)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(stat_day)
ORDER BY (tenant_id, stat_day, event_name);

写入聚合状态:

INSERT INTO uv_state
SELECT
    tenant_id,
    event_day AS stat_day,
    event_name,
    uniqCombined64State(user_id) AS users
FROM app_events
GROUP BY
    tenant_id,
    stat_day,
    event_name;

查询时合并状态:

SELECT
    tenant_id,
    stat_day,
    event_name,
    uniqCombined64Merge(users) AS uv
FROM uv_state
GROUP BY
    tenant_id,
    stat_day,
    event_name;

简单求和用 SummingMergeTree,复杂聚合状态用 AggregatingMergeTree。别拿普通 MergeTree 硬扛所有预聚合需求。

ReplicatedMergeTree:生产更常见

单机表可以用 MergeTree。如果是多副本部署,生产环境通常会用 ReplicatedMergeTree 系列。

示例:

CREATE TABLE app_events_replica
(
    tenant_id UInt64,
    app_id UInt32,
    event_day Date,
    event_name LowCardinality(String),
    user_id UInt64
)
ENGINE = ReplicatedMergeTree(
    '/clickhouse/tables/{shard}/app_events_replica',
    '{replica}'
)
PARTITION BY toYYYYMM(event_day)
ORDER BY (tenant_id, app_id, event_name, event_day);

复制路径和副本名会依赖集群配置。不要在不了解集群拓扑的情况下复制粘贴生产建表 SQL。

连接 MySQL:临时查用 table function,长期用 engine 或落表

ClickHouse 官方提供 MySQL 表引擎和 mysql table function。MySQL 引擎支持对远端 MySQL 表执行 SELECTINSERT

临时查询:

SELECT *
FROM mysql(
    'mysql-host:3306',
    'shop',
    'orders',
    'reader',
    'reader_password'
)
LIMIT 10;

建一张外部表:

CREATE TABLE mysql_orders
(
    id UInt64,
    buyer_id UInt64,
    status String,
    amount Decimal(18, 2),
    order_day Date
)
ENGINE = MySQL(
    'mysql-host:3306',
    'shop',
    'orders',
    'reader',
    'reader_password'
);

把 MySQL 数据落到 MergeTree:

CREATE TABLE orders_local
(
    id UInt64,
    buyer_id UInt64,
    status LowCardinality(String),
    amount Decimal(18, 2),
    order_day Date
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(order_day)
ORDER BY (status, order_day, buyer_id, id);

INSERT INTO orders_local
SELECT
    id,
    buyer_id,
    status,
    amount,
    order_day
FROM mysql_orders;

外部表适合联邦查询、轻量同步、维表补充。真正高频分析,还是建议落到 MergeTree。

ClickHouse 连接 MySQL Mongo

MySQL 连接的一点细节

官方文档里提到,简单 WHERE 条件可以下推到 MySQL 侧,例如 =!=>>=<<=。更复杂的条件可能会在 ClickHouse 侧处理,也就意味着远端读取量可能会变大。

生产配置建议用 named collection,避免密码到处散:

CREATE NAMED COLLECTION mysql_creds AS
    host = 'mysql-host',
    port = 3306,
    database = 'shop',
    user = 'reader',
    password = 'reader_password';

SELECT *
FROM mysql(mysql_creds, table = 'orders')
WHERE id = 10001;

如果要提高复用连接的能力,可以关注 MySQL 表引擎的连接池相关设置,比如 connection_pool_sizeconnection_auto_close 等。具体值不要拍脑袋,按并发和远端 MySQL 承受能力来调。

连接 MongoDB:更像只读外部源

MongoDB 表引擎是只读表引擎,用来读取远端 MongoDB collection。官方文档也说明,MongoDB table function 支持 SELECT 查询。

临时查询:

SELECT *
FROM mongodb(
    'mongo-host:27017',
    'shop',
    'events',
    'reader',
    'reader_password',
    'event_name String, user_id UInt64, amount Float64',
    'authSource=admin'
)
LIMIT 10;

建一张外部表:

CREATE TABLE mongo_events
(
    _id String,
    event_name String,
    user_id UInt64,
    amount Float64,
    raw String
)
ENGINE = MongoDB(
    'mongo-host:27017',
    'shop',
    'events',
    'reader',
    'reader_password',
    'authSource=admin',
    '_id'
);

如果用 URI:

CREATE TABLE mongo_events_uri
(
    _id String,
    event_name String,
    user_id UInt64,
    amount Float64
)
ENGINE = MongoDB(
    'mongodb://reader:reader_password@mongo-host:27017/shop?authSource=admin',
    'events',
    '_id'
);

把 MongoDB 数据落到 MergeTree:

CREATE TABLE events_local
(
    event_name LowCardinality(String),
    user_id UInt64,
    amount Float64
)
ENGINE = MergeTree
ORDER BY (event_name, user_id);

INSERT INTO events_local
SELECT
    event_name,
    user_id,
    amount
FROM mongo_events;

MongoDB 的文档结构比较灵活,ClickHouse 列结构比较硬。接入时最好先把字段结构整理清楚,嵌套对象可以转成 JSON 字符串,真正要高频分析的字段单独拆列。

MongoDB 查询要留意支持范围

官方文档说明,MongoDB 连接只支持简单表达式,例如固定字段过滤、排序和限制行数。这些表达式会被转换到 MongoDB 侧执行。

如果表达式不受支持,可能导致更多数据被拉到 ClickHouse 侧处理。对大 collection 来说,这会很痛。

所以建议:

  • 外部查询先加明确过滤条件
  • _id 查询要处理好 oid 列
  • 不要把 MongoDB 外部表当成高频分析主表
  • 高频分析先抽到 MergeTree

Python 写入 ClickHouse

Python 可以用 clickhouse-connect 操作 ClickHouse。

pip install clickhouse-connect

创建客户端:

import clickhouse_connect


client = clickhouse_connect.get_client(
    host="127.0.0.1",
    port=8123,
    username="default",
    password="",
)

建表:

client.command("""
CREATE TABLE IF NOT EXISTS py_events
(
    tenant_id UInt64,
    app_id UInt32,
    event_day Date,
    event_name LowCardinality(String),
    user_id UInt64,
    amount Float64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_day)
ORDER BY (tenant_id, app_id, event_name, event_day, user_id)
""")

批量写入:

from datetime import date


sample_day = date.today()

rows = [
    [10001, 8, sample_day, "pay", 90001, 19.9],
    [10001, 8, sample_day, "refund", 90002, -9.9],
    [10001, 9, sample_day, "pay", 90003, 29.9],
]

client.insert(
    "py_events",
    rows,
    column_names=[
        "tenant_id",
        "app_id",
        "event_day",
        "event_name",
        "user_id",
        "amount",
    ],
)

查询:

result = client.query("""
SELECT
    event_name,
    count() AS total,
    sum(amount) AS amount_sum
FROM py_events
WHERE tenant_id = {tenant_id:UInt64}
GROUP BY event_name
ORDER BY total DESC
""", parameters={"tenant_id": 10001})

for row in result.result_rows:
    print(row)

写入时尽量批量提交。MergeTree 不喜欢一行一行喂,小批量会带来很多小 part。

Python 从 MySQL 抽取再写入 MergeTree

如果你不想直接用 MySQL 引擎,也可以在 Python 里自己抽取,再写入 ClickHouse。这种方式适合需要清洗、字段映射、质量校验的场景。

pip install pymysql clickhouse-connect
from datetime import date

import clickhouse_connect
import pymysql


mysql_conn = pymysql.connect(
    host="mysql-host",
    port=3306,
    user="reader",
    password="reader_password",
    database="shop",
    cursorclass=pymysql.cursors.DictCursor,
)

ch = clickhouse_connect.get_client(
    host="127.0.0.1",
    port=8123,
    username="default",
    password="",
)

with mysql_conn.cursor() as cursor:
    start_day = date.today()
    cursor.execute("""
        SELECT id, buyer_id, status, amount, order_day
        FROM orders
        WHERE order_day >= %s
    """, (start_day,))
    rows = cursor.fetchall()

ch_rows = [
    [
        item["id"],
        item["buyer_id"],
        item["status"],
        item["amount"],
        item["order_day"],
    ]
    for item in rows
]

ch.insert(
    "orders_local",
    ch_rows,
    column_names=["id", "buyer_id", "status", "amount", "order_day"],
)

这条路线更啰嗦,但控制力更强。你可以在 Python 中做类型处理、异常重试、脏数据隔离,再把干净数据写进 MergeTree。

Python 从 MongoDB 抽取再写入 MergeTree

MongoDB 的结构更灵活,Python 清洗通常更自然。

pip install pymongo clickhouse-connect
import clickhouse_connect
from pymongo import MongoClient


mongo = MongoClient("mongodb://reader:reader_password@mongo-host:27017/shop")
collection = mongo["shop"]["events"]

ch = clickhouse_connect.get_client(
    host="127.0.0.1",
    port=8123,
    username="default",
    password="",
)

docs = collection.find(
    {"event_name": {"$in": ["pay", "refund"]}},
    {"event_name": 1, "user_id": 1, "amount": 1},
).limit(10000)

rows = []

for doc in docs:
    rows.append([
        doc.get("event_name", ""),
        int(doc.get("user_id", 0)),
        float(doc.get("amount", 0)),
    ])

ch.insert(
    "events_local",
    rows,
    column_names=["event_name", "user_id", "amount"],
)

MongoDB 抽取时尤其要小心字段缺失、类型不一致、嵌套结构。不要假设每个文档都长得一样。

一套比较稳的落地姿势

如果是分析系统,我更建议这样分层:

MySQL / MongoDB:业务源
外部表或 Python 抽取:接入层
MergeTree 明细表:分析底座
Replacing / Summing / Aggregating:状态表和聚合表
视图或 BI:查询出口

外部表不是不能查,而是不要把它当核心分析存储。ClickHouse 真正的性能优势,还是来自 MergeTree 的列式存储、排序、索引和后台合并。

建表小抄

明细大表:

ENGINE = MergeTree
PARTITION BY toYYYYMM(event_day)
ORDER BY (tenant_id, app_id, event_name, event_day, user_id)

最终状态:

ENGINE = ReplacingMergeTree(version)
ORDER BY business_id

简单加和预聚合:

ENGINE = SummingMergeTree
ORDER BY (tenant_id, metric_day, metric_name)

复杂聚合状态:

ENGINE = AggregatingMergeTree
ORDER BY (tenant_id, stat_day, event_name)

多副本:

ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/table_name', '{replica}')

常见坑

ORDER BY 不等于展示排序,它是表的读取效率核心。

PARTITION BY 不要切得过细,尤其不要按高基数字段分区。

主键不唯一,不要拿 MergeTree 当 OLTP 唯一索引。

频繁小批写入会产生很多小 part,批量写入更友好。

ReplacingMergeTree 的去重依赖后台合并,查询一致性需求强时要谨慎使用 FINAL

外部 MySQL/Mongo 查询不要不加过滤就扫大表。

MongoDB 文档结构要先整理,否则 ClickHouse 表结构会很难维护。