ClickHouse 在酷家乐指标系统中的实践

背景

目前我们的指标系统主要以 prometheus+ thanos 架构为主,面向业务主要支持如下三种埋点方式:

  • 以 prometheus Java SDK 开发的二方包;
  • 自定义 Analyze 级别日志收集;
  • 前端 SDK 上报,收集器收到数据后再打印为 Analyze 日志;

三种埋点方式,实际上只有两种数据处理流程。第一种埋点基于 Prometheus 的 pull 模式,而后两种埋点均是基于Analyze 日志经过 Kafka+Flink 集群处理,写入自研 thanos-ingest 集群,无缝接入 thanos 集群,从而提供指标数据查询;

当然了,基于 Prometheus 这类时序数据库做指标存储和查询,最大的缺点就是高基数问题。同时基于SDK暴露 /metrics 采集端口的方式,对服务内存和CPU都有着不小的压力。

什么是高基数?

高基数是字段对应值的唯一数量,比如 isSuccess 取值只有 true/false,基数为2,我们认为success不是高基数字段。再比如 userId 取值数量可能成千上万,我们认为userId就是高基数字段。

在Prometheus和可观测性的世界里,标签基数是非常重要的,因为它影响到你的监控系统的性能和资源使用。下面这张图, 可以清晰地反应关注基数的重要性: 在上面的例子中,可以看到同一个实例,因为url是高基数,导致series数量显著增多。这种笛卡尔积的组合方式在引入高基数字段后,将发生爆炸性增长。

在 Prometheus 中每个时间序列都包含多个维度的标签 ,并且唯一组合的标签值会产生唯一的时间序列 。当有大量不同的标签组合时,就会产生大量的时序记录,因此对存储和查询都会有极大的影响,甚至崩溃。

Prometheus 是否适合业务埋点?

Prometheus 是 Cloud Native Computing 基金会在2016年开源的一款时序数据库,与云原生技术的结合可以提供更强大的监控和警报能力。

在云原生容器等监控场景中往往不会有高基数指标、比如 cpu 、网络、pod 状态监控、磁盘容量等,同时对数据精度没有完全精准的要求,往往以获取长时间趋势为目标,所以 Promehteus 在数据模型和存储上结合这些特点做了优化设计。

因此 Prometheus 更偏向于 简单的趋势数据记录,在这类场景有非常明显的存储成本和查询优势。而在业务中,面对高基数、高维度,往往显得捉襟见肘,不得不在系统性能和埋点需求之间做出取舍。

同时在业务监控埋点场景中,比如对方案保存着一场景做埋点,当查询每分钟保存方案数量的趋势图,如果用 prometheus 来统计,你会惊奇的发现居然有小数点,非常反直觉;

再比如保存方案时,同时希望记录 (用户id、方案id、保存耗时),这样 Prometheus 就完全实现不了了,因为 userId、designId 就是明显的高基数;

当然了,基于 Prometheus 这类时序数据库做指标存储和查询,最大的缺点就是高基数问题。同时基于SDK暴露 /metrics 采集端口的方式,对服务内存和CPU都有着不小的压力。

所以为了缓解 /metrics 压力,以及满足业务中的高基数埋点,需要实现一套支持业务场景埋点的监控系统;

为什么选择 ClickHouse?

在我们监控系统中,使用多种不同的数据库来存储监控数据,包括 Prometheus+thanos、druid、ES 、ClickHouse 以及neo4j图数据库。最终选择 ClickHouse 存储业务埋点主要基于以下几点原因:

  1. Prometheus 主要面向云原生组件本身的状态监控,并不适合业务监控场景中的高基数埋点;
  2. Druid 架构太过复杂,组件很多。运维难度大。同时资源占用也并无优势。
  3. ClickHouse 已经在我们监控系统中逐步完成对ES的替换,承担了 业务日志、前端性能监控、DPI流量分析系统的数据存储。其数据写入性能十分优秀,相比ES提升明显。因此我们最后以 ClickHouse 作为技术路线,开始调研其是否满足做业务监控的需求;

在我们的埋点场景中,不可能针对每一个埋点,建立独立的表来存储,这将造成极大的运维负担,且让整个埋点流程看起来并不那么轻松。

ClickHouse 构建埋点存储系统

大宽表还是字段映射?

为了更快的过滤数据,可以预先创建固定数量的常规字段。比如创建5个字符串字段字段名位为 str_1、str_2、... str_5。

在元数据库中,建立监控项字段名和上述字段序号的对应关系。查询数据时,从 可读字段名翻译为ck字段名,再渲染为 SQL 查询语句。对查询结果字段名再次翻译为可读字段名。

同时可能会有部分埋点字段数量超过5个字符串。因此创建 map 列来装多余的字段。

针对以上情况,衍生出出4种方案:

  1. 方案1:维度列、指标列分别用 Map(String,String) 、Map(String,Float64) 存储;

    • 优势:理论上用户可以加无限多个维度key 和指标 key;
    • 劣势:不能加索引,过滤和聚合效率很低,严重拖慢查询;
  2. 方案2:完全使用普通列来创建,比如 创建固定 a 个 维度列,固定 b 个指标列。

    • 优势:可以加索引,甚至可以放到排序键中,查询速度能达到最大化;
    • 劣势:大部分监控项的字段并不多,因此列会变得稀疏,同时对维度列和指标列数量有固定的限制。
  3. 方案3:在方案1的基础上,独立创建 a 个普通维度列,可用于加索引,加速筛选。同理创建 b 个普通指标列。这样做的目的是优先使用普通列,超过的字段放入Map列;

    • 优势:因为大部分监控项字段并不多,因此普通列就能放下,超过数量的字段依旧放入Map字段中;
    • 劣势:两种字段映射关系导致开发变得复杂,且对用户调整字段不友好。
  4. 方案4:使用大宽表存储,保持表字段和监控项字段一一对应:

    • 优势:对应关系明确;
    • 劣势:大宽表,需要动态加字,保持数据库和Flink步调一致逻辑链路复杂。同时大宽表在merge过程中耗时明显更大。

对大宽表做了性能测试,这里是做完测试的总结:

  • 2000字段的大宽表,即使每个监控项目仅占用数十列,依然会对merge造成巨大的压力;
  • 在本轮测试所表现的 merge 耗时上,大宽表和常规存储模式的差距十分明显,差距至少在 20 倍以上;
  • 因此非常不建议使用上千字段的大宽表模式;

综合考虑,选择方案3,兼顾了ck性能和用户埋点字段数量同时大宽表在merge过程中耗时明显更

抽样查询

对历史数据的大跨度查询,为了提高查询速度,采用抽样查询是必要的。

引入抽样查询,辅助降低引擎查询压力:

  • 优势:大跨度时间查询时,启用抽样可以快速跳过一些块;
  • 劣势:降低准确率,且需要在统一查询层适配,对返回结果的数据做放大;

抽样查询的原理是:根据指定字段 hash,然后放入排序键中,在查询时按配置的抽样比快速跳过一些数据块。

需要注意的是,只有在创建表结构时开启抽样查询功能,才能执行抽样查询 SQL 。

要均匀地抽样,首先想到的是使用时间戳作为抽样条件,intHash64(timestamp),但是实际上 时间戳 会导致 intHash64 值太多,从而降低过滤效果。所以这里我们手动生成 1-100 的随机数存到表字段中。用这个均匀可控的随机数作为采样的条件。

历史数据存对象存储(我们用的 COS)

超过半年的冷数据上传到 cos:

  • 存cos的好处自然不必多言,降低存储成本。同时劣势也很明显,查询速度相比查直接查本地磁盘,差异非常大。
  • 但是配合抽样查询可以达到尽可能满足需求的效果;

我们对查询 cos 和本地磁盘做了测试对比,大致得到这样一张还耗时对比:

统计结果趋势图:

总结一下:

  • 查询耗时和存储器类别有关;
  • 本地磁盘查询耗时稳定性很高,cos存储查询有较明显的波动;
  • 整体来看 cos 的查询耗时 是 本地 hdd 查询耗时的15倍左右;
  • 查询cos时,机器网络下行相对平稳,基本保持在 30MB/s 以内;
  • 如果对 cos 的数据使用抽样查询,抽样 1/15= 0.06 理论上可以达到和本地磁盘查询近似的耗时;

ClickHouse支持DiskS3类型磁盘,使用S3接口访问存储于对象存储上的数据,原生支持AWS对象存储S3以及腾讯云对象存储COS。

整个数据的生命周期,我们采用 SSD+HDD+COS的搭配方式,SSD 存储近1个月的数据,HDD近6个月,超过6个月上传COS。

数据生命周期的滚动,我们并不依赖于 ClickHouse 表本身的 TTL 特性,也不依赖于volume 的 move_factor。这两个特性对数据滚动的时机不可控,可能恰好在业务流量高峰期执行数据搬迁,这会导致机器负载陡增,从而影响到数据写入和查询。

因此我们开发定时程序 ClickLive,在业务低峰期,自动执行SQL来实现分区数据的滚动。

架构图如下:

至此存储层面构建完成。

实现DSL到SQL的翻译

面向用户,并不能直接提供SQL语句来查数据,应该在产品层面做到尽可能的简化。同时其他组件也需要以这些数据做二次开发,因此直接使用SQL查询会有极大的复杂度。因此我们在SQL的基础上做一层DSL的翻译,只需要传入固定几个参数就能查到想要的数据。

大致逻辑如下:

面向产品的DSL语句模型:

json 复制代码
`{`  
`    ``"table"``:``"tableName"``,`  `// 表名`  
`    ``"db"``:``"clickhouse"``,`  `// 数据库类型`  
`    ``"conditions"``:``{`  
`        ``"assertConditions"``:``[` `// 过滤条件`  
`        ``]``,`  
`        ``"logicOp"``:``"And"` `// 过滤条件的关系`  
`    ``}``,`  
`    ``"interval"``:``{`  
`        ``"start"``:``1687669320000``, // 时间区间`  
`        ``"end"``:``1687680147908`  
`    ``}``,`  
`    ``"measures"``:``[` `// 视图,不同的视图定义了不同的SQL函数`  
`        ``{`  
`            ``"field"``:``"count"``,`  
`            ``"aggregator"``:``"Sum"`  
`        ``}`  
`    ``]``,`  
`    ``"order"``:``{ // 排序字段`  
   
`    ``}``,`  
`    ``"granularity"``:``"1m"``,` `// 查询时间序列时的粒度`  
`    ``"groupFields"``:``[ //分组字段`  
   
`    ``]``,`  
`    ``"limit"``:``{`  
`        ``"size"``:``30000`  
`    ``}`  
`}`

在监控系统中我们使用的 prometheus、druid、ES、以及新引入的 ClickHouse 都适配了如上DSL模型;

不难看出,除开最基础的如表名、过滤条件、查询范围外,最为核心且重要的就是 视图 概念,这里的视图决定了最终的数据聚合方式。在诸如prometheus查询中我们使用 freemark 定义 PromQL 从而标识一个查询方式,在ClickHouse 中我们做了更细致的定义。 clickhouse 在我们监控体系里有如下查询场景:

  1. 分组聚合统计:返回按时间粒度聚合的数据序列;
  2. 分组聚合后的同环比计算:在聚合统计的基础上计算同比,要用到 global join 查询;
  3. 原文查询: select * from .... 按筛选条件查询每个字段的值;
  4. 某一个字段的 terms 查询:用于输入框提示可选值;

基础视图的渲染和查询

  1. 针对四类查询,分别定义了 freeMark 模版。其中分组聚合需要重点考虑,因为要支持翻译不同的聚合方法到具体的SQL 语句中,这里我们默认适配了基础聚合方法,因为这些聚合方式都是官方有函数直接支持的:
    • count
    • sum
    • avg
    • max
    • min
    • quantile
    • uniq
  2. 以查询 my_test 表 cost 每分钟最大值为例,DSL 语句如下:
js 复制代码
{
    "table": "my_test",
    "db": "clickhouse",
    "conditions":
    {
        "assertConditions":
        [],
        "logicOp": "And"
    },
    "interval":
    {
        "start": 1687669320000,
        "end": 1687680147908
    },
    "measures":
    [
        {
            "field": "cost",
            "aggregator": "max"
        }
    ],
    "order":
    {},
    "granularity": "1m",
    "groupFields":
    [],
    "limit":
    {
        "size": 30000
    }
}    
  1. 查询时的SQL 如:
sql 复制代码
SELECT 
  toStartOfInterval(
    timestamp, INTERVAL 60000 millisecond
  ) as group_time, 
  max(cost) as cost_max 
FROM 
  my_test 
WHERE 
  timestamp >= '2023-12-06 19:34:00.000' 
  AND timestamp < '2023-12-06 19:49:05.948' 
GROUP BY 
  (group_time) 
ORDER BY 
  cost_max DESC 
LIMIT 
  30000 OFFSET 0
  1. 稍微复杂一点的视图,比如可以针对任意字符串字段 做成功率的计算
  2. 视图名称定义:strSuccessRate
  3. 聚合函数定义:divide(count(CASE WHEN (@agg-field='true' OR @agg-field='1' ) THEN 1 END) , count())
  4. 比如埋点中有一个 success = true/false 的字段,当对 success 字段作用 strSuccessRate 视图时,即可达到成功率的计算。
  5. 查询SQL的渲染结果:
sql 复制代码
SELECT 
  toStartOfInterval(
    timestamp, INTERVAL 60000 millisecond
  ) as group_time, 
  divide(
    count(
      CASE WHEN (
        success = 'true' 
        OR success = '1'
      ) THEN 1 END
    ), 
    count()
  ) as success_strSuccessRate 
FROM 
  my_test 
WHERE 
  timestamp >= '2023-12-06 19:34:00.000' 
  AND timestamp < '2023-12-06 19:49:05.948' 
GROUP BY 
  (group_time) 
ORDER BY 
  success_strSuccessRate DESC 
LIMIT 
  30000 OFFSET 0
  1. 当然了,也可以一次性查询多个视图,只需要将多个聚合函数渲染到一条SQL中。
  2. 诸如此类我们配置了大量方便业务使用的视图函数。

同比视图渲染的查询

  1. 比如对前面的耗时平均值做同比计算,为了给用户最大的灵活度,在DSL中我们还支持了一个字段:compareOffset,
  2. 表示和多长时间之前的一个数据区间的聚合结果相比,使用偏移的时间区间分别查询两段数据,再做join,对join的结果再做同比计算。
sql 复制代码
SELECT 
  rightSet.group_time AS group_time, 
  (
    CASE WHEN isNull(leftSet.cost_Max) 
    OR abs(leftSet.cost_Max) < 0.000000001 THEN 0.0 ELSE rightSet.cost_Max / leftSet.cost_Max - 1 END
  ) AS cost_Max 
FROM 
  (
    SELECT 
      addSeconds(
        toStartOfInterval(
          timestamp, INTERVAL 60000 millisecond
        ), 
        3600
      ) AS group_time, 
      max(num02) AS cost_Max 
    FROM 
      my_test 
    WHERE 
      timestamp >= addSeconds(
        toDateTime64(
          '2023-12-06 19:15:00.000', 3, 'Asia/Shanghai'
        ), 
        -3600
      ) 
      AND timestamp < addSeconds(
        toDateTime64(
          '2023-12-06 20:15:29.775', 3, 'Asia/Shanghai'
        ), 
        -3600
      ) 
    GROUP BY 
      (group_time) 
    ORDER BY 
      cost_Max DESC 
    LIMIT 
      30000 OFFSET 0
  ) AS leftSet GLOBAL 
  RIGHT JOIN (
    SELECT 
      toStartOfInterval(
        timestamp, INTERVAL 60000 millisecond
      ) AS group_time, 
      max(num02) AS cost_Max 
    FROM 
          my_test 
    WHERE 
      timestamp >= '2023-12-06 19:15:00.000' 
      AND timestamp < '2023-12-06 20:15:29.775' 
    GROUP BY 
      (group_time) 
    ORDER BY 
      cost_Max DESC 
    LIMIT 
      30000 OFFSET 0
  ) AS rightSet ON leftSet.group_time = rightSet.group_time

重写时间粒度

* 因为我们将时间粒度和step看作一个概念。因此当查询跨度太大,而时间粒度过小,会导致查询的数据点过多,从而导致前端崩溃。
* 因此当查询层判定时间粒度设置不合理时,会使用合理的时间粒度做计算,并将实际使用的时间粒度大小返回给调用方。

抽样处理

* 如果后端使用了抽样查询,在返回查询数据时,同时返回抽样比,在即席查询中给出使用了抽样查询的提示;
  1. 对查询类型的支持:
查询类型 聚合函数/计算方式 是否支持抽样 抽样后的查询结果是否可以直接使用 处理方式
分组聚合 sum 支持 不可以 按抽样比放大
count 支持 不可以
其他 支持 可以 --
同比 分子分母会转化为比值 支持 可以
原文查询 查询原文是为了看细节,因此不应该采样 不支持 不支持
terms 查询 做提示框,抽样 支持 可以
  1. 抽样比设置:
时间范围 t 抽样比(0.5 表示抽取 50% 的数据)
t <= 30d 全量
30d < t <= 60d 0.5
60d < t <= 90d 0.3
90d < t <= 120d 0.2
120d < t 0.1
  1. 至此查询逻辑已介绍完成。此处只介绍了大致逻辑,其实我们还实现了原文、terms等查询翻译,但大同小异。如果在具体的业务查询场景中有更加个性户的聚合逻辑,我们也可以根据需求实时的配置出可用的视图,进而不断完善埋点的查询能力。

总结

  1. 本文介绍了高基数、云原生指标监控和业务场景监控的区别。监控与大数据的数据仓库架构不同,支持各种ETL。为了满足对数据的高实效性要求,我们使用 Prometheus 这类数据库,但无法解决高基数问题,因此使用场景受限。在更精细化的业务监控场景中,高基数问题无法避免,而这一直是时序数据库的痛点。幸运的是,ClickHouse作为列式数据库,具有出色的写入性能,因此我们选择它来存储原始埋点数据。
  2. 在实现过程中,我们也发现,使用大宽表有明显的性能问题。这也不难分析,ClickHouse 会针对每列做Merge,而根据观测,ClickHouse CPU花在Merge 上的CPU耗时占比达到70%以上。为了更多地存储数据并兼顾查询性能,我们考虑到对数据做分层存储,数据从 SSD到HDD,再到COS。在测试阶段,也发现COS和本地磁盘的查询性能有20倍左右的差距。但是久远的历史数据往往极小概率被查询。因此在这种情况下,我们结合抽样,从而尽可能缓解历史数据查询满的问题。
  3. 在集群稳定性方面,根据过往经验,我们并没有使用集群自带的数据生命周期管理,如文中所述,不可控的数据流转会在业务高峰期将集群CPU打得很高,从而影响正常的查询。在解决这一点上问题上目前我们认为最好的办法就是使用定时任务来驱动数据的生命周期,从而避免在业务高峰期对响用户查询的影响。
  4. 最后介绍了用户如何查询我们的数据,面向用户,自然是不能直接使用SQL的,因此我们做了一层封装,而封装的核心就是视图。简而言之 视图 表达了 数据的聚合方式,特别是结合 case when,可以解决很多业务的查询场景。比如 http 调用的 5xx错误率,成功率等等。
  5. 感谢你能读到这里,如果有更好的建议也欢迎在评论区留言讨论。
相关推荐
弥琉撒到我1 小时前
微服务swagger解析部署使用全流程
java·微服务·架构·swagger
2401_857622666 小时前
SpringBoot框架下校园资料库的构建与优化
spring boot·后端·php
2402_857589366 小时前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端
哎呦没8 小时前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch8 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
韩楚风9 小时前
【linux 多进程并发】linux进程状态与生命周期各阶段转换,进程状态查看分析,助力高性能优化
linux·服务器·性能优化·架构·gnu
杨哥带你写代码9 小时前
足球青训俱乐部管理:Spring Boot技术驱动
java·spring boot·后端
AskHarries10 小时前
读《show your work》的一点感悟
后端
A尘埃10 小时前
SpringBoot的数据访问
java·spring boot·后端
yang-230710 小时前
端口冲突的解决方案以及SpringBoot自动检测可用端口demo
java·spring boot·后端