Netflix Technology Blog

作者:Parth JainRakesh SukumarYingwu ZhaoRenzo Sanchez-SilvaNathan Fisher
深入探讨 Netflix 规模下构建实时服务依赖图的工程挑战——从流式架构和分布式聚合管道到时光旅行查询,以及让这一切成功的方法论。

引言

在我们的第一篇文章中,我们介绍了问题:Netflix 的工程师需要一个统一的实时服务依赖视图,以更快地排查故障、理解影响范围并导航分布式架构。我们描述了多源方法——将 eBPF 网络流、IPC 指标和分布式追踪合并为物理上分离的图层,这些图层可以独立查询或合并为一个综合视图。

那篇文章解释了我们构建了什么以及为什么。本文讲述的是如何——在 Netflix 规模下构建这个系统的工程现实。

事实是:第一个版本在本地环境运行完美。生产环境则是另一回事。Kafka 消费者滞后,实例内存耗尽,有些节点收到的流量是其他节点的 100 倍。垃圾回收暂停消耗的 CPU 比实际业务逻辑还多。

你将在本文中学到的不是成功故事,而是学习之旅。我们将介绍实现规模化的架构决策、考验这些决策的生产挑战、指导我们前进的优化方法论,以及适用于任何分布式系统的经验教训。在此过程中,我们将分享使每秒处理数百万流记录、随时重建拓扑并提供亚秒级查询响应成为可能的创新——同时保持近实时的新鲜度。

架构深度剖析:为流式处理和规模化而构建

流式优先:为什么实时性很重要

传统的服务拓扑系统使用批处理——每小时或每天聚合数据,然后存储完整的快照。这种方法在中等规模下可行,但有一个根本问题:当你看到数据时,数据已经过时了。在凌晨 3 点的生产事故中,一小时前的依赖图是考古学,而不是可观测性。

我们的关键架构决策是构建流式优先。我们不是使用处理历史数据的批处理作业,而是持续从多区域 Kafka 流和作为 Server-Sent Events 的 IPC 指标中摄取流记录,通过带背压处理的响应式管道处理它们,并提供近实时拓扑更新——通常在几十分钟内,相比批处理方法提供的数小时或数天前的数据。

这不仅仅是关于新鲜度——它对我们的用例至关重要。实时事件无法等待下一个小时批处理。事故响应需要当前数据。变更验证需要看到即时影响。架构必须支持持续更新,同时在不滞后的情况下处理大规模流量。

背压如何实现实时处理
流式方法带来了新挑战,但也需要解决一个根本问题:如何在不丢失下游系统变慢时的数据的情况下实时处理每秒数百万流记录?

传统方法在我们的规模下不足:

  • 无界队列:简单但危险。持续缓冲直到内存耗尽,然后实例崩溃。
  • 基于丢弃的流量控制:缓冲区满时丢弃数据。快速,但现在你的拓扑不完整——你丢失了连接信息。
  • 批处理:处理一切,但数小时之后。那时事故已经结束(或者更糟,仍在发生但使用陈旧数据)。

我们需要不同的东西:在负载下优雅减速而不丢失数据的能力。这就是响应式流和背压变得至关重要的地方。

工作原理如下:当 Stage 3 无法足够快地写入图数据库时,它向 Stage 2 发出减速信号。Stage 2 向 Stage 1 发出信号。Stage 1 向 Kafka 消费者发出暂停信号。数据在 Kafka 中等待,直到下游容量恢复。

按回车键或点击查看完整大小图片

当下游阶段无法跟上时,它向上游发出减速信号——背压沿数据相反方向流动

背压自然地在整个系统中传播。当任何阶段不堪重负时——由于流量峰值、GC 暂停或外部减速——管道会自动减速到可持续速率。大多数情况下不会丢失数据,实例不会崩溃,系统会优雅降级。

这就是在我们的规模下实现“实时”的原因。在正常运行期间,我们以最小延迟处理。在负载峰值或临时减速期间,我们减速而不是崩溃。数据仍然会被处理——只是延迟几秒或几分钟,而不是立即处理。对于拓扑更新,这种权衡是可接受的:略有延迟的实时更新远好于数小时前的批处理数据或因丢弃记录而导致的不完整拓扑。

这种方法的代价是复杂性。响应式流比传统同步阻塞模型更难推理(我们将在挑战部分进一步讨论)。但在 Netflix 规模下,背压不是可选的——它是让系统在生产负载下可靠运行的机制。

多层架构:物理分离以实现独立优化

正如我们在第一篇文章中介绍的,我们的多源方法使用三个物理上分离的拓扑层,每个层都有针对性的存储优化:

  • 网络层:图数据库分区中的 eBPF 流日志——覆盖全面但缺乏应用上下文
  • IPC 层:不同图数据库(与网络层隔离)中的应用指标——丰富的端点详情但仅限已插桩的服务
  • 追踪层:列式存储(Parquet)中的分布式追踪——实际请求路径但经过采样。(我们将在下一篇文章中介绍追踪层及其集成

按回车键或点击查看完整大小图片

流日志和 IPC 指标通过两个独立优化的管道进入单独的图存储,通过单一 API 统一

物理存储隔离实现了独立优化——每个层有不同的吞吐量、查询模式和演进时间线。在查询时,我们跨相关存储系统执行并行查询并合并结果,提供亚秒级延迟的统一视图,同时保持各层独立演进的灵活性。

三阶段分布式聚合管道

网络层摄取的核心是一个三阶段分布式管道。该架构解决了网络流日志的一个根本挑战:它们只显示单个网络跃点,而不是构建有用拓扑所需的真实应用级连接

核心问题:网络中介

在云环境中,应用之间的流量很少直接流动——它会穿越负载均衡器、NAT 网关、API 网关和代理等中间网络组件。网络流日志显示单个跃点:App A → 负载均衡器和负载均衡器 → App B 作为单独的流出现。但工程师需要的是逻辑依赖:App A → App B。如果不解析这些中介,我们的拓扑会被基础设施组件堆满,而不是显示对故障排查重要的服务到服务关系。

三阶段管道解决了这个问题:

按回车键或点击查看完整大小图片

流日志管道详解——通过 SSE 连接的三个阶段,富化在最终图写入前应用

Stage 1:初始聚合(FlowLog Ingestion Service)

Multi-Region Kafka (4 regions)
→ 过滤无效流日志
→ 5 分钟时间窗口批处理
→ 为每个窗口创建初始聚合器
→ 通过一致性哈希分发
→ 通过 SSE 流式传输到 Stage 2

Stage 1 从多区域 Kafka 消费流日志,过滤无效记录,将它们批处理到 5 分钟时间窗口中,并创建初始聚合器对象。在这个阶段,我们仍在处理原始网络跃点——识别涉及中介的流,但尚未解析它们。聚合器流式传输到 Stage 2 进行解析。

Stage 2:网络中介解析层(Intermediate GraphEntity Ingestion Service)

Stage 1 聚合器(通过 SSE 流)
→ 按中介(负载均衡器、NAT 网关、代理等)分组流
→ 识别对:(源 → 中介)+(中介 → 目标)
→ 解析为直接边:源 → 目标
→ 跟踪遍历的中介
→ 跨两个跃点聚合指标
→ 通过一致性哈希重新分发
→ 通过 SSE 流式传输到 Stage 3

这是关键步骤。Stage 2 执行图解析:

  1. 按中介收集流:将中介是源或目标的聚合器分组——创建流向中介(源 → 中介)和来自中介(中介 → 目标)的流映射
  2. 解析直接边:对于每个中介,连接其入站和出站流以创建直接应用边(App A → App B),合并两个跃点的指标
  3. 结果:显示 App A → App B 而非 App A → 负载均衡器 → App B 的干净应用级拓扑

这种解析在聚合时发生,而不是查询时,解析后的边流向 Stage 3。

为什么不能在单一阶段完成?根本问题是数据局部性。要将 App A → 负载均衡器 → App B 解析为 App A → App B,我们需要在同一实例上同时拥有两个流来执行连接。但在 Stage 1 中,流根据 Kafka 分区分散在实例上。Stage 2 的关键功能是按中介标识符重新分发聚合器——所有涉及“负载均衡器 X”的流路由到同一实例进行解析。这是经典的 map-reduce 模式:Stage 1 映射,Stage 2 按中介洗牌并归约,Stage 3 执行最终聚合。

按回车键或点击查看完整大小图片

为什么单一阶段不够的具体示例——Stage 1 按分区分散流,Stage 2 按中介重新洗牌以解析直接边,Stage 3 持久化最终结果。

Stage 3:最终聚合和富化(GraphEntity Ingestion Service)

Stage 2 聚合器(通过 SSE 流)
→ 跨时间窗口的最终聚合
→ 用外部数据富化(查询键值存储)
→ 转换为图实体
→ 持久化到图数据库(限流写入)

Stage 3 对解析后的边执行最终聚合,从外部数据源(应用健康状况、所有权、元数据)富化图节点,将聚合器转换为具体的图实体(填充所有属性的节点和边),并以受控限流持久化到分布式图数据库以遵守存储系统限制。

为什么是三阶段而不是两阶段?

我们最初使用两阶段:Stage 1 聚合,Stage 2 解析并持久化。这在测试中有效,但在生产规模下失败——Stage 2 被数据集中压垮。

问题:中介解析需要将涉及中介的所有流收集在同一实例上。因此,处理流行应用及其中介的流日志的实例由于显著的数据集中而成为“热点节点”。此外,数据富化(从外部存储查询健康状况和元数据)意味着最繁忙的实例也在执行最多的 I/O。

解决方案:将职责拆分为三个阶段。Stage 2 专注于解析并重新分发。Stage 3 处理富化和持久化。这种分级重新分发——分发、解析、再次分发、持久化——将负载分散到多个实例,并将计算密集型解析与 I/O 密集型富化隔离。即使中介看到 100 倍的典型流量,也没有单个实例成为瓶颈。

为什么使用 Server-Sent Events 而不是 gRPC 或消息队列?

我们最初使用 gRPC,但它成为性能瓶颈——流式响应的序列化开销、连接池管理和内存压力消耗的 CPU 比业务逻辑还多。消息队列为我们的用例增加了基础设施复杂性而没有带来好处。

SSE 被证明是理想的:轻量级 HTTP 协议,序列化开销最小,与响应式流自然集成背压,以及更简单的连接模型。经验教训:像“服务通信使用 gRPC”这样的行业最佳实践并非普遍适用。对于流式传输大量预聚合数据,更轻量级的替代方案可能更合适。测量,不要假设。

为什么 IPC 不需要三阶段

按回车键或点击查看完整大小图片

IPC 管道与流日志管道模式相同,但只需要单一阶段。

IPC 层使用单阶段聚合,因为:(1) IPC 指标已经是应用级别——没有需要解析的中介,(2) 数据从一开始就正确分区——每个节点通过一致性哈希接收其分配应用的所有 IPC 指标,消除了重新分发的需要。这突出了一个关键原则:数据分区策略决定处理架构。当数据以正确的分区到达时,你可以直接聚合;当它没有时(如需要中介解析的网络流),你需要洗牌/重新分发阶段。

动态负载分发:哈希如何与自动扩缩容配合工作

当我们的自动扩缩容组动态添加或移除实例时,我们如何决定哪个实例接收哪个聚合器?传统方法假设静态集群——当集群大小变化时需要显式重新平衡、协调服务或手动数据移动。

我们的方法:动态一致性哈希

我们使用一致性哈希,结合从服务注册中心动态发现实例。每个实例查询注册中心以获取当前健康 ASG 实例列表,按排序顺序维护它们(确保所有实例具有相同视图),并将此列表用于哈希函数 findOwnerInstance(aggregator.primaryKey)。当 ASG 扩缩容时,哈希函数根据更新的实例列表自然重新分发聚合器——不需要显式协调。

关键洞察:利用现有基础设施。我们的服务注册中心已经跟踪 ASG 成员以进行健康检查。将其作为真实来源为我们免费提供了动态集群成员。一致性哈希提供稳定分区(在成员变化期间大多数聚合器留在同一实例上),而排序列表确保一致性。

结果

负载自动跟随基础设施。在流量峰值或实时事件期间,新实例立即接收其份额。在部署期间,聚合器无缝转移到健康实例。这种模式对生产稳定性至关重要——无需人工干预,无需协调协议,只需自动重新平衡。

V1 之旅:生产规模下的重大挑战

将初始版本(V1)投入生产让我们认识到规模改变一切。在开发中有效的东西在生产中失效。每个假设都受到考验。修复一个瓶颈会揭示下一个。

挑战 1:Kafka 消费者滞后

问题:我们的多区域 Kafka 消费者开始滞后——消费者滞后从秒增长到分钟,然后是小时。流日志到达的速度比我们能处理的快。如果这种情况持续,我们将永远追不上,我们的“实时”拓扑将变得越来越陈旧。

调查:我们对 Kafka 消费者指标进行了大量插桩。主要发现:

  • Kafka 的分区少于消费者组大小的最佳值
  • 每次获取操作检索的记录相对较少
  • 网络套接字缓冲区未针对我们的吞吐量正确调整大小
  • 跨区域读取延迟增加了开销

应用的解决方案

  1. 增加 Kafka 分区:更多分区使消费者组中的更多并行消费者成为可能,将负载分布到更多实例。
  2. 调整获取参数:增加每次获取操作的记录数,减少网络往返次数。这以每消息延迟(我们获取更大的批次)换取吞吐量(每秒处理更多记录)。
  3. 增加套接字接收缓冲区大小:确保网络缓冲区永远不会限制获取操作。在我们的规模下,默认缓冲区大小太小。

结果:吞吐量显著改善,滞后减少到可接受水平——即使在峰值流量期间通常也低于一分钟。

经验教训:在规模化下,你无法孤立地优化。修复 Kafka 滞后揭示了下一个瓶颈:我们的实例本身无法跟上更高的摄取速率。管道移动得更快,这暴露了下游容量问题。

挑战 2:热点节点和数据放大

问题:这是我们面临的最严重的生产问题。自动扩缩容组中的一些实例收到的流量是其他实例的 100 倍。内存使用量激增。垃圾回收暂停变得频繁且漫长。更多 CPU 时间花在 GC 上而不是业务逻辑上。最终,热点实例会 DOWN 掉,触发级联故障,因为它们的负载重新分配到其他实例。

根本原因调查
流行服务的流日志主导流量量。像我们的身份验证层或推荐 API 这样的服务被数百个其他服务调用,生成的流记录数量级多于典型服务。

我们最初的架构使用一致性哈希来确定哪个实例拥有每个目标服务的聚合。给定目标的所有流日志路由到同一实例——该目标的“所有者”。这个设计似乎合理:将相关数据分组以实现高效聚合。

但热门目标创建了热点节点。一个实例可能拥有身份验证服务,另一个可能拥有很少使用的后端服务。负载分布极不均匀——一些实例处理的流记录是其他实例的 100 倍。

更糟的是,在重新分发期间发生了数据放大。考虑一个被 100 个上游服务跨 10 个实例调用的服务。所有 10 个实例都接收该目标的流日志(因为它们都有调用它的本地客户端)。当它们将聚合器路由到所有者实例时,该实例接收 10 个单独的聚合器,它必须合并。在洗牌期间数据量成倍增长。

当许多实例将同一键的数据路由到一个所有者时,流量正好在落点处倍增——热点节点的根本原因。

我们使用 async-profiler 和堆转储分析进行了广泛的分析。结果很清楚:热点实例的大部分 CPU 花在垃圾回收上,试图管理聚合器对象的快速分配和释放,因为流日志涌入的速度比处理速度快。内存压力导致 GC 抖动,消耗 CPU,减慢处理,增加内存压力——一个恶性循环。

解决方案:三阶段管道的双重好处
我们之前描述的三阶段管道——主要为代理解析而设计——恰好是我们解决热点节点问题所需的。原因如下:

Stage 1 在任何分发之前在本地执行初始聚合。而不是立即将每个流日志发送到远程实例。每个实例直接在内存中将原始流日志在线聚合到时间窗口聚合器(超过 5 分钟周期);这允许原始流快速被丢弃和垃圾回收,显著减少内存压力,并确保只有聚合结果通过网络传输到下游阶段。

在收件箱中获取 Netflix Technology Blog 的故事

免费加入 Medium 以获取来自此作者的更新。

记住我以加快登录

Stage 2 专注于代理解析,但也提供中间重新分发。Stage 1 的聚合器通过一致性哈希分发到 Stage 2 实例。现在我们移动的是压缩的聚合器,而不是单个流日志。解析后,Stage 2 再次将解析后的边重新分发到 Stage 3,提供第二次哈希操作,进一步分散负载。

Stage 3 接收已压缩两次并分发两次的解析后聚合器。即使对于极其流行的服务,负载也已分散到足够多的分发点,没有单个实例不堪重负。

关键洞察:由一个需求(代理解析)驱动的架构决策往往作为有益的副作用解决其他问题(负载分发)。三阶段管道配合分级重新分发实现了两个目标——它解析代理以显示干净的应用级拓扑,并通过跨多个分发点分散负载来防止热点节点。

从 gRPC 切换到 SSE
如前所述,这个挑战也揭示了 gRPC 不是我们规模下阶段间通信的正确协议。我们用 Server-Sent Events 替换了 gRPC,大幅减少了发送方和接收方上的资源消耗。

结果

  • CPU 使用率在实例间均匀分布——不再有负载是其他实例 10 倍的热点节点
  • 由于更好的聚合和更轻量级的协议,网络带宽使用率显著下降
  • 通过减少对象分配率,内存压力降低
  • 系统随自动扩缩容组变化优雅扩展

经验教训:技术选择必须匹配你的特定用例。gRPC 非常适合请求-响应 RPC 模式。对于在管道中流式传输大量聚合数据,更轻量级的替代方案可能更合适。让测量指导决策,而不是行业炒作或现有团队专业知识。

挑战 3:内存和垃圾回收

问题:即使修复了热点节点,我们仍然看到高堆使用率、频繁的垃圾回收暂停,以及实例偶尔 DOWN 掉。GC 日志显示暂停消耗了大量 CPU 时间——在某些情况下,超过我们的业务逻辑。

根本原因:多个因素导致:对象在等待 5 分钟聚合窗口完成时在堆中累积,数据流经各阶段时不同对象类型之间不必要的转换,以及不可变性开销——遵循 Scala 最佳实践,我们对聚合器使用不可变数据结构,但每次更新都创建新对象,在每秒数百万记录的情况下压垮了垃圾收集器。

调查:堆转储和 GC 日志显示流日志对象保留超过其有用生命周期,不必要的中间转换对象,以及不可变聚合器版本的不断创建/销毁。次要 GC 每隔几秒发生一次,主要 GC 耗时数百毫秒——JVM 花在垃圾回收上的时间多于业务逻辑。

应用的解决方案

  1. 更快处理:立即处理流日志,快速聚合,释放引用。优化 Pekko 流阶段以最小化对象生命周期。
  2. 消除不必要的转换:直接在阶段间路由聚合器,而不是转换为中间类型。
  3. 热路径上的可变结构:这有争议——Scala 最佳实践强调不可变性。但在我们的规模下,不可变性创建了太多对象。我们务实地在热路径上选择可变聚合器(其他地方保持不可变性),优先考虑性能而非约定。切换到可变聚合器将堆分配减少了 50% 以上,并显著缩短了 GC 暂停时间,尽管需要更仔细的代码审查。
  4. 调整时间窗口:平衡数据新鲜度与内存压力。

结果

  • 堆使用率大幅下降
  • GC 暂停减少到可接受水平(数十毫秒而不是数百毫秒)
  • CPU 释放用于业务逻辑而不是垃圾回收
  • 实例稳定性改善——不再因内存问题导致实例 DOWN 掉

经验教训:“最佳实践”是起点,而不是绝对规则。在独特规模下,你可能需要偏离约定。但要基于测量故意为之,并意识到权衡。不要在所有地方都放弃不可变性——只在性能数据证明必要的地方放弃。

挑战 4:响应式流复杂性

问题:我们的 Pekko Streams 管道会意外停滞。背压传播不如预期。我们难以调试为什么某些流会在没有明显错误的情况下停止处理。响应式编程心智模型——强调异步边界、背压和需求驱动处理——被证明比预期的更难掌握。

我们学到的
带背压的响应式流是构建能优雅处理负载峰值系统的强大工具。当下游消费者减速时(由于临时负载、GC 暂停或外部系统减速),背压允许上游生产者减速,而不是溢出缓冲区或丢弃数据。

但这种能力伴随着复杂性:

  • 非直观行为:传统命令式代码从上到下流动。响应式流是需求驱动的——下游消费者从上游生产者拉取。这控制反转并不直观。
  • 异步边界:Pekko Streams 中的 .async 运算符创建一个处理移动到不同线程的边界。这可以改善并行性,但也引入了围绕缓冲区大小、需求信号和错误传播的复杂性。我们最初误解了何时使用 .async,最终得到了过度并行化的流,创建的开销多于收益。
  • 调试困难:当流停滞时,没有指向问题的堆栈跟踪。你必须理解内部机制——需求信号、缓冲区状态、物化器状态——才能诊断问题。

我们的方法

  1. 深度学习投入:我们投入大量时间深入理解响应式流概念。阅读文档、用小示例实验,并建立团队专业知识。
  2. 简化模式:在可能的地方,我们简化了流图。复杂的分支和合并模式强大但难以调试。我们更喜欢有清晰阶段边界的线性流。
  3. 更好的监控:我们在流边界添加了指标——跟踪缓冲区大小、元素吞吐量、背压事件。对流内部的可见性帮助诊断问题。
  4. 团队教育:我们记录了我们的学习,分享有效模式,并建立关于响应式流的机构知识。

经验教训:强大的抽象需要投入。不要在没有验证的情况下假设你理解框架。故意构建你的心智模型,通过实验测试它,并对你的理解保持谦逊。响应式流值得掌握以构建需要优雅处理负载的系统,但要预期学习曲线。

V2 演进:持续优化

V1 让我们进入生产。主要架构挑战——Kafka 滞后、热点节点、内存压力——已解决。但全面规模下的生产揭示了新的优化机会。V2 代表将工作系统转变为生产就绪系统的持续优化。

挑战 5:持续堆压力

问题:尽管有 V1 优化,我们仍然观察到高于预期的堆使用率。GC 指标改善但未达到最佳。内存分析显示仍有改进空间。

根本原因:更深入的分析显示,我们仍在阶段间进行不必要的对象转换。我们在路由到下一阶段之前将聚合器转换为完整的图实体(填充所有属性),即使下一阶段只需要压缩的聚合器状态。

解决方案:架构变更以直接通过所有阶段路由聚合器,仅在 Stage 3 持久化前立即转换为最终图实体。这消除了两个中间转换步骤和相关对象分配。

结果:堆使用率进一步下降,GC 暂停变得更不频繁,内存余量改善。

挑战 6:序列化复杂性

问题:SSE 消息的自定义序列化逻辑导致偶尔出现难以重现和调试的异常错误。代码库的不同部分使用不一致的序列化方法。

解决方案:在整个管道中标准化为 JSON 编码。虽然比二进制序列化略低效,但 JSON 的人类可读性使调试容易得多,开销与其他操作相比可以忽略不计。一致性消除了一整类错误。

结果:序列化相关错误消失。调试变得更容易,因为我们可以直接读取 SSE 消息内容。

挑战 7:流处理低效

问题:即使更好地理解了响应式流,我们的 Pekko 配置仍未达到最佳。我们过度并行化了一些阶段而低并行化了其他阶段。.async 边界未放置在最佳位置。

解决方案:通过持续分析和实验,我们调整了并行度参数、缓冲区大小,并优化了异步边界位置。我们在流边界添加了监控以识别瓶颈。

结果:吞吐量改善,处理延迟更一致。

挑战 8:图数据库吞吐量不均

问题:写入我们图数据库的分布不均。某些分区接收大量写入流量,而其他分区空闲。这导致限流不均匀触发并限制了整体写入吞吐量。

解决方案:在写入图数据库前实现聚合器批处理,并改进跨分区的分发逻辑。我们不是立即写入每个聚合器,而是将它们批处理并在协调操作中写入多个实体。

结果:更一致的写入吞吐量和更好的数据库容量利用率。

挑战 9:聚合时的数据富化

除了核心拓扑图,我们还需要用额外上下文富化节点。在 Stage 3,在持久化图实体之前,我们从外部源集成富化数据——应用健康状态、所有权信息和其他元数据。在聚合时执行这种富化而不是查询时,避免了查询后连接的性能开销,并确保每个拓扑节点在查询时都有完整上下文。

模式识别

每个 V2 挑战都遵循相同模式:生产揭示一个假设,分析识别根本原因,针对性修复改善特定指标。测量、假设、验证、迭代。这就是在规模下构建的方式——不是一开始就全部做对,而是通过持续学习和改进。

时光旅行:持续拓扑重建

我们构建的最强大功能之一是支持查询历史拓扑:“当这个事故发生时调用图是什么样的?”这个时光旅行功能需要解决一个有趣的架构挑战——如何跨时间高效存储和重建拓扑。

问题

工程师需要回答时间问题:事故期间拓扑是什么样的?依赖关系如何演变?传统方法——完整快照或事件溯源——要么存储成本呈指数级,要么需要缓慢的日志重放。

我们的方法:带变更跟踪的时间窗口聚合器

我们结合两种机制:

1. 时间窗口聚合器快照:每个聚合器为其 5 分钟窗口存储 startTs 和 endTs 时间戳。这些不可变聚合器以 (entity_id, timestamp) 为键持久化在图数据库中,每 5 分钟提供检查点状态。

2. 属性级变更跟踪:图数据库在属性级维护变更历史——仅存储带时间戳的变更属性。这比完整实体副本高效得多,并提供超出 5 分钟聚合边界的窗口内精度。

3. 查询时重建:查询历史拓扑时,我们查询时间范围的变更历史 API,检索所有变更,并按顺序应用变更重建拓扑状态。

这种方法提供高效存储(压缩的聚合器状态 + 稀疏属性变更)、快速检索(索引的变更历史,无需日志重放)和灵活分析(任意时间范围,无需预计算所有可能性)。

查询时重新聚合:我们可以使用与摄取相同的聚合器类在查询时进一步聚合历史数据。这支持任意 groupby 维度(可用性层级、业务域、部署集群),这些维度未预先计算,允许探索性分析而不爆炸存储成本。

分布式系统经验教训

虽然这些挑战特定于服务拓扑,但经验教训广泛适用于规模化分布式系统。

规模改变一切

在每秒 100 个请求时有效的方法在每秒 100,000 个请求时失效。变化不是线性的——是质的。在中等规模下没问题的做法在极端规模下遇到根本性壁垒。

我们旅程中的例子:不可变数据结构在每秒数百万次分配时创建 GC 压力;单阶段聚合在幂律流量分布下灾难性失败;标准 gRPC 在流式聚合量大时变得笨重。

经验教训:当规模证明合理时,愿意打破传统智慧。但要基于测量,而不是推测。

一次优化一个瓶颈

分布式系统有级联瓶颈。修复 Kafka 滞后,你会发现热点节点问题。修复热点节点,你会发现 GC 问题。修复 GC,你会发现序列化低效。

这不是失败——是复杂系统的本质。每次优化提高吞吐量,压力下一个最弱点。方法:基于影响优先级,用确认解决的测量彻底修复当前瓶颈,然后转向下一个。规模下的优化是持续的,而不是一次性的。

分发是规模化的关键

单聚合点是不可避免的瓶颈。一致性哈希分发负载,但当数据本身分布不均(像我们的幂律分布)时无法防止集中。

我们带分级重新分发的三阶段管道解决了这个问题。负载在每个阶段的多个分发点上分散。即使数据高度偏斜,也没有单个实例不堪重负。通用原则:处理规模化偏斜数据时,使用每阶段都有重新分发的多阶段处理。

当前状态和影响

Service Topology 目前在生产中运行,处理来自多个区域的流日志、IPC 指标和追踪,并以亚秒级延迟提供查询服务。Netflix 各团队每天使用它进行事故调查、影响范围分析、依赖理解和生产变更管理。该系统已成为大规模维护可靠性的关键基础设施。

结论

Netflix 的 Service Topology 代表了构建规模化分布式系统的旅程。我们从工程师难以理解分散在各个工具中的依赖关系开始。我们构建了一个使用流式聚合、网络中介解析和时光旅行功能的多层架构。我们认识到规模下的优化是持续的——测量、迭代、验证、重复。

我们面临的挑战——Kafka 滞后、热点节点、内存压力——需要当数据证明合理时打破传统智慧。每个修复都揭示了下一个瓶颈。但这种迭代过程,由持续测量指导,正是使系统在极端规模下工作的原因。

在下一篇文章中,我们将探讨追踪层集成、跨异构存储的统一查询,以及所有三层如何结合提供全面的拓扑可见性。

致谢

Service Topology 由 Parth JainRakesh SukumarYingwu ZhaoRenzo Sanchez-SilvaNathan Fisher 构建。

特别感谢许多使这成为可能的 Netflix 工程师——构建更广泛系统的可观测性团队,提供存储基础的图数据库平台团队,以及在整个开发过程中提供宝贵反馈和用例的 Platform Modernization Engineering 和 Live 团队。