ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

ClickHouse 可观测数据建模实战:指标、日志、链路、告警四类数据的 MergeTree 表设计与集群化改造

2026/10/3 20:05:19 拓冰建站 浏览量
ClickHouse 可观测数据建模实战:指标、日志、链路、告警四类数据的 MergeTree 表设计与集群化改造 做过可观测平台监控/日志/APM的工程师大概率会遇到同一个问题一套 ClickHouse 集群要同时扛下监控指标、日志、调用链路、告警四类数据。这四类数据在写入量、保留周期、查询模式上差异极大——指标要分钟级多粒度聚合日志要解析出动态字段链路明细只留 7 天但聚合要留一年告警还要把原始报文完整存下来留证。如果建表时一张 MergeTree 走天下后面一定会撞上分区数失控、主键无法剪枝、TTL 不生效、磁盘冷热不分等一系列问题。这篇文章把我们在生产上迭代了多轮的 ClickHouse 建模方案完整整理出来从 dwd/dws 分层、分区与 TTL 取舍、ORDER BY 主键设计、tags 字段建模到从单机迁移到 ReplicatedMergeTree Distributed 集群的改造模板最后附一份实打实的踩坑清单。文中所有建表语句都来自生产环境已做脱敏改名可以直接参考。一、四类数据的特点与分层设计先把四类数据的脾气摸清楚建表决策才有依据数据类型单日写入量级典型保留期典型查询模式监控指标数值/文本千万~亿级行明细 30~90 天汇总 1 年按对象指标名查时间序列多粒度聚合日志解析属性亿级行100 天按对象/任务查解析出的字段值APM 链路明细亿级行7 天明细聚合长期按时间窗扫最近数据按服务聚合告警三方原始报文十万级90 天低频检索、留证我们按数仓习惯把表分了四层映射到可观测场景非常自然dwd 明细层采集上来的原始/半加工数据按天分区、短 TTL、大写入量dws 汇总层按 5 分钟/1 小时/1 天预聚合的统计数据按月分区、长保留dim 维度层从配置管理库同步的资产维度表CMDB量小但被高频 joinstat 统计层面向页面指标纳管率、命中率、解析率的专用统计表。分区粒度跟着层走明细按天toYYYYMMDD汇总按月toYYYYMM。这是全文最重要的一个取舍——分区不是越细越好汇总层数据量小按天分区会让分区总数膨胀merge 压力反而上来。二、建表的五个核心决策2.1 分区粒度明细按天汇总按月明细层按天分区配合 TTL 删除整天的分区几乎是免费的直接 drop 分区目录汇总层一天往往只有几万行按天分区纯属浪费按月分区足够-- 明细层按天分区PARTITIONBYtoYYYYMMDD(date)-- 汇总层按月分区PARTITIONBYtoYYYYMM(date)2.2 TTL 差异化保留与冷热分离不同表的保留期差得很远链路明细 7 天、指标明细 30~90 天、日志解析属性 100 天、汇总表 1 年以上。TTL 直接挂在建表语句里靠 merge 异步清理TTLdatetoIntervalDay(90)如果机房有 SSD HDD 混部更进一步的做法是给存储策略配两个 volumeTTL 先搬家再删除——30 天内数据留 SSD30~90 天搬到 HDD90 天后删除TTLdatetoIntervalDay(30)TOVOLUMEhdd,datetoIntervalDay(90)SETTINGS storage_policyssd_hdd_policy我们早期是整库共用一个带冷热盘的存储策略、TTL 只做删除后来才演进到分卷 TTL冷数据的查询确实变慢了但存储成本降了一半以上对低频查询是划算的。2.3 ORDER BY 主键维度在前时间在后MergeTree 的ORDER BY决定主键索引稀疏索引它是为查询模式服务的。可观测数据最常见的查询是某个对象 某个指标 一段时间所以明细表的标准写法是维度列在前、时间列殿后ORDERBY(component,item_name,object_id)而分区已经承担了时间粗剪枝月/天主键再放时间反而挤掉维度列的剪枝能力。反过来如果查询永远只扫最近 N 分钟全量数据比如 APM 大盘ORDER BY time这种时间优先的排序键才是对的——按服务查长周期的需求交给聚合表。没有正确答案只有和查询模式匹配的答案这一点在第 4 节的实例里会反复看到。2.4 tags 字段建模Map 还是 Array(LowCardinality)标签/扩展属性怎么存我们在两种方案之间摇摆过很久Array(LowCardinality(String))只存标签值集合适合枚举型标签可用区、环境类型LowCardinality字典编码压缩率极高Map(String, String)键值都动态适合日志解析出来的字段——每条日志解析出的字段名都不一样不可能提前建列。结论很清晰枚举标签用 Array(LowCardinality)动态键值用 Map。Map 列在 21.x 之后支持子列读取tags[dc]配合 bloom_filter 索引可以做点查这是把无 schema 日志字段塞进列存的关键武器。2.5 指标值类型拆表数值表 文本表监控指标的 value 有的是数字CPU 使用率有的是字符串集群健康状态、版本号。Float64和String混在一列要么丢精度要么浪费空间我们直接按值类型拆成两张同构表dwd_metric_num/dwd_metric_text写入端按指标类型路由。代价是查询侧要 UNION但指标元数据里本来就有类型信息路由是确定性的这个代价值得付。三、四类数据的建表实例以下 DDL 均来自生产环境表名已脱敏结构原样保留。3.1 监控指标明细 多粒度汇总通用指标明细表数值版文本版同构CREATETABLEdwd_metric_num(componentStringCOMMENT组件名,item_nameStringCOMMENT指标名,dateDateTimeCOMMENT采集时间,tagsArray(LowCardinality(String))COMMENT标签值集合,object_idStringCOMMENT监控对象ID,valueFloat64COMMENT指标值)ENGINEMergeTreePARTITIONBYtoYYYYMM(date)ORDERBY(component,item_name)SETTINGS index_granularity8192;注意这张表的ORDER BY只有(component, item_name)是当时的一个权衡查询几乎都带组件和指标名时间粗剪枝交给月分区。后来短时间窗查询多了我们新增的表把object_id也加进了排序键——同一张表服务不了所有查询模式按查询模式拆表比加索引更 ClickHouse。K8s 场景的指标带多级维度集群→命名空间→工作负载→Pod单独建表把这些维度放进排序键容器视角的查询剪枝效率高得多CREATETABLEdwd_metric_k8s_num(cluster_idString,namespace_idString,workload_idString,pod_idString,object_idString,componentString,item_nameString,dateDateTime,valueFloat64,tagsArray(LowCardinality(String)))ENGINEMergeTreePARTITIONBYtoYYYYMMDD(date)ORDERBY(cluster_id,namespace_id)TTLdatetoIntervalDay(90)SETTINGS storage_policyssd_hdd_policy,index_granularity8192;明细之上是 5 分钟 / 1 小时 / 1 天三档汇总生产上由 Flink 任务实时聚合写入思路与《ClickHouse 物化视图实战AggregatingMergeTree 实现监控指标多粒度聚合》一致物化视图和流任务两条路线都可行CREATETABLEdws_metric_1h(componentString,item_nameString,object_idString,dateDateTimeCOMMENT统计窗口起点,avgFloat64,maxFloat64,minFloat64)ENGINEMergeTreePARTITIONBYtoYYYYMM(date)ORDERBY(component,item_name,object_id)SETTINGS index_granularity8192;3.2 日志解析属性独立存储 行数突增突降日志侧最值得说的设计是**“解析属性独立存储”**原始日志全文进 Elasticsearch 做检索另文详述而解析规则提取出来的结构化字段单独落到 ClickHouse 做统计用 Map 列承载动态字段名CREATETABLEdwd_log_attr(idStringCOMMENT日志ID,rule_idStringCOMMENT解析规则ID,log_timeDateTime64(3),log_task_idStringCOMMENT采集任务ID,object_idStringCOMMENT日志来源对象,log_sourceStringCOMMENT日志文件标识,valuesMap(String,String)COMMENT解析出的字段名-字段值,save_timeDateTime64(3)MATERIALIZED now64(3))ENGINEMergeTreePARTITIONBYtoYYYYMMDD(log_time)PRIMARYKEY(log_time,id)ORDERBY(log_time,id)TTL log_timetoIntervalDay(100)SETTINGS index_granularity8192;这张表让统计某任务解析出的响应码分布这类查询完全绕开 ESClickHouse 里一把梭。配套还有两张统计表日志行数窗口统计按对象文件按窗口计数做趋势图和突增突降结果表异常检测任务写入condition 字段区分突增/突降CREATETABLEdws_log_linecount(idUUIDDEFAULTgenerateUUIDv4(),window_startDateTimeCOMMENT统计窗口起点,systemStringCOMMENT应用系统,object_idString,log_sourceString,line_countsUInt64COMMENT窗口内日志行数,save_timeDateTimeDEFAULTnow())ENGINEMergeTreePARTITIONBYtoYYYYMM(window_start)ORDERBY(window_start,system,object_id,log_source)SETTINGS index_granularity8192;3.3 APM 链路明细 7 天 分钟级聚合长期链路trace数据的量级最凶残策略是明细短存、聚合长存。segment 明细表把 span 数据 JSON 原样留存TTL 7 天排障时才回查CREATETABLEdwd_trace_segment(trace_idStringCOMMENT链路ID,segment_idStringCOMMENT段ID,spansStringCOMMENTJSON格式的span数组,service_nameString,endpoint_nameStringCOMMENT接口名,start_timeDateTime64(3),end_timeDateTime64(3),latencyInt32COMMENT耗时ms,...)ENGINEMergeTreePARTITIONBYtoYYYYMMDD(start_time)ORDERBY(start_time,service_name)TTL start_datetoIntervalDay(7)SETTINGS index_granularity8192;大盘和拓扑消费的是分钟级聚合表服务/实例/应用三个视角各一张还带上了 Apdex 口径的满意数/容忍数CREATETABLEdws_trace_service_min(app_codeStringCOMMENT应用编码,data_centerString,service_nameString,timeDateTimeCOMMENT分钟级窗口,request_countInt32,error_countInt32,timeout_countInt32,avg_used_timeInt32COMMENT平均耗时ms,satisfied_countInt32DEFAULT0COMMENTApdex满意数,tolerant_countInt32DEFAULT0COMMENTApdex容忍数)ENGINEMergeTreePARTITIONBYtoYYYYMMDD(time)ORDERBYtimeTTLtimetoIntervalDay(7)SETTINGS index_granularity8192;这张表ORDER BY time时间优先因为它服务的就是最近 30 分钟全网大盘这类全量扫描式查询——和 2.3 节的原则呼应。3.4 告警原始报文留存三方告警源的字段经常变与其追着映射不如原始报文整条留存 抽取核心字段事后要溯源随时有原文CREATETABLEdwd_alarm_raw(idStringCOMMENT告警主键,messageStringCOMMENT三方原始报文,save_timeDateTime)ENGINEMergeTreePARTITIONBYtoYYYYMM(save_time)ORDERBYsave_time TTL save_timetoIntervalDay(90)SETTINGS index_granularity8192;3.5 维度层资产维度同步从配置管理库CMDB定时同步的资产维表指标/日志的object_id都靠它翻译成人类可读的名称。量小、变更慢TTL 反而用于过期下线CREATETABLEdim_asset_item(asset_idString,asset_nameString,model_idStringCOMMENT资产模型ID,ipString,dateDateTimeCOMMENT同步时间)ENGINEMergeTreePARTITIONBYtoYYYYMMDD(date)ORDERBYdateTTLdatetoIntervalDay(30)SETTINGS index_granularity8192;四、从单机到集群ReplicatedMergeTree Distributed 改造单机版跑稳之后上集群改造是模板化的每张表加_local后缀的本地表ReplicatedMergeTree再建同名 Distributed 表做路由。以 1 分片 1 副本的测试集群为例CREATETABLEdws_metric_k8s_1h_localONCLUSTER obs_cluster(cluster_idString,namespace_idString,dateDateTime,valueFloat64,...-- 与单机版字段一致)ENGINEReplicatedMergeTree(/clickhouse/tables/{shard}/dws_metric_k8s_1h_local,{replica})PARTITIONBYtoYYYYMMDD(date)ORDERBY(cluster_id,namespace_id)TTLdatetoIntervalDay(90)SETTINGS storage_policyssd_hdd_policy,index_granularity8192;CREATETABLEdws_metric_k8s_1hONCLUSTER obs_clusterASdws_metric_k8s_1h_localENGINEDistributed(obs_cluster,obs_dw,dws_metric_k8s_1h_local,rand());三个要点ZooKeeper 路径模板必须带{shard}否则多分片之间会互相覆盖元数据这是新手最容易翻车的地方分片键上面用了rand()追求写入均匀代价是跨分片查询无法下推按维度的裁剪。如果查询强依赖某个维度比如app_code把该列做分片键更划算——写入热点和查询效率之间要选边站业务侧只写 Distributed 表副本高可用交给 ReplicatedMergeTree应用代码对集群无感。数据从 MySQL/业务库同步进集群的另一条链路Flink CDC ReplicatedMergeTree 建表全流程我在《Flink CDC 实战MySQL 数据实时同步到 ClickHouse 集群》里展开过这里不重复。五、统计层ClickHouse 与 StarRocks 双引擎同构日志治理页面要展示纳管数、模式数、命中率、解析率这类指标我们做了 ClickHouse 与 StarRocks 双引擎的等价设计正好把两种 OLAP 的建模差异讲清楚。以日志模式统计表为例-- ClickHouseMergeTree 手动去重CREATETABLEstat_log_pattern(stat_timeDateTimeCOMMENT统计时间(分钟级),source_typeLowCardinality(String)COMMENT采集源: Agent/Syslog,app_idString,object_typeLowCardinality(String)COMMENTSERVICE/DB/MIDDLEWARE,pattern_hashStringCOMMENT日志模板哈希,pattern_templateStringCOMMENT日志模板内容,hit_statusUInt8COMMENT0-未命中 1-已命中,is_new_patternUInt8,log_countUInt64,log_levelLowCardinality(String))ENGINEMergeTree()PARTITIONBYtoYYYYMMDD(stat_time)ORDERBY(app_id,object_type,stat_time,pattern_hash)TTL stat_timeINTERVAL90DAY;-- StarRocksAggregate 模型 自动预聚合CREATETABLEstat_log_pattern(stat_timeDATETIMENOTNULL,source_typeVARCHAR(32),app_idVARCHAR(64)NOTNULL,object_typeVARCHAR(32),pattern_hashVARCHAR(64),pattern_templateVARCHAR(65533),hit_statusTINYINT,is_new_patternTINYINT,log_countBIGINTSUMDEFAULT0COMMENT导入时自动求和)ENGINEOLAP AGGREGATEKEY(stat_time,source_type,app_id,object_type,pattern_hash,pattern_template,hit_status,is_new_pattern)PARTITIONBYRANGE(stat_time)(START(2026-01-01)END(2027-01-01)EVERY(INTERVAL1DAY))DISTRIBUTEDBYHASH(app_id)BUCKETS16PROPERTIES(replication_num3,dynamic_partition.enabletrue,dynamic_partition.time_unitDAY,dynamic_partition.start-3,dynamic_partition.end3);几个直接影响写法的差异聚合语义CK 的 MergeTree 是纯明细模型重复导入会重复计数去重要靠uniqCombinedStarRocks 的 Aggregate 模型在导入阶段就把度量列 SUM 掉count(distinct)也有专门优化查询直接SUM即可生命周期CK 用TTL子句声明式管理StarRocks 用动态分区滚动建删分区dynamic_partition.start -3表示只保留 3 天分区调参时要小心别把要查的分区滚没了维表关联应用名、任务名这类配置数据CK 用字典引擎Dictionary直连业务库避免高频 join 打爆 MySQLStarRocks 则建议定时同步进本地表。六、踩坑清单最后是学费换来的经验按疼痛程度排序UUID 放主键首位 主键索引报废。早期有一张汇总表写成PRIMARY KEY idUUID 默认值ORDER BY (id, date)每行一个新 UUID数据完全无序散落按时间范围查询只能靠月分区粗剪枝主键索引形同虚设。补救要么去掉 UUID 用业务排序键要么 UUID 殿后。时间列放排序键首位前先想清楚。ORDER BY time对最近 N 分钟全网扫描友好但单个对象查一个月会退化成全分区扫描。按你的 P0 查询模式定排序键其余模式靠拆表解决。PRIMARY KEY 必须是 ORDER BY 的前缀写反了直接报错别硬凑。分区不是越细越好。汇总层误用按天分区一年 365 个分区乘上表数量后台 merge 和system.parts都会变得难看。明细按天、汇总按月控制单表活跃分区数在两位数以内。TTL 是 merge 时异步生效的不是准点删除写入停了但 merge 积压时磁盘不会立刻降下来监控告警别按 TTL 边界卡秒级预期。命名规范从第一天统一。我们的老表里同一个含义的字段在不同表里一会儿是驼峰、一会儿是蛇形写入端来自不同组件各自带了自己的习惯跨表 join 时字段名对不上的坑能挖一天。新表一律蛇形命名老表逐步迁移。指标 value 的 String/Float 混杂别用String存数值排序和聚合都废也别用可空 Float 硬扛文本按类型拆表写入端路由。写在最后可观测数据的 ClickHouse 建模本质上是在写入吞吐、查询剪枝、存储成本三者之间给每一类数据找各自的平衡点明细层为写入和 TTL 而生汇总层为查询而生维度层为关联而生没有万能模板。文中的分层思路和 DDL 在我们几十张表的生产库上验证过单机起步、集群化改造的路径也是平滑的。你的可观测平台是用一套 ClickHouse 扛所有数据还是指标走 Prometheus、日志走 ES、链路走专门的 APM 存储多套存储之间的取舍你踩过什么坑欢迎在评论区聊聊。相关阅读ClickHouse 物化视图实战AggregatingMergeTree 实现监控指标多粒度聚合Flink CDC 实战MySQL 数据实时同步到 ClickHouse 集群云原生日志采集与检索架构实战K8s 日志和传统日志统一接入日志平台的设计方案