我们用了一天时间1搭建了一个系统,它立即发现了一个价值数百万美元(中七位数)的优化方案(并最终成功上线)。在随后的一年里,该系统为我们带来了每年数千万美元(中八位数)的成本节约。这个系统的核心功能是能够跨所有主机、所有服务以及任意时间范围(自系统启用以来)查询指标数据,因此我们内部将其命名为LongTermMetrics(LTM),因为我喜欢这种直白且描述性的命名方式。

这个项目的起因是,我当时正在寻找一个既能帮助我理解Twitter基础设施栈,又能产生可量化价值的入门项目。Andy Wilcox建议我研究一些大型服务的JVM幸存者空间利用率。如果你不熟悉幸存者空间的概念,可以将其理解为JVM中一个可配置的固定大小缓冲区(至少在使用Twitter默认的GC算法时如此)。当时,如果你随机查看一个大型服务,通常会遇到以下两种情况之一:

  1. 缓冲区过小,导致性能不佳,在高负载下甚至可能造成灾难性后果。
  2. 缓冲区过大,导致内存浪费,也就是资金浪费。

但与其随机抽查服务,我们完全有理由能够查询所有服务,并获取一份按性能下降程度或成本节约潜力排序的、存在配置优化空间的服务列表。既然我们能为JVM幸存者空间编写这样的查询,那么它同样适用于其他配置参数(例如其他JVM参数、CPU配额、内存配额等)。然而,由于数据一致性和性能问题的双重影响,编写一个适用于所有服务的查询比预想的要困难得多。数据一致性问题包括:

  • 任何给定的指标可能有大约100种名称,例如我发现JVM幸存者空间就有94种不同的名称
    • 我怀疑实际数量更多,这些只是我通过简单搜索找到的
  • 相同的指标名称在不同服务中可能含义不同
    • 可能是计数器或仪表盘
    • 可能采用不同单位,例如字节 vs MB,或微秒 vs 毫秒
  • 指标有时会带有错误的服务名称标签
  • 僵尸分片可能在集群管理器已启动新实例后继续运行并上报指标,导致特定分片名称出现重复且不一致的指标数据

我们的指标数据库 MetricsDB 专为处理监控、仪表盘、告警等场景而设计,不支持通用查询。这完全合理,因为在马斯洛可观测性需求层次中,监控和仪表盘的位置低于通用指标分析。在与来自其他公司人员的私下讨论中,围绕 MetricsDB 的整套系统似乎解决了许多其他公司类似规模下困扰人们的问题,但这种专业化意味着我们无法对 MetricsDB 中的指标运行任意 SQL 查询。

另一种查询数据的方式是使用以 Parquet 格式写入 HDFS 的副本,这允许人们运行任意 SQL 查询(以及编写使用这些数据的 Scalding(MapReduce)作业)。

不幸的是,由于指标名称数量庞大,HDFS 上的数据无法以每列一个名称的列式格式存储——如果你向 Presto 输入太多列,它会变得不稳定,而我们拥有的不同指标数量远超这个限制。如果不使用列式格式(也不应用其他技巧),任何非平凡查询都会读取大量数据。结果是,你无法在所有服务或所有主机上运行任何非平凡查询(甚至许多简单查询),否则就会超时。Scalding 没有类似的超时限制,但 Scalding 性能要差得多,针对一天指标数据的简单 Scalding 查询通常需要三到二十小时(取决于集群负载),这使得 Scalding 不适合任何探索性数据分析。

鉴于已有的数据基础设施,解决这两个问题的一个简单方法是编写一个 Scalding 作业,存储我们关心的用于性能或容量相关查询的 0.1% 到 0.01% 的指标数据,并将其重新写入列式格式。我猜测至少 90% 的指标在绝大多数情况下几乎没人会查看,而在人们真正关心的指标中,绝大多数与性能无关。这样做的一个好处是,由于只有极小部分数据相关,长期存储成本很低。标准指标数据转储会在几周后删除,因为数据量太大,长期存储成本过高;更长的指标记忆对于容量规划或其他需要历史数据的分析将非常有用。

我们保存的数据包括(但不限于)每个服务的每个分片的以下内容:

  • 各种缓冲区的利用率和大小
  • CPU、内存及其他利用率
  • 线程数、上下文切换、核心迁移
  • 各种队列深度和网络统计信息
  • JVM 版本、功能标志等
  • GC 统计信息
  • Finagle 指标

以及每个主机的:

  • procfs 中的各种指标,例如 iowait 时间、idle 等。
  • 机器所属的集群
  • 主机级信息,如网卡速度、主机核心数、内存
  • 主机级“健康”状态统计,如热节流、机器检查等
  • 操作系统版本、主机级软件版本、主机级功能标志等
  • Rezolus 指标

对于我们知道变化非常频繁的事物(如主机网卡速度),我们每天存储一次,但大多数指标以与其他指标相同的频率和粒度存储。在某些情况下,这显然有些浪费(例如,JVM 分代晋升阈值通常在同一服务的每个分片上完全相同且很少变化),但鉴于我们现有的指标基础设施,这是处理此问题的最简单方式。

尽管这个项目的初衷是找出哪些服务的 JVM 幸存者空间配置不足或过度,但它最初是从 GC 和容器指标开始的,因为这些是非常明显的观察对象,此后我们逐步添加了其他指标。为了让你了解我们可以查询哪些内容以及如果懂一点 SQL 查询有多简单,以下是一些示例:

非常高的 p90 JVM 幸存者空间利用率

这是寻找配置不足/过度服务的原始目标的一部分。任何 p90 JVM 幸存者空间利用率非常高的服务,其幸存者空间很可能配置不足。同样,在峰值负载下 p99 或 p999 JVM 幸存者空间利用率非常低的服务,则可能配置过度(此处未显示查询,但我们可以将查询范围限定在高负载时段)。

一个用于查询所有服务中非常高的 p90 幸存者空间利用率的 Presto 查询如下:

with results as (
  select servicename,
    approx_distinct(source, 0.1) as approx_sources, -- 服务的分片数量
    -- 实际查询使用 [coalesce 和 nullif](https://prestodb.io/docs/current/functions/conditional.html) 处理边界情况,此处为简洁省略
    approx_percentile(jvmSurvivorUsed / jvmSurvivorMax, 0.90) as p90_used,
    approx_percentile(jvmSurvivorUsed / jvmSurvivorMax, 0.50) as p50_used,
  from ltm_service
  where ds >= '2020-02-01' and ds <= '2020-02-28'
  group by servicename)
select * from results
where approx_sources > 100
order by p90_used desc

无需查看大量仪表盘,我们可以直接获取列表,然后向相关团队发送带有配置变更的差异,或者编写一个脚本,将查询输出自动写入差异。上述查询为任何基本利用率数值或速率提供了模式;你可以通过类似查询查看内存使用、新生代或老年代 GC 频率等。在一个案例中,我们发现某个服务浪费的 RAM 足以支付我十年的薪水。

我逐渐不再使用阈值对比简单百分位数来发现问题,但之所以展示这个查询,是因为这是人们常做且确实有用的操作,而且我无需花大量篇幅解释其合理性;我偏好的替代方案不在本文讨论范围内,可能需要单独撰文说明。

网络利用率

上述查询针对所有服务,但我们也可以跨主机进行查询。此外,还能结合主机属性、功能开关等条件进行联合查询。

通过一组查询,我们发现即使主机级网络利用率较低,仍有大量服务接近网络限制。随后计算平台团队逐步调整了网络上限,我们通过类似下文的查询进行监控,确认未出现性能降级(理论上增加网络上限可能导致主机或交换机触及网络瓶颈)。

网络调整后,我们观察到队列深度减小、队列大小(字节数)降低、丢包减少等现象。

为简洁起见,下文查询仅展示队列深度;若要添加上述所有指标,只需在查询中增加更多字段名称即可。

我们普遍能做到的是:针对平台或服务级功能的任何特定发布,都能观察其对实际服务的影响。

with rolled as (
 select
   -- 发布期间所有主机固定不变,可从时间段内任选元素
   arbitrary(element_at(misc, 'egress_rate_limit_increase')) as rollout,
   hostId
 from ltm_deploys
 where ds = '2019-10-10'
 and zone = 'foo'
 group by ipAddress
), host_info as(
 select
   arbitrary(nicSpeed) as nicSpeed,
   hostId
 from ltm_host
 where ds = '2019-10-10'
 and zone = 'foo'
 group by ipAddress
), host_rolled as (
 select
   rollout,
   nicSpeed,
   rolled.hostId
 from rolled
 join host_info on rolled.ipAddress = host_info.ipAddress
), container_metrics as (
 select
   service,
   netTxQlen,
   hostId
 from ltm_container
 where ds >= '2019-10-10' and ds <= '2019-10-14'
 and zone = 'foo'
)
select
 service,
 nicSpeed,
 approx_percentile(netTxQlen, 1, 0.999, 0.0001) as p999_qlen,
 approx_percentile(netTxQlen, 1, 0.99, 0.001) as p99_qlen,
 approx_percentile(netTxQlen, 0.9) as p90_qlen,
 approx_percentile(netTxQlen, 0.68) as p68_qlen,
 rollout,
 count(*) as cnt
from container_metrics
join host_rolled on host_rolled.hostId = container_metrics.hostId
group by service, nicSpeed, rollout

其他迎刃而解的问题

  • X 的延迟、CPU 使用率、CPI 或其他性能影响是什么?
    • 增加或减少每个容器监控的性能计数器数量
    • 调整内核参数
    • 操作系统或其他版本更新
    • 增加或减少主机级别的超额订阅
    • 主机级别的常规负载
    • 重试预算耗尽
  • 对于上述相关项,X 的分布情况如何(一般情况或特定条件下)?
  • 哪些主机在控制负载等因素后,其上每个服务的服务级性能都异常低下?
    • 这通常是由于硬件配置错误或故障导致的
  • 除了对主机级别负载的总体影响外,哪些服务与其他服务不兼容?
  • 故障转移或其他高负载事件对延迟的影响是什么?
    • 考虑到未来高负载事件加上当前增长,我们应预期未来达到何种负载水平?
    • 哪些服务在故障转移期间负载增加,哪些服务负载不变,哪些介于两者之间?
  • 对于任何固定大小的缓冲区或分配,我们可以进行哪些配置更改,以在不增加成本的情况下提高性能,或在不降低性能的情况下降低成本?
  • 对于某个特定的主机级别健康问题,如果我们观察到 N 次,它再次发生的概率是多少?
  • 等等,如果你能对历史指标数据编写任意查询,很多问题都会变得容易回答。

设计决策

LTM 是一个尽可能平淡无奇的系统。每一个设计决策都遵循阻力最小的路径。

  • 为什么使用 Scalding?
    • 它是 Twitter 的标准工具,集成过程让一切变得非常简单。我尝试过 Spark,它有一些优势,但当时我需要手动完成集成工作,而 Scalding 则免费提供了这些功能。
  • 为什么使用 Presto 而不是像 Druid 这样支持实时切片和切块查询的工具?
    • Rebecca Isaacs 和 Jonathan Simms 正在做相关的追踪工作,我们知道需要将 LTM 与他们创建的任何内容进行连接。这在 Presto 中很简单,但使用 Druid 这样的工具则需要更多的规划和工作量,至少在当时是这样。
    • George Sirois 将一部分数据导入到 Druid 中供我们试用,它提供的功能非常出色;未来某个时候可能值得重新考虑。
  • 为什么不使用 Postgres 或类似工具?
    • 我们要存储的数据量使得这变得不可行,除非投入大量精力;尽管数据存储成本很低,但这仍然是一个“大数据”问题。
  • 为什么使用 Parquet 而不是更高效的格式?
    • 它是标准支持格式中最合适的一种(另一种主要支持的格式是原始 Thrift),引入新格式将比这个项目本身大得多。
  • 为什么系统不是实时的(延迟至少一小时)?
    • Twitter 的批处理作业流水线易于构建,只需阅读一些关于其工作原理的教程,然后编写类似的内容,但业务逻辑不同即可。
    • 在我加入 Twitter 之前几年,有一份写得不错的提案,旨在为指标数据构建实时分析流水线,但从未实现,因为(我估计)需要一到四个季度的工作才能产生一个最小可行产品,而且不清楚哪个团队既有合适的任务授权,又有四个季度的人力可用。但添加一个批处理作业只需一天,你不需要为一天的工作召开路线图和规划会议,可以直接做,然后逐步进行后续工作。
    • 如果我们寻找的是配置错误或优化机会,这些很少在一小时内消失(如果真的消失了,它们的影响肯定很小),实际上,它们通常持续数月到数年,所以放弃实时性并不会损失太多(我们确实失去了将输出用于某些监控用例的能力)。
    • 实时版本将是一个运营成本高昂的系统,无法由一个人在不承受过度负担的情况下操作。这个系统的运营/维护负担比我期望的要大,平均每月大约需要我 1-2 天的时间,这目前占系统总成本的很大一部分,但它从不触发告警,而且工作量可以轻松由一个人处理。

平淡无奇的技术

我认为,像这样纯粹是枯燥工作的系统,其价值被严重低估了。我读到的文章和演讲中,有相当大比例都在讨论采用热门技术的系统。我对热门新技术并无成见,但大量实用工作恰恰来自将平淡无奇的技术组合起来,并做显而易见的事。由于关于枯燥工作的文章和演讲相对罕见,我认为记录这类内容比它理应得到的关注更有价值。

举个例子,几年前在 Matt Singer 为我们这种规模的公司组织的本地聚会上(主要讨论基础设施,即比 Facebook/亚马逊/谷歌规模小的公司),我问是否有人做过与我们刚完成的工作类似的事。当时在场的人都没有(至少没人承认),而且来自两家不同公司的工程师对我们能存储如此海量数据感到震惊——不仅存储了每个时间段的平均值,还保留了部分直方图信息。这项工作过于直白浅显,谈不上新颖,我确信许多地方都有人构建过类似系统。它本质上只是将指标数据以支持交互式查询的格式,无限期地存储在 HDFS(或者用更通用的术语说,数据湖)上。

如果计算一下我们这种规模公司此类项目所需的指标数据存储成本,那基本可以忽略不计。我们交付的单个差异补丁所节省的成本,就足以轻松覆盖数十年的存储开销。我认为,当人们日常部署着成本高得多的分析和可观测性工具时,存储几年甚至十年量级的指标数据根本不该令人震惊。但事实证明这确实令人意外,部分原因就在于没人会记录这种平淡无奇的工作。

举个不相关的例子:不久前,我在一家规模类似的公司遇到一个人,他也想从指标数据中获取类似的洞察。但他没有像这个项目那样从一天就能完成的工作入手,而是直接开始了深度学习。虽然我认为将机器学习或统计学应用于基础设施指标是有价值的,但他们把一个原本只需几个人天就能为公司带来显著价值的项目,变成了一个耗费数人年的项目。如果你只能在以下两种方式中选择一种:要么由具备基础设施经验的人运用简单启发式方法和简单统计模型,要么天真地应用深度学习——我认为前者的投资回报率要高得多。将复杂的统计/机器学习与从业者指导的启发式方法结合起来,确实能比单独使用任何一种方法获得更好的结果,但我认为从一天就能搭建、再花一两天就能开始应用的小项目入手,远比从需要数月甚至数年才能搭建并应用的大项目要合理得多。然而,存在许多偏向于做大项目的偏见:它能让简历更好看(深度学习!);在很多地方,它更有利于晋升;人们也更愿意就这个酷炫的深度学习系统发表演讲或撰写博客文章。

以上讨论了为什么撰写工作成果对整个行业有价值。我们在之前的文章中已经探讨过为什么撰写工作成果对撰写方公司有价值,因此这里不再赘述。

附录:我搞砸的事情

我认为不幸的是,如果没有私下交流,你很难了解到系统的缺点,因此以下是我事后看来相当明显的错误。如果以后有其他事情变得显而易见,我会继续补充。

  • 几乎所有地方都使用双精度浮点数
    • 在理想情况下,有些东西本不该用双精度,但我们指标栈中的每个指标都会经历一个阶段,最终都被转换为双精度
    • 我将大多数“应该”是整数类型的东西存储为整数类型,但执行 long -> double -> long 的转换永远不会比直接进行 long -> double 转换更精确,反而会引入其他问题
    • 我将一些本不该是整数类型的东西存储为整数类型,导致小数值不必要地丢失精度
      • 幸运的是,这在我进行的所有可操作分析中尚未造成严重错误,但在某些分析中确实可能引发问题
  • 使用断言而非将错误条目写入某种“错误条目”表
    • 由于本文范围之外的原因,在 Scalding 作业中并没有合理的方式来记录错误或警告,因此我使用断言来捕获不应发生的情况,这导致每次出现意外时整个作业都会崩溃;更好的解决方案是将错误的输入条目写入一个表,然后如果该表不为空,通过邮件发送该表作为软警报
      • 一个本可节省运维成本的例子是,我们曾遇到异常严重的时钟偏差(3600 年),导致时间戳溢出。如果我有一个记录错误条目的表,那么错误条目会被从输出中忽略,这是正确的行为,并且可以避免中断以及推送修复和重新部署作业的麻烦
  • 代码中的 Longterm 与 LongTerm 大小写不一致
    • 最初编写时我不确定该用哪种大小写,做出决定后也未能通过 grep 查找并统一所有错误写法,因此现在各处存在这种无意义的不一致

这些都是快速编写代码且未充分考虑时可能出现的问题。最后一项很容易修复,问题也不大,因为 Twitter 广泛使用 IDE,基本上任何受影响的人都能通过 IDE 自动补全正确的大小写。

第一项问题更大,不仅可能实际导致分析错误,而且修复它需要迁移所有现有数据。我估计这需要半周到一周的工作量,而当初我只需多花三十秒思考一下就能轻松避免。

第二项介于两者之间。在第一项和第二项之间,我大概为这个系统直接投入了双倍的工作量(不包括分析系统中数据所花的时间,仅指构建系统的时间),但基本没有带来任何收益。

感谢 Leah Hanson、Andy Wilcox、Lifan Zeng 和 Matej Stuchlik 提供的评论、修正和讨论。


  1. 实际工作量大约是一天,但因为需要学习 Scala、Scalding、Twitter 通用技术栈以及指标栈等,所以花了一周时间。

一天也只是对初始数据集工作量的估算。自那以后,我可能又花了几周时间,Wesley Aptekar-Cassels 和 Kunal Trivedi 也投入了大约一两周时间。运营成本大概相当于我每月 1-2 天的时间(平均下来),总成本大约在一到两个月左右。

我还没有算上使用数据集的时间,以及调试问题的时间,其中很多时间只能粗略估计。例如,当计算平台团队因一项耗时约一小时的数据分析而更改网络出口限制时,这暴露了一个潜在的 Mesos 漏洞,可能耗费了 Ilya Pronin 一天的时间;David Mackey 花了相当多的时间追踪奇怪的问题,比如数据显示异常但原因不明等等。如果要把因本文讨论的数据集分析而产生的所有相关工作都算进去,我怀疑,从服务级团队到平台级团队(如 JVM、操作系统和硬件团队),总耗时可能接近一个人年。

但由于最初创建一个可用且有用的系统只花了一天时间,再加上学习入门材料的时间,而系统带来了七位数的收益,因此所有这些额外投入的时间都很容易合理化。如果一开始就需要一年的前期工作,情况可能就不同了。其余大部分时间通常不会在路线图评审中“计入”系统创建成本(比如用户使用时间、运营开销),但也许持续的运营成本应该在创建系统时“计入”(我认为将用户使用时间“计入”系统成本并不合理,因为系统越有用,用户使用时间就越长,这似乎不算是成本)。

在此基础上,也有相关工具的开发工作。Kunal Trivedi 投入了大量时间,在此之上构建了一层更友好的展示界面,使其比 SQL 查询更易于使用——这部分工作或许可以归功于该项目。

[返回]