本文为Google监控团队发表在VLDB的论文
由于一次偶然机会,使我认识了Monarch,Monarch是Google的大规模监控系统,服务于Google全球大规模实时业务监控,其实现为超大规模时序数据库集群,被公认为当今全球最大规模的时序数据库。Monarch本身没有开源,主要信息来源于Google在2020年8月份发表在PVLDB上的一篇论文:
原文:Adams, Colin, et al. “Monarch: Google’s planet-scale in-memory time series database.” Proceedings of the VLDB Endowment 13.12 (2020): 3181-3194.
原文链接: Monarch: Google’s Planet-Scale In-Memory Time Series Database
摘要
Monarch 是谷歌的一个全球分布式内存时序数据库。它是一个多租户的服务,通常用于监控谷歌内部那些服务于数十亿用户的系统的可用性,正确性,性能,负载以及其他各方面。每秒钟,Monarch采集了TB级别的时序数据到内存,并且支撑了数百万的查询。Monarch有一个region化的架构用于支撑自身的可用性与可扩展性,在这之上有全局的配置管理和查询系统将这些region化的服务整合成一个统一的系统。Monarch在它的分布式架构之上,提供了灵活的配置,表达能力强的关系数据模型,以及强大的查询能力。本篇论文描述了整个系统的基本结构,以及在regional的分布式架构之上构建灵活而统一的系统用到的创新技术。同时我们也在文章中分享了十年来研发和运维Monarch服务的一些经验与教训。
1. 介绍
谷歌有无数计算机系统的监控诉求。数以千计的团队在研发和运维着面向全球用户的服务(比如Youtube,GMail,谷歌地图等),或者提供支撑这些服务的底层软硬件服务(比如Spanner,Borg,F1等)。这些团队需要监控数十亿分布于全球的实体组合,并且他们还在持续增长与变化中(比如设备,虚拟机,容器等)。指标需要从每个实体中采集,然后存储到时序数据库,最终查询出来用于支持这些用例场景:(1)检测并在被监控服务不正常工作时候发出报警。(2)做出用于展现系统状态和健康度图表,并组合成大盘(3)执行ad-hoc查询用于问题的诊断,探索系统的性能和资源使用状况。
最开始,Bogmon是负责谷歌内部应用程序和基础设施监控的系统。Borgmon将采集时间序列指标作为最重要的特性并且提供了一个强大的查询语言来自定义监控数据分析,从而满足用户的各种定制需求。通过这两点,它革命性的重塑了人们关于监控和预警的认识。在2004年到2014年期间,因为监控流量的爆发性增长,Borgmon的部署规模急剧的增长,但同时这也暴露了它的一些限制和缺陷:
Borgmon的架构鼓励人们用一个去中心化的运维模型,这样每个团队需要部署和运维他们自己的Borgmon实例。然而,这个导致了很多不可忽视的运维与人力成本,因为很多团队缺乏长期稳定运维Borgmon的必要的专业知识或者对应的运维岗位。并且,用户经常需要跨越不同应用和基础设施的边界,来查询和关联分析监控数据,用以定位问题;这个在一个个孤立的Borgmon实例的情况下是几乎不可能实现的。
Borgmon自身缺乏强模型定义的监测维度和数据导致了语义模糊的查询,同时也限制了查询语言在数据分析过程中的表达能力。
Borgmon缺乏较好的对于数值分布型的支持(比如:直方图),而这恰好是非常强大的数据结构。它可以让数据统计分析更加有效。(例如,计算大量服务器上请求延迟的99分位)
Borgmon 需要用户手动对全局服务性质的大量被监控实体进行拆分,拆分多个Borgmon实例,并且构建一个查询执行的树状结构。
因为已知了这些问题,我们设计了Monarch作为谷歌下一代的大规模监控系统。从设计上,它就支持随着持续的流量增长而扩展,同时也支持不断新生的使用场景。作为一个为所有的google团队提供的多租户统一服务,它可以减少对应使用团队的运维工作。Monarch 使用了有结构(schema)的数据模型来支持复杂的查询,并且全方位的支持了数据分布类型的时间序列指标(distribution)。这套监控系统从2010年就开始开始运行,在全球范围内流量和吞吐不断增长的背景下,它采集、计算、存储并且查询天量的时间序列数据。现在它使用内存存储了接近一PB经过压缩之后的时间序列数据,每秒入口的吞吐达到了数TB,并且支持了百万查询每秒。
这篇文章主要的工作和贡献集中在这几点上:
我们呈现了Monarch的技术架构:一个多租户,全球级别的内存时序数据库。它部署在了很多地理上的不同区域,支持了谷歌的应用和基础设施的监控告警需求。为了高可用和扩展性,Monarch使用地域化的结构消费和存入了监控时序数据,同时它有一层全局的查询共享层(译注:原文是联邦)用来将地理上分散的数据的组成一个全局视角。而且它提供了一个全局的配置管控面来做统一管控。Monarch将数据存储在内存,用来防止自身被持久存储层的故障和问题所影响,从而提高整体的可用性(当然它也有日志文件存储备份用来做持久存储,而且也有一个长期数据存储)
我们描述Monarch表达能力丰富的时间序列数据查询语言的底层数据模型。这个模型是新颖的、支持丰富数据类型的。它允许用户执行各种各样的操作,在支持复杂的数据分析的同时,允许做静态的查询分析与优化。这个数据模型支持复杂的指标值类型比如数据分布,用以支撑有用的统计分析。据我们所知,Monarch是第一个全球级的,支持关系型时序数据模型的内存时序数据库。它在监控场景的规模达到了PB级别的内存数据存储的同时支持了百万级QPS。
我们勾勒了Monarch 的(1)规模化的采集执行流,它提供了稳健的,低延迟的数据采集,支持自动的负载均衡,采集层的预聚合,其中采集层的预聚合极大的提高了了我们的执行效率;(2)一个可以执行拥有丰富表达能力的查询语言的子系统,一个高效的分布式查询执行引擎,以及一个紧凑的索引子系统。其中索引子系统极大的提升了性能和可扩展性。(3)一个全局的,能提供用户精细化管控能力的配置管控层,它可以控制他们的时序数据的很多方面。
我们展示了Monarch的真实运行规模,并且说明了一些支撑Monarch规模的关键设计决策及其意义。同时,我们也分享了我们在研发,运维和演化Monarch过程中的经验教训,希望这些对那些正在构建和运维超大规模的监控系统的用户有一定的意义。
这篇文章的下文是按照这样的内容组织的。首先在第二节,我们描述了Monarch的系统架构和关键组件。在第三节,我们详细解释了他的数据模型。我们在第四节说明了Monarch的数据采集。查询子系统,包括查询语言,执行引擎,索引等会在第五节进行展示。第六节是这套系统的全局配置管控中心。在第七节,我们实验性的测试和衡量了Monarch的能力指标。在第八节,我们把Monarch和业界相关的工作进行了对比。我们最后在第九节分享了一些我们在研发运维Monarch过程中的经验教训,在第十节对这篇文章做了总结。
2. 系统全貌
Monarch的设计思想是由它的主要用途(用于监控和预警)所决定的。首先,Monarch 以一致性为代价换取了高可用和分区容错性。从一个强一致的数据库(像Spanner)读写数据可能会阻塞较长的时间;这对于Monarch是不可接受的,因为这会潜在的在意外发生时候延长平均故障发现时间和平均故障恢复时间。为了及时地将告警发出去,Monarch必须提供最新的数据;为此Monarch丢弃了延迟写入的数据,然后在必要时仅返回部分数据给查询。在网络分区的场景下,Monarch可以继续支持其用户的监控和告警需求,并通过一些机制说明数据可能不完整或不一致。第二点,Monarch的关键告警路径上,需要尽量减少外部依赖。为了减少依赖,Monarch将监控数据存储在内存中,尽管这样成本较高。谷歌大多数的存储系统,包括Bigtable,Colossus(GFS的后继系统),Spanner,Blobstore,F1,都依赖Monarch提供的稳定的监控服务;因此,Monarch不能将这些系统使用在关键的告警路径上,以防止潜在危险的循环依赖关系。最终,那些把Monarch作为一个全局时序数据库使用的非监控性应用(比如quota servcies) ,必须接受潜在可能不一致的监控数据。
Monarch的主要架构组织原则就跟图1中展示的那样,是区域性的zone自己负责自身本zone的监控,再加上一个全局的管理和查询服务。本地监控让Monarch保持数据就近存放于采集源,减少了传输开销,延迟以及其他的可靠性问题,并能让一个zone内的监控和zone外的组件保持相对的独立。全局的管理和查询支持了一些全球化部署的系统的监控,给予了在一个统一的视图俯瞰系统全貌的能力。
每个Monarch zone是由一组集群所组成的一个自治的,内部网络强联通的独立故障隔离域。Zone内的组件通过分散副本到在多个集群之间来保障整体服务的可靠性。Monarch通过将数据存储到内存中防止强依赖,来做到每个zone可以在别的zone、全局组件、以及底层依赖的持久存储出现短暂故障时能持续的正常工作。Monarch的全局组件会做地理上的多副本,它们和zone内的组件进行交互时候会利用就近原则。
根据各个组件的功能,Monarch的组件可以分为三大类:状态持有类,数据收集链路,查询服务链路。
状态持有型组件包括这些:
-
Leaves(叶子节点),将监控数据存储在内存时序存储中。
Recovery logs(备份恢复日志)将一份与叶子节点同样的监控数据存储到磁盘上。这部分数据最终会重写到一个长周期的时间序列存储(由于篇幅限制,在这里没有讨论)
一个全局的 configuration server (配置服务器) 以及它在zone内的镜像 将配置数据存储到了Spanner数据库。
- Leaf是内存中的缓存存储模块,Log用来做持久化和高可用,在服务失效的时候可用于快速恢复数据。
数据采集链路包括:
-
Ingestion routers (采集路由)会通过数据中的时间序列key中的信息来决定将数据路由到对应的合适的Monarch zone中的叶子路由。
Leaf routers(叶子路由)会承接将要存储到本zone的数据,并且将其路由到合适的叶子节点做真正的存储。
Range assigners(域分配器)会管理数据到叶子节点的分配关系,用于平衡一个zone内各个叶子节点的负载
数据查询链路包括:
-
Mixers 将查询拆分成很多可以路由并且被叶子节点执行的子查询,并且合并这些子查询的结果。查询可能会被提交到全局根节点(root mixers)级别,或者在一个zone级别(zone mixers)。全局根节点级别的查询需要使用到root mixer和zone mixers这两者。
Index Servers (索引服务器) 会为每个zone和leaf节点索引数据,指引查询的执行。
Evaluators 会周期性的提交 standing queries (译作常驻查询)到mixer,并且将执行结果回写到叶子节点。(5.2 节中有详细的讲解)
值得注意的是,在这个架构中,leaf节点是特殊的,他们支持了全部三大功能组件。另外,查询执行也会同时存在于zone内和全局层面。
3. 数据模型
抽象的理解,Monarch 将监控数据以时间序列的组织形式存储在有固定结构的表中(schematized tables)。每个表由多个 key列(key columns)作为时间序列key,还有一个值列用来存储这个时间序列的历史数据点。具体可以看图2,有一个例子。key列也被称之为field,有两个来源,目标实体和指标,具体定义如下。
3.1 目标实体
Monarch用目标实体(target)这个概念来将每个时间序列和它的来源实体(或者说是被监控实体)做关联,具体比如说,生产这个时间序列指标的进程或者VM。每个target代表了一个被监控的实体,并且遵从一个定义了有序的目标列名(target field)和列的数据类型目标实体表结构(target schema)。图2展示了一个比较常用的目标结构,叫做ComputeTask。每个compute task目标对应了一个在Borg集群中运行的任务,它有四列:user, job, cluster 和 task_num。
为了就近原则,Monarch会将数据存放到靠近数据产生地的位置。每个taget的结构有一个列是标记作为location字段的(位置标记);这个位置标记列的值决定了一条时间序列数据会被路由和存储到具体哪个Monarch zone。举个例子,ComputeTask的位置字段就是cluster,每个borg的集群被映射到了(通常是最近的那个)Monarch zone。同时如5.3节中说明的,位置字段同时用于优化查询的执行。
在一个zone内,Monarch把相同目标实体的时间序列数据存储到同一个leaf节点,因为它们源于同一个实体,并且更可能在一个join 类型的查询中被一起查询。Monarch 会把target 分组成一个个没有交集的目标范围域,具体是这种形式: [Sstart, Send)左闭右开区间,其中Sstart和Send是目标的字符串表示的起始和结束值。目标的字符串表示意思是指通过将目标实体的表结构列名和值拼接起来组成的一个可以代表目标实体的字符串。比如图2中的一个目标的字符串表示(ComputeTask::sql-dba::db.server::aa::0876)代表了一个borg集群上的一个数据库服务实例。目标实体范围主要用作按字典序的分片和不同叶子节点之间的负载均衡(具体会在4.2节中讲到)。这样的做法让Monarch在提供查询服务时,针对相邻的目标实体(译注:同类型的)数据聚合效率得到了提升(具体参看5.3节)。
3.2 指标
一个指标度量了一个被监控实体的某一个方面,比如说一个task已近服务了多少个RPC请求,一个VM的内存使用率等等。与目标实体类似,一个指标是有一个固定的指标的结构的(metric schema),这个结构定义了时间序列数据的值类型以及一组指标字段。指标的命名类似文件。图2展示了一个衡量服务的rpc请求的延迟的示例指标,它叫做 /rpc/server/latency。他有两个指标字段叫做servcie和command,这两个字段区分了不同的rpc请求。
指标的时间序列数据值类型可以是 boolean,int64,double,string,distribution,以及其他类型的一个多元组(tuple)。所有的类型都是标准数据类型,除了distribution,它是代表一大组double类型数据的(统计特征)的组合类型。一个数值分布包含了一个直方图,它将double数值的集合分区到了几个称之为桶的子集,并且将桶中的数据用一些统计值比如平均值,总量以及标准差等来描述。为了平衡数据的粒度(精度)和存储成本,桶的边界条件是可以配置的:用户可以为他们的数据,包括一些通用的数据,指定一个更好的桶划分规则。图3展示了一个/rpc/server/latency指标的示例的数值分布类型时间序列,用于衡量服务器在处理RPC请求的延迟情况。它有一个固定10ms为粒度的桶配置。一个时间序列的数值分布类型的点是可以有不同的桶边界条件配置的,在查询的时候会使用插值法来将跨度包含不同桶边界配置的点一起使用。数值分布是一个在需要总览大量采样数据时候的有效的特性。针对系统监控的时候,平均延迟是不够的,我们还需要其他的统计数值比如99分位或者99.9分位值等。为了有效的获取这些信息,直方图或者也就是数值分布,是不可或缺的。
Exemplars(样本)。每个数值分布数据类型中的一个桶内,会包含一份这个桶中的数据的一个原始样本。一个RPC指标的样本,比如说/rpc/server/latency ,可能就是一个Dapper的RPC trace链路。这对于定位高延迟RPC请求的问题是非常有用的。并且,一个样本会包含它的采集源目标的信息以及指标的各种字段值信息。这些信息会在数值分布类型数据聚合的时候得到保留,因此用户是可以通过这些样本轻易的查出有问题的任务。图4展示了一个通过数值分布类型的时间序列数据绘制的热力图,它还包含了一个可能可以解释图中长尾延迟的慢RPC的样本。
Metric Types (指标类型)。一个指标可以是当下的观测值或者累计值。每一个观测值的时间序列数据的一个点,它的值是一个瞬时状态的度量。比如队列的长度,它是在这个数值点的测量时间的观测到的。对于累计型的时间序列的每个点,它的值是某一个观测维度从一个起始时间到观测时间为止的一个累计度量。例如,图三中的/rpc/server/latency 是一个累计指标:每个点都代表了从起始时间到观测时间为止的所有的RPC请求的数值分布,而这个起始时间,可以是这个RPC服务程序的启动时间。累积型的指标是相对棒的,当某些点丢失之后,剩余数据点仍然是有意义的,因为实际上每个点都包含了所有在它之前(起始时间之后)的点的变化信息。累计型的指标对于支持分布式系统的监控是非常重要的,因为这些系统包含了很多会经常被任务调度所重启的服务程序,在重启时数据点不可避免地会丢失。
4. 可扩展的采集
为了实时收集大量的时间序列数据,Monarch使用了两个分而治之的策略以及一个核心的优化:在数据采集的同时就进行数据聚合。
4.1 数据采集链路总览
图1的右半部给出了Monarch的采集链路的整体架构图。两个层面的路由实现了两层分而治之的策略:采集层路由(ingestion router)通过时间序列数据的location field(位置字段)将数据划分到并路由到一个个zone内,叶子路由(leaf router)根据目标域分配器(range assigner)数据分发到不同的叶子节点。这里回顾一下,每个时间序列数据都是和一个目标实体所关联的,并且其中一个目标实体的字段就是位置字段。
将时间序列数据写入到Monarch划分为了四个步骤:
-
一个客户端(client)将数据发送到了一个就近的采集路由(译注:承担了类似网关的职责),采集路由是在所有的集群内都有部署的。客户端通常是使用我们的 instrumentation 库(译注:应该是google内部用来做apm的分析,嵌入到进程的库)。这个库会自动按照一个能满足数据保存策略的频率将数据写入到Monarch。
采集路由会根据数据中目标实体的位置字段的值,找到数据存储的目标zone,并且将数据转发到目标zone的叶子路由。位置字段到目标zone的对应关系是配置在采集路由上的,并且是可以动态更新的。
叶子路由会将数据转发到对应的叶子存储节点。这个节点负责的目标实体域包含这条数据的target。在每个zone内,时间序列数据是根据目标实体的字符串表示的字典序来进行分片的(具体参看4.2节)。叶子路由维护了一份在持续更新的目标范围域映射,这份映射会将一个个目标实体域映射到三个叶子节点副本。需要注意的是,叶子路由是通过叶子节点而不是域分配器来更新范围域映射的。所有的目标域覆盖了整个字符串的可能取值空间。所有新的目标实体可以在没有分配器的干预下找到应该去的位置。因此数据采集功能在域分配器遭受一些临时故障的时候仍然能持续工作。
每个叶子节点会将数据写入到它的内存存储,并且写入备份恢复日志(recovery log)。这个内存时序存储是经过高度优化过的:它(1)能把时间戳高效地进行编码,并且时间戳序列可以在同一个目标实体对象的不同时间序列进行共享。(2)它可以处理复杂类型的时间序列数据值的增量和游程编码(一种压缩技术),包括对于数值分布类型和多元组。(3)支持快速读取,写入和做快照。(4)可以在查询或者改变目标域映射的时候提供持续的服务能力。(5)尽可能的减少了内存碎片和分配的临时对象。为了能平衡CPU和内存的开销,这个内存存储仅仅做了一些轻度的压缩,比如说时间戳共享以及增量编码。时间戳共享是非常高效的:平均一个时间戳的序列会被10个左右的时间序列数据所共享。
注意叶子节点并不会同步等待成功写入备份恢复日志(recovery log)。叶子节点会将日志写入到分布式文件系统(Colossus)在不同集群的独立实例中,并且会根据检测出来的日志健康状态做独立的故障切换。然而,Monarch实际需要在所有的Colossus实例都不可用的情况下能够继续工作,因此,定下了这种尽力而为的日志写入策略。备份恢复日志会被打包重写到一个适合快速读取的格式(叶子节点写入的时候是以一种对写比较友好的优化格式),并且会由后台持续不断运行的进程合并到长周期的存储中,这部分细节我们在这篇文章中略过了。所有的日志文件同时也会异步复制到三个不同的集群中来提高可用性。
叶子节点在数据采集过程中也会触发zone内和整体的索引服务的更新,这些索引服务可以用来限制查询放大(具体查看5.4节)。
4.2 zone内的负载均衡
如上所说,一个表的结构由一个目标实体的结构和指标结构所组成。zone内的数据根据字典分片的逻辑仅仅使用了和目标实体结构相关的列。这极大地降低了采集中的写入放大,在单个写入消息中,一个目标实体可以一次发送单个包含了上百个不同指标内容的时间序列点;一个目标实体的所有的时间序列在一起,意味着这个写入消息仅仅需要最多去三个不同的叶子节点副本。这不仅能让一个zone可以通过加更多叶子节点的方式来水平扩容,还让大多数的查询被限制在叶子节点的一个很小的子集中执行。并且,经常被用户使用一个target内部的关联查询,可以被下推到叶子节点上执行,这让查询代价更小,执行速度也会更快(具体参考5.3节)。
此外,我们是提供给用户配置异构的副本策略的(1-3个副本)能力的,帮助用户来平衡可用性和存储成本。每个目标域的副本有相同的边界,但是他们的数据体量和占用的CPU负载可能是有区别的。举个例子,一个用户可能只在第一个副本上保存了合适时间粒度的数据,然而另一个用户保存了全部三个副本,并且数据是一个非常细的时间粒度。因此,域分配器会把每个目标实体域单独进行调度分配。当然一个叶子节点是肯定不会被分配到一个目标实体范围域的多个副本的。通常,一个Monarch 的zone会包含分布在不同的故障恢复域(集群)的叶子节点。分配器会将一个目标实体范围域的副本调度到不同的故障恢复域。
范围域分配器做负载均衡的方式是类似Slicer的。在每个zone内,域分配器会在叶子节点之间切分,合并,并且移动目标范围域,用来应对由于存储叶子节点上的目标范围域导致的CPU和内存使用负载变化。由于target range的分配是持续不断的在变化的,数据采集通过recovery log来做到了无缝的平滑切换。举个例子(范围分裂和合并是非常普遍的),当域分配器决定削减一个叶子节点的负载而去移动一个范围域的分配关系(R)时,会发生如下的这些事件:
-
范围域分配器选择了一个当前负载较低的目的节点,来将 R 分配给它。这个目的节点通过通知叶子路由知道自己是负责R这个范围的新节点,从而开始承接R这个范围域的数据收集工作。新节点会写入key在R这个范围的时间序列数据(到内存),同时它也会写recovery log。
为了让源节点的日志数据(recovery log)能顺利落盘,目的节点会等待一秒钟,然后开始通过恢复日志,以时间逆序的顺序恢复R这个范围域的旧数据(新的数据更加重要)。
当目的叶子节点已经完整地恢复了R这个域的数据,它会通知域分配器将R的分配关系从源节点上摘除。然后源节点就会不再继续收集R这个域的数据,并且将内存存储中关于R的数据全部丢弃。
在以上的这个过程中,源和目的叶子节点都是在同时持续的采集,存储这些数据并且写入recovery log。这样范围域R的数据可用性就能得到持续可用的保证。注意,是叶子节点而不是域分配器来保证叶子路由上关于目标实体范围域的对应关系得到更新。这个设计的主要是考虑到两个原因:(1)叶子节点是数据被存储的地方,它有最真实的信息;(2)这样能让整个系统在域分配器出现问题的时候进行优雅降级。
4.3 采集层聚合
对某些监控场景,如果把客户端写入的时间序列数据原封不动的存起来是令人望而生畏的。一个典型场景是监控上百万服务器的磁盘I/O。每个IO操作(IOP)都会被记录到上万的goole内部用户中的一个。这样就产生了上百亿的时间序列,原封不动的存下来开销是非常大的。而事实上,可能仅仅关心的是聚合后的每个用户在集群所有服务器上的IOPs总和。采集层聚合通过在数据收集过程中进行聚合的方式来把这个问题解决了。
增量时间序列。我们通常推荐客户端对像磁盘IOPs之类的指标使用累计型时间序列指标,因为累积型的指标在丢失部分点的情况下是更加稳健的(参看 3.2节)。然而,对不同起始时间的累计型值进行聚合的时候是毫无意义的。因此采集层聚合要求产生指标的目标对象把累计型指标的相邻两个点的delta而不是裸值直接进行写入。例如,每个磁盘服务器可以每Td秒间隔把过去Td时间内单个用户的IOP执行次数进行写入。采集路由会收到这些写请求,并且将对同一个用户(的数据)的全部写请求转发到同一批叶子节点副本。这些delta值可以在client被预聚合,也可以在采集路由被预聚合,当然最终它会在叶子存储节点被聚合。
分桶。在采集层聚合过程中,叶子节点会根据差值代表的时间区间的结束时间,把一个个差值(delta)放到连续的时间桶中,具体图5中有说明。桶的长度Tb是最终输出的时间序列的周期,这个是可以由用户进行配置的。不同的最终输出时间序列的桶的对齐边界是不同的,以便进行负载的平滑。写入到某个桶中的差值,最终会根据用户指定的聚合函数(reducer)被聚合成一个点。比如,磁盘IO的话,用户可以选择sum函数,将一个用户的对所有服务器上的磁盘操作加总起来。
准入窗口。此外,每个叶子节点也维护了一个滑动的准入窗口,它会拒绝比窗口长度Tw还要早的数据。因此,更早先的桶会变成不可变的,并且生成最终可以使用增量和游程编码来高效存储的数据点。准入窗口的机制也让Monarch可以快速从网络拥塞中恢复,如果没有这个机制的话,叶子节点可能会被延迟的流量打爆,并且永远都跟不上当前的数据,而这些当前的数据对于关键报警来说是更重要的。实践中,被拒绝的写入只占通信量的一小部分。一旦一个桶的结束时间从准入窗口中移出,那么这个桶就被最终确定了,聚合后的数据点会被写到内存存储,以及恢复日志中。
为了处理时钟漂移的问题,我们使用TrueTime(译注:这是个特有的概念,参考文献中应该有)来标记差值,桶和准入窗口的时间。为了在大的采集流量和时间序列的准确性这两点上做折中,差值周期Td是在实践中被设置成了10秒。准入窗口的长度 Tw = Td + TT.now.latest - TT.now.earliest,其中TT是TrueTime。桶的长度,1s <= Tb <= 60s,这个是由用户进行配置的。最终确定一个桶的数据需要花费 Tb +Tw 这么长时间,因此在最长的Tb为60s的情况下,恢复日志通常是延迟达到70s左右。在目标范围进行移动的时候,Tb会被临时设置成1s,因为70s对负载均衡操作来说太长了,这个70s中可能叶子节点已经被压挂了。
5. 可扩展的查询
为了查询时间序列数据,Monarch提供了一种由分布式引擎支持,强表达能力的语言。该引擎使用静态不变量和一种新的索引来本地化查询执行。
5.1 查询语言
一个 Monarch 查询是一个类关系代数的表操作的工作流,每个操作会读一个或者多个时间序列数据表作为输入,并且输出一个单表。图6展示了一个返回如图7结果的这么一个查询:按程序构建标签(比如二进制版本)细分的一组任务的RPC延迟分布。这个查询可以用来检测导致rpc服务延迟的异常发布版本。图6 的每一行都是一个表操作。
第一行的fetch操作读取了图2中由命名好的target和metric的结构所定义的时间序列数据表。在第四行,fetch操作读取了同一个目标实体结构和这个target结构的指标/build/label组成的字符串时间序列值,字符串内容是二进制的构建标签。
filter操作有一个谓词,该谓词针对每个时间序列进行计算,并且只通过那些谓词为true的时间序列。第2行的谓词是user字段上的单个字段相等的谓词。谓词可以是任意复杂的,例如就像第五行写的那样,将字段谓词与逻辑运算符组合在一起。
第3行上的align操作生成一个表,其中所有时间序列都从同一开始时间,以相同且有规律的时间戳间隔开的。delta一个窗口的操作,将align过后的每个时间序列点与过去一个小时区间之间的延迟数值分布进行了估算。对于任意需要合并时间线的操作比如 join或者group_by,有一个(时间戳)对齐的输入是非常重要的。系统可以在需要的地方自动提供align,就像图中对/build/label一样(缺少显式的align操作)。
第6行的Join操作对被大括号包围的,分号分隔的子查询执行后获取的输入表中的key列做了一个inner join。它生成了一个包含两个输入表的的key列的表,以及一个有两个值列的时间序列:延迟分布和构建标签。输出包含了每一对公共列值都想匹配的时间序列点。left,right和full join都是支持的。
第7行的group_by操作让每个时间序列的key列只包含label(构建标签)。然后他会把所有的key的值相等的(同一个build label的)时间序列点(数值分布)聚合起来。图7展示了它的结果。
图6中展示出来的操作仅仅是所有可用的操作中的一个子集,其他操作包括了根据数值计算的表达式求top n时间序列,聚合同一时间线的不同时间点的数据,聚合不同时间线的数据,重新映射结构,修改key和值列,union 输入表,用任意的表达式计算时间序列值,比如从数值分布类型中提取分位值等。
5.2 查询执行概览
系统中有两种类型的查询:ad-hoc查询和常驻查询。Ad hoc 查询的诉求通常来自系统以外的用户。而常驻查询是周期性执行的物化视图查询,它的结果会回写到Monarch中。常驻查询通常被各个团队用来:(1)为后续能更快的查询或者节省成本而去浓缩固化数据;(2)生成告警。常驻查询可以被zone内的evaluator 和 全局的根 evaluator所执行。具体谁来执行的决策是通过对于查询的静态分析,查询需要的输入表的表结构(具体会在5.3节中论述)。大部分的常驻查询是由zone内的evaluator执行完成的,同时它会把同一个查询发送到查询输出结果对应的zone 的mixer,并且把输出结果也写到那个zone。这种查询是高效且对网络分区的情况有很强的适应性的。zone内和根的evaluator会根据他们处理的常驻查询来进行哈希分片的,这样就能让我们将系统扩展到能处理百万级别的常驻查询。
Query Tree 查询树。就跟在图1中展示的一样,全局的查询是在一个三层树状结构之上执行出来的。root mixer收到查询请求,然后拆解到zone mixer,zone mixer会把查询拆解到那个zone的叶子节点上。可以在zone内执行完成的常驻查询会被直接发送到zone内的mixer。为了限制读放大,root mixer和zone mixer会找索引服务(index server)获取一组潜在关联的节点来具体执行这个查询(具体参看5.4节)。如果field hints index 指出一个叶子节点或者一个zone可能包含这个查询相关的数据,那么他们就会被认为和这个查询是相关联的。
Level analysis 执行层次分析。当一个节点接收到一个查询请求时,他会决定每个查询操作应该在哪个层次执行,并且仅把需要更下层执行的部分发送下去(具体看5.3节)。并且,这个执行树的根节点会负责安全性和权限控制校验,当然也可能会把这个查询进行重写,用于静态优化。在查询的执行过程中,更低层次的节点读出并计算数据,让后以流的形式将输出的时间序列发送到更高层次的节点,高层次节点会将所有子节点时序的结果进行合并。高层次节点会根据每个参与此次查询的子节点的网络延迟分配一个缓冲区来存储时间序列数,同时也会用一个基于令牌的流控算法限制流的速率。
Replica resolusion副本分析。因为数据的副本策略是高度可配置化的,可以使用不同的周期和频率来保留时序数据。另外,因为目标范围域(的分配)是会移动的(参看4.2节),某些副本可能正在恢复中,没有完整的数据。为了在时间范围,密度和完整性等方面选择存有最佳质量数据的叶子节点,zone内查询在处理数据之前要经过副本解析过程。查询关联的叶子节点会返回匹配的目标以及对应的数据质量汇总信息,然后zone mixer会把这些目标分片到若干目标范围域中,根据数据质量会为每个目标范围选择单个叶子节点(去执行)。然后每个叶子节点对发送给它的针对它上面的目标范围的表操作进行求值。虽然域分配器(range assigner)是包含了目标信息的,副本分析还是完全通过叶子节点之上的目标数据来完成的。这防止了对range assigner 的依赖,也防止了range assigner处理负载过高。在执行查询的过程中,关联的数据因为范围移动以及数据保留过期策略而可能被删除的,为了解决这个问题,叶子节点会对输入数据做一个快照,直到查询处理结束为止。
User isolation 用户隔离。Monarch是作为一个共享的服务提供出来的,执行查询的节点上的资源是被不同用户的查询所共享的。在用户隔离方面,查询的内存占用是在本地(本节点)和跨节点都被跟踪到的,如果一个用户的查询使用了过多的内存,这么这个查询就会被强制杀掉。查询用的线程会被放到用户级的cgroup中,每个cgroup被分配了一部分的CPU执行时间。
5.3 查询下推
Monarch会把查询的表操作的执行尽可能地下推到数据存储的源头位置。下推会用到数据分布的静态不变量(这个特性是从目标表结构定义中得到的)来决定一个操作在在哪个层次可以被充分的执行完。充分执行完的意思是,这个层次的每个节点给出了这个操作所有输出的时间序列数据的一个不相交的子集。这样,后续的操作就可以从这个层次开始做了。查询下推增加了可以被执行的查询的规模,也减少了查询延迟,因为(1)更多的操作在低层次执行意味着更高的并行度以及更加平摊分配的负载;(2)全部或者部分的聚合操作在更低层次被执行完极大的减少了需要被传输到更高层次节点的数据体量。
Pushdown to Zone 下推到zone。回顾一下,数据是通过目标的位置字段(location field)路由到zone内的。对于特定位置的数据,它只可能存在于一个zone内。如果一个操作的输出时间序列仅仅用从单个zone获取的输入数据能执行完的,那么这个操作就可以在zone这个层级完成。举个例子,一个输出的时序数据的key包含了location字段的group_by操作,一个对两个有共同的location字段的join操作,它们都是可以在zone内执行完的。因此,需要由root evaluator提交的常驻查询仅剩下两类,(a)操作一些所谓无地域zone内来的输入数据,这些无地域的zone通常用来存储那些没有位置字段的常驻查询的结果数据;或(b)聚合来自多个zone的数据,具体比如说聚合中丢掉输入的时序数据的location字段,求来自不同zone的时序数据的top n的操作等。实践结果上看,下推到zone这个设计让 95% 的常驻查询完全在zone内这个层次上能执行完(用zone evaluator),极大提升了对网络分区故障的容忍性。而且,这也通过减少从root evaluator到叶子节点的跨域的请求,极大的降低了查询延迟。
Pushdown to leaf 下推到叶子节点。在4.2节中,我们提到一个zone内的数据是根据目标范围域来分片到所有的叶子节点上的。因此,一个叶子节点要么包含一个目标的所有数据,要么完全没有。对于一个目标对象的操作会在一个叶子节点的层面完成。比如,一个在输出结果中保留了所有目标实体的字段的group_by操作,一个输入数据中包含了所有的目标实体的字段的join操作,这两个操作都是可以在叶子节点这个层面完成。对一个目标实体内的join操作在我们的监控流量中是非常常见的,比如用同一个目标实体缓慢变化的元数据时间序列进行过滤等。在图6的示例查询中,join操作会在叶子节点上完成,/build/label 可以被当做是目标实体(比如运行中的task)的元数据(或者一个属性),这个属性仅仅在有新的二进制版本发布的时候才会变化。此外,由于一个目标范围域包含了一段连续的目标实体(比如,这些实体的前几个目标字段是一模一样的),一个叶子节点通常包含了一次查询相关联的多个目标实体。聚合操作也被尽可能的下推了,甚至在查询操作无法在叶子节点完成的情况下也是如此。叶子节点会聚合聚集在一起的目标实体的时间序列数据,然后将结果发送到mixer处。示例中的group_by 操作会在全部三个层次执行。无论是一个节点有多少输入的时间线,它对每个分组仅产生一条时间线(示例中的就是每个build label一条时间线)。
Fixed Fields(固定/常量字段)。某些字段可以通过静态分析查询语句和表结构得出,在查询时会是常量,这个特性也被用于下推更多的查询操作。比如,当我们通过过滤操作filter cluster == “om” 来获取某个特定的cluster的时序数据时候,一个本来是全局层面的聚合操作就可以在zone内完成。这是因为包含特定cluster om 的输入的时序数据只会存储在一个zone内。
5.4 Field Hints Index
为了提高可扩展性,Monarch用了所谓的field hints index (FHI)索引。FHI存储在索引服务器中(index server),用于限制一个查询从父节点到子节点发送时候的放大。具体方式是通过跳过不相关的子节点(也就是那些不包含特定查询需要的输入数据的节点)。FHI是一个简洁的,对所有子节点的时序数据字段值构建的,并且持续更新的索引。FHI 通过分析查询的字段谓词来跳过无关子节点。并且FHI对处理正则表达式谓词是非常高效的,它无需完整迭代具体的字段值。FHI可以在包含数万亿时序key,超过一万叶子节点的zone很好的运作。在这种体量下,它仍然能保持一个足够小的体积,以便在内存中能够存的下。就像布隆过滤器一样,对FHI来说,假阳性是可能存在的。也就是说FHI返回结果中可能还会包含一些无关子节点。因为无关的子节点会在随后的副本分析中被忽略掉,所以这个假阳性是不影响数据的正确性的。
一个field hint就是一个字段值的摘要信息。最常用的hints是三字符组成的。比如^^m,^mo,mon,ona,nar,arc,rch,ch$,以及h$$是monarch这个字段值的三字符hint,其中^和$分别代表了一段文本的开始和结束。一个field hint 索引本质上说就是一个从字段hint的fingerprint(指纹)到包含这个hint的子节点集合的一个多值映射。一个所谓的fingerprint是从一个hint的三部分信息:表结构,字段名以及摘要信息(三字符摘要)固定生成的int64类型的数字。
当要下推一个查询的时候,一个根(或者zone内的)mixer会从查询中提取一组约束性的field hint,然后查询一下根(后者zone内的)FHI 以确定目的zone(叶子节点)。我们以图6中的查询为例,他的正则谓词 “mixer.*” 给出了这些 field hint:^^m,^mi,mix,ixe,以及 xer。任何符合这个谓词要求的子节点必须包含全部这些三字符hint。因此,只有在 FHI[^^m] ∩ FHI[^mi] ∩ FHI[mix] ∩ FHI[ixe] ∩ FHI[xer] 的子节点是需要执行具体查询的。
我们尽量缩小了FHI的体积,让它可以存储在内存中,以便在二级存储(译注:磁盘或外部存储,但是实际内存也会坏,这点感觉还是有点强词夺理)出现故障的时候仍然能正常工作。将FHI存储在内存中同样也加速了查询和索引更新。FHI用精确性损失换取了更小的体积:(1)它索引了短小的摘要信息来减少唯一hint的数量。比如,实际上对于小写字母来说,最多只有26的三次方个唯一的三字符hint。因此,在前面的例子中,FHI会认为一个包含了目标job:“mixixer”的叶子节点也是关联到查询的,但是事实上这个叶子节点的目标并不能满足正则表达是“mixer.*”。(2)FHI对每个字段是单独处理的。这就导致了当查询中含有使用多个字段的谓词操作会,FHI的结果会出现假阳性现象。比如,如果一个叶子节点包含了两个目标实体的时间序列:user:‘monarch’,job:‘leaf’ 和 user:‘foo’,job:‘mixer.root’,那么他会被FHI认为是符合user==‘monarch’&&job=~‘mixer.*’这个谓词演算的(图6)。其实这两个目标实体都不满足这个条件。
尽管 FHI 的体积是很小的(几个G或者更小),但是在zone内它减少了99.5%查询的放大,在根上他减少了80%的放大。FHI还有其他四个附加属性:
(1)三元字符索引的方式能让FHI过滤使用了正则表达式的字段计算条件。RE2这个库可以将正则表达式转化成一组三元字符的代数计算表达式和操作(集合交并)。为了匹配一个正则谓词计算,Monarch就简单地把它的三元字符组在FHI中查询一下,然后再执行这个表达式。
(2)FHI支持使用不同的摘要信息来细粒度的平衡索引精度和索引大小。比如,包含较少字符集合字符串字段(比如ISBN)可以被配置成除了使用三元字符串外,还使用四元字符和整个字符串来做摘要信息,以提高精确性。
(3)Monarch 综合使用了静态分析和FHI来深度降低包含join操作的查询的放大:它只把图6示例中的查询发送到了满足全部两个过滤谓词的叶子节点。(这个查询包含了一个叶子节点层次的inner-join,而且这个join只会在满足全部两个条件的节点上才会有输出)。这个技术也会类似地应用于其他语义的嵌套join查询中。
(4)metric name(指标名)也是被索引了的,具体是通过全字符串的方式。它会被当成保留字段“:metric”的值。因此,FHI甚至是可以在没有没有任何谓词语句的查询中被利用到。
就跟图1中所说的,FHI是自底向上构建出来,并且在索引服务器中维护着。由于它体积比较小,FHI索引是不需要被持久化存储的。当一个索引服务器启动的时候,它可以在几分钟内就从存活的叶子节点上获取数据并构建出来。每个zone内的索引服务器都维持着和所有这个zone内叶子节点的RPC长链接,这样就可以持续不断的更新这个zone的FHI。而根的索引服务器同样的持续不断的从各个zone中获取变化信息来更新根的FHI。FHI是通过一个有较高保障等级的网络传输来更新的。通过根索引服务器丢失索引更新消息这个指标,每个zone服务的不可用状态可以比较可靠的检测出来。这个指标让全局查询对zone的不可用性有比较好的适应性。
叶子节点中的类似索引。前文介绍的Field hints index是在索引服务器上的,在每个查询定位相关联节点的这个过程中提供了巨大的作用。而在每个叶子节点中,同样有一个类似的索引,它可以帮助每个查询在本叶子节点负责的一大堆实体对象中,找到相关联的目标实体。总结一下,一个查询从根节点开始,然后用根层的索引服务器中的FHI找到查询关联的zone,然后它使用zone的索引服务器提供的FHI服务找到查询具体关联的叶子节点,最后它是使用叶子接点中FHI来找到最终相关的目标实体。
5.5 可靠的查询
作为一个监控系统,对Monarch来说,优雅地处理失败和故障是非常重要的。我们前面已经讨论过,Monarch的zone在一些极端场景比如说文件系统故障,或者全局组件出问题的时候仍然能持续提供服务。这里我们讨论下我们是如何让查询能适应zone级别的故障和叶子节点级别的故障。
Zone pruning(zone裁剪)。在全局层面,我们需要保护全局查询免受区域性故障的影响。长期的统计显示,几乎所有的(99.998%)成功执行的查询在给定超时时间过去一半时候就开始从zone内传输数据了(到全局服务层)。这让我们能强制给定一个针对每个zone更短的软查询超时限制,这个软限制是一种可以简单检测被查询的zone的健康状态的方法。一个zone如果在给定的软限制内是完全没有响应的,那么它就会在查询中被裁剪掉。当一个zone发生不可用状态时,这给了它在没有较大程度影响查询执行延迟情况下的一个反馈响应的机会。当发生zone裁剪现象时候,用户会感知到,因为查询结果中包含了这个信息。
Hedged reads(对冲读)。在一个zone内,一个单查询可能仍然会放大到要查超过一万个叶子节点。为了让查询能适应慢叶子节点,Monarch会尝试从响应更快的副本中读取数据。就像 4.2 节中说的,叶子节点上保存着与查询相关的重叠的但是不相同的的目标实体集合。由于我们下推了可以把某个叶子节点上所有相关的目标数据聚合的操作,不同叶子节点上的数据输出是没有简单的等价可比性的。即使是叶子节点返回了一样的输出时间序列key,这也可能是从不同的输入数据中聚合而来的。因此普通的对冲读方式并不起作用。
Monarch 用一种新的,可以读隔离的方法构造查询路径上输入数据的等价性。如前所述,zone mixer在副本分析的过程中选择了一个叶子节点(称之为主叶子节点)来对每个目标范围跑这个查询。zone mixer同时也会为负责每个目标范围的主叶子节点构建一个备用叶子节点集合。zone mixer从主叶子节点读取的时间序列进行处理,同时跟踪它们的响应延迟。如果主叶子节点没有响应或者是异常的慢,那么zone mixer会复制这个查询到等价的备用节点上去执行。这个查询仍然并行地在主节点和备用节点执行,zone mixer会从这两个节点中响应较快的返回中提取数据,并且做去重。
6. 配置管理
由于Monarch天生就是作为一个分布式,多租户的服务来运行的,它需要一个中心化的配置管理系统来提供方便的,细粒度的控制能力。配置管理系统需要能管理用户的监控配置,并且将配置分发到整个监控系统的各个角落。用户会和一个单一的全局配置视图来进行交互,而配置会影响到Monarch的各个zone。
6.1 配置分发
所有的配置修改都是被配置服务器(configuration server)来承接。如图1所示,配置服务器会将配置存储到一个全局的Spanner数据库。每个配置在提交之前都是对其依赖做过校验的(比如对于一个常驻查询来说,它的表结构就会被校验)。
配置服务器同样负责将高层次的配置(更贴近用户的)转换成一个分发效率更高的形式,这种底层形式的配置会在其他组件中缓存。例如,叶子节点只需要知道一个常驻查询的输出结构就可以存储其结果。在配置系统中进行这种转换可以确保Monarch组件之间的一致性,并简化客户端代码,从而降低错误配置更改导致其他组件崩溃的风险。配置服务器会对依赖关系进行跟踪,以使这些转换保持最新。
配置状态会被复制到每个zone内部的configuration mirrors(镜像配置服务器),然后才会被分发到zone内的其他组件。这让配置链路即使是在网络分区的情况下,也能保持高可用。zone内的组件比如叶子节点会将自身相关的配置缓存在内存中,以减少配置查询的延迟。这些组件的配置缓存是在启动的时候从镜像配置服务器中获取的,然后进行周期性的更新。通常,缓存中的配置是最新的。但是如果镜像配置服务器不可用了,那么zone内的组件可以继续工作,只是配置不是最新的,此时监控系统的SRE会收到告警,然后来人工介入处理。
6.2 配置的多个方面
对于一些通用的目标实体和指标结构,预置的配置已经被部署好了,包括采集,查询和预警等阶段。这样使得新用户能在尽量少操作的情况下就能获取基本的监控能力。用户也可以部署他们自己的配置以充分使用Monarch的灵活性。以下的小节描述用户配置的主要几个部分。
6.2.1 表结构
针对通用的计算负载和库,监控系统中是有预置的目标实体结构和指标结构的,比如像第三节中描述的ComputeTask和/rpc/server/latency,这样使得他们的数据能被自动的收集。高阶用户可以定义他们自己的自定义的目标实体结构,以便用到Monarch的各种灵活能力来支持各种类型的实体的监控。Monarch提供了一个监控库,让用户能方便地在他们的代码中定义有结构的指标。这个库还会根据6.2.2节中描述配置,周期性的发送一些时间序列度量数据到Monarch中。随着用户的监控需求的演化,他们可以方便的增加列,表结构会自动地进行后台更新。用户可以对他们的指标空间设置访问控制策略,防止其他用户对他们的指标结构进行修改。
6.2.2 采集,聚合和数据保存策略
用户有对数据保存策略的细粒度控制能力。比如,那个指标从哪个目标实体中采集,如何保存他们等。他们可以控制数据采样的频率,数据保存多长时间,存储介质是什么,以及要存储多少副本。用户还可以对一定时间前的数据进行降精度,以减少数据的存储成本。
6.2.3 常驻查询
用户可以拉起一些周期性执行的常驻查询,这些查询的结果会回写到Monarch中(具体见5.2节)。用户可以把他们的常驻查询配置成分片的方式进行执行,以便处理超大的输入。用户可以配置预警,其本质就是常驻查询的输出之后跟了一个和用户配置的告警条件做比较的布尔逻辑判断。当然,还可以配置当告警条件出发时候,信息发送给用户的方式(比如通过页面或者邮件)。
7. 系统评测
Monarch 部署了挺多套测试性质的集群,以及三套生产环境的集群:internal,external,meta。internal和external是提供给google内部和外部的用户使用的。meta集群相对特殊,使用的一个已经被持续验证过稳定性的老版本。通过它,我们来监控了所有其他的Monarch部署集群。下面我们只展示了从我们internal集群上获取的数据,它不包含外部用户的数据。需要注意的是,Monarch自身的规模不是单纯的一个被监控系统规模的函数。事实上,它会被很多其他因素大大影响,比如持续的内部优化,有多少的数据是被聚合了的,那些层面被监控了等。
7.1 系统规模
Monarch的内部部署集群被google内部超过三万名员工和各个团队频繁使用。它运行在了遍布5大洲的38个zone中。它包含了大概40万个task(其中重要的那些被列在了表1)(译注:task应该约等于阿里的容器,不过不知道google的资源规格是啥样的)。这些task中大部分是叶子节点,因为他们是真正作为内存时序存储在提供服务的。根据zone内有多少叶子节点我们来做一个分类,现在有 5 个小 zone (< 100 个叶子节点),16个中型zone(< 1000个叶子节点),11个大型zone(< 10000个叶子节点),以及最后还有6个超大的zone(>= 10000 叶子节点)。每个zone包含了三个range assigner,其中一个range assigner会被选成主节点。其他表1中的组件(config,router,mixer,index server以及evaluator)都是同时出现在zone内和根层。全局的根层的task数是比zone内对应的组件要少的,因为根层的task会尽量把工作负载下推到zone内的task中。
Monarch的独特架构和优化让它成为一个高度可扩展的系统。它从开始提供服务到现在保持了高速的增长,并且规模还在快速增长中。图8和图9展示了我们内部服务集群的时间序列数量和采集的数据量(byte)。到2019年7月的时候,Monarch存储了大约9500亿时间序列,在已经高度优化过存储数据结构后,消耗了大约750TB的内存。适应这样的增长速度不仅需要关键组件有高度的水平扩展能力,也需要对采集和查询有一些创新性的优化,比如采集时聚合(4.3节有说),field hint index(5.4节)。
在图11中,2019年7月的时候,Monarch的内部服务集群采集了大约2.2TB数据每秒。从2018年到2019年,采集急剧增长了一倍,主要是因为采集层聚合这个能力开放了之后,能采集一些包含的时间序列key达到上百亿的指标(比如磁盘io)。平均下来,Monarch会把36个输入的时间序列在采集层聚合层一个时间序列,在极端场景下,超过百万的输入时间序列会被聚合成一个。采集层聚合是极其高效的,它可以用单核cpu聚合超过一百万典型的时间序列。除了显而易见的内存节省(因为只需要存更少的时间序列),采集层聚合仅仅使用了原先写入原始数据,然后用常驻查询计算并将结果输出到Monarch这种模式的25%的CPU。
7.2 可扩展的查询
为了评测查询性能,我们列出了关于查询下推,field hints index(FHI,5.4节)关键的统计数据和查询延迟。我们也用示例查询测试了多种优化对于性能的影响。
7.2.1 查询性能总览
图 10 展示了Monarch的内部集群的查询频率:它维持了一个指数级的增长曲线。在2019年7月的时候,实际服务体量已经达到了600万QPS了。大约95%的查询就是常驻查询(包括告警查询)。这主要是因为用户使用常驻查询是为了(1)减少那些明确会被经常查到的数据的响应延迟;(2)为了告警。而用户通常是偶尔才提交一些ad-hoc 的非常驻查询。并且,大部分的这种常驻查询是由zone内的evaluator初始化提交的(而不是由根层的evaluator)。原因是Monarch比较激进的将这些可以独立地在某个zone内执行完成的常驻查询下推到了zone内的evaluator,以整体上减少root层evaluator的那些不必要的工作负载。
为了量化从zone mixer到叶子节点的查询下推的效果,我们测算了叶子节点的输出数据和输入时间序列数据相比的整体比例,大概只有23.3%。换句话说,查询下推让zone mixer这个层面需要处理的数据减少了四分之三。
除了查询下推,field hints index是另外一个支撑大规模查询的关键技术。表2展示了关于全局层的FHI和某些zone的FHI的相关统计数据。全局层的FHI大概包含了1.7亿的摘要信息,平均算下来,它大概把全局层的查询放大减少了从34 * (1 - 0.758) = 9,在这其中有大约 9 * 0.45 = 4个zone实际包含需要的数据。每个zone包含的叶子节点数量有挺大的差异,因此他们内部FHI的摘要信息数量也是差异很大。然而,所有zone 的FHIs的抑制率均能达到99.2%或更高。FHI的命中率在不同的zone内表现不一,从15.7%到 60.5%都有。整体上看,FHI在较小的zone内有更高的命中率,因为当一个zone包含很少的目标实体的时候,field hits的假阳性是不太可能出现的。FHI的空间效率是很高的,平均算下来,一个摘要信息仅占用了1.3Byte的内存。huge-zone-2包含了最大量的摘要信息,(6.54亿),而它的FHI的大小是808M。我们能拿到这样的效果是因为我们对叶子节点用短整形编码,并且对一些常用的摘要信息的整形编码用了位图的方式来存储。
在图12中的数据显示,全局根层的查询的平均延迟大概是79ms,99.9%分位的延迟是6s。延迟的差异来源主要是一个查询的输入时间序列数量。一个中型的查询一般仅仅看单个时间序列,而99.9%分位的查询会查到12500个时间序列。每个zone的查询延迟也是与挺大差异的。整体上,小的zone查询更快(译注:这不废话)。但是也有例外:可以显著看到,large-zone-2的平均查询延迟比large-zone-1和large-zone-3的平均查询延迟要高得多。这是因为large-zone-2中查询平均输入的时间序列数量是其他两个大zone的两倍以上。大型和超大型的zone的99.9%分位查询延迟都在50s左右。这些每个查了900万到2300万时间线的常驻查询的开销非常大。其中很多都是对有一个zone内每个job的所有task聚合一些常用指标(比如:预定义的指标/rpc/server/latency)。因为这种指标可能是会被很多用户用到的,我们自动拉起了对应的常驻查询,以防止用户提交冗余的查询(做重复计算)。
7.2.2 单个查询的性能
表3 展示了各种查询性能优化对我们图6中的示例查询的性能影响。这个查询大概读取了30万的输入时间线。field hints index筛选出了6.8万个叶子节
如表3中的数据展示的,当查询下推和field hints index都打开时,这个查询会在6.73秒内完成。如果我们(1)在叶子节点关掉局部聚合,(2)在叶子节点和zone mixer都关掉局部聚合,那么查询分别耗时9.75秒和34.44秒,分别变慢了1.4倍和5.1倍。这是因为,如果没有在叶子节点和zone mixer上的局部聚合,更多的时间序列数据需要被转发并且由更上层的执行层进行聚合。而在那里并行度是更低的(也就是仅有一个root mixer和并行的被很多叶子节点执行)。
进一步看,如果我们让join只在(1)zone mixer上执行,(2)只在root mixer上执行,并且聚合操作也在同层级或者更高层级执行,那么查询需要耗费242.5秒和1728.3秒来完成,分别是变慢36倍和256.7倍。将join操作的执行从更低层次移动到更高层次会增加跨层级之间的时序数据的传输,因为join的两侧都需要将输入的时间序列数据传输到上层,而这其中有些数据会被inner join过滤掉。而且,更高层级的节点会串行处理这些更大量的时序数据,这会显著增加处理延迟。而且得益于field hints index做的将叶子节点的inner join的两边的谓词条件过滤出来的叶子集合求了交的优化,叶子节点级的join也让读放大从92k降低到了68k叶子节点(参看5.4节中FHI的第三个附加特性)。
最后,如果我们执行这个查询的时候不去访问全局和zone内索引服务器以及叶子节点内部的FHI,这个查询需要耗费67.54秒,导致了变慢10倍。这说明,FHI是减少查询放大,并降低查询延迟。具体是因为(1) FHI通过排除7.3万无关的叶子节点,减少了读放大;(2)叶子节点中的索引也同样帮助排除了大量的不相关的目标实体和时序数据。
8. 相关工作
时间序列数据的爆炸性增长推动了对其收集[35]、聚类[34,2]、压缩[11,33,6]、建模[44,23]、挖掘[17]、查询[4,43]、搜索[32,38]、存储[3]和可视化[31]的大量研究[29,5,48]。最近的研究大多集中在无线传感器网络[11,33]和物联网[24]的受限硬件中管理和处理时间序列。较少有研究涉及云环境规模下实时存储和查询数据的时间序列数据管理系统[37,30]。
有很多开源的时间序列数据库[5];Graphite[16]、IntruxDB[27]、OpenTSDB[12]、普罗米修斯[39]和tsdb[15]是最受欢迎的几个数据库。它们将数据存储在了二级存储(本地的或者分布式的,比如HBase)。二级存储的使用使它们不太适合用于关键监视。和Monarch的一个zone类似,它们支持能水平扩展的分布式的部署,但是他们缺乏一个全局的配置管理以及查询聚合能力,而这都是Monarch已经提供的服务。
Gorilla是facebook的内存时序数据库。与Monarch的结构化数据模型不同,Gorilla时间序列由字符串键标识。Gorilla缺乏一个表达能力丰富的查询语言。Gorilla跨地域复制数据以进行灾备,但这限制了网络分区期间的可用性。相比之下,Monarch在附近的数据中心复制数据以实现数据的本地化。Gorilla也没有与Monarch的全球级查询引擎,也没有提供支持类似引擎的优化,比如基于FHI的查询执行本地化,以及查询下推。Gorilla缺少的其他Monarch特性包括:(1)丰富的数据类型,例如带着数据采样的数值分布类型;(2)采集优化,包括字典序分片执行和采集层聚合;(3)针对数据存储策略的细粒度配置;(4)常驻查询和警报查询。
Monarch的采集层聚合通过在数据收集的过程中将时间序列数据进行聚合,减少了累积性指标的存储开销。这种方法是和无线传感器网络中的in-nerwork aggregation 方法类似的。
9. 经验教训
过去十年的活跃研发和用户使用过程中,Monarch的特性,架构和核心数据结构都在持续的演化。我们学到的关键经验包括如下几点:
对时间序列数据key进行字典分片提升了数据收集和查询阶段的可扩展性。这让Monarch的一个zone可以扩展到上万个叶子节点。来源于同一个目标实体的指标可以在一条消息中被发送到对应的目标叶子节点。在查询中对于目标实体的聚合和join操作可以在叶子节点内完成。当相邻的目标实体被同一个叶子节点所负责时,对于他们之间的数据聚合也会变得更有效率。这样可以减少读放大,并且减少数据在叶子节点和mixer之间的传输。
基于push模式的数据采集会在减小系统复杂度的同时提高系统的稳定性。早期版本的Monarch主动去发现被监控实体,并且通过查询被监控实体的方式“拉取”监控数据。这样的架构需要部署发现服务和代理,增加了系统的复杂度,并且让系统更加不稳定。基于push模式的采集,每个实体可以单纯地将他们的数据发送到Monarch,减少了这些外部依赖。
结构化的数据模型提升了稳定性和性能。与使用非结构化数据的Borgmon这样的系统相比,Monarch在配置方面需要花费更多的精力,但对结构化数据进行操作可以使查询在执行之前得到验证和优化。根据我们的经验,由于我们便利和灵活的配置管理系统,和Borgmon相比结构化信息并没有对我们用户引入较大的负担。
系统扩展是一个持续的进程。比如索引服务器,采集层聚合,以及常驻查询的分片执行是在我们完成Monarch的初始设计之后为了解决扩展性问题而引入的。我们在不断的重构Monarch的架构以支持更好的水平扩展,同时也在持续演化我们的数据结构和算法来支撑更大规模的数据体量和新的使用模式。
Monarch这样一个多租户的大规模服务对用户是很方便的,但是对这个系统的开发来说非常之蛋疼。用户在使用Monarch的方式上具有极大的灵活性,并且与服务的操作层隔离。但是,多种使用模式的并存使得保障系统稳定性成为一个挑战。为了使Monarch满足可用性,正确性和延迟的SLO目标,使用计量计费、数据清理、用户隔离和流量限制等功能是必需的。优化需要对几乎所有使用方式都能生效。对Monarch的代码更改必须向后兼容,以允许进行优雅的实时更新或者应对可能发生的回滚。随着Monarch持续吸引更多强调系统许多不同层面能力的用户,我们也在不断改善Monarch的多租户支持。
10. 总结
Monarch是一个可以处理万亿级别的时间序列数据的全球级多租户内存时序数据库。它的部署横跨了多个地理上隔离的数据中心。由于Monarch有一个自治的区域化监视子系统架构,再通过全局配置和查询层集成到一个统一的整体中,它可以在此规模上可以高效且可靠地运行。它采用了一种新颖的,类型丰富的关系时间序列数据模型,该模型可实现高效且可扩展的数据存储,同时为丰富的数据分析场景提供强大的查询语言。为了适应如此大规模的需求,Monarch采用了多种优化技术来进行数据收集和查询执行。对于数据采集,Monarch使用了zone内负载平衡和采集层聚合以提高可靠性和效率。对于查询执行,Monarch以分布式,分层的方式执行每个查询,采用激进的过滤和聚合下推以提高性能和吞吐量。这个过程中会利用紧凑而强大的分布式索引来进行有效的数据裁剪。
自从最初部署到生产环境以来,Monarch的使用率一直持续多年增长。当前,它每秒采集数TB的数据,在内存中存储接近PB的高度压缩后的时间序列数据,并且每秒处理数百万个查询。Monarch可以满足Google十亿用户规模的监视和警报需求。它作为基础架构层,激发了上层许多新用例,包括用于警报的异常检测,用于持续集成和金丝雀部署分析以及用于Google集群上自动调优task规格来优化资源使用率。