数据库基础体系 · 第 47/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
ClickHouse 查询与运维:批量写入、物化视图、集群和性能诊断
ClickHouse 是面向分析场景的列式数据库。它擅长扫描大量数据、按列读取、并行聚合和压缩存储,但它并不是把传统行式 OLTP 数据库的事务模型简单替换成另一套 SQL 语法。
理解 ClickHouse 的查询与运维,至少要先建立下面几条主线:
- 批量写入决定了数据如何形成数据 part,以及后台合并的压力。
- 物化视图决定了写入时是否同步派生数据,以及聚合结果能否正确处理迟到数据和重复写入。
- 集群由分片、副本、路由和协调服务共同组成;“复制”和“分片”解决的是不同问题。
- 性能诊断需要区分扫描过多、过滤失效、聚合过重、后台合并、网络交换和资源争用。
- 运维操作必须同时说明状态、风险、验证方法和恢复路径,而不能只给一条命令。
下文以公开稳定语义为准。具体部署还会受到 ClickHouse 版本、配置文件、Keeper、表引擎和客户端驱动行为影响。
一、先建立数据模型:MergeTree 为什么是所有讨论的基础
1.1 MergeTree 表不是“每次插入一行就修改一个大文件”
典型的 MergeTree 表可以这样创建:
CREATE TABLE events_local
(
event_time DateTime,
event_date Date MATERIALIZED toDate(event_time),
user_id UInt64,
event_type LowCardinality(String),
value Float64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_type, event_time, user_id);
这里有三个容易混淆的概念:
PARTITION BY是物理分区键,决定数据进入哪个 partition。ORDER BY是数据排序键,决定每个数据 part 内的数据排序方式,也是主索引组织的基础。PRIMARY KEY如果显式声明,决定主索引表达式;没有显式声明时,通常使用ORDER BY表达式作为主键表达式。
对这个表执行一次 INSERT,ClickHouse 通常不会立即修改已有大文件,而是把这一批数据写成一个或多个新的 part。后台线程再把多个 part 合并成更大的 part。
因此,下面两个过程是不同的:
前台 INSERT:
输入 block -> 按排序键排序 -> 写出新 part -> 对查询可见
后台 merge:
多个旧 part -> 归并排序 -> 新 part -> 删除或替换旧 part
单次插入的可见性通常是原子的:查询不会看到一个 part 只写了一半的状态。但这不意味着多个 INSERT 语句之间具有传统数据库的跨语句事务语义。
1.2 排序键如何影响查询
MergeTree 的主索引不是为每一行保存一个 B-tree 节点,而是按 granule 保存稀疏索引信息。一个索引条目代表一段连续的数据范围,具体粒度由 index_granularity 等设置影响。
假设数据按:
ORDER BY (event_type, event_time, user_id)
排序,查询:
SELECT count()
FROM events_local
WHERE event_type = 'purchase'
AND event_time >= '2025-01-01 00:00:00'
AND event_time < '2025-01-02 00:00:00';
主索引可以利用前缀:
event_type = 'purchase'
event_time 落在给定范围
从而跳过不可能匹配的 granule。这里的“跳过”发生在读取数据块之前,属于数据跳过,而不是读取后再过滤。
但如果排序键是:
ORDER BY (user_id, event_time)
而查询只有:
WHERE event_type = 'purchase'
由于 event_type 不在排序键前缀中,主索引通常不能有效缩小读取范围。查询仍然可能正确,但会读取更多列和更多 granule。
这解释了一个重要边界:
排序键不是“所有常用查询字段的索引列表”,而是根据数据访问模式设计的物理排序顺序。
1.3 分区不是普通索引
对于:
PARTITION BY toYYYYMM(event_date)
查询:
WHERE event_date >= '2025-01-01'
AND event_date < '2025-02-01'
可以裁剪到 202501 分区。
但分区过细会带来大量 part、目录和后台合并任务。例如按用户 ID 分区,可能产生极多小分区,导致:
- 每次插入创建许多小 part;
- 后台 merge 被分区边界阻断;
- 元数据和文件数量膨胀;
OPTIMIZE和备份操作更复杂。
分区通常适合时间、租户等低到中等基数的生命周期管理维度,而不是代替排序键实现任意过滤。
二、批量写入:从客户端 block 到后台 merge
2.1 为什么批量写入比逐行写入重要
ClickHouse 的执行和存储都偏向 block 处理。批量写入可以减少:
- 网络往返;
- SQL 解析和查询初始化;
- part 创建次数;
- 排序与压缩的固定开销;
- 副本之间的日志和元数据操作次数。
反例是逐行写入:
INSERT INTO events_local VALUES (...);
INSERT INTO events_local VALUES (...);
INSERT INTO events_local VALUES (...);
即使每条语句都很小,ClickHouse 仍要为大量小批次执行插入流程。结果通常不是“写入更实时”,而是产生大量小 part,最终出现:
Too many parts
Too many parts (N). Merges are processing significantly slower than inserts
具体阈值和错误形式取决于版本与表设置,但根因通常是part 产生速度长期超过合并速度。
更合适的方式是使用一条批量 INSERT:
INSERT INTO events_local
(event_time, user_id, event_type, value)
FORMAT JSONEachRow
{"event_time":"2025-01-01 10:00:00","user_id":1,"event_type":"view","value":1}
{"event_time":"2025-01-01 10:00:01","user_id":2,"event_type":"purchase","value":99.5}
{"event_time":"2025-01-01 10:00:02","user_id":3,"event_type":"view","value":1}
生产环境通常由客户端驱动以流式方式发送批量数据,而不是拼接巨大的 SQL 字符串。常见格式包括:
Native:ClickHouse 原生二进制格式,通常效率较高;JSONEachRow:易调试,适合服务集成;CSV、TSV:适合批处理;Parquet:适合文件交换和列式数据导入。
2.2 “一批数据”不是固定行数
批次大小没有跨业务通用的固定数字。它受到以下因素影响:
- 每行大小;
- 列数量和类型;
- 分区数量;
- 排序键基数;
- 网络带宽;
- 副本数量;
- 同时写入的客户端数量;
- 后台 merge 速度。
一个批次即使有很多行,如果这些行分散到大量 partition,仍然可能创建大量 part。可以把 part 产生压力粗略理解为:
新 part 数量 ≈ 每个 INSERT block 中实际涉及的 partition 数量
这是近似关系,不是所有引擎和版本的规范公式,但足以说明为什么“总行数大”不等于“part 少”。
例如同样插入一百万行:
情况 A:全部属于 2025-01
可能只涉及一个 partition
情况 B:分散到 1000 个日期、租户或自定义分区
可能同时制造大量 partition 内的写入对象
因此批量写入应同时观察:
SELECT
database,
table,
partition,
count() AS parts,
sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS bytes
FROM system.parts
WHERE database = currentDatabase()
AND table = 'events_local'
AND active
GROUP BY database, table, partition
ORDER BY parts DESC
LIMIT 20;
system.parts 中只统计 active part,才能避免把已经被合并替换的历史 part 混入当前状态。
2.3 异步插入:减少小批次压力,但不是“自动解决一切”
ClickHouse 支持异步插入。启用后,服务器可以先把多个客户端的小插入缓冲起来,达到一定条件后再形成较大的写入批次。
示例:
INSERT INTO events_local
SETTINGS
async_insert = 1,
wait_for_async_insert = 1
FORMAT JSONEachRow
{"event_time":"2025-01-01 10:00:00","user_id":1,"event_type":"view","value":1}
两个设置的语义不同:
async_insert = 1:允许服务器异步缓冲插入数据;wait_for_async_insert = 1:客户端等待缓冲数据真正写入后再返回。
如果设置:
SETTINGS wait_for_async_insert = 0
客户端可能在数据尚未持久化、甚至尚未完成真正写入前就收到成功响应。它降低了写入等待,但也提高了调用方确认数据已落盘的风险。
异步插入还涉及以下边界:
- 缓冲通常按表、用户、查询设置和数据格式等维度组织,不能把它理解成整个集群的全局队列。
- 服务进程崩溃、超时或配置不当时,需要明确哪些数据已经确认、哪些数据需要重试。
- 物化视图通常在实际处理该插入 block 时执行,异步只改变前台接收路径,不改变物化视图的业务语义。
- 生产上应配合
system.asynchronous_insert_log等系统日志检查异步插入结果,具体日志是否启用取决于配置和版本。
2.4 重试与幂等:成功响应不能随意重复写
网络超时不等于服务器一定没有写入。调用方若直接重试:
客户端发送 INSERT
服务器已写入
响应在网络中丢失
客户端认为失败并重试
就可能产生重复数据。
对于复制表,ClickHouse 有基于插入块的去重机制;也可以使用 insert_deduplication_token 为同一业务批次提供稳定的去重标识。但必须区分:
- 去重是特定引擎和路径下的机制,不是所有表、所有写入方式都自动提供业务级幂等;
- 去重依赖服务端保存的去重信息,其保留范围和配置有限;
- 使用不同 token 重试同一批数据,仍会被当成不同批次;
- 业务上真正可靠的幂等通常还需要事件 ID、版本号或下游去重设计。
如果业务要求“同一事件只能计算一次”,最稳妥的建模通常是保留唯一事件标识,并让查询或下游汇总明确处理重复,而不是只依赖网络层重试策略。
三、物化视图:写入触发器,而不是自动刷新查询结果
3.1 ClickHouse 物化视图的基本数据流
ClickHouse 的普通物化视图可以理解为:
INSERT INTO source
|
v
读取本次 INSERT 的数据 block
|
v
执行 SELECT
|
v
INSERT INTO target
创建一个原始事件表和汇总表:
CREATE TABLE events
(
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
amount Decimal(18, 2)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time, user_id);
使用 SummingMergeTree 保存按天、按类型的汇总:
CREATE TABLE event_daily
(
day Date,
event_type LowCardinality(String),
events UInt64,
amount Decimal(18, 2)
)
ENGINE = SummingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (day, event_type);
创建物化视图:
CREATE MATERIALIZED VIEW mv_event_daily
TO event_daily
AS
SELECT
toDate(event_time) AS day,
event_type,
count() AS events,
sum(amount) AS amount
FROM events
GROUP BY
day,
event_type;
插入:
INSERT INTO events VALUES
('2025-01-01 10:00:00', 1, 'purchase', 10.00),
('2025-01-01 10:01:00', 2, 'purchase', 20.00),
('2025-01-01 10:02:00', 3, 'view', 0.00);
本次插入的 block 经过视图查询后,逻辑上产生:
2025-01-01, purchase, 2, 30.00
2025-01-01, view, 1, 0.00
然后这些结果被插入 event_daily。
3.2 为什么汇总表查询仍然通常要再次聚合
SummingMergeTree 的合并是后台异步行为。不同批次产生的相同 (day, event_type) 记录,在后台 merge 完成前可能仍然是多行。
因此不能简单依赖:
SELECT *
FROM event_daily
WHERE day = '2025-01-01';
来保证每个键只返回一行。更稳妥的查询是:
SELECT
day,
event_type,
sum(events) AS events,
sum(amount) AS amount
FROM event_daily
WHERE day = '2025-01-01'
GROUP BY day, event_type;
后台 merge 只是减少物理行和查询工作,不应被当作查询正确性的唯一来源。
3.3 SummingMergeTree 与 AggregatingMergeTree 的区别
SummingMergeTree 适合简单数值求和。例如:
同一排序键:
(2025-01-01, purchase, 2, 30)
(2025-01-01, purchase, 3, 40)
合并后可得到:
(2025-01-01, purchase, 5, 70)
但它不适合所有聚合。例如:
- 去重计数;
- 分位数;
- 复杂状态聚合;
- 需要保留中间聚合状态的函数。
这时可以使用 AggregatingMergeTree,并在物化视图中写入聚合状态:
CREATE TABLE user_daily_agg
(
day Date,
event_type LowCardinality(String),
users AggregateFunction(uniq, UInt64),
amount AggregateFunction(sum, Decimal(18, 2))
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (day, event_type);
物化视图:
CREATE MATERIALIZED VIEW mv_user_daily_agg
TO user_daily_agg
AS
SELECT
toDate(event_time) AS day,
event_type,
uniqState(user_id) AS users,
sumState(amount) AS amount
FROM events
GROUP BY day, event_type;
查询时必须使用对应的合并函数:
SELECT
day,
event_type,
uniqMerge(users) AS users,
sumMerge(amount) AS amount
FROM user_daily_agg
GROUP BY day, event_type;
这里的关键是:
uniqState 产生可合并的聚合状态
uniqMerge 合并这些状态并得到最终值
不能把 AggregateFunction 状态列当作普通整数直接 sum()。
3.4 物化视图只处理新写入的数据
普通增量物化视图通常处理创建后流入源表的插入数据。创建视图时,已有历史数据不会自动完整回填,除非显式执行回填:
INSERT INTO event_daily
SELECT
toDate(event_time) AS day,
event_type,
count() AS events,
sum(amount) AS amount
FROM events
GROUP BY day, event_type;
回填时存在两个风险:
- 如果物化视图仍然启用,并且回填语句写入的是源表,历史数据可能被再次触发;
- 如果回填直接写入目标表,要确保目标表没有已有重复结果。
常见做法是:
先设计独立的历史回填路径
确认目标表分区和时间范围
避免让同一批历史数据同时经过 INSERT 源表和直接 INSERT 目标表
回填后按时间范围核对源表与目标表
也可以使用创建视图时的相关设置控制是否处理已有数据,但这类设置需要根据实际版本和创建语句确认,不能只根据其他数据库的“创建即全量刷新”经验推断。
3.5 迟到数据、更新和删除是物化视图的核心边界
假设某事件先写入:
2025-01-01 purchase amount = 10
物化视图生成:
2025-01-01 purchase amount = 10
后来业务发现应修正为 15,并再次插入一条 15。对 SummingMergeTree 来说,结果可能变成 25,而不是自动把 10 改成 15。
因此物化视图不是通用 UPDATE 触发器。对于修正型数据,需要选择明确的策略:
- 原始表保留事件版本,查询时使用
argMax或其他版本选择逻辑; - 使用
ReplacingMergeTree,并接受后台去重的异步性质; - 写入正负抵消记录;
- 按受影响时间分区重建目标汇总;
- 在上游保证事件不可变,只追加修正事件。
例如使用版本化原始表:
CREATE TABLE user_state
(
user_id UInt64,
status String,
version UInt64
)
ENGINE = ReplacingMergeTree(version)
ORDER BY user_id;
查询“当前状态”时,常见形式是:
SELECT
user_id,
argMax(status, version) AS status
FROM user_state
GROUP BY user_id;
ReplacingMergeTree 的替换也依赖 merge;如果业务要求查询时立即得到唯一最新值,通常仍需要 argMax,不能只依赖 FINAL。FINAL 会增加读取和计算成本,应当针对具体查询评估。
四、查询执行:从分区裁剪到并行聚合
4.1 查询性能的基本分解
一个典型查询可以拆成:
读取 part
-> 分区裁剪
-> 主索引和数据跳过
-> 读取所需列
-> PREWHERE / WHERE 过滤
-> JOIN、聚合、排序
-> 多线程合并
-> 返回结果
列式存储的收益来自只读取需要的列。例如:
SELECT count()
FROM events
WHERE event_type = 'purchase';
通常不需要读取 user_id、amount 等未参与查询的列。但如果写成:
SELECT *
FROM events
WHERE event_type = 'purchase';
就会读取更多列,网络传输和解压成本也会增加。
PREWHERE 是一种读取优化:先读取过滤列,筛出候选行后,再读取其他需要返回的列。ClickHouse 可以自动选择 PREWHERE,也可以显式写:
SELECT
user_id,
amount
FROM events
PREWHERE event_type = 'purchase'
WHERE event_time >= '2025-01-01';
它的适用价值在于过滤选择性较高、返回列较多的查询;如果过滤几乎不能排除数据,收益可能很小。
4.2 用 EXPLAIN 验证,而不是凭 SQL 外观猜测
查看执行计划:
EXPLAIN indexes = 1
SELECT count()
FROM events
WHERE event_type = 'purchase'
AND event_time >= '2025-01-01'
AND event_time < '2025-02-01';
不同版本输出格式会变化,但应重点观察:
- 是否裁剪了不相关分区;
- 主键条件是否被识别;
- 预计读取多少 granule;
- 是否使用了数据跳过索引;
- 是否出现大规模全表扫描。
还可以查看执行管线:
EXPLAIN PIPELINE
SELECT
event_type,
count()
FROM events
WHERE event_time >= now() - INTERVAL 1 DAY
GROUP BY event_type;
它可以显示读取、过滤、聚合、排序和合并等处理器,以及并行度的大致结构。
4.3 数据跳过索引不是普通二级索引
数据跳过索引保存的是数据块级别的摘要。例如对低基数但不适合放在排序键前缀的字段,可以考虑:
ALTER TABLE events
ADD INDEX idx_user_id user_id TYPE bloom_filter(0.01) GRANULARITY 4;
然后物化已有数据中的索引:
ALTER TABLE events MATERIALIZE INDEX idx_user_id;
查询:
SELECT count()
FROM events
WHERE user_id = 123456;
数据跳过索引只有在摘要能够排除大量 granule 时才有价值。它不能保证像 B-tree 那样直接定位每一行,也会增加写入、存储和维护成本。
常见误解是:
“加了 bloom_filter,就一定能让 user_id 查询变成 O(1)。”
实际情况是:ClickHouse 仍然要遍历相关 part 的索引摘要,只有能证明某些块不可能匹配时,才跳过这些块。高命中率、几乎每块都包含目标值的字段,可能几乎没有收益。
五、集群:分片、复制、Distributed 表和 Keeper
5.1 分片和副本解决不同问题
假设集群有两个分片,每个分片有两个副本:
shard 1: replica 1, replica 2
shard 2: replica 1, replica 2
- 分片(shard):把数据水平拆开,扩大总存储和写入能力。
- 副本(replica):保存同一分片的数据副本,提高可用性和读取能力。
- 协调服务 Keeper:保存复制元数据、日志队列、选主或分布式协调信息。
复制不等于分片。一个三副本单分片集群可以提高容灾能力,但不一定把数据总吞吐线性扩大三倍。分片则决定了单个查询是否需要访问多个数据子集。
5.2 本地表与 Distributed 表
在每个节点上创建本地表:
CREATE TABLE events_local
(
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
amount Decimal(18, 2)
)
ENGINE = ReplicatedMergeTree(
'/clickhouse/tables/{shard}/events_local',
'{replica}'
)
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time, user_id);
再创建逻辑上的分布式表:
CREATE TABLE events_all
AS events_local
ENGINE = Distributed(
'analytics_cluster',
currentDatabase(),
events_local,
cityHash64(user_id)
);
Distributed 表通常不保存实际数据。它负责:
- 根据分片键把 INSERT 路由到分片;
- 查询时向各分片发送子查询;
- 接收各分片结果并在发起节点合并。
这里的:
cityHash64(user_id)
是分片表达式。相同 user_id 会稳定地路由到同一分片,但这并不等于自动实现业务上的全局唯一,也不保证数据分布均匀。
5.3 查询分发的两阶段
执行:
SELECT
event_type,
count()
FROM events_all
WHERE event_time >= '2025-01-01'
GROUP BY event_type;
逻辑上近似为:
协调节点:
将过滤和局部聚合下发到各分片
分片节点:
读取本地 events_local
WHERE 过滤
计算局部 count
返回 event_type -> count
协调节点:
合并各分片的局部 count
count、sum 等可合并聚合适合这种两阶段执行。对于排序、LIMIT、JOIN 和复杂聚合,网络中间结果可能很大,协调节点也可能成为瓶颈。
如果查询带有:
ORDER BY some_column
LIMIT 100
下推和合并策略取决于查询结构及设置,不能简单假定“每个分片只返回 100 行就足够”。正确性需要由全局合并过程保证,而性能需要用执行计划和查询日志验证。
5.4 Distributed 写入和副本选择
使用 Distributed 表写入时,分片表达式决定数据进入哪个 shard。每个 shard 内如何选择副本,则取决于集群配置、internal_replication 和相关分布式写入设置。
使用复制表时,常见设计是:
客户端
-> Distributed 表
-> 每个 shard 选择一个副本写入
-> ReplicatedMergeTree 通过复制日志同步到其他副本
如果错误地让 Distributed 表把同一批数据直接发送到同一 shard 的所有副本,同时本地表又会复制,就可能造成重复写入或不必要流量。复制拓扑必须和 internal_replication 语义一致,不能只看表名中是否带 Replicated。
分布式写入还有一个重要状态:数据可能先写入发送节点的本地队列,随后异步发送到远端。需要关注:
SELECT *
FROM system.distribution_queue
WHERE database = currentDatabase()
AND table = 'events_all';
队列积压意味着远端不可达、远端写入变慢、权限或表结构不一致等问题之一。清理队列文件不是恢复手段,可能造成数据丢失;应先定位失败原因,再决定重试、修复目标节点或从可靠上游重放。
5.5 复制状态与故障路径
在 ReplicatedMergeTree 表上检查复制状态:
SELECT
database,
table,
is_leader,
is_readonly,
queue_size,
inserts_in_queue,
merges_in_queue,
absolute_delay,
total_replicas,
active_replicas,
zookeeper_exception
FROM system.replicas
WHERE database = currentDatabase()
AND table = 'events_local';
这些字段应这样理解:
queue_size:复制队列中尚未完成的任务数量;inserts_in_queue:等待拉取或处理的插入相关任务;merges_in_queue:等待处理的合并任务;absolute_delay:相对复制日志的延迟量;active_replicas:当前可被识别为活跃的副本数量;is_readonly:表是否因协调服务或其他原因进入只读状态;zookeeper_exception:Keeper 交互中的错误信息。
典型故障路径:
副本 B 与 Keeper 失联
-> 无法获取新日志或确认状态
-> 复制队列增长
-> absolute_delay 增大
-> 副本 B 可能进入只读或不再参与正常写入
恢复步骤不能只执行 SYSTEM RESTART REPLICA。应先检查:
- Keeper 网络、会话和磁盘;
- 副本节点是否能访问其他副本;
system.replicas中队列是否继续增长;- 表是否只读;
- 修复后数据是否追平;
- 查询路由是否仍把请求发送到异常副本。
必要时可使用:
SYSTEM RESTART REPLICA events_local;
它的作用是重新初始化或重启该表的复制相关处理流程,不是删除数据、重建副本或强制解决所有损坏。执行前应确认异常来自暂时性复制队列问题,而不是 Keeper 数据损坏或本地文件损坏。
5.6 ON CLUSTER 不是瞬时全局 DDL
例如:
CREATE TABLE IF NOT EXISTS events_local
ON CLUSTER analytics_cluster
(
event_time DateTime,
user_id UInt64,
event_type String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time, user_id);
ON CLUSTER 依赖 ClickHouse 集群配置和 Keeper 中的 DDL 队列。发起节点提交任务后,各节点分别执行。它不是传统数据库中的全局两阶段提交,因此可能出现:
节点 A 已执行
节点 B 执行失败
节点 C 尚未连接 Keeper
验证方式:
SELECT
hostName(),
database,
name,
engine
FROM clusterAllReplicas('analytics_cluster', system.tables)
WHERE database = currentDatabase()
AND name IN ('events_local', 'events_all');
实际函数参数和可见系统表受版本、权限和集群配置影响;更基础的做法是分别在节点检查:
SHOW CREATE TABLE events_local;
SHOW CREATE TABLE events_all;
生产 DDL 必须考虑幂等性、结构版本、失败节点补偿和执行结果记录。DDL 队列积压时,继续提交大量 DDL 会增加恢复复杂度。
六、性能诊断:从“慢”还原为可验证的资源问题
6.1 先确认查询本身是否仍在运行
查看当前查询:
SELECT
query_id,
user,
address,
elapsed,
read_rows,
read_bytes,
written_rows,
written_bytes,
memory_usage,
query
FROM system.processes
ORDER BY elapsed DESC;
可重点观察:
read_rows很大:可能分区裁剪或主索引失效;read_bytes很大但行数不多:可能列宽大、压缩效果差或读取了不必要列;memory_usage持续增长:可能是大聚合、高基数 JOIN、全局排序或 DISTINCT;written_rows很大:可能是物化视图、INSERT SELECT、mutation 或分布式写入;- 查询本身不慢但客户端很慢:可能是结果集过大或网络传输问题。
终止查询:
KILL QUERY WHERE query_id = 'query-id';
通常应优先使用 KILL QUERY,而不是直接杀 ClickHouse 进程。需要注意,终止是协作式的,正在进行的线程、远端子查询或资源释放可能需要时间。执行后应重新检查 system.processes 和查询日志。
6.2 使用 query_log 回看已完成查询
SELECT
query_start_time,
query_duration_ms,
query_id,
read_rows,
read_bytes,
result_rows,
result_bytes,
memory_usage,
ProfileEvents['SelectedParts'] AS selected_parts,
ProfileEvents['SelectedRanges'] AS selected_ranges,
query
FROM system.query_log
WHERE type = 'QueryFinish'
AND event_time >= now() - INTERVAL 1 HOUR
ORDER BY query_duration_ms DESC
LIMIT 20;
字段和 ProfileEvents 内容会随版本变化,某些事件可能不存在或名称不同。诊断时要把日志字段与该版本实际输出结合起来,不应硬编码一套指标名称。
常用分析顺序是:
1. query_duration_ms
2. read_rows / read_bytes
3. memory_usage
4. selected parts / ranges
5. 是否访问 Distributed 表
6. 是否伴随后台 merge、mutation 或复制延迟
6.3 查询慢不一定是查询计划的问题
同时检查后台任务:
SELECT
database,
table,
elapsed,
progress,
num_parts,
result_part_name,
is_mutation,
command
FROM system.merges
ORDER BY elapsed DESC;
检查 mutation:
SELECT
database,
table,
mutation_id,
command,
create_time,
parts_to_do,
is_done,
latest_fail_reason
FROM system.mutations
WHERE is_done = 0
ORDER BY create_time;
Mutation 是对已有数据执行的异步变更,例如:
ALTER TABLE events
UPDATE amount = amount * 1.1
WHERE event_type = 'purchase';
这不是就地修改每一行,而是后台重写受影响的 part。执行期间可能增加磁盘、CPU 和读写放大。若条件覆盖大量历史数据,不能把它当作低成本 UPDATE。
删除同样如此:
ALTER TABLE events
DELETE WHERE event_time < '2023-01-01';
如果需求是删除整个时间范围的历史数据,按分区设计并使用:
ALTER TABLE events
DROP PARTITION '202301';
通常比逐行 mutation 更直接,但 DROP PARTITION 是高风险破坏性操作,必须核对分区表达式、目标表、备份和副本状态。复制表上的 DDL 传播行为也应先在测试环境验证。
6.4 通过系统表识别 part 爆炸和合并滞后
SELECT
database,
table,
countIf(active) AS active_parts,
sumIf(rows, active) AS rows,
formatReadableSize(sumIf(bytes_on_disk, active)) AS bytes
FROM system.parts
GROUP BY database, table
ORDER BY active_parts DESC;
如果某表 active parts 持续增长,并且 system.merges 长期繁忙,常见原因包括:
- 客户端批次太小;
- 并发写入过高;
- 一个批次涉及太多分区;
- 排序键导致排序和合并昂贵;
- 磁盘 I/O 饱和;
- 副本同时承担写入、查询和复制;
- mutation 阻塞或消耗了合并资源。
OPTIMIZE TABLE 示例:
OPTIMIZE TABLE events_local
PARTITION '202501'
FINAL;
它会主动请求合并目标范围。风险包括:
- 需要额外 CPU、磁盘空间和 I/O;
FINAL可能形成很大的新 part;- 对高并发生产系统可能加重压力;
- 它不是解决持续小批量写入的长期方案。
如果根因是上游持续产生小 part,反复执行 OPTIMIZE FINAL 只是把问题推迟,并不能替代修复写入批次和并发模型。
七、常见故障与诊断路径
7.1 写入报 “Too many parts”
推荐按顺序检查:
SELECT
partition,
count() AS active_parts,
sum(rows) AS rows
FROM system.parts
WHERE database = currentDatabase()
AND table = 'events_local'
AND active
GROUP BY partition
ORDER BY active_parts DESC;
然后检查:
SELECT
database,
table,
elapsed,
num_parts,
progress
FROM system.merges
WHERE database = currentDatabase()
AND table = 'events_local'
ORDER BY elapsed DESC;
判断逻辑:
每次 INSERT 行数很少
-> 客户端批次问题
每次 INSERT 行数不少,但跨很多 partition
-> 分区设计或写入组织问题
part 数不多,但 merge 长期堆积
-> 磁盘、CPU、排序、mutation 或并发资源问题
临时提高 part 相关限制可能让写入继续,但会掩盖后台处理能力不足,不能替代根因修复。
7.2 物化视图数据不对
检查源表和目标表:
SHOW CREATE TABLE events;
SHOW CREATE TABLE event_daily;
SHOW CREATE TABLE mv_event_daily;
再核对单个时间范围:
SELECT
toDate(event_time) AS day,
event_type,
count() AS events,
sum(amount) AS amount
FROM events
WHERE event_time >= '2025-01-01'
AND event_time < '2025-01-02'
GROUP BY day, event_type
ORDER BY day, event_type;
与目标表比较:
SELECT
day,
event_type,
sum(events) AS events,
sum(amount) AS amount
FROM event_daily
WHERE day = '2025-01-01'
GROUP BY day, event_type
ORDER BY day, event_type;
若目标少数据,排查:
- 物化视图是否在数据写入之后才创建;
- 源表 INSERT 是否实际成功;
- 视图 SELECT 的列和类型是否正确;
- 目标表是否被直接清理或 mutation;
- 分布式写入是否仍在队列中;
- 复制副本是否有延迟;
- 回填时是否绕过或重复触发了视图。
若目标多数据,优先排查:
- 同一批数据是否被重试;
- Distributed 写入是否错误地发送到多个副本;
- 回填是否与正常增量路径重复;
- 聚合表是否被直接重复插入。
7.3 集群查询结果重复
通常从三层区分:
逻辑重复:
上游重复事件或客户端重试
路由重复:
Distributed 表把同一数据发送到了多个副本
复制重复:
本地 ReplicatedMergeTree 的复制流程或表配置不一致
检查:
SELECT
hostName(),
count()
FROM clusterAllReplicas(
'analytics_cluster',
currentDatabase(),
events_local
)
GROUP BY hostName();
还应检查各节点表结构和集群配置是否一致。不要用 SELECT DISTINCT * 作为长期修复,它只能隐藏重复,不能解释重复来源,也可能产生很高的内存和 CPU 成本。
八、事务、原子性与一致性边界
8.1 单条 INSERT 的原子性不等于跨表事务
对普通 MergeTree 表,应采用以下心智模型:
一条 INSERT:
本批次作为一个可见写入单元处理
多条 INSERT:
默认不是一个自动回滚的事务
源表 + 物化视图目标表:
是写入触发的数据流,不应直接类比 OLTP 的任意多表事务
分片 + 副本:
可能存在网络延迟、复制延迟和分布式队列积压
ClickHouse 的事务能力、支持的表引擎、设置和客户端行为需要按具体版本核对。即使某版本支持一定范围的 BEGIN、COMMIT,也不能因此把所有 MergeTree、Distributed、物化视图和远程写入组合都当作传统数据库的全局事务。
需要跨表保证一致性时,常见设计是:
- 以不可变事件表为事实来源;
- 使用批次 ID 或版本号;
- 下游通过批次状态表判断某批数据是否完整;
- 汇总表按批次重建或替换;
- 查询侧只读取已确认完成的批次。
这类设计把一致性从“隐含在一次复杂 SQL 中”变成显式的数据状态。
8.2 复制是异步数据复制,不是自动的强一致共识写
ReplicatedMergeTree 使用 Keeper 协调复制日志和元数据。一个副本写入后,其他副本通常通过复制日志拉取数据 part 或执行对应任务。
因此可能出现:
副本 A 已看到新数据
副本 B 尚未拉取完成
如果查询被路由到不同副本,短时间内可能看到不同结果。是否允许读取延迟副本,取决于查询设置、负载均衡配置和业务容忍度。
如果业务要求在写入返回后立即从任意副本读取相同结果,应显式设计写入确认、读路由和复制延迟策略,而不能只因为表名包含 Replicated 就假定强一致。
九、一个可验证的端到端示例
下面的流程展示单机本地表、物化视图和诊断之间的关系。
9.1 创建源表和汇总表
CREATE DATABASE IF NOT EXISTS demo;
CREATE TABLE demo.events
(
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
amount Decimal(18, 2)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time, user_id);
CREATE TABLE demo.event_daily
(
day Date,
event_type LowCardinality(String),
events UInt64,
amount Decimal(18, 2)
)
ENGINE = SummingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (day, event_type);
CREATE MATERIALIZED VIEW demo.mv_event_daily
TO demo.event_daily
AS
SELECT
toDate(event_time) AS day,
event_type,
count() AS events,
sum(amount) AS amount
FROM demo.events
GROUP BY day, event_type;
前置条件是当前用户拥有建库、建表和建视图权限。
9.2 分两批写入
INSERT INTO demo.events VALUES
('2025-01-01 10:00:00', 1, 'purchase', 10.00),
('2025-01-01 10:01:00', 2, 'purchase', 20.00);
INSERT INTO demo.events VALUES
('2025-01-01 11:00:00', 3, 'purchase', 5.00);
物化视图可能在目标表中产生两条相同排序键的物理记录:
2025-01-01, purchase, 2, 30.00
2025-01-01, purchase, 1, 5.00
即使后台尚未合并,正确查询仍是:
SELECT
day,
event_type,
sum(events) AS events,
sum(amount) AS amount
FROM demo.event_daily
GROUP BY day, event_type;
预期结果:
2025-01-01, purchase, 3, 35.00
9.3 验证物理状态
SELECT
name,
partition,
rows,
bytes_on_disk,
active
FROM system.parts
WHERE database = 'demo'
AND table = 'events'
ORDER BY modification_time;
SELECT
name,
partition,
rows,
active
FROM system.parts
WHERE database = 'demo'
AND table = 'event_daily'
ORDER BY modification_time;
这里可以观察物化视图是否产生了目标表 part,但不能仅凭 part 数量判断业务结果是否正确;结果仍应通过源表重算与目标表聚合进行核对。
十、运维操作的验证与恢复原则
10.1 删除数据前先确认分区
执行分区删除前:
SELECT DISTINCT partition
FROM system.parts
WHERE database = 'demo'
AND table = 'events'
AND active
ORDER BY partition;
确认目标后再执行:
ALTER TABLE demo.events
DROP PARTITION '202501';
验证:
SELECT
partition,
count() AS parts,
sum(rows) AS rows
FROM system.parts
WHERE database = 'demo'
AND table = 'events'
AND active
GROUP BY partition
ORDER BY partition;
如果表有物化视图,删除源表分区后,目标汇总表不会因为“源表删除”而自动回滚已产生的汇总结果。必须同步处理目标表,或采用按分区重建目标汇总的方案。这是物化视图最容易被忽略的运维边界之一。
10.2 结构变更要考虑新旧数据
例如增加列:
ALTER TABLE demo.events
ADD COLUMN source LowCardinality(String) DEFAULT 'unknown';
默认值通常可以用于读取旧 part,但物化视图、INSERT 列表、下游表结构和跨节点 DDL 仍需分别验证。
变更物化视图时,不应只修改目标表结构后立即认为链路已经兼容。至少需要检查:
SHOW CREATE TABLE demo.events;
SHOW CREATE TABLE demo.event_daily;
SHOW CREATE TABLE demo.mv_event_daily;
并用一小批测试数据验证:
源表写入是否成功
物化视图是否触发
目标表列类型是否匹配
分布式副本结构是否一致
历史数据是否需要回填
10.3 诊断时保留证据
性能问题和复制问题往往是瞬时的。建议保留:
query_id;- 查询文本和设置;
read_rows、read_bytes、内存;- 当时的
system.parts; system.merges、system.mutations;system.replicas;system.distribution_queue;- 节点磁盘、CPU、内存和网络指标。
没有这些状态快照时,“昨天很慢”通常不足以区分查询计划、后台 merge、复制延迟和外部资源争用。
十一、把几个关键误区放在一起看
误区一:分区越细,查询越快
只有查询条件能够明确裁剪这些分区,并且分区数量仍在可管理范围内时,细分才可能有收益。过细分区会制造大量 part 和后台任务。
误区二:物化视图等于实时全量索引
普通物化视图处理的是新插入 block。它不会自动理解 UPDATE、DELETE、迟到修正和重复事件的业务含义,也不会默认回填创建前的全部历史数据。
误区三:FINAL 可以修复所有重复
FINAL 只对支持相应合并语义的表引擎提供查询时合并效果,并且可能很昂贵。它不能修复错误的分片路由、重复的业务事件或目标汇总表被重复回填。
误区四:复制副本越多,写入越快
副本通常增加复制流量、Keeper 元数据操作、磁盘写入和后台处理。它主要提高可用性和读取承载能力,不会自动提高单分片的写入吞吐。
误区五:OPTIMIZE FINAL 是常规维护命令
它适合明确的维护场景,例如确认某个分区需要整理,但不适合作为持续小批量写入的补救机制。长期修复应回到批次、分区、排序键、资源和并发设计。
ClickHouse 的性能和可靠性,最终取决于数据流是否与其存储模型匹配:
合理批量写入
-> 可控数量的 part
-> 合并压力稳定
合适的分区和排序键
-> 分区裁剪和数据跳过有效
-> 查询读取更少数据
物化视图使用正确的聚合引擎
-> 增量结果可合并
-> 回填、迟到和重复有明确策略
分片、副本和路由边界清晰
-> 查询与写入不重复
-> 故障时能定位队列和副本状态
查询日志、parts、merges、mutations、replicas 联合分析
-> “慢”可以还原为具体的扫描、计算、网络或后台任务问题
把这些机制分开验证,再组合成部署方案,通常比单纯调整某个参数更能解决 ClickHouse 的实际问题。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:ClickHouse 列式建模:MergeTree、排序键、分区和数据跳过
- 下一篇:SQL Server 数据库引擎:存储、事务日志、锁、索引和执行计划
- 延伸:数据库复制、分片与高可用:一致性、路由、故障转移和扩容
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论