1、背景
唯品会日志系统dragonfly 1.0是基于EFK构建,于2014年服务至今已长达7年,支持物理机日志采集,容器日志采集,特殊分类日志综合采集等,大大方便了全公司日志的存储和查询。
随着公司的业务发展,日志应用场景逐渐遇到了一些瓶颈,主要表现在应用数量和打印的日志越来越多,开发需要打印更多日志,定位业务问题,做出运营数据分析;另外外部攻击问题和审计要求,需要更多安全相关的日志数据要上报并且能够提供半年以上的保存时长,以应对潜在的攻击和攻击发生时调查原因和受影响面。ELK的架构的缺点显现,ES集群规模达260台机器,需要的硬件和维护成本高达千万,如果通过扩容的方法去满足上述业务场景,ES集群会太大会变动不稳定,创建独立集群,也需要更高成本,两者都会使得成本和维护工作量剧增。
鉴于这些问题,去年六月份我们开始探索新的日志系统架构,以彻底解决上面的问题。
2、日志系统演进之路
标准日志格式
规范标准日志格式,有利于正确的识别出日志关键元信息,以满足查询,告警和聚合计算的需求。从以上格式日志,通过filebeat转换后的结果如下:
时间戳,日志级别,线程名,类名,eventName,和自定义字段将被日志采集Agent解析后和其他元数据如域名,容器名或主机名一起以JSON格式上报。
自定义字段是开发人员根据业务需要打印到日志,主要支持功能:
①查询时支持各种聚合分析场景
②根据自定义字段进行聚合函数告警。
ES存储方案问题
ES日志存储模型
EFK日志存储在elasticsearch,每个域的日志以天粒度在ES创建一个索引,索引大小是根据前几日数据大小计算得出,每个索引分片大小不超过30G,日志量越多的域分片越多。如果一个域的日志量写入过大或超长,将会占用ES节点大量CPU来做解析和segment合并,这会影响其他域日志的正常写入,导致整体写入吞吐下降。排查是哪个域的哪个分片日志过大通常较为困难,在面对这种热点问题时经常要花很长时间。我们ES版本使用的是5.5,还不支持索引自动删除和冷热迁移,有几个脚本每日定时执行,完成删除索引,关闭索引,移动冷索引,创建新索引的任务,其中移动索引和创建新索引都是耗时非常长的操作。整个生命周期每天循环执行,如果突然一天某个步骤执行失败,或者执行时间太长,会导致整个生命周期拉长甚至无法完成,第二天的新数据写入将受到严重影响,甚至无法写入。另外ES的倒排索引需要对日志进行分词,产生的索引文件较大,占用了大量磁盘空间。
不过ES也有其优点,基于倒排索引的特性使得ES查询时,1个分片只需要一个核即可完成查询,因为查询速度通常较快,QPS较高。下面是在大规模(或海量)日志存储场景下ES的主要存储优点和缺点:
日志系统2.0方案
选择clickhouse的原因
2019年我们尝试了另外一种HDFS存储方案,把每个域的数据按照域名+toYYDDMMHH(timestamp)+host作为键在客户端缓存,当大小或过期时间到了之后,提交到HDFS生成一个独立的文件,存储路径包含了域,主机和时间信息,搜索时即可根据这几个标签过滤,这种存储方式有点类似loki,它的缺点显而易见,优点是吞吐和压缩率都非常高,可以解决我们吞吐和压缩率不足的问题。如果基于此方案继续增强功能,如添加标签,简单的跳数索引,查询函数,多节点并发查询,多字段存储,需要开发的工作量和难度都非常大。我们对比了业界前沿使用的一些存储方案,最终选择了clickhouse,他的批量写入和列式存储方案完全满足我们的要求(基于HDFS存储),另外还提供了占用磁盘空间非常小的主键索引和跳数索引,相比ES的全文索引,优势明显。
将近26G的应用日志分别使用clickhouse的lz4,zstd和ES的lz4压缩算法对比
实际生产环境中zstd的日志压缩比更高,这和应用日志的相似度有关,最大达到15.8。
Clickhouse压缩率这么高,但没有索引,其查询速度如何?虽然没有索引,但其向量执行和SIMD配合多核CPU,可以大大缓解没有全文索引的缺点。经过多次测试对比后,其查询速度在绝大多数场景下和ES不相上下,在部分场景下甚至比ES还要快。
下图是实际生产环境的数千个应用真实运行数据,查询24小时时间范围内日志和24小时以上时间范围日志的耗时对比
通过对日志的应用场景分析,我们发现万亿级别的日志,真正能被查询的日志数量是非常非常少的,这意味着ES对所有日志的分词索引,大多数是无效的,日志越多,这个分词消耗的资源越浪费。相对比clickhouse的MergeTree引擎专一的多,主要资源消耗是日志排序压缩和存储。
另外Clickhouse的MPP架构使得集群非常稳定,几乎不要太多运维工作。下面以一幅图综合对比ES和Clickhouse的优缺点,说明为什么我们选择将clickhouse作为下一代日志存储数据库。
3、技术详解
EFK架构发展这么多年体系要成熟得多,ES默认参数和倒排索引使得你不需要对ES有太多了解即可轻松使用,开源kibana又提供丰富的查询界面和图形面板,对于日志量不大的场景来讲,EFK架构仍然是首选。Clickhouse是近几年OLAP领域比较热门的数据库,其成熟度和生态仍在快速发展中,用来存储日志的开源方案不是很多,要用好它不但需要对Clickhouse有深入的了解,还需要做很多开发工作。
3.1 日志摄入 - vfilebeat
起初dragonfly使用logstash来做日志采集,但logstash的配置较复杂并且无法支持配置文件下发,不便于容器环境下的日志采集,当时另一个使用GO语言开发的采集工具vfilebeat在性能和扩展性方面较好,我们在此基础上做了定制开发自己的日志采集组件vfilebeat。
vfilebeat运行在宿主机上,启动时可以通过参数指定采集的宿主机日志所属的域,如果没有指定,则读取安装时CMDB配置文件的域名和主机名,宿主机采集的每条日志均带上域名和主机名作为标签。
容器环境下vfilebeat还会监听容器的创建和销毁,当容器创建时,读取容器的POD信息获取到域名和主机名,然后从ETCD拉取到域的日志采集路径等配置参数,按照域名和POD名称生成容器所属目录的日志文件采集路径,并在本地生成新的配置文件,vfilebeat重新加载配置文件,即可滚动采集。
现在我们环境绝大部分应用均使用vfilebeat采集,少部分场景保留使用logstash采集。vfilebeat将采集到的日志附带上应用和系统环境等标签,序列化配置的数据格式,上报到kafka集群,应用日志是JSON,Accesslog为文本行。
3.2 日志解析 - flink writer
采集到kafka的日志将被一个flink writer任务实施消费后再写入到clickhouse集群。
writer把从kafka消费的数据先转换为结构化数据,vfilebeat上报的时候可能会上报一些日期较久的数据,太久的数据,报上来意义不大,并且会导致产生比较多的小part,消耗clickhosue cpu资源,这一步把这些过期超过三天的日期丢掉,无法解析的数据或者缺少必须字段的日志也会丢掉。经解析过滤后的数据再经过转换步骤,转换为clickhouse的表字段和类型。
转换操作从schema和metadata表读取域日志存储的元信息,schema定义了clickhouse本地表和全局表名,字段信息,以及默认的日志字段和表字段的映射关系。metadata定义了域日志具体使用的schema信息,日志存储的时长,域分区字段值,域自定义字段映射到的表字段,通过这些域级别的配置信息,我们做到可以指定域存储的表,存储的时长,超大日志域独立分区存储,降低日志合并的CPU消耗。自定义字段默认是按照数组存储,有些域打印的自定义日志字段较多,在日志量大的情况下,速度较慢,配置了自定义映射物理字段存储,可以提供比数组更快的查询速度和压缩率。
clickhouse表schema信息
域自定义存储元数据信息
经过转换后的数据,携带了存储到CK表所需要的所有信息,将临时存储在本地的一个队列内,本地队列可能混合存储了多个域多张表的日志,达到指定的长度或时间后,再被提交到一个进程级的全局队列内。
因为writer进程是多线程消费多个kafka分区,全局队列将同一个表多个线程的数据合并到一起,使得单次提交的批次更大,全局线程短暂缓冲,当满足写入条数,大小或超时后,数据将被作为一次写入,提交到submit worker线程。submit worker负责数据的写入,高可用,负载均衡,容错和重试等逻辑。
submit收到提交的批量数据后,随机寻找一个可用的clickhosue分片,提交写入到分片节点。clickhouse集群配置是双副本,当一个副本节点失败时,将尝试切换写入到另一个节点上,如果两个都失败,则暂时剔除分片,重新寻找一个健康的分片写入。
写入数据到Clickhouse我们使用的是clickhouse-jdbc,起初写入时消耗内存和CPU都较大,对jdbc源码进行分析后,我们发现jdbc写入数据时,先把所有数据转换成一个List对象,这个list对象相当于提交数据的byte[]副本格式,为了降低这个占用,在数据转换步骤我们进行优化,每条日志数据直接转换为jdbc可以直接使用的List数据,这样jdbc在构造生成SQL的时候,拿到的数据其实是List的一个引用,这个优化降低了约三分之一内存消耗。
另外对writer进程做火焰图分析时,我们发现jdbc在生成SQL时,会把提交数据的每个字符进行判定,识别出特殊字符如'\', '\n', '\b'等做转义,这个转义操作使用的是map函数,在数据量大时,消耗了约17%的CPU,我们对此做了优化,使用swtich后,内存大幅降低,节约了13%的CPU消耗。
clickhouse的弱集群概念保证了单节点宕机时,整个集群几乎不受影响,submit高可用保证了当节点异常时,数据仍然可以正常写入到健康节点,从而使得整个日志写入非常稳定,几乎没有因为节点宕机导致的延迟情况。
关于日志摄入Clickhouse的方式,石墨开源了另一种摄入方式,创建KafkaEngine表直接消费clickhouse,再将数据导入到物化视图内,通过物化视图最终导入到本地表。这种方式好处是节省了一个writer的组件,上报到kafka的数据直接就可以存储到clickhouse,但缺点非常多:
每个topic都需要创建独立的KafkaEngine,如果需要切换表,增加topic,都要变更DDL,并且无法支持一个topic不同域存储到不同表
另外解析kafka数据和物化视图都要消耗节点CPU资源,而clickhouse合并和查询都是非常依赖cpu资源的操作,这会加重clickhouse的负载,从而限制了clickhosue整体吞吐,影响了查询性能,需要扩容更多的节点来缓解此问题,clickhouse的单台服务器需要更多核数,SSD和大磁盘存储,因此扩容成本很高。
选择了将解析写入组件独立出来,可解决上面提到的很多问题,也为后期很多扩展功能提供了很大灵活性,好处很多,不再一一列举。
3.3 存储 - Clickhouse
高吞吐写入
提交到Clickhouse的数据以二维表的形式存储,二维表我们使用的是Clickhouse最常用的MergeTree引擎,关于MergeTree更详细的描述可以参考网上这篇文章《MergeTree的存储结构》。
https://developer.aliyun.com/article/761931spm=a2c6h.12873639.0.0.2ab34011q7pMZK
数据在磁盘的逻辑存储示意图
MergeTree采用类似LSM-Tree数据结构存储,每次提交的批量数据,按照表的分区键,分别保存到不同的part目录内,一个part内的行数据按照排序键进行排序后,再按列压缩存储到不同的文件内,Clickhouse后台任务会持续对这些每个小型的part进行合并,生成更大的part。
MergeTree虽然没有ES的倒排索引,但有更轻量级的分区键,主键索引和跳数索引。
分区键可以确保查找的时候快速过滤掉很多part,例如按照时间搜索时,只命中时间范围的part。
主键索引和关系型数据库的主键不同,是用来对排序数据块进行快速查找的轻量级索引。
跳数索引则根据索引类型对字段值进行索引,例如minmax索引指定字段的最大值和最小值,set存储了字段的唯一值进行索引,tokenbf_v1则对字段进行切分,创建bloomfilter索引,查询的时候可以直接根据关键字计算日志是否在对应数据块内.
一个part的数据会被按照排序键进行排序,然后按照大小切分成一个个较小的块(index_granularity),块默认有8192行,同时主键索引对每个块的边界进行索引,跳数索引则根据索引的字段生成索引文件,通常这三者生成的索引文件都非常小,可缓存在内存中加速查询。
了解了MergeTree的实现原理,我们可以发现,影响Clickhouse写入的一个关键因素是part的数量,每次写入都会产生一个part,part越多,那么后台合并任务也将越繁忙。除了这个因素外,part的生成和合并均需要消耗CPU和磁盘IO。
所以总结一下,三个影响写入的因素:
①part数量 - 少
②CPU核数 - 多
③磁盘IO - 高
要提高写入吞吐,就需要从这三个因素入手,降低part数量,提高CPU核数,提高磁盘IO
将图中的方法按照实现手段进行分类
硬件:CPU核数越多越好,我们生产环境40+,磁盘SSD是标配,由于SSD价格贵容量小,采用SSD+HDD冷热分离模式
表结构:长日志量又大的域使用bloomfilter索引加速查询,其他域则使用普通跳数索引即可,我们测试观察能节约近一半的CPU。
数据写入:Writer提交的数据,按照分区键进行分批提交,或者部分分区字段都可,也即单次提交的分区键基数尽可能小,最理想为1,此方法可大大降低小part数量。分区键的选择上,可根据应用日志的数量选择独立分区键,存储大日志量域,大日志量应用通常会达到条数阈值提交,可使得合并的part都是较大part,效率高;或者混合分区键,将小应用混合在一个分区提交。
高速查询
很多次,我和别人解释为什么日志系统没有(全文索引)仍然这么快的原因时,我都直接丢出这张图,图源自商用产品Humio公司的网站,也是我们老板多次推荐我们学习参考的一个产品,2021年初已被CrowdStrike以4亿美元收购。
1PB的数据存储,没有了全文索引的情况,直接暴力检索一个关键字,肯定是超时的,如果先经过时间,标签以及bloomfilter进行过滤筛选后,再执行暴力搜索,则需要检索的数据量会小的多。MergeTree引擎是列式存储,压缩率很高,高压缩率有很多优势,从磁盘读取的数据量少,页面缓存需要的内存少,更多的文件可以缓存在高速内存中,Clickhouse有和Humio一样的向量化执行和SIMD,在查询时,这些内存中的压缩数据块会被CPU批量的执行SIMD指令,由于块足够小,通常为压缩前1M,这样函数向量执行和SIMD计算的数据足够全部放在cpu缓存内,不仅减少了函数调用次数,并且cpu cache的miss率大大降低。查询速度相比没有向量执行和SIMD有数倍提升。
3.4 应用维度日志TTL
起初我们计划使用表级别的TTL来管理日志,将不同存储时长的日志放入不同的表内,但这样会导致表和物化视图变得非常多,不方便管理,后来使用了一个改进方案,将TTL放在表分区字段内,开发一个简单的定时任务,每天扫描删除所有超过TTL日期的part,这样做到了一张表支持不同TTL的日志存储,灵活性非常高,应用可以通过界面很方便查看和调整存储的时长。
3.5 自定义字段存储方案
标准格式日志内的自定义字段名称由业务输出,基数是不确定的,我们第一版方案是创建数百个字符串,整数和浮点数的扩展字段,由开发自行配置这个自定义映射,后来发现这个方案存在严重缺陷:
①开发需要将日志的每一个字段均手动配置到映射上去,随着日志的变更,这样的字段越来越多,随着数量膨胀将难以维护,
②Clickhouse需要创建大量的列来保存这些字段,由于所有应用混合在一起存储,对于大多数应用,太多列不但浪费,并且降低了存储速度,占用了大量的文件系统INODE节点
后来借鉴了Uber日志存储的方案,每种数据类型的字段,分别创建两个数组,一个保存字段名称,另一个保存字段值,名字和值按顺序一一对应,查询时,使用clickhouse的数组检索函数来检索字段,这种用法支持所有的Clickhouse函数计算。
[type]_names和[type]_values分别存储对应数据类型字段的名称和值
插入
多层嵌套的json字段将被打平存储,例如{"json": {"name": "tom"}}将转换为 json_name="tom"字段
不再支持数组的存储,数组字段值将被转换为字符串存储,例如:{"json": [{"name": "tom", "age": 18}]},转换为json="[{\"name\": \"tom\", \"age\": 18}]"
查询
原来的映射自定义字段目前仍然保留10个,如果不够,可以随时添加,可以支持一些域的固定自定义字段,或者一些特殊类型的日志,例如审计日志,系统日志等,这些字段在查询的时候用户可以使用原来的名称,访问Clickhouse之前会被替换为表字段名称
自定义字段的另一个方案是存储在map内,可以节约两个字段,查询也更简单,但经过我们测试,查询性能没有数组好:
①数组存储压缩率相比比Map略好
②数组查询速度比Map快1.7倍以上
③Map的查询语法比数组简单,在前端简化了数组的查询语法情况下,这个优势可忽略
4.前端日志查询系统
日志系统第一版是基于kibana开发的,版本较老。2.0系统我们直接抛弃旧版,自研了一套查询系统,效果如下:
新版查询会自动对用户输入的查询语句进行分析,添加上查询的应用域名和时间范围等,降低用户操作难度,支持多租户隔离。
自定义字段的查询是非常繁琐的,我们也做了一个简化操作:
string_values[indexOf(string_names, 'name')] 简化为:str.name
number_values[indexOf(number_names, 'height')] 简化为:num.height
Clickhouse一次执行一条语句,日志查询时柱状图和TOP示例日志是两条语句,会使得查询时间范围翻倍,参考携程的优化方法,查询详情时,我们会根据柱状图的结果,将时间范围缩小至TOP条记录所在的时间区间。
丰富查询用法
Clickhouse丰富的查询语法,让我们新日志系统的查询分析功能非常强大,从海量日志提取关键字,非常容易,下面列举两个查询用法:
①从文本和JSON混合的日志数据中提取JSON字段
②从日志计算分位数
5、正确使用姿势
1、打印日志不要太长,不超过10K
2、查询条件带上有跳数索引的标签,或者其他非日志详情的字段,召回日志数越小,查询速度越快
OLAP数据库Clickhouse是处理大规模数据密集型场景的利器,非常适合海量日志存储和查询分析,构建了一个低成本,无单点,高吞吐,高速查询的下一代日志系统。