Skip to content

第 17 章 运维与可观测

学习目标:学完本章你能独立完成一台 ClickHouse 实例的「日常运维四件套」 —— 改配置、做备份、升级版本、接监控。能讲清楚 config.xml / users.xml / config.d/ 的层叠加载规则;能熟练使用 SYSTEM 命令族操控副本、合并、缓存;能实施 BACKUP / RESTOREclickhouse-backup 两套备份方案;能配置 Prometheus + Grafana 监控并写出实用告警;最后能独立排查一次「副本停止同步」的真实事故。


0. 开场白:DBA 的一天

07:00  Grafana 告警 → 副本延迟 5 分钟         → 17.6 节会教你查
09:00  开发提需求 → 临时调高某用户 max_memory → 17.2 / 17.3 会教你 users.d/
11:00  做备份                                → 17.4 节
14:00  升级 23.8 → 24.3                      → 17.5 节
16:00  磁盘告警 → 老分区搬到 HDD             → 17.2 + 17.3 节
22:00  发布窗口 → SYSTEM RELOAD CONFIG       → 17.3 节

如果上述任一项你看了一眼就能说出大致命令,可以快速跳过;否则建议从头读一遍 —— 它们在生产里全都会发生,而且都有「错按一下就翻车」的细节。


17.1 关键配置文件全景

ClickHouse 的配置不只一个文件,而是一整套分层 XML 体系。理解层叠规则是改配置不翻车的前提。

17.1.1 文件结构

/etc/clickhouse-server/
├── config.xml           ← 实例级主配置(端口、存储、副本协调器)
├── users.xml            ← 用户/Profile/Quota 配置
├── config.d/            ← 主配置覆盖目录(按字母序加载)
│   ├── disable_logs.xml
│   ├── prometheus.xml
│   └── storage_s3.xml
├── users.d/             ← 用户配置覆盖目录
│   ├── readonly.xml
│   └── tenant_a.xml
└── conf.d/              ← 旧名字,等价于 config.d/

层叠加载顺序

1. 读 config.xml (基础)
2. 顺序合并 config.d/*.xml
3. 应用环境变量 CLICKHOUSE_* (新版本支持)
4. 应用启动命令行 --<key>=<value>

5. users.xml + users.d/*.xml 同样规则合并

最终在内存里形成「合成配置树」

合并规则(重要,面试常考):

操作XML 写法行为
覆盖默认同路径节点直接替换
追加<remote_servers replace="replace">整段替换
删除<some_node remove="remove" />移除该节点
引用<openSSL incl="openSSL_default" />引用 /etc/metrika.xml 中的同名片段

📌 首次术语解释 · config.d / users.d:约定俗成的「Drop-in 目录」,灵感来自 systemd。永远不要直接改 config.xml / users.xml,所有自定义改动都丢到 config.d / users.d,升级时不会被包管理器覆盖。

17.1.2 必看的配置块清单

xml
<!-- config.xml 必看片段 -->
<clickhouse>
  <!-- ① 监听地址 -->
  <listen_host>0.0.0.0</listen_host>     <!-- 监听所有网卡;生产建议改成 :: 或具体 IP -->

  <!-- ② 端口 -->
  <tcp_port>9000</tcp_port>              <!-- TCP 协议(clickhouse-client / driver) -->
  <http_port>8123</http_port>            <!-- HTTP 协议(curl / clickhouse-connect) -->
  <interserver_http_port>9009</interserver_http_port>  <!-- 副本之间互拷数据 -->
  <mysql_port>9004</mysql_port>          <!-- MySQL 兼容协议 -->
  <postgresql_port>9005</postgresql_port><!-- PG 兼容协议 -->

  <!-- ③ 存储路径与多盘 -->
  <path>/var/lib/clickhouse/</path>
  <storage_configuration>
    <disks>
      <hot>  <type>local</type><path>/data1/ssd/</path></hot>
      <cold> <type>local</type><path>/data2/hdd/</path></cold>
      <s3>   <type>s3</type><endpoint>https://...</endpoint></s3>
    </disks>
    <policies>
      <hot_to_cold>
        <volumes>
          <ssd><disk>hot</disk></ssd>
          <hdd><disk>cold</disk></hdd>
        </volumes>
        <move_factor>0.2</move_factor>
      </hot_to_cold>
    </policies>
  </storage_configuration>

  <!-- ④ 集群拓扑:定义多分片 / 多副本 -->
  <remote_servers>
    <prod_cluster>
      <shard>
        <internal_replication>true</internal_replication>
        <replica><host>ck-1</host><port>9000</port></replica>
        <replica><host>ck-2</host><port>9000</port></replica>
      </shard>
      <shard>
        <internal_replication>true</internal_replication>
        <replica><host>ck-3</host><port>9000</port></replica>
        <replica><host>ck-4</host><port>9000</port></replica>
      </shard>
    </prod_cluster>
  </remote_servers>

  <!-- ⑤ 协调器:ZK 或 ClickHouse Keeper -->
  <zookeeper>
    <node><host>kp-1</host><port>9181</port></node>
    <node><host>kp-2</host><port>9181</port></node>
    <node><host>kp-3</host><port>9181</port></node>
  </zookeeper>

  <!-- ⑥ MergeTree 全局默认 -->
  <merge_tree>
    <parts_to_throw_insert>3000</parts_to_throw_insert>
    <parts_to_delay_insert>1000</parts_to_delay_insert>
    <max_bytes_to_merge_at_max_space_in_pool>161061273600</max_bytes_to_merge_at_max_space_in_pool>
  </merge_tree>

  <!-- ⑦ 内置监控 -->
  <prometheus>
    <endpoint>/metrics</endpoint>
    <port>9363</port>
    <metrics>true</metrics>
    <events>true</events>
    <asynchronous_metrics>true</asynchronous_metrics>
  </prometheus>
</clickhouse>

📌 改配置后SYSTEM RELOAD CONFIG; 大多数项可以热加载;但端口、存储路径、Keeper 地址这类启动期才读的项必须重启实例。

17.1.3 用 SQL 直接看活配置

不想去文件里翻?直接查表:

sql
-- 当前所有 server 设置
SELECT name, value, changed
FROM system.server_settings
ORDER BY changed DESC, name;

-- 所有 query 级 settings
SELECT name, value, description
FROM system.settings
WHERE changed = 1;            -- 只看相对默认值改了的

-- 看用户配置
SELECT name, host_ip, default_database
FROM system.users;

-- 看 profile / quota
SELECT * FROM system.profiles;
SELECT * FROM system.quotas;

17.2 SYSTEM 命令族大全

SYSTEM 是运维 ClickHouse 的「遥控器」,按功能分为五个家族。

17.2.1 配置 / 字典 / 模型重载

sql
SYSTEM RELOAD CONFIG;                 -- 重新读取 config.xml + config.d/*
SYSTEM RELOAD USERS;                  -- 重新读取 users.xml + users.d/*
SYSTEM RELOAD DICTIONARIES;           -- 全部字典刷新
SYSTEM RELOAD DICTIONARY <name>;      -- 单个字典刷新
SYSTEM RELOAD MODEL <name>;           -- CatBoost / ONNX 模型
SYSTEM RELOAD FUNCTION <name>;        -- Executable UDF 刷新

17.2.2 后台任务控制

sql
SYSTEM STOP MERGES [db.table];        -- 暂停所有 / 某表的 merge
SYSTEM START MERGES [db.table];
SYSTEM STOP REPLICATION_QUEUES;       -- 暂停副本队列
SYSTEM START REPLICATION_QUEUES;
SYSTEM STOP DISTRIBUTED SENDS [db.table];
SYSTEM START DISTRIBUTED SENDS [db.table];

SYSTEM STOP TTL MERGES;
SYSTEM START TTL MERGES;

典型场景:导入 1 TB 历史数据时先 STOP MERGES,避免边写边合并把 IO 打满;导完再 START MERGES,让后台慢慢追。

17.2.3 副本协调

sql
SYSTEM SYNC REPLICA <db>.<table>;     -- 等本副本追平 leader
SYSTEM RESTART REPLICA <db>.<table>;  -- 重新初始化某副本(不删数据)
SYSTEM RESTORE REPLICA <db>.<table>;  -- 重建本副本元数据(数据完好但 ZK 状态丢了时用)
SYSTEM DROP REPLICA '<replica_name>'; -- 在协调器中清理已下线副本的注册
SYSTEM RESTART REPLICAS;              -- 重启所有副本

📌 常见踩坑SYSTEM SYNC REPLICA阻塞直到追平;如果队列卡了 1 小时,命令也会等 1 小时。生产建议加 SETTINGS receive_timeout = 600

17.2.4 缓存与日志

sql
SYSTEM DROP MARK CACHE;               -- 清 mark 索引缓存
SYSTEM DROP UNCOMPRESSED CACHE;       -- 清未压缩块缓存
SYSTEM DROP COMPILED EXPRESSION CACHE;-- 清 LLVM 编译表达式缓存
SYSTEM DROP DNS CACHE;                -- 清 DNS 缓存(修了 hosts 才用)
SYSTEM DROP QUERY CACHE;              -- 23.x 引入的 query result cache
SYSTEM DROP FILESYSTEM CACHE;         -- 清远端存储(S3)的本地文件缓存

SYSTEM FLUSH LOGS;                    -- 把 query_log/text_log/trace_log 强制刷盘
SYSTEM FLUSH DISTRIBUTED <table>;     -- 强制把 Distributed 表的写入缓冲冲下去

17.2.5 维护与修复

sql
SYSTEM SHUTDOWN;                      -- 优雅关停(等所有 query 跑完)
SYSTEM KILL;                          -- 强杀(不等)
SYSTEM RELOAD ASYNCHRONOUS_METRICS;
SYSTEM ENABLE FAILPOINT '<name>';     -- 测试用,注入故障
SYSTEM JEMALLOC PURGE;                -- 触发 jemalloc 内存归还 OS

17.2.6 与 PG / MySQL 对比

📌 横向对比

操作ClickHousePostgreSQLMySQL
重载配置SYSTEM RELOAD CONFIGpg_reload_conf() / kill -HUPFLUSH PRIVILEGES 仅权限
杀死会话KILL QUERY WHERE query_id=...pg_cancel_backend(pid) / pg_terminate_backend(pid)KILL <thread_id>
清缓存SYSTEM DROP MARK CACHEDISCARD ALL 仅会话级RESET QUERY CACHE
强制刷日志SYSTEM FLUSH LOGSpg_switch_wal() 半相关FLUSH BINARY LOGS

17.3 备份与恢复

OLAP 数据动辄 TB ~ PB 级,备份策略不能套用 OLTP 那一套「每天一份全量」。ClickHouse 提供四种主流方案,按场景挑。

17.3.1 方案一:内置 BACKUP / RESTORE(21.x+,推荐入门)

sql
-- 1) 配置一个备份盘(config.d/backup_disk.xml)
<storage_configuration>
  <disks>
    <backups>
      <type>local</type>
      <path>/var/backups/clickhouse/</path>
    </backups>
  </disks>
</storage_configuration>
<backups>
  <allowed_disk>backups</allowed_disk>
  <allowed_path>/var/backups/clickhouse/</allowed_path>
</backups>
sql
-- 2) 备份单表
BACKUP TABLE learn_ck.events_v2
TO Disk('backups', 'events_v2_2025_04_17.zip');

-- 备份整库
BACKUP DATABASE learn_ck
TO Disk('backups', 'learn_ck_2025_04_17.zip');

-- 增量备份(基于上一次的 base 快照)
BACKUP TABLE learn_ck.events_v2
TO Disk('backups', 'events_v2_2025_04_17_inc.zip')
SETTINGS base_backup = Disk('backups', 'events_v2_2025_04_17.zip');

-- 备份到 S3
BACKUP DATABASE learn_ck
TO S3('https://my-bkt.s3.amazonaws.com/ck/2025-04-17', 'AKID', 'SK');

-- 恢复
RESTORE TABLE learn_ck.events_v2 AS learn_ck.events_v2_restored
FROM Disk('backups', 'events_v2_2025_04_17.zip');

-- 看历史与状态
SELECT id, name, status, error, num_files, total_size
FROM system.backups
ORDER BY start_time DESC;

特点

  • 优点:原生命令,支持全量 / 增量 / 选择性恢复;可备份到 Local / S3 / Azure;一致性快照(基于 part 硬链接)。
  • 缺点:自身不做调度、不做生命周期;备份元数据存在 system.backups,重启后只剩历史,不能续传。

17.3.2 方案二:clickhouse-backup(社区生产标配)

Altinity/clickhouse-backup 是社区最广泛使用的运维工具,对 BACKUP / RESTORE 做了一层「工程化封装」:调度、压缩、保留策略、远端推送、增量管理、UI。

bash
# 安装
curl -L https://github.com/Altinity/clickhouse-backup/releases/latest/download/clickhouse-backup_linux_amd64.tar.gz | tar xz
sudo mv clickhouse-backup /usr/local/bin/

# 配置 /etc/clickhouse-backup/config.yml
general:
  remote_storage: s3
  backups_to_keep_local: 7
  backups_to_keep_remote: 30
clickhouse:
  username: default
s3:
  access_key: ...
  secret_key: ...
  bucket: my-ck-backups
  region: ap-east-1
  path: prod/

# 一条命令完成:本地快照 → 推 S3
clickhouse-backup create_remote daily_$(date +%F)

# 列出
clickhouse-backup list

# 还原
clickhouse-backup restore_remote daily_2025-04-17

clickhouse-backup 推荐配 systemd timer 跑,比写 cron 更稳。

17.3.3 方案三:FREEZE PARTITION(最古老,最低开销)

sql
ALTER TABLE learn_ck.events_v2 FREEZE PARTITION 202504;
-- → 在 /var/lib/clickhouse/shadow/<N>/ 下生成对硬链接
-- → 几乎瞬间完成(不复制数据,只 hardlink)
-- → 之后 rsync / tar 这个目录到外部存储即可

恢复流程是手动的:把 shadow 目录 rsync 回 detached/,再 ALTER TABLE ... ATTACH PARTITION 挂载。

适用场景

  • 想跨 ClickHouse 集群手工搬数据;
  • 需要在备份时完全不影响业务(hardlink 不占新空间,秒级完成);
  • 自己写脚本管理增量。

17.3.4 方案四:物理备份(rsync 整个数据目录)

最朴素:实例停机或 SYSTEM STOP MERGES; + FLUSH ASYNC INSERT QUEUE; 后整目录 rsync 走。不推荐

  • 不能保证一致性快照;
  • 与 ZK / Keeper 中的元数据不同步;
  • 恢复时副本注册要手工修。

仅在「机器要报废、紧急抢救」时一次性用。

17.3.5 物理 vs 逻辑、全量 vs 增量

维度物理(FREEZE / rsync)逻辑(BACKUP / clickhouse-backup)
速度快(hardlink)略慢(要序列化 part metadata)
跨版本不保证推荐做大版本升级前的安全备份
选择性恢复易(按表/分区/库)
远端存储自己 rsync内置 S3 / Azure / GCS
一致性看你怎么搞自带快照点

生产建议:日常 clickhouse-backup + 大变更前一次 BACKUP DATABASE,本地保留 7 天,远端保留 30 天。


17.4 升级与版本兼容

ClickHouse 发版极快(基本一月一版),生产升级要讲方法。

17.4.1 选 LTS 版

ClickHouse 官方有「LTS(Long-Term Support)」标签,例如 22.823.323.824.324.8生产环境不要追最新,优先选最近的 LTS:

bash
# 看当前装的版本
clickhouse-server --version
# ClickHouse server version 23.8.7.24 (official build).

# Altinity 的 LTS 列表
# https://docs.altinity.com/altinitystablebuilds/

17.4.2 升级前的检查清单

  1. 看 release notes 中的 Backward Incompatible Changes(每次都要看,绝对不能跳过)。
  2. BACKUP DATABASE system; BACKUP DATABASE <biz>; 备一次。
  3. SELECT * FROM system.settings WHERE changed=1 导出当前自定义 settings。
  4. 下载新版本,先在 staging 实例上跑业务样本 SQL 验证(重点测:物化视图、复杂聚合、UDF)。
  5. 确认 driver / clickhouse-connect / JDBC 等客户端兼容性。

17.4.3 灰度升级流程(多副本集群)

前提:至少 2 副本 / 分片,业务通过 LB / 域名 + Keeper 访问

第 1 步:下线副本 R1(从 LB 摘掉)

第 2 步:在 R1 上:
          systemctl stop clickhouse-server
          apt install clickhouse-server=24.3.x.x
          systemctl start clickhouse-server

第 3 步:观察 system.replicas / replication_queue
          确认 R1 与 R2 同步追平

第 4 步:把 R1 加回 LB,观察 query_log 错误率

第 5 步:对其余副本逐台重复 1~4

回滚:只要 part 兼容(同一年内的版本通常兼容),把 deb 包降级即可。如果 part 格式有破坏性变更(罕见,发布说明会强提示),需要从备份还原。

17.4.4 版本兼容速记

范围通常兼容性
Patch 升级(24.3.1 → 24.3.5)100% 兼容,随便升
Minor 升级(24.3 → 24.5)高度兼容,看 release notes 即可
跨大版本(22.8 → 24.3)必须读完所有中间版本的不兼容变更,并准备灰度
协议 / wire 兼容服务端版本通常向下兼容客户端 1~2 个大版本

17.5 监控:Prometheus + Grafana

「不可观测的系统等于不可运维」。ClickHouse 内置 Prometheus 端点,10 分钟搭起整套监控。

17.5.1 启用内置 Prometheus 端点

config.d/prometheus.xml

xml
<clickhouse>
  <prometheus>
    <endpoint>/metrics</endpoint>
    <port>9363</port>
    <metrics>true</metrics>
    <events>true</events>
    <asynchronous_metrics>true</asynchronous_metrics>
    <status_info>true</status_info>
    <errors>true</errors>
  </prometheus>
</clickhouse>

SYSTEM RELOAD CONFIG;curl http://127.0.0.1:9363/metrics 就能看到几百个指标。

Prometheus 抓取配置:

yaml
scrape_configs:
  - job_name: 'clickhouse'
    static_configs:
      - targets: ['ck-1:9363', 'ck-2:9363', 'ck-3:9363', 'ck-4:9363']
    scrape_interval: 15s

17.5.2 必盯的关键指标

指标类型说明告警阈值参考
ClickHouseProfileEvents_Querycounter总查询数rate 突变看是否压测 / 流量异常
ClickHouseProfileEvents_SelectQuery / _InsertQuerycounter读写分别计数比例突变定位是否插入风暴
ClickHouseAsyncMetrics_ReplicasMaxAbsoluteDelaygauge副本最大绝对延迟(秒)> 60 报警
ClickHouseAsyncMetrics_ReplicasSumQueueSizegauge复制队列总长> 100 报警
ClickHouseMetrics_BackgroundMergesAndMutationsPoolTaskgauge后台 merge 在跑数长期 = 池上限 → 调大或排查写入风暴
ClickHouseAsyncMetrics_MaxPartCountForPartitiongauge单分区最大 part 数> 150 警告,> 300 紧急
ClickHouseMetrics_MemoryTrackinggauge实例总内存(追踪值)> 物理内存 80% 报警
ClickHouseProfileEvents_MarkCacheHits / _Missescountermark cache 命中hit_ratio < 90% 警告
ClickHouseAsyncMetrics_DiskUsed_defaultgauge默认盘占用> 80% 报警
ClickHouseProfileEvents_FailedQuerycounter失败查询数rate 突增
ClickHouseProfileEvents_OSCPUVirtualTimeMicrosecondscounter用户态 CPU推 utilization 公式

17.5.3 Grafana 面板模板

ClickHouse 官方 + Altinity 维护的常用面板 ID:

  • Grafana ID 14192Altinity ClickHouse Server Dashboard,覆盖 Query / Replication / Merge / Disk / Memory 五大版块。
  • Grafana ID 13500:ClickHouse Internal Metrics,更细粒度的 ProfileEvents。
  • Grafana ID 2515:经典 ClickHouse Overview。

直接 Import → Paste ID → 选 Prometheus 数据源,几秒钟就能用。

17.5.4 Prometheus 告警规则示例

yaml
groups:
- name: clickhouse
  rules:
  - alert: CHReplicaDelay
    expr: ClickHouseAsyncMetrics_ReplicasMaxAbsoluteDelay > 60
    for: 5m
    labels: { severity: critical }
    annotations:
      summary: "ClickHouse 副本延迟 > 60s"
      description: "{{ $labels.instance }} 副本延迟 {{ $value }} 秒"

  - alert: CHTooManyParts
    expr: ClickHouseAsyncMetrics_MaxPartCountForPartition > 200
    for: 10m
    labels: { severity: warning }
    annotations:
      summary: "单分区 Part 数过多 ({{ $value }})"

  - alert: CHMemoryHigh
    expr: ClickHouseMetrics_MemoryTracking
          / on(instance) node_memory_MemTotal_bytes > 0.85
    for: 5m
    labels: { severity: critical }
    annotations: { summary: "ClickHouse 内存占比 > 85%" }

  - alert: CHDiskUsage
    expr: (1 - ClickHouseAsyncMetrics_DiskAvailable_default
              / ClickHouseAsyncMetrics_DiskTotal_default) > 0.85
    for: 10m
    labels: { severity: critical }
    annotations: { summary: "ClickHouse 数据盘 > 85%" }

  - alert: CHFailedQueryBurst
    expr: rate(ClickHouseProfileEvents_FailedQuery[5m]) > 1
    for: 5m
    labels: { severity: warning }
    annotations: { summary: "失败查询飙升 (>1/s)" }

17.6 真实案例:副本停止同步的完整排查流程

下面这个场景在生产里几乎每个 DBA 都遇到过,借此把 17.1 ~ 17.5 学到的东西串起来。

现象

监控告警:CHReplicaDelay = 7200s(2 小时),同时业务反馈某些表查到的数据陈旧。

Step 1:确认范围 —— 是哪张表 / 哪个副本

sql
-- 在卡住的副本上执行
SELECT
    database, table,
    is_leader, is_readonly, is_session_expired,
    absolute_delay,
    queue_size, inserts_in_queue, merges_in_queue,
    log_max_index, log_pointer
FROM system.replicas
WHERE absolute_delay > 60
ORDER BY absolute_delay DESC;

输出长这样:

database │ table       │ is_readonly │ absolute_delay │ queue_size │ inserts_in_queue
learn_ck │ events_dist │      0      │      7203      │    412     │      398

→ 锁定到 learn_ck.events_dist(其实是它对应的本地表 events_local)卡住了 412 条队列。

Step 2:看队列里到底卡在哪条

sql
SELECT
    type,                  -- GET_PART / MERGE_PARTS / MUTATE_PART / ...
    new_part_name,
    create_time,
    last_attempt_time,
    last_exception,
    num_postponed,
    postpone_reason
FROM system.replication_queue
WHERE database = 'learn_ck' AND table = 'events_local'
ORDER BY create_time
LIMIT 20;

通常 last_exception 一栏就有线索。常见三种:

  1. Cannot fetch part xxx, exception: Connection refused → 源副本宕了 / 网络断了。
  2. Cannot allocate disk space → 磁盘满了。
  3. Code: 999, e.what() = Coordination → Keeper 出问题了。

Step 3:分情况处理

3.1 源副本宕

sql
-- 在还活的副本看其它副本的活跃情况
SELECT host_name, host_address, port, is_active
FROM system.clusters WHERE cluster = 'prod_cluster';

-- 在协调器中看注册副本
SELECT * FROM system.zookeeper
WHERE path = '/clickhouse/tables/<shard>/events_local/replicas';

确认源副本机器实际状态;启不来就 SYSTEM DROP REPLICA '<replica_name>' FROM TABLE events_local; 把它从协调器中踢掉,再添新副本。

3.2 磁盘满

清出空间,或把老分区 MOVE PARTITION ... TO DISK 'cold' 搬走。然后队列会自动恢复。

3.3 Keeper / ZK 异常

sql
-- 看 keeper 是不是健康
SELECT name, value FROM system.zookeeper WHERE path = '/';

-- 4 字命令(Keeper / ZK)
$ echo mntr | nc kp-1 9181
$ echo stat | nc kp-1 9181

如果发现 znode count 异常大或者 leader 漂移频繁,问题在协调器侧,要重启 Keeper 实例。

Step 4:恢复后验证

sql
-- 推一下追平
SYSTEM SYNC REPLICA learn_ck.events_local;

-- 等命令返回后再验证
SELECT absolute_delay, queue_size FROM system.replicas
WHERE database = 'learn_ck' AND table = 'events_local';
-- absolute_delay → 0
-- queue_size → 0

最后写一条事故复盘到 wiki。真实事故 90% 都来自这三类:源副本宕、磁盘满、Keeper 抖。


17.7 与 PG / MySQL 运维工具对比

📌 横向对比小框

维度ClickHousePostgreSQLMySQL
配置层叠config.xml + config.d/*postgresql.conf + conf.d/* + ALTER SYSTEMmy.cnf + !includedir
重载SYSTEM RELOAD CONFIGSELECT pg_reload_conf()FLUSH PRIVILEGES(仅权限)
备份方案BACKUP/RESTORE + clickhouse-backup + FREEZEpg_dump + pg_basebackup + pgBackRest / barmanmysqldump + xtrabackup
监控生态内置 Prometheus 端点pg_exporter(外置)mysqld_exporter(外置)
副本管理system.replicas + SYSTEM SYNCpg_stat_replication + pg_promote()SHOW SLAVE STATUS + START/STOP SLAVE
升级方式包升级 + 灰度pg_upgrade(大版本要做)in-place 或 dump-restore

一句话比较:ClickHouse 的运维像「写 SQL 操作底层 —— SYSTEM 是 SQL,监控是 SQL,备份也是 SQL」,对熟悉 SQL 的人极其友好;PG / MySQL 则更依赖外部工具链。


17.8 本章小结

┌──────────────────────────────────────────────────────────┐
│                    本章核心要点                           │
├──────────────────────────────────────────────────────────┤
│  ① 配置体系:                                              │
│     config.xml + config.d/ + users.xml + users.d/         │
│     永远改 .d/,不改主文件                                │
│                                                            │
│  ② SYSTEM 命令五家族:                                     │
│     RELOAD(配置/字典)/ STOP-START(merge/repl)         │
│     SYNC-RESTART(副本)/ DROP CACHE/ FLUSH LOGS          │
│                                                            │
│  ③ 备份四方案:                                            │
│     BACKUP/RESTORE 入门首选                                │
│     clickhouse-backup 生产标配                            │
│     FREEZE PARTITION 零成本快照                           │
│     rsync 应急用                                           │
│                                                            │
│  ④ 升级原则:选 LTS / 灰度副本 / 备份兜底                 │
│                                                            │
│  ⑤ 监控:内置 Prometheus 端点                              │
│     必盯指标:副本延迟 / Part 数 / 内存 / 磁盘 / 失败查询  │
│     Grafana ID 14192 / 13500 即用                         │
│                                                            │
│  ⑥ 事故 SOP:                                              │
│     system.replicas → replication_queue → ZK status       │
│     90% 事故来自源副本宕 / 磁盘满 / Keeper 抖             │
└──────────────────────────────────────────────────────────┘

17.9 面试高频题

Q1:config.xmlusers.xml 中的配置加载优先级是怎样的?为什么不要直接改主配置文件?

考察点:是否理解 ClickHouse 的配置层叠机制和最佳实践。

标准答案

  1. ClickHouse 启动时按顺序加载:① config.xml 主文件 → ② 顺序合并 config.d/*.xml → ③ 合并环境变量 CLICKHOUSE_* → ④ 合并启动命令行 --key=value,形成「合成配置树」;users.xmlusers.d/ 同理。
  2. 同名节点默认是「覆盖」语义;可以用 replace="replace" / remove="remove" / incl="..." 控制合并行为。
  3. 不要改主文件的原因:升级 deb / rpm 包时主文件可能被覆盖;自定义改动放 config.d/users.d/ 既不会被升级覆盖,也方便用 ConfigMap / Ansible 管理。
  4. 改完用 SYSTEM RELOAD CONFIG 热加载;端口、存储路径、Keeper 地址等启动期项需要重启。

加分项:能提「SELECT * FROM system.server_settings WHERE changed=1 直接看活配置」;能讲 incl="..." 引用外部文件(早年的 metrika.xml 习惯)。

易错点:以为 config.d/ 中的文件会整体替换 config.xml —— 实际是节点级合并


Q2:SYSTEM SYNC REPLICASYSTEM RESTART REPLICA 有什么区别?

考察点:副本协调机制。

标准答案

  • SYSTEM SYNC REPLICA <table>:阻塞等本副本把当前 replication_queue 里的所有任务跑完(追平 leader),不影响数据本身,只是「等」。常用于「我刚改完拓扑,先等同步再放业务流量」。
  • SYSTEM RESTART REPLICA <table>:把这个副本的元数据从 ZK / Keeper 重新加载一次,相当于「卸载 + 重新挂载这张表」,数据不会丢,但会清空内存里的副本状态。常用于副本卡死、状态错乱时的「软重启」。
  • 还有 SYSTEM RESTORE REPLICA(更重):当 ZK 元数据丢了但数据 part 还在时,重建协调器中的注册项。

加分项:能补一句「SYSTEM DROP REPLICA '<name>' FROM TABLE t」用于清理已下线副本在协调器中的「僵尸注册」。

易错点:把 RESTART 当成「重启进程」 —— 它只重启这张表的副本对象,不影响其他表,也不影响进程。


Q3:你怎么给一个上线 1 年的 ClickHouse 集群做升级?

考察点:实战流程。

标准答案(按步骤回答):

  1. 预检:读新版本 release notes 中的 Backward Incompatible Changes;导出当前 system.settings 中改过默认值的项;记录使用的物化视图、UDF、Kafka 引擎等敏感对象。
  2. 备份BACKUP DATABASE + clickhouse-backup 双保险,远端 S3 至少一份。
  3. Staging 验证:在低流量副本(或独立 staging 集群)升级,跑业务样本 SQL 半天到一天,重点测物化视图触发、复杂聚合、JOIN、客户端兼容。
  4. 灰度上线:摘 1 个副本 → 升级 → 观察 system.replicas 追平 → 加回 LB → 观察 system.query_log 错误率与 P99 → 重复对其余副本。
  5. 回滚预案:包降级即可;若 part 格式有变化(罕见),从备份还原。
  6. 优先选 LTS 版(22.8 / 23.3 / 23.8 / 24.3 / 24.8 这些标 LTS 的)。

加分项:能提到「客户端 driver 也要测兼容性」和「升级期间停止 mutation 队列,等队列清空再开始」。

易错点:直接 apt upgrade 全节点滚一遍 —— 出错就是全军覆没。


Q4:备份方案怎么选?BACKUP/RESTOREclickhouse-backupFREEZE PARTITION 有什么区别?

考察点:场景选型。

标准答案

方案一句话适合
BACKUP / RESTORE内置 SQL 命令,支持本地/S3/Azure,全量+增量中小规模,作为入门方案
clickhouse-backup社区工具对 BACKUP 的工程化封装:调度+保留策略+远端推送生产标配
FREEZE PARTITION硬链接快照,零成本,秒级完成,再自己 rsync跨集群手工搬数据;零业务影响
rsync 整目录朴素物理备份只在「机器报废、紧急抢救」用

加分项:能提「BACKUP 的本质也是基于 part 硬链接打快照 + 序列化 metadata」;能说「逻辑备份(INSERT INTO ... SELECT 写到另一张 S3 表)跨大版本最稳」。

易错点:用 mysqldump 思维 SELECT * FROM ... INTO OUTFILE 当备份 —— 没有元数据 / 索引 / 物化视图 / DICTIONARY 信息,只能算「数据导出」。


Q5:ClickHouse 监控应该重点看哪几个指标?为什么?

考察点:实战经验。

标准答案:5 个核心指标 + 它们对应的「事故症状」:

  1. ReplicasMaxAbsoluteDelay:副本最大延迟。> 60 秒说明同步滞后,分布式查询会读到陈旧数据。
  2. MaxPartCountForPartition:单分区最大 part 数。> 150 警告,> 300 INSERT 会被拒绝。
  3. MemoryTracking:实例总内存追踪。> 物理内存 80% 即将 OOM。
  4. BackgroundMergesAndMutationsPoolTask:后台 merge 在跑数。长期顶池上限 = 写入风暴或 mutation 失控。
  5. MarkCacheHits / Misses 命中率:< 90% 说明工作集塞不进 mark cache,要么调大 cache、要么改 SQL 减少 mark 扫描。

加分项:还能提 FailedQuery 速率突变、DiskUsed_default 容量、OSCPUVirtualTimeMicroseconds 折算 CPU 利用率;能说出 Grafana 模板 ID(14192 / 13500)。

易错点:只盯 QPS / 平均查询耗时,没看副本延迟 → 业务读到旧数据投诉时才发现。


Q6:副本停止同步了,你怎么排查?

考察点:综合事故处理。

标准答案:4 步 SOP:

  1. 范围确定SELECT database, table, absolute_delay, queue_size FROM system.replicas WHERE absolute_delay > 60 锁定卡住的表 / 副本。
  2. 看队列SELECT type, new_part_name, last_exception, num_postponed, postpone_reason FROM system.replication_queue WHERE database=... AND table=... ORDER BY create_time LIMIT 20,重点看 last_exception
  3. 分类处理
    • 异常含「Connection refused」/「Cannot fetch part」→ 源副本宕,去看那台机器;启不来就 SYSTEM DROP REPLICA + 加新副本。
    • 异常含「Cannot allocate disk space」→ 磁盘满,清空间或 MOVE PARTITION 到冷盘。
    • 异常含「Coordination」/「Session expired」→ Keeper / ZK 异常,echo mntr | nc keeper 9181 检查协调器,必要时重启 Keeper 实例。
  4. 验证恢复SYSTEM SYNC REPLICA <table> 推一下追平,再确认 absolute_delay = 0queue_size = 0,事后写复盘。

加分项:能提「事故记录到 system.text_log 里搜 level='Error' 看历史触发时间线」;能提「Keeper 集群的 leader 漂移监控也很关键」。

易错点:直接 RESTART REPLICA 而不查原因,结果队列还是卡住,徒劳。


📌 下一章预告:第 18 章我们看 ClickHouse 的「外延」 —— clickhouse-localchdb、UDF、与 BI / Spark / Flink 集成、生态周边工具。配合这一章,你就能把 ClickHouse 从「一个数据库」用成「一套数据基础设施」。

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗

💻 示例代码

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
第 17 章 · ClickHouse 健康巡检脚本
====================================

干什么:
    一次性查 5 张关键 system 视图, 把「副本延迟 / Part 数 / Merge 队列 /
    Mutation 队列 / 磁盘水位 / 内存 / 失败查询」的现状汇总成一张 Markdown 风格
    的健康报告. 适合放到 cron / systemd timer 里每 5 分钟跑一次.

前置:
    python -m pip install clickhouse-connect

用法:
    python ops_check.py
    python ops_check.py --host 127.0.0.1 --port 8123
    python ops_check.py --json   # 输出 JSON, 给 Alertmanager / 飞书机器人吃

退出码:
    0 = 全部 OK
    1 = WARNING (有指标超阈值, 但不致命)
    2 = CRITICAL (必须立刻处理)
"""
from __future__ import annotations

import argparse
import json
import sys
from dataclasses import dataclass, field
from typing import Any, Dict, List

import clickhouse_connect


# ============== 阈值表(可改) ==============
THRESH = {
    "replica_delay_warn":   30,     # 秒
    "replica_delay_crit":   120,
    "queue_size_warn":      50,
    "queue_size_crit":      300,
    "parts_per_partition_warn": 150,
    "parts_per_partition_crit": 300,
    "mutation_pending_warn":  10,
    "mutation_pending_crit":  50,
    "disk_pct_warn":          0.80,
    "disk_pct_crit":          0.90,
    "mem_pct_warn":           0.75,
    "mem_pct_crit":           0.90,
    "mark_cache_hit_warn":    0.90,
}

LEVEL_OK   = 0
LEVEL_WARN = 1
LEVEL_CRIT = 2
LEVEL_NAME = {0: "OK", 1: "WARN", 2: "CRIT"}


@dataclass
class Section:
    name: str
    level: int = LEVEL_OK
    rows:  List[Dict[str, Any]] = field(default_factory=list)
    notes: List[str] = field(default_factory=list)


def get_client(host: str, port: int, user: str, password: str):
    return clickhouse_connect.get_client(
        host=host, port=port, username=user, password=password,
    )


# ---------------------------------------------------------------------------
# 1) 副本延迟 + 队列
# ---------------------------------------------------------------------------
def check_replicas(client) -> Section:
    s = Section("1) 副本同步状态 (system.replicas)")
    rows = client.query("""
        SELECT
            database, table,
            is_readonly, is_session_expired,
            absolute_delay,
            queue_size, inserts_in_queue, merges_in_queue
        FROM system.replicas
        ORDER BY absolute_delay DESC
        LIMIT 20
    """).result_rows
    cols = ["db", "table", "ro", "expired", "delay_s",
            "queue", "ins_q", "mrg_q"]
    for r in rows:
        d = dict(zip(cols, r))
        if d["delay_s"] >= THRESH["replica_delay_crit"] \
                or d["queue"] >= THRESH["queue_size_crit"] \
                or d["expired"] == 1:
            d["__level"] = LEVEL_CRIT
            s.level = max(s.level, LEVEL_CRIT)
        elif d["delay_s"] >= THRESH["replica_delay_warn"] \
                or d["queue"] >= THRESH["queue_size_warn"] \
                or d["ro"] == 1:
            d["__level"] = LEVEL_WARN
            s.level = max(s.level, LEVEL_WARN)
        else:
            d["__level"] = LEVEL_OK
        s.rows.append(d)
    if not rows:
        s.notes.append("(无 Replicated 表, 跳过)")
    return s


# ---------------------------------------------------------------------------
# 2) Part 数量 (找出单分区 Part 数过多)
# ---------------------------------------------------------------------------
def check_parts(client) -> Section:
    s = Section("2) 单分区 Part 数 (system.parts active=1)")
    rows = client.query("""
        SELECT
            database, table, partition,
            count() AS parts,
            sum(rows) AS rows,
            formatReadableSize(sum(bytes_on_disk)) AS size
        FROM system.parts
        WHERE active AND database NOT IN ('system','INFORMATION_SCHEMA')
        GROUP BY database, table, partition
        HAVING parts >= 50
        ORDER BY parts DESC
        LIMIT 20
    """).result_rows
    cols = ["db", "table", "partition", "parts", "rows", "size"]
    for r in rows:
        d = dict(zip(cols, r))
        if d["parts"] >= THRESH["parts_per_partition_crit"]:
            d["__level"] = LEVEL_CRIT
            s.level = max(s.level, LEVEL_CRIT)
        elif d["parts"] >= THRESH["parts_per_partition_warn"]:
            d["__level"] = LEVEL_WARN
            s.level = max(s.level, LEVEL_WARN)
        else:
            d["__level"] = LEVEL_OK
        s.rows.append(d)
    if not rows:
        s.notes.append("所有分区 part 数 < 50, 健康.")
    return s


# ---------------------------------------------------------------------------
# 3) 后台 Merge / Mutation 队列
# ---------------------------------------------------------------------------
def check_merges(client) -> Section:
    s = Section("3) 后台任务 (system.merges + system.mutations)")
    merges = client.query("""
        SELECT database, table, elapsed, progress, num_parts,
               formatReadableSize(total_size_bytes_compressed) AS size,
               is_mutation
        FROM system.merges
        ORDER BY elapsed DESC LIMIT 10
    """).result_rows
    cols = ["db", "table", "elapsed_s", "progress",
            "num_parts", "size", "is_mut"]
    for r in merges:
        d = dict(zip(cols, r))
        d["__level"] = LEVEL_OK
        s.rows.append(d)

    pending = client.query("""
        SELECT database, table, mutation_id,
               command, parts_to_do, latest_fail_reason
        FROM system.mutations
        WHERE is_done = 0
        ORDER BY create_time
    """).result_rows
    cols2 = ["db", "table", "mut_id", "cmd", "parts_left", "fail_reason"]
    for r in pending:
        d = dict(zip(cols2, r))
        d["__level"] = LEVEL_WARN
        s.level = max(s.level, LEVEL_WARN)
        s.rows.append(d)

    if len(pending) >= THRESH["mutation_pending_crit"]:
        s.level = LEVEL_CRIT
    elif len(pending) >= THRESH["mutation_pending_warn"]:
        s.level = max(s.level, LEVEL_WARN)
    if not merges and not pending:
        s.notes.append("无活跃 merge, 无待处理 mutation, 健康.")
    return s


# ---------------------------------------------------------------------------
# 4) 磁盘水位
# ---------------------------------------------------------------------------
def check_disks(client) -> Section:
    s = Section("4) 磁盘水位 (system.disks)")
    rows = client.query("""
        SELECT name, path,
               formatReadableSize(total_space) AS total,
               formatReadableSize(free_space) AS free,
               total_space, free_space, type
        FROM system.disks
    """).result_rows
    cols = ["name", "path", "total", "free", "total_b", "free_b", "type"]
    for r in rows:
        d = dict(zip(cols, r))
        used_pct = 1.0 - (d["free_b"] / max(d["total_b"], 1))
        d["used_pct"] = round(used_pct * 100, 1)
        if used_pct >= THRESH["disk_pct_crit"]:
            d["__level"] = LEVEL_CRIT
            s.level = max(s.level, LEVEL_CRIT)
        elif used_pct >= THRESH["disk_pct_warn"]:
            d["__level"] = LEVEL_WARN
            s.level = max(s.level, LEVEL_WARN)
        else:
            d["__level"] = LEVEL_OK
        d.pop("total_b"); d.pop("free_b")
        s.rows.append(d)
    return s


# ---------------------------------------------------------------------------
# 5) 内存 + Mark Cache 命中
# ---------------------------------------------------------------------------
def check_memory(client) -> Section:
    s = Section("5) 内存与缓存 (system.metrics + system.events)")

    mem_track = client.query("""
        SELECT value FROM system.metrics WHERE metric = 'MemoryTracking'
    """).result_rows[0][0]

    try:
        mem_total_row = client.query("""
            SELECT value FROM system.asynchronous_metrics
            WHERE metric = 'OSMemoryTotal'
        """).result_rows
        mem_total = mem_total_row[0][0] if mem_total_row else None
    except Exception:
        mem_total = None

    mem_pct = None
    if mem_total and mem_total > 0:
        mem_pct = mem_track / mem_total

    s.rows.append({
        "item": "MemoryTracking",
        "value": f"{mem_track / (1024 ** 3):.2f} GiB",
        "of_total_pct": (
            f"{mem_pct * 100:.1f}%" if mem_pct is not None else "n/a"
        ),
        "__level": (
            LEVEL_CRIT if mem_pct and mem_pct >= THRESH["mem_pct_crit"]
            else LEVEL_WARN if mem_pct and mem_pct >= THRESH["mem_pct_warn"]
            else LEVEL_OK
        ),
    })
    if mem_pct and mem_pct >= THRESH["mem_pct_crit"]:
        s.level = max(s.level, LEVEL_CRIT)
    elif mem_pct and mem_pct >= THRESH["mem_pct_warn"]:
        s.level = max(s.level, LEVEL_WARN)

    cache = client.query("""
        SELECT
            sumIf(value, event = 'MarkCacheHits')   AS hits,
            sumIf(value, event = 'MarkCacheMisses') AS misses
        FROM system.events
        WHERE event LIKE 'MarkCache%'
    """).result_rows[0]
    hits, misses = cache
    total = hits + misses
    hit_ratio = hits / total if total else 1.0
    lvl = LEVEL_OK if hit_ratio >= THRESH["mark_cache_hit_warn"] else LEVEL_WARN
    s.level = max(s.level, lvl)
    s.rows.append({
        "item": "MarkCacheHitRatio",
        "value": f"{hit_ratio * 100:.2f}%",
        "of_total_pct": f"hits={hits:,} misses={misses:,}",
        "__level": lvl,
    })
    return s


# ---------------------------------------------------------------------------
# 6) 最近 5 分钟失败查询 Top
# ---------------------------------------------------------------------------
def check_failed(client) -> Section:
    s = Section("6) 最近 5 分钟失败查询 (system.query_log)")
    rows = client.query("""
        SELECT
            count() AS cnt,
            substring(any(exception), 1, 100) AS exception_sample,
            substring(normalizeQuery(any(query)), 1, 100) AS query_sample
        FROM system.query_log
        WHERE event_time > now() - INTERVAL 5 MINUTE
          AND type IN ('ExceptionBeforeStart', 'ExceptionWhileProcessing')
        GROUP BY normalizeQueryHash(query)
        ORDER BY cnt DESC
        LIMIT 10
    """).result_rows
    if not rows:
        s.notes.append("最近 5 分钟无失败查询, 健康.")
    cols = ["cnt", "exception", "query_template"]
    for r in rows:
        d = dict(zip(cols, r))
        d["__level"] = LEVEL_WARN if d["cnt"] >= 5 else LEVEL_OK
        s.level = max(s.level, d["__level"])
        s.rows.append(d)
    return s


# ---------------------------------------------------------------------------
# 报告渲染
# ---------------------------------------------------------------------------
SYMBOL = {LEVEL_OK: "[OK]", LEVEL_WARN: "[WARN]", LEVEL_CRIT: "[CRIT]"}


def render_text(sections: List[Section]) -> str:
    out = []
    overall = max((s.level for s in sections), default=LEVEL_OK)
    out.append("=" * 64)
    out.append(f"ClickHouse 健康巡检报告  总体状态: {SYMBOL[overall]}")
    out.append("=" * 64)
    for s in sections:
        out.append("")
        out.append(f"## {SYMBOL[s.level]} {s.name}")
        for n in s.notes:
            out.append(f"  - {n}")
        if not s.rows:
            continue
        keys = [k for k in s.rows[0].keys() if not k.startswith("__")]
        widths = {k: max(len(k), max(len(str(r.get(k, ""))) for r in s.rows))
                  for k in keys}
        header = "  " + " | ".join(k.ljust(widths[k]) for k in keys)
        sep    = "  " + "-+-".join("-" * widths[k] for k in keys)
        out.append(header)
        out.append(sep)
        for r in s.rows:
            tag = SYMBOL[r.get("__level", LEVEL_OK)]
            line = "  " + " | ".join(
                str(r.get(k, "")).ljust(widths[k]) for k in keys
            )
            out.append(f"{line}   {tag}")
    out.append("")
    out.append("=" * 64)
    return "\n".join(out)


def render_json(sections: List[Section]) -> str:
    overall = max((s.level for s in sections), default=LEVEL_OK)
    body = {
        "overall": LEVEL_NAME[overall],
        "sections": [
            {
                "name": s.name,
                "level": LEVEL_NAME[s.level],
                "notes": s.notes,
                "rows": [
                    {**{k: v for k, v in r.items() if not k.startswith("__")},
                     "level": LEVEL_NAME[r.get("__level", LEVEL_OK)]}
                    for r in s.rows
                ],
            } for s in sections
        ],
    }
    return json.dumps(body, ensure_ascii=False, indent=2)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=8123)
    ap.add_argument("--user", default="default")
    ap.add_argument("--password", default="")
    ap.add_argument("--json", action="store_true",
                    help="以 JSON 格式输出, 适合喂给告警系统")
    args = ap.parse_args()

    client = get_client(args.host, args.port, args.user, args.password)
    sections = [
        check_replicas(client),
        check_parts(client),
        check_merges(client),
        check_disks(client),
        check_memory(client),
        check_failed(client),
    ]

    if args.json:
        print(render_json(sections))
    else:
        print(render_text(sections))

    overall = max((s.level for s in sections), default=LEVEL_OK)
    sys.exit(overall)


if __name__ == "__main__":
    main()

ops_check.py ↗