news 2026/10/3 20:05:13

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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),量小但被高频 join;
  • stat 统计层:面向页面指标(纳管率、命中率、解析率)的专用统计表。

分区粒度跟着层走:明细按天(toYYYYMMDD),汇总按月(toYYYYMM)。这是全文最重要的一个取舍——分区不是越细越好,汇总层数据量小,按天分区会让分区总数膨胀,merge 压力反而上来。

二、建表的五个核心决策

2.1 分区粒度:明细按天,汇总按月

明细层按天分区,配合 TTL 删除整天的分区几乎是"免费"的(直接 drop 分区目录);汇总层一天往往只有几万行,按天分区纯属浪费,按月分区足够:

-- 明细层:按天分区PARTITIONBYtoYYYYMMDD(date)-- 汇总层:按月分区PARTITIONBYtoYYYYMM(date)

2.2 TTL 差异化保留与冷热分离

不同表的保留期差得很远:链路明细 7 天、指标明细 30~90 天、日志解析属性 100 天、汇总表 1 年以上。TTL 直接挂在建表语句里,靠 merge 异步清理:

TTLdate+toIntervalDay(90)

如果机房有 SSD + HDD 混部,更进一步的做法是给存储策略配两个 volume,TTL 先搬家再删除——30 天内数据留 SSD,30~90 天搬到 HDD,90 天后删除:

TTLdate+toIntervalDay(30)TOVOLUME'hdd',date+toIntervalDay(90)SETTINGS storage_policy='ssd_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(`component`StringCOMMENT'组件名',`item_name`StringCOMMENT'指标名',`date`DateTimeCOMMENT'采集时间',`tags`Array(LowCardinality(String))COMMENT'标签值集合',`object_id`StringCOMMENT'监控对象ID',`value`Float64COMMENT'指标值')ENGINE=MergeTreePARTITIONBYtoYYYYMM(date)ORDERBY(component,item_name)SETTINGS index_granularity=8192;

注意这张表的ORDER BY只有(component, item_name),是当时的一个权衡:查询几乎都带组件和指标名,时间粗剪枝交给月分区。后来短时间窗查询多了,我们新增的表把object_id也加进了排序键——同一张表服务不了所有查询模式,按查询模式拆表比加索引更 ClickHouse。

K8s 场景的指标带多级维度(集群→命名空间→工作负载→Pod),单独建表把这些维度放进排序键,容器视角的查询剪枝效率高得多:

CREATETABLEdwd_metric_k8s_num(`cluster_id`String,`namespace_id`String,`workload_id`String,`pod_id`String,`object_id`String,`component`String,`item_name`String,`date`DateTime,`value`Float64,`tags`Array(LowCardinality(String)))ENGINE=MergeTreePARTITIONBYtoYYYYMMDD(date)ORDERBY(cluster_id,namespace_id)TTLdate+toIntervalDay(90)SETTINGS storage_policy='ssd_hdd_policy',index_granularity=8192;

明细之上是 5 分钟 / 1 小时 / 1 天三档汇总(生产上由 Flink 任务实时聚合写入,思路与《ClickHouse 物化视图实战:AggregatingMergeTree 实现监控指标多粒度聚合》一致,物化视图和流任务两条路线都可行):

CREATETABLEdws_metric_1h(`component`String,`item_name`String,`object_id`String,`date`DateTimeCOMMENT'统计窗口起点',`avg`Float64,`max`Float64,`min`Float64)ENGINE=MergeTreePARTITIONBYtoYYYYMM(date)ORDERBY(component,item_name,object_id)SETTINGS index_granularity=8192;

3.2 日志:解析属性独立存储 + 行数突增突降

日志侧最值得说的设计是**“解析属性独立存储”**:原始日志全文进 Elasticsearch 做检索(另文详述),而解析规则提取出来的结构化字段单独落到 ClickHouse 做统计,用 Map 列承载动态字段名:

CREATETABLEdwd_log_attr(`id`StringCOMMENT'日志ID',`rule_id`StringCOMMENT'解析规则ID',`log_time`DateTime64(3),`log_task_id`StringCOMMENT'采集任务ID',`object_id`StringCOMMENT'日志来源对象',`log_source`StringCOMMENT'日志文件标识',`values`Map(String,String)COMMENT'解析出的字段名->字段值',`save_time`DateTime64(3)MATERIALIZED now64(3))ENGINE=MergeTreePARTITIONBYtoYYYYMMDD(log_time)PRIMARYKEY(log_time,id)ORDERBY(log_time,id)TTL log_time+toIntervalDay(100)SETTINGS index_granularity=8192;

这张表让"统计某任务解析出的响应码分布"这类查询完全绕开 ES,ClickHouse 里一把梭。配套还有两张统计表:日志行数窗口统计(按对象+文件按窗口计数,做趋势图)和突增突降结果表(异常检测任务写入,condition 字段区分突增/突降):

CREATETABLEdws_log_linecount(`id`UUIDDEFAULTgenerateUUIDv4(),`window_start`DateTimeCOMMENT'统计窗口起点',`system`StringCOMMENT'应用系统',`object_id`String,`log_source`String,`line_counts`UInt64COMMENT'窗口内日志行数',`save_time`DateTimeDEFAULTnow())ENGINE=MergeTreePARTITIONBYtoYYYYMM(window_start)ORDERBY(window_start,system,object_id,log_source)SETTINGS index_granularity=8192;

3.3 APM 链路:明细 7 天 + 分钟级聚合长期

链路(trace)数据的量级最凶残,策略是明细短存、聚合长存。segment 明细表把 span 数据 JSON 原样留存,TTL 7 天,排障时才回查:

CREATETABLEdwd_trace_segment(`trace_id`StringCOMMENT'链路ID',`segment_id`StringCOMMENT'段ID',`spans`StringCOMMENT'JSON格式的span数组',`service_name`String,`endpoint_name`StringCOMMENT'接口名',`start_time`DateTime64(3),`end_time`DateTime64(3),`latency`Int32COMMENT'耗时ms',...)ENGINE=MergeTreePARTITIONBYtoYYYYMMDD(start_time)ORDERBY(start_time,service_name)TTL start_date+toIntervalDay(7)SETTINGS index_granularity=8192;

大盘和拓扑消费的是分钟级聚合表,服务/实例/应用三个视角各一张,还带上了 Apdex 口径的满意数/容忍数:

CREATETABLEdws_trace_service_min(`app_code`StringCOMMENT'应用编码',`data_center`String,`service_name`String,`time`DateTimeCOMMENT'分钟级窗口',`request_count`Int32,`error_count`Int32,`timeout_count`Int32,`avg_used_time`Int32COMMENT'平均耗时ms',`satisfied_count`Int32DEFAULT0COMMENT'Apdex满意数',`tolerant_count`Int32DEFAULT0COMMENT'Apdex容忍数')ENGINE=MergeTreePARTITIONBYtoYYYYMMDD(time)ORDERBYtimeTTLtime+toIntervalDay(7)SETTINGS index_granularity=8192;

这张表ORDER BY time时间优先,因为它服务的就是"最近 30 分钟全网大盘"这类全量扫描式查询——和 2.3 节的原则呼应。

3.4 告警:原始报文留存

三方告警源的字段经常变,与其追着映射,不如原始报文整条留存 + 抽取核心字段,事后要溯源随时有原文:

CREATETABLEdwd_alarm_raw(`id`StringCOMMENT'告警主键',`message`StringCOMMENT'三方原始报文',`save_time`DateTime)ENGINE=MergeTreePARTITIONBYtoYYYYMM(save_time)ORDERBYsave_time TTL save_time+toIntervalDay(90)SETTINGS index_granularity=8192;

3.5 维度层:资产维度同步

从配置管理库(CMDB)定时同步的资产维表,指标/日志的object_id都靠它翻译成人类可读的名称。量小、变更慢,TTL 反而用于"过期下线":

CREATETABLEdim_asset_item(`asset_id`String,`asset_name`String,`model_id`StringCOMMENT'资产模型ID',`ip`String,`date`DateTimeCOMMENT'同步时间')ENGINE=MergeTreePARTITIONBYtoYYYYMMDD(date)ORDERBYdateTTLdate+toIntervalDay(30)SETTINGS index_granularity=8192;

四、从单机到集群:ReplicatedMergeTree + Distributed 改造

单机版跑稳之后上集群,改造是模板化的:每张表加_local后缀的本地表(ReplicatedMergeTree),再建同名 Distributed 表做路由。以 1 分片 1 副本的测试集群为例:

CREATETABLEdws_metric_k8s_1h_localONCLUSTER obs_cluster(`cluster_id`String,`namespace_id`String,`date`DateTime,`value`Float64,...-- 与单机版字段一致)ENGINE=ReplicatedMergeTree('/clickhouse/tables/{shard}/dws_metric_k8s_1h_local','{replica}')PARTITIONBYtoYYYYMMDD(date)ORDERBY(cluster_id,namespace_id)TTLdate+toIntervalDay(90)SETTINGS storage_policy='ssd_hdd_policy',index_granularity=8192;CREATETABLEdws_metric_k8s_1hONCLUSTER obs_clusterASdws_metric_k8s_1h_localENGINE=Distributed(obs_cluster,obs_dw,dws_metric_k8s_1h_local,rand());

三个要点:

  1. ZooKeeper 路径模板必须带{shard},否则多分片之间会互相覆盖元数据,这是新手最容易翻车的地方;
  2. 分片键:上面用了rand()追求写入均匀,代价是跨分片查询无法下推按维度的裁剪。如果查询强依赖某个维度(比如app_code),把该列做分片键更划算——写入热点和查询效率之间要选边站;
  3. 业务侧只写 Distributed 表,副本高可用交给 ReplicatedMergeTree,应用代码对集群无感。

数据从 MySQL/业务库同步进集群的另一条链路(Flink CDC + ReplicatedMergeTree 建表全流程),我在《Flink CDC 实战:MySQL 数据实时同步到 ClickHouse 集群》里展开过,这里不重复。

五、统计层:ClickHouse 与 StarRocks 双引擎同构

日志治理页面要展示"纳管数、模式数、命中率、解析率"这类指标,我们做了 ClickHouse 与 StarRocks 双引擎的等价设计,正好把两种 OLAP 的建模差异讲清楚。以日志模式统计表为例:

-- ClickHouse:MergeTree + 手动去重CREATETABLEstat_log_pattern(`stat_time`DateTimeCOMMENT'统计时间(分钟级)',`source_type`LowCardinality(String)COMMENT'采集源: Agent/Syslog',`app_id`String,`object_type`LowCardinality(String)COMMENT'SERVICE/DB/MIDDLEWARE',`pattern_hash`StringCOMMENT'日志模板哈希',`pattern_template`StringCOMMENT'日志模板内容',`hit_status`UInt8COMMENT'0-未命中 1-已命中',`is_new_pattern`UInt8,`log_count`UInt64,`log_level`LowCardinality(String))ENGINE=MergeTree()PARTITIONBYtoYYYYMMDD(stat_time)ORDERBY(app_id,object_type,stat_time,pattern_hash)TTL stat_time+INTERVAL90DAY;
-- StarRocks:Aggregate 模型 + 自动预聚合CREATETABLEstat_log_pattern(`stat_time`DATETIMENOTNULL,`source_type`VARCHAR(32),`app_id`VARCHAR(64)NOTNULL,`object_type`VARCHAR(32),`pattern_hash`VARCHAR(64),`pattern_template`VARCHAR(65533),`hit_status`TINYINT,`is_new_pattern`TINYINT,`log_count`BIGINTSUMDEFAULT"0"COMMENT'导入时自动求和')ENGINE=OLAP 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_num"="3","dynamic_partition.enable"="true","dynamic_partition.time_unit"="DAY","dynamic_partition.start"="-3","dynamic_partition.end"="3");

几个直接影响写法的差异:

  • 聚合语义:CK 的 MergeTree 是纯明细模型,重复导入会重复计数,去重要靠uniqCombined;StarRocks 的 Aggregate 模型在导入阶段就把度量列 SUM 掉,count(distinct)也有专门优化,查询直接SUM即可;
  • 生命周期:CK 用TTL子句声明式管理,StarRocks 用动态分区滚动建删分区,dynamic_partition.start = "-3"表示只保留 3 天分区,调参时要小心别把要查的分区滚没了;
  • 维表关联:应用名、任务名这类配置数据,CK 用字典引擎(Dictionary)直连业务库,避免高频 join 打爆 MySQL;StarRocks 则建议定时同步进本地表。

六、踩坑清单

最后是学费换来的经验,按疼痛程度排序:

  1. UUID 放主键首位 = 主键索引报废。早期有一张汇总表写成PRIMARY KEY id(UUID 默认值)+ORDER BY (id, date),每行一个新 UUID,数据完全无序散落,按时间范围查询只能靠月分区粗剪枝,主键索引形同虚设。补救:要么去掉 UUID 用业务排序键,要么 UUID 殿后。
  2. 时间列放排序键首位前先想清楚。ORDER BY time对"最近 N 分钟全网扫描"友好,但"单个对象查一个月"会退化成全分区扫描。按你的 P0 查询模式定排序键,其余模式靠拆表解决。
  3. PRIMARY KEY 必须是 ORDER BY 的前缀,写反了直接报错,别硬凑。
  4. 分区不是越细越好。汇总层误用按天分区,一年 365 个分区乘上表数量,后台 merge 和system.parts都会变得难看。明细按天、汇总按月,控制单表活跃分区数在两位数以内。
  5. TTL 是 merge 时异步生效的,不是准点删除;写入停了但 merge 积压时,磁盘不会立刻降下来,监控告警别按 TTL 边界卡秒级预期。
  6. 命名规范从第一天统一。我们的老表里同一个含义的字段在不同表里一会儿是驼峰、一会儿是蛇形(写入端来自不同组件,各自带了自己的习惯),跨表 join 时字段名对不上的坑能挖一天。新表一律蛇形命名,老表逐步迁移。
  7. 指标 value 的 String/Float 混杂:别用String存数值(排序和聚合都废),也别用可空 Float 硬扛文本,按类型拆表,写入端路由。

写在最后

可观测数据的 ClickHouse 建模,本质上是在"写入吞吐、查询剪枝、存储成本"三者之间给每一类数据找各自的平衡点:明细层为写入和 TTL 而生,汇总层为查询而生,维度层为关联而生,没有万能模板。文中的分层思路和 DDL 在我们几十张表的生产库上验证过,单机起步、集群化改造的路径也是平滑的。

你的可观测平台是用一套 ClickHouse 扛所有数据,还是指标走 Prometheus、日志走 ES、链路走专门的 APM 存储?多套存储之间的取舍你踩过什么坑?欢迎在评论区聊聊。

相关阅读:

  • ClickHouse 物化视图实战:AggregatingMergeTree 实现监控指标多粒度聚合
  • Flink CDC 实战:MySQL 数据实时同步到 ClickHouse 集群
  • 云原生日志采集与检索架构实战:K8s 日志和传统日志统一接入日志平台的设计方案
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 19:59:24

甘特图重温

一开始让Agent美化一下成这样子了,好像不太对劲,不是想要的效果。再来~~把自己的链接拿来用,发现控制台报一堆红,排查了一下,发现是没打包,效果如下和最终效果还是差蛮多的,还需完善~~

作者头像 李华
网站建设 2026/10/3 19:58:50

AI 短剧换镜头脸就变了,有什么工具或方法能让人物形象不崩?

用 AI 生成短剧时,「换镜头主角换脸」是最常见的翻车点:同一个角色,镜头一切,五官、发型、肤色甚至性别都变了,观众一秒出戏。这个问题在技术上叫「角色一致性」,本文从成因、通用方法、工具对比三个层面给…

作者头像 李华
网站建设 2026/10/3 19:52:55

千万不能忽视!江苏体育高职单招培训机构最新排名揭晓

引言 随着体育教育的不断发展,越来越多的学生选择通过高职单招的方式进入心仪的体育院校。然而,在激烈的竞争中,如何选择一家优质的培训机构成为了许多考生和家长关注的重点。本文将为您揭示最新的江苏体育高职单招培训机构排名,并…

作者头像 李华
网站建设 2026/10/3 19:51:45

Spark Streaming 与 HBase 写入:批量 Put、连接池管理与写入吞吐优化

Spark Streaming 与 HBase 写入:批量 Put、连接池管理与写入吞吐优化1. Spark Streaming 与 HBase 集成基础Spark Streaming 是 Spark 的核心组件之一,用于处理实时数据流。HBase 作为 Hadoop 生态系统中的 NoSQL 数据库,常用于存储大规模结构…

作者头像 李华