文章来源:【公众号:唯技术】

目录

  • 背景

  • 日志系统演进之路‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍

  • 技术详解

  • 前端日志查询系统

  • 正确使用姿势

背景

唯品会日志系统 dragonfly 1.0 是基于 EFK 构建,于 2014 年服务至今已长达 7 年,支持物理机日志采集,容器日志采集,特殊分类日志综合采集等,大大方便了全公司日志的存储和查询。

随着公司的业务发展,日志应用场景逐渐遇到了一些瓶颈,主要表现在应用数量和打印的日志越来越多,开发需要打印更多日志,定位业务问题,做出运营数据分析。

另外外部攻击问题和审计要求,需要更多安全相关的日志数据要上报并且能够提供半年以上的保存时长,以应对潜在的攻击和攻击发生时调查原因和受影响面。

ELK 的架构的缺点显现,ES 集群规模达 260 台机器,需要的硬件和维护成本高达千万,如果通过扩容的方法去满足上述业务场景,ES 集群会太大会变动不稳定,创建独立集群,也需要更高成本,两者都会使得成本和维护工作量剧增。

鉴于这些问题,去年六月份我们开始探索新的日志系统架构,以彻底解决上面的问题。

日志系统演进之路

标准日志格式如下图:

3c008f357c0588a98b5f43d5c27488db.png

规范标准日志格式,有利于正确的识别出日志关键元信息,以满足查询,告警和聚合计算的需求。

从以上格式日志,通过 filebeat 转换后的结果如下:

c358784fadda8cd76d7870c26415cd6d.png

时间戳,日志级别,线程名,类名,eventName,和自定义字段将被日志采集 Agent 解析后和其他元数据如域名,容器名或主机名一起以 JSON 格式上报。

自定义字段是开发人员根据业务需要打印到日志,主要支持功能:

  • 查询时支持各种聚合分析场景

  • 根据自定义字段进行聚合函数告警。

| ES 存储方案问题

ES 日志存储模型如下图:

51d6c5d1bfc0d06ad8899a7983c91820.png

EFK 日志存储在 Elasticsearch,每个域的日志以天粒度在 ES 创建一个索引,索引大小是根据前几日数据大小计算得出,每个索引分片大小不超过 30G,日志量越多的域分片越多。

如果一个域的日志量写入过大或超长,将会占用 ES 节点大量 CPU 来做解析和 segment 合并,这会影响其他域日志的正常写入,导致整体写入吞吐下降。

排查是哪个域的哪个分片日志过大通常较为困难,在面对这种热点问题时经常要花很长时间。

我们 ES 版本使用的是 5.5,还不支持索引自动删除和冷热迁移,有几个脚本每日定时执行,完成删除索引,关闭索引,移动冷索引,创建新索引的任务,其中移动索引和创建新索引都是耗时非常长的操作。

整个生命周期每天循环执行,如果突然一天某个步骤执行失败,或者执行时间太长,会导致整个生命周期拉长甚至无法完成,第二天的新数据写入将受到严重影响,甚至无法写入。

另外 ES 的倒排索引需要对日志进行分词,产生的索引文件较大,占用了大量磁盘空间。

不过 ES 也有其优点,基于倒排索引的特性使得 ES 查询时,1 个分片只需要一个核即可完成查询,因为查询速度通常较快,QPS 较高。

下面是在大规模(或海量)日志存储场景下 ES 的主要存储优点和缺点:

0be16582bb1943ac5a7f4674a40e25c7.png

| 日志系统 2.0 方案

选择 Clickhouse 的原因:

2019 年我们尝试了另外一种 HDFS 存储方案,把每个域的数据按照域名+toYYDDMMHH(timestamp)+host 作为键在客户端缓存。

当大小或过期时间到了之后,提交到 HDFS 生成一个独立的文件,存储路径包含了域,主机和时间信息,搜索时即可根据这几个标签过滤。

这种存储方式有点类似 loki,它的缺点显而易见,优点是吞吐和压缩率都非常高,可以解决我们吞吐和压缩率不足的问题。

如果基于此方案继续增强功能,如添加标签,简单的跳数索引,查询函数,多节点并发查询,多字段存储,需要开发的工作量和难度都非常大。

我们对比了业界前沿使用的一些存储方案,最终选择了 Clickhouse,他的批量写入和列式存储方案完全满足我们的要求(基于 HDFS 存储),另外还提供了占用磁盘空间非常小的主键索引和跳数索引,相比 ES 的全文索引,优势明显。

将近 26G 的应用日志分别使用 Clickhouse 的 lz4,zstd 和 ES 的 lz4 压缩算法对比:

6110cd33b7ce01804969b4d4406b2f03.png

实际生产环境中 zstd 的日志压缩比更高,这和应用日志的相似度有关,最大达到 15.8。

44f08423fb8016c2ff2fb16adb20ce43.png

Clickhouse 压缩率这么高,但没有索引,其查询速度如何?虽然没有索引,但其向量执行和 SIMD 配合多核 CPU,可以大大缓解没有全文索引的缺点。

经过多次测试对比后,其查询速度在绝大多数场景下和 ES 不相上下,在部分场景下甚至比 ES 还要快。

下图是实际生产环境的数千个应用真实运行数据,查询 24 小时时间范围内日志和 24 小时以上时间范围日志的耗时对比:

77099555bcb52597bba3faec71509ca1.png

通过对日志的应用场景分析,我们发现万亿级别的日志,真正能被查询的日志数量是非常非常少的,这意味着 ES 对所有日志的分词索引,大多数是无效的,日志越多,这个分词消耗的资源越浪费。

相对比 Clickhouse 的 MergeTree 引擎专一的多,主要资源消耗是日志排序压缩和存储。另外 Clickhouse 的 MPP 架构使得集群非常稳定,几乎不要太多运维工作。

下面以一幅图综合对比 ES 和 Clickhouse 的优缺点,说明为什么我们选择将 Clickhouse 作为下一代日志存储数据库。

25cb768109fc32649daf6675975f025b.png

技术详解

EFK 架构发展这么多年体系要成熟得多,ES 默认参数和倒排索引使得你不需要对 ES 有太多了解即可轻松使用,开源 Kibana 又提供丰富的查询界面和图形面板,对于日志量不大的场景来讲,EFK 架构仍然是首选。

Clickhouse 是近几年 OLAP 领域比较热门的数据库,其成熟度和生态仍在快速发展中,用来存储日志的开源方案不是很多,要用好它不但需要对 Clickhouse 有深入的了解,还需要做很多开发工作。

a1e49c2f6ce63fa0cbbee4f7bd20f224.png

| 日志摄入:vfilebeat

起初 dragonfly 使用 logstash 来做日志采集,但 logstash 的配置较复杂并且无法支持配置文件下发,不便于容器环境下的日志采集。

当时另一个使用 GO 语言开发的采集工具 vfilebeat 在性能和扩展性方面较好,我们在此基础上做了定制开发自己的日志采集组件 vfilebeat。

vfilebeat 运行在宿主机上,启动时可以通过参数指定采集的宿主机日志所属的域,如果没有指定,则读取安装时 CMDB 配置文件的域名和主机名,宿主机采集的每条日志均带上域名和主机名作为标签。

容器环境下 vfilebeat 还会监听容器的创建和销毁,当容器创建时,读取容器的 POD 信息获取到域名和主机名,然后从 ETCD 拉取到域的日志采集路径等配置参数,按照域名和 POD 名称生成容器所属目录的日志文件采集路径,并在本地生成新的配置文件,vfilebeat 重新加载配置文件,即可滚动采集。

现在我们环境绝大部分应用均使用 vfilebeat 采集,少部分场景保留使用 logstash 采集。

vfilebeat 将采集到的日志附带上应用和系统环境等标签,序列化配置的数据格式,上报到 Kafka 集群,应用日志是 JSON,Accesslog 为文本行。

| 日志解析:flink writer

采集到 Kafka 的日志将被一个 flink writer 任务实施消费后再写入到 Clickhouse 集群。

ba080e370b023dc925fad0968a774aab.png

writer 把从 kafka 消费的数据先转换为结构化数据,vfilebeat 上报的时候可能会上报一些日期较久的数据。

太久的数据,报上来意义不大,并且会导致产生比较多的小 part,消耗 clickhosue cpu 资源,这一步把这些过期超过三天的日期丢掉,无法解析的数据或者缺少必须字段的日志也会丢掉。

经解析过滤后的数据再经过转换步骤,转换为 clickhouse 的表字段和类型。

转换操作从 schema 和 metadata 表读取域日志存储的元信息,schema 定义了 clickhouse 本地表和全局表名,字段信息,以及默认的日志字段和表字段的映射关系。

metadata 定义了域日志具体使用的 schema 信息,日志存储的时长,域分区字段值,域自定义字段映射到的表字段,通过这些域级别的配置信息,我们做到可以指定域存储的表,存储的时长,超大日志域独立分区存储,降低日志合并的 CPU 消耗。

自定义字段默认是按照数组存储,有些域打印的自定义日志字段较多,在日志量大的情况下,速度较慢,配置了自定义映射物理字段存储,可以提供比数组更快的查询速度和压缩率。

clickhouse 表 schema 信息:

a260c47c0c275072ce1275203753c686.png

域自定义存储元数据信息:

c2f715eeb5dd9b2e96cae9976fa488f4.png

经过转换后的数据,携带了存储到 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 和大磁盘存储,因此扩容成本很高。 

选择了将解析写入组件独立出来,可解决上面提到的很多问题,也为后期很多扩展功能提供了很大灵活性,好处很多,不再一一列举。

| 存储:Clickhouse

①高吞吐写入

提交到 Clickhouse 的数据以二维表的形式存储,二维表我们使用的是 Clickhouse 最常用的 MergeTree 引擎。

关于 MergeTree 更详细的描述可以参考网上这篇文章《MergeTree 的存储结构》。

https://developer.aliyun.com/article/761931spm=a2c6h.12873639.0.0.2ab34011q7pMZK

a4a1e3f74f6f931ad48b1db0bf55afd2.png

数据在磁盘的逻辑存储示意图:

412bd83fac00fdf8893aae1f9f5c0b48.png

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。

c0916faa887bd50c29c046daedaf01d8.png

将图中的方法按照实现手段进行分类:

  • 硬件:CPU 核数越多越好,我们生产环境 40+,磁盘 SSD 是标配,由于 SSD 价格贵容量小,采用 SSD+HDD 冷热分离模式

  • 表结构:长日志量又大的域使用 bloomfilter 索引加速查询,其他域则使用普通跳数索引即可,我们测试观察能节约近一半的 CPU。

  • 数据写入:Writer 提交的数据,按照分区键进行分批提交,或者部分分区字段都可,也即单次提交的分区键基数尽可能小,最理想为 1,此方法可大大降低小 part 数量。

分区键的选择上,可根据应用日志的数量选择独立分区键,存储大日志量域,大日志量应用通常会达到条数阈值提交,可使得合并的 part 都是较大 part,效率高;或者混合分区键,将小应用混合在一个分区提交。

②高速查询

很多次,我和别人解释为什么日志系统没有(全文索引)仍然这么快的原因时,我都直接丢出这张图,图源自商用产品 Humio 公司的网站,也是我们老板多次推荐我们学习参考的一个产品,2021 年初已被 CrowdStrike 以 4 亿美元收购。

1b8cf9084f284e820cdacd536a1e7be3.png

1PB 的数据存储,没有了全文索引的情况,直接暴力检索一个关键字,肯定是超时的,如果先经过时间,标签以及 bloomfilter 进行过滤筛选后,再执行暴力搜索,则需要检索的数据量会小的多。

MergeTree 引擎是列式存储,压缩率很高,高压缩率有很多优势,从磁盘读取的数据量少,页面缓存需要的内存少,更多的文件可以缓存在高速内存中。

Clickhouse 有和 Humio 一样的向量化执行和 SIMD,在查询时,这些内存中的压缩数据块会被 CPU 批量的执行 SIMD 指令,由于块足够小,通常为压缩前 1M。

这样函数向量执行和 SIMD 计算的数据足够全部放在 cpu 缓存内,不仅减少了函数调用次数,并且 cpu cache 的 miss 率大大降低。查询速度相比没有向量执行和 SIMD 有数倍提升。

1adc82fada8f7d4a7aae66b4b5382cd9.png

| 应用维度日志 TTL

起初我们计划使用表级别的 TTL 来管理日志,将不同存储时长的日志放入不同的表内,但这样会导致表和物化视图变得非常多,不方便管理。

后来使用了一个改进方案,将 TTL 放在表分区字段内,开发一个简单的定时任务,每天扫描删除所有超过 TTL 日期的 part,这样做到了一张表支持不同 TTL 的日志存储,灵活性非常高,应用可以通过界面很方便查看和调整存储的时长。

| 自定义字段存储方案

标准格式日志内的自定义字段名称由业务输出,基数是不确定的,我们第一版方案是创建数百个字符串,整数和浮点数的扩展字段,由开发自行配置这个自定义映射。

后来发现这个方案存在严重缺陷:

  • 开发需要将日志的每一个字段均手动配置到映射上去,随着日志的变更,这样的字段越来越多,随着数量膨胀将难以维护

  • Clickhouse 需要创建大量的列来保存这些字段,由于所有应用混合在一起存储,对于大多数应用,太多列不但浪费,并且降低了存储速度,占用了大量的文件系统 INODE 节点

后来借鉴了 Uber 日志存储的方案,每种数据类型的字段,分别创建两个数组,一个保存字段名称,另一个保存字段值,名字和值按顺序一一对应,查询时,使用 clickhouse 的数组检索函数来检索字段,这种用法支持所有的 Clickhouse 函数计算

55fd3d9448bcf363aa24827545814220.png

[type]_names 和 [type]_values 分别存储对应数据类型字段的名称和值。

插入:

431f8e88ac09e8ec3d0f4f21693a73c2.png

多层嵌套的 json 字段将被打平存储,例如{"json": {"name": "tom"}}将转换为 json_name="tom"字段。

不再支持数组的存储,数组字段值将被转换为字符串存储,例如:{"json": [{"name": "tom", "age": 18}]},转换为 json="[{\"name\": \"tom\", \"age\": 18}]"

查询:

b2643106b69586b6114d9e34fb76d078.png

原来的映射自定义字段目前仍然保留 10 个,如果不够,可以随时添加,可以支持一些域的固定自定义字段,或者一些特殊类型的日志,例如审计日志,系统日志等。

这些字段在查询的时候用户可以使用原来的名称,访问 Clickhouse 之前会被替换为表字段名称。

自定义字段的另一个方案是存储在 map 内,可以节约两个字段,查询也更简单,但经过我们测试,查询性能没有数组好:

  • 数组存储压缩率相比比 Map 略好

  • 数组查询速度比 Map 快 1.7 倍以上

  • Map 的查询语法比数组简单,在前端简化了数组的查询语法情况下,这个优势可忽略

前端日志查询系统

日志系统第一版是基于 kibana 开发的,版本较老。2.0 系统我们直接抛弃旧版,自研了一套查询系统,效果如下:

4e03e79c16ee92299f5d1ec2e41a13ff.png

新版查询会自动对用户输入的查询语句进行分析,添加上查询的应用域名和时间范围等,降低用户操作难度,支持多租户隔离。

自定义字段的查询是非常繁琐的,我们也做了一个简化操作:

  • string_values[indexOf(string_names, 'name')] 简化为:str.name

  • number_values[indexOf(number_names, 'height')] 简化为:num.height

Clickhouse 一次执行一条语句,日志查询时柱状图和 TOP 示例日志是两条语句,会使得查询时间范围翻倍,参考携程的优化方法,查询详情时,我们会根据柱状图的结果,将时间范围缩小至 TOP 条记录所在的时间区间。

| 丰富查询用法

Clickhouse 丰富的查询语法,让我们新日志系统的查询分析功能非常强大,从海量日志提取关键字,非常容易,下面列举两个查询用法:

①从文本和 JSON 混合的日志数据中提取 JSON 字段。

13d9fa3468a9fb466c11c971d2ccc1ea.png

②从日志计算分位数

‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍f1b8f2c6e7ab7f8a901c55f81dcb6656.png

正确使用姿势

‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍

如下:

  • 打印日志不要太长,不超过 10K

  • 查询条件带上有跳数索引的标签,或者其他非日志详情的字段,召回日志数越小,查询速度越快

OLAP 数据库 Clickhouse 是处理大规模数据密集型场景的利器,非常适合海量日志存储和查询分析,构建了一个低成本,无单点,高吞吐,高速查询的下一代日志系统。

-------------  END  -------------
扫码免费获取600+页石杉老师原创精品文章汇总PDF



原创技术文章汇总


点个在看你最好看
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐