本文最初发表于 Uber 官方博客,InfoQ 经对方授权对全文进行了如下翻译。
Uber 的业务遍布全球,每天需要处理全球数百万人次的出行,实时性对 Uber 而言非常重要。在一次行程中,多个参与者可以修改和查看正在进行中的行程状态,这需要实时更新。无论是取车时间、到达时间、路线还是在打开应用时附近的司机数量,所有参与者和应用都必须保持实时信息同步。本文介绍了 Uber 如何通过轮询保持信息实时更新以及基于 gRPC 双向流协议构建应用。
轮询更新
在 Uber 的应用场景下,司机侧需要每隔几秒钟对服务器进行轮询,以查看是否有新的订单。乘客侧可以每隔几秒钟轮询一次服务器,以检查是否分配了司机。
这些应用程序的轮询频率取决于所轮询的数据变化率。在 Uber 这样的大型应用中,变化率的取值范围非常大,从几秒钟到几小时不等。
移动应用轮询的问题
在某些时候,发送到后端 API 网关的请求中 80% 都是轮询调用。
主动轮询可以保持应用响应,但是会占用大量服务器资源。轮询频率上的任何错误都会导致后端负载和性能下降。当需要更多实时动态数据时,这种方法是行不通的,因为它将在后端增加大量负载。
轮询会导致电池消耗加快、应用迟钝以及网络级拥堵。这一点在 2G/3G 网络或整个城市网络不稳定的地方尤为明显,应用在每次轮询时都会多次尝试。
随着功能数量的增加,开发者试图让现有的轮询 API 过载,或创建一个新的 API。在高峰时期,该应用会轮询几十个 API。每一个 API 都有多个功能过载。这些轮询 API 最终只会变成一组负载分片的 API,供应用轮询其功能。保持 API 级别的一致性和逻辑分离,仍然是一个日益增长的挑战。
应用的冷启动是轮询策略中最具挑战的场景。每当用户打开应用,所有功能都需要从后端提取出最新的状态来渲染用户界面。这样就会产生多个竞争的并发 API 调用,应用只有从服务器获取关键组件后才能渲染。由于所有 API 都包含一些关键信息的片段,没有优先级,因此应用的加载时间会持续增加。恶劣的网络条件会使冷启动问题更加恶化。
显然,我们需要对市场上各个参与者的状态同步方式进行彻底改革。在创建推送消息平台的过程中,我们允许服务器根据需要向应用发送数据。
在采用该体系结构时,我们发现效率有了显著提高,同时也解决了很多问题和挑战。接下来的部分,我们将介绍整个该平台是如何演变的。
取消轮询,引入 RAMEN
虽然使用推送消息是取消轮询的必然选择,但是如何进行架构设计却有许多考虑。主要的设计原则有以下四点。
从轮询迁移到推送更容易
现有的很多轮询端点都可以为企业提供动力。在不重写的情况下,新系统必须利用现有轮询 API 中的负载来构建业务逻辑。
易于开发
与开发轮询 API 相比,开发人员不应采取完全不同的方式来推送数据。
可靠性
在网络上,所有消息都应该可靠地发送,如果发送失败,则应重试
线路效率
由于 Uber 的快速发展,对用户来说传输数据的成本是一个挑战,特别是那些每天与平台连接数小时的司机。这个协议必须在服务器和移动应用之间传输最少的数据。
我们将新系统命名为 RAMEN(RealtimeAsynchronousMEssagingNetwork,意即实时异步消息网络)。
图 1:整个系统的高级架构
确定生成消息
实时信息随时都在数百名乘客、司机、餐厅和行程中发生变化。信息的生命周期从确定“何时”为用户生成信息负载开始。
Fireball 是一个微服务,负责解决“何时推送消息”的问题。决策的很大一部分被捕获为配置。它监听系统中发生的各种类型事件,并确定是否需要推送所涉及的用户。
举个例子,当司机“接受”订单时,司机和行程实体的状态会发生变化。这种变化会触发 Fireball 服务。Fireball 根据配置来确定应该将哪种推送消息发送给相关的参与者。许多情况下,一次触发需要多个用户的多个消息负载。
触发器可以是任何类型的重要事件,并且应该为其生成推送负载。例如,像请求乘车这样的用户操作、应用程序的打开、固定时间间隔的计时器滴答声、消息总线上的后端业务事件,或者地理上的出入口事件。
这些触发器全部被过滤,然后转换成对各种 API 网关端点的调用。为了产生合适的本地化响应负载,API 网关需要诸如设备区域、设备操作系统和应用程序版本等用户上下文信息。在调用 API 网关时, Fireball 获取设备上的下文 RAMEN 服务器,并将其添加到头文件。
生成消息负载
来自 Uber 应用的所有服务器调用都由 API 网关提供(在此阅读有关我们网关演变的更多信息),推送的负载也以同样的方式生成。
当 Fireball 确定谁以及何时推送消息时,API 网关负责确定“推送什么”。网关调用各种域服务来产生正确的推负载。
在产生负载的方式上,网关中的所有 API 都是类似的。但是,这个 API 可以分为 Pull API 和 Push API。Pull API 指的是在移动设备上为任何 HTTP 操作调用的端点。所谓 Push API,就是从 Fireball 调用的端点,还有一个附加的 Push 中间件,它可以截取来自 Pull API 的响应并将其转发给 Push 消息传输系统。
在这两者之间设置 API 网关有许多好处:
Pull API 和 Push API 共享了大多数端点业务逻辑。一个给定的负载可以从 Pull API 无缝地切换到 Push API。举例来说,不管应用是通过 Pull API 调用来拉取用户对象,还是通过 Push API 调用来发送用户对象,都使用相同的逻辑。
网关负责处理许多交叉问题,如推送消息的速率限制、路由和模式验证。
与网关一起, Fireball 生成推送消息,并在适当的时候发送给用户。“推送消息系统”负责向移动设备发送此消息。
推送消息负载元数据
为进行优化,每个推送消息都有不同的配置。
优先级
因为对于不同的用例,会产生数百种不同的负载,所以首先要对发送给应用的东西进行优先级排序。我们接下来将会看到 Uber 所采用的协议限制了在一个连接上发送多个并发负载。接收设备的带宽也受到限制。
为获取相对优先级的感受,在理解其影响的基础上,将消息大致分成三个不同的优先级:
高:重要的核心用户体验消息
中:增量用户体验功能消息
低:高数据负载大小或低频非关键消息
这种优先级配置随后将用于管理平台的各种行为。举例来说,当建立连接时,消息以优先级的递减顺序被放入套接字中。如果 RPC 发生故障,通过服务器端重试,高优先级的消息会变得更可靠,并支持跨区域复制。
存活时间
推送消息是为了改善实时体验。因此,每条消息都有一个定义的存活时间值,范围从几秒到 30 分钟不等。消息传输系统会将消息持久化,并重新尝试传输消息,直到存活时间值到期。
重复数据删除
该配置确定了在通过不同的触发器或重试多次产生相同的消息类型时,推送消息是否应该被重复数据删除。对大多数用例而言,发送特定类型的最新推送消息就足够了,这使我们能够降低总体数据传输率。
消息传输
推送消息系统的最后一个组成部分是实际的负载传输服务。它的功能是在消息负载到达之后,保持与全球数以百万计的移动应用程序的有效连接,并快速地将它们同时发送。
世界各地的移动网络提供了不同程度的可靠性,所以传输系统需要对故障作出可靠响应。本系统提供“至少一次”的传输保障。
RAMEN 传输协议
要提供可靠的传输渠道,我们必须利用基于 TCP 的持久连接,以便从应用向我们的数据中心提供传输服务。在 2015 年的应用协议中,我们的选择是利用 HTTP/1.1 与长轮询、Web Sockets 或最终的服务器发送事件(Server-Sent events,SSE)。
根据安全性、对移动 SDK 的支持、二进制大小的影响等因素,我们最终选择了 SSE,这对于 Uber 已经支持的 HTTP+ JSON API 协议栈是非常简单可行的。
然而,SSE 是一个单向协议,即数据只能从服务器发送到应用。为提供至少一次上述保证,需要在应用协议之上建立传输协议来进行确认和重试。
基于 SSE 定义了一种非常优雅和简单的协议方案。
图 2:SSE 协议的服务器 - 客户端交互
客户端在任何新的会话开始时,在第一次 HTTP 请求/ramen/receive?seq=0
时开始接收消息,序列号为 0。服务器用 HTTP 200 和“Content-Type: text/event-stream”响应,以维持 SSE 连接。
接下来,服务器按照优先级降序发送所有待处理的消息,并关联递增的序列号。因为底层的传输协议是 TCP 连接,所以如果没有发送 seq#3 的消息,则该连接应该已断开、超时或失败。
客户端应该在下次使用它看到的最大序列号(本例中 seq=2)重新连接。这样可以告诉服务器即使 3 号被写进套接字中,它也不会被发送。这样,服务器就会重新发送相同的消息或者以 seq=3 开始的任何新优先级更高的消息。该协议建立了所需的可恢复流式连接,服务器做大部分的存储工作,在客户端实现起来很简单。
为了解连接是否处于活动状态,服务器每 4 秒发送一条单字节大小的心跳消息。若客户机在 7 秒内未看到心跳或消息,则认为连接已中断并重新连接。
在上述协议中,每当客户端使用更高的序列号重新连接时,它作为一个确认机制,允许服务器刷新旧消息。当网络运行良好时,用户可以保持长达几分钟的连接,从而使服务器继续积累旧消息。为减轻此问题,应用会每 30 秒调用/ramen/ack?seq=N
。
协议的简单性允许用许多不同的语言和平台快速编写客户端。
设备上下文存储
每次建立连接时,RAMEN 服务器都附加存储设备上下文。这个上下文公开给 Fireball,这样用户就可以访问设备上下文,该 id 根据用户及其设备参数产生,具有唯一的哈希值。这样,即使用户同时使用多个设备或应用,且设置不同,也可以隔离推送消息。
消息存储
RAMEN 服务器将所有的消息保存在内存中,或者备份在数据库中。如果连接不稳定,服务器可以继续重试发送,直到 TTL 到期。
实施细节
首代 RAMEN 服务器是用 Node.js 编写的,使用了“Ringpop”这个优步内部一致的哈希 / 分片框架。Ringpop是一种去中心化的分片系统。所有的连接都用一个用户 UUID 进行分片,并使用 Redis 作为持久化数据存储。
在全球范围部署 RAMEN
随后的一年半时间里,该推送平台在全公司得到了广泛应用。该系统最多保持 60 万个并发流连接,在高峰期每秒向三种不同类型的应用程序推送超过 7 万条 QPS 推送消息。该系统很快就成为服务器客户端 API 基础设施中最重要的部分。
随着流量和持久连接数量的增加,我们的技术选择也需要进行扩展。基于 Ringpop 的分布式分片是一个非常简单的架构,但是不能随着环中节点的增加而扩展。Ringpop 库使用 gossip 协议对成员进行评估。gossip 协议的收敛时间随环的大小增加而增加。
此外,Node.js worker 是单线程的,它会增加事件循环的滞后程度,从而使成员信息的收敛变得更慢。这会导致拓扑信息不一致,以及消息丢失、超时和出错。
2017 年初,我们决定重启 RAMEN 协议的服务器实现,以便继续扩展。我们在本次迭代中使用了下列技术:Netty、Apache Zookeeper、Apache Helix、Redis 和 Apache Cassandra。
Netty:Netty 是一个广泛使用的高性能库,用于构建网络服务器和客户端。Netty 的 bytebuf 支持零拷贝缓冲区,这使得系统非常高效。
Apache ZooKeeper:拥有一致的网络连接哈希,可以在中间不需要任何存储层的情况下直接传输数据。但是,我们没有选择分散式的拓扑管理,而是选择了 ZooKeeper 的集中式共享。ZooKeeper 是一种非常强大的分布式同步和配置管理系统,可以快速检测连接节点的故障。
Apache Helix:Helix 是一个强大的集群管理框架,在 ZooKeeper 上运行,支持定义自定义拓扑和重新平衡算法。它还很好地将拓扑逻辑从核心业务逻辑中抽象出来。它使用 ZooKeeper 来监控连接的工作器,并传播分片状态信息的变化。它还允许我们编写一个自定义的“Leader-Follower”拓扑结构,以及自定义的渐进式再平衡算法。
Redis 与 Apache Cassandra:由于我们正准备进行多区域云架构,所以需要适当地复制和存储消息。Cassandra 是一种持久的、跨区域复制的存储。在 Cassandra 之上使用 Redis 作为容量缓存,以避免由部署或故障转移事件中常见的碎片系统引起的惊群问题(thundering herd problems)。
译注:惊群问题(thundering herd problems),是计算机科学中,当许多进程等待一个事件,事件发生后这些进程被唤醒,但只有一个进程能获得 CPU 执行权,其他进程又得被阻塞,这造成了严重的系统上下文切换代价。
图 3:新 RAMEN 后端服务器的架构
StreamGate:该服务实现了 Netty 上的 RAMEN 协议,并且拥有所有的逻辑,包括连接处理、消息和存储。这个服务还实现了一个 Apache Helix 参与者,它与 ZooKeeper 建立连接,并维护心跳。
StreamgateFE(Streamgate Front End):该服务作为一个 Apache Helix Spectator,监听 ZooKeeper 的拓扑变化。它实现了一个反向代理。每个来自客户端 Fireball、网关或移动应用的请求都是使用拓扑信息分片并路由到正确的 Streamgate 工作器。
Helix Controllers:顾名思义,这是一个由五个节点组成的独立服务,只负责运行 Apache Helix Controller 进程,是进行拓扑管理的“大脑”。无论何时启动或停止任何 Streamgate 节点,它都会检测到更改并重新分配分片分区。
近几年来,我们一直在使用这种架构,并能够在服务器端实现 99.99% 的基础架构可靠性。这种推送基础设施的应用越来越多,支持 iOS、 Android 和 Web 平台上的十几种不同类型的应用。在 150 万以上并发连接的情况下,我们已经在运行这个系统,每秒推送超过 25 万条消息。
gRPC 推动基础设施的未来
这种服务器端基础设施一直保持稳定。在网络条件日益改善、应用范围日益扩大的新城市里,我们将致力于不断提高向移动设备发送消息的长尾可靠性。为了填补空白,我们已经尝试了新的协议和开发方法。经过检验,以下几点是导致可靠性下降的主要原因。
丢失确认
上文定义的 RAMEN 协议是为减少数据传输而优化的,因此只有每 30 秒或在客户端重新连接时才会报告确认。这样会导致确认被延迟,在某些情况下,确认消息传输失败。这样很难区分消息的真实丢失与确认请求的失败。
连接稳定性差
保持服务器和客户端之间的健康连接是至关重要的。在处理错误、超时、后退或应用程序生命周期事件(打开或关闭)、网络状态变化、主机名和数据中心故障切换方面,不同平台的客户端实现之间存在许多细微的差别。这样会在不同版本中产生性能差异。
传输限制
因为协议是在 SSE 上实现的,所以数据传输是单向的。有些新的应用经验需要我们实现双向信息传输。在没有实时测量往返时间的情况下,无法确定网络状况、传输速度和减少线路阻塞。在没有 base64 这样的文本编码的情况下, SSE 也是一种基于文本的协议,这限制了我们传输二进制负载的能力,导致负载变大。
2019 年底,我们开始开发下一代的 RAMEN 协议,以克服上述缺点。经过大量的考虑,我们选择了在 gRPC 上构建它。 gRPC 是一种被广泛采用的 RPC 协议栈,具有跨多种语言的客户机和服务器的标准化实现。对于许多不同的 RPC 方法,它提供了一流的支持,并且可以与 QUIC 传输层协议进行互操作。
新的基于 GRPC 的 RAMEN 协议扩展了以前的基于 SSE 的协议,但其中存在一些重要区别:
当前将立即发送反向流的确认。这样就提高了确认的可靠性,但数据传输量却略微增加。
实时确认使我们能够实时测量 RTT,了解网络状态。可将消息的真实损失与网络损失区分开来。
其提供了协议之上的抽象层来支持流复用等功能。同时也使我们可以尝试采用网络优先级和流控制算法来提高数据使用和通信延迟的效率。
这个协议抽象了消息负载,以支持不同类型的序列化。将来我们可以研究其他的序列化,但 gRPC 保留在传输层。
强大的不同语言客户端实现还允许我们快速支持不同类型的应用和设备。
这项工作目前正处于测试发布阶段,目前对其未来看好。
最后
在 Uber 的出行体验中, 推送平台是一个不可或缺的部分。现在这个平台已经提供了上百种不同的功能,以下是该平台在获得巨大成功的几个主要原因。
关注点分离
随着业务需求的变化,消息触发、创建和交付系统之间的明确职责划分使我们能够将焦点转移到平台的不同部分。在 Apache Helix 中,将交付组件与拓扑逻辑、流媒体的核心业务逻辑高度分离,这使得 gRPC 在完全相同的架构上得到支持,但是使用不同的线协议。
行业标准技术
以行业标准技术为基础,使实施更稳健且长远,成本更低。上面的系统维护开销已经很小了。作为一个平台,我们可以在团队规模上提供极高的效率。以我们的经验来看,Helix 和 Zookeeper 非常稳定。
更简单的设计
通过这个协议,我们可以在不同的网络环境下扩展到数百万用户、数百种功能和数十种应用,这个协议的简单性使得它容易扩展,发展迅速。
作者简介:
Uday Kiran Medisetty,Uber 高级工程师;Nilesh Mahajan,Uber 工程师;Anirudh Raja,Uber 二级软件工程师;Madan Thangavelu,Uber 高级工程经理。
原文链接:
评论