作者: Karthik Yagna[1] , Baskar Odayarkoil[2] , 和 Alex Ellis[3]
Pushy是Netflix的WebSocket服务器,它与运行Netflix应用程序的设备保持持久的WebSocket连接。这允许后端服务按需向设备发送数据,而无需设备不断发送轮询请求。在过去几年里,Pushy经历了巨大的增长,从最初的尽力而为的消息传递服务演变成Netflix生态系统不可或缺的一部分。本文描述了我们如何发展和扩展Pushy以满足其新的和未来的需求,因为它处理数亿并发WebSocket连接,每秒传递数十万条消息,并保持稳定的99.999%的消息传递可靠性。
历史与动机
推动Pushy最初开发和使用的主要有两个用例。第一个是语音控制,你可以用语音命令如"在Netflix上给我播放《怪奇物语》"来播放节目或搜索。(如果你想自己尝试,请参阅 如何在Netflix上使用语音控制[4] )。
如果我们考虑Alexa的用例,我们可以看到这种与亚马逊的合作是如何实现的。一旦他们收到语音命令,我们允许他们通过 apiproxy[5] (我们的流媒体边缘代理)向我们的内部语音服务发起经过身份验证的调用。这个调用包括元数据,如用户信息和命令详情,比如要播放的具体节目。然后语音服务构造一条消息给设备,并将其放入消息队列,然后由Pushy处理并发送给设备。最后,设备接收到消息,并执行"在Netflix上给我播放《怪奇物语》"这样的操作。这个初始功能是为Fire TV开发的,后来又进行了扩展。
Alexa语音命令的示例系统图。aws在哪里结束,互联网在哪里开始,这个问题留给读者自己思考。
另一个主要用例是上面提到的RENO,即快速事件通知系统。在与Pushy集成之前,电视UI会不断轮询后端服务,以查看是否有任何行更新以获取最新信息。这些请求每隔几秒钟就会发生一次,最终导致对后端的额外请求,并且对设备来说代价高昂,因为设备通常资源受限。与WebSocket和Pushy的集成缓解了这两个问题,允许源服务在准备好时发送行更新,从而降低请求率并节省成本。
关于Pushy的更多背景信息,你可以看看 Susheel Aroskar的这个InfoQ演讲[6] 。自那次演讲以来,Pushy在规模和范围上都有所增长,本文将讨论我们为下一代功能演进Pushy所做的投资。
客户端覆盖范围
这种集成最初是为Fire TV、PS4、三星电视和LG电视推出的,覆盖了大约3000万台候选设备。有了这些明显的好处,我们继续为更多设备构建这种功能,实现同样的效率提升。截至今天,我们已经将候选设备列表进一步扩展到近10亿台设备,包括运行Netflix应用程序的移动设备和网站体验。我们甚至扩展了对缺乏现代功能(如TLS和HTTPS请求支持)的旧设备的支持。对于这些设备,我们通过在客户端和Pushy上各自启用加密/解密层,实现了从客户端到Pushy的安全通信,允许机密消息在设备和服务器之间流动。
扩展以应对增长(及更多)
增长
随着覆盖范围的扩大,Pushy变得更加繁忙。在过去五年里,Pushy从数千万并发连接增长到数亿并发连接,并且经常达到每秒发送30万条消息。为了支持这种增长,我们重新审视了Pushy过去的假设和设计决策,着眼于Pushy未来的角色和未来的稳定性。在过去几年里,Pushy在运营上相对无需人工干预,当我们更新Pushy以适应其不断发展的角色时,我们的目标也是让它在未来几年保持稳定状态。这一点特别重要,因为我们正在构建依赖Pushy的新功能;强大、稳定的基础设施基础让我们的合作伙伴能够继续自信地在Pushy之上构建。
在这个演进过程中,我们能够保持高可用性和一致的消息传递率,Pushy在过去几个月里成功地保持了99.999%的消息传递可靠性。当我们的合作伙伴想要向设备传递消息时,我们的工作就是确保他们能够做到这一点。
以下是我们演进Pushy以应对其不断增长的规模的几种方式。
Pushy直接生态系统中的一些相关服务以及我们为它们所做的改变。
消息处理器
我们投资的一个方面是异步消息处理器的演进。消息处理器的前一个版本是一个Mantis流处理作业,用于处理来自消息队列的消息。它非常高效,但它有一个固定的作业大小,如果我们想要水平扩展它,就需要手动干预,而且在推出新版本时也需要手动干预。
多年来,它很好地满足了Pushy的需求。随着处理的消息规模增加,我们在消息处理器中进行了更多的代码更改,我们发现自己在寻找更灵活的东西。特别是,我们在寻找我们在其他服务中享受到的一些功能:自动水平扩展、金丝雀、自动红/黑部署,以及更多的可观察性。考虑到这一点,我们将消息处理器重写为一个独立的Spring Boot服务,使用Netflix铺平的路径组件。它的工作是相同的,但它可以轻松部署,金丝雀配置让我们可以安全地推出变更,我们定义的自动扩展策略让它能够处理不同的数据量。
重写总是有风险的,它从来不是我们首先考虑的解决方案,特别是在处理已经到位并运行良好的系统时。在这种情况下,我们发现维护和改进自定义流处理作业的负担越来越重,我们做出了重写的判断。我们这样做的部分原因是消息处理器扮演的角色很明确 - 我们不是在重写一个巨大的单体服务,而是一个范围明确的组件,它有明确的目标、明确定义的成功标准,以及明确的改进路径。自2023年中期完成重写以来,消息处理器组件完全无需人工干预,愉快地自动化运行,可靠性很高。
推送注册表
在其生命周期的大部分时间里,Pushy使用 Dynomite[7] 来跟踪其推送注册表中的设备连接元数据。Dynomite是Netflix开源的Redis包装器,提供了一些额外的功能,如自动分片和跨区域复制,它为Pushy提供了低延迟和简单的记录过期,这两者对Pushy的工作负载都至关重要。
随着Pushy的组合增长,我们遇到了一些使用Dynomite的痛点。Dynomite的性能很好,但随着系统的增长,它需要手动扩展。云数据工程(CDE)团队的人员(他们正在为Netflix内部数据构建铺平的路径)热心地帮助我们扩展它并进行调整,但随着我们不断增长,这最终成为一个复杂的过程。
这些痛点与KeyValue的引入不谋而合,KeyValue是CDE团队提供的一个新产品,对Netflix开发人员来说大致相当于"HashMap即服务"。KeyValue是存储引擎本身的抽象,这允许我们选择最能满足我们SLO需求的最佳存储引擎。在我们的情况下,我们重视低延迟 - 我们从KeyValue读取的速度越快,这些消息就能越快地传递。在CDE的帮助下,我们将我们的推送注册表迁移到使用KV,我们对结果非常满意。在根据Pushy的需求调整我们的存储后,它一直处于自动驾驶状态,适当地扩展并以非常低的延迟服务我们的请求。
水平和垂直扩展Pushy
我们团队运行的大多数其他服务,如apiproxy(流媒体边缘代理),都受CPU限制,我们有自动扩展策略,当我们看到CPU使用率增加时就会水平扩展它们。这很好地映射到它们的工作负载 - 更多的HTTP请求意味着使用更多的CPU,我们可以相应地上下扩展。
Pushy的性能特征略有不同,每个节点维护许多连接并按需传递消息。在Pushy的情况下,CPU使用率始终很低,因为大多数连接都处于停放状态,等待偶尔的消息。我们不依赖CPU,而是根据连接数来扩展Pushy,在达到更高阈值后进行指数扩展以更快地扩展。我们对建立连接的初始HTTP请求进行负载均衡,并依赖一个重新连接协议,设备每30分钟左右重新连接一次,有一些错开,这给我们提供了一个稳定的重新连接设备流,以平衡所有可用实例之间的连接。
几年来,我们的扩展策略一直是,当平均连接数达到每个实例60,000个连接时,我们就会添加新的实例。对于几亿台设备来说,这意味着我们经常运行数千个Pushy实例。我们可以无限地水平扩展Pushy,但我们对账单不会那么满意,而且我们必须进一步分片Pushy以绕过NLB连接限制。这种演进努力与内部对成本效率的关注很好地契合,我们利用这个机会重新审视了这些早期假设,着眼于效率。
通过增加每个Pushy节点可以处理的连接数,这两个问题都可以得到缓解,减少Pushy实例的总数,并在实例类型、实例成本和最大并发连接之间取得正确的平衡,从而更有效地运行。它还允许我们在NLB限制方面有更多的喘息空间,随着我们继续增长,减少额外分片的辛劳。话虽如此,增加每个节点的连接数并非没有自身的缺点。当一个Pushy实例宕机时,连接到它的设备会立即尝试重新连接。通过增加每个实例的连接数,这意味着我们会增加立即尝试重新连接的设备数量。我们可以每个实例有一百万个连接,但一个宕机的节点会导致一百万台设备同时尝试重新连接的雷鸣般的群体效应。
这种微妙的平衡导致我们对许多实例类型和性能调优选项进行了深入评估。在取得平衡后,我们最终得到了每个节点平均处理200,000个连接的实例,如果必要的话,还有空间可以增加到400,000个连接。这在CPU使用率、内存使用率和设备连接时的雷鸣般群体效应之间取得了很好的平衡。我们还增强了我们的自动扩展策略,以指数方式扩展;我们超过目标平均连接数越多,我们添加的实例就越多。这些改进使Pushy在运营上几乎完全无需人工干预,随着更多设备以不同模式上线,给我们提供了充分的灵活性。
可靠性和构建稳定的基础
除了这些为未来扩展Pushy的努力外,我们还仔细审视了我们的可靠性,因为在最近的功能开发过程中发现了一些连接边缘情况。我们发现在Pushy和设备之间的连接方面有一些需要改进的地方,失败是由于Pushy试图在已经失败但没有通知Pushy的连接上发送消息。理想情况下,像静默失败这样的情况不应该发生,但我们经常看到奇怪的客户端行为,特别是在较旧的设备上。
在与客户端团队的合作下,我们能够做出一些改进。在客户端方面,更好的连接处理和重新连接流程的改进意味着它们更有可能适当地重新连接。在Pushy中,我们添加了额外的心跳、空闲连接清理和更好的连接跟踪,这意味着我们保留的过时连接越来越少。
虽然这些改进主要是针对功能开发中的那些边缘情况,但它们有一个附带的好处,就是进一步提高了我们的消息传递率。我们已经有了很好的消息传递率,但这个额外的提升使Pushy能够经常平均达到5个9的消息传递可靠性。
最近2周期间的推送消息传递成功率。
最新发展
有了这个稳定的基础和所有这些连接,我们现在能用它们做什么呢?这个问题一直是几乎所有最近在Pushy之上构建的功能的驱动力,作为一个基础设施团队,这是一个令人兴奋的问题。
向直接推送转变
Pushy传统角色的第一个变化是我们称之为直接推送的功能;不再是后端服务将消息放入异步消息队列,而是可以利用Push库完全跳过异步队列。当被调用以直接路径传递消息时,Push库会在Push注册表中查找连接到目标设备的Pushy,然后直接将消息发送给该Pushy。Pushy将响应一个状态码,反映它是否能够成功传递消息或遇到错误,Push库会将该状态码传回给服务中的调用代码。
直接和间接推送路径的系统图。
Pushy的原作者Susheel将此功能添加为一个可选路径,但多年来,几乎所有后端服务都依赖于间接路径,其"尽力而为"的方式对他们的用例来说已经足够好了。近年来,随着后端服务需求的增长,我们看到这种直接路径的使用真正起飞了。特别是,这些直接消息不再只是尽力而为,而是允许调用服务立即获得关于传递的反馈,让他们在目标设备离线时可以重试。
如今,通过直接推送发送的消息构成了通过Pushy发送的大多数消息。例如,在最近的24小时内,直接消息平均每秒约160,000条,而间接消息平均每秒约50,000条。
直接vs间接每秒消息数的图表。
设备到设备的消息传递
随着我们思考这个不断发展的用例,我们对消息发送者的概念也在演变。如果我们想超越Pushy传递服务器端消息的模式呢?如果我们想让一个设备向后端服务发送消息,或者甚至向另一个设备发送消息呢?我们的消息传统上是单向的,因为我们从服务器向设备发送消息,但现在我们利用这些双向连接和直接设备消息传递来实现我们称之为设备到设备消息传递的功能。这种设备到设备的消息传递支持了早期的手机到电视通信,以支持像Triviaverse这样的游戏,它也是我们的 伴侣模式[8] 的消息传递基础,因为电视和手机来回通信。
作者之一使用移动设备作为控制器玩Triviaquest的截图。
这需要对系统有更高层次的了解,我们不仅需要知道单个设备的信息,还需要知道更广泛的信息,比如手机可以与哪些设备配对的账户连接了哪些设备。这也使得订阅设备事件成为可能,以便知道另一个设备何时上线以及何时可以配对或向其发送消息。这是通过一个额外的服务来实现的,该服务从Pushy接收设备连接信息。这些事件通过Kafka主题发送,让服务能够跟踪给定账户的设备列表。设备可以订阅这些事件,允许它们在同一账户的另一个设备上线时从服务接收消息。
Pushy与设备列表服务的关系,用于发现其他设备。
这个设备列表实现了这些设备到设备消息的可发现性方面。一旦设备获得了同一账户连接的其他设备的知识,它们就能够从这个列表中选择一个目标设备,然后向其发送消息。
一旦设备有了该列表,它就可以通过其WebSocket连接向Pushy发送一条消息,将该设备作为目标,我们称之为_设备到设备消息_(图中的1)。Pushy在Push注册表中查找目标设备的元数据(2),并将消息发送到目标设备连接的第二个Pushy(3),就像它是上面直接推送模式中的后端服务一样。该Pushy将消息传递给目标设备(4),原始Pushy将收到一个状态码作为响应,它可以将其传回源设备(5)。
设备到设备消息的基本事件顺序。
消息协议
我们为设备到设备消息传递定义了一个基本的基于JSON的消息协议,允许这些消息从源设备传递到目标设备。作为一个网络团队,我们自然倾向于在可能的情况下通过封装来抽象通信层。这种通用消息意味着设备团队能够在这些消息之上定义自己的协议 - Pushy只是传输层,愉快地来回转发消息。
客户端应用协议建立在设备到设备协议之上,而设备到设备协议又建立在Pushy之上。
这种泛化在投资和运营支持方面得到了回报。我们在2022年10月构建了这个功能的大部分,此后只需要小的调整。当客户端团队在这一层之上构建功能,定义支持他们正在构建的功能的更高级别的特定于应用程序的协议时,我们几乎不需要任何修改。我们确实很喜欢与我们的合作团队一起工作,但如果我们能够让他们自由地在我们的基础设施层之上构建,而不需要我们参与,那么我们就能够提高他们的速度,让他们的生活更轻松,并扮演我们作为消息平台提供者的基础设施角色。
随着早期功能的实验,Pushy平均每秒看到1000条设备到设备的消息,这个数字只会继续增长。
设备到设备每秒消息数的图表。
Netty的细节
在Pushy中,我们在PushClientProtocolHandler中处理传入的WebSocket消息( 指向我们扩展的Zuul中的类的代码[9] ),它扩展了Netty的ChannelInboundHandlerAdapter,并被添加到每个客户端连接的Netty管道中。我们在其channelRead方法中监听来自连接设备的传入WebSocket消息并解析传入消息。如果它是一个设备到设备的消息,我们将消息、ChannelHandlerContext和有关连接身份的PushUserAuth信息传递给我们的DeviceToDeviceManager。
这些组件的内部组织的粗略概述。
DeviceToDeviceManager负责验证消息,进行一些簿记,并启动一个异步调用,该调用验证设备是否是授权目标,在本地缓存中查找目标设备的Pushy(如果没有找到则调用数据存储),并转发消息。我们异步运行这个过程,以避免由于这些调用而阻塞任何事件循环。DeviceToDeviceManager还负责可观察性,包括缓存命中、对数据存储的调用、消息传递率和延迟百分比测量等指标。我们严重依赖这些指标进行警报和优化 - Pushy真的是一个偶尔会传递一两条消息的指标服务!
安全性
作为Netflix云的边缘,安全考虑始终是首要考虑的。通过HTTPS的每个连接,我们将这些消息限制为仅经过身份验证的WebSocket连接,添加了速率限制,并添加了授权检查以确保设备能够定位另一个设备 - 你可能有最好的意图,但我强烈希望你不能从你的设备向我的个人电视发送任意数据(我相信反之亦然!)。
延迟和其他考虑
使用这个功能构建的产品的一个主要考虑因素是延迟,特别是当这个功能用于Netflix应用内的任何交互式功能时。
我们在Pushy中添加了缓存,以减少热路径中查找不太可能频繁变化的内容的次数,比如设备允许的目标列表和目标设备连接的Pushy实例。我们必须在初始消息上进行一些查找以知道将它们发送到哪里,但它使我们能够在没有任何KeyValue查找的情况下更快地发送后续消息。对于这些缓存从热路径中移除KeyValue的请求,我们能够大大加快速度。从传入消息到达Pushy到响应被发送回设备,我们将中位数延迟减少到不到一毫秒,99%的延迟百分比不到4毫秒。
我们的KeyValue延迟通常非常低,但我们确实看到由于我们的KeyValue数据存储中的底层问题,有短暂的读取延迟增加的时期。Pushy的其他部分(如客户端注册)的整体延迟增加了,但有了这个缓存,我们在设备到设备的延迟方面几乎没有看到增加。
使这项工作成为可能的文化方面
Pushy的规模和系统设计考虑使这项工作在技术上很有趣,但我们也有意关注推动Pushy增长的非技术方面。我们专注于首先解决最困难问题的迭代开发,项目经常从快速黑客或原型开始,以证明一个功能。在进行这个初始版本时,我们尽最大努力着眼于未来,使我们能够快速从支持单一、集中的用例转向广泛、通用的解决方案。例如,对于我们的跨设备消息传递,我们能够在早期为_Triviaverse_解决困难的问题,后来我们利用这些问题来实现通用的设备到设备解决方案。
从上面的系统图中可以立即看出,Pushy并不是孤立存在的,项目经常涉及至少半打团队。信任、经验、沟通和强大的关系都使这一切成为可能。没有我们的平台用户,我们的团队就不会存在,如果没有我们的产品和客户端团队所做的所有工作,我们当然也不会在这里写这篇文章。这也强调了构建和分享的重要性 - 如果我们能够与设备团队一起完成一个原型,我们就能够展示它以激发其他团队的想法。提到你可以发送这些消息是一回事,但展示电视对手机控制器按钮的第一次点击做出响应是另一回事!
Pushy的未来
如果这个世界上有什么是确定的,那就是Pushy将继续增长和发展。我们有许多新功能正在开发中,如WebSocket消息代理、WebSocket消息跟踪、全局广播机制,以及支持游戏和直播的订阅功能。有了所有这些投资,Pushy是一个稳定、加强的基础,为这一代新功能做好了准备。
我们也会写关于这些新功能的文章 - 请继续关注未来的文章。
特别感谢我们出色的同事 Jeremy Kelly[10] 和 Justin Guerra[11] ,他们对Pushy的成长和整个WebSocket生态系统都做出了宝贵的贡献。我们还要感谢我们更大的团队和众多合作伙伴的出色工作;这确实需要整个村庄的努力!
参考链接
- Karthik Yagna: https://www.linkedin.com/in/kyagna/
- Baskar Odayarkoil: https://www.linkedin.com/in/baskar-o-n-46477b3/
- Alex Ellis: https://www.linkedin.com/in/alexander-ellis/
- 如何在Netflix上使用语音控制: https://help.netflix.com/en/node/111997
- apiproxy: https://netflixtechblog.com/open-sourcing-zuul-2-82ea476cb2b3
- Susheel Aroskar的这个InfoQ演讲: https://www.youtube.com/watch?v=6w6E_B55p0E
- Dynomite: https://netflixtechblog.com/introducing-dynomite-making-non-distributed-databases-distributed-c7bce3d89404
- 伴侣模式: https://help.netflix.com/en/node/132821
- 指向我们扩展的Zuul中的类的代码: https://github.com/Netflix/zuul/blob/99ef8841c8b7b82536d5fb193fd751c675c9ad0d/zuul-core/src/main/java/com/netflix/zuul/netty/server/push/PushClientProtocolHandler.java
- Jeremy Kelly: https://www.linkedin.com/in/jeremy-kelly-526a30180/
- Justin Guerra: https://www.linkedin.com/in/justin-guerra-3282262b/