改造背景
在分布式系统中,前端发起的一次请求可能经过了多个微服务、多台机器、多个中间件,这使得排查问题变得艰难,调用链通常是帮助开发同学快速定位、排查线上问题的重要手段。酷家乐通过自研的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]【链路追踪】采样那些事儿
[3] Skywalking 使用 ClickHouse 存储实践,性能提高 N 倍
[4] clickhouse.com/