本文最初发布于 Uber 官方博客,InfoQ 经授权翻译如下。
在 Uber,我们提供了一个集中的、可靠的、交互式的日志平台,让工程师们可以快速完成大规模日志分析工作。这些日志被标记为一组丰富的上下文键值对,工程师可以使用它们来切分数据,以显示异常或有趣的模式,从而指导产品改进。当前,该平台每秒从不同区域数以千计的服务摄取数以百万计的日志,存储几个 PB 的数据,每秒为来自仪表盘和程序的数百个查询提供服务。
自从 2014 年开始使用ELK进行日志记录以来,我们的系统流量和用例之间的差异显著增加。到了支持这一快速增长的流量规模瓶颈时,我们决定深入研究 Uber 的大量日志用例,以构建我们的下一代平台,从根本上改善可靠性、可扩展性、性能,最重要的是确保用户和运营商的愉快体验。
背景近几年,日志流量的增长导致了平台部署规模巨大,用户需求也发生了显著变化。两者都给基于 ELK 的平台带来了许多挑战,而这些挑战在较小的规模下是看不到的:
1.日志模式:我们的日志是半结构化的。ES(Elasticsearch)会自动推导模式,在整个集群中保持一致,并在后续日志中强制执行。如果字段类型不兼容,将导致 ES 出现类型冲突错误,从而丢弃违规日志。尽管对具有明确定义结构的业务事件实施一致的模式是合理的,但是对日志记录而言,这将导致开发人员的生产力大大下降,因为日志模式会随着时间的推移有机地演化。举例来说,一个大型的 API 平台可能会有数百名工程师进行更新,在 ES 索引映射中累积了数千个字段,而且不同的工程师很可能使用相同的字段名,但是不同的字段值是不同的类型,从而导致 ES 的类型冲突。这会迫使工程师学习已有的模式,并保持它们的一致性,仅仅打印一些服务日志,效率很低。理想情况下,平台应该将模式更改作为一个规范,并且能够为用户处理多种类型的字段。
2.运营成本:如果一个集群受到大量查询或映射爆炸的不利影响,我们必须在每个区域运行 20 多个 ES 集群,以限制爆炸半径。Logstash 管道的数量更多,每个区域有 50 多个,以适应特殊用例和自定义配置。昂贵的查询和映射爆炸都会严重影响 ES 集群的性能,有时甚至会“冻结”集群,这时我们不得不重新启动集群使其恢复。随着集群数量的增加,这种中断越来越频繁。虽然我们竭尽全力实现流程自动化,例如检测并禁用会引起映射爆炸和类型冲突的字段,重新平衡 ES 集群之间的流量等等,但是人工干预解决类型冲突等仍是不可避免的。我们想要一个能够支持我们组织中大量新用例的平台,而不必承担大量的运营开销。
3.硬件成本:在 ES 中,索引字段的成本相当高,因为它需要建立和维护复杂的倒排索引和正排索引结构,并将其写入事务日志,周期性地将内存缓冲区刷新到磁盘上,并定期进行后台合并,以保持刷新索引段的数量不至于无限制地增长,这些都需要大量的处理能力,并且会增加日志成为可查询的延迟。因此,我们的 ES 集群没有对日志中的所有字段进行索引,而是配置为索引多达三个级别的字段。但是摄取所有生成的日志仍然会消耗大量的硬件资源,并且扩展成本太高。
4.聚合查询:在我们的生产环境中发现,80% 以上的查询都是聚合查询,比如术语、直方图和百分数聚合。虽然 ES 在优化前向索引结构方面有所改进,但其设计仍然不能支持跨大型数据集的快速聚合。低效的性能会导致不愉快的用户体验。举例来说,在查询最后 1 小时的日志(大约 1.3 TB)时,一个日志数量巨大的服务的关键仪表盘加载速度非常缓慢,而且在查询最后 6 小时的日志时常常出现超时,从而导致无法诊断产品问题。
我们只能在一个基于 ELK 的平台上摄取 Uber 内部生成的部分日志。在 ELK 平台基础上的大规模部署和许多固有的低效,使扩展以摄取所有日志并提供完整的、高分辨率的产品环境概述的成本高得令人望而却步。
模式无关的新日志分析平台介绍我们的目标是收集 Uber 中生成的所有日志,以较低的平台成本进行存储和服务,并确保用户和运营商的愉快体验。总的来说,我们设计了一个新的日志分析平台,考虑到了这些关键的需求:
功能开发人员工作效率的模式无关性高效支持聚合查询支持多区域和跨租户查询效率和维护适当支持多租户整合部署降低成本,并能处理 10 倍规模的问题提高可靠性,简化操作从 ELK 平台透明迁移,例如用户可以继续使用 Kibana 来交互分析日志。
我们评估了多种日志产品和存储解决方案。最后,我们决定使用开源的分布式面向列的 DBMSClickHouse作为底层日志存储技术,并在其之上构建了一个抽象层,以支持模式无关的数据模型。
模式无关的数据模型我们的原始日志被格式化为 JSON,并且它的模式可以逐渐改变。在发布类似“Job finished”之类的日志消息时,开发人员可以用键值对作为上下文来标记它们。在输出日志中,日志消息和标签被编码为字段。标签值可以是原始类型,如数字或字符串,或者是组合类型,如数组或对象。对于 Uber 来说,日志平均有 40 多个字段,都被我们的平台一视同仁,并提供丰富的上下文。
为了支持模式的原生演化,我们可以如下所示,在摄取过程中在日志模式中跟踪字段的所有类型。此模式被持久化,在查询执行过程中使用,稍后将进行解释。每一种字段类型都有一个时间戳标记,它表示该类型被观察到的时间,并且可用于清除模式中的过时信息。
ClickHouse 表模式一开始,我们尝试了两种表模式来保存 ClickHouse 中的日志。第一个模式只在_source列下保留了 json 格式的原始日志,在查询执行过程中,日志字段通过 ClickHouse 的 json 解组(unmarshal)函数visitParamExtractString访问,但由于 json 解组的开销,使用这种模式查询速度过慢。
第二种模式不仅将原始日志保存在_source中,以便能够快速地检索原始日志,而且还将所有字段扁平化到专门的列中,并注明字段名和类型,以处理类型冲突,这样就可以直接从列中查询字段值。结果表明,查询速度比第一个模式快 50 倍。但这种模式也不能进行扩展,因为随着表列数的增加,磁盘文件的数量线性增加,ClickHouse 也停止了对写和读的响应,在后台合并部分负担过重。
最后,我们得到了下图所示的表模式 (为了简明扼要而作了简化),它可以提供良好的查询性能,同时避免无限增加的磁盘文件数量。基本上,每个日志都被扁平化为一组键值对;这些键值对按其值类型分组,如String、Number或StringArray。在表中,我们使用一对数组来存储这些组的键值对。(string.names,string.value) 用来存储具有字符串值的一组键值对,(number_array.names,number_array.value) 用来存储具有数字数组值的键值对,以此类推。
为了更快地检索,常用的元数据字段都保存在专门的列中。特别是_namespace列,它使我们能够有效地支持多租户。需要注意的是,我们总是将原始日志保存在_source列中,以避免在运行时重新生成全部日志,这对于嵌套结构来说是复杂而昂贵的。尽管我们基本上只存储了两次日志,但是由于有效的压缩,使用磁盘的次数并没有增加多少。在生产流量试验中,采用此表模式的压缩比可达 3 倍 (有些情况下可达 30 倍),相当于采用 ES 所能获得的压缩比,而且通常效果更好。
在查询执行过程中,我们使用 ClickHouse 的数组函数来访问字段值,比如string.values[indexOf(string.names, 'endpoint')]='/health'。从这些数组列中,我们可以访问任何字段,比解组原始日志摄取值快大约 5 倍。与上述第二种模式相比,从数组列提取字段值比从专用列访问字段值慢。由于大多数过滤器都是基于字段进行评估的,因此我们建议如果字段被频繁访问,那么可以将字段值写在专门的列中,以加速查询,即使用 ClickHouse 的物化列功能的自适应地索引字段。
这种表模式不仅能提高查询执行的性能和灵活性,而且能实现有效的日志摄取。从我们的实验中可以看出,一个 ClickHouse 节点每秒可以摄取 300 K 日志,比一个 ES 节点多 10 倍。
快速摄取所有内容并查询任何内容在本节中,我们将讨论如何将所有日志摄取到如上创建的 ClickHouse 表中,而不管日志模式是如何演化的;通过一组定制的高级接口查询这些日志,从而可以推断字段类型;基于访问模式自适应地使用物化列提高查询性能。
无模式摄取日志对于值班工程师调试故障至关重要。在减少 MTTR 方面,我们尽量使我们的日志分析平台的日志摄取更快更完整。
如上图所示,日志从 Kafka 摄取到 ClickHouse。我们平台的摄取管道有两大部分:摄取器(ingester)和批处理器(batcher)。摄取器从 Kafka 摄取日志,并将 JSON 格式的日志扁平化为键值对。这些键值对按其值类型进行分组,并通过m3msg发送到下游。摄取器不会提交 Kafka 偏移量,直到它收到来自批处理器的 acks,这意味着日志已经成功写入 ClickHouse,提供至少一次的交付保证。m3msg 传输还可以轻松实现沿摄取管道的背压,当我们的下游速度变慢时,利用 Kafka 来缓冲日志。
ClickHouse 在大批量写入时效果最好,所以我们将多个租户适当打包到表中,以保证足够快的批处理速度,在不增加写入速度的情况下降低摄取延迟。在摄取过程中,日志模式会从当前的日志批处理中提取出来,并持久化到批处理机存储的元数据中,以用于查询服务生成 SQL。与 ES 不同的是,在 ES 中,索引更新是数据摄取路径上的一个阻塞步骤,我们继续向 ClickHouse 摄取数据,即使有错误更新模式。我们假设日志模式可以一直演化,但大多数标签都是重复的,因此后续批次极有可能会更新模式,并使其最终与 ClickHouse 中的日志同步。更重要的是,元数据存储能够保存非常大的日志模式,使得我们的平台对映射爆炸问题的免疫力大大增强。
类型识别查询让用户直接编写 ClickHouse SQL 来检索我们自定义表模式下的日志是一件令人畏惧的事情。这需要用户了解如何使用数组列表示键值对、如何在表之间移动日志以改进数据位置,以及如何基于查询历史创建适应性索引等等。为提供熟悉而愉快的用户体验,我们为日志用例提供了一组精心设计的高级查询接口,并建立了一个查询服务,以自动生成 SQL 并与 ClickHouse 集群交互。
查询接口以下是查询接口的演示,出于简洁的考虑,进行了简化。RawQuery 可以检索带有过滤条件的原始日志;AggregationQuery 可以通过将日志分组,然后使用某些字段的原始值来计算有关日志的统计数据;BucketQuery 可以通过表达式评估的结果来将日志分组,例如每 10 分钟对日志进行一次桶存储(bucketize)。要注意的是 Calculation 子句可以有自己的筛选子句,利用 ClickHouse 的条件聚合功能,可以方便地表示复杂的分析结果。另外,所有这些查询都可以通过merge() 函数在后台访问来自名称空间列表,所有这些查询都可以从一个命名空间列表(即租户)的日志。
SQL 生成从请求生成 ClickHouse SQL 的查询服务主要分为两个阶段:逻辑阶段和物理阶段。在逻辑阶段,字段类型约束由查询请求收集,字段存在检查由接收时间收集的日志模式。经过查询请求之后,一组字段名称和它们的类型约束被收集。举例来说,字段“foo”应该访问String或StringArray类型值,因为它存在于“foo”=“abc”这样的过滤表达式中,或者字段“bar”因为用于group-by等,所以应该访问String、Number、StringArray或NumberArray类型值。
逻辑阶段的下一步是通过比较从查询请求中收集的类型约束和保存在日志模式中的字段类型,确定字段类型。举例来说,上面提到的“foo”字段在模式中可能只有String类型,因此在生成 ClickHouse SQL 时,我们应该只访问包含String值的列;上面示例中的字段“bar”,在模式中可能有String和Number两种类型,因此我们应该在一个 SQL 中访问两种类型的值。
当从一个字段中访问多个类型的值时,可能需要进行类型转换,因为 SQL 中的表达式期望从该字段中获得特定类型的值。目前,我们遵循下图所示的规则,根据表达式中使用的运算符,将非 String 类型转换为 String 或 StringArray。如果前者是后者的基本类型,则还可以转换标量类型和数组类型。
通过这种方式,影响基于 ELK 平台可用性的类型冲突问题在新的日志平台中仅仅作为一个规范来处理。当字段类型确定之后,在逻辑阶段结束时,表列访问表达式也会相应的产生。举例来说,对于“bar”字段,我们可能会得到下面这个 SQL 表达式:
通过在逻辑阶段解析的列表达式,可以知道如何访问每个字段的值。查询请求中指定的各种表达式在物理阶段转换为最终 SQL。在物理阶段结束时,将决定查询设置,它控制 ClickHouse 执行查询的方式,比如向任务分配多少线程。以下是转换的典型示例:
在编写时, ES 会确定字段类型,而我们的平台会将字段类型的解析延迟到查询中,这会简化摄取逻辑,极大地提高数据完整性。一般情况下,写路径的错误预算比查询路径要少得多,因为它不能停机太长,否则 Kafka 中的日志会自动删除。使用更多的错误预算,我们可以更快地迭代查询服务,甚至可以在检索日志时对日志进行复杂的转换,而不必像 Logstash 那样在摄取管道中进行复杂的预处理。
自适应索引通过对生产查询进行分析,我们发现只使用了索引字段的 5%。这就是说,ES 中其他 95% 的字段的索引成本都浪费了。所以我们设计了一个平台,摄取所有的日志,而不需要预先支付索引字段的成本。尽管如此,我们还是有选择地索引查询频率最高的字段,将其具体化为专门的列,如下图这样,可以加快查询:
在后台, ClickHouse 异步地将字段值回填到物化列,而不会阻止正在进行的读写操作。它更酷的一点是,当你查询一个物化列时,你可以使用物化列的预填充值功能,而且当物化列未被回填时,你可以透明地返回到基于数组的取值。这样可以简化编写使用物化列的 SQL 查询的逻辑。从根本上说,在解析列访问表达式时,检查字段是否被物化,并尽可能使用快速访问路径。如下所示:
物化字段会在写入路径上增加额外的成本,因此平台会定期清理那些不经常访问的列。
可靠性、可扩展性、多区域和多租户。在本节中,我们将讨论架构设计,使我们的日志基础设施能够可靠地扩展,如何跨区域工作,以及如何应用到多租户的资源管理。
在 ClickHouse 中,我们使用ReplicatedMergeTable引擎,并且设置了RF=2来提高系统的可靠性,并增加了冗余。复制是异步的和多主机的,因此日志可以写到副本集中任何可用的副本中,查询也可以访问任何副本中的日志。所以,像重新启动或升级这样的节点临时丢失不会影响系统的可用性和数据持久性。但当一个节点因为异步复制而永久丢失时,有可能丢失一定数量的日志。为了提高一致性,我们可以将复制配置为同步的,但是现在我们发现为了提高可用性,这是一个可以接受的折衷方案。
为扩展系统,我们在 ClickHouse 中使用了表分片支持。表格可以有多个分片。现在,我们只需要将整个 ClickHouse 集群中的每个表进行分片。ClickHouse 也让我们可以配置查询来跳过不可用的分片,返回与最佳可用性相匹配的结果,这在需要快速响应而非准确性时尤其有用。
ClickHouse 仅提供非常基本的集群管理支持,因此我们将此功能增强为平台的管理服务。总的来说,它类似于状态驱动的集群管理框架。群集的目标状态描述了一个群集应该是什么样的,并保存在元数据存储中。在框架中实现管理工作流,当目标状态改变时,管理员服务会调用该工作流,或者按计划将集群从实际状态过渡到目标状态。这些工作流是幂等的,可以安全地重试,以一种容错的方式管理集群。通过此框架,可以可靠地自动执行登入租户、扩展集群、替换节点、物化字段、优化租户位置、清除旧日志等常用操作。
为了便于对所有分片的日志进行查询,使用了分布式表功能。分布式表不存储任何物理数据,但是需要对所有分片的集群信息进行扇形查询,并正确地汇总部分结果。首先,我们在所有 ClickHouse 节点上创建所有分布式表,以便任何节点能够提供分布式查询。但是,当我们把集群扩展到跨区域的数百个节点时,我们发现,要在一个时间内连贯地从全局元数据存储向所有分布式表传播集群信息非常困难。因此我们把节点分为查询和数据两个角色,这样只有查询节点需要集群拓扑信息来为分布式查询提供服务。由于查询节点的数目较少,因此可以方便地向它们传播集群信息,并快速收敛。另外,角色分离使我们能够使用不同的硬件 SKU 对查询节点和数据节点进行独立扩展。例如查询节点几乎是无状态的,并且很容易扩展。
通过增加更多的 CH 节点,我们可以在此架构下线性扩展集群。但是为了能够可靠地支持多租户,我们还需要在摄取和查询过程中适当地隔离租户。对于摄取器,我们对每个租户设定一个速率限制。在查询服务中,我们控制每个租户的最大访问次数,并且为每个查询保守地分配资源,例如查询线程,以便为并行查询和访问负载提供空间。
成本和性能与 ELK 技术相比,我们在为更多的生产流量服务的同时,降低了超过一半的硬件成本。关于运营开销,我们只需在每个区域运行一个统一的日志摄取管道,所有常用操作都已通过管理服务自动完成。另外,平台不受类型冲突错误的影响,过去在运行旧平台时,类型冲突错误是一个主要的待命工作量来源。
就性能而言,吸收延迟的上限为 1 分钟。在我们的平台上查询、检索、汇总多个区域的日志,通常在几秒内完成,尤其是当查询窗口小于一天时。由于 ClickHouse 将在内存中缓存数据,因此随后的查询(例如从刷新仪表盘中进行的查询)会更快。由于 ClickHouse 提供了适当的资源隔离支持,我们的平台可以在较高的查询负载下继续运行,而不会出现严重的降级或受限制的现象。
ELK 的透明迁移除了直接调用 ES 端点来检索日志的许多服务之外,Uber 工程师每月还会保存约 1000 个 Kibana 仪表盘以进行日志分析。因此,为了便于迁移,无需客户端修改其指示盘或代码,我们构建了 QueryBridge 服务来将 ES 查询转换成新的查询接口,并返回一个响应。通过对查询结果进行验证,我们向服务中添加了特性标志,从而逐步迁移用户。
目标不在于支持完整的 ES 查询语法,而在于只支持那些在产品中找到的语法。即使这样,转换逻辑也是相当复杂的。例如:
Lucene 查询可以通过 query_string 操作符嵌入 ES 查询中。我们将它们转换为整个 AST(抽象语法树)的子树,代表整个 ES 查询。
聚合可以有与之相关的过滤条件,就像过滤器聚合一样,我们将其转换为查询请求中特定筛选器的计算。ES 内部字段,如@timestamp和_source,必须单独处理,因为它们不是日志主体内的数据字段。我们必须对关键字和文本字段的过滤器进行不同的转换。例如,foo:"abc"表达式转换为equals(foo, "abc")检查是否为关键字类型,但包含(``foo,"abc")检查是否为查询请求中的文本类型。未来展望日志传达了对生产环境的高分辨率洞察力,尤其是当它们被标记为请求 ID、地理位置或 IP 地址等高基数字段时。可以近实时地分析这些日志可以很有效地调试联机系统,识别有趣的模式,从而提高产品质量。基于当前的用户体验,我们相信它是一个能够满足 Uber 广泛的日志分析需求的有效平台。而且 ClickHouse 的确是一个强大的分析引擎,我们希望继续进行探索。
放眼未来,我们打算在这些令人感兴趣的领域跟进:
SQL 查询中的查询服务支持。通过自适应使用 PreWhere 和 Where 子句,微调索引粒度,探索跳过索引,并根据更多收集的统计数据微调查询设置,积极改善查询延迟的差异性。探索分层存储,提高数据保留率并降低成本。构建下一代 UI/UX,取代 Kibana,并更好地与后台整合,发现和分析日志,通过交叉检查日志、指标和痕迹,方便事件调查。
如果你有兴趣加入 Uber 的可靠性平台团队,打造下一代的可观测性体验,请申请加入我们的团队!
作者介绍:
Chao Wang,Uber 软件工程师,在过去 5 年里,在可观测性领域工作,目前正在领导团队构建下一代日志基础设施。
Xiaobing Li,Uber 日志团队的前软件工程师。他领导了新的日志查询服务项目,重点是透明地从 ELK 迁移日志用户。
原文链接:
/