为什么要定义RSocket协议
水平有限,有些地方翻译的不准,英文原版请点击下方阅读原文。
导言
大型分布式系统通常由不同团队使用各种技术和编程语言以模块化方式实现的。各个部分需要可靠地通信,并支持快速、独立的发展。模块之间有效且可扩展的通信是分布式系统中的一个关键问题。它会显着影响用户体验的延迟时间以及构建和运行系统所需的资源量。
Reactive Manifesto 中记录并在Reactive Streams和Reactive Extensions等库中实现的架构模式支持异步消息传递,并包含request/response之外的通信模式。这“RSocket”协议是一种拥抱“响应式”原则的正式通信协议。
以下是我们定义新协议的动机:
消息驱动
网络通信是异步的,RSocket 协议包含这一点,并将所有通信建模为在单个网络连接(TCP)上的、多路复用的消息流,在等待响应时从不同步阻塞。
响应式宣言指出:
反应式系统依赖异步的消息传递,从而确保了松耦合、隔离、位置透明的组件之间有着明确边界。这一边界还提供了将失败作为消息委托出去的手段。使用显式的消息传递,可以通过在系统中塑造并监视消息流队列, 并在必要时应用回压, 从而实现负载管理、 弹性以及流量控制。使用位置透明的消息传递作为通信的手段, 使得跨集群或者在单个主机中使用相同的结构成分和语义来管理失败成为了可能。非阻塞的通信使得接收者可以只在活动时才消耗资源, 从而减少系统开销。
此外,HTTP/2 FAQ[1] 很好地解释了在持久连接上采用多路复用的面向消息的协议的动机:
-
HTTP/1.x 有一个叫做 “head-of-line blocking(队头阻塞)” 的问题,在这种情况下,即在一个连接上一次只能有一个未完成请求。
-
HTTP/1.1 试图通过流水线(Pipelining)来解决这个问题,但它并没有完全解决这个问题(大的或慢的响应仍然会阻塞后面的其他响应)。此外,过流水线很难部署,因为许多代理和服务器不能正确地处理它。
在HTTP/1中使用并发连接和域名分片来缓解HOL问题
-
并发连接,浏览器争对每个源(域名)可以打开4-8个TCP连接,提升并发度。 -
域名分片,浏览器和HTTP/1限制了并发连接的数量,那么就把多个域名指向一台服务器,从而提升连接数量。
这迫使客户端使用一些启发式方法(通常是猜测)来确定什么请求在什么时候放在与源站的哪个连接上;由于加载一个页面的次数通常是可用连接数量的10倍或者更多。这会导致被阻塞的请求“瀑布式”的增长,从而严重的影响性能。
多路复用通过允许多个请求和响应消息在一个连接上同时传输来解决这些问题;甚至可以将一条消息的部分与另一条消息的部分混合在一起。
使用HTTP/1,浏览器为每个源打开4-8个连接,由于许多站点使用多个源,这可能意味着打开单个页面要加载30多个TCP连接.
一个应用程序同时打开如此多的连接,打破了TCP所建立的许多假设;由于每个连接都会在响应中传输大量的数据,因此TCP缓冲区很大可能会溢出,从而导致拥塞事件和超时重传。
一个应用程序同时打开如此多的连接,此外,使用如此多的连接不公平地垄断了网络资源,“窃取”了其他性能更好的应用程序(如VoIP)的资源。
交互模型
不合适的协议会增加系统开发的成本。它可能是一个不匹配的抽象,但是我们必须将系统设计强加到他允许的交互模型中。这迫使开发人员花费额外的时间来解决它的缺点,以处理错误并获得可接受的性能。在多语言环境中,这个问题被放大了,因为不同的语言将使用不同的方法来解决这个问题,这需要团队之间的额外协调。到目前为止,通信协议事实上的标准是HTTP,它只支持请求/响应的交互模式。在某些情况下,这可能不是最理想通信模型。
在这里我还是有比较深刻的体会的,不同的团队因为业务特性的不同选择了不同的开发语言,比如java、go。java使用的dubbo协议,go需要和java进行交互,就不得不适配dubbo协议。从序列化到编解码到语言层面的特性都需要进行适配。并且私有化协议在网关上和各种开源组件上都需进行相应的适配。
一个例子是推送通知。使用request/respones交互模型,客户端必须使用轮训不断检查服务端的状态。应用程序每秒执行大量的请求,只是为了轮询,然后被告知没有适合它们的东西。这对于客户端、服务器、网络来说是巨大的浪费。花费金钱;并增加了基础设施的规模、运营的复杂性,从而提高了可用性。它通常还会增加用户在接收通知时的延迟,因为为了降低成本,轮询会使用更长的间隔。
出于这个和其他原因,RSocket不仅仅局限于一个交互模型。下面描述的各种支持的交互模型为系统设计提供了强大的新可能性:
发后即忘(Fire-and-Forget)
Fire-and-forget 是在不需要响应时对requset/response交互模型的一种优化,他可以显著的优化性能,不仅因为跳过响应节省了网络使用量,并且还减少了客户端和服务端的处理时间,因为客户端不需要记录和等待请求关联的响应和取消请求。
在HTTP/1 中,void() 方法在远程调用中不属于发后即忘,虽然void表达了不关心返回值的语义,但是在通信层面仍然需要做出响应。
可以这样使用
Future<Void> completionSignalOfSend = socketClient.fireAndForget(message);
请求/响应(Request/Response)
标准请求/响应语义仍然受到支持,并且任然表示RSocket上的大多数请求,这些请求/响应交互可以被视为优化的“只有 1 个响应的流”,并且是在单个连接上多路复用的异步消息。
消费者等待响应消息,因此它看起来像是一个典型的请求/响应,但是在底层它永远不会同步阻塞。
可以这样使用
Future<Payload> response = socketClient.requestResponse(requestPayload);
请求/流(Request/Stream)
Request/Stream 是从 Request/Response 扩展而来,它允许多条消息流式传输回来。可以将其视为“collection”或“list”响应,但不是将所有数据作为单个响应返回,而是将每个元素按顺序流回。
使用案例可能如下:
-
获取视频列表 -
获取目录中的商品 -
逐行检索数据
可以这样使用
Publisher<Payload> response = socketClient.requestStream(requestPayload);
通道(Channel)
通道是双向的,两个方向都有消息流。
受益于此交互模型的一个示例用例是:
-
客户端请求一个数据流,该数据流最初会破坏当前的世界视图
-
当发生更改时,增量/差异从服务器发送到客户端
-
客户端随时间更新订阅以添加/删除条件/主题等。
如果没有双向通道,客户端将不得不取消初始请求、重新请求并从头开始接收所有数据,而不仅仅是更新订阅并有效地获取差异。
可以这样使用
Publisher<Payload> output = socketClient.requestChannel(Publisher<Payload> input);
行为
除了上面的交互模型之外,还有其他行为可以使应用程序和系统效率受益。
Single-response vs Multi-response
单响应和多响应之间的一个关键区别是RSocket堆栈如何将数据传递给应用程序:单个响应意味着应用程序只有在接收到整个响应时才能获取其数据,而多响应则提供零碎的信息,这允许用户在设计服务时考虑使用多响应,然后客户端可以在收到第一个块后就开始处理数据。
双向的(Bi-Directional)
RSocket支持双向请求,其中客户端和服务器都可以充当请求者或响应者。这允许客户端(例如用户设备)充当来自服务器的请求的响应者。例如,服务器可以查询客户端的跟踪调(trace debug)试信息、状态等。通过允许服务器端在需要时进行查询,而不是让数百万/数十亿客户端不断提交偶尔需要的数据,这可以用来减少基础设施的伸缩需求。这也为客户机和服务器之间的未来交互模型开辟了新的天地。
取消(Cancellation)
所有流(包括请求/响应)都支持取消,以便高效地清理服务器(响应者)资源。这意味着当客户机取消或离开时,服务器有机会提前终止工作。对于流和订阅等交互模型是必不可少的,对于请求/响应交互模型也非常有用,这样允许用“backup requests”等方法来控制尾部延迟(tail requset)。(更多信息请点击 here, here, here, and here)
尾部延迟:尾部延迟,也称为高百分比延迟,是指客户端很少看到的高延迟。诸如:“我的服务主要在 10 毫秒左右响应,但有时需要大约 100 毫秒”。
尾部延迟对业务系统有非常重要的影响,参考 “尾部延迟”如何影响面向客户的应用程序, According to a LinkedIn blog post.
可恢复性(Resumability)
对于那些长期存在的流,特别是那些从移动客户端提供订阅服务的流,如果所有订阅都必须重新建立,那么网络断开会严重影响成本和性能。当网络立即重新连接,或在Wifi和蜂窝网络之间切换时,这种情况尤其严重。
RSocket支持会话恢复,允许通过一个简单的握手在一个新的传输连接上恢复客户端/服务器会话。
应用级的流控
RSocket支持两种形式的应用程序级流控制,以帮助保护客户端和服务器资源不被压垮:“Reactive Streams” request(n)
async pull, and leasing.
该协议设计用于数据中心、server-to-server的场景,以及internet上的服务器到设备的场景,例如移动设备或浏览器。
“Reactive Streams” request(n)
Async Pull
第一种形式的流量控制适用于服务器到服务器(server-to-server)和服务器到设备(server-to-device)的场景。它的灵感来自 Reactive Streams Subscription.request(n)行为。RxJava[2]、 Reactor[3] 和 Akka Streams[4] 就是使用这种“异步 pull-push” 流控制形式的实现示例。
RSocket 允许 request(n) 信号在从请求者到响应者(通常是客户端到服务器)的网络边界上组成。这在应用程序级别使用 Reactive Streams 语义控制从响应者到请求者发送的流,并允许使用有界缓冲区,因此流率会根据应用程序消耗进行调整,而不仅仅依赖于传输和网络缓冲。
Java 9 的 java.util.concurrent.Flow
类型套件中采用了相同的数据类型和方法。
租赁(Leasing)
第二种形式的流控制主要用于数据中心中的服务器到服务器(server-to-server )的场景。当启用时,响应者(通常是服务器)可以根据其容量向请求者发出租约,以控制请求速率。在请求端,允许应用程序级负载平衡,以便仅向具有发送容量信号的响应者(服务器)发送消息。这种从服务器到客户机的信号允许在数据中心集群中使用更智能的路由和负载均衡算法。
多语言支持
通过利用现有的协议、库和技术,可以实现上述许多动机。然而,这往往与特定的实现紧密结合,这些实现必须在不同语言、平台和技术堆栈之间达成一致。
相反,将交互模型和流控制行为作为一个协议可以在不同语言的实现之间提供契约。这反过来在比无处不在的HTTP/1.1请求/响应更广泛的行为集合中改进了多语言交互,同时还支持跨语言的反应流应用程序级流控制(而不仅仅是在Java中,例如最初定义反应流的地方)。
传输层的灵活性
正如 HTTP 请求/响应不是应用程序可以或应该进行通信的唯一方式一样,TCP 也不是唯一可用的传输层协议,也不是所有场景的最佳选择。因此,RSocket 允许您根据环境、设备的功能和性能需求替换传输层协议。RSocket(应用层协议)以 WebSockets、TCP 和Aeron[5]为目标,并期望在任何具有类似TCP特性的传输层上使用,例如QUIC[6]。
更重要的是,它使TCP、WebSockets和Aeron不需要费很大的力就能使用。例如,WebSockets 的使用通常很有吸引力,但它暴露的只是框架语义,因此使用它需要定义应层协议,这通常是很困难的。TCP甚至不提框架语义。因此,大多数应用程序始终使用HTTP/1.1并坚持请求/响应的交互模型,错过了同步请求/响应之外的交互模型的好处。
效率和性能
低效使用网络资源的协议(重复握手、连接建立、关闭连接以及臃肿的消息格式等)会大大增加系统的感知延迟。此外,如果没有流控制语义,当依赖服务变慢时,单个编写不当的模块可能会拖垮其他的系统,从而可能导致重试风暴,给系统带来更大的压力。Hystrix 是一个解决同步的请求/响应问题的示例方案。然而,这是以开销和复杂性为代价的[7]。
此外,选择不当的通信协议会浪费服务器资源(CPU、内存和网络带宽)。虽然这对于较小的部署来说是可以接受的,但拥有数百或数千节点的大型系统会将小的效率低下变成明显的过剩。由于服务器资源相对便宜但不是无限的,因此在占用大量空间的情况下运行的扩展空间较小管理大型集群的成本要高得多,而且即使有好的工具,也不够灵活。经常被遗忘的一点是,集群越大,其操作就越复杂,这就成为一个可用性问题。
RSocket 旨在:
-
通过支持非阻塞、双工、异步应用程序通信,对来自任何语言的多个传输进行流控制,减少可感知的延迟并提高系统效率。
-
通过以下方式减少硬件占用空间(从而降低成本和操作复杂性):
-
通过使用二进制编码提高 CPU 和内存利用率 -
通过使用长连接避免冗余工作 -
通过以下方式减少用户感知的延迟:
-
避免握手和相关的往返网络开销 -
使用二进制编码减少计算时间 -
减少内存分配,降低垃圾回收成本
比较
下面是我们在决定创建RSocket之前对一些协议的简要回顾。它不试图详尽无遗,不批评各种协议有协议,因为它们都有各自擅长的方向。本节只阐述现有协议不能充分满足需求从而创建RSocket的原因。
对于上下文:
-
RSocket 位于 OSI的 5/6层,或 TCP/IP协议模型中的应用层 -
它旨在用于行为类似于 TCP 的全双工、二进制传输协议(此处进一步描述)。
TCP & QUIC
-
没有框架或应用程序语义 -
必须提供应用协议
WebSockets
-
没有应用语义,只是框架 -
必须提供应用协议
HTTP仅提供了用于构建的应用程序协议的基本功能,但仍然需要在其上定义应用程序协议。它在定义应用程序语义方面是不够的。(谷歌的gRPC[8]就是在HTTP/2之上构建的一个协议,以添加这些类型的语义)。
这些有限的应用语义通常需要一个应用协议来定义诸如:
-
使用 GET、POST 或 PUT 进行请求 -
使用 Normal、Chunked 或 SSE 进行响应 -
有效载荷的 MimeType -
错误消息以及标准状态代码 -
客户端应该如何处理状态代码 -
使用 SSE 作为从服务器到客户端的持久通道,以允许服务器向客户端发出请求
没有定义从响应者(通常是服务器)到请求者(通常是客户端)的流控制机制。HTTP/2在字节级进行流量控制,而不是在应用程序级。用于通信请求者(通常是服务器)可用性的机制(例如请求失败)效率低下且困难的。它不支持像“Fire-and-Forget”这样的交互模型,而且流模型效率很低(分块编码或SSE,基于ascii)。
HTTP/2对于浏览器和请求/响应文档传输来说要好得多,但不像本文档前面描述的那样暴露应用程序所需的行为和交互模型。此外,HTTP/2 的 “push promises” 专注于为标准 Web 浏览行为填充浏览器缓存:
这意味着我们仍然需要 SSE 或 WebSockets(并且 SSE 是一种文本协议,因此需要将 Base64 编码为 UTF-8)进行推送。
HTTP/2 意味着更好的 HTTP/1.1,但是它主要用于网站浏览器中的文档检索。对于应用程序,我们可以做得比 HTTP/2 更好。
参考资料
why-is-http2-multiplexed: https://http2.github.io/faq/#why-is-http2-multiplexed
[2]RxJava 是一个库,用于使用 Java VM 的可观察序列组合异步和基于事件的程序。: https://github.com/ReactiveX/RxJava
[3]应用程序和框架以及反应性扩展的反应性基础,具有 Mono(1 个元素)和 Flux(n 个元素)类型。: https://github.com/reactor/reactor
[4]一种直观且安全的方式来制定流处理设置,以便我们可以有效地执行它们并限制资源使用。: https://doc.akka.io/about/akka/2.4/scala/stream/index.html
[5]高效可靠的 UDP 单播、UDP 多播和 IPC 消息传输。: https://github.com/real-logic/Aeron
[6]QUIC 是一种新的传输方式,与 TCP 相比,它减少了延迟。: https://www.chromium.org/quic
[7]hystrix: https://github.com/Netflix/Hystrix/wiki/FAQ#what-is-the-processing-overhead-of-using-hystrix
[8]GRPC-PROTOCOL-HTTP2: https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md