ClickHouse
一、是什么
业务库里有几亿条订单或事件后,一条按月份、地区和渠道聚合的报表 SQL,可能把在线事务需要的 CPU、缓存和 I/O 一起占满。ClickHouse 解决的是这类大范围读取和聚合问题:它按列组织数据,让查询只读取需要的列,再利用压缩、向量化执行和数据跳过减少扫描成本。它擅长事件、日志、指标、账单明细和用户行为分析,不是把事务库原样换个连接地址就能得到的“更快 SQL”。
数据写入 MergeTree 表后会形成不可变 part,后台 merge 再把小 part 合并为更大的有序 part。排序键影响能跳过多少数据,写入批次影响 part 数量,副本与 Keeper 影响数据如何在节点故障后继续可用。先把这三个对象弄清楚,再看分片、备份和资源治理,文章里的命令才不会变成互不相干的配置清单。
MergeTree 决定数据如何生长
MergeTree 家族的核心物理对象是不可变 data part。一次插入通常形成一个或多个 part,后台任务再把它们合并成更大的有序 part。过小且过密的写入会让 part 生成速度长期高于合并速度,最终出现 Too many parts,所以批量写入不是单纯的性能优化,而是稳定性约束。
ORDER BY 决定 part 内的物理排序和稀疏主索引。常见选择是先放高频等值过滤列,再放时间列,最后放稳定唯一键。PARTITION BY 主要服务于分区级删除、迁移和生命周期管理,不是越细越好;按用户或请求标识分区会制造海量目录和 part。
| 对象 | 解决的问题 | 不能替代 |
|---|---|---|
| ORDER BY | 数据局部性、范围裁剪、压缩 | 传统数据库的唯一约束 |
| PARTITION BY | 分区级生命周期与运维 | 任意过滤条件的索引 |
| PRIMARY KEY | 稀疏索引定义,默认与排序键相关 | 行级唯一性校验 |
| TTL | 到期删除、迁移或聚合 | 精确到秒的实时删除 |
| skip index | 对特定分布追加跳过能力 | 错误排序键的补救 |
| projection | 预计算另一种数据组织 | 无成本的万能索引 |
ReplacingMergeTree 会在后台合并时按排序键保留较新版本,但重复行在合并完成前仍可能同时可见。查询必须用 argMax 等确定性聚合表达业务最新值,或在小范围校验时使用成本更高的 FINAL。CollapsingMergeTree 依赖严格成对的正负 sign 事件,丢一边就会失真;AggregatingMergeTree 存放聚合状态,查询必须使用对应的 *Merge 函数。三者都是显式数据模型,不是“自动去重”的开关。
ALTER UPDATE 与 ALTER DELETE mutation 会重写相关 part。大范围 mutation 会竞争磁盘、CPU 与后台线程,生产链路优先采用追加新版本、分区替换、TTL 或离线重算,并通过 system.mutations 跟踪完成和失败状态。
列类型、编码与压缩共同决定扫描成本
列式存储的收益来自“只读需要的列”,但列类型决定每一行在磁盘、内存和网络中占多少字节。可枚举且取值稳定的维度适合 LowCardinality(String);时间必须明确时区与精度;金额若要求十进制定点语义,使用 Decimal 而不是 Float;IP、UUID、IPv4 与 IPv6 使用原生类型,避免每次查询解析字符串。Nullable 会额外保存 null map,并让部分聚合与条件推导更复杂,业务上有明确默认值时不应无条件把所有列声明为 Nullable。
Array、Map、Tuple 与 Nested 能表达半结构化事件,但它们不会免除模型成本。高频筛选字段应提升为独立列;把整个 JSON 长期保存在 String 中,会失去类型校验、压缩局部性和直接裁剪能力。原始载荷可以作为审计补充,查询主链路仍应使用稳定列。新增列通常是元数据操作,改变类型却可能触发重写或兼容性问题,因此 schema 演进需要先证明旧查询能读取新旧数据,再逐步回填和切换。
先用真实样本比较类型与 codec,而不是仅比较建表语句。system.columns 能看到每列压缩前后字节,system.parts_columns 能把差异定位到 part。压缩比高并不必然代表总体更快,复杂 codec 也会增加 CPU;排序后相邻值变化缓慢的数值列才适合 Delta 或 DoubleDelta,单调时间序列常适合 Delta 配合 ZSTD。
CREATE TABLE analytics.events_codec_lab
(
tenant_id LowCardinality(String),
event_type LowCardinality(String),
event_id UUID,
occurred_at DateTime64(3, 'UTC') CODEC(Delta, ZSTD(1)),
amount Decimal(18, 2) CODEC(Delta, ZSTD(1)),
source_ip IPv6 CODEC(ZSTD(1)),
attributes Map(LowCardinality(String), String),
raw_payload String CODEC(ZSTD(3))
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (tenant_id, event_type, occurred_at, event_id);
SELECT table, name, type,
formatReadableSize(data_compressed_bytes) AS compressed,
formatReadableSize(data_uncompressed_bytes) AS uncompressed,
round(data_uncompressed_bytes / nullIf(data_compressed_bytes, 0), 2) AS ratio
FROM system.columns
WHERE database = 'analytics' AND table = 'events_codec_lab'
ORDER BY data_uncompressed_bytes DESC;相同样本、相同查询并重复预热后,才能比较 codec。正常结果是高频过滤列类型稳定、压缩比有收益且查询 CPU 没有越过预算;若 raw_payload 占据大部分空间,应先拆出热字段、调整保留期或迁移冷数据,而不是盲目提高 ZSTD 等级。
查询由读取、并行流水线与聚合状态组成
查询进入服务端后先完成解析、语义分析和权限检查,再根据分区、主键、跳过索引与 PREWHERE 决定读取哪些 granule。读取阶段把列块送入并行 pipeline,过滤、表达式、聚合与排序按 block 执行,最后合并线程汇总状态。线程数更多只在 CPU、磁盘和网络仍有余量时有效;并发查询已经占满核心时继续提高 max_threads,会增加争用而不是缩短延迟。
PREWHERE 先读取少量过滤列,再为命中行读取其余列,特别适合宽表。优化器可以自动移动条件,但显式 PREWHERE 仍要用 EXPLAIN 和 query_log 验证。聚合函数把每组状态保存在内存中,高基数 GROUP BY 的主要风险不是返回行数,而是中间哈希表;两级聚合和外部聚合可以控制峰值,却会引入磁盘 I/O。ORDER BY、DISTINCT、窗口函数和集合型聚合也应按中间状态估算,而不是只看最终结果。
EXPLAIN PIPELINE
SELECT tenant_id, event_type, count() AS events,
uniqCombined64(event_id) AS unique_events
FROM analytics.events_local
PREWHERE occurred_at >= now() - INTERVAL 1 DAY
WHERE tenant_id IN ('tenant-a', 'tenant-b')
GROUP BY tenant_id, event_type
ORDER BY events DESC
LIMIT 100;
SELECT query_id, query_duration_ms, read_rows, read_bytes,
result_rows, memory_usage, ProfileEvents['SelectedMarks'] AS selected_marks,
ProfileEvents['ExternalAggregationWritePart'] AS spilled_parts
FROM system.query_log
WHERE type = 'QueryFinish' AND query_id = 'capacity-query-01';观察时要把 read_rows 与 result_rows、SelectedMarks、峰值内存和 spill 同时放在一起。只看到查询返回 100 行无法证明它便宜;读取数十亿行后 LIMIT 100 仍然是一条重查询。
JOIN、字典与物化视图解决不同的数据组合问题
ClickHouse 支持 JOIN,但分布式 JOIN 的成本由两侧数据量、分片布局和算法共同决定。小维表可以使用广播语义或 Dictionary;两张大表若没有按连接键共置,GLOBAL JOIN 会把右表发送到各节点,普通分布式 JOIN 也可能产生多次远程组合。任何 JOIN 都要先限制时间范围与投影列,并通过 EXPLAIN、网络字节和峰值内存验证。
Dictionary 适合把较小、按键查找的维度数据加载到内存或缓存结构,查询使用 dictGet 避免重复构造 JOIN 哈希表。它需要明确刷新周期、源端失败语义和缺失键默认值;维度变更必须立即强一致可见时,Dictionary 不是免费答案。字典状态从 system.dictionaries 检查,加载异常不能只靠业务查询偶然暴露。
ClickHouse source 即使指向本机,也会以 source 配置中的用户重新认证;省略用户和密码会退回 default 与空密码,既可能在硬化环境加载失败,也可能意外依赖高权限 default。生产改用专用 dictionary_reader、TLS 9440 和 Server 侧命名 collection。配置管理把下面 XML 安装到每个 Server,密码从进程 secret 注入,文件与环境只允许 clickhouse 服务账号读取。
<clickhouse>
<named_collections>
<analytics_dictionary_source>
<host>ch-router.internal.example</host>
<port>9440</port>
<secure>1</secure>
<user>dictionary_reader</user>
<password from_env="CLICKHOUSE_DICTIONARY_PASSWORD"/>
<db>analytics</db>
</analytics_dictionary_source>
</named_collections>
</clickhouse>dictionary_reader 由后文的受控身份流程创建,不在迁移 SQL 中硬编码密码。创建 Dictionary 前,先用这个身份通过同一 9440 endpoint 完成 TLS、身份和单表 SELECT 正向验证,并确认它不能读取 events、执行 DDL 或 BACKUP。
CREATE TABLE analytics.tenant_dimension
(
tenant_id String,
tenant_name String,
plan LowCardinality(String),
updated_at DateTime64(3, 'UTC')
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY tenant_id;
INSERT INTO analytics.tenant_dimension VALUES
('tenant-a', '示例租户', 'standard', now64(3));
CREATE ROLE IF NOT EXISTS dictionary_source_role;
GRANT SELECT ON analytics.tenant_dimension TO dictionary_source_role;
GRANT dictionary_source_role TO dictionary_reader;
CREATE DICTIONARY analytics.tenant_dict
(
tenant_id String,
tenant_name String,
plan LowCardinality(String)
)
PRIMARY KEY tenant_id
SOURCE(CLICKHOUSE(
NAME analytics_dictionary_source
TABLE 'tenant_dimension'
))
LAYOUT(HASHED())
LIFETIME(MIN 300 MAX 600);
SELECT tenant_id,
dictGet('analytics.tenant_dict', 'tenant_name', tenant_id) AS tenant_name,
count() AS events
FROM analytics.events_local
WHERE occurred_at >= now() - INTERVAL 1 DAY
GROUP BY tenant_id
ORDER BY events DESC;
SELECT database, name, status, element_count, bytes_allocated,
last_successful_update_time, loading_duration, last_exception
FROM system.dictionaries
WHERE database = 'analytics';命名 collection 把凭据移出 DDL,却不会让缓存变成强一致。维表写入后要等待 LIFETIME 触发刷新,或在受控变更中执行 SYSTEM RELOAD DICTIONARY analytics.tenant_dict;随后检查 system.dictionaries.status='LOADED'、last_exception 为空,并用一个已知 tenant_id 做正向 lookup。每个查询副本都必须装有同名 collection、受信 CA 和 secret,并得到相同维度版本,不能只在创建 DDL 的节点验证成功。
物化视图在插入源表时把新 block 转换并写入目标表,不会自动回算创建前的数据,也不会随着源表 mutation 自动保持历史结果一致。它适合稳定口径的增量预聚合;口径变化时通常要创建新目标表、新视图、回填历史、比对双读,再原子切换消费者。
CREATE TABLE analytics.events_hourly
(
tenant_id LowCardinality(String),
event_type LowCardinality(String),
bucket DateTime('UTC'),
ingested_rows AggregateFunction(count),
unique_events AggregateFunction(uniqCombined64, UUID)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(bucket)
ORDER BY (tenant_id, event_type, bucket);
CREATE MATERIALIZED VIEW analytics.events_hourly_mv
TO analytics.events_hourly AS
SELECT tenant_id, event_type,
toStartOfHour(occurred_at) AS bucket,
countState() AS ingested_rows,
uniqCombined64State(event_id) AS unique_events
FROM analytics.events_local
GROUP BY tenant_id, event_type, bucket;
SELECT tenant_id, event_type, bucket,
countMerge(ingested_rows) AS physical_ingested_rows,
uniqCombined64Merge(unique_events) AS unique_events
FROM analytics.events_hourly
WHERE bucket >= toStartOfHour(now() - INTERVAL 1 DAY)
GROUP BY tenant_id, event_type, bucket
ORDER BY bucket, tenant_id, event_type;这个物化视图明确统计物理摄取行数,unique_events 只是在同一租户、事件类型和小时 bucket 中估算不同 event_id。向 ReplacingMergeTree 源写入同一 event_id 的两个版本时,physical_ingested_rows 会增加两次;源表以后 merge 不会向目标聚合表发送撤销记录。同一事件若改变 event_type 或 occurred_at bucket,还会进入两个聚合组。因此它适合摄取量和稳定不可变事件,不应被命名或解释为“当前逻辑事件总数”。需要最新业务状态时,先在可控范围按 event_id 和版本用 argMax 归一,或采用带正负补偿的显式模型并对迟到、重放与撤销做反向实验。
回填不能再次经过同一视图造成双写。安全做法是明确目标表,按分区运行 INSERT INTO events_hourly SELECT ...State(),记录每个分区的源物理行数、唯一 event_id 与聚合校验,最后再开放消费者。重复执行回填前先按目标分区删除或使用新的版本化目标表,不能期待聚合状态自动去重。
协议、进程与系统表
Native 协议通常使用端口 9000,TLS 入口通常使用 9440;HTTP 通常使用 8123,HTTPS 通常使用 8443。端口可以修改,客户端必须同时验证服务端版本、当前用户、当前 database 和 TLS 主机名,不能把“TCP 可达”当成身份正确。
clickhouse client 与发行包中的 clickhouse-client 都用于通过 Native 协议执行 SQL。密码省略值时由客户端交互询问;自动化通过仓库外、权限为 0600 的配置文件或密钥注入器提供秘密。密码不进入命令参数、连接 URL、Git 仓库和 shell 历史。
单一 clickhouse 可执行文件通过 client 子命令进入 Native 客户端,发行包同时提供 clickhouse-client 入口。下载单文件时先运行 ./clickhouse client --version 确认客户端身份;clickhousectl 管理本地或 Cloud 资源,不是 SQL 客户端的新名字。
客户端只读取找到的第一份配置,查找顺序从显式 --config 开始,再到当前目录、$XDG_CONFIG_HOME/clickhouse/config.yaml、用户目录和系统目录。交互历史默认进入 .clickhouse-client-history;生产排障用 --history_file 指向受控短期路径。批处理用 --query 或 --queries-file,参数化 SQL 使用 --param_tenant_id 一类参数,不把值拼进查询文本。--connection 选择命名连接,TLS 连接同时使用 --secure。
批处理验收同时检查退出码、query_id 和服务端 initial_query_id。查询运行状态从 system.processes 与 system.query_log 复核;导入格式使用 CSVWithNames 或 JSONEachRow,文件导出使用 INTO OUTFILE 并确认目标权限。共享环境为客户端设置 max_rows_to_read、max_result_rows 与 max_execution_time,避免一次探索查询拖垮节点。
接入 AI 辅助诊断时,数据库进程和查询工具都不读取 OPENAI_API_KEY 或 ANTHROPIC_API_KEY,也不为方便而打开 enable_schema_access。脱敏 schema 与抽样计划经过审批后再离开数据库边界,查询历史和导出文件继续按数据分级保管。
| 系统表 | 观察重点 | 典型完成标准 |
|---|---|---|
| system.parts | active part 数、行数、字节、分区 | part 数稳定且没有持续单调增长 |
| system.merges | 正在合并的 part、耗时与进度 | 合并队列能回落 |
| system.query_log | 查询耗时、读行数、异常、query_id | 核心查询命中预算 |
| system.processes | 当前查询与资源占用 | 取消后查询消失 |
| system.replicas | 副本只读、延迟、队列、会话 | 全部可写且延迟接近零 |
| system.replication_queue | 复制任务类型、重试和异常 | 无长期滞留任务 |
| system.distribution_queue | Distributed 异步投递 | 队列可清空 |
| system.mutations | mutation 进度和失败原因 | 目标 mutation 完成 |
| system.backups | 备份或恢复状态与错误 | 状态为 BACKUP_CREATED 或 RESTORED |
单机、分片副本与托管边界
单机适合开发验证和可重建分析任务;单分片多副本适合容量尚未要求横向拆分、但必须容忍单节点故障的生产链路;多分片多副本同时解决容量和节点故障,但把分片键、跨分片聚合、扩容迁移、Keeper 法定多数与恢复顺序带入日常运维。
下面采用两分片、每分片两副本、三个独立 Keeper 节点作为自建生产基线。四个 ClickHouse Server 不与 Keeper 共用故障域,副本跨主机放置,写入按租户标识稳定散列到分片。小团队若没有维护法定多数、备份演练和滚动升级的能力,ClickHouse Cloud 通常比自行拼装集群更可控;托管方案仍需核对区域、网络、身份、费用、备份保留和退出路径。
二、为什么
关系型业务库擅长短事务、点查和强约束,但同一套实例承担大范围聚合后,长扫描会与在线写入争夺缓存、CPU 和 I/O。ClickHouse 把分析负载隔离出来,让业务库继续处理事务,让列式链路承接大规模扫描。这个收益只有在数据复制延迟、口径、权限和恢复目标都明确时才成立。
从选择边界推导可交付架构
选择它之前先量化边界
| 维度 | 更适合 ClickHouse | 应继续评估其他方案 |
|---|---|---|
| 查询 | 大范围过滤、聚合、时间序列分析 | 高频单行更新、强事务点查 |
| 写入 | 追加为主、可批量、允许秒级可见 | 每行同步提交、持续小更新 |
| 一致性 | 可用事件版本或补偿处理重复 | 跨表强事务和即时唯一约束 |
| 数据量 | 压缩与扫描成本已成为瓶颈 | 数据量小且普通索引已足够 |
| 团队能力 | 能管理表模型、资源与恢复演练 | 只能确认进程存活 |
概念验证应使用脱敏但保持真实分布的数据,至少覆盖常见租户规模、时间跨度、事件类型倾斜和迟到数据。验收不只比较平均耗时,还要记录 p95、读取行数、读取字节、峰值内存、写入批次大小、part 增长和后台 merge 是否回落。
容量模型必须同时计算数据、合并和复制
容量不能只用“每天原始日志大小乘保留天数”。应先从代表性样本测量压缩后每行字节,再乘每日行数、保留天数、副本数与安全余量;后台 merge 会同时保留输入和输出 part,mutation、备份与副本追平也需要临时空间。生产磁盘不能把稳定使用率设计到接近 100%,否则一次大 merge 就可能让节点只读。
设压缩后每行 180 字节,每日 5 亿行,保留 180 天,两副本,基础数据约为 32.4 TB。再考虑 30% 的 merge/突发余量、索引与元数据、备份暂存后,集群可用容量必须明显高于这个值。实际计算使用样本表的 bytes_on_disk / rows,并分别记录热分区和冷分区,因为字符串分布、codec 与排序局部性会改变压缩比。
SELECT
table,
sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS disk,
round(sum(bytes_on_disk) / nullIf(sum(rows), 0), 2) AS bytes_per_row,
round(sum(data_uncompressed_bytes) / nullIf(sum(data_compressed_bytes), 0), 2) AS compression_ratio
FROM system.parts
WHERE active AND database = 'analytics'
GROUP BY table
ORDER BY sum(bytes_on_disk) DESC;
SELECT toStartOfHour(event_time) AS hour,
sum(written_rows) AS inserted_rows,
sum(written_bytes) AS inserted_bytes,
countIf(type = 'ExceptionWhileProcessing') AS failed_queries
FROM system.query_log
WHERE event_time >= now() - INTERVAL 1 DAY
GROUP BY hour
ORDER BY hour;CPU 预算从峰值并发乘单查询核心数开始,内存预算按最重聚合、JOIN 与并发相加,再给后台 merge、字典和操作系统页缓存留空间。网络同时承载客户端写入、分布式查询、复制抓取和备份;跨可用区部署前必须估算复制与查询流量成本。达到容量阈值后的动作应预先写清:先降低保留或把冷数据迁往对象存储,还是增加副本磁盘、扩分片并重平衡。没有动作阈值的容量报表只是历史记录。
一致性来自写入语义与查询口径
MergeTree 不提供关系库式唯一约束。客户端超时后同一批数据可能已经落盘,也可能未落盘;重试必须复用稳定的 insert_deduplication_token,或证明序列化后的 block 与首次完全相同从而复用 block hash,并把业务事件 ID 纳入模型。query_id 只用于追踪、诊断和并发查询身份,不提供持久插入去重,单独复用它不能防止重复。副本表的去重窗口有时间和数量边界,跨窗口重放仍可能形成重复 part;最终口径需要 ReplacingMergeTree 的版本键或聚合层的确定性去重。
异步 Distributed 写入把“本机接受文件”和“目标 shard 已写入”分成两个时刻。关键数据优先使用前台投递并明确 quorum;追求吞吐而选择异步时,必须监控 system.distribution_queue,上游只有在队列清空并完成业务校验后才能把批次标记为全链路完成。insert_quorum 保证同分片足够副本确认,不会让多个分片成为一个原子事务。
查询也有副本可见性边界。刚完成 quorum 写入后,路由到另一个未确认副本的查询不能假定立即看到数据。需要读己之写时可以固定会话路由、使用同步副本设置或把完成标志放到业务控制面,但要测量延迟和可用性代价。跨多表物化视图、Dictionary 和异步摄取形成的是多阶段最终一致链路,报表必须定义允许的水位,而不是笼统宣称“实时”。
INSERT INTO analytics.events
SETTINGS
insert_quorum = 2,
insert_quorum_timeout = 10000,
insert_deduplication_token = 'tenant-a:batch-0001'
FORMAT JSONEachRow
{"tenant_id":"tenant-a","event_type":"page_view","event_id":"018f0c95-9012-7a11-b345-111111111111","occurred_at":0,"ingested_at":1,"amount":"0.00","payload":"{}"}写入失败时先用业务批次表、目标数据和 query log 三方确认,再决定是否重试。不能把客户端超时直接解释为服务端回滚,也不能更换 token 后盲目重放。
当前版本基线必须可复现
当前官方安装文档与包索引同时提供稳定版本 26.7.5.10 与长期支持版本 26.3.25.2。生产基线选择长期支持版本 26.3.25.2,原因是升级窗口更保守;需要更快获得新功能的团队可以选择稳定通道,但必须重新完成真实数据、驱动、备份恢复和滚动升级验证。MergeTree、Keeper 与原生备份的行为分别以 MergeTree 官方文档、Keeper 官方文档和备份恢复官方文档为准。
| 工件 | 固定值 | 验证用途 |
|---|---|---|
| APT 软件包 | 26.3.25.2 | 服务端与客户端一致安装 |
| 仓库签名公钥 | SHA-256 74c1dfd89393b27c5739ee579a5af661ebe628cef9c33222c6fb29d0b8d42ad0 | 导入前验证下载内容 |
| 容器镜像 | clickhouse/clickhouse-server:26.3.25.2 | 仅开发与复现 |
| 多架构镜像 digest | sha256:1b6d698b24b681d97d1ddefb4c54dc075030a704b2b7ea5255932480125efdf2 | 避免 tag 漂移 |
| amd64 manifest | sha256:4d8f163dabf90600a753c6162b6add2dc6c531f919eee51d43d8b7f7c14b3e28 | amd64 拉取校验 |
| arm64 manifest | sha256:7cfe151a405e5ba31ada69f70611bbfba4c137af33a4fba05182ff5c9a054111 | arm64 拉取校验 |
| Node.js 客户端 | @clickhouse/client 1.23.1,Node.js 20 及以上 | 应用接入可复现 |
固定 tag 仍然不等于供应链验证。镜像部署要记录实际拉取的 repo digest,软件包部署要保留仓库 key 哈希、包版本和变更审批。升级前阅读官方 changelog 与 backward-incompatible 说明,并在预生产回放真实查询和写入。
排序键、分区键和批次决定成本
下面的事件模型把常见等值过滤列放在排序键前部,把月份作为生命周期分区。event_id 保证同一租户内的版本归并边界稳定,ingested_at 为 ReplacingMergeTree 提供版本。
CREATE DATABASE IF NOT EXISTS analytics;
CREATE TABLE analytics.events_local
(
tenant_id LowCardinality(String),
event_type LowCardinality(String),
event_id UUID,
occurred_at DateTime64(3, 'UTC'),
ingested_at DateTime64(3, 'UTC'),
amount Decimal(12, 2),
payload String
)
ENGINE = ReplacingMergeTree(ingested_at)
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (tenant_id, event_type, occurred_at, event_id)
TTL occurred_at + INTERVAL 180 DAY DELETE
SETTINGS index_granularity = 8192;排序键的正实验使用租户、事件类型和时间范围,负实验只按 payload 做包含匹配。EXPLAIN indexes = 1 应显示前者裁剪 granule,后者读取更多数据;执行后再用相同 query_id 从 system.query_log 比较 read_rows、read_bytes 与峰值内存。
EXPLAIN indexes = 1
SELECT count()
FROM analytics.events_local
WHERE tenant_id = 'tenant-a'
AND event_type = 'page_view'
AND occurred_at >= now() - INTERVAL 7 DAY;
EXPLAIN indexes = 1
SELECT count()
FROM analytics.events_local
WHERE payload ILIKE '%campaign%';高基数分区的反例可以在一次性实验表上使用 PARTITION BY tenant_id,写入相同样例后比较 system.parts 中 active part 和 partition 数量。完成观察立即删除实验表,不能把反例结构带入共享环境。
安全与恢复属于同一条交付链
开发实例只绑定回环地址;生产实例绑定私网地址,客户端使用 TLS 入口,节点间复制使用受限网络和 interserver HTTPS。网络可达范围、数据库权限、settings profile 和 quota 分层控制,避免一个应用账号既能写原始表、改 schema、执行 BACKUP,又能读取所有租户。
备份的完成条件不是出现一个归档文件。一次恢复演练必须在隔离实例中恢复到新 database,验证 schema、行数、关键聚合、权限负例和应用只读查询,再记录恢复耗时。副本不是备份:错误删除和错误 mutation 会被复制到所有副本,Keeper 也不保存数据 part 本身。
三、怎么做
十分钟跑通一张 MergeTree 表
先在本机启动一个可以随时删除的容器。这里使用 LTS 发行线,HTTP 和 Native 端口都只绑定回环地址;学习数据写入独立 volume,不会混进其他项目。
export CLICKHOUSE_PASSWORD='replace-with-a-local-password'
docker run -d --name clickhouse-learning \
-p 127.0.0.1:8124:8123 \
-p 127.0.0.1:9001:9000 \
-e CLICKHOUSE_DB=analytics \
-e CLICKHOUSE_USER=dev_app \
-e CLICKHOUSE_PASSWORD \
-v clickhouse-learning-data:/var/lib/clickhouse \
clickhouse/clickhouse-server:26.3.25.2
docker exec clickhouse-learning clickhouse-client \
--user dev_app --password "$CLICKHOUSE_PASSWORD" \
--query "SELECT version(), currentUser(), currentDatabase() FORMAT Vertical"输出应显示 LTS 版本、dev_app 和 analytics。TCP 端口已经监听但查询失败时,先看 docker logs clickhouse-learning;不要通过清空密码或开放默认用户绕过初始化错误。
进入客户端后创建事件表。这里不另造“入门专用表”,而是先建立后文一直复用的事件契约:租户、事件类型、稳定事件 ID、发生与摄取时间、金额和原始载荷。单机、Node.js、分片副本与恢复环境只改变引擎和入口,不改变这组业务列。
docker exec -it clickhouse-learning clickhouse-client \
--user dev_app --password "$CLICKHOUSE_PASSWORD" \
--database analyticsCREATE TABLE events
(
tenant_id LowCardinality(String),
event_type LowCardinality(String),
event_id UUID,
occurred_at DateTime64(3, 'UTC'),
ingested_at DateTime64(3, 'UTC'),
amount Decimal(12, 2),
payload String
)
ENGINE = ReplacingMergeTree(ingested_at)
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (tenant_id, event_type, occurred_at, event_id);
INSERT INTO events VALUES
('tenant-a','page_view','018f0c95-9012-7a11-b345-111111111111',now64(3),now64(3),0,'{"page":"/pricing"}'),
('tenant-a','purchase', '018f0c95-9012-7a11-b345-222222222222',now64(3),now64(3),199.00,'{"order_no":"ORD-1001"}'),
('tenant-b','purchase', '018f0c95-9012-7a11-b345-333333333333',now64(3),now64(3),88.50,'{"order_no":"ORD-1002"}');
SELECT tenant_id,
count() AS events,
sum(amount) AS revenue
FROM events
GROUP BY tenant_id
ORDER BY revenue DESC;结果中 tenant-a 应有两条事件、收入 199.00,tenant-b 有一条事件、收入 88.50。这一步证明的是列类型、建表、批量写入和聚合查询,不是高可用。
接着把 SQL 结果和物理 part 联系起来:
SELECT partition,
name,
rows,
formatReadableSize(bytes_on_disk) AS disk_size
FROM system.parts
WHERE database = currentDatabase()
AND table = 'events'
AND active
ORDER BY partition, name;
EXPLAIN indexes = 1
SELECT count()
FROM events
WHERE tenant_id = 'tenant-a'
AND event_type = 'purchase'
AND occurred_at >= now() - INTERVAL 1 DAY;system.parts 会显示刚才的批次已经落成 part;EXPLAIN indexes = 1 会展示分区和主键条件如何缩小 granule 范围。反向查询只按 amount > 0 过滤时,排序键无法提供同样的裁剪效果。由此可以直接看到:LIMIT 只限制返回行,排序键与时间范围才影响读取量。
退出客户端后删除学习环境:
docker rm -f clickhouse-learning
docker volume rm clickhouse-learning-data
unset CLICKHOUSE_PASSWORD在 Linux 上建立可维护的单节点服务
下面完成 Ubuntu 22.04/24.04 的原生包与 systemd 单节点基线,再完成应用接入,最后扩展到两分片、每分片两副本和三个 Keeper。示例域名与私网地址需要替换为环境中的受控地址,密码和私钥由密钥系统注入。
用官方仓库安装固定版本
先下载并核对仓库签名公钥,再写入 APT keyring。哈希不一致时停止安装并从官方包页面重新确认,不能跳过校验。
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
curl --fail --proto '=https' --tlsv1.2 \
--output /tmp/clickhouse-repo-key.asc \
https://packages.clickhouse.com/rpm/lts/repodata/repomd.xml.key
printf '%s %s\n' \
'74c1dfd89393b27c5739ee579a5af661ebe628cef9c33222c6fb29d0b8d42ad0' \
'/tmp/clickhouse-repo-key.asc' \
| sha256sum --check -
sudo gpg --dearmor --yes \
--output /usr/share/keyrings/clickhouse-keyring.gpg \
/tmp/clickhouse-repo-key.asc
echo 'deb [signed-by=/usr/share/keyrings/clickhouse-keyring.gpg] https://packages.clickhouse.com/deb lts main' \
| sudo tee /etc/apt/sources.list.d/clickhouse.list
sudo apt-get update
apt-cache madison clickhouse-server clickhouse-client clickhouse-common-static
sudo apt-get install -y \
clickhouse-common-static=26.3.25.2 \
clickhouse-server=26.3.25.2 \
clickhouse-client=26.3.25.2
sudo systemctl enable --now clickhouse-server若发行仓库为包版本追加修订后缀,以 apt-cache madison 返回的完整版本字符串为准,三件套必须选择同一完整版本。部署记录同时保存 clickhouse-server --version、clickhouse-client --version 和 systemctl status clickhouse-server。
默认数据目录是 /var/lib/clickhouse,日志通常位于 /var/log/clickhouse-server,主配置位于 /etc/clickhouse-server。数据盘挂载后确认属主为 clickhouse;配置变更先用 clickhouse extract-from-config 读取预期 key,确认合并后的配置能解析,再通过 systemd 重启并观察日志。
sudo install -d -o clickhouse -g clickhouse -m 0750 /srv/clickhouse
sudo systemctl status clickhouse-server --no-pager
sudo journalctl -u clickhouse-server -n 100 --no-pager
clickhouse-client --query \
"SELECT version(), currentUser(), currentDatabase(), timezone() FORMAT Vertical"建立最小权限与资源边界
管理员在受审计终端中创建角色,再由密钥系统支持的身份流程创建 analytics_runtime、analytics_read、analytics_migrate 与仅供 Dictionary source 使用的 dictionary_reader 用户。用户秘密不写在 SQL 文件中。应用运行时与迁移身份分离,运行时账号不能创建、删除、备份、读取本地存储表或修改用户;dictionary_reader 只获得维表 SELECT,并由 Server 的命名 collection 使用。
CREATE ROLE IF NOT EXISTS analytics_ingest_role;
CREATE ROLE IF NOT EXISTS analytics_read_role;
CREATE ROLE IF NOT EXISTS analytics_migrate_role;
GRANT INSERT ON analytics.events TO analytics_ingest_role;
GRANT SELECT ON analytics.events TO analytics_read_role;
GRANT CREATE TABLE, ALTER TABLE, DROP TABLE
ON analytics.* TO analytics_migrate_role;
GRANT analytics_ingest_role, analytics_read_role TO analytics_runtime;
GRANT analytics_read_role TO analytics_read;
GRANT analytics_migrate_role TO analytics_migrate;
SET DEFAULT ROLE analytics_ingest_role, analytics_read_role TO analytics_runtime;
SET DEFAULT ROLE analytics_read_role TO analytics_read;
SET DEFAULT ROLE analytics_migrate_role TO analytics_migrate;
CREATE SETTINGS PROFILE IF NOT EXISTS analytics_ingest_profile
SETTINGS
max_execution_time = 30,
max_memory_usage = 4000000000,
max_threads = 8,
readonly = 0;
CREATE SETTINGS PROFILE IF NOT EXISTS analytics_read_profile
SETTINGS
max_execution_time = 30,
max_memory_usage = 4000000000,
max_threads = 8,
readonly = 1;
CREATE QUOTA IF NOT EXISTS analytics_runtime_quota
FOR INTERVAL 1 HOUR MAX queries = 20000, errors = 200
TO analytics_runtime, analytics_read;
ALTER USER analytics_runtime SETTINGS PROFILE analytics_ingest_profile;
ALTER USER analytics_read SETTINGS PROFILE analytics_read_profile;生产客户端配置放在仓库外并限制为服务组可读。配置只声明端点、TLS CA、用户和 database,密码由进程级 secret 注入;如果密钥系统必须落盘,生成单独 0600 文件并在进程退出后按策略清理。配置管理先把下面的 YAML 渲染到受控暂存路径,再原子安装到目标文件。
connections_credentials:
connection:
- name: analytics-prod-read
hostname: ch-router.internal.example
port: 9440
secure: true
user: analytics_read
database: analytics
- name: analytics-prod-runtime
hostname: ch-router.internal.example
port: 9440
secure: true
user: analytics_runtime
database: analytics
- name: analytics-prod-migrate
hostname: ch-router.internal.example
port: 9440
secure: true
user: analytics_migrate
database: analytics
openSSL:
client:
caConfig: /etc/company-ca/clickhouse-ca.pem
history_file: /run/clickhouse-client/history
history_max_entries: 100sudo install -d -o root -g app -m 0750 /etc/app
sudo install -o root -g app -m 0640 \
/srv/config-rendered/clickhouse-client.yaml \
/etc/app/clickhouse-client.yaml
sudo -u app test -r /etc/app/clickhouse-client.yaml
sudo -u app grep -F 'name: analytics-prod-read' \
/etc/app/clickhouse-client.yaml
sudo -u app clickhouse-client \
--config /etc/app/clickhouse-client.yaml \
--connection analytics-prod-read \
--ask-password \
--query_id smoke-identity \
--query \
"SELECT version(), currentUser(), currentDatabase(), timezone() FORMAT Vertical"HTTP 自动化使用权限为 0600 的 curl 配置文件承载 user 与 password,业务 URL 只含 HTTPS 主机。日志只记录 query_id、状态和耗时,不记录 Authorization 头或配置内容。
用 Compose 固化团队开发环境
开发容器固定 LTS tag 和多架构 digest,端口只绑定 127.0.0.1。密码通过未跟踪的 .env.local 注入;Docker 管理员仍可能通过容器元数据看到环境变量,因此这一方式不能作为生产秘密边界。
services:
clickhouse:
image: clickhouse/clickhouse-server:26.3.25.2@sha256:1b6d698b24b681d97d1ddefb4c54dc075030a704b2b7ea5255932480125efdf2
environment:
CLICKHOUSE_DB: analytics
CLICKHOUSE_USER: dev_app
CLICKHOUSE_PASSWORD: $CLICKHOUSE_PASSWORD
ports:
- "127.0.0.1:8124:8123"
- "127.0.0.1:9001:9000"
volumes:
- clickhouse-data:/var/lib/clickhouse
healthcheck:
test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
interval: 5s
timeout: 3s
retries: 20
volumes:
clickhouse-data:install -m 0600 /dev/null .env.local
read -r -s -p 'Local ClickHouse password: ' CLICKHOUSE_PASSWORD
printf '\nCLICKHOUSE_PASSWORD=%s\n' "$CLICKHOUSE_PASSWORD" > .env.local
unset CLICKHOUSE_PASSWORD
docker compose --env-file .env.local up -d
docker compose ps
docker compose exec clickhouse clickhouse-client \
--query "SELECT version(), currentUser(), currentDatabase()"开发清理前先确认 Compose 项目名与 volume 名,再执行 docker compose down。只有明确要删除这套可重建样例数据时才追加 --volumes。
初始化表并验证 part、排序与版本语义
迁移工具以专用身份执行 schema,应用运行时不自动获得 DDL 权限。样例写入应一次提交数百到数万行,而不是逐行循环。
INSERT INTO analytics.events_local
SELECT
concat('tenant-', toString(number % 20)),
arrayElement(['page_view', 'purchase', 'login'], 1 + number % 3),
generateUUIDv4(),
now64(3) - toIntervalSecond(number % 86400),
now64(3),
toDecimal64(number % 10000, 2),
concat('{"sample":', toString(number), '}')
FROM numbers(100000);
SYSTEM FLUSH LOGS;
SELECT partition, count() AS active_parts, sum(rows) AS rows
FROM system.parts
WHERE active AND database = 'analytics' AND table = 'events_local'
GROUP BY partition
ORDER BY partition;
SELECT elapsed, progress, num_parts, total_size_bytes_compressed
FROM system.merges
WHERE database = 'analytics' AND table = 'events_local';ReplacingMergeTree 的正反实验向同一个 event_id 写入两个不同 ingested_at 版本。普通查询可能暂时看到两行,argMax(payload, ingested_at) 应稳定返回较新值;FINAL 仅用于小范围校验。mutation 实验必须使用一次性表,并在 system.mutations 显示完成后清理。
用 clickhouse-client 完成日常分析链
先确认连接身份,再浏览真实对象
连接后的第一步不是执行业务查询,而是证明端点、身份、database、时区和版本都正确。命名连接负责保存非秘密参数,--ask-password 交互读取密码;自动任务则由 secret 管理器向进程注入,不把密码放进参数。--multiquery 可以执行多条语句,但脚本不会像关系数据库事务那样整体回滚,所以迁移仍要让每一步可重入,并在失败后查询实际对象状态。
clickhouse-client \
--config /etc/app/clickhouse-client.yaml \
--connection analytics-prod-read \
--ask-password \
--query_id cli-identity-001 \
--query "SELECT hostName(), version(), currentUser(), currentDatabase(), timezone() FORMAT Vertical"
clickhouse-client \
--config /etc/app/clickhouse-client.yaml \
--connection analytics-prod-read \
--ask-password \
--query "SHOW DATABASES"
clickhouse-client \
--config /etc/app/clickhouse-client.yaml \
--connection analytics-prod-read \
--ask-password \
--query "SHOW TABLES FROM analytics"
clickhouse-client \
--config /etc/app/clickhouse-client.yaml \
--connection analytics-prod-read \
--ask-password \
--query "DESCRIBE TABLE analytics.events"
clickhouse-client \
--config /etc/app/clickhouse-client.yaml \
--connection analytics-prod-read \
--ask-password \
--query "SHOW CREATE TABLE analytics.events"身份查询必须返回预期节点或路由、批准版本、只读用户与 analytics database。对象浏览报权限错误时先核对角色,不应临时授予全库管理权限。SHOW CREATE 是核对实际 engine、排序键和 TTL 的证据,仓库迁移文件与服务端定义出现差异时先停止发布。
用参数化查询控制口径和扫描范围
参数化查询用 {name:Type} 占位符与 --param_name 传值,既避免字符串拼接,也让类型在服务端解析前明确。时间范围和 LIMIT 都应显式存在;共享环境不能让交互查询无限扫描。
clickhouse-client \
--config /etc/app/clickhouse-client.yaml \
--connection analytics-prod-read \
--ask-password \
--query_id cli-tenant-summary-001 \
--param_tenant_id 'tenant-a' \
--param_lookback_seconds '86400' \
--query "
SELECT event_type, count() AS events
FROM analytics.events
WHERE tenant_id = {tenant_id:String}
AND occurred_at >= now() - toIntervalSecond({lookback_seconds:UInt32})
GROUP BY event_type
ORDER BY events DESC
LIMIT 100
FORMAT PrettyCompact"退出码为零且结果口径正确只是第一层完成条件。随后用同一个 query_id 检查 system.query_log,确认读取行数、字节、内存和耗时没有越过预算;参数类型错误应在执行前失败,权限负例应返回访问被拒绝。
导入和导出必须保留格式与失败语义
CSV 与 JSONEachRow 适合跨工具交换,Native 适合同版本 ClickHouse 之间保留类型并获得更高吞吐。CSVWithNames 明确首行为列名,输入列顺序与类型必须和 INSERT 列清单匹配。导入前先在临时表验证格式与坏行策略,再进入正式表;用“跳过所有错误”换取成功退出会把数据质量问题静默带入报表。
set -euo pipefail
test -s /srv/import/events.csv
clickhouse-client \
--config /etc/app/clickhouse-client.yaml \
--connection analytics-prod-runtime \
--ask-password \
--query_id cli-import-001 \
--query "
INSERT INTO analytics.events
(tenant_id,event_type,event_id,occurred_at,ingested_at,amount,payload)
FORMAT CSVWithNames" \
< /srv/import/events.csv
export_part=/srv/export/tenant-a-events.jsonl.part
export_final=/srv/export/tenant-a-events.jsonl
checksum_part=/srv/export/tenant-a-events.jsonl.sha256.part
checksum_final=/srv/export/tenant-a-events.jsonl.sha256
rm -f "$export_part" "$checksum_part"
clickhouse-client \
--config /etc/app/clickhouse-client.yaml \
--connection analytics-prod-read \
--ask-password \
--query_id cli-export-001 \
--param_tenant_id 'tenant-a' \
--query "
SELECT tenant_id,event_type,event_id,occurred_at,amount,payload
FROM analytics.events
WHERE tenant_id = {tenant_id:String}
ORDER BY occurred_at,event_id
FORMAT JSONEachRow" \
> "$export_part"
test -s "$export_part"
clickhouse-local --query \
"SELECT count() FROM file('$export_part', JSONEachRow)" \
| grep -Eq '^[1-9][0-9]*$'
mv -f "$export_part" "$export_final"
sha256sum "$export_final" > "$checksum_part"
mv -f "$checksum_part" "$checksum_final"analytics-prod-runtime 对应已经获得 analytics_ingest_role 与 analytics_read_role 的 analytics_runtime,不是 DDL 迁移身份;导入表必须是它获准 INSERT 的 Distributed 表。set -euo pipefail 让导入、导出或校验任一步失败立即停止。导出先写 .part,用 clickhouse-local 按 JSONEachRow 解析并确认至少一行,成功后才原子改名并生成 SHA-256;失败的半文件留在明确的 part 路径供隔离或删除,不能冒充最终制品。
用 query_id 定位并取消失控查询
长查询使用已知 query_id 定位和取消。先查当前状态,再只取消精确 ID;模糊匹配用户或 SQL 文本可能误杀其他业务。SYNC 等待服务端确认取消,随后从 system.processes 复核消失,并在 query log 中保留异常码和资源证据。
clickhouse-client \
--config /etc/app/clickhouse-client.yaml \
--connection analytics-prod-read \
--ask-password \
--param_target_query_id 'cli-long-query-001' \
--query "
SELECT query_id,user,elapsed,read_rows,read_bytes,memory_usage,query
FROM system.processes
WHERE query_id = {target_query_id:String}
FORMAT Vertical"
clickhouse-client \
--config /etc/clickhouse-client/ops.yaml \
--connection analytics-prod-ops \
--ask-password \
--param_target_query_id 'cli-long-query-001' \
--query "KILL QUERY WHERE query_id = {target_query_id:String} SYNC"
clickhouse-client \
--config /etc/clickhouse-client/ops.yaml \
--connection analytics-prod-ops \
--ask-password \
--param_target_query_id 'cli-long-query-001' \
--query "
SELECT count()
FROM system.processes
WHERE query_id = {target_query_id:String}"最后一条应返回零。查询仍存在时检查是否取消了 initial_query_id 对应的分布式子查询、权限是否足够,以及远端 shard 是否仍在执行;不能连续发送宽条件 KILL 掩盖路由问题。
完成 Node.js 应用接入
官方客户端固定为 @clickhouse/client 1.23.1,运行时使用 Node.js 20 或更高版本。项目把连接、仓储和迁移拆开,运行时只获得 SELECT/INSERT,迁移进程使用独立身份。
先固定工程、连接和身份边界
analytics-service/
├── package.json
├── src/
│ ├── clickhouse.js
│ ├── events-repository.js
│ └── server.js
├── migrations/
│ └── 001-events.sql
└── test/
└── smoke.mjs{
"type": "module",
"engines": { "node": ">=20" },
"dependencies": {
"@clickhouse/client": "1.23.1"
},
"scripts": {
"start": "node src/server.js",
"smoke": "node test/smoke.mjs"
}
}连接 URL 不包含用户名和密码。服务管理器分别注入 CLICKHOUSE_URL、CLICKHOUSE_USER、CLICKHOUSE_PASSWORD、CLICKHOUSE_CA_FILE、CLICKHOUSE_EVENTS_TABLE 与 CLICKHOUSE_INSERT_QUORUM;单机开发把表名和 quorum 设为 analytics.events_local 与 1,生产集群设为 analytics.events 与 2。启动时缺少任何必填值都立即失败。
// src/clickhouse.js
import { createClient } from '@clickhouse/client'
import { readFileSync } from 'node:fs'
const required = (name) => {
const value = process.env[name]
if (!value) throw new Error('Missing required setting: ' + name)
return value
}
export const eventsTable = required('CLICKHOUSE_EVENTS_TABLE')
if (!/^[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*$/.test(eventsTable)) {
throw new Error('Invalid CLICKHOUSE_EVENTS_TABLE')
}
export const insertQuorum = Number(required('CLICKHOUSE_INSERT_QUORUM'))
if (![1, 2].includes(insertQuorum)) {
throw new Error('Invalid CLICKHOUSE_INSERT_QUORUM')
}
export const clickhouse = createClient({
url: required('CLICKHOUSE_URL'),
username: required('CLICKHOUSE_USER'),
password: required('CLICKHOUSE_PASSWORD'),
tls: {
ca_cert: readFileSync(required('CLICKHOUSE_CA_FILE')),
},
request_timeout: 10_000,
max_open_connections: 20,
clickhouse_settings: {
max_execution_time: 30,
max_memory_usage: '4000000000',
},
})
export async function closeClickHouse() {
await clickhouse.close()
}用稳定批次号写入同一事件契约
仓储层只允许参数化查询。写入按批次提交,并把上游稳定批次号同时放入 query_id 与 insert_deduplication_token:前者只用于追踪,后者才用于插入去重;只有拥有稳定去重令牌的批次才能在明确的网络中断后重试。
// src/events-repository.js
import { randomUUID } from 'node:crypto'
import { clickhouse, eventsTable, insertQuorum } from './clickhouse.js'
const transient = (error) => {
const code = error?.cause?.code ?? error?.code
const status = error?.status ?? error?.response?.status
return ['ECONNRESET', 'ETIMEDOUT', 'EPIPE'].includes(code)
|| [429, 502, 503, 504].includes(status)
}
const retryTransient = async (operation) => {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await operation()
} catch (error) {
if (!transient(error) || attempt === 2) throw error
await new Promise((resolve) => setTimeout(resolve, 100 * (2 ** attempt)))
}
}
}
const assertEvent = (row) => {
if (!/^[a-zA-Z0-9_-]{1,80}$/.test(row?.tenant_id ?? '')) {
throw new Error('invalid tenant_id')
}
if (!/^[a-zA-Z0-9_-]{1,50}$/.test(row?.event_type ?? '')) {
throw new Error('invalid event_type')
}
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(row?.event_id ?? '')) {
throw new Error('invalid event_id')
}
if (!Number.isFinite(Date.parse(row?.occurred_at))
|| !Number.isFinite(Date.parse(row?.ingested_at))) {
throw new Error('invalid event time')
}
if (!/^(0|[1-9][0-9]{0,9})(\.[0-9]{1,2})?$/.test(String(row?.amount ?? ''))) {
throw new Error('invalid amount')
}
if (typeof row?.payload !== 'string'
|| Buffer.byteLength(row.payload, 'utf8') > 65_536) {
throw new Error('invalid payload')
}
JSON.parse(row.payload)
}
export async function insertEvents(rows, batchId) {
if (rows.length === 0) return
if (rows.length > 10_000) throw new Error('batch too large')
if (!/^[a-zA-Z0-9_-]{1,80}$/.test(batchId)) {
throw new Error('invalid batch id')
}
rows.forEach(assertEvent)
await retryTransient(() => clickhouse.insert({
table: eventsTable,
values: rows,
format: 'JSONEachRow',
query_id: 'events-insert-' + batchId,
clickhouse_settings: {
insert_deduplication_token: batchId,
async_insert: 0,
distributed_foreground_insert: 1,
insert_quorum: insertQuorum,
insert_quorum_timeout: 10_000,
},
}))
}
export async function countEvents(tenantId, from) {
if (!/^[a-zA-Z0-9_-]{1,80}$/.test(tenantId)
|| !Number.isFinite(Date.parse(from))) {
throw new Error('invalid count query')
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 8_000)
try {
const result = await retryTransient(() => clickhouse.query({
query: [
'SELECT count() AS total',
'FROM ' + eventsTable,
'WHERE tenant_id = {tenant_id:String}',
'AND occurred_at >= {from:DateTime64(3)}',
].join(' '),
query_params: { tenant_id: tenantId, from },
format: 'JSONEachRow',
query_id: 'events-count-' + randomUUID(),
abort_signal: controller.signal,
}))
const rows = await result.json()
return Number(rows[0]?.total ?? 0)
} finally {
clearTimeout(timer)
}
}重试器最多追加两次尝试,只处理连接重置、超时和网关 429/502/503/504。SELECT 可以重试;INSERT 必须复用同一个 batchId,不能在每次尝试生成新令牌。语法错误、权限错误、内存上限和数据格式错误直接失败并告警。AbortSignal 默认只中止 HTTP 请求,服务端只读查询要结合 cancel_http_readonly_queries_on_client_close 或显式 KILL QUERY 验证真正取消。
用 HTTP 边界限制输入并完成优雅关闭
HTTP 外壳限制请求体并验证输入,业务层只能调用仓储函数。下面省略路由框架,保留可直接运行的标准库入口和关闭链路。
// src/server.js
import { createServer } from 'node:http'
import { closeClickHouse } from './clickhouse.js'
import { countEvents, insertEvents } from './events-repository.js'
const readJson = async (request) => {
const chunks = []
let bytes = 0
for await (const chunk of request) {
bytes += chunk.length
if (bytes > 1_000_000) throw new Error('request too large')
chunks.push(chunk)
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
}
const server = createServer(async (request, response) => {
try {
if (request.method === 'POST' && request.url === '/events') {
const body = await readJson(request)
await insertEvents(body.rows, body.batchId)
response.writeHead(204).end()
return
}
if (request.method === 'GET' && request.url?.startsWith('/events/count?')) {
const url = new URL(request.url, 'http://127.0.0.1')
const total = await countEvents(
url.searchParams.get('tenant_id') ?? '',
url.searchParams.get('from') ?? '',
)
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({ total }))
return
}
response.writeHead(404).end()
} catch (error) {
console.error({ message: error.message })
response.writeHead(500).end()
}
})
server.listen(Number(process.env.PORT ?? 3000), '127.0.0.1')
const shutdown = () => {
server.close(async () => {
await closeClickHouse()
process.exit(0)
})
setTimeout(() => process.exit(1), 12_000).unref()
}
process.once('SIGTERM', shutdown)
process.once('SIGINT', shutdown)用迁移和正反 smoke 证明去重与最小权限
单节点开发迁移文件包含完整可重放 DDL。生产集群把这一文件替换为后文带 ON CLUSTER 的 ReplicatedReplacingMergeTree 与 Distributed DDL,文件名和迁移版本保持不变。
-- migrations/001-events.sql
CREATE DATABASE IF NOT EXISTS analytics;
CREATE TABLE IF NOT EXISTS analytics.events_local
(
tenant_id LowCardinality(String),
event_type LowCardinality(String),
event_id UUID,
occurred_at DateTime64(3, 'UTC'),
ingested_at DateTime64(3, 'UTC'),
amount Decimal(12, 2),
payload String
)
ENGINE = ReplacingMergeTree(ingested_at)
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (tenant_id, event_type, occurred_at, event_id)
TTL occurred_at + INTERVAL 180 DAY DELETE
SETTINGS non_replicated_deduplication_window = 100;smoke 直接调用仓储层,重复提交同一个批次令牌,验证查询结果与查询日志,再确认危险权限被拒绝。拒绝断言同时检查错误文本包含权限原因,避免把网络失败误判为权限成功。
// test/smoke.mjs
import { randomUUID } from 'node:crypto'
import { clickhouse, closeClickHouse, eventsTable } from '../src/clickhouse.js'
import { countEvents, insertEvents } from '../src/events-repository.js'
const expectDenied = async (query) => {
try {
await clickhouse.command({ query, query_id: 'negative-' + randomUUID() })
throw new Error('dangerous statement unexpectedly succeeded')
} catch (error) {
if (!/ACCESS_DENIED|not enough privileges/i.test(String(error))) throw error
}
}
try {
const tenant = 'smoke-' + randomUUID()
const eventId = randomUUID()
const batchId = 'batch-' + randomUUID()
const now = new Date()
const row = {
tenant_id: tenant,
event_type: 'smoke',
event_id: eventId,
occurred_at: now.toISOString(),
ingested_at: now.toISOString(),
amount: '19.90',
payload: '{"source":"smoke"}',
}
await insertEvents([row], batchId)
await insertEvents([row], batchId)
const from = new Date(now.getTime() - 60_000).toISOString()
const total = await countEvents(tenant, from)
if (total !== 1) throw new Error('deduplication smoke failed: ' + total)
await new Promise((resolve) => setTimeout(resolve, 8_000))
const log = await clickhouse.query({
query: [
'SELECT count() AS total FROM system.query_log',
'WHERE query_id = {query_id:String}',
"AND type = 'QueryFinish'",
].join(' '),
query_params: { query_id: 'events-insert-' + batchId },
format: 'JSONEachRow',
query_id: 'smoke-query-log-' + randomUUID(),
})
const logRows = await log.json()
if (Number(logRows[0]?.total ?? 0) < 1) {
throw new Error('query_id missing from system.query_log')
}
if (process.env.CLICKHOUSE_VERIFY_DENIED === '1') {
await expectDenied('DROP TABLE ' + eventsTable)
await expectDenied(
"BACKUP TABLE " + eventsTable + " TO Disk('backups', 'forbidden')",
)
await expectDenied('SELECT count() FROM analytics.events_local')
}
console.log('clickhouse smoke passed')
} finally {
await closeClickHouse()
}安装、迁移、启动和测试使用同一组固定工件。迁移身份交互输入自己的密码,应用与 smoke 由服务管理器注入运行时 secret,终端不打印环境值。
npm install --ignore-scripts
clickhouse-client \
--config /etc/app/clickhouse-client.yaml \
--connection analytics-prod-migrate \
--ask-password \
--queries-file migrations/001-events.sql
CLICKHOUSE_EVENTS_TABLE=analytics.events_local CLICKHOUSE_INSERT_QUORUM=1 npm start &
SERVICE_PID=$!
trap 'kill "$SERVICE_PID" 2>/dev/null || true' EXIT
CLICKHOUSE_EVENTS_TABLE=analytics.events_local CLICKHOUSE_INSERT_QUORUM=1 npm run smoke服务收到 SIGTERM 后先停止接收新请求,等待在途请求到达超时,再调用 closeClickHouse()。预期输出是 clickhouse smoke passed;测试后管理员按 tenant_id 清理 smoke 数据,并复核 mutation 完成。生产 smoke 把表名改为 analytics.events,并设置 CLICKHOUSE_VERIFY_DENIED=1,在影子租户执行相同断言;本地 Docker 自动创建的开发用户权限较宽,不把它的权限负例当作生产验收。
扩展为两分片两副本与三个 Keeper
资产表为每个节点提供唯一主机名和私网地址。下面的角色分布避免 Keeper 与 ClickHouse Server 共用进程;生产还要让三个 Keeper 跨故障域放置。
先建立独立故障域中的 Keeper 多数派
| 节点 | 角色 | 私网示例 |
|---|---|---|
| keeper-01 | Keeper 1 | 10.20.0.11 |
| keeper-02 | Keeper 2 | 10.20.0.12 |
| keeper-03 | Keeper 3 | 10.20.0.13 |
| ch-s1-r1 | shard 1 replica 1 | 10.20.1.11 |
| ch-s1-r2 | shard 1 replica 2 | 10.20.1.12 |
| ch-s2-r1 | shard 2 replica 1 | 10.20.2.11 |
| ch-s2-r2 | shard 2 replica 2 | 10.20.2.12 |
Keeper 节点只安装与 Server 相同完整版本的 clickhouse-keeper 包。每个 Keeper 的 server_id 唯一,Raft 成员清单完全相同。安全客户端端口 9281 只对四个 Server 开放,Raft TLS 端口 9444 只对三个 Keeper 开放;主机防火墙拒绝办公网与公网。下面展示 keeper-01,另外两台只改变 server_id。
sudo apt-get install -y clickhouse-keeper=26.3.25.2
sudo install -d -o clickhouse -g clickhouse -m 0750 \
/var/lib/clickhouse/coordination/log \
/var/lib/clickhouse/coordination/snapshots<clickhouse>
<keeper_server>
<tcp_port_secure>9281</tcp_port_secure>
<server_id>1</server_id>
<log_storage_path>/var/lib/clickhouse/coordination/log</log_storage_path>
<snapshot_storage_path>/var/lib/clickhouse/coordination/snapshots</snapshot_storage_path>
<coordination_settings>
<operation_timeout_ms>10000</operation_timeout_ms>
<session_timeout_ms>30000</session_timeout_ms>
<raft_logs_level>information</raft_logs_level>
</coordination_settings>
<raft_configuration>
<secure>true</secure>
<server><id>1</id><hostname>keeper-01.internal</hostname><port>9444</port></server>
<server><id>2</id><hostname>keeper-02.internal</hostname><port>9444</port></server>
<server><id>3</id><hostname>keeper-03.internal</hostname><port>9444</port></server>
</raft_configuration>
</keeper_server>
<openSSL>
<server>
<certificateFile>/etc/clickhouse-keeper/tls/server.crt</certificateFile>
<privateKeyFile>/etc/clickhouse-keeper/tls/server.key</privateKeyFile>
<caConfig>/etc/company-ca/clickhouse-ca.pem</caConfig>
<verificationMode>strict</verificationMode>
<loadDefaultCAFile>false</loadDefaultCAFile>
</server>
<client>
<certificateFile>/etc/clickhouse-keeper/tls/server.crt</certificateFile>
<privateKeyFile>/etc/clickhouse-keeper/tls/server.key</privateKeyFile>
<caConfig>/etc/company-ca/clickhouse-ca.pem</caConfig>
<verificationMode>strict</verificationMode>
<loadDefaultCAFile>false</loadDefaultCAFile>
</client>
</openSSL>
</clickhouse>配置管理把每台已替换主机名、server_id 和证书路径的 XML 写到暂存区,再安装为 standalone Keeper 的实际配置。启动前校验 XML、证书 SAN、私钥权限与配置文件权限;三台依次启动,不能并发启动后只看进程状态。
sudo install -d -o root -g clickhouse -m 0750 \
/etc/clickhouse-keeper/tls
sudo install -o root -g clickhouse -m 0640 \
/srv/config-rendered/keeper_config.xml \
/etc/clickhouse-keeper/keeper_config.xml
sudo install -o root -g clickhouse -m 0640 \
/srv/pki/keeper.crt /etc/clickhouse-keeper/tls/server.crt
sudo install -o root -g clickhouse -m 0640 \
/srv/pki/keeper.key /etc/clickhouse-keeper/tls/server.key
xmllint --noout /etc/clickhouse-keeper/keeper_config.xml
openssl x509 -in /etc/clickhouse-keeper/tls/server.crt \
-noout -checkhost keeper-01.internal
sudo systemctl enable --now clickhouse-keeper
sudo systemctl status clickhouse-keeper --no-pager
set -o pipefail
printf 'mntr\n' \
| openssl s_client -quiet \
-connect keeper-01.internal:9281 \
-servername keeper-01.internal \
-verify_hostname keeper-01.internal \
-verify_return_error \
-CAfile /etc/company-ca/clickhouse-ca.pem \
-cert /etc/clickhouse-keeper/tls/server.crt \
-key /etc/clickhouse-keeper/tls/server.key \
| grep -E 'zk_server_state|zk_synced_followers|zk_outstanding_requests'三台 mntr 输出必须恰好包含一个 zk_server_state leader 与两个 zk_server_state follower,leader 的 zk_synced_followers 为 2,zk_outstanding_requests 能回到 0。证书校验失败、成员状态不唯一或 follower 未同步时不启动 ClickHouse Server。
再让 Server 只使用受信协调与加密端口
四个 Server 使用同一份 Keeper 和 remote_servers 配置。internal_replication 让 Distributed 表每个分片只选择一个副本接收写入,再由 ReplicatedMergeTree 复制。Native 与 HTTP 对应用只开放 TLS 入口,interserver HTTPS 证书与私钥由配置管理系统分发。基础配置中的明文 http_port、tcp_port 和 interserver_http_port 必须移除,配置测试确认只留下 8443、9440 与 9010。
<clickhouse>
<listen_host>10.20.1.11</listen_host>
<http_port remove="1"/>
<tcp_port remove="1"/>
<interserver_http_port remove="1"/>
<https_port>8443</https_port>
<tcp_port_secure>9440</tcp_port_secure>
<interserver_https_port>9010</interserver_https_port>
<openSSL>
<server>
<certificateFile>/etc/clickhouse-server/tls/server.crt</certificateFile>
<privateKeyFile>/etc/clickhouse-server/tls/server.key</privateKeyFile>
<caConfig>/etc/company-ca/clickhouse-ca.pem</caConfig>
<verificationMode>strict</verificationMode>
<loadDefaultCAFile>false</loadDefaultCAFile>
</server>
<client>
<certificateFile>/etc/clickhouse-server/tls/server.crt</certificateFile>
<privateKeyFile>/etc/clickhouse-server/tls/server.key</privateKeyFile>
<caConfig>/etc/company-ca/clickhouse-ca.pem</caConfig>
<verificationMode>strict</verificationMode>
<loadDefaultCAFile>false</loadDefaultCAFile>
</client>
</openSSL>
<interserver_http_credentials>
<user>interserver</user>
<password from_env="CH_INTERSERVER_PASSWORD"/>
</interserver_http_credentials>
</clickhouse>每台节点把自己的私网地址写入 listen_host。配置管理把节点专属 XML 和证书写到暂存区,完成权限与 SAN 校验后再安装。systemd drop-in 通过 EnvironmentFile=/run/secrets/clickhouse-server.env 注入 interserver secret,文件由密钥代理生成、属主为 root、权限为 0600;日志与配置转储不能输出环境文件内容。
sudo install -d -o root -g clickhouse -m 0750 \
/etc/clickhouse-server/tls \
/etc/systemd/system/clickhouse-server.service.d
sudo install -o root -g clickhouse -m 0640 \
/srv/config-rendered/tls.xml \
/etc/clickhouse-server/config.d/tls.xml
sudo install -o root -g clickhouse -m 0640 \
/srv/pki/server.crt /etc/clickhouse-server/tls/server.crt
sudo install -o root -g clickhouse -m 0640 \
/srv/pki/server.key /etc/clickhouse-server/tls/server.key
sudo install -o root -g root -m 0644 \
/srv/config-rendered/clickhouse-secrets.conf \
/etc/systemd/system/clickhouse-server.service.d/secrets.conf
xmllint --noout /etc/clickhouse-server/config.d/tls.xml
openssl x509 -in /etc/clickhouse-server/tls/server.crt \
-noout -checkhost ch-s1-r1.internal
sudo -u clickhouse clickhouse extract-from-config \
--config-file=/etc/clickhouse-server/config.xml \
--key=tcp_port_secure \
| grep -Fx '9440'
sudo systemctl daemon-reload
sudo systemctl restart clickhouse-server
for PORT in 8443 9440 9010; do
ss -lnt | awk '{print $4}' | grep -Eq ":$PORT$"
done
for PORT in 8123 9000 9009; do
if ss -lnt | awk '{print $4}' | grep -Eq ":$PORT$"; then exit 1; fi
doneclickhouse-secrets.conf 只包含 [Service] 和 EnvironmentFile=/run/secrets/clickhouse-server.env,不包含秘密本身。示例命令以 ch-s1-r1 为例,其他节点必须替换 SAN 与私网监听地址;安全端口未全部监听或明文端口仍存在时不加入负载均衡。
<clickhouse>
<zookeeper>
<node><host>keeper-01.internal</host><port>9281</port><secure>1</secure></node>
<node><host>keeper-02.internal</host><port>9281</port><secure>1</secure></node>
<node><host>keeper-03.internal</host><port>9281</port><secure>1</secure></node>
</zookeeper>
<remote_servers>
<analytics_cluster>
<secret from_env="CH_CLUSTER_SECRET"/>
<shard>
<internal_replication>true</internal_replication>
<replica><host>ch-s1-r1.internal</host><port>9440</port><secure>1</secure></replica>
<replica><host>ch-s1-r2.internal</host><port>9440</port><secure>1</secure></replica>
</shard>
<shard>
<internal_replication>true</internal_replication>
<replica><host>ch-s2-r1.internal</host><port>9440</port><secure>1</secure></replica>
<replica><host>ch-s2-r2.internal</host><port>9440</port><secure>1</secure></replica>
</shard>
</analytics_cluster>
</remote_servers>
<distributed_ddl>
<path>/clickhouse/task_queue/ddl</path>
</distributed_ddl>
</clickhouse>每台 Server 的宏文件只改变 shard 与 replica。例如 ch-s1-r1 使用 01 与 ch-s1-r1,ch-s2-r2 使用 02 与 ch-s2-r2。
<clickhouse>
<macros>
<cluster>analytics_cluster</cluster>
<shard>01</shard>
<replica>ch-s1-r1</replica>
</macros>
</clickhouse>用同一事件契约建立本地表和分布式入口
配置测试通过并依次启动 Keeper 后,先用 clickhouse-keeper-client 验证三成员能够选出 leader,再启动四个 Server。随后通过 ON CLUSTER 创建本地复制表和 Distributed 入口。
CREATE DATABASE IF NOT EXISTS analytics ON CLUSTER analytics_cluster;
CREATE TABLE analytics.events_local ON CLUSTER analytics_cluster
(
tenant_id LowCardinality(String),
event_type LowCardinality(String),
event_id UUID,
occurred_at DateTime64(3, 'UTC'),
ingested_at DateTime64(3, 'UTC'),
amount Decimal(12, 2),
payload String
)
ENGINE = ReplicatedReplacingMergeTree(
'/clickhouse/tables/{shard}/analytics/events_local',
'{replica}',
ingested_at
)
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (tenant_id, event_type, occurred_at, event_id)
TTL occurred_at + INTERVAL 180 DAY DELETE;
CREATE TABLE analytics.events ON CLUSTER analytics_cluster
AS analytics.events_local
ENGINE = Distributed(
analytics_cluster,
analytics,
events_local,
cityHash64(tenant_id)
);写入入口使用 Distributed 表并开启前台投递,使请求只有在目标分片确认后返回。应用的实际写请求设置 insert_quorum = 2 与十秒 insert_quorum_timeout,在两副本没有形成写入确认时失败,并依靠同一个稳定批次令牌重试。
用副本与多数派故障证明完成语义
集群验收先查 system.clusters 确认两分片四副本,再分别查四台的 system.replicas。测试数据按租户散列后两个分片都应有数据,同分片两副本的业务聚合一致,is_readonly = 0、is_session_expired = 0、absolute_delay 接近零,queue_size 能归零。停止任意一个副本后,落到该分片的新写入必须在十秒内以 quorum 不足失败,不能返回假成功;读取继续从健康副本服务。恢复节点后复制队列必须追平。停止两个 Keeper 后协调写入同样应失败,恢复多数派后副本重新可写。
两副本配 quorum 2 选择了副本确认优先,因此逐副本维护期间冻结写入,读取保持在线。若业务要求维护时持续写入,应把每分片扩为三副本并采用 quorum 2;临时把两副本集群降为 quorum 1 会改变已承诺的数据确认语义,只能经过业务风险审批,不能由脚本自动切换。
配置备份并完成隔离恢复
每台 Server 创建仅 clickhouse 可访问的备份目录。若备份要离开节点,使用受版本控制的对象存储备份策略或备份代理将产物复制到独立故障域,并由密钥系统提供对象存储凭据。
冻结写入并生成两个可校验的分片备份
sudo install -d -o clickhouse -g clickhouse -m 0750 \
/var/lib/clickhouse/backups<clickhouse>
<storage_configuration>
<disks>
<backups>
<type>local</type>
<path>/var/lib/clickhouse/backups/</path>
</backups>
</disks>
</storage_configuration>
<backups>
<allowed_disk>backups</allowed_disk>
<allowed_path>/var/lib/clickhouse/backups/</allowed_path>
</backups>
</clickhouse>每个分片选定一个健康副本,冻结写入并等待复制队列归零后使用带 shard 的唯一备份名。先在 ch-s1-r1 执行第一条 BACKUP,再在 ch-s2-r1 执行第二条;生产调度器记录集群名、分片、副本、DDL 版本、备份名、开始时间、结束状态和对象存储版本。
BACKUP TABLE analytics.events_local
TO Disk('backups', 'analytics/events-s01-full');
BACKUP TABLE analytics.events_local
TO Disk('backups', 'analytics/events-s02-full');
SELECT id, name, status, error, start_time, end_time
FROM system.backups
ORDER BY start_time DESC
LIMIT 10;每台选定副本对自己的备份目录生成逐文件 SHA-256 清单,再通过专用备份身份复制到独立故障域。目标端重新执行 sha256sum --check,任何缺失或哈希不一致都让任务失败。
set -euo pipefail
cd /var/lib/clickhouse/backups
sudo -u clickhouse find analytics/events-s01-full -type f -print0 \
| sort -z \
| sudo -u clickhouse xargs -0 sha256sum \
> analytics/events-s01-full.sha256
rsync -aR --checksum \
analytics/events-s01-full analytics/events-s01-full.sha256 \
backup-gateway.internal:/vault/clickhouse/
ssh backup-gateway.internal \
"cd /vault/clickhouse && sha256sum --check analytics/events-s01-full.sha256"
ssh backup-gateway.internal \
"touch /vault/clickhouse/analytics/events-s01-full.complete"rsync -R 保留清单中的 analytics/events-s01-full 相对路径;远端校验成功后才创建完成标记。ch-s2-r1 把路径替换为 events-s02-full 后执行同一复制流程。
在隔离协调空间恢复两个分片
恢复侧准备 restore-s01 与 restore-s02 两个隔离实例,各自连接独立于生产的 Keeper。restore-s01 使用 shard=01、replica=restore-s01,restore-s02 使用 shard=02、replica=restore-s02;二者分别取回并校验自己的备份,只恢复一张复制表。这样原 DDL 中的 Keeper 路径和宏不会在同一实例或同一协调空间冲突。
-- 在 restore-s01 执行
CREATE DATABASE IF NOT EXISTS analytics_restore;
RESTORE TABLE analytics.events_local
AS analytics_restore.events_local
FROM Disk('backups', 'analytics/events-s01-full');
-- 在 restore-s02 执行
CREATE DATABASE IF NOT EXISTS analytics_restore;
RESTORE TABLE analytics.events_local
AS analytics_restore.events_local
FROM Disk('backups', 'analytics/events-s02-full');汇总并证明跨分片业务口径
第三个隔离汇总实例不连接生产 Keeper,创建非复制 staging 表。两个恢复实例通过各自的 TLS 命名连接导出 Native 流,汇总实例以受限导入身份接收;三份连接配置均在仓库外,密码由 secret 注入。
CREATE DATABASE IF NOT EXISTS analytics_restore;
CREATE TABLE analytics_restore.events
(
tenant_id LowCardinality(String),
event_type LowCardinality(String),
event_id UUID,
occurred_at DateTime64(3, 'UTC'),
ingested_at DateTime64(3, 'UTC'),
amount Decimal(12, 2),
payload String
)
ENGINE = ReplacingMergeTree(ingested_at)
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (tenant_id, event_type, occurred_at, event_id);set -euo pipefail
for SOURCE in restore-s01 restore-s02; do
clickhouse-client \
--config /etc/clickhouse-client/restore.yaml \
--connection "$SOURCE" \
--query "SELECT * FROM analytics_restore.events_local FORMAT Native" \
| clickhouse-client \
--config /etc/clickhouse-client/restore.yaml \
--connection restore-aggregate \
--query "INSERT INTO analytics_restore.events FORMAT Native"
doneSELECT count(), min(occurred_at), max(occurred_at)
FROM analytics_restore.events;
SELECT tenant_id, count() AS events
FROM analytics_restore.events
GROUP BY tenant_id
ORDER BY events DESC
LIMIT 20;恢复验收先分别比较两个 shard 的 schema 哈希、分区清单和行数,再比较组合视图与源 Distributed 表的总行数、租户级关键聚合和时间边界。只读应用账号运行真实查询,并验证该账号执行 BACKUP、DROP 和读取未授权 database 都失败。只有两份 system.backups 都显示 RESTORED、跨分片业务校验一致、权限负例成立且恢复耗时满足 RTO,备份任务才算通过。Keeper 快照单独保护协调元数据,不能代替数据备份。
按副本滚动升级
升级先在预生产回放真实批量写入、参数化查询、物化视图、备份与恢复。两副本 quorum 2 基线在维护窗口冻结写入,生产每次只升级一个副本,同一分片始终保留一个健康旧版本副本提供读取;Keeper 单独升级且始终保持三个成员中的多数派在线。
先逐副本升级 Server 并观察完整窗口
set -euo pipefail
TARGET_VERSION="$(sudo cat /etc/clickhouse-server/approved-target-version)"
test -n "$TARGET_VERSION"
CURRENT_VERSION="$(dpkg-query -W clickhouse-server | cut -f2)"
test "$TARGET_VERSION" != "$CURRENT_VERSION"
for PACKAGE in clickhouse-common-static clickhouse-server clickhouse-client; do
apt-cache madison "$PACKAGE" \
| awk '{print $3}' \
| grep -Fx "$TARGET_VERSION" >/dev/null
done
clickhouse-client \
--config /etc/clickhouse-client/ops.yaml \
--connection local-node-maintenance \
--query \
"SELECT database, table, queue_size, absolute_delay, is_readonly FROM system.replicas"
sudo systemctl stop clickhouse-server
sudo apt-get install -y \
clickhouse-common-static="$TARGET_VERSION" \
clickhouse-server="$TARGET_VERSION" \
clickhouse-client="$TARGET_VERSION"
sudo -u clickhouse clickhouse extract-from-config \
--config-file=/etc/clickhouse-server/config.xml \
--key=tcp_port_secure \
| grep -Fx '9440'
sudo systemctl start clickhouse-server
sudo systemctl status clickhouse-server --no-pager
for PACKAGE in clickhouse-common-static clickhouse-server clickhouse-client; do
INSTALLED_VERSION="$(dpkg-query -W "$PACKAGE" | cut -f2)"
test "$INSTALLED_VERSION" = "$TARGET_VERSION"
done
clickhouse-client \
--config /etc/clickhouse-client/ops.yaml \
--connection local-node-maintenance \
--query "SELECT version()" \
| grep -Fx "$TARGET_VERSION"/etc/clickhouse-server/approved-target-version 由变更管理写入经过预生产验证的完整包版本,不能填 latest、通配符或浮动 minor。APT 找不到完全匹配版本时命令立即停止,不能悄悄安装仓库默认版本。
每台升级后的放行门槛是包版本与 version() 都等于目标版本、身份查询成功、核心只读 smoke 通过、system.replicas 可读、复制队列归零、错误率和 p95 未越界。完成同一分片两个副本后恢复写入,执行 quorum 2 写入 smoke;完成一个分片并观察完整业务窗口,再继续下一个分片。
若新版本尚未接受写入,可以停止该节点并恢复旧软件包;一旦新版本接受了写入、执行了 mutation 或改变了元数据,就不做原地降级。此时停止升级、隔离新流量,保留数据与日志,通过已验证备份恢复完整旧版本集群,再按事件记录重放可证明安全的增量。
再逐 follower 升级 Keeper 并保持多数派
Keeper 每次只升级一个 follower。目标恰好是 leader 时,先在健康 follower 上执行 rqld 请求领导权转移,并用 mntr 确认目标已变为 follower;随后安装精确包、启动并等待它重新同步。
set -euo pipefail
TARGET_VERSION="$(sudo cat /etc/clickhouse-keeper/approved-target-version)"
CURRENT_VERSION="$(dpkg-query -W clickhouse-keeper | cut -f2)"
test -n "$TARGET_VERSION"
test "$TARGET_VERSION" != "$CURRENT_VERSION"
apt-cache madison clickhouse-keeper \
| awk '{print $3}' \
| grep -Fx "$TARGET_VERSION" >/dev/null
printf 'rqld\n' \
| openssl s_client -quiet \
-connect keeper-02.internal:9281 \
-servername keeper-02.internal \
-verify_hostname keeper-02.internal \
-verify_return_error \
-CAfile /etc/company-ca/clickhouse-ca.pem \
-cert /etc/clickhouse-keeper/tls/server.crt \
-key /etc/clickhouse-keeper/tls/server.key \
| grep -F 'Sent leadership request'
sudo systemctl stop clickhouse-keeper
sudo apt-get install -y clickhouse-keeper="$TARGET_VERSION"
xmllint --noout /etc/clickhouse-keeper/keeper_config.xml
sudo systemctl start clickhouse-keeper
test "$(dpkg-query -W clickhouse-keeper | cut -f2)" = "$TARGET_VERSION"
printf 'mntr\n' \
| openssl s_client -quiet \
-connect keeper-01.internal:9281 \
-servername keeper-01.internal \
-verify_hostname keeper-01.internal \
-verify_return_error \
-CAfile /etc/company-ca/clickhouse-ca.pem \
-cert /etc/clickhouse-keeper/tls/server.crt \
-key /etc/clickhouse-keeper/tls/server.key \
| grep -E 'zk_server_state|zk_synced_followers|zk_outstanding_requests'local-node-maintenance 是节点专属 TLS 命名连接,固定该节点私网主机、9440、CA 与受控运维用户,密码由 secret 注入。示例在 keeper-01 本机升级该节点,并向 keeper-02 请求领导权。每一轮都要重新确认一个 leader、两个 follower、leader 的 zk_synced_followers = 2 与 Server 复制队列归零;任一门槛失败就停止,不继续下一个 Keeper。
四、问题处理
ClickHouse 的故障通常沿着四条链出现:客户端没有建立正确身份,写入产生 part 的速度超过合并能力,查询中间状态越过资源预算,或者副本与 Keeper 失去协调。定位时先保存 query_id、节点、database、表名和时间窗,再查询对应系统表;只看进程是否存活,很难判断数据是否仍在正确流动。
端口可达,但 TLS 或认证失败
先在客户端所在机器检查 DNS、TCP 和证书主机名:
getent ahosts ch-router.internal.example
nc -vz ch-router.internal.example 9440
openssl s_client \
-connect ch-router.internal.example:9440 \
-servername ch-router.internal.example \
-CAfile /etc/company-ca/clickhouse-ca.pem \
-verify_hostname ch-router.internal.example \
-verify_return_error </dev/nullDNS 应返回预期私网地址,TLS 应显示 Verify return code: 0。证书正常后,再用目标用户执行身份查询:
clickhouse-client \
--host ch-router.internal.example --port 9440 --secure \
--user analytics_read --ask-password \
--query "SELECT version(), currentUser(), currentDatabase() FORMAT Vertical"AUTHENTICATION_FAILED 多数来自用户、密码或允许来源不匹配,ACCESS_DENIED 则说明身份已经建立但缺权限。修复 CA、SAN、用户来源或 secret 版本后,重新开启证书校验做正向连接,并证明只读用户仍不能 INSERT、DROP 或读取未授权 database。不要用空密码、默认用户或关闭主机名校验长期止血。
Too many parts 或 merge 队列持续增长
先看每个分区的 active part 数和正在执行的 merge:
SELECT database, table, partition,
count() AS active_parts,
sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS disk_size
FROM system.parts
WHERE active
GROUP BY database, table, partition
ORDER BY active_parts DESC
LIMIT 30;
SELECT database, table, elapsed, progress, num_parts,
formatReadableSize(total_size_bytes_compressed) AS merge_size
FROM system.merges
ORDER BY elapsed DESC;part 生成速度持续高于合并速度,通常意味着生产者逐行或极小批次写入、分区键基数过高、mutation 抢占后台资源,或者磁盘吞吐已经饱和。先暂停异常小批生产者和非必要 mutation,把上游合并成更大的批次;随后检查磁盘延迟、后台池和分区设计。直接调高 part 限制只会延后失败。
确认恢复时,要看到新 part 生成速率低于合并速率,最热分区的 active part 持续回落,写入错误停止,查询延迟没有因后台 merge 进一步恶化。
查询返回很少,却扫描很多或内存超限
LIMIT 100 只限制结果,不限制读取。拿到 query_id 后同时查看当前查询和历史日志:
SELECT query_id, user, elapsed, read_rows, read_bytes,
memory_usage, peak_threads_usage, query
FROM system.processes
WHERE query_id = 'slow-query-T0';
SYSTEM FLUSH LOGS;
SELECT type, query_duration_ms, read_rows, read_bytes,
result_rows, memory_usage,
exception_code, exception
FROM system.query_log
WHERE query_id = 'slow-query-T0'
ORDER BY event_time_microseconds;先用 EXPLAIN indexes = 1 确认分区与排序键裁剪,再看高基数 GROUP BY、JOIN 右表、全局排序、窗口函数和聚合状态。短期可以取消精确 query_id、限制入口并降低并发;长期要重写过滤条件、调整排序键或预聚合,并给用户 profile 设置查询时长、读取量和内存预算。只增加 max_memory_usage 会把一次失败变成节点级 OOM。
修复后使用同样的数据分布和参数比较 read_rows、SelectedMarks、峰值内存与 p95,而不是只比较终端显示的返回行数。
磁盘接近满后节点进入只读
先区分 data part、日志、临时 spill、备份和复制队列各占多少空间:
df -hT /var/lib/clickhouse /var/log/clickhouse-server
df -i /var/lib/clickhouse /var/log/clickhouse-server
sudo du -xhd1 /var/lib/clickhouse | sort -h
sudo du -xhd1 /var/log/clickhouse-server | sort -hSELECT database, table,
formatReadableSize(sum(bytes_on_disk)) AS disk_size,
count() AS active_parts
FROM system.parts
WHERE active
GROUP BY database, table
ORDER BY sum(bytes_on_disk) DESC;
SELECT name, path, free_space, total_space, keep_free_space
FROM system.disks;先暂停大查询、mutation 和非关键写入,为 merge、复制和临时文件保留空间。只有保留策略明确允许删除的日志、过期备份或测试数据才可以清理;运行中的 part 目录不能手工删除。长期通过 TTL、分区级 drop、冷存储或扩容控制增长,并让备份目标与数据盘隔离。
节点退出只读后,必须完成一次写入、查询和副本追平验证。磁盘刚低于告警线但 merge 队列仍在增长,故障还没有结束。
Keeper 失去多数派
Keeper 管理副本协调元数据。三个 Keeper 中只有一个可用时,拒绝需要协调的新写入是安全表现,不应在两侧分别建立新协调状态。
for host in keeper-01 keeper-02 keeper-03; do
printf '%s ' "$host"
printf 'mntr' | nc -w 2 "$host" 9181 | head
done输出应能识别一个 leader 和可用 follower;超时、standalone 或成员列表不一致时,继续检查 DNS、时钟、Raft 端口、磁盘和日志。优先恢复原成员之间的网络与服务,不删除 Keeper 数据目录,也不在不同分区各自重建集群。
多数派恢复后,检查 system.replicas 中 is_session_expired=0、is_readonly=0,复制队列可以推进,并完成 quorum 写入。只看到 Keeper 进程重新启动,不能证明 ClickHouse 副本已经重新获得协调会话。
单个副本延迟或长期只读
SELECT database, table, replica_name,
is_readonly, is_session_expired,
queue_size, inserts_in_queue, merges_in_queue,
absolute_delay, lost_part_count, last_queue_update_exception
FROM system.replicas
ORDER BY absolute_delay DESC, queue_size DESC;
SELECT database, table, type, create_time,
num_tries, last_exception, source_replica
FROM system.replication_queue
ORDER BY create_time;网络或 Keeper 会话问题会让队列整体停止;磁盘不足和校验失败常集中在某类任务;单个大 part 下载慢则要结合网络和磁盘吞吐判断。先把读写路由移出异常副本,保留同分片健康副本。修复资源或网络后让队列自然追平;只有本地数据确实不可恢复、同分片健康副本完整并且备份可用时,才按官方步骤重建该副本。
恢复后比较同分片副本的关键聚合与 part 清单,确认队列归零、延迟回到基线且节点重新可写。SYSTEM SYNC REPLICA 超时不能通过清空队列来“修好”。
Distributed 队列滞留或某个分片不可达
使用异步 Distributed 写入时,待发送文件留在发起节点。先确认错误目标和积压规模:
SELECT database, table, data_path, is_blocked,
error_count, last_exception, files_to_insert, bytes_to_insert
FROM system.distribution_queue
ORDER BY bytes_to_insert DESC;检查远端分片的 DNS、端口、认证、表结构和磁盘。不要手工删除队列目录;那会把“延迟写入”变成永久丢失。短期暂停继续流向故障分片的写入,修复目标后观察队列自行清空。需要严格失败反馈的业务应采用同步插入或在应用端等待并记录结果,不能把异步队列当成事务提交。
ReplacingMergeTree 仍然出现重复业务行
ReplacingMergeTree 只在后台 merge 时按排序键选择版本,同一业务键在合并前出现多行是正常机制。先比较物理行数和确定性逻辑结果:
SELECT event_id, count() AS physical_rows,
argMax(payload, ingested_at) AS latest_payload,
max(ingested_at) AS latest_version
FROM analytics.events_local
GROUP BY event_id
HAVING physical_rows > 1
ORDER BY physical_rows DESC
LIMIT 50;如果 argMax 能得到唯一最新值,查询口径需要显式采用版本聚合;FINAL 只适合范围受控的校验。若同一业务事件被写成不同 event_id 或改变排序键,则 MergeTree 无法识别它们是同一对象,必须修复上游幂等键和重试协议。
mutation 长期不结束
SELECT database, table, mutation_id, command,
create_time, parts_to_do, is_done,
latest_fail_time, latest_fail_reason
FROM system.mutations
WHERE NOT is_done
ORDER BY create_time;大范围 UPDATE/DELETE 会重写 part,并和 merge、复制及查询争夺资源。先停止继续提交同类 mutation,检查失败 part、磁盘和副本状态。能够用新增版本、TTL、分区替换或离线重算表达的变化,优先改模型;不要连续执行 KILL MUTATION 后重提同一重写任务。
完成后除了 is_done=1,还要核对业务聚合、磁盘增长、复制队列和查询延迟。mutation 结束但数据口径错误,仍然需要从备份或版本化数据重建。
备份显示成功,但隔离恢复失败
先记录 ClickHouse 版本、备份 ID、目标 disk、system.backups 状态和第一条错误。对象命名冲突说明目标并不干净,缺少文件或校验失败说明备份介质不可用,跨版本恢复失败则要检查兼容性。
恢复目标使用新的 database 和隔离集群,不覆盖生产表。恢复后比较 schema、分区、行数、时间边界和租户级关键聚合,再用只读应用身份执行真实查询,并证明它不能执行 BACKUP、DROP 或读取未授权数据。分片集群需要分别恢复每个 shard;Keeper 快照保护协调元数据,不能替代数据 part 备份。
如果最近备份不能恢复,立即保护更早的可用恢复点并重新计算实际 RPO。继续生成会覆盖最后好副本的新备份,不是修复。
滚动升级后错误率或复制队列上升
先停止继续升级,保留当前已经升级和尚未升级的节点集合。比较 version()、服务端日志、客户端错误、system.replicas、核心查询计划和写入格式。单个副本异常时先移出路由并修复,不要让问题越过一个分片的多数副本。
新版本节点一旦写入新格式或改变元数据,不假设原地降级安全。需要回退时,隔离新集群并从升级前已验证备份恢复旧版本环境,再处理升级窗口内的增量数据。确认所有节点版本一致、复制队列归零、真实查询回放与写入 smoke 通过,而且新版本备份能够恢复后,滚动升级才算完成。
