像大多数大型科技公司一样,优步广泛依赖指标来有效监控其整个堆栈。从底层的系统指标,如主机的内存利用率,到高层的业务指标,包括特定城市的优步外卖订单数量,这些指标让我们的工程师能够深入了解我们的服务每天是如何运行的。
随着度量的维度和使用的增加,常见的解决方案如下普罗米修斯而且石墨变得难以管理,有时甚至停止工作。由于缺乏可用的解决方案,我们决定构建一个内部的开源度量平台,命名为M3,这可以处理我们的指标规模。
M3平台的一个主要组件是它的查询引擎,它是我们从头开始构建的,并且已经在内部使用了好几年。截至2018年11月,我们的指标查询引擎每秒处理约2500个查询(图1),每秒处理约85亿个数点(图2),大约35 Gbps(图3)。由于我们堆栈的各个部分增加了对指标的采用,这些数字一直在以远高于优步的有机增长速度不断上升。
在本文中,我们介绍了在为M3设计查询引擎时所面临的挑战,该引擎必须具有足够的性能和可伸缩性,以支持每秒大量数据的计算,并具有足够的灵活性,以支持多种查询语言。
查询引擎架构
M3查询经历三个阶段:解析、执行和数据检索。查询解析和执行组件作为公共查询服务的一部分一起工作,检索通过存储节点上的薄包装器完成,如下图4所示:
为了支持多种查询语言,我们设计了一种有向无环图(DAG)格式,M3查询语言(M3QL)和Prometheus查询语言(PromQL)都可以被编译成DAG格式。DAG可以理解为需要执行的查询的抽象表示。这种设计通过将语言解析与查询执行分离,使得添加新的查询语言变得很容易。在调度DAG执行之前,我们使用一个速率限制器和授权器来确保只有授权用户可以发出请求,限制他们的速率以防止滥用。
执行阶段跟踪所有正在运行的查询及其资源消耗。它可以拒绝或取消导致任何资源耗尽的查询,例如进程的总可用内存。一旦查询开始执行,执行引擎就会获取所需的数据,然后执行任何指定的函数。执行阶段与存储分离,允许我们轻松地添加对其他存储后端的支持。
而M3DB, Uber的开源时间序列数据库,是目前唯一支持M3的存储后端,未来我们计划增加对更多存储提供商的支持,如OpenTSDB而且MetricTank.
内存利用率
随着越来越多的Uber团队开始使用M3,平台上构建了一些工具来执行异常检测、资源估计和警报等功能。我们很快意识到,我们需要限制查询的大小,以防止昂贵的查询耗尽我们服务的所有内存。例如,我们对单个查询的初始内存限制是3.5 GB,这个大小使我们能够在不过度配置系统的情况下提供合理的流量。但是,如果查询主机接收多个大型查询,它就会超载并耗尽内存。
此外,我们意识到,一旦我们的查询服务开始降级,用户将不断刷新他们的仪表板,因为查询返回太慢或根本不返回。这将通过生成额外的查询来加剧问题,这些查询将开始堆积,因为原始查询从未取消。此外,我们发现一些构建在查询服务之上的高级用户和平台正在突破这些内存限制,迫使我们考虑提高系统内存利用率的方法。
池
在查询执行期间,我们花了大量的时间分配可以存储计算结果的大量切片。因此,我们开始对系列和标签等对象进行池化,这一举措显著降低了垃圾收集开销。
在此基础上,我们假设我们的goroutine创建是非常轻量级的,事实也确实如此,但这只是因为每个新的goroutine都从一个小的2 kibibytes (KiB)内存堆栈开始。我们所有的火焰图都表明,我们花费了大量的时间来增加新创建的goroutine堆栈(运行时。morestack, runtime.newstack).我们意识到这是因为我们的许多堆栈调用超过了2 KiB的限制,所以Go运行时花了很多时间分配新的2 KiB的gorout例程,然后立即将它们丢弃,并将堆栈复制到一个更大的4或8 KiB堆栈上。我们决定通过合并gorout例程并通过a重用它们来完全避免所有这些分配工人池.
HTTP关闭通知
查看查询服务的使用统计数据,我们发现了一种一致的模式,即查询服务花费大量时间执行不再需要的查询,或已被新查询取代的查询。这可能发生在用户在Grafana中刷新仪表板时,并且在大型、缓慢的查询中更频繁地发生。在这种情况下,服务会浪费资源对长时间运行的查询进行不必要的评估。为了解决这个问题,我们添加了一个通知器,通过上下文传播检测客户端何时断开连接并取消了在查询服务的不同层之间的剩余执行。
尽管上述所有方法都显著减少了查询引擎的内存占用,但内存使用仍然是查询执行的主要瓶颈。
为下一个数量级重新设计
我们知道,如果我们要支持查询服务规模的一个数量级的增长,我们就需要一个基本的范式转变。我们为系统设计了一个完整的mapreduce风格框架,其中包含独立的查询节点和执行节点。这种设计选择背后的想法是,为了确保执行节点可以无限扩展,我们需要将查询分解为更小的执行单元(Map阶段)。一旦处理了执行单元,我们就可以重新组合结果(Reduce阶段)。
当我们开始实现我们的设计时,我们很快意识到这个解决方案的复杂性,所以开始寻找更简单的解决方案。
从我们的评估过程中得到的一个关键见解是,如果我们处理的存储后端在内部压缩数据,那么我们不应该在获取时解压缩数据,这正是M3DB存储数据的方式。如果我们尽可能地延迟解压,我们就可以减少内存占用。为了实现这一点,我们决定从函数式编程书中抽出一页,重新设计查询引擎,以惰性方式计算函数,尽可能长时间地延迟中间表示的分配(有时甚至完全取消它们)。
上面的图5演示了一个线性操作-clamp_min紧随其后的是总和.在计算过程中,我们确定有两种方法来执行这个解压缩:依次应用两个函数(方法1)或应用clamp_min而且总和(方法2)。上图概述了每种方法执行的步骤。
要尝试方法1,我们需要大约1.7 GB的内存(假设每个数据点是8字节)。在这种情况下,我们必须生成一个包含所有10K系列的块,这将占用大部分内存。因此,它的比例是系列的数量*每个系列的数据点的数量。
然而,在方法2中,我们只需要在所有系列中为给定的列维护一个切片,然后调用总和在上面。片占用80 KB内存(假设8字节数据点),最后一个块需要161 KB内存。这种方法会随着序列的数量线性地扩展内存利用率,因此可以显著降低内存占用。
数据存储
根据我们服务的使用模式,查询中的大多数转换在每个时间间隔的不同系列中应用。因此,以柱状格式存储数据有助于提高数据的内存位置。此外,如果我们将数据按时间分割成块,大多数转换可以在不同的块上并行工作,从而提高了我们的计算速度,如下图6所示:
这个决定需要注意的一点是,一些函数,例如movingAverage随着时间的推移,在同一系列上工作。对于这些函数,我们不能独立地处理这些块,因为一个块可能依赖于前面的多个块。对于这些情况,函数为每个输出块计算出依赖的输入块,并将输入块缓存在内存中,直到函数拥有输出块的所有依赖块。一旦不再需要某个输入块,它就会从缓存中被移除。
需要注意的是,其他监控系统(如Prometheus)提倡使用记录规则来预聚合度量,这样就可以避免这类问题。虽然我们有预聚合的能力,但我们发现开发人员仍然经常需要运行临时查询,这些查询最终将获取数万甚至数十万个时间序列来生成最终结果。
减少度量延迟
Uber运行在一个双活集群设置中,同时为多个数据中心以及多个云区域的流量提供服务,这意味着在许多不同的地理位置不断生成指标。我们的许多指标用例需要对数据中心的指标有一个全局视图。例如,当我们将流量从一个数据中心转移到另一个数据中心时,我们可能希望确认所有数据中心在线的驱动程序合作伙伴的总数没有改变,即使每个数据中心的数量发生了巨大变化。
为了实现这种全面的视图,我们需要一个数据中心的指标在其他数据中心可用。我们可以在写入时通过将所有数据复制到多个数据中心来实现这一点,也可以在读取时通过在查询数据时展开来实现这一点。但是,写入所有数据中心将增加与数据中心数量成比例的存储成本。为了降低成本,我们决定在查询时执行读取扇出以检索指标数据。
我们最初的查询服务实现使接收请求的实例从多个数据中心获取数据。一旦它获取了相应的数据,节点将在本地计算查询并返回结果。然而,这很快就成为了延迟瓶颈,因为跨数据中心的读取很慢,而且我们的跨数据中心连接的带宽太窄。此外,这种实现给接收初始查询请求的实例带来了更大的内存压力。
在我们的第一个版本中,跨数据中心获取指标的p95延迟比在单个数据中心获取指标的p95延迟高10倍。随着问题的恶化,我们开始寻找更好的解决方案。在跨数据中心调用期间,我们开始直接返回压缩的指标,而不是发送未压缩的指标。此外,我们开始流式传输这些指标,而不是一次性发送。这些共同的努力使跨数据中心查询的延迟减少了3倍。
降采样
除了减少从数据中心检索数据时的延迟外,我们还希望改进各种UI工具的性能,包括监视系统使用的Grafana。对于返回大量数据点的查询,将所有内容返回给用户并不一定有意义。事实上,有时Grafana和其他工具会导致浏览器在任何给定时间出现过多数据时锁住。此外,用户需要可视化的粒度级别通常可以在不显示每个数据点的情况下实现。
而Grafana则限制了基于屏幕上像素的数量来渲染的数据点maxDatapoints设置时,用户在显示较大图形时仍然会遇到延迟。为了减少这种负载,我们实现了下采样,这大大减少了查询指标时检索到的数据点的数量。
我们最初实现了一个简单的平均算法,这意味着我们将通过平均几个数据点来生成一个数据点,但这具有相当负面的影响,即隐藏数据中的峰值和低谷,以及修改实际数据点并不准确地呈现它们。
在评估了几种下采样算法后,我们决定利用最大三角三桶(LTTB)算法。该算法在保留数据的原始形状方面做得很好,包括显示用更简单的方法经常会丢失的异常值,以及只返回原始数据集中存在的数据点的有用属性。在下面的图7、8和9中,我们展示了不向下采样(图7),使用朴素平均算法(图8)和使用LTTB算法(图9)之间的差异:
M3QL
在开发M3QL之前,我们的度量系统只支持基于路径的语言Graphite(例如,foo.bar.baz).然而,Graphite并不能满足我们的需求,我们需要一个基于标记的解决方案(例如,foo: bar商业:巴兹),使查询更简单。更具体地说,我们想要一种解决方案,可以提高发现性和易用性,因为我们的指标存储规模巨大。
根据我们的经验,使用基于路径的解决方案检索指标要困难得多,因为用户必须确切地知道每个节点在指标中代表什么。另一方面,使用基于标记的方法,没有猜测,自动补全工作得更好。除了创建基于标记的查询语言之外,我们还决定使M3QL成为一种基于管道的语言(如UNIX),而不是类似sql的语言(如IFQL)或类似C表达式的语言(如PromQL)。基于管道的语言允许用户从左到右读取查询,而不是由内而外。我们在图10中分解了一个典型的M3QL查询,显示了某个端点的失败率:
支持普罗米修斯
对我们来说,将查询引擎与广泛使用的开源监控系统Prometheus集成是一个明确的决定。Prometheus的导出器,比如它的节点导出器,使得检索硬件和内核相关的度量变得非常容易。
在此基础上,我们发现许多工程师都有使用PromQL的经验,这使得团队更容易使用M3。由于我们的查询引擎现在原生支持PromQL,用户不需要学习额外的语言,也不需要为第三方导出器重新创建仪表板。我们还构建了查询引擎的API,以便用户可以通过选择Prometheus数据源类型直接插入Grafana。
期待
随着我们继续构建M3生态系统,我们致力于通过添加新功能和处理其他用例来改进我们的查询引擎。
警报和指标是相辅相成的,这就是为什么警报一直是我们的首要任务。我们目前支持在查询引擎上运行Grafana。在内部,我们运行一个更高级的闭源警报应用程序我们打算在接下来的几个月里开放源代码。
鉴于Uber的巨大增长,我们需要同时考虑短的日常查询和长时间运行的容量规划和其他汇总/报告用例,这些用例通常需要来自多个数据源的数据。为了支持这一点,我们计划创建一个Presto连接器,使用户能够运行更长的查询,并使组合来自其他数据源(如SQL或HDFS)的数据成为可能。
从M3开始
试试M3查询引擎为自己!请将拉请求、问题和建议提交给M3的提案库.
我们希望你能开始使用M3并给我们你的反馈!
如果你对大规模应对基础设施挑战感兴趣,可以考虑申请一个角色在乳房。
访问Uber的官方开源页面查看有关M3和其他项目的更多信息。






