Netflix 如何打造高可靠在线有状态系统

Netflix 如何打造高可靠在线有状态系统
2024年06月14日 18:14 InfoQ

作者 | Joseph Lynch

译者 | 王强

策划 | Tina

要点

  • 可靠性意味着花钱将故障概率、爆炸半径和恢复时间都降至零。

  • 大规模可靠服务的构建工作必须在客户端、服务器和 API 上三管齐下。

  • 可靠的服务器是冗余的、对负载优化的和被大量缓存的。它们提供快速数据恢复和跨多个云可用区利用多个复制副本的能力。

  • 可靠的客户端会持续迭代前进,并使用来自服务器的信号来学习如何重试或对冲请求,以满足服务级别目标(SLO)。

  • 可靠的 API 依赖于幂等性和固定大小工作单元等概念。

本文是我在 2023 年 10 月 QCon SF 上的演讲摘要。多年来,我曾参与过多个有状态系统和存储引擎的项目。在本文中,我想讨论如何让有状态系统足够可靠。但首先,我想定义“可靠”的含义。

可靠性

如果你去问数据库行业的从业者,他们可能会说可靠性意味着有很多个 9——如果你有很多个 9,那么你的可靠性就很高。但根据我的经验,多数数据库用户并不太关心你的系统有多少个 9。为了说明原因,我们来考虑三个假设的有状态服务。

服务 A 总是会失败一点;它永远不会恢复。服务 B 偶尔会出现灾难性故障。它恢复得很快,但在此期间仍会经历近 100% 的中断。最后,服务 C 很少发生故障,但一旦发生故障,就会长时间停摆。

下图显示以上每个服务都有相同数量的 9。但是,解决这三种故障模式的方法却截然不同!第一种需要请求对冲或重试,第二种需要负载削减或背压,第三种需要更快的检测和故障转移。

不要去问“我有多少个 9”,而要问以下问题:

  • 我的系统多久发生一次故障?

  • 当它们发生故障时,影响范围有多大?

  • 我们需要多长时间才能从中断中恢复?

然后,你会花钱购买一些工程时间或计算机来让这些数字归零。数据库用户只希望系统不会发生故障,或者故障影响最小化并能快速恢复——这就是“可靠”的含义。有状态服务中的不同故障模式需要不同的解决方案。下文中,我们将探讨一系列可用来尝试让这些属性朝着我们想要的方向发展的技术。

Netflix 在线有状态扩展

我们在 Netflix 使用这些技术来实现非常大规模的在线有状态服务。我们有近端缓存,它们位于服务主机上,每秒处理数十亿个请求,延迟不到 100 微秒。我们有基于 Memcached 的远端缓存,每秒处理数千万个请求,延迟目标为 100 微秒,存储 PB 级数据。最后,我们还有基于 Apache Cassandra 的有状态数据库,以 4 区域全活跃模式运行,提供区域内读写一致性,延迟仅为数毫秒。

我们将用户状态复制到四个 Amazon 区域,采用全活跃拓扑。

构建可靠的有状态服务

构建可靠的大规模有状态服务需要结合以下三种技术:

  • 构建可靠的有状态服务器

  • 将它们与可靠的有状态客户端配对

  • 改变有状态 API 的设计,以使用所有这些可靠性技术

    可靠的有状态服务器

单租户

我们的应用程序是单租户的,这是 Netflix 最基础的有状态服务器技术;它们不共享缓存或数据存储。数据存储会根据每个用例的需求来启动和关闭。以前,当我们用的是为多个客户提供服务的多租户数据存储时,我们很少遇到具有重大爆炸影响的故障。随着我们转向这种技术,我们现在遭遇的故障更频繁,但它们的爆炸半径更孤立可控。对于 Netflix 的业务来说这种权衡是合理的,因为有着较大爆炸半径的故障会损害我们的品牌形象,而轻微的局部故障则不会,因为我们可以把它们隐藏起来。

容量规划

为了尽可能降低故障的发生概率,我们希望确保每个单租户数据存储都得到适当的配置。我们的起点一直都是数学层面非常严格的容量规划和对硬件的理解。为此,我们必须将生成分布拟合到观察到的数据中,以便我们能够了解延迟异常值等尾部事件的概率。下面是两个 EC2 实例,我们测量了它们的磁盘对实际负载的响应速度,并拟合了总结性 beta 分布来描述它们。

请注意,第二个驱动器的 p99 上有一个小的延迟峰值,这可能意味着由于硬件可靠性较低,你的有状态服务可能会有更高的 SLO 破坏事件频率。

在了解我们的软件和底层硬件的基础上,我们编写了负载容量模型,该模型考虑了与负载相关的几个参数来模拟可能的 CPU、内存、网络和磁盘要求。根据这些负载细节,该模型随后输出一组计算机的规格,我们称之为最不后悔的选择。关于这个容量建模机制的更多信息,请参见:2022 年 AWS re:Invent 演讲。

这些自动化容量模型为我们提供了一个中心支点,可以将我们的支出转向关键集群(第 0 层)和需要昂贵属性(例如强一致性)的集群,同时节省低关键集群和不需要强一致和耐用性的用例的资金投入。

高度复制

我们将集群复制到了分布在四个区域(region)的 12 个 Amazon 可用区(zone)上,因为我们希望确保所有微服务都有对其数据的本地区域访问权限。网络通信有可能出故障,因此我们尝试将尽可能多的数据保留在一个区内,如果必须跨可用区,我们会尝试将其保留在一个区域内。但有时,我们确实必须跨区域。这种复制技术使我们能够进行高度可靠的写入和读取操作,因为我们可以使用仲裁来接受任何区域中的写入。由于在每个区域都有三个副本,我们可以提供非常高的可靠性,同时保持强一致性。

在有状态上花钱,在无状态上省钱

有时,我们必须从某个降级区域撤离流量。下图说明了正在运行的 Netflix 系统的整体容量使用情况。我们的大部分资金都花在了无状态服务上,通常配置为处理全球流量的四分之一左右。

当 us-east-1 发生故障时,我们必须从该区域撤离流量。在 Netflix,我们的弹性团队已经发展出了一种惊人的能力,可以在几分钟内撤离一个 Amazon 区域;然而,这对数据存储来说是一个问题,因为它们必须准备好立刻就能吸收 33% 的新流量。这甚至对无状态服务来说也是一个问题,因为它们无法如此快速地自动扩展。你必须为缓慢扩展的服务保留余量(缓冲区),并为快速扩展的服务预先注入这些缓冲。使用这种架构时,你必须为故障转移保留 (⅓) / (¼) = 33% 的余量。

另一种方法是对我们的数据库采用更传统的分片方法,运行两个状态副本而不是四个。例如,假设我们有两个复制组:一个位于 us-west-2 和 us-east-2 之间的“America”,另一个位于 us-east-1 和 eu-west1 之间的“Europe”。在这种传统方法中,我们必须在故障转移期间为流量保留更多的余量:(½) / (¼) = 100%。

由于硬件和软件原因,区域会不断受到影响,因此拥有这种快速撤离能力可让 Netflix 在发生故障时极快地恢复。但只有在你可以将负载分散到所有其他区域时,这种方法才有成本效益,而全活跃数据拓扑可以实现这一点。

有状态敏捷性

有时,即使做了所有这些容量规划和分片准备,也会发生不好的事情。当它们发生时,你希望能够快速部署软件和硬件以缓解问题。我们在有状态镜像中刻入了突变接缝,这样就能更快做出反应,而不会影响系统的可用性。具体来说,我们将有状态实例分解为三个组件。

我们有:

  1. 黄色部分为代理或 sidecar,我们每天都会更改它们,但不会影响数据存储的可用性

  2. 有状态进程和操作系统内核,每月更改一次,需要主进程停机

  3. 状态本身,必须在硬件更改时移动,我们的目标是每季度能移动一次我们将有状态进程和操作系统内核结合在一起,因为如果我们必须升级(比如说 Linux 操作系统),就必须要关闭数据存储进程。每次主进程关闭时我们都会面临仲裁失败的风险,因此我们希望尽快完成此操作。

状态是有状态服务中最成问题的部分,与你运行的硬件相关联。你不想太频繁地触碰它。如果你必须移动状态,就会面临可靠性降低的风险,因此如果必须这样做,我们希望尽快移动状态。在 Netflix,为了最大限度地降低故障可能性,我们使用了快照恢复。在这里,我们会尽快将 S3 中的备份吸收到新的热实例上。然后,我们在切换时共享增量(delta)。状态移动对可靠性有重大影响,因为它需要时间,并且在增量传输时,你会面临仲裁丢失的风险。

监控你的组件性能

由于硬件故障的风险很大,因此当我们启动新实例时,首先会确保它们可以处理我们将施加给它们的负载。在开始承载实际负载前我们会做一系列检查,包括网络功能和磁盘是否按预期运行——我们称之为飞行前检查。然后,我们持续监控硬件和软件的错误和延迟,并在开始出现故障的硬件成为故障源之前,先下手将其从队列中弹出。

你也可以使用你的软件做这件事!例如,在 Netflix,我们在用 Java 编写的有状态服务上使用 jvmquake,因为它可以尽早检测到 GC 死亡螺旋,并通过一个令牌桶算法预防与并发模式故障相关的灰色故障:

持续监控对于可靠性来说至关重要,因为当你在一年内拥有 2000 多个集群时,你的硬件和软件都会发生不良情况。如果你主动监控组件,就可以快速检测故障、修复故障并在故障传播到有状态服务的客户之前恢复正常。

智能部署

软件需要频繁部署,但对于有状态系统,我们必须小心对待维护的速度。例如看一下一个 Apache Cassandra 集群上的一次 系统故障模拟。该图的 x 轴表示对 Cassandra 集群执行维护的频率,y 轴表示维护工作预计会导致多少次中断。每条曲线代表一种可能的维护方式,包括实例状态复制、网络驱动器重连接,以及没有任何状态移动的情况下对软件做最优化的就地镜像操作。如你所见,维护越频繁,维护时间越长,你面临的风险就越大:

在 Netflix,我们从红色开始(每次软件更改时在实例之间复制数据),然后随着时间的推移移动到绿色(升级软件时移动零字节的状态)。这使我们能够更快地从软件问题中恢复,同时又不会承担更高的故障风险。最终,我们必须使用就地镜像技术来实现每周软件更改之类的目标。

服务前端的缓存

在 Netflix,我们将缓存视为物化视图引擎,负责缓存在数据上运行的复杂业务逻辑,而不是缓存底层数据。对一个服务的大多数操作都会命中这个缓存视图,而不是服务本身。每当该服务的底层数据发生变化时,服务都会重新计算缓存值并用新视图填充缓存。

服务前端的缓存负责保护服务,至少对我们来说,服务是第一个出现故障的组件。相对于服务而言,缓存成本较低;运行实际业务逻辑的无状态应用程序的运行成本相当高。这种技术可以帮助我们通过减少服务和数据存储必须处理的负载量来提高可靠性。它确实将负载转移到了缓存上,但缓存更容易提升可靠性。

这种技术的逻辑结论是使用完全近端缓存,我们使用所需数据的本地系统副本来处理所有数据请求。Netflix 的视频元数据系统的工作方式如下:

在这种架构中,所有操作都针对的是被最终一致的发布 - 订阅填充的本地缓存,这非常可靠,因为在服务请求的热路径中没有数据库调用。

如果不缓解冷缓存问题,缓存就会降低可靠性,但好消息是,在流量到达缓存之前预热缓存是很简单的。一旦你有了良好的缓存预热能力,Memcached 就可以比任何 Java 服务更好地服务订单或处理更多流量。

可靠的有状态客户端

我们已经看到了几种打造可靠有状态服务器的技术,但如果有状态客户端做错事并导致故障,这些技术就没什么用了。

服务器向客户端发出信号

过去,Netflix 曾就如何调整超时值进行过激烈的争论,而对于有状态服务,我们最终意识到你无法真正设置一个正确的超时。相反,你需要知道客户端是谁、它们正在与哪个数据存储的哪个分片通信,以及它们试图访问哪些数据。然后,根据该上下文,服务器可以推断出合理的服务级别目标并将其发送给客户端。

我们使用服务器信号将上下文传达给客户端,从而提高可靠性。例如,我们使用相同的信号通知客户端压缩和分块数据以实现最大可靠性。我们使用有状态客户端的 LZ4 压缩,在发送大负载之前对其压缩,实际上,这可以将发送的字节数减少 1/2 到 2/3。压缩数据可以提高可靠性,因为每个数据包都带来一个 Linux 触发 200ms 重新传输的机会——这是一个明显的 SLO 破坏者;字节数越少,可靠性就越高。同样,将大负载分块成较小的负载也更可靠,因为客户端可以对冲并重试每个小操作。

动态信号的一个例子是服务级别目标,或者我们认为服务器执行不同操作的速度:

左侧的 KeyValue 服务和右侧的 TimeSeries 服务与客户端通信:“这就是我认为你的目标 SLO 应该达到的水平,也是最大值应该达到的水平。”你可以将目标延迟视为我们将使用对冲和重试尝试达到的延迟,而最大延迟则更为传统:“在此之后,只需超时即可离开。”在传达超时数值的同时,服务器会传输当前的并发限制。服务器说:“客户端,你可以对我进行 50 个并发请求的对冲。你可以发送 1000 个正常请求。”对冲是指客户端多次发送请求的技术,无论哪个服务器先回答,客户端都会接受其响应。

每个命名空间和类型的服务级别目标

我们还可以根据这些 SLO 尝试访问的特定命名空间、客户端及其观察到的平均延迟来调整它们。考虑以下情况:

例如,假设我们有客户端 A,它们正在对命名空间 1 发出 GET 请求。如果它们观察到 1 毫秒的延迟,那么要达到 10 毫秒的 SLO,我们将不得不发送大约 9 毫秒的对冲。同一个客户端可能正在执行平均 1.5 毫秒的 PUT 请求。如果我们想尝试达到 10 毫秒的 SLO,就必须稍早一些做对冲。另一方面,SCAN 请求非常慢,它们的 SLO 约为 100 毫秒。我们不想在那之后的 77 毫秒进行对冲。最后,客户端 B 可能会持续超过 SLO 延迟,因此对冲不会对你有帮助。

并发限制对冲和 GC 容忍超时

我们在对冲请求时也必须非常小心。我们必须使用并发限制来防止过多负载进入后端服务。

在这个例子中,client1 向服务器 1 发送两个请求,server1 立即进入垃圾收集暂停状态。此时,client1 等待 SLO 减去平均值,然后对冲 server2。它就地设置了一个 1 的对冲限制,这不允许 client1 对冲 server2。一旦我们收到来自 server2 对请求 A 的响应,这个对冲限制就会解除,并且 client1 到 server1 的后续 PUT 可以推断。

这三个请求都会遇到破坏延迟的 24 毫秒 GC 暂停。有了这个对冲系统,我们就可以挽救两三个请求。

我们可以使用类似的技术来实现 GC 容忍超时。可以将单个异步 future 设置为 500 毫秒来实现简单的超时,然后在 500 毫秒结束时会抛出一个异常。我们的有状态客户端使用一些 GC 容忍计时器,其中我们将四个异步 future 链接起来,间隔 125 毫秒。这会创建一个虚拟时钟,它可以应对 client1 的暂停,并且不会分散调查人员的注意力,这样你可以解决实际的问题根源。

重试和负载均衡

有时事情会出错,你会遇到负载削减。发生这种情况时,我们允许对有状态服务进行有限的重试。我们使用了一个略微修改的上限指数退避算法。这里唯一值得一提的真正改动是第一次重试与 SLO 目标相关。有了这些 SLO,我们就可以更智能地针对后端进行重试。

如果我们一开始就不向那个慢速服务器发送任何请求,情况会更好。这就是负载均衡(或不均衡)的用武之地。典型的负载均衡算法(如随机或 round-robin)在避免慢速服务器方面效果不佳。在 Netflix,我们使用了对 2 选 1 算法的一项 改进,称之为 加权 n 选 1。

通过加权 n 选 1 算法,我们利用了与云中网络相关的先验知识。会有这些知识是因为我们在每个区域都有数据副本,所以如果我们将请求发送到同一区域就能获得更快的响应。我们要做的就是将请求权重分配给本地区域副本。如果我们只有两个副本,或者客户端处于过载区域,我们希望它能自然降级。我们不仅要制定严格的路由规则,还要考虑并发性。我们不会选择并发性最低的两个,而是根据以下因素来衡量并发性:不在同一可用区、没有副本或处于不健康状态。这种技术可将延迟降低多达 40%,并通过将流量保持在同一个区中来提高可靠性!

一个实践案例

我们来结合所有这些技术看一个真实的 KeyValueService 问题。在时间 t1 上午 7:02,此 KeyValueService 的上游客户端遭遇重试风暴,其流量瞬间翻倍。在 30 秒内,我们的服务器并发限制器将负载从过载的服务器上转移出来,试图保护后端免受突发的负载峰值的影响。同时,客户端对冲和指数重试减轻了这一影响。这个影响非常小,没有违反我们的任何 SLO。

唯一的问题是高延迟影响。我们在 10 秒内从 40% 的利用率上升到 80%,因此延迟降级了。最后,我们的服务器自动扩展系统通过注入容量来恢复延迟 SLO,以弥补延迟影响。整个过程(从客户端负载加倍到我们的服务器限制器跳闸,再到我们的客户端对冲和指数重试缓解,直到服务器自动扩展并最终恢复 SLO)大约需要五分钟,无需人工参与。

综上所述:我们运行时有足够的缓冲来预防严重中断;出现影响时,系统会自动缓解影响,最后,我们可以快速恢复而无需人工干预。

可靠的有状态 API

API 也必须支持上文介绍的所有这些技术。如果请求有副作用,你就无法重试请求!

幂等性令牌

假设你必须重试;如何确保可变 API 的安全?答案是幂等性令牌。从概念上讲这非常简单。生成一个令牌并将其发送到你的端点。然后,如果发生了不好的事情,你只需使用相同的幂等性令牌重试即可。你必须确保后备存储引擎实现了这种幂等性。

在 Netflix,幂等性令牌通常是时间戳和某种形式的令牌的一个元组。我们可以使用客户端单调时间戳,在本例中是一个微秒时间戳(在最后一个字符中混合了一点随机性,只是为了防止一点点重复)和一个随机 nonce。如果我们有一个更一致的存储,就可能需要在同一区域内创建一个全局单调时间戳。最后,最一致的选项是采用一个全局事务 ID。

上图的 x 轴是一致性的强弱程度,范围从完全不一致一直到全局线性化;y 轴是该技术的可靠性。我们会注意到区域和全局隔离令牌存在的问题,这是因为它们需要跨网络通信。在 Netflix,我们尽可能使用客户端单调时钟令牌来实现高可靠性。如果你能让它不那么一致,那就让它不那么一致。

KeyValue 抽象

下面我们来深入研究一些使用这些概念的真实 API 示例。我们将从 KeyValue 抽象开始,其中 Netflix 为开发人员提供了一个稳定的 HashMap 即服务,并公开了一个非常简单的 CRUD 接口。

看一下 PutItems。我们一眼就能看到这个幂等性令牌弹出了,它是通过我们之前讨论的一种方法生成的。我们还看到项目列表中有一个 chunk 号,这有助于客户端将大负载拆分为多个小负载,然后提交。

在读取侧可以看到同样的分解工作的技术。一个 GetItems 响应不能保证你将收到 X 个项目,但它会尝试一次返回固定大小的数据(工作)。这是因为我们希望能够重试每个组件。我们不使用带有 gRPC 的流式传输,这是因为前面谈到的那些弹性技术让你可以重试单个工作、对冲、推断和重试——它们在流式 API 中不起作用。相反,我们将几乎所有有状态 API 都转换为返回固定数据量的分页 API。

由于多数存储引擎都是根据行数来分页,而我们希望根据大小来分页,因此我们就有了这种不匹配,即服务器可能会多次往返数据存储以积累一个页面。这可能会破坏 SLO,但因为我们知道 SLO 是什么,所以我们可以停止分页。我们不保证返回一个固定的工作量,因此我们只是向客户端显示进度,然后让客户端在需要时请求下一页。

SCAN 就像 GET 一样,只是没有主键。当你启动一次 scan 时,KeyValueService 会动态计算它向该客户端释放的并发游标数量——如果系统负载较低,则释放的游标数量会更多;如果许多客户端正在扫描,则释放的游标数量会较少。

TimeSeries 抽象

TimeSeries 抽象存储了我们所有的跟踪信息和播放日志,这些日志告诉我们有人点击了播放按钮。该服务每秒处理数千万个事件,完全持久化,可保留数周,在某些情况下甚至长达数年。

该服务中的幂等性令牌内置于 TimeSeries 合约中。如果你提供了具有相同事件时间和唯一事件 ID 的事件,则后端存储会对该操作进行重复数据删除。在读取侧,我们将潜在的巨大响应分解为可以逐步消费的多个页面。

由于规模巨大,开发人员要求我们提供额外的可靠性级别,并愿意牺牲大量一致性——我们为客户提供三种模式:

第一种模式是“即发即弃”,深受用户喜爱,因为它可以处理每秒 2000 万次写入,正常运行时间接近 100%。这些用户并不关心我们是否删掉了数十亿条服务跟踪记录中的某一条。接下来的两种模式逐渐变得更加一致,但可靠性更低。例如,TimeSeries 可以通知你何时将数据排入 TimeSeries 服务器上的内存队列。或者,我们可以推迟确认操作,直到它在存储中完全持久化。不同的用例需要不同的权衡。

总    结

只在服务器、客户端或 API 中单独注入可靠性,并不足以构建可靠的大规模有状态服务。通过结合这三个组件中每一个明智而有意义的选择,我们可以在 Netflix 构建大规模可扩展、符合 SLO 的有状态服务,即:

  • 构建可靠的有状态服务器

  • 将它们与可靠的有状态客户端配对

  • 调整 API 的设计,以使用所有这些可靠性技术

作者介绍

Joey Lynch 是 Netflix 的首席软件工程师,专注于在数据存储基础设施之上构建高可靠性和高利用率的数据抽象。他是 Netflix 数据存储平台的核心贡献者,该平台支持包括 Cassandra、Elasticsearch、CockroachDB、Zookeeper 等在内的多语言数据层。他喜欢构建分布式系统,并学习它们扩展、运行和中断的有趣和令人兴奋的各种方式。多年来,他曾负责过许多大型分布式系统,目前他的大部分时间都花在处理 KeyValue 和 TimeSeries 事件存储上。Joseph 的博客在这里。

财经自媒体联盟更多自媒体作者

新浪首页 语音播报 相关新闻 返回顶部