酷家乐基于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]【链路追踪】采样那些事儿

[2] 得物云原生全链路追踪Trace2.0架构实践

[3] Skywalking 使用 ClickHouse 存储实践,性能提高 N 倍

[4] clickhouse.com/

[5] doris.apache.org/

[6] dbaplus.cn/news-73-415...

相关推荐
.生产的驴几秒前
Electron Vue框架环境搭建 Vue3环境搭建
java·前端·vue.js·spring boot·后端·electron·ecmascript
爱学的小涛8 分钟前
【NIO基础】基于 NIO 中的组件实现对文件的操作(文件编程),FileChannel 详解
java·开发语言·笔记·后端·nio
爱学的小涛9 分钟前
【NIO基础】NIO(非阻塞 I/O)和 IO(传统 I/O)的区别,以及 NIO 的三大组件详解
java·开发语言·笔记·后端·nio
北极无雪14 分钟前
Spring源码学习:SpringMVC(4)DispatcherServlet请求入口分析
java·开发语言·后端·学习·spring
爱码少年19 分钟前
springboot工程中使用tcp协议
spring boot·后端·tcp/ip
2401_857622668 小时前
SpringBoot框架下校园资料库的构建与优化
spring boot·后端·php
2402_857589368 小时前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端
哎呦没9 小时前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch10 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
杨哥带你写代码11 小时前
足球青训俱乐部管理:Spring Boot技术驱动
java·spring boot·后端