本期作者
束家麒
基础架构部资深开发工程师
凌涛
基础架构部资深开发工程师
01 背景介绍
日志作为线上定位问题排障的重要手段,在可观测领域有着不可替代的作用。稳定性、成本、易用性、可扩展性都是日志系统需要追求的关键点。
B站基于Elastic Stack的日志系统(Billions) 从2017建设以来, 已经服务了超过5年,目前规模超过500台机器,每日写入日志量超过700TB。
ELK体系是业界最常用的日志技术栈,在传输上以结合规范key的JSON作为传输格式,易于多种语言实现和解析,并支持动态结构化字段。存储上ElasticSearch支持全文检索,能够快速从杂乱的日志信息中搜寻到关键字。展示上Kibana具有美观、易用等特性。
随着业务系统的高速发展,日志系统的规模也随之快速扩展,我们遇到了一系列的问题,同时可观测业界随着OpenTelemetry规范的成熟,推动着我们重新考量,迈入下一代日志系统。
02 遇到的问题
03 新架构体系
针对上述的一系列问题,我们设计了Bilibili日志服务2.0的体系,主要的进化为使用ClickHouse作为存储,实现了自研的日志可视化分析平台,并使用OpenTelemetry作为统一日志上报协议。
如图所示为日志实时上报和使用的全链路。日志从产生到消费会经过采集→摄入 →存储 →分析四个步骤,分别对应我们在链路上的各个组件,先做个简单的介绍:
日志采集器,以Agent部署方式部署在物理机上,通过Domain Socket接收OTEL协议日志,同时进行低延迟文件日志采集,包括容器环境下的采集。支持多种Format和一定的加工能力,如解析和切分等。
负责从日志 kafka 订阅日志数据, 然后将日志数据按时间维度和元数据维度(如AppID) 拆分,并进行多队列聚合, 分别攒批写入ClickHouse中.
我们使用的日志存储方案,在ClickHouse高压缩率列式存储的基础上,配合隐式列实现了动态Schema以获得更强大的查询性能,在结构化日志场景如猛虎添翼。
日志查询模块,负责对日志查询进行路由、负载均衡、缓存和限流,以及提供查询语法简化。
新一代日志的可视化分析平台,提供一站式的日志检索、查询和分析,追求日志场景的高易用性,让每个研发0学习成本无障碍使用。
下边我们将针对几个重点进行详细设计阐述。
3.1 基于ClickHouse的日志存储
新方案的最核心的部分就是我们将日志的通用存储换成了ClickHouse。
先说结果,我们在用户只需要付出微小迁移成本的条件下( 转过来使用SQL语法进行查询),达到了10倍的写入吞吐性能,并以原先日志系统 1/3的成本,存储了同等规模量的日志。在查询性能上,结构化字段的查询性能提升2倍, 99%的查询能够在3秒内完成。
下图为同一份日志在Elasticsearch, ClickHouse和ClickHouse(zstd)中的容量, 最终对比ES达到了 1:6。
ClickHouse方案里另一个最大的提升是写入性能,ClickHouse的写入性能达到了ES的10倍以上。
在通用结构化日志场景,用户往往是使用动态Schema的,所以我们引入了隐式列Map类型来存储动态字段, 以同时获得动态性和高查询性能,稍后将会重点介绍隐式列的实现。
我们的表设计如下,我们针对每个日志组,都建立了一张复制表。表中的字段分为公共字段(即OTEL规范的Resource字段, 以及trace_id和span_id等),以及隐式列字段,string_map,number_map,bool_map 分别对应字符串字段,数字字段和布尔字段。我们对常用的日志值类型进行分组,使用这三个字段能满足大部分查询和写入的需求。
同时,我们根据日志重要程度和用户需求定义了不同时间范围的TTL。根据统计大部分(90%)的日志查询集中在4小时以内,我们为日志制定了三个阶段的日志生命周期,Hot,Warm和Cold.Hot阶段,所有日志在高速存储中,保证高写入和检索性能,通常我们的资源保证24小时日志数据会在此阶段中;Warm阶段,日志迁移到Sata盘中,同样可以进行检索,通常需要更长时间;Cold阶段,在达到了第二个TTL时间后,在ClickHouse中删除腾出空间,如果有需求会在HDFS中有备份。
对于大部分的字段,我们都使用了ZSTD(1)的压缩模式,经过测试相比默认的Lz4提升了50%的压缩率,在写入和查询性能上的代价不超过5%,适合日志写多读少的场景。
3.2 查询网关
查询网关承担着查询入口的任务。我们主要在这个组件上集成了查询路由,查询负载均衡,简化查询语法,缓存,限流等功能。
这里先介绍下简化查询语法的功能。作为日志对前端以及对外的接口,我们的目标是对用户屏蔽一些底层复杂实现。如ClickHouse的Local表/分布式表,隐式列和公共字段的查询区别,以及对用户查询进行限制(强制Limit,强制时间范围)。
上图可见我们允许用户在编写查询SQL时,不需要关心字段是否为隐式列,也不需要关心目标表和集群,以最简单的方式通过Restful API与日志查询网关进行交互获取日志数据。这样的实现还有个目的就是为以后可能再一次的存储引擎迭代做铺垫,将日志查询与底层实现解耦。
此外,在查询网关上还集成了Luence语法的解析器,将其自动转化为SQL语法,为用户的API迁移提供便利。
3.3 可视化分析平台
在升级日志架构的过程中,很多用户反应希望能够延续之前的日志使用方式,尽量减少迁移学习的成本。我们考虑了让Kibana兼容ClickHouse作为存储引擎。然而由于我们同时维护中Kibana5和7两个版本,且越高版本的Kibana功能丰富,与Elastic Stack绑定过深,我们决定还是自研日志可视化查询分析平台。
我们的目标是:
紧记着上面两个关键目标,我们开始了自研之路。
Kibana作为非常成熟的日志分析界面,具有非常多的细节,都是在使用过程中沉积下来的功能。任何一个功能用户都有不低的使用频率。如图所示我们能实现了查询语句高亮和提示、字段分布分析、日志时间分布预览、查询高亮以及日志略缩展示等等。
用户在查询日志时, 使用标准SQL的Where condition部分, 如 log.level = ‘ERROR’ 进行日志过滤, 并对用户将隐式列和具体的表透明化(通过查询网关的能力)。
我们还使用code-mirror2实现了查询的自动补全和查询提示。提示的内容包括常用历史查询, 字段, 关键字和函数, 进一步降低用户的使用门槛。
在此基础之上,我们开发了能够领先于Kibana的使用体验的杀手锏,我们自研的可视化分析平台集成了快速分析能力。用户可以直接输入SQL聚合语句,即时对日志进行聚合分析。
同时我们可以将查询分析界面作为一个入口,打通相关信息和功能。如日志告警快速的快速配置、日志写入量统计和优化点、快速配置二级索引、快速跳转分布式追踪平台等。
这些都是为了能让原先使用Kibana的用户能够在无缝地切换到新平台上来使用的基础上,拥有更好的排障体验。
3.4 日志告警
除了在日志分析界面上人为进行日志查询排障外,日志监控规则也是常用且好用的快速感知系统问题的手段。
在日志服务2.0的版本中,日志告警服务在兼容了日志ClickHouse作为数据源的基础上,将计算模型进行了统一化,剥离了原先Elasticsearch场景的特有语义,使得计算和触发规则更灵活,配置更容易。
我们将一个日志告警规则定义为由以下几个属性组成:
目前B站系统中目前已配置超过5000+的日志告警,我们提供了告警迁移工具,能够自动通过原ES语法的规则,生成对应的2.0版本过滤规则,来帮助用户快速迁移。
3.5 OpenTelemetry Logging
原 OpenTracing1和 OpenCensus2项目已经合并入 OpenTelemetry 项目,从趋势和未来考虑,新项目不再推荐使用前述两个项目,建议直接使用 Opentelemtry 通用 api 收集可观测数据。
Opentelemetry3 是一套工具集,专注于可观测性领域的数据收集端,致力于可观测性 3 大领域 metrics,logs,traces 的通用api 规范以及支持编程语言的 sdk 实现。除了嵌入用户代码的 sdk ,Opentelemetry 也提供了可观测性数据收集时的收集器4实现,该收集器可作为 sidecar,daemonset,proxy 3种形式部署,支持多种可观测协议的收集和导出。
目前,OTEL Logging的协议已经处于stable,主要定义了以下的标准日志模型。
我们完整的按照OTEL的标准实现了Golang和Java的SDK,并在采集器(Log-Agent)集成了OTEL兼容层。
3.6 如何解决日志搜索问题
得益于ClickHouse的高压缩率和查询性能,小日志量的应用日志直接可以搜索即可。在大日志量场景,对于某种唯一id的搜索,使用tokenbf_v1建立二级索引,并引导用户使用hasToken)或通过上文描述的~`操作符进行搜索,跳过大部分的part,能获得不亚于ES的查询性能。对于某种日志模式的搜索,引导用户尽可能使用logger_name,或者source(代码行号)来进行搜索 同时尽量减少需要搜索的日志范围。在此基础上,我们还必须推进日志结构化。
一开始的日志往往是无结构化的,人可读的。随着微服务架构发展,日志作为可观测性的三大支柱,越来越需要关注日志的机器可读性。统一的日志平台也对日志的结构化有要求,来进行复杂聚合分析。
ClickHouse方案中,由于缺少倒排索引,对日志结构化程度的要求会更高。在推进业务迁于新方案时,我们也需要同步进行结构化日志的推进。
对于这样一段常见日志:
log.Info("report id=32 created by user 4253)"
结构化我们可以抽取report id和user id,作为日志属性独立输出,并定义该段日志的类型,如:
log.Infov(log.KVString("log_type","report_created"),log.KVInt("report_id",32), log.KVInt("user_id",4253))
结构化后对于整体存储和查询性能上都能获得提升,用户可以更方便的对user id或report id进行搜索或分析。
04 Clickhouse 功能增强与优化
在日志场景中,我们选用clickhouse作为底层的查询引擎,主要原因有两个:一个是clickhouse相较于ES具备更高的资源利用效率,在磁盘,内存方面的消耗都更低;另外一个就是Clichouse支持的丰富的数据和索引类型能够满足我们针对特定pattern的查询性能需求。
4.1 Clickhouse配置优化
因为日志的数据量比较大,在实际的接入过程中我们也是遇到了一些问题。
Too many parts
这个问题在clickhouse写入过程中还是比较常见的,根因是clickhouse merge的速度跟不上part的 生成速度。这个时候我们的优化方向可以从两个方面层面考虑,写入侧和clickhouse侧。
在日志的写入侧我们降低写入的频次,通过攒batch这种方式来降低clickhouse part的生成速度。缺点是这种攒批会导致数据的时延会增高,对于实时查询的体感较差。而且,如果写入只由batch大小控制,那么在数据断流的时候就会有一部分数据无法写入。因为日志场景下对数据查询的时效性要求比较高,所以我们除了设置batch大小还会有一个超时时间的配置项。当达到这个超时时间,即便batch大小没有达到指定大小,也会执行写入操作。
在写入侧配置了满足业务需求的参数配置之后,我们的part生成速度依然超过了merge的速度。所以我们在clickhouse这边也进行了merge相关的参数配置,主要目的是控制merge的消耗的资源,同时提升merge的速度。
Merge相关的参数,我们主要修改了以下几个:
这个参数是用来指定落盘的part格式。当part的大小超过了配置就会生成wide part,否则为compact part。日志属于写多读少的一个场景,通过增大这个参数,我们可以让频繁生成的part在落盘时多为compact part。因为compact part相较于wide part小文件的个数要更少一些,测试下来,我们发现相同的数据量,compact part的merge速度要优于wide part。相关的还有另一个参数min_rows_for_wide_part,作用跟min_bytes_for_wide_part相似。
当merge的线程资源比较紧张时,我们可以通过调整这个参数来配置可merge part的最大大小。默认大小是1M,我们将这个参数上调了。主要的原因是,我们在频繁写入的情况下,merge资源基本处于打满的状态。而写入的part大小基本也都超过了1M,此时这些part就不被merge,进而导致part数据不断变多,最终抛出Too many parts的问题。
这个参数是用来指定merge线程资源充足时可merge的最大part大小。通过调整这个参数,我们可以避免去merge一些较大的part。因为这些part的合并耗时可能是小时级别的,如果在这期间有较为频繁的数据写入,那就有可能会出现merge线程不够而导致的too many parts问题。
默认线程池大小为16,我们调整为32,可以有更多的线程资源参与merge。之所以没有继续上调这个参数,是因为较多的merge线程可能会导致系统的CPU和IO负载过高。
Zookeeper负载过高
Clickhouse的副本表需要频繁的读写clickhouse,熟悉clickhouse的用户都知道zookeeper集群是clickhouse集群的性能瓶颈之一。因为zookeeper的写压力是没法通过增加节点数得到缓解,所以当一个集群的写请求过于频繁时,zookeeper就可能成为我们写入失败的主要原因了。我们的日志系统为了能够在服务级别进行优化和数据TTL设置,就将每个接入的服务通过不同的表进行隔离。所以接入的服务越多,对应的表和分区就会越多。而分区和part过多,就会导致整个集群对zookeeper的读写请求频次增加,进而导致zookeeper集群负载过高。针对这个问题,社区已经有相关的PR解决了,那就是auxiliary zookeeper。这个特性让我们可以在一个clickhouse集群中配置多个zookeeper集群,配置好之后我们只需要在建分布式表时指定对应的zookeeper集群即可。这样,zookeeper写请求过于频繁的问题也能得到很好的解决了。
4.2 Clickhouse 动态类型 Map
Map类型作为数据库中一个重要的数据类型,能够很好地满足用户对于表动态schema的需求。对于后期可能会动态增减的字段,或者因为数据属性而不同的字段,我们可以将其抽象成一个或多个map存储,使用不同的k-v来存储这些动态字段。Clickhouse的在版本v21.1.2.15将Map类型作为一个实验特性增加到支持的数据类型之中。而Map类型在B站的日志系统中解决的就是日志系统中不同服务具有不同的特有字段问题。使用Map字段一方面可以统一不同服务的表结构,另一方面使用Map能够更好地预设表的schema,避免了后期因业务新增导致表结构频繁变更的问题, 降低了整个日志链路的复杂程度。
但是随着数据体量的增加和查询时间跨度的延伸,针对clickhouse原生map类型的查询和过滤效果越来越不如人意,虽然clickhouse目前支持的map类型在功能上能够满足我们的需求,但是性能上却依然有提升的空间。
4.3 Clickhouse Map实现原理
首先我们可以看看原生Map的实现。原生的Map是通过Array(Tupple(key, value))这种嵌套的数据结构来保存Map的数据。当我们读取某个指定key值的数据时,Clickhouse需要将指定的对应的ColumnKey和ColumnValue都读取到内存中进行反序列化。
假设原始表的数据如下表所示:
当我们需要查询k1对应的数据时,select map’k1’ from map_table,那么每一行除了key值为k1的数据对我们而言都是不需要的。但是,在Clickhouse反序列化时,这些数据都会被读取到内存之中,产生了不必要的计算和I/O。而且因为Clickhouse不支持Map类型的索引,这种读放大造成的损耗在数据体量很大的情况下对查询性能有很大的影响。
通过上面的例子,可以看出,原生Map对于我们而言主要有两个瓶颈:
4.4 Map 类型索引支持
针对上面的问题,我们的首次尝试是对Clickhouse索引进行加强,使其可以支持Map类型。因为我们的场景中key值多为String类型,所以我们优先改造了bloom filter相关的索引。在数据写入的时候我们会把每行数据的map都单独拎出来处理,获取到每一个key值,对key值创建索引。查询时,我们会解析出需要查询的key值,索引中不包含该值时则会跳过对应的granule,以达到尽量少读取不必要数据的目的。
通过上述方式,我们在执行select map’k1’ from map_table时,可以将未命中索引的granule都过滤掉,这样可以在一定程度上减少不必要的计算和I/O。
创建测试表:
CREATE TABLE bloom_filter_map( id UInt32, <span class="code-snippet__built_in" style="box-sizing: border-box;">map</span> Map(String, String), INDEX map_index map TYPE tokenbf_v1(128, 3, 0) GRANULARITY 1)ENGINE = MergeTreeORDER BY idSETTINGS index_granularity = 2
----插入数据 insert into bloom_filter_map values (1, {'k1':'v1', 'k2':'v2'});insert into bloom_filter_map values (2,{'k1':'v1_2','k3':'v3'});insert into bloom_filter_map values (3,{'k4':'v4','k5':'v5'});
----查询数据 select map['key1'] from bloom_filter_map;
通过query_id可以查看到只查询了两行数据,索引生效。
通过上述的实现在索引粒度设置合适的情况下我们能够有效地下推过滤掉部分非必要的数据。这部分的源码我们已经贡献到社区,相关PR5。
虽然支持了Map类型的索引能够过滤部分数据,但是当map的key分布地比较稀疏时,例如上例中的k1,如果在每个granule中都出现的话我们还是会读取很多无效的数据,同时,一个map中的我们不需要的kv我们还是没法跳过。为了解决这个问题,我们实现了Clickhouse Map类型的隐式列。
4.5 Map隐式列简介
隐式列就是在Map数据写入的时候,我们会把map中的每个key单独抽出来在底层作为一个Column存储。通过这种对用户透明的转换,我们可以在查询指定key时读取对应的column,通过这种方式来避免读取需要的kv。
通过这种转换,当我们再执行类似select map’k1’ from map_table这种SQL时,Clickhouse就只会去读取column_k1这个Column的数据,能够极大地避免读放大的情况。
考虑到已有的业务,我们并没有直接在原生的Map类型上进行改造,而是支持了一种新的数据类型MapV2,底层则是实现了Map到隐式列的转换。
4.6 Map隐式列实现
在数据写入的过程中,每个part对应的columns.txt文件,我们额外追加隐式列的信息。这样在加载part信息的时候,clichouse就可以通过loadColumns方法把隐式列的信息加载到part的元信息之中了。
4.7 查询测试
创建测试表:
---- 隐式列CREATE TABLE lt.implicit_map_local( id UInt32, <span class="code-snippet__built_in" style="box-sizing: border-box;">map</span> MapV2(String, String))ENGINE = MergeTreeORDER BY idSETTINGS index_granularity = 8192; ---- 原始map类型 CREATE TABLE lt.normal_map_local( id UInt32, <span class="code-snippet__built_in" style="box-sizing: border-box;">map</span> Map(String, String))ENGINE = MergeTreeORDER BY idSETTINGS index_granularity = 8192;
测试数据通过jdbc写入,自动生成10000000条数据,数据完全相同。
测试简单的指定key值查询。
此次测试中Map类型隐式列对于指定key值的查询场景有显著的性能提升。
Map类型是我们目前解决动态schame最优的选择,原生Map类型的读放大问题在通过我们的优化之后能够一定缓解。而隐式列的实现则让我们可以像对待普通列一样,极大地避免了读取非必要的map数据。同时,我们后面也加上了对隐式列的二级索引支持,进一步增加了优化读取的手段。
当然,隐式列也有一些不适用的场景。
因为Map被拆成了多个column存储,所以无法支持select *或者select map这样的查询。当然我们也进行过尝试。主要实现过两个方案:其一是冗余存储一份Map数据,按照原来的格式查询,当需要select整个map字段时我们就按原来的流程读取。这样查询性能跟原生的Map一样;还有一个方案就是在查询时读取Map对应的所有隐式列,然后实时生成Map返回,这种方案经过测试之后发现性能太差,最终也被放弃。
通过以上几个部分的讲解,我们知道clickhouse的隐式列实际上就是把map中的key拆出来作为单独的columns存储。在这个情况下,如果用户的map字段中的key值基数过高就会导致底层存储的列过多。针对这种情况,我们是增加了一个max_implicit_columns的MergeTree参数,通过这个参数我们可以控制隐式列的个数,当超过阈值时会直接返回错误信息给用户。在我们的日志系统的使用实践手册中我们会引导用户按照规范创建key值,避免key的基数过高的情况出现。如果有不按照约束写入的用户,max_implicit_columns这个参数也会对其进行限制。
针对于第一个问题,我们也是实现了一套退而求其次的方案,那就是通过select每一个隐式列来实现。为此,我们会将每个part的隐式列都写到系统表system.parts中。当用户不知道有哪些key值时则可以通过查询系统表获取。
日志场景下,在功能上隐式列能够满足其对于动态schema的需求。同时,在查询时,用户一般都会勾选特定的字段。而在这种指定字段的查询上,隐式列的查询性能够做到与普通列保持一致。而且,因为隐式列能够像普通列一样配置二级索引,对于一些对性能要求更加极致的场景,我们的优化手段也要更加的灵活一些。
05 下一步的工作
5.1 日志模式提取
目前虽然我们通过日志最佳实践和日志规范引导用户尽可能继续结构化日志的输出,但是仍然不可避免部分场景难以进行结构化,用户会将大段文本输入到日志中,在分析和查询上都有一定受限。我们计划实现日志模式并使用在查询交互,日志压缩, 后置结构化,异常模式检测这些场景上。
5.2 结合湖仓一体
在湖仓一体日益成熟的背景下,日志入湖会带来以下收益:
此外,我们长远期探索减少日志上报的中间环节,如从agent直接到ClickHouse,去掉中间的Kafka,以及更深度的结合ClickHouse和湖仓一体,打通ClickHouse和iceberg。
5.3 Clickhouse全文检索
参考链接:
4 https://opentelemetry.io/docs/collector/
5 https://github.com/ClickHouse/ClickHouse/pull/28511
本文为从大数据到人工智能博主「xiaozhch5」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。