Skip to content

指标表 schema 演进:一个会让数据悄悄断流的设计 ​

2088 字 · 约 5 分钟

指标写进 ClickHouse,最自然的做法是拍平成一张大宽表。这条路我们走过,然后前前后后改了三次。中间踩到的那个坑特别值得说道说道:用户在业务系统里改了一个标签,指标在时间上就断了,不报错,也不丢数据,就是查不出连续的曲线。

一张宽表装不下 ​

指标数据有它自己的脾气。指标数量极多,指标名还不固定,schema 固定的表不合适;用 JSON 或者 Map 存,读写性能没法保证,管理起来也麻烦。列存天然适合把它拍平成大宽表,这个方向是对的。

sinker 的动态表 schema(dynamicSchema)就是为这个做的:根据 Kafka 里 JSON 的字段自动给表扩列,上限由 maxDims 控制,再配上白名单、黑名单,防止脏数据把表撑坏。

方向是对了,可一张表装不下。每一个指标都是一条时间序列,全塞进一张大宽表,这张表会非常庞大,行多,列也多,插入和查询都快不起来。

第一次改:拆成两张表 ​

那就把随时间变化的值,和不随时间变化的维度分开。

metric 表只有三个字段,极窄极长:

字段说明
__series_id指标序列 ID,标识这是哪一条序列
timestamp时间
value值

metric_series 表是宽表,存维度:

__series_id__name__labelmetric1metric2…metricN
按指标属性维度算出的序列 ID指标名指标携带的标签

查询的时候,两张表按 __series_id 关联。好处很直接,metric 表一行只有三列,写入量再大也是窄行;维度信息放在 metric_series 里,同一条序列只存一份,不用每个数据点都重复一遍。

在 sinker 里,这对应的是 prometheusSchema: true,一条记录会被自动拆进两张表,series 表的表名默认是 tableName 加上 _series 后缀。

然后就撞上了那个坑 ​

__series_id 是根据指标的属性维度算出来的,也就是说,标签参与了它的计算。问题就出在这儿。

用 exporter 暴露指标的时候,经常会带上一些跟指标本身没关系的 label。比如同样是 CPU 使用率,一部分是业务 A 的机器,一部分是业务 B 的机器,这类业务标签就一起带进来了。

头一个后果是 series 表膨胀。查询的人可能只关心业务 A 的指标,可业务标签一旦参与了 __series_id 的计算,就会冒出超级多的 __series_id,series 表胀得特别厉害,时间一长,还会堆出一大堆没用的垃圾数据。

更要命的是第二个后果,断流。改业务标签是经常会发生的事,业务标签一改,__series_id 就跟着变,这条序列在 metric 表里就换了个身份。同一个指标的数据在时间上不再连续,甚至直接断掉。

这个故障不好发现。它不报错,也不丢数据,新 series 的数据照样在写,你只是在 Grafana 上看到曲线断了一截,或者干脆换了一条新线。而触发它的,是某个人在业务系统里改了一个标签,那个人压根不知道自己动了监控。

第二次改:拆出 __mgmt_id ​

根子在于一个 ID 里混进了两类性质完全不同的信息。一类是指标自己的身份,它是什么指标、哪台机器、哪个实例,这些很稳定;另一类是业务管理属性,它属于哪个业务、哪个团队、哪个项目,这些经常变。两类混在一起算 hash,等于让经常变的东西去决定稳定的东西是谁。

所以我们引入了 __mgmt_id,代表管理标签的 id,把指标 id 和业务 id 分开:

__series_id   ← 只由指标自身属性决定    → 稳定
__mgmt_id     ← 由业务管理标签决定      → 可变

这样指标就干净了,不会因为改了某个业务标签就查不到,series 表的量级也可控了,不会无限制地膨胀。

第三次改:bmSeriesMap 和它的内存 ​

拆开以后,新问题又来了。

同一个业务的管理标签是一致的,但可能涉及好多个指标;同一个指标,也可能因为业务标签改过,对应好几个 __mgmt_id。两者是多对多的关系。

相同的 __mgmt_id 和 __series_id 组合,我们不希望每次都插一遍,那样开销非常大,数据多了,merge 和带 FINAL 的查询都会变慢。我们想要的是,只有当 __series_id 和 __mgmt_id 之间的映射关系变了,才 insert 一条新记录。

那就得有个 map 记着这两者的映射。

然后这个 map 自己又成了问题。指标越来越多,业务标签不断扩展,这种映射关系可能到几百万级,这么大的量放在内存里,有严重的内存爆炸风险。每次插入都去数据库实时查?性能更差。再说有些任务可能早就不用了,映射关系却还留在内存里,纯粹是垃圾,也得有个清理机制。

最后的办法是给 bmSeriesMap 加了 reload 机制:只保留一天以内还有效的指标,控制总量;每隔一个小时从数据库查一次最新的映射,刷新到 map 里,保证准确。

只保留「一天以内的有效指标」            → 控制总量
每隔一个小时从数据库实时查询最新映射     → 保证准确
   并刷新到 map 中

对应两个配置项:

配置默认值含义
activeSeriesRange86400(1 天)有效 series 的时间范围
reloadSeriesMapInterval3600(1 小时)自动重新加载 seriesmap 的时间间隔

这两个数是在内存占用和映射准确性之间做的取舍。范围放大,内存跟着涨;reload 间隔拉长,新序列就要更晚才被识别出来。

顺带说说 recordPoolSize ​

还有一个相关的设计,也是内存的事。

sinker 往 ClickHouse 写这一步是异步的,起一个协程专门负责写,主流程不等结果,马上去处理下一批 fetch。本意是尽量提高写入性能,只要写入不报错,就让它慢慢写,写完了再提交 offset。

可这么做有个风险。如果 ClickHouse 因为硬件资源的原因写得慢,Kafka 的 lag 又积压得特别多,很可能 fetch 了一大堆数据,全堵在 flush 这一步。而 sinker 在数据没完全写成功之前,是不会释放这部分内存的。

表现出来的曲线很好认:

sinker 刚启动      →  内存有明显冲高(lag 大,fetch 快,flush 慢)
lag 慢慢追上       →  内存缓缓降下来

recordPoolSize 是每个进程实例最多能同时处理的消息上限。池子里积压的消息到了上限,就先暂停从 Kafka 拉新消息,等池子里腾出空间再拉。说白了就是一个反压机制,解决了内存占用过高的问题,代价是写入速度会降一些。我们建议设成 bufferSize 的 3~4 倍。

回头看 ​

三次改,改的其实是同一类问题:设计的时候默认某个量不会太大、不会经常变,结果它变了。一张宽表,默认了指标数量可控,结果行和列都爆了;__series_id 里带业务标签,默认了标签稳定,结果它经常变;bmSeriesMap 全量常驻内存,默认了映射关系有限,结果到了百万级。每次的办法也差不多,把那个会变的东西拆出去,单独给它一套生命周期:拆成两张表,拆出 __mgmt_id,给 map 加上 activeSeriesRange。

断流那个坑我印象最深。一个不在监控系统里的人,改了一个不在监控系统里的字段,监控就断了,而监控自己一个错都没报。这种跨系统的隐式耦合是最难测出来的,因为每个系统单独拿出来看,都在正常工作。


相关:扩容之后为什么不自动做数据均衡 · clickhouse_sinker

内容来自一线生产环境,客户信息均已脱敏