由 Karthik Yagna ,Baskar Odayarkoil ,和 Alex Ellis编写_
Pushy 是 Netflix 的 WebSocket 服务器,它与运行 Netflix 应用程序的设备保持持久的 WebSocket 连接。这使得后端服务可以在需要时向设备发送数据,而无需设备不断发出轮询请求。在过去的几年里,Pushy 见证了巨大的增长,从最初作为尽力而为的消息传递服务的角色,发展成为 Netflix 生态系统中不可或缺的一部分。本文描述了我们如何扩展和扩展 Pushy,以满足其新的和未来的需求,它处理数亿个并发的 WebSocket 连接,每秒传输数十万条消息,并保持稳定的 99.999% 的消息传递可靠性。
历史与动机有两个主要的使用场景推动了Pushy最初的发展和使用。第一个是语音控制,你可以通过语音命令像“用Netflix播放《怪奇物语》”这样的指令来播放一个标题或搜索(如果你想自己尝试,可以参见如何使用Netflix的语音控制)。
如果我们考虑Alexa的使用场景,可以看到与Amazon的合作是如何使这一功能实现的。一旦他们接收到语音命令,我们允许他们通过我们的流媒体边缘代理apiproxy对我们内部的语音服务发起一个经过身份验证的调用。这个调用包括元数据,例如用户的个人信息以及关于命令的详细信息,比如要播放的具体节目。语音服务随后为设备构建一条消息,并将其放置在消息队列中,然后这条消息被处理并发送到Pushy以传递给设备。最后,设备接收到消息,并执行相应的操作,例如“在Netflix上播放《怪奇物语》”。这一初始功能最初是为FireTVs开发的,并在此基础上进行了扩展。
示例的系统图,用于Amazon Alexa的语音命令。从aws结束到互联网开始的部分留给读者自行探索。
另一个主要用例是 RENO,即上述提到的快速事件通知系统。在与 Pushy 集成之前,电视 UI 会持续轮询后端服务,以查看是否有行更新来获取最新信息。这些请求每几秒钟就会发生一次,最终导致向后端发送了不必要的请求,并且对于经常资源受限的设备来说成本很高。与 WebSocket 和 Pushy 的集成解决了这两个问题,允许源服务在准备好时发送行更新,从而降低了请求率并节省了成本。
了解更多关于 Pushy 的背景信息,你可以观看 Susheel Aroskar 在 InfoQ 的演讲 此视频。自那次演讲以来,Pushy 在规模和范围上都有所增长,本文将讨论我们为推动下一代功能而对 Pushy 进行的投资。
客户端覆盖此集成最初面向Fire TV、PS4、三星电视和LG电视推出,覆盖了约3000万台候选设备。鉴于这些明显的效益,我们继续为更多设备构建此功能,以实现相同的效率提升。截至目前,我们已将候选设备的列表进一步扩展到近10亿台设备,包括运行Netflix应用和网站体验的移动设备。我们甚至为那些缺乏现代功能(如TLS和HTTPS请求支持)的旧设备提供了支持。对于这些设备,我们通过在每个设备上添加加密/解密层,实现了从客户端到Pushy的安全通信,从而允许设备与服务器之间传输保密消息。
扩展以处理增长(以及更多) 增长有了更广泛的覆盖范围,Pushy 的工作变得更加繁忙。在过去五年中,Pushy 的并发连接数从数千万增加到了数亿,并且每秒发送的消息数量经常达到 30 万条。为了支持这种增长,我们重新审视了 Pushy 过去的假设和设计决策,着眼于其未来的角色和稳定性。在过去几年中,Pushy 在运营上相对较少需要人工干预,随着我们更新 Pushy 以适应其不断变化的角色,我们的目标也是让它在未来几年内保持稳定状态。这在我们构建依赖于 Pushy 的新功能时尤为重要;一个强大而稳定的基础设施基础,让我们的合作伙伴能够继续在 Pushy 之上进行开发,充满信心。
在整个演变过程中,我们一直能够保持高可用性和一致的消息传递速率,Pushy在过去几个月中成功地保持了99.999%的消息传递可靠性。当我们的合作伙伴想要向设备发送消息时,我们的任务是确保他们能够做到这一点。
这里是我们为应对Pushy不断增长的规模而做出的一些改进。
A few of the related services in Pushy’s immediate ecosystem and the changes we’ve made for them.
消息处理器我们投入精力的一个方面是异步消息处理器的演进。之前的版本是一个 Mantis 流处理作业,它从消息队列中处理消息。它非常高效,但有一个固定的作业大小,如果我们想要进行水平扩展,需要手动干预。此外,在推出新版本时也需要手动干预。
它多年来很好地满足了Pushy的需求。随着处理的消息量增加以及我们在消息处理器中进行更多代码更改,我们发现自己在寻找更灵活的解决方案。特别是,我们希望拥有我们在其他服务中享受的一些功能:自动水平扩展、金丝雀部署、自动化红黑发布以及更多的可观测性。考虑到这一点,我们将消息处理器重写为一个独立的Spring Boot服务,使用了Netflix的标准化组件。它的任务仍然相同,但现在它可以通过简单的发布、让我们安全地推出更改的金丝雀配置以及我们定义的自动扩展策略来处理不同的流量量。
重构总是伴随着风险,而且我们通常不会将它作为首选解决方案,尤其是在处理一个已经运行良好的系统时。在这种情况下,我们发现维护和改进自定义流处理作业的负担在增加,因此我们决定进行重构。其中一个原因是消息处理器所扮演的角色非常明确——我们不是在重构一个庞大的单体服务,而是一个范围明确的组件,它有明确的目标、定义清晰的成功标准以及明确的改进路径。自2023年中期完成重构以来,消息处理器组件已经完全实现了零维护,自动化运行且非常可靠。
推送注册表在它的大部分生命周期中,Pushy 使用 Dynomite 来跟踪其 Push Registry 中的设备连接元数据。Dynomite 是 Netflix 开源的 Redis 包装器,提供了诸如自动分片和跨区域复制等一些额外功能,它为 Pushy 提供了低延迟和易于记录过期,这两者对于 Pushy 的工作负载都至关重要。
随着Pushy的产品线扩展,我们在使用Dynomite时遇到了一些痛点。Dynomite的性能非常出色,但随着系统规模的扩大,它需要手动进行扩展。Netflix内部数据的铺设路径是由云数据工程(CDE)团队构建的,他们很乐意帮助我们扩展并进行调整,但随着我们不断增长,这个过程变得相当复杂。
这些问题与KeyValue的推出恰好吻合,KeyValue是CDE团队为Netflix开发者提供的一个新服务,类似于“服务中的HashMap”。KeyValue是对存储引擎本身的抽象,这使我们能够选择最适合我们SLO需求的存储引擎。在我们的案例中,我们重视低延迟——我们从KeyValue读取的速度越快,消息的传递速度就越快。在CDE的帮助下,我们将Push Registry迁移到了KV,对此结果我们非常满意。经过调整以适应Pushy的需求后,它自那以后一直自动运行,适当地扩展并以非常低的延迟响应我们的请求。
横向和纵向扩展Pushy我们团队运行的其他服务,如 apiproxy、流媒体边缘代理等,都是 CPU 密集型的,我们有自动扩展策略,当看到 CPU 使用率上升时,会水平扩展这些服务。这与它们的工作负载非常匹配——更多的 HTTP 请求意味着更多的 CPU 使用,我们也可以根据需要进行扩展和缩减。
Pushy 的性能特性略有不同,每个节点维护着许多连接,并根据需要发送消息。在 Pushy 的情况下,CPU 使用率一直很低,因为大多数连接都处于空闲状态,等待偶尔的消息。我们不是依赖 CPU,而是根据连接数量来扩展 Pushy,采用指数级扩展,在达到较高阈值后更快地扩展。我们对初始的 HTTP 请求进行负载均衡,以建立连接,并依赖于一个重连协议,其中设备每隔大约 30 分钟重连一次,有些设备会错开重连时间,这为我们提供了一个稳定的重连设备流,以平衡所有可用实例之间的连接。
多年来,我们的扩展策略一直是当平均连接数达到每实例60,000个连接时,就添加新的实例。对于数亿台设备而言,这意味着我们经常运行数千个Pushy实例。我们可以随意横向扩展Pushy,但我们对账单并不满意,而且为了绕过NLB连接限制,我们还需要进一步拆分Pushy。这次演化工作与我们内部对成本效率的关注非常契合,我们借此机会重新审视这些早期假设,以提高效率。
这将通过增加每个 Pushy 节点可以处理的连接数量来得到改善,减少总的 Pushy 实例数量,并以正确的实例类型、实例成本和最大并发连接之间的平衡更高效地运行。这也将使我们在 NLB 限制方面有更多的余地,减少随着我们继续增长而进行额外分片的工作量。不过,增加每个节点的连接数量也有其自身的缺点。当一个 Pushy 实例宕机时,连接到它的设备会立即尝试重新连接。通过增加每个实例的连接数量,意味着我们将增加立即尝试重新连接的设备数量。我们可以在每个实例上拥有百万个连接,但一个宕机的节点会导致百万个设备同时尝试重新连接。
这种微妙的平衡促使我们对许多实例类型和性能调优选项进行了深入评估。通过找到这种平衡,我们最终实现了每个节点平均处理20万次连接的能力,甚至在必要时可以扩展到40万次连接。这在CPU使用率、内存使用率和设备连接时的“雷鸣群效应”之间达到了很好的平衡。我们还增强了自动扩展策略,使其呈指数级扩展;我们偏离目标平均连接数越远,就会添加更多的实例。这些改进使得Pushy几乎可以实现完全的自动化操作,为我们提供了足够的灵活性,以适应更多设备在不同模式下上线。
可靠性及构建稳定的基础在扩展 Pushy 以应对未来需求的同时,我们也仔细检查了我们的可靠性,在最近的功能开发中发现了一些连接边缘情况。我们发现了一些改进空间,特别是在 Pushy 和设备之间的连接方面,由于 Pushy 在一个已经失败的连接上尝试发送消息而没有通知 Pushy 导致了一些失败。理想情况下,这种类似静默失败的情况不会发生,但我们经常看到客户端的奇怪行为,特别是在较旧的设备上。
与客户端团队合作,我们取得了一些改进。在客户端方面,更好的连接处理和围绕重连流程的改进意味着他们更有可能适当重连。在 Pushy 方面,我们增加了额外的心跳、空闲连接清理和更好的连接跟踪,这意味着我们保留的过时连接越来越少。
虽然这些改进主要是针对该功能开发中的边缘情况,但它们也带来了额外的好处,即进一步提高了消息传递速率。我们已经拥有良好的消息传递速率,但这一额外提升使 Pushy 能够定期平均达到 5 个 9 的消息传递可靠性。
最近两周内推送消息的送达成功率
近期发展有了这个稳定的基础和所有这些连接,我们现在能用它们做什么?这个问题一直是推动几乎所有最近在Pushy之上构建的功能的主要动力,对于一个基础设施团队来说,这是一个令人兴奋的问题。
转向直接推送从Pushy的传统角色开始,第一个变化是我们所说的直接推送;而不是后端服务将消息放到异步消息队列中,它可以直接利用Push库跳过整个异步队列。当被调用来通过直接路径传递消息时,Push库会在Push Registry中查找连接到目标设备的Pushy,然后直接将消息发送给该Pushy。Pushy会用一个状态码来反映它是否成功传递了消息或遇到了错误,Push库会将这个状态码返回给服务中的调用代码。
The system diagram for the direct and indirect push paths.
Susheel,Pushy 的原始作者,添加了此功能作为可选路径,但多年来,几乎所有的后端服务都依赖于间接路径,其“尽力而为”的方式足以满足它们的需求。近年来,随着后端服务需求的增长,我们看到这种直接路径的使用量大幅增加。特别是,这些直接消息不再是仅仅“尽力而为”,而是允许调用服务立即获得关于消息传递情况的反馈,如果目标设备已离线,它们可以进行重试。
这些天,通过直接推送发送的消息占通过Pushy发送的消息的大多数。例如,在最近的24小时内,直接推送的消息平均每秒约为160,000条,而间接推送的消息平均每秒约为50,000条。
每秒直接消息与间接消息的图表。
设备到设备消息传递随着我们对这一不断演变的用例的思考,我们对消息发送者的概念也发生了变化。如果我们希望超越 Pushy 的服务器端消息传递模式,该怎么办?如果我们希望设备能够向后端服务发送消息,甚至向其他设备发送消息呢?我们传统上发送消息的方式是从服务器到设备的单向传递,但现在我们利用这些双向连接和直接设备间消息传递,以实现我们所说的设备到设备的消息传递。这种设备到设备的消息传递支持了早期的手机到电视通信,例如在 Triviaverse 这样的游戏中,它也是我们 Companion Mode 的消息传递基础,当电视和手机相互通信时,这种模式就派上了用场。
A screenshot of one of the authors playing Triviaquest with a mobile device as the controller.
这需要对系统有更深入的了解,不仅要了解单个设备的信息,还需要了解更广泛的信息,比如某个账户下的哪些设备可以与手机配对。这还支持订阅设备事件,以便知道其他设备何时上线以及何时可以进行配对或发送消息。这项功能是通过一个额外的服务实现的,该服务从 Pushy 接收设备连接信息。这些事件通过 Kafka 主题发送,使服务能够跟踪给定账户下的设备列表。设备可以订阅这些事件,从而在有相同账户的其他设备上线时从服务接收到消息。
Pushy and its relationship with the Device List Service for discovering other devices.
此设备列表使这些设备间的消息具备了可发现性。一旦设备了解了同一账户下连接的其他设备,它们就可以从这个列表中选择一个目标设备,然后向其发送消息。
一旦设备有了这个列表,它可以通过WebSocket连接向Pushy发送一条消息,将目标设备作为接收方,我们称之为设备到设备消息(如下图1所示)。Pushy会在Push注册表中查找目标设备的元数据(2),然后将消息发送给目标设备连接的第二个Pushy(3),就像在上面的直接推送模式中后端服务一样。这个Pushy将消息传递给目标设备(4),而原始的Pushy会收到一个状态码作为响应,并将其回传给源设备(5)。
A basic order of events for a device to device message.
消息协议我们定义了一个基于JSON的消息协议,用于设备之间的消息传递,这使得消息可以从源设备传递到目标设备。作为网络团队,我们自然倾向于在可能的情况下通过封装来抽象通信层。这种通用的消息意味着设备团队可以在这些消息之上定义自己的协议——Pushy只是传输层,愉快地在设备之间转发消息。
客户端应用程序协议是在设备到设备协议之上构建的,而设备到设备协议又是建立在 Pushy 之上的。
这种抽象化在投资和运营支持方面带来了回报。我们于2022年10月构建了大部分功能,自那以后我们只需要进行一些小的调整。当客户端团队在这一层之上构建功能并定义他们正在开发的特性所需的更高层次的应用特定协议时,我们几乎不需要进行任何修改。我们确实喜欢与合作伙伴团队合作,但如果能够让他们在我们的基础设施层上自由构建,而无需我们参与,那么我们就能提高他们的开发速度,使他们的工作更轻松,并扮演好作为消息平台提供商的角色。
在早期功能的试验中,Pushy 每秒平均看到 1000 条设备到设备的消息,这一数字只会继续增长。
每秒设备间消息的图表。
详细的Netty实现细节在 Pushy 中,我们通过 PushClientProtocolHandler(指向 Zuul 中扩展的类的代码)处理传入的 WebSocket 消息,该类扩展了 Netty 的 ChannelInboundHandlerAdapter,并添加到每个客户端连接的 Netty 管道中。我们在其 channelRead 方法中监听来自连接设备的传入 WebSocket 消息,并解析传入的消息。如果消息是设备到设备的消息,我们将消息、ChannelHandlerContext 和连接身份的 PushUserAuth 信息传递给我们的 DeviceToDeviceManager。
这些组件的内部组织的粗略概述。
设备到设备管理器(DeviceToDeviceManager)负责验证消息,做一些记录工作,并启动一个异步调用以验证设备是否为授权目标,查找目标设备在本地缓存中的Pushy(如果未找到则调用数据存储),并将消息转发出去。我们异步运行这些操作以避免因这些调用而导致事件循环阻塞。设备到设备管理器还负责可观察性,包括缓存命中率、数据存储调用次数、消息传递速率和延迟百分位数测量等指标。我们高度依赖这些指标来触发警报和优化——Pushy实际上是一个偶尔会传递一两条消息的指标服务!
安全性作为Netflix云的边缘,安全考量始终是首要考虑的。每当我们通过HTTPS建立连接时,我们仅限于经过身份验证的WebSocket连接发送这些消息,并添加了速率限制和授权检查,以确保一个设备能够向另一个设备发送消息——你可能怀有最好的意图,但我强烈建议你不能从你的设备向我的个人电视发送任意数据(反之亦然,我相信)。
延迟及其他考虑因素一个主要考虑因素是延迟,特别是在此功能用于Netflix应用中的任何交互式内容时。
我们在 Pushy 中增加了缓存功能,以减少热点路径中对那些不太可能频繁更改的内容(如设备允许的目标列表和目标设备连接的 Pushy 实例)的查找次数。我们仍需在初始消息中进行一些查找以确定发送位置,但这样可以让我们更快地发送后续消息,而无需任何 KeyValue 查找。对于这些通过缓存移除了 KeyValue 从热点路径中的请求,我们大大加快了处理速度。从消息到达 Pushy 到响应发送回设备,我们将中位延迟降低到不到 1 毫秒,第 99 百分位的延迟也低于 4 毫秒。
我们的KeyValue延迟通常非常低,但因我们的KeyValue数据存储底层问题,我们曾见到过短暂的读取延迟升高。总体而言,Pushy其他部分(如客户端注册)的延迟有所增加,但在这种缓存机制下,设备间的延迟几乎没有增加。
一个支持这种工作的文化因素Pushy 的规模和系统设计考虑使其工作在技术上变得有趣,但我们也有意地关注了那些推动 Pushy 成长的非技术方面。我们专注于迭代开发,优先解决最困难的问题,项目经常从快速的黑客技术或原型开始来证明一个功能。在我们进行初始版本时,我们尽量着眼于未来,使我们能够从支持单一、专注的用例快速过渡到广泛的通用解决方案。例如,对于我们的跨设备消息传递,我们能够在早期为 Triviaverse 解决一些难题,后来我们利用这些解决方案为通用的设备到设备解决方案提供了支持。
如上图所示,Pushy 并不是一个孤立存在的系统,项目通常会涉及至少六支团队。信任、经验、沟通和强大的关系使这一切成为可能。如果没有我们的平台用户,我们的团队也不会存在;如果没有我们产品和客户端团队的所有工作,我们也不会在这里写这篇文章。这也强调了构建和分享的重要性——如果我们能够与设备团队一起制作一个原型,我们就可以向其他团队展示并激发他们的想法。仅仅说你可以发送这些消息是一回事,但看到电视对手机控制器按钮的第一个点击做出反应又是另一回事!
Pushy的未来如果这个世界有什么是确定的,那就是Pushy将继续成长和演进。我们正在开发许多新功能,例如WebSocket消息代理、WebSocket消息追踪、全球广播机制以及支持游戏和直播的订阅功能。通过所有这些投入,Pushy已经成为一个稳定且强化的基础,准备迎接下一代功能的到来。
我们也会介绍这些新功能——敬请期待未来的帖子。
特别感谢我们出色的同事们 Jeremy Kelly 和 Justin Guerra ,他们对Pushy的成长以及WebSocket生态系统的发展都做出了不可替代的贡献。我们还要感谢我们更大的团队和众多合作伙伴的辛勤工作;这真的需要大家共同努力!
共同学习,写下你的评论
评论加载中...
作者其他优质文章