酷家乐基于Clickhouse实现的调用链全保留方案实践

改造背景

在分布式系统中,前端发起的一次请求可能经过了多个微服务、多台机器、多个中间件,这使得排查问题变得艰难,调用链通常是帮助开发同学快速定位、排查线上问题的重要手段。酷家乐通过自研的Hunter SDK来进行分布式服务调用链追踪,Hunter主要借鉴了Google Dapper论文的开源版本Twitter Zipkin实现。

过去酷家乐的调用链数据以日志落盘,采用filebeat将数据采集到Kafka,再采样存入Elasticsearch,最后通过监控系统的Web UI呈现出一条有调用关系的完整调用链,这其中我们对线上的正常调用链进行了1/1000采样,对请求异常的调用链(错误、超时)进行了全保留,虽然也能满足大多数需求,但业务同学还是反馈了一些使用上的痛点:

  • 关键请求的调用链没采样到,丢失了证据;
  • 高峰期调用链偶尔存在较大延迟(10+min以上),线上排查问题效率低;
  • 调用链保存时间太短,事后排查问题困难;
  • 线下、线上调用链未分离,导致高峰期线上延迟,影响线下调用链的实时性,测试同学经常反馈线下调用链延迟太高或查不到。

针对这些问题,我们也做了一些弥补工作:

  • 自适应采样,保证低流量接口也能查到调用链;
  • 自定义采样,用户可以配置指定服务、接口、用户等关键信息的20分钟全保留策略。

除了用户体验不好,我们的调用链系统也存在一些性能问题:

  • 由于采用尾部采样策略导致机器资源消耗过大,实时性降低。这种采样策略是在请求处理完成时决定整条链路是否保留。客户端会将所有 Span 都上报到调用链追踪系统后台,后台根据一定预设的规则,决定哪些链路被保留。在我们的Flink流处理中,设立一个2min的时间窗口等待完整的调用链,然后判断该条调用链是否保留,因此需要大量的内存资源存储2min的调用链数据,这不仅耗费机器资源,系统稳定性也受影响;
  • Elasticsearch存储调用链数据同样对资源消耗过大,而且写入性能不高,导致高峰期写入出现瓶颈,写入能力下降,最终延迟现象频现。

因此我们不得不重新思考该如何彻底解决调用链的采样、延迟、系统不稳定问题,这就是我们采用ClickHouse来进行调用链全保留的背景。

改造效果

使用ClickHouse来进行调用链全保留以后,也稳定运行了半年,总结了下最终的改造效果:

  • 调用链系统更稳定
  • 降本增效,ClickHouse集群替代了Elasticsearch集群,使用了原来50%的机器资源完成了100%的调用链数据存储
  • 调用链延迟从高峰期2~10分钟降低到1分钟左右,即数据从产生到可查的延迟在1分钟左右。
  • 调用链查询平均响应时间500ms以内,P999响应时间在7s,查询整条调用链的查询性能相较Elasticsearch较低,但是在可容忍范围内
  • ClickHouse集群单节点写入性能200w条/s

实现方案

酷家乐调用链系统架构图

旧版架构图如下:

通过Flink的2min窗口等待完整的调用链实现尾部采样,然后写入Elasticsearch集群,因此该方案延迟至少2min,并且存在上文提到的性能和用户体验问题。

新版架构图如下:

由于我们使用ClickHouse替代了Elasticsearch,那么采样的逻辑自然就移除掉,同时也省去了2min窗口消耗的巨大资源。

为什么选择ClickHouse

  • 调用链的Span数据是结构化的,适合大宽表场景
  • 调用链数据主表是单表,不涉及Join查询
  • 调研发现ClickHouse拥有不错的单表写入性能和查询性能
  • 调用链数据写多读少

调用链数据冷热存储

不管是调用链还是日志数据,历史数据的价值总比实时数据低,因此我们没必要对全量数据进行长时间存储,于是采用冷热存储的策略,对于实时数据进行全保留,存储周期根据数据量和集群磁盘容量来决定,数据量小可以存几天或者一周,数据量大可以存几小时。对于历史数据,比如30天内的数据,我们就需要进行采样存储:

  • 调用链路上出现Error
  • 请求耗时>=1s
  • 慢Sql请求
  • 自定义保留策略
  • 自适应采样机制
  • 用户查询过即保留
  • 测试环境全保留

实时消费进行调用链数据全保留

全保留的调用链数据是实时消费Kafka的。主要工作是将解析调用链原文日志,直接写入ClickHouse热数据表(全量表),其次进行简单的采样逻辑处理,即判断TraceId是否采样,可以快速判断每个Span是否发生Error,耗时是否超过阈值,是否慢Sql,是否满足保留等等策略,来决定该Span对应的TraceID是否需要保留,如果需要保留,那么将TraceID单独存入ClickHouse的另一张表「SampledTraceId」中。该逻辑对实时流来说性能影响不大。

延迟消费实现调用链采样

调用链采样流程是延迟处理的,并且同样采用尾部采样的策略,延迟消费流中,需要从「SampledTraceID」表中拉取需要采样的TraceID列表存入内存中,为了节约内存,可以使用Bloom Filter,虽然Bloom Filter有假阳性,但是对我们这个场景是可容忍的。采样流程会延迟30~60分钟消费调用链原文日志,再次完成解析。延迟消费的时间可长可短,毕竟实时数据我们有全量表,采样表只是存储采样后的历史数据,它的处理速度不会直接影响用户使用。

ClickHouse数据表和索引设计

涉及到的数据表:

集群 数据库 表名 表引擎 作用
trace_cluster monitor trace_span ReplicatedMergeTree 调用链Span数据表,存储分散的span数据
trace_cluster monitor trace_span_merge Merge Merge引擎不是MergeTree,它本身不存储数据,主要用于同时从任意多个trace_span表中并行读取数据。后文介绍其作用。
trace_cluster monitor trace_span_all Distributed Distributed引擎,即分布式引擎,主要用于分布式场景,可以在多个服务器上进行分布式并行查询。主要是借助每个服务器上的trace_span_merge表进行查询。
trace_cluster monitor mview_trace_time ReplicatedMergeTree 记录调用链TraceID、时间、服务信息。可以根据TraceID在monitor.mv_trace_time表中查询数服务列表和时间段(最小时间和最大时间),来加快调用链查询
trace_cluster monitor mview_trace_time_merge Merge Merge引擎不是MergeTree,它本身不存储数据,主要用于同时从任意多个mview_trace_time表中并行读取数据。后文介绍其作用。
trace_cluster monitor mview_trace_time_all Distributed Distributed引擎,即分布式引擎,主要用于分布式场景,可以在多个服务器上进行分布式并行查询。主要是借助每个服务器上的mview_trace_time_merge表进行查询。
trace_cluster monitor trace_time_mview - 物化视图:将Span的TraceID、时间和服务信息写入mview_trace_time表

Span数据表设计要点和经验

  • Span数据表存储的是调用链的span数据,是结构化的数据结构,因此我们除了通用的span字段需要创建,还可以根据业务需求,添加更多的业务信息,比如用户ID、订单ID等,利用ClickHouse大宽表的优势,尽量存储更多的信息,方便通过业务信息进行TraceID的过滤查询
  • 建议数据类型尽量保持"轻量级",如String,Int64等,不适合添加过多的像LowCardinality之类的类型,虽然对存储效率和查询效率会有所帮助,但是会对写入性能造成影响,由于是调用链全保留,因此优先考虑写入性能,多耗费一点存储,以及查询稍慢一点是不影响的
  • 排序索引要结合业务需求来考虑,如果直接根据TraceID进行排序,虽然能保证同一个TraceID的Span数据存储在一起,查询性能提高,但是如果产品上是先根据服务信息或其他业务信息先查询出所有Span,再根据Span的TraceID查询调用链,可能查询Span列表这个前置操作性能就不能得到保障(数据比较分散,ClickHouse查询时需要拉取更多的数据颗粒Granularity),当然这只是个例子,具体需要安装实际情况进行分析和验证
  • 根据实际查询需求,对经验用于过滤条件的字段创建二级索引(跳数索引),可以协助ClickHouse过滤掉更多的数据颗粒,减少数据颗粒的拉取个数
  • 不建议使用TTL进行过期清除,对于过期的Span,我们可以按分区的粒度一次性删除,虽然很粗暴,但是相比TTL更简单直接
  • 存储策略(storage_policy)配置尽量优先写入SSD,再迁移至HDD盘,因为实时数据是巨大的,我们尝试过轮流写入2块HDD盘,但是集群不稳定,写入性能还是跟不上,最后还是优先写入SSD,再定期迁移至HDD存储
  • 不一定别人的建议就是最优解,一定要多验证

Span数据表设计案例

Span信息表就是存储调用链数据的表,调用链是由多个Span组成,因此我们不是存储一条完整的调用链记录,而是将分散的Span结构化以后直接入库,再通过TraceID将同一条调用链上的Span查出来。

sql 复制代码
CREATE database IF NOT EXISTS monitor ON CLUSTER trace_cluster;

CREATE TABLE monitor.trace_span ON CLUSTER trace_cluster (
  `trace_id` String, // 调用链TraceID
  `span_id` String, // SpanID
  `parent_span_id` String, // 父SpanID
  `span_name` String, // Span名称
  `span_type` String, // Span类别
  `start_time` DateTime64(3, 'Asia/Shanghai'), // Span开始时间
  `end_time` DateTime64(3, 'Asia/Shanghai'), // Span结束时间
  `duration` Int64, // Span耗时
  `is_error` Int8, // 是否有Error
  `service` String, // 服务名称
  `cluster` String, // 环境名称
  `annotations` Map(String, String), // 附加信息
  `additional_annotations` Map(String, String), // 其他附加信息
  `timestamp` DateTime64(3, 'Asia/Shanghai'),
	// ...其他字段

  INDEX idx_duration duration TYPE minmax GRANULARITY 2,
  INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 2,
  INDEX idx_trace_id_set trace_id TYPE set(8192) GRANULARITY 1
) ENGINE = ReplicatedMergeTree(
  '/clickhouse/tables/{shard}/trace-span',
  '{replica}'
) PARTITION BY toYYYYMMDD(timestamp)
ORDER BY
  (service, cluster, span_type, timestamp) SETTINGS storage_policy = 'hot_to_cold',
  index_granularity = 8192

建表说明

  • 分区(PARTITION BY):toYYYYMMDD(start_time) 。 根据span创建时间按天分区。如果全量保存5天,那就是5个分区。

  • 排序索引/主键稀疏索引(ORDER BY):service, cluster, span_type, timestamp。 考虑到酷家乐在查询Span数据时过滤条件都包括:时间范围timestamp、服务信息service、环境信息cluster、Span类型span_type,因此按照这几个字段进行Group By,service字段在前面是为了同一个服务的span尽量都在同一个颗粒(Granularity)中,查询时减少颗粒扫描范围,加快查询速度。

  • 二级索引(跳数索引):

    • duration。由于耗时总是作为查询条件,因此考虑加一个跳数索引,统计数据颗粒的最小+最大耗时,利于数据颗粒的过滤。
    • http_uri_pattern。由于http_uri_pattern是常用的查询过滤条件,因此可以使用set优化查询。
    • user_id。由于userId没有在排序索引中,但是存在通过userId查询场景,因此可以使用Bloom Filter优化查询。
    • trace_id。在根据trace_id查询整条调用链的span时,需要通过trace_id来加速查询。

其他说明

  • 为什么没有使用低基数(LowCardinality):ClickHouse对于少于10,000个不同值的字段(实际上可以达到10w级别),可以使用LowCardinality数据类型,能显著提高SELECT的性能和存储效率(底层使用倒排索引进行优化存储和查询)。虽然对存储和查询有性能提高,但会影响写入性能,因此不考虑使用。
  • 为什么没有设置过期机制:此处没有使用自动删除的机制,例如:TTL toDateTime(start_time) + toIntervalDay(5) 。因为按天分区了,我们只需要起一个定时任务,定期一次性删除过期的分区即可,效率比较高。

建表Q&A

Q: 通过Span数据表的设计发现,它的Order By和分区都和TraceID没关系,都是为了加速Span的查询,但是我们查询调用链不只是查询Span,还会根据TraceID去查询整条调用链,我们知道一条调用链的Span是跨多个服务的,Span是相对比较分散的,那我们根据TraceID去查询的时候效率能得到保证吗?

A: 答案肯定是不行的,Span数据表这样设计也是无奈之举,因为在实际使用时,都是先查出Span列表,再根据Span列表的TraceID去拉取整条调用链,因此我们也要保证Span列表的查询效率。那至于如何优化根据TraceID查询整条调用链的所有Span,可以通过辅助表记录每个TraceID的时间范围和service集合,从而缩小查询范围。这点我们后文介绍。

物化视图MATERIALIZED VIEW的最佳实践

对于一条调用链的查询(根据TraceID查询场景),需要快速定位TraceID所属的分区、数据颗粒。前期我们尝试创建了 mview_trace_time 物化视图,保存了trace_id、service、timestamp,建表语句:

sql 复制代码
CREATE MATERIALIZED VIEW IF NOT EXISTS monitor.mview_trace_time ON CLUSTER trace_cluster 
ENGINE = ReplicatedMergeTree(
  '/clickhouse/tables/{shard}/mview_trace_time',
  '{replica}'
) PARTITION BY toYYYYMMDD(timestamp)
ORDER BY
  (trace_id) 
SETTINGS storage_policy = 'hot_and_cold',index_granularity = 8192 
AS SELECT
  trace_id,
  service,
  timestamp
FROM
  monitor.trace_span

在查询调用链时,先根据TraceID在物化视图表中查询出所在的时间范围和服务列表,再根据 时间范围+TraceID+服务列表 去主表Span数据表查询,这样能加快查询效率,但是后面发现物化视图查询性能没有想象中高,尝试通过添加二级索引发现物化视图不支持添加二级索引。

于是在github上提了一个issue,作者回复了一种方案:就是单独创建一个表存储物化视图的数据(目标表),再创建MV往目标表里写数据,就可以在目标表上添加二级索引。

目标表建表语句:

sql 复制代码
CREATE TABLE IF NOT EXISTS monitor.mv_trace_time ON CLUSTER trace_cluster (
  `trace_id` String,
  `service` String,
  `timestamp` DateTime64(3, 'Asia/Shanghai'),
  INDEX idx_trace_id trace_id TYPE
  set(9000) GRANULARITY 1
) ENGINE = ReplicatedMergeTree(
  '/clickhouse/tables/{shard}/mv_trace_time',
  '{replica}'
) PARTITION BY toYYYYMMDD(timestamp)
ORDER BY
  (trace_id, service) SETTINGS storage_policy = 'hot_to_cold',
  index_granularity = 8192

由于ORDER BY已经有索引作用,因此不再添加其他二级索引,

然后创建MV往目标表里写数据:

sql 复制代码
CREATE MATERIALIZED VIEW monitor.trace_time_mview ON CLUSTER trace_cluster 
TO monitor.mv_trace_time (
  `trace_id` String,
  `service` String,
  `timestamp` DateTime64(3, 'Asia/Shanghai')
) AS
SELECT
  trace_id,
  service,
  timestamp
FROM
  monitor.trace_span

这样一来,Span数据往monitor.trace_span表中写数据时,就会自动往物化视图的目标表里写入trace_id、service、timestamp。那么,我们在根据TraceID查询调用链前,可以先根据TraceID在monitor.mv_trace_time表中查询数service列表和时间段(最小时间和最大时间),通过这两个字段足以缩小颗粒的扫描范围,加快查询速度。

Merge表设计

对于存储数据的表,它还只是一个本地表,我们登录到其中一台服务器上查询monitor.trace_span表的数据时,返回的只是当前一个服务器节点的数据,并没有其他服务器节点的数据,当然Merge表的作用也不是为了进行分布式查询,它是作为分布式查询的桥梁。

我们通过一个场景来说明Merge表的妙用。

在平时运维过程中,集群并不是一直稳定的,可能会因为各种原因导致monitor.trace_span表无法写入数据,出现ReadOnly模式,为了尽快修复,我们一般会将monitor.trace_span表改为monitor.trace_span_20231024,然后新创建一个monitor.trace_span表,那么新数据就照样往monitor.trace_span中写,旧数据就存在于monitor.trace_span_20231024表中(可能不止这一个版本)。那我们怎么让集群能查到monitor.trace_span_20231024中的数据呢,我们可以创建这样一个Merge表:

sql 复制代码
CREATE TABLE IF NOT EXISTS monitor.trace_span_merge ON CLUSTER trace_cluster 
AS monitor.trace_span 
ENGINE = Merge("monitor", '^trace_span($|(.*\d$))')

monitor.trace_span_merge不存任何数据,但是他可以代理查询所有monitor.trace_span_xxx的本地表,相当于在单个服务器上,将该服务器上所有版本的monitor.trace_span表的数据查询返回。

Distributed表设计

Merge表只是在单个服务器上发挥作用,在集群模式下,进行分布式查询时,就需要用到Distributed表,它的作用就是为了查询所有服务器上的数据,因此最好是通过统一的表名去查询(比如Merge表,因为只会有一个版本),因此我们可以创建对应的分布式引擎表:

sql 复制代码
CREATE TABLE IF NOT EXISTS monitor.trace_span_all ON CLUSTER trace_cluster
AS monitor.trace_span_merge 
ENGINE = Distributed(trace_cluster, monitor, trace_span_merge)

那么集群就可以通过trace_span_all表去分布式查询所有服务器上的数据,查询每台服务器上的数据时,通过Merge表trace_span_merge去查询多个版本的monitor.trace_span数据表。

ClickHouse冷热数据迁移

为了保证写入性能,实时的调用链数据都往固态硬盘SSD写,然后在低峰期(夜晚)起定时任务将SSD上的热数据迁移到机械硬盘HDD上。

1.先查出数据表的所有分区

sql 复制代码
SELECT table, partition
FROM system.parts
WHERE database = 'monitor'
GROUP BY (table,partition)
ORDER BY (table,partition) ASC

查询结果:

2.将每个分区SSD上的数据迁移到HDD

sql 复制代码
ALTER TABLE monitor.trace_span MOVE PART '20231224' TO VOLUME 'cold_volume'

ClickHouse过期数据删除

全保留如果只保留5天,那么就需要把5天前的所有partition删除,因为我们按天分区,可以一次性删除一个partition即一天的数据。

sql 复制代码
ALTER TABLE monitor.trace_span DROP PARTITION '20231224'

ClickHouse Sink的优化

关于clickhouse-sink,我们选择的是 github.com/ivi-ru/flin...,并在这基础上做了一个改进和优化:

  • 新增自适应屏蔽异常主机节点机制
  • 新增集群节点的写入权重配置,不同的机器设置不同的写入比例,解决不同机器性能差异问题
  • 支持配置数据写入多个ClickHouse集群

写入时,每次批量写入Span数据。

总结

本文介绍了酷家乐监控团队使用ClickHouse进行调用链全保留的最终实践方案,相比Elasticsearch,ClickHouse可以无压力全量写入,即使面对流量上涨,也可以添加服务器进行集群的快速扩容,虽然查询的性能相比Elasticsearch可能较慢,但是也能满足用户的需求。在灰发期间,我们也遇到了各种问题,经过不断的调整、优化,积累了运维经验,整理出我们最终的实践方案,笔者水平有限,如有遗漏错误之处,欢迎大家留言讨论。

参考文献

1\][【链路追踪】采样那些事儿](https://link.juejin.cn?target=https%3A%2F%2Fcloud.tencent.com%2Fdeveloper%2Farticle%2F1953839 "https://cloud.tencent.com/developer/article/1953839") \[2\] [得物云原生全链路追踪Trace2.0架构实践](https://link.juejin.cn?target=https%3A%2F%2Fzhuanlan.zhihu.com%2Fp%2F562084589 "https://zhuanlan.zhihu.com/p/562084589") \[3\] [Skywalking 使用 ClickHouse 存储实践,性能提高 N 倍](https://link.juejin.cn?target=https%3A%2F%2Fsegmentfault.com%2Fa%2F1190000041592257 "https://segmentfault.com/a/1190000041592257") \[4\] [clickhouse.com/](https://link.juejin.cn?target=https%3A%2F%2Fcf.qunhequnhe.com%2Fpages%2Fcreatepage.action%3FspaceKey%3D~huoshan%26title%3D5%26linkCreation%3Dtrue%26fromPageId%3D80832879099 "https://cf.qunhequnhe.com/pages/createpage.action?spaceKey=~huoshan&title=5&linkCreation=true&fromPageId=80832879099") \[5\] [doris.apache.org/](https://link.juejin.cn?target=https%3A%2F%2Fdoris.apache.org%2F "https://doris.apache.org/") \[6\] [dbaplus.cn/news-73-415...](https://link.juejin.cn?target=https%3A%2F%2Fdbaplus.cn%2Fnews-73-4152-1.html "https://dbaplus.cn/news-73-4152-1.html")

相关推荐
柏油5 小时前
MySQL InnoDB 行锁
数据库·后端·mysql
咖啡调调。5 小时前
使用Django框架表单
后端·python·django
白泽talk5 小时前
2个小时1w字| React & Golang 全栈微服务实战
前端·后端·微服务
摆烂工程师5 小时前
全网最详细的5分钟快速申请一个国际 “edu教育邮箱” 的保姆级教程!
前端·后端·程序员
一只叫煤球的猫5 小时前
你真的会用 return 吗?—— 11个值得借鉴的 return 写法
java·后端·代码规范
Asthenia04126 小时前
HTTP调用超时与重试问题分析
后端
颇有几分姿色6 小时前
Spring Boot 读取配置文件的几种方式
java·spring boot·后端
AntBlack6 小时前
别说了别说了 ,Trae 已经在不停优化迭代了
前端·人工智能·后端
@淡 定6 小时前
Spring Boot 的配置加载顺序
java·spring boot·后端