数据库基础体系 · 第 47/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

ClickHouse 查询与运维:批量写入、物化视图、集群和性能诊断

ClickHouse 是面向分析场景的列式数据库。它擅长扫描大量数据、按列读取、并行聚合和压缩存储,但它并不是把传统行式 OLTP 数据库的事务模型简单替换成另一套 SQL 语法。

理解 ClickHouse 的查询与运维,至少要先建立下面几条主线:

  1. 批量写入决定了数据如何形成数据 part,以及后台合并的压力。
  2. 物化视图决定了写入时是否同步派生数据,以及聚合结果能否正确处理迟到数据和重复写入。
  3. 集群由分片、副本、路由和协调服务共同组成;“复制”和“分片”解决的是不同问题。
  4. 性能诊断需要区分扫描过多、过滤失效、聚合过重、后台合并、网络交换和资源争用。
  5. 运维操作必须同时说明状态、风险、验证方法和恢复路径,而不能只给一条命令。

下文以公开稳定语义为准。具体部署还会受到 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:易调试,适合服务集成;
  • CSVTSV:适合批处理;
  • 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

客户端可能在数据尚未持久化、甚至尚未完成真正写入前就收到成功响应。它降低了写入等待,但也提高了调用方确认数据已落盘的风险。

异步插入还涉及以下边界:

  1. 缓冲通常按表、用户、查询设置和数据格式等维度组织,不能把它理解成整个集群的全局队列。
  2. 服务进程崩溃、超时或配置不当时,需要明确哪些数据已经确认、哪些数据需要重试。
  3. 物化视图通常在实际处理该插入 block 时执行,异步只改变前台接收路径,不改变物化视图的业务语义。
  4. 生产上应配合 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 SummingMergeTreeAggregatingMergeTree 的区别

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;

回填时存在两个风险:

  1. 如果物化视图仍然启用,并且回填语句写入的是源表,历史数据可能被再次触发;
  2. 如果回填直接写入目标表,要确保目标表没有已有重复结果。

常见做法是:

先设计独立的历史回填路径
确认目标表分区和时间范围
避免让同一批历史数据同时经过 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,不能只依赖 FINALFINAL 会增加读取和计算成本,应当针对具体查询评估。


四、查询执行:从分区裁剪到并行聚合

4.1 查询性能的基本分解

一个典型查询可以拆成:

读取 part
  -> 分区裁剪
  -> 主索引和数据跳过
  -> 读取所需列
  -> PREWHERE / WHERE 过滤
  -> JOIN、聚合、排序
  -> 多线程合并
  -> 返回结果

列式存储的收益来自只读取需要的列。例如:

SELECT count()
FROM events
WHERE event_type = 'purchase';

通常不需要读取 user_idamount 等未参与查询的列。但如果写成:

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

countsum 等可合并聚合适合这种两阶段执行。对于排序、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。应先检查:

  1. Keeper 网络、会话和磁盘;
  2. 副本节点是否能访问其他副本;
  3. system.replicas 中队列是否继续增长;
  4. 表是否只读;
  5. 修复后数据是否追平;
  6. 查询路由是否仍把请求发送到异常副本。

必要时可使用:

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 的事务能力、支持的表引擎、设置和客户端行为需要按具体版本核对。即使某版本支持一定范围的 BEGINCOMMIT,也不能因此把所有 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_rowsread_bytes、内存;
  • 当时的 system.parts
  • system.mergessystem.mutations
  • system.replicas
  • system.distribution_queue
  • 节点磁盘、CPU、内存和网络指标。

没有这些状态快照时,“昨天很慢”通常不足以区分查询计划、后台 merge、复制延迟和外部资源争用。


十一、把几个关键误区放在一起看

误区一:分区越细,查询越快

只有查询条件能够明确裁剪这些分区,并且分区数量仍在可管理范围内时,细分才可能有收益。过细分区会制造大量 part 和后台任务。

误区二:物化视图等于实时全量索引

普通物化视图处理的是新插入 block。它不会自动理解 UPDATE、DELETE、迟到修正和重复事件的业务含义,也不会默认回填创建前的全部历史数据。

误区三:FINAL 可以修复所有重复

FINAL 只对支持相应合并语义的表引擎提供查询时合并效果,并且可能很昂贵。它不能修复错误的分片路由、重复的业务事件或目标汇总表被重复回填。

误区四:复制副本越多,写入越快

副本通常增加复制流量、Keeper 元数据操作、磁盘写入和后台处理。它主要提高可用性和读取承载能力,不会自动提高单分片的写入吞吐。

误区五:OPTIMIZE FINAL 是常规维护命令

它适合明确的维护场景,例如确认某个分区需要整理,但不适合作为持续小批量写入的补救机制。长期修复应回到批次、分区、排序键、资源和并发设计。


ClickHouse 的性能和可靠性,最终取决于数据流是否与其存储模型匹配:

合理批量写入
  -> 可控数量的 part
  -> 合并压力稳定

合适的分区和排序键
  -> 分区裁剪和数据跳过有效
  -> 查询读取更少数据

物化视图使用正确的聚合引擎
  -> 增量结果可合并
  -> 回填、迟到和重复有明确策略

分片、副本和路由边界清晰
  -> 查询与写入不重复
  -> 故障时能定位队列和副本状态

查询日志、parts、merges、mutations、replicas 联合分析
  -> “慢”可以还原为具体的扫描、计算、网络或后台任务问题

把这些机制分开验证,再组合成部署方案,通常比单纯调整某个参数更能解决 ClickHouse 的实际问题。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。