2021-01-05
ClickHouse 好用,很大一部分是因为 `MergeTree` 系列表引擎够能打。它负责列式存储、高吞吐写入、后台合并、分区裁剪、稀疏主键索引、数据跳读索引,也撑起了很多生产分析场景。
MergeTree 可以理解成 ClickHouse 里最常用的“分析表底座”。官方文档里也把 MergeTree 家族描述为 ClickHouse 存储能力的核心,具备列式存储、自定义分区、稀疏主键索引、二级跳读索引等能力。
它的写入方式很有特点:
写入数据 -> 生成 part -> part 内排序 -> 后台合并 part
也就是说,插入不是每行慢慢维护一个传统 B 树索引,而是把一批数据组织成 part。每个 part 内部按排序键排好,再由后台任务逐步合并。这个模型非常适合大批量写入和分析查询。
先建一张事件明细表:
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_id、app_id、event_name 放在前面就很合理。查询条件命中排序键前缀,ClickHouse 才更容易用稀疏索引定位读取范围。
如果你把 user_id 放第一位,但大部分查询都是按租户、应用和事件名筛选,查询就可能读很多不必要的数据。
设计排序键时可以按这几个问题来想:
PRIMARY KEY多数场景不用单独写 PRIMARY KEY,因为 ClickHouse 会把排序键当作主键。只有你想让主键比排序键更短时,才考虑单独指定,而且主键必须是排序键前缀。
MergeTree 的主键索引不是一行一个索引项,而是按 granule 建 marks。默认 index_granularity 是 8192,也就是大致每隔一批行记录一个 mark。
查询时,ClickHouse 根据排序键条件判断哪些 granule 值得读,哪些可以跳过。
这就是为什么它能扛巨大数据量:索引很小,常驻内存压力低,但依然能减少大量磁盘读取。
代价也很明确:它不是传统 OLTP 主键索引,不保证主键唯一,也不适合按任意单行随机查。你可以插入多行相同排序键的数据,这在明细分析里很常见。
PARTITION BY 别切太碎很多人刚用 ClickHouse 时,会把分区当成“越细越快”。这很容易翻车。
官方文档也提醒:分区不是替代 ORDER BY 的性能按钮,通常不需要过细分区,更不要拿客户 id、姓名这种高基数字段做分区。
合理分区的目标是:
常见写法:
PARTITION BY toYYYYMM(event_day)
如果数据量没那么大,也可以不写分区:
ENGINE = MergeTree
ORDER BY (tenant_id, app_id, event_name)
没有分区不丢人。乱分区才麻烦。
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 常被用来处理“同一业务 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 会在合并时,把相同排序键下的数值列求和:
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 硬扛所有预聚合需求。
单机表可以用 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。
ClickHouse 官方提供 MySQL 表引擎和 mysql table function。MySQL 引擎支持对远端 MySQL 表执行 SELECT 和 INSERT。
临时查询:
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。
官方文档里提到,简单 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_size、connection_auto_close 等。具体值不要拍脑袋,按并发和远端 MySQL 承受能力来调。
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 侧执行。
如果表达式不受支持,可能导致更多数据被拉到 ClickHouse 侧处理。对大 collection 来说,这会很痛。
所以建议:
_id 查询要处理好 oid 列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。
如果你不想直接用 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。
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 表结构会很难维护。