本章主要内容
- Droplr
- Firebase
- Urban Airship
在本章中,我们将介绍两部分案例研究中的第一部分,它们是由已经在内部基础设施中广泛使用了Netty的公司贡献的。我们希望这些其他人如何利用Netty框架来解决现实世界问题的例子,能够拓展你对于Netty能够做到什么事情的理解。
注意 每个案例分析的作者都直接参与了他们所讨论的项目。
14.1 Droplr——构建移动服务
Bruno de Carvalho,首席架构师
在Droplr,我们在我们的基础设施的核心部分、从我们的API服务器到辅助服务的各个部分都使用了Netty。
这是一个关于我们是如何从一个单片的、运行缓慢的LAMP[1]应用程序迁移到基于Netty实现的现代的、高性能的以及水平扩展的分布式架构的案例研究。
14.1.1 这一切的起因
当我加入这个团队时,我们运行的是一个LAMP应用程序,其作为前端页面服务于用户,同时还作为API服务于客户端应用程序,其中,也包括我的逆向工程的、第三方的Windows客户端windroplr。
后来Windroplr变成了Droplr for Windows,而我则开始主要负责基础设施的建设,并且最终得到了一个新的挑战:完全重新考虑Droplr的基础设施。
在那时,Droplr本身已经确立成为了一种工作的理念,因此2.0版本的目标也是相当的标准:
- 将单片的技术栈拆分为多个可横向扩展的组件;
- 添加冗余,以避免宕机;
- 为客户端创建一个简洁的API;
- 使其全部运行在HTTPS上。
创始人Josh和Levi对我说:“要不惜一切代价,让它飞起来。”
我知道这句话意味的可不只是变快一点或者变快很多。“要不惜一切代价”意味着一个完全数量级上的更快。而且我也知道,Netty最终将会在这样的努力中发挥重要作用。
14.1.2 Droplr是怎样工作的
Droplr拥有一个非常简单的工作流:将一个文件拖动到应用程序的菜单栏图标,然后Droplr将会上传该文件。当上传完成之后,Droplr将复制一个短URL——也就是所谓的拖乐(drop)——到剪贴板。
就是这样。欢畅地、实时地分享。
而在幕后,拖乐元数据将会被存储到数据库中(包括创建日期、名称以及下载次数等信息),而文件本身则被存储在Amazon S3上。
14.1.3 创造一个更加快速的上传体验
Droplr的第一个版本的上传流程是相当地天真可爱:
(1)接收上传;
(2)上传到S3;
(3)如果是图片,则创建略缩图;
(4)应答客户端应用程序。
更加仔细地看看这个流程,你很快便会发现在第2步和第3步上有两个瓶颈。不管从客户端上传到我们的服务器有多快,在实际的上传完成之后,直到成功地接收到响应之间,对于拖乐的创建总是会有恼人的间隔——因为对应的文件仍然需要被上传到S3中,并为其生成略缩图。
文件越大,间隔的时间也越长。对于非常大的文件来说,连接[2]最终将会在等待来自服务器的响应时超时。由于这个严重的问题,当时Droplr只可以提供单个文件最大32MB的上传能力。
有两种截然不同的方案来减少上传时间。
- 方案A,乐观且看似更加简单(见图14-1):
- 完整地接收文件;
- 将文件保存到本地的文件系统,并立即返回成功到客户端;
- 计划在将来的某个时间点将其上传到S3。
- 方案B,安全但复杂(见图14-2):
- 实时地(流式地)将从客户端上传的数据直接管道给S3。
图14-1 方案A,乐观且看似更加简单
图14-2 方案B,安全但复杂
1.乐观且看似更加简单的方案
在收到文件之后便返回一个短URL创造了一个空想(也可以将其称为隐式的契约),即该文件立即在该URL地址上可用。但是并不能够保证,上传的第二阶段(实际将文件推送到S3)也将最终会成功,那么用户可能会得到一个坏掉的链接,其可能已经被张贴到了Twitter或者发送给了一个重要的客户。这是不可接受的,即使是每十万次上传也只会发生一次。
我们当前的数据显示,我们的上传失败率略低于0.01%(万分之一),绝大多数都是在上传实际完成之前,客户端和服务器之间的连接就超时了。
我们也可以尝试通过在文件被最终推送到S3之前,从接收它的机器提供该文件的服务来绕开它,然而这种做法本身就是一堆麻烦:
- 如果在一批文件被完整地上传到S3之前,机器出现了故障,那么这些文件将会永久丢失;
- 也将会有跨集群的同步问题(“这个拖乐所对应的文件在哪里呢?”);
- 将会需要额外的复杂的逻辑来处理各种边界情况,继而不断产生更多的边界情况;
在思考过每种变通方案和其陷阱之后,我很快认识到,这是一个经典的九头蛇问题——对于每个砍下的头,它的位置上都会再长出两个头。
2.安全但复杂的方案
另一个选项需要对整体过程进行底层的控制。从本质上说,我们必须要能够做到以下几点。
- 在接收客户端上传文件的同时,打开一个到S3的连接。
- 将从客户端连接上收到的数据管道给到S3的连接。
- 缓冲并节流这两个连接:
- 需要进行缓冲,以在客户端到服务器,以及服务器到S3这两个分支之间保持一条的稳定的流;
- 需要进行节流,以防止当服务器到S3的分支上的速度变得慢于客户端到服务器的分支时,内存被消耗殆尽。
- 当出现错误时,需要能够在两端进行彻底的回滚。
看起来概念上很简单,但是它并不是你的通常的Web服务器能够提供的能力。尤其是当你考虑节流一个TCP连接时,你需要对它的套接字进行底层的访问。
它同时也引入了一个新的挑战,其将最终塑造我们的终极架构:推迟略缩图的创建。
这也意味着,无论该平台最终构建于哪种技术栈之上,它都必须要不仅能够提供一些基本的特性,如难以置信的性能和稳定性,而且在必要时还要能够提供操作底层(即字节级别的控制)的灵活性。
14.1.4 技术栈
当开始一个新的Web服务器项目时,最终你将会问自己:“好吧,这些酷小子们这段时间都在用什么框架呢?”我也是这样的。
选择Netty并不是一件无需动脑的事;我研究了大量的框架,并谨记我认为的3个至关重要的要素。
(1)它必须是快速的。我可不打算用一个低性能的技术栈替换另一个低性能的技术栈。
(2)它必须能够伸缩。不管它是有1个连接还是10 000个连接,每个服务器实例都必须要能够保持吞吐量,并且随着时间推移不能出现崩溃或者内存泄露。
(3)它必须提供对底层数据的控制。字节级别的读取、TCP拥塞控制等,这些都是难点。
要素1和要素2基本上排除了任何非编译型的语言。我是Ruby语言的拥趸,并且热爱Sinatra和Padrino这样的轻量级框架,但是我知道我所追寻的性能是不可能通过这些构件块实现的。
要素2本身就意味着:无论是什么样的解决方案,它都不能依赖于阻塞I/O。看到了本书这里,你肯定已经明白为什么非阻塞I/O是唯一的选择了。
要素3比较绕弯儿。它意味着必须要在一个框架中找到完美的平衡,它必须在提供了对于它所接收到的数据的底层控制的同时,也支持快速的开发,并且值得信赖。这便是语言、文档、社区以及其他的成功案例开始起作用的时候了。
在那时我有一种强烈的感觉:Netty便是我的首选武器。
1.基本要素:服务器和流水线
服务器基本上只是一个ServerBootstrap
,其内置了NioServerSocketChannelFactory
,配置了几个常见的ChannelHandler
以及在末尾的HTTP RequestController
,如代码清单14-1所示。
代码清单14-1 设置ChannelPipeline
pipelineFactory = new ChannelPipelineFactory { @Override public ChannelPipeline getPipeline throws Exception { ChannelPipeline pipeline = Channels.pipeline; pipeline.addLast("idleStateHandler", new IdleStateHandler(...)); ← -- IdleStateHandler 将关闭不活动的连接 pipeline.addLast("httpServerCodec", new HttpServerCodec); ← -- HttpServerCodec 将传入的字节转换为HttpRequest,并将传出的HttpResponse 转换为字节 pipeline.addLast("requestController", ← -- 将RequestController添加到ChannelPipeline 中 new RequestController(...)); return pipeline; }};
RequestController
是ChannelPipeline中唯一自定义的Droplr代码,同时也可能是整个Web服务器中最复杂的部分。它的作用是处理初始请求的验证,并且如果一切都没问题,那么将会把请求路由到适当的请求处理器。对于每个已经建立的客户端连接,都会创建一个新的实例,并且只要连接保持活动就一直存在。
请求控制器负责:
- 处理负载洪峰;
- HTTP
ChannelPipeline
的管理; - 设置请求处理的上下文;
- 派生新的请求处理器;
- 向请求处理器供给数据;
- 处理内部和外部的错误。
代码清单14-2给出的是RequestController
相关部分的一个纲要。
代码清单14-2 RequestController
public class RequestController extends IdleStateAwareChannelUpstreamHandler { @Override public void channelIdle(ChannelHandlerContext ctx, IdleStateEvent e) throws Exception { // Shut down connection to client and roll everything back. } @Override public void channelConnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { if (!acquireConnectionSlot) { // Maximum number of allowed server connections reached, // respond with 503 service unavailable // and shutdown connection. } else { // Set up the connection's request pipeline. } } @Override public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { if (isDone) return; if (e.getMessage instanceof HttpRequest) { handleHttpRequest((HttpRequest) e.getMessage); ← -- Droplr 的服务器请求验证的关键点 } else if (e.getMessage instanceof HttpChunk) { handleHttpChunk((HttpChunk)e.getMessage); ← -- 如果针对当前请求有一个活动的处理器,并且它能够接受HttpChunk 数据,那么它将继续按HttpChunk 传递 } }}
如同本书之前所解释过的一样,你应该永远不要在Netty的I/O线程上执行任何非CPU限定的代码——你将会从Netty偷取宝贵的资源,并因此影响到服务器的吞吐量。
因此,HttpRequest
和HttpChunk
都可以通过切换到另一个不同的线程,来将执行流程移交给请求处理器。当请求处理器不是CPU限定时,就会发生这样的情况,不管是因为它们访问了数据库,还是执行了不适合于本地内存或者CPU的逻辑。
当发生线程切换时,所有的代码块都必须要以串行的方式执行;否则,我们就会冒风险,对于一次上传来说,在处理完了序列号为n的HttpChunk
之后,再处理序列号为n -1的HttpChunk
必然会导致文件内容的损坏。(我们可能会交错所上传的文件的字节布局。)为了处理这种情况,我创建了一个自定义的线程池执行器,其确保了所有共享了同一个通用标识符的任务都将以串行的方式被执行。
从这里开始,这些数据(请求和HttpChunk
)便开始了在Netty和Droplr王国之外的冒险。
我将简短地解释请求处理器是如何被构建的,以在RequestController
(其存在于Netty的领地)和这些处理器(存在于Droplr的领地)之间的桥梁上亮起一些光芒。谁知道呢,这也许将会帮助你架构你自己的服务器应用程序呢!
2.请求处理器
请求处理器提供了Droplr的功能。它们是类似地址为/account
或者/drops
这样的URI背后的端点。它们是逻辑核心——服务器对于客户端请求的解释器。
请求处理器的实现也是(Netty)框架实际上成为了Droplr的API服务器的地方。
3.父接口
每个请求处理器,不管是直接的还是通过子类继承,都是RequestHandler
接口的实现。
其本质上,RequestHandler
接口表示了一个对于请求(HttpRequest
的实例)和分块(HttpChunk
的实例)的无状态处理器。它是一个非常简单的接口,包含了一组方法以帮助请求控制器来执行以及/或者决定如何执行它的职责,例如:
- 请求处理器是有状态的还是无状态的呢?它需要从某个原型克隆,还是原型本身就可以用来处理请求呢?
- 请求处理器是CPU限定的还是非CPU限定的呢?它可以在Netty的工作线程上执行,还是需要在一个单独的线程池中执行呢?
- 回滚当前的变更;
- 清理任何使用过的资源。
这个接口[3]就是RequestController
对于相关动作的所有理解。通过它非常清晰和简洁的接口,该控制器可以和有状态的和无状态的、CPU限定的和非CPU限定的(或者这些性质的组合)处理器以一种独立的并且实现无关的方式进行交互。
4.处理器的实现
最简单的RequestHandler
实现是AbstractRequestHandler
,它代表一个子类型的层次结构的根,在到达提供了所有Droplr的功能的实际处理器之前,它将变得愈发具体。最终,它会到达有状态的实现SimpleHandler
,它在一个非I/O工作线程中执行,因此也不是CPU限定的。SimpleHandler
是快速实现那些执行读取JSON格式的数据、访问数据库,然后写出一些JSON的典型任务的端点的理想选择。
5.上传请求处理器
上传请求处理器是整个Droplr API服务器的关键。它是对于重塑webserver
模块——服务器的框架化部分的设计的响应,也是到目前为止整个技术栈中最复杂、最优化的代码部分。
在上传的过程中,服务器具有双重行为:
- 在一边,它充当了正在上传文件的API客户端的服务器;
- 在另一边,它充当了S3的客户端,以推送它从API客户端接收的数据。
为了充当客户端,服务器使用了一个同样使用Netty构建的HTTP客户端库[4][5]。这个异步的HTTP客户端库暴露了一组完美匹配该服务器的需求的接口。它将开始执行一个HTTP请求,并允许在数据变得可用时再供给给它,而这大大地降低了上传请求处理器的客户门面的复杂性。
14.1.5 性能
在服务器的初始版本完成之后,我运行了一批性能测试。结果简直就是让人兴奋不已。在不断地增加了难以置信的负载之后,我看到新的服务器的上传在峰值时相比于旧版本的LAMP技术栈的快了10~12倍(完全数量级的更快),而且它能够支撑超过1000倍的并发上传,总共将近10k的并发上传(而这一切都只是运行在一个单一的EC2大型实例之上)。
下面的这些因素促成了这一点。
- 它运行在一个调优的JVM中。
- 它运行在一个高度调优的自定义技术栈中,是专为解决这个问题而创建的,而不是一个通用的Web框架。
- 该自定义的技术栈通过Netty使用了NIO(基于选择器的模型)构建,这意味着不同于每个客户端一个进程的LAMP技术栈,它可以扩展到上万甚至是几十万的并发连接。
- 再也没有以两个单独的,先接收一个完整的文件,然后再将其上传到S3,的步骤所带来的开销了。现在文件将直接流向S3。
- 因为服务器现在对文件进行了流式处理,所以:
- 它再也不会花时间在I/O操作上了,即将数据写入临时文件,并在稍后的第二阶段上传中读取它们;
- 对于每个上传也将消耗更少的内存,这意味着可以进行更多的并行上传。
- 略缩图生成变成了一个异步的后处理。
14.1.6 小结——站在巨人的肩膀上
所有的这一切能够成为可能,都得益于Netty的难以置信的精心设计的 API,以及高性能的非阻塞的I/O架构。
自2011年12月推出Droplr 2.0以来,我们在API级别的宕机时间几乎为零。在几个月前,由于一次既定的全栈升级(数据库、操作系统、主要的服务器和守护进程的代码库升级),我们中断了已经连续一年半安静运行的基础设施的100%正常运行时间,这次升级只耗费了不到1小时的时间。
这些服务器日复一日地坚挺着,每秒钟处理几百个(有时甚至是几千个)并发请求,而同时还保持了如此低的内存和CPU使有率,以至于我们都难以相信它们实际上正在真实地做着如此大量的工作:
- CPU使用率很少超过5%;
- 无法准确地描述内存使用率,因为进程启动时预分配了1 GB的内存,同时配置的JVM可以在必要时增长到2 GB,而在过去的两年内这一次也没有发生过。
任何人都可以通过增加机器来解决某个特定的问题,然而Netty帮助了Droplr智能地伸缩,并且保持了相当低的服务器账单。
14.2 Firebase——实时的数据同步服务
Sara Robinson,Developer Happiness副总裁
Greg Soltis,Cloud Architecture副总裁
实时更新是现代应用程序中用户体验的一个组成部分。随着用户期望这样的行为,越来越多的应用程序都正在实时地向用户推送数据的变化。通过传统的3层架构很难实现实时的数据同步,其需要开发者管理他们自己的运维、服务器以及伸缩。通过维护到客户端的实时的、双向的通信,Firebase提供了一种即时的直观体验,允许开发人员在几分钟之内跨越不同的客户端进行应用程序数据的同步——这一切都不需要任何的后端工作、服务器、运维或者伸缩。
实现这种能力提出了一项艰难的技术挑战,而Netty则是用于在Firebase内构建用于所有网络通信的底层框架的最佳解决方案。这个案例研究概述了Firebase的架构,然后审查了Firebase使用Netty以支撑它的实时数据同步服务的3种方式:
- 长轮询;
- HTTP 1.1 keep-alive和流水线化;
- 控制SSL处理器。
14.2.1 Firebase的架构
Firebase允许开发者使用两层体系结构来上线运行应用程序。开发者只需要简单地导入Firebase库,并编写客户端代码。数据将以JSON格式暴露给开发者的代码,并且在本地进行缓存。该库处理了本地高速缓存和存储在Firebase服务器上的主副本(master copy)之间的同步。对于任何数据进行的更改都将会被实时地同步到与Firebase相连接的潜在的数十万个客户端上。跨多个平台的多个客户端之间的以及设备和Firebase之间的交互如图14-3所示。
图14-3 Firebase的架构
Firebase的服务器接收传入的数据更新,并将它们立即同步给所有注册了对于更改的数据感兴趣的已经连接的客户端。为了启用状态更改的实时通知,客户端将会始终保持一个到Firebase的活动连接。该连接的范围是:从基于单个Netty Channel
的抽象到基于多个Channel
的抽象,甚至是在客户端正在切换传输类型时的多个并存的抽象。
因为客户端可以通过多种方式连接到Firebase,所以保持连接代码的模块化很重要。Netty的Channel
抽象对于Firebase集成新的传输来说简直是梦幻般的构建块。此外,流水线和处理器[6]模式使得可以简单地把传输相关的细节隔离开来,并为应用程序代码提供一个公共的消息流抽象。同样,这也极大地简化了添加新的协议支持所需要的工作。Firebase只通过简单地添加几个新的ChannelHandler
到ChannelPipeline
中,便添加了对一种二进制传输的支持。对于实现客户端和服务器之间的实时连接而言,Netty的速度、抽象的级别以及细粒度的控制都使得它成为了一个的卓绝的框架。
14.2.2 长轮询
Firebase同时使用了长轮询和WebSocket传输。长轮询传输是高度可靠的,覆盖了所有的浏览器、网络以及运营商;而基于WebSocket的传输,速度更快,但是由于浏览器/客户端的局限性,并不总是可用的。开始时,Firebase将会使用长轮询进行连接,然后在WebSocket可用时再升级到WebSocket。对于少数不支持WebSocket的Firebase流量,Firebase使用Netty实现了一个自定义的库来进行长轮询,并且经过调优具有非常高的性能和响应性。
Firebase的客户端库逻辑处理双向消息流,并且会在任意一端关闭流时进行通知。虽然这在TCP或者WebSocket协议上实现起来相对简单,但是在处理长轮询传输时它仍然是一项挑战。对于长轮询的场景来说,下面两个属性必须被严格地保证:
- 保证消息的按顺序投递;
- 关闭通知。
1.保证消息的按顺序投递
可以通过使得在某个指定的时刻有且只有一个未完成的请求,来实现长轮询的按顺序投递。因为客户端不会在它收到它的上一个请求的响应之前发出另一个请求,所以这就保证了它之前所发出的所有消息都被接收,并且可以安全地发送更多的请求了。同样,在服务器端,直到客户端收到之前的响应之前,将不会发出新的请求。因此,总是可以安全地发送缓存在两个请求之间的任何东西。然而,这将导致一个严重的缺陷。使用单一请求技术,客户端和服务器端都将花费大量的时间来对消息进行缓冲。例如,如果客户端有新的数据需要发送,但是这时已经有了一个未完成的请求,那么它在发出新请求之前,就必须得等待服务器的响应。如果这时在服务器上没有可用的数据,则可能需要很长的时间。
一个更加高性能的解决方案则是容忍更多的正在并发进行的请求。在实践中,这可以通过将单一请求的模式切换为最多两个请求的模式。这个算法包含了两个部分:
- 每当客户端有新的数据需要发送时,它都会发送一个新的请求,除非已经有了两个请求正在被处理;
- 每当服务器接收到来自客户端的请求时,如果它已经有了一个来自客户端的未完成的请求,那么即使没有数据,它也将立即回应第一个请求。
相对于单一请求的模式,这种方式提供了一个重要的改进:客户端和服务器的缓冲时间都被限定在了最多一次的网络往返时间里。
当然,这种性能的增加并不是没有代价的;它导致了代码复杂性的相应增加。该长轮询算法也不再保证消息的按顺序投递,但是一些来自TCP协议的理念可以保证这些消息的按顺序投递。由客户端发送的每个请求都包含一个序列号,每次请求时都将会递增。此外,每个请求都包含了关于有效负载中的消息数量的元数据。如果一个消息跨越了多个请求,那么在有效负载中所包含的消息的序号也会被包含在元数据中。
服务器维护了一个传入消息分段的环形缓冲区,在它们完成之后,如果它们之前没有不完整的消息,那么会立即对它们进行处理。下行要简单点,因为长轮询传输响应的是HTTPGET
请求,而且对于有效载荷的大小没有相同的限制。在这种情况下,将包含一个对于每个响应都将会递增的序列号。只要客户端接收到了达到指定序列号的所有响应,它就可以开始处理列表中的所有消息;如果它还没有收到,那么它将缓冲该列表,直到它接收到了这些未完成的响应。
2.关闭通知
在长轮询传输中第二个需要保证的属性是关闭通知。在这种情况下,使得服务器意识到传输已经关闭,明显要重要于使得客户端识别到传输的关闭。客户端所使用的Firebase库将会在连接断开时将操作放入队列以便稍后执行,而且这些被放入队列的操作可能也会对其他仍然连接着的客户端造成影响。因此,知道客户端什么时候实际上已经断开了是非常重要的。实现由服务器发起的关闭操作是相对简单的,其可以通过使用一个特殊的协议级别的关闭消息响应下一个请求来实现。
实现客户端的关闭通知是比较棘手的。虽然可以使用相同的关闭通知,但是有两种情况可能会导致这种方式失效:用户可以关闭浏览器标签页,或者网络连接也可能会消失。标签页关闭的这种情况可以通过iframe
来处理,iframe
会在页面卸载时发送一个包含关闭消息的请求。第二种情况则可以通过服务器端超时来处理。小心谨慎地选择超时值大小很重要,因为服务器无法区分慢速的网络和断开的客户端。也就是说,对于服务器来说,无法知道一个请求是被实际推迟了一分钟,还是该客户端丢失了它的网络连接。相对于应用程序需要多快地意识到断开的客户端来说,选取一个平衡了误报所带来的成本(关闭慢速网络上的客户端的传输)的合适的超时大小是很重要的。
图14-4演示了Firebase的长轮询传输是如何处理不同类型的请求的。
图14-4 长轮询
在这个图中,每个长轮询请求都代表了不同类型的场景。最初,客户端向服务器发送了一个轮询(轮询0)。一段时间之后,服务器从系统内的其他地方接收到了发送给该客户端的数据,所以它使用该数据响应了轮询0。在该轮询返回之后,因为客户端目前没有任何未完成的请求,所以客户端又立即发送了一个新的轮询(轮询1)。过了一小会儿,客户端需要发送数据给服务器。因为它只有一个未完成的轮询,所以它又发送了一个新的轮询(轮询2),其中包含了需要被递交的数据。根据协议,一旦在服务器同时存在两个来自相同的客户端的轮询时,它将响应第一个轮询。在这种情况下,服务器没有任何已经就绪的数据可以用于该客户端,因此它发送回了一个空响应。客户端也维护了一个超时,并将在超时被触发时发送第二次轮询,即使它没有任何额外的数据需要发送。这将系统从由于浏览器超时缓慢的请求所导致的故障中隔离开来。
14.2.3 HTTP 1.1 keep-alive和流水线化
通过HTTP 1.1 keep-alive特性,可以在同一个连接上发送多个请求到服务器。这使得HTTP流水线化——可以发送新的请求而不必等待来自服务器的响应,成为了可能。实现对于HTTP流水线化以及keep-alive特性的支持通常是直截了当的,但是当混入了长轮询之后,它就明显变得更加复杂起来。
如果一个长轮询请求紧跟着一个REST(表征状态转移)请求,那么将有一些注意事项需要被考虑在内,以确保浏览器能够正确工作。一个Channel
可能会混和异步消息(长轮询请求)和同步消息(REST请求)。当一个Channel
上出现了一个同步请求时,Firebase必须按顺序同步响应该Channel
中所有之前的请求。例如,如果有一个未完成的长轮询请求,那么在处理该REST请求之前,需要使用一个空操作对该长轮询传输进行响应。
图14-5说明了Netty是如何让Firebase在一个套接字上响应多个请求的。
图14-5 网络图
如果浏览器有多个打开的连接,并且正在使用长轮询,那么它将重用这些连接来处理来自这两个打开的标签页的消息。对于长轮询请求来说,这是很困难的,并且还需要妥善地管理一个HTTP请求队列。长轮询请求可以被中断,但是被代理的请求却不能。Netty使服务于多种类型的请求很轻松。
- 静态的HTML页面——缓存的内容,可以直接返回而不需要进行处理;例子包括一个单页面的HTTP应用程序、robots.txt和crossdomain.xml。
- REST请求——Firebase支持传统的
GET
、POST
、PUT
、DELETE
、PATCH
以及OPTIONS
请求。 - WebSocket——浏览器和Firebase服务器之间的双向连接,拥有它自己的分帧协议。
- 长轮询——这些类似于HTTP的
GET
请求,但是应用程序的处理方式有所不同。 - 被代理的请求——某些请求不能由接收它们的服务器处理。在这种情况下,Firebase将会把这些请求代理到集群中正确的服务器。以便最终用户不必担心数据存储的具体位置。这些类似于
REST
请求,但是代理服务器处理它们的方式有所不同。 - 通过SSL的原始字节——一个简单的TCP套接字,运行Firebase自己的分帧协议,并且优化了握手过程。
Firebase使用Netty来设置好它的ChannelPipeline
以解析传入的请求,并随后适当地重新配置ChannelPipeline
剩余的其他部分。在某些情况下,如WebSocket和原始字节,一旦某个特定类型的请求被分配给某个Channel
之后,它就会在它的整个生命周期内保持一致。在其他情况下,如各种HTTP请求,该分配则必须以每个消息为基础进行赋值。同一个Channel
可以处理REST请求、长轮询请求以及被代理的请求。
14.2.4 控制SslHandler
Netty的SslHandler
类是Firebase如何使用Netty来对它的网络通信进行细粒度控制的一个例子。当传统的Web技术栈使用Apache或者Nginx之类的HTTP服务器来将请求传递给应用程序时,传入的SSL请求在被应用程序的代码接收到的时候就已经被解码了。在多租户的架构体系中,很难将部分的加密流量分配给使用了某个特定服务的应用程序的租户。这很复杂,因为事实上多个应用程序可能使用了相同的加密Channel
来和Firebase通信(例如,用户可能在不同的标签页中打开了两个Firebase应用程序)。为了解决这个问题,Firebase需要在SSL请求被解码之前对它们拥有足够的控制来处理它们。
Firebase基于带宽向客户进行收费。然而,对于某个消息来说,在SSL解密被执行之前,要收取费用的账户通常是不知道的,因为它被包含在加密了的有效负载中。Netty使得Firebase可以在ChannelPipeline
中的多个位置对流量进行拦截,因此对于字节数的统计可以从字节刚被从套接字读取出来时便立即开始。在消息被解密并且被Firebase的服务器端逻辑处理之后,字节计数便可以被分配给对应的账户。在构建这项功能时,Netty在协议栈的每一层上,都提供了对于处理网络通信的控制,并且也使得非常精确的计费、限流以及速率限制成为了可能,所有的这一切都对业务具有显著的影响。
Netty使得通过少量的Scala代码便可以拦截所有的入站消息和出站消息并且统计字节数成为了可能,如代码清单14-3所示。
代码清单14-3 设置ChannelPipeline
case class NamespaceTag(namespace: String)class NamespaceBandwidthHandler extends ChannelDuplexHandler { private var rxBytes: Long = 0 private var txBytes: Long = 0 private var nsStats: Option[NamespaceStats] = None override def channelRead(ctx: ChannelHandlerContext, msg: Object) { msg match { case buf: ByteBuf => { rxBytes += buf.readableBytes( ← -- 当消息传入时,统计它的字节数 tryFlush(ctx) } case _ => { } } super.channelRead(ctx, msg) } override def write(ctx: ChannelHandlerContext, msg: Object, promise: ChannelPromise) { msg match { case buf: ByteBuf => { ← -- 当有出站消息时,同样统计这些字节数 txBytes += buf.readableBytes tryFlush(ctx) super.write(ctx, msg, promise) } case tag: NamespaceTag => { ← -- 如果接收到了命名空间标签,则将这个Channel 关联到某个账户,记住该账户,并将当前的字节计数分配给它 updateTag(tag.namespace, ctx) } case _ => { super.write(ctx, msg, promise) } } } private def tryFlush(ctx: ChannelHandlerContext) { nsStats match { case Some(stats: NamespaceStats) => { ← -- 如果已经有了该Channel 所属的命名空间的标签,则将字节计数分配给该账户,并重置计数器 stats.logOutgoingBytes(txBytes.toInt) txBytes = 0 stats.logIncomingBytes(rxBytes.toInt) rxBytes = 0 } case None => { // no-op, we don't have a namespace } } } private def updateTag(ns: String, ctx: ChannelHandlerContext) { val (_, isLocalNamespace) = NamespaceOwnershipManager.getOwner(ns) if (isLocalNamespace) { nsStats = NamespaceStatsListManager.get(ns) tryFlush(ctx) } else { // Non-local namespace, just flush the bytes txBytes = 0 ← -- 如果该字节计数不适用于这台机器,则忽略它并重置计数器 rxBytes = 0 } }}
14.2.5 Firebase小结
在Firebase的实时数据同步服务的服务器端架构中,Netty扮演了不可或缺的角色。它使得可以支持一个异构的客户端生态系统,其中包括了各种各样的浏览器,以及完全由Firebase控制的客户端。使用Netty,Firebase可以在每个服务器上每秒钟处理数以万计的消息。Netty之所以非常了不起,有以下几个原因。
- 它很快。开发原型只需要几天时间,并且从来不是生产瓶颈。
- 它的抽象层次具有良好的定位。Netty提供了必要的细粒度控制,并且允许在控制流的每一步进行自定义。
- 它支持在同一个端口上支撑多种协议。HTTP、WebSocket、长轮询以及独立的TCP协议。
- 它的GitHub库是一流的。精心编写的Javadoc使得可以无障碍地利用它进行开发。
- 它拥有一个非常活跃的社区。社区非常积极地修复问题,并且认真地考虑所有的反馈以及合并请求。此外,Netty团队还提供了优秀的最新的示例代码。Netty是一个优秀的、维护良好的框架,而且它已经成为了构建和伸缩Firebase的基础设施的基础要素。如果没有Netty的速度、控制、抽象以及了不起的团队,那么Firebase中的实时数据同步将无从谈起。
14.3 Urban Airship——构建移动服务
Erik Onnen,架构副总裁
随着智能手机的使用以前所未有的速度在全球范围内不断增长,涌现了大量的服务提供商,以协助开发者和市场人员提供令人惊叹不已的终端用户体验。不同于它们的功能手机前辈,智能手机渴求IP连接,并通过多个渠道(3G、4G、WiFi、WiMAX以及蓝牙)来寻求连接。随着越来越多的这些设备通过基于IP的协议连接到公共网络,对于后端服务提供商来说,伸缩性、延迟以及吞吐量方面的挑战变得越来越艰巨了。
值得庆幸的是,Netty非常适用于处理由随时在线的移动设备的惊群效应所带来的许多问题。本节将详细地介绍Netty在伸缩移动开发人员和市场人员平台——Urban Airship时的几个实际应用。
14.3.1 移动消息的基础知识
虽然市场人员长期以来都使用SMS来作为一种触达移动设备的通道,但是最近一种被称为推送通知的功能正在迅速地成为向智能手机发送消息的首选机制。推送通知通常使用较为便宜的数据通道,每条消息的价格只是SMS费用的一小部分。推送通知的吞吐量通常都比SMS高2~3个数量级,所以它成为了突发新闻的理想通道。最重要的是,推送通知为用户提供了设备驱动的对推送通道的控制。如果一个用户不喜欢某个应用程序的通知消息,那么用户可以禁用该应用程序的通知,或者干脆删除该应用程序。
在一个非常高的级别上,设备和推送通知行为之间的交互类似于图14-6中所描述的那样。
图14-6 移动消息平台集成的高级别视图
在高级别上,当应用程序开发人员想要发送推送通知给某台设备时,开发人员必须要考虑存储有关设备及其应用程序安装的信息[7]。通常,应用程序的安装都将会执行代码以检索一个平台相关的标识符,并且将该标识符上报给一个持久化该标识符的中心化服务。稍后,应用程序安装之外的逻辑将会发起一个请求以向该设备投递一条消息。
一旦一个应用程序的安装已经将它的标识符注册到了后端服务,那么推送消息的递交就可以反过来采取两种方式。在第一种方式中,使用应用程序维护一条到后端服务的直接连接,消息可以被直接递交给应用程序本身。第二种方式更加常见,在这种方式中,应用程序将依赖第三方代表该后端服务来将消息递交给应用程序。在Urban Airship,这两种递交推送通知的方式都有使用,而且也都大量地使用了Netty。
14.3.2 第三方递交
在第三方推送递交的情况下,每个推送通知平台都为开发者提供了一个不同的API,来将消息递交给应用程序安装。这些API有着不同的协议(基于二进制的或者基于文本的)、身份验证(OAuth、X.509等)以及能力。对于集成它们并且达到最佳的吞吐量,每种方式都有着其各自不同的挑战。
尽管事实上每个这些提供商的根本目的都是向应用程序递交通知消息,但是它们各自又都采取了不同的方式,这对系统集成商造成了重大的影响。例如,苹果公司的Apple推送通知服务(APNS)定义了一个严格的二进制协议;而其他的提供商则将它们的服务构建在了某种形式的HTTP之上,所有的这些微妙变化都影响了如何以最佳的方式达到最大的吞吐量。值得庆幸的是,Netty是一个灵活得令人惊奇的工具,它为消除不同协议之间的差异提供了极大的帮助。
接下来的几节将提供Urban Airship是如何使用Netty来集成两个上面所列出的服务提供商的例子。
14.3.3 使用二进制协议的例子
苹果公司的APNS是一个具有特定的网络字节序的有效载荷的二进制协议。发送一个APNS通知将涉及下面的事件序列:
(1)通过SSLv3连接将TCP套接字连接到APNS服务器,并用X.509证书进行身份认证;
(2)根据Apple定义的格式[8],构造推送消息的二进制表示形式;
(3)将消息写出到套接字;
(4)如果你已经准备好了确定任何和已经发送的消息相关的错误代码,则从套接字中读取;
(5)如果有错误发生,则重新连接该套接字,并从步骤2继续。
作为格式化二进制消息的一部分,消息的生产者需要生成一个对于APNS系统透明的标识符。一旦消息无效(如不正确的格式、大小或者设备信息),那么该标识符将会在步骤4的错误响应消息中返回给客户端。
虽然从表面上看,该协议似乎简单明了,但是想要成功地解决所有上述问题,还是有一些微妙的细节,尤其是在JVM上。
- APNS规范规定,特定的有效载荷值需要以大端字节序进行发送(如令牌长度)。
- 在前面的操作序列中的第3步要求两个解决方案二选一。因为JVM不允许从一个已经关闭的套接字中读取数据,即使在输出缓冲区中有数据存在,所以你有两个选项。
- 在一次写出操作之后,在该套接字上执行带有超时的阻塞读取动作。这种方式有多个缺点,具体如下。
- 阻塞等待错误消息的时间长短是不确定的。错误可能会发生在数毫秒或者数秒之内。
- 由于套接字对象无法在多个线程之间共享,所以在等待错误消息时,对套接字的写操作必须立即阻塞。这将对吞吐量造成巨大的影响。如果在一次套接字写操作中递交单个消息,那么在直到读取超时发生之前,该套接字上都不会发出更多的消息。当你要递交数千万的消息时,每个消息之间都有3秒的延迟是无法接受的。
- 依赖套接字超时是一项昂贵的操作。它将导致一个异常被抛出,以及几个不必要的系统调用。
- 使用异步I/O。在这个模型中,读操作和写操作都不会阻塞。这使得写入者可以持续地给APNS发送消息,同时也允许操作系统在数据可供读取时通知用户代码。
- 在一次写出操作之后,在该套接字上执行带有超时的阻塞读取动作。这种方式有多个缺点,具体如下。
Netty使得可以轻松地解决所有的这些问题,同时提供了令人惊叹的吞吐量。
首先,让我们看看Netty是如何简化使用正确的字节序打包二进制APNS消息的,如代码清单14-4所示。
代码清单14-4 ApnsMessage
实现
public final class ApnsMessage { private static final byte COMMAND = (byte) 1; ← -- APNS 消息总是以一个字节大小的命令作为开始,因此该值被编码为常量 public ByteBuf toBuffer { short size = (short) (1 + // Command ← -- 因为消息的大小不一,所以出于效率考虑,在ByteBuf创建之前将先计算它 4 + // Identifier 4 + // Expiry 2 + // DT length header 32 + //DS length 2 + // body length header body.length); ByteBuf buf = Unpooled.buffer(size).order(ByteOrder.BIG_ENDIAN); ← -- 在创建时,ByteBuf 的大小正好,并且指定了用于APNS 的大端字节序 buf.writeByte(COMMAND); ← -- 来自于类中其他地方维护的状态的各种值将会被写入到缓冲区中 buf.writeInt(identifier); buf.writeInt(expiryTime); buf.writeShort((short) deviceToken.length); ← -- 这个类中的deviceToken字段(这里未展示)是一个Java 的byte buf.writeBytes(deviceToken); buf.writeShort((short) body.length); buf.writeBytes(body); return buf; ← -- 当缓冲区已经就绪时,简单地将它返回 }}
关于该实现的一些重要说明如下。
❶ Java数组的长度属性值始终是一个整数。但是,APNS协议需要一个2-byte
值。在这种情况下,有效负载的长度已经在其他的地方验证过了,所以在这里将其强制转换为short
是安全的。注意,如果没有显式地将ByteBuf
构造为大端字节序,那么在处理short
和int
类型的值时则可能会出现各种微妙的错误。
❷ 不同于标准的java.nio.ByteBuffer
,没有必要翻转[9]缓冲区,也没必要关心它的位置——Netty的ByteBuf
将会自动管理用于读取和写入的位置。
使用少量的代码,Netty已经使得创建一个格式正确的APNS消息的过程变成小事一桩了。因为这个消息现在已经被打包进了一个ByteBuf
,所以当消息准备好发送时,便可以很容易地被直接写入连接了APNS的Channel
。
可以通过多重机制连接APNS,但是最基本的,是需要一个使用SslHandler
和解码器来填充ChannelPipeline
的ChannelInitializer,
如代码清单14-5所示。
代码清单14-5 设置ChannelPipeline
public final class ApnsClientPipelineInitializer extends ChannelInitializer<Channel> { private final SSLEngine clientEngine; public ApnsClientPipelineFactory(SSLEngine engine) { ← -- 一个X.509 认证的请求需要一个javax.net.ssl.SSLEngine 类的实例 this.clientEngine = engine; } @Override public void initChannel(Channel channel) throws Exception { final ChannelPipeline pipeline = channel.pipeline; final SslHandler handler = new SslHandler(clientEngine); ← -- 构造一个Netty的SslHandler handler.setEnableRenegotiation(true); ← -- APNS 将尝试在连接后不久重新协商SSL,需要允许重新协商 pipeline.addLast("ssl", handler); pipeline.addLast("decoder", new ApnsResponseDecoder); ← -- 这个类扩展了Netty 的ByteToMessageDecoder,并且处理了APNS 返回一个错误代码并断开连接的情况 }}
值得注意的是,Netty使得协商结合了异步I/O的X.509认证的连接变得多么的容易。在Urban Airship早期的没有使用Netty的原型APNS的代码中,协商一个异步的X.509认证的连接需要80多行代码和一个线程池,而这只仅仅是为了建立连接。Netty隐藏了所有的复杂性,包括SSL握手、身份验证、最重要的将明文的字节加密为密文,以及使用SSL所带来的密钥的重新协商。这些JDK中异常无聊的、容易出错的并且缺乏文档的API都被隐藏在了3行Netty代码之后。
在Urban Airship,在所有和众多的包括APNS以及Google的GCM的第三方推送通知服务的连接中,Netty都扮演了重要的角色。在每种情况下,Netty都足够灵活,允许显式地控制从更高级别的HTTP的连接行为到基本的套接字级别的配置(如TCP keep-alive以及套接字缓冲区大小)的集成如何生效。
14.3.4 直接面向设备的递交
上一节提供了Urban Airship如何与第三方集成以进行消息递交的内部细节。在谈及图14-6时,需要注意的是,将消息递交到设备有两种方式。除了通过第三方来递交消息之外,Urban Airship还有直接作为消息递交通道的经验。在作为这种角色时,单个设备将直接连接Urban Airship的基础设施,绕过第三方提供商。这种方式也带来了一组截然不同的挑战。
- 由移动设备发出的套接字连接往往是短暂的。根据不同的条件,移动设备将频繁地在不同类型的网络之间进行切换。对于移动服务的后端提供商来说,设备将不断地重新连接,并将感受到短暂而又频繁的连接周期。
- 跨平台的连接性是不规则的。从网络的角度来看,平板设备的连接性往往表现得和移动电话不一样,而对比于台式计算机,移动电话的连接性的表现又不一样。
- 移动电话向后端服务提供商更新的频率一定会增加。移动电话越来越多地被应用于日常任务中,不仅产生了大量常规的网络流量,而且也为后端服务提供商提供了大量的分析数据。
- 电池和带宽不能被忽略。不同于传统的桌面环境,移动电话通常使用有限的数据流量包。服务提供商必须要尊重最终用户只有有限的电池使用时间,而且他们使用昂贵的、速率有限的(蜂窝移动数据网络)带宽这一事实。滥用两者之一都通常会导致应用被卸载,这对于移动开发人员来说可能是最坏的结果了。
- 基础设施的所有方面都需要大规模的伸缩。随着移动设备普及程度的不断增加,更多的应用程序安装量将会导致更多的到移动服务的基础设施的连接。由于移动设备的庞大规模和增长,这个列表中的每一个前面提到的元素都将变得愈加复杂。
随着时间的推移,Urban Airship从移动设备的不断增长中学到了几点关键的经验教训:
- 移动运营商的多样性可以对移动设备的连接性造成巨大的影响;
- 许多运营商都不允许TCP的keep-alive特性,因此许多运营商都会积极地剔除空闲的TCP会话;
- UDP不是一个可行的向移动设备发送消息的通道,因为许多的运营商都禁止它;
- SSLv3所带来的开销对于短暂的连接来说是巨大的痛苦。
鉴于移动增长的挑战,以及Urban Airship的经验教训,Netty对于实现一个移动消息平台来说简直就是天作之合,原因将在以下各节强调。
14.3.5 Netty擅长管理大量的并发连接
如上一节中所提到的,Netty使得可以轻松地在JVM平台上支持异步I/O。因为Netty运行在JVM之上,并且因为JVM在Linux上将最终使用Linux的epoll方面的设施来管理套接字文件描述符中所感兴趣的事件(interest),所以Netty使得开发者能够轻松地接受大量打开的套接字——每一个Linux进程将近一百万的TCP连接,从而适应快速增长的移动设备的规模。有了这样的伸缩能力,服务提供商便可以在保持低成本的同时,允许大量的设备连接到物理服务器上的一个单独的进程[10]。
在受控的测试以及优化了配置选项以使用少量的内存的条件下,一个基于Netty的服务得以容纳略少于100万(约为998 000)的连接。在这种情况下,这个限制从根本上来说是由于Linux内核强制硬编码了每个进程限制100万个文件句柄。如果JVM本身没有持有大量的套接字以及用于JAR文件的文件描述符,那么该服务器可能本能够处理更多的连接,而所有的这一切都在一个4GB大小的堆上。利用这种效能,Urban Airship成功地维持了超过2000万的到它的基础设施的持久化的TCP套接字连接以进行消息递交,所有的这一切都只使用了少量的服务器。
值得注意的是,虽然在实践中,一个单一的基于Netty的服务便能够处理将近1百万的入站TCP套接字连接,但是这样做并不一定就是务实的或者明智的。如同分布式计算中的所有陷阱一样,主机将会失败、进程将需要重新启动并且将会发生不可预期的行为。由于这些现实的问题,适当的容量规划意味着需要考虑到单个进程失败的后果。
14.3.6 Urban Airship小结——跨越防火墙边界
我们已经演示了两个在Urban Airship内部网络中每天都会使用Netty的场景。Netty适合这些用途,并且工作得非常出色,但在Urban Airship内部的许多其他的组件中也有它作为脚手架存在的身影。
1.内部的RPC框架
Netty一直都是Urban Airship内部的RPC框架的核心,其一直都在不断进化。今天,这个框架每秒钟可以处理数以十万计的请求,并且拥有相当低的延迟以及杰出的吞吐量。几乎每个由Urban Airship发出的API请求都经由了多个后端服务处理,而Netty正是所有这些服务的核心。
2.负载和性能测试
Netty在Urban Airship已经被用于几个不同的负载测试框架和性能测试框架。例如,在测试前面所描述的设备消息服务时,为了模拟数百万的设备连接,Netty和一个Redis实例(http://redis.io/)相结合使用,以最小的客户端足迹(负载)测试了端到端的消息吞吐量。
3.同步协议的异步客户端
对于一些内部的使用场景,Urban Airship一直都在尝试使用Netty来为典型的同步协议创建异步的客户端,包括如Apache Kafka(http://kafka.apache.org/)以及Memcached(http://memcached.org/)这样的服务。Netty的灵活性使得我们能够很容易地打造天然异步的客户端,并且能够在真正的异步或同步的实现之间来回地切换,而不需要更改任何的上游代码。
总而言之,Netty一直都是Urban Airship服务的基石。其作者和社区都是极其出色的,并为任何需要在JVM上进行网络通信的应用程序,创造了一个真正意义上的一流框架。
14.4 小结
本章旨在揭示真实世界中的Netty的使用场景,以及它是如何帮助这些公司解决了重大的网络通信问题的。值得注意的是,在所有的场景下,Netty都不仅是被作为一个代码框架而使用,而且还是开发和架构最佳实践的重要组成部分。
在下一章中,我们将介绍由Facebook和Twitter所贡献的案例研究,描述两个开源项目,这两个项目是从基于Netty的最初被开发用来满足内部需求的项目演化而来的。
[1] 一个典型的应用程序技术栈的首字母缩写;由Linux、Apache Web Server、MySQL以及PHP的首字母组成。
[2] 指客户端和服务器之间的连接。——译者注
[3] 指RequestHandler。——译者注
[4] 你可以在https://github.com/brunodecarvalho/http-client找到这个HTTP客户端库。
[5] 上一个脚注中提到的这个HTTP客户端库已经废弃,推荐AsyncHttpClient(https://github.com/AsyncHttpClient/ async-http-client)和Akka-HTTP(https://github.com/akka/akka-http),它们都实现了相同的功能。——译者注
[6] 指ChannelPipeline
和ChannelHandler
。——译者注
[7] 某些移动操作系统允许一种被称为本地推送的推送通知,可能不会遵循这种做法。
[8] 有关APNS的信息,参考http://docs.aws.amazon.com/sns/latest/dg/mobile-push-apns.html和http://bit.ly/189mmpG。
[9] 即调用ByteBuffer的flip方法。——译者注
[10] 注意,在这种情况下物理服务器的区别。尽管虚拟化提供了许多的好处,但是领先的云计算提供商仍然未能支持到单个虚拟主机超过200 000~300 000的并发TCP连接。当连接达到或者超过这种规模时,建议使用裸机(bare metal)服务器,并且密切关注网络接口卡(Network Interface Card,NIC)提供商。