1、Droplr——构建移动服务
Bruno de Carvalho,首席架构师
在Droplr,我们在我的基础设施的核心部分、从我们的API服务器到辅助服务的各个部分都使用了Netty。
这是一个关于我们如何从一个单片的、运行缓慢的LAMP(Linux、Apache Web Server、MySQL以及PHP)应用程序迁移到基于Netty实现的现代的、高性能的以及水平扩展的分布式架构的案例研究。
1.1、一切的起因
当我加入这个团队时,我们运行的是一个LAMP应用程序,其作为前端页面服务于用户,同时还作为API服务于客户端应用程序,其中,也包括我的逆向工程、第三方的Windows客户端windroplr。
后来windroplr变成了Droplr for Windows,而我则开始主要负责基础设施的建设,并且最终得到了一个新的挑战:完全重新考虑Droplr的基础设施。
在那时,Droplr本身已经确立成为了一种工作的理念,因此2.0版本的目标也是相当的标准:
——将单片的技术栈拆分为多个可横向扩展的组件
——添加冗余,以避免宕机
——为客户端创建一个简洁的API
——使其全部运行在HTTPS上
创始人Josh和Levi对我说:“要不惜一切代价,让它飞起来”
我知道这句话意味的可不只是变快一点或者变快很多。这意味着一个完全数量级上的更快。而且我也知道,Netty最终将会在这样的努力中发挥重要作用。
1.2、Droplr是怎样工作的
Droplr拥有一个非常简单的工作流:将一个文件拖动到应用程序的菜单栏图标,然后Droplr将会上传该文件。当上传完成之后,Droplr将复制一个短URL——也就是所谓的拖乐(drop)——到剪贴板。
而在幕后,拖乐元数据将会被存储到数据库中(包括创建日期、名称以及下载次数等信息),而文件本身则被存储在Amazon S3上。
1.3、创造一个更加快速的上传体验
Droplr的第一个版本的上传流程是相当地天真可爱:
(1)接收上传
(2)上传到S3
(3)如果是图片,则创建缩略图
(4)应答客户端应用程序
更加仔细地看看这个流程,你很快便会发现在第2步和第3步上有两个瓶颈。不管从客户端上传到我们的服务器有多快,在实际的上传完成之后,直到成功地接收到响应之间,对于拖乐的创建总是会有恼人的间隔——因为对应的文件仍然需要被上传到S3中,并为其生成缩略图。
文件越大,间隔的时间也越长。对于非常大的文件来说,连接最终将会在等待来自服务器的响应时超时。由于这个严重的问题,当时Droplr只可以提供单个文件最大32MB的上传能力。
有两个截然不同的方案来减少上传时间。
方案A,乐观且看似更加简单:
——完整地接收文件
——将文件保存到本地的文件系统,并立即返回成功到客户端
——计划在将来的某个时间点将其上传到S3
在收到文件之后便返回一个短URL创建一个空想(也可以将其称为隐式的契约),即该文件立即在该URL地址上可用。但是并不能保证,上传的第二阶段(实际将文件推送到S3)也将最终会成功,那么用户可能会得到一个坏掉的链接,其可能已经被张贴到了Twitter或者发送给了一个重要的客户。这是不可接受的,即使是每十万次上传也只会发生一次。
我们当前的数据显示,我们上传失败率低于0.01%,绝大多数都是在上传实际完成之前,客户端和服务器之间的连接就超时了。
我们也可以尝试通过在文件将最终推送到S3之前,从接收它的机器提供该文件的服务来绕开它,然而这种做法本身就是一堆麻烦:
——如果在一批文件被完整地上传到S3之前,机器出现了故障,那么这些文件将会永久丢失;
——也将会有跨集群的同步问题;
——将会需要额外的复杂的逻辑来处理各种边界情况,继而不断产生更多的边界情况;
在思考过每种变通方案和其陷阱之后,我很快认识到,这是一个经典的九头蛇问题——对于每个砍下的头,它的位置上都会再长出两个头
方案B,安全且复杂:
——实时地(流式地)将从客户端上传的数据直接管道给S3
另一个选项需要对整体过程进行底层的控制。从本质上说,我们必须要能够做到以下几点。
——在接收客户端上传文件的同时,打开一个到S3的连接
——将从客户端连接上收到数据管道给到S3的连接
——缓冲并节流这两个链接:
——需要进行缓冲,以在客户端到服务器,以及服务器到S3这两个分支之间保持一条的稳定的流
——需要进行节流,以防止当服务器到S3的分支上的速度变得慢于客户端到服务器的分支时,内存被消耗殆尽
——当出现错误时,需要能够在两端进行彻底的回滚
看起来概念上很简单,但是它并不是你的通常的Web服务器能够提供的能力。尤其是当你考虑节流一个TCP连接时,你需要对它的套接字进行底层的访问。
它同时也引入一个新的挑战,其将最终塑造我们的中继架构:推迟缩略图的创建。这也意味着,无论该平台最终构建于哪种技术栈之上,它都必须要不仅能够提供一些基本的特性,如难以置信的性能和稳定性,而且在必要时还要能够提供操作底层(即字节级别的控制)的灵活性。
1.4、技术栈
当开始一个新的Web服务器项目时,最终你将会问自己:“好吧,这些酷小子们这段时间都在用什么框架呢?”我也是这样的。
选择Netty并不是一件无需动脑的事;我研究了大量的框架,并谨记我认为的3个至关重要的要素。
(1)它必须是快速的。我可不打算用一个低性能的技术栈替换另一个低性能的技术栈
(2)它必须能够伸缩的。不管它是有1个连接还是10000个连接,每个服务器实例都必须要能够保持吞吐量,并且随着时间推移不能出现崩溃或者内存泄露
(3)它必须提供对底层数据的控制。字节级别的读取、TCP拥塞控制等,这些都是难点。
1-基本要素:服务器和流水线
服务器基本上只是一个ServerBootstrap,其内置了NioServerSocketChannelFactory,配置了几个常见的ChannelHandler以及在末尾的HTTP RequestController,如下代码所示。
pipelineFactory = new ChannelPipelineFactory(){
public ChannelPipeline getPipeline() throws Exception{
ChannelPipeline pipeline = Channels.pipeline();
pipeline.addLast("idleStateHandler",new IdleStateHandler(...));
pipeline.addLast("httpServerCodec",new HttpServerCodec());
pipeline.addLast("requestController",new RequestController(...));
return pipeline;
}
};
RequestController是ChannelPipeline中唯一自定义的Droplr代码,同时也可能是整个Web服务器中最复杂的部分。它的作用是处理初始请求的验证,并且如果一切都没问题,那么将会把请求路由到适当的请求处理器。对于每个已经建立的客户端连接,都会创建一个新的实例,并且只要连接保持活动就一直存在。
请求控制器负责:
——处理负载洪峰;
——HTTP ChannelPipeline的管理
——设置请求处理的上下文
——派生新的请求处理器
——向请求处理器供给数据
——处理内部和外部的错误
以下代码给出了RequestController相关部分的一个纲要
public class RequestController extends IdleStateAwareChannelUpstreamHandler{
public void channelIdle(ChannelHandlerContext ctx, IdleStateEvent e)throws Exception{
//Shut down connection to client and roll everything back
}
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
}
}
public void messageReceived(ChannelHandlerContext ctx,MessageEvent e)throws Exception{
if(isDone()){
return;
}
if (e.getMessage() instanceof HttpRequest){
handleHttpRequest((HttpRequest)e.getMessage());
} else if (e.getMessage() instanceof HttpChunk){
handleHttpChunk((HttpChunk)e.getMessage());
}
}
}
正如之前解释的,你应该永远不要在Netty的I/O线程上执行任何非CPU限定的代码——你将会从Netty偷取宝贵的资源,并因此影响到服务器的吞吐量。
因此,HttpRequest和HttpChunk都可以通过切换到另一个不同的线程,来将执行流程移交给请求服务器。当请求处理器不是CPU限定时,就会发生这样的情况,不管是因为它们访问了数据库,还是执行了不适合本地内存或者CPU的逻辑。
当发生线程切换时,所有的代码块都必须要以串行的方式执行;否则,我们就会冒风险,对于一次上传来说,在处理完了序列号为n的HttpChunk之后,再处理序列号n-1的HttpChunk必然会导致文件内容的损坏。为了处理这种情况,我创建了一个自定义的线程池执行器,其确保了所有共享了同一个通用标识符的任务都将以串行的方式执行。
我将简短地解释请求处理器是如何被构建的,以在RequestController和这些处理器之间的桥梁上亮起一些光芒。
2-请求处理器
请求处理器提供了Droplr的功能,它们是类似地址为/account或者/drops这样的URI背后的端点。它们是逻辑核心——服务器对于客户端请求的解释器。
请求处理器的实现也是框架实际上成为了Droplr的API服务器的地方。
3-父接口
每个请求处理器,不管是直接的还是通过子类继承,都是RequestHandler接口的实现。
其本质上,RequestHandler接口表示了一个对于请求(HttpRequest的实例)和分块(HttpChunk的实例)的无状态处理器。它是一个非常简单的接口,包含了一组方法以帮助请求控制器来执行以及/或者决定如何执行它的职责。
这个接口就是RequestController对于相关动作的所有理解,通过它非常清晰和简洁的接口,该控制器可以和有状态的和无状态的、CPU限定的和非CPU限定的处理器以一种独立的并且实现无关的方式进行交互。
4-处理器的实现
最简单的RequestHandler实现是AbstractRequestHandler,它代表一个子类型的层次结构的根,在到达提供了所有Droplr的功能的实际处理器之前,它将变得愈发具体。最终,它会到达有状态的实现SimpleHandler,它在一个非I/O工作线程中执行,因此也不是CPU限定的。SimpleHandler是快速实现哪些执行读取JSON格式的数据、访问数据库、然后写出一些JSON的典型任务的端点的理想选择。
5-上传请求处理器
上传请求处理器是整个Droplr API服务器的关键。它是对于重塑webserver模块——服务器的框架化部分的设计的响应,也是到目前为止整个技术栈中最复杂、最优化的代码部分。
在上传过程中,服务器具有双重行为:
——在一边,它充当了正在上传文件的API客户端的服务器
——在另一边,它充当了S3的客户端,以推送它从API客户端接收的数据
为了充当客户端,服务器使用了一个同样使用Netty构建的HTTP客户端库。这个异步的HTTP客户端库暴露了一组完美匹配该服务器的需求的接口。它将开始执行一个HTTP请求,并允许在数据变得可用时再供给它,而这大大地降低了上传请求处理器的客户门面的复杂性。
1.5 性能
新的服务器的上传在峰值时相比于旧版本的LAMP技术栈的快了10-20倍(完全数量级的更快),而且他能够支撑超过1000倍的并发上传,总共将近10k的并发上传。
下面的这些因素促成这一点。
——它运行在一个调优的JVM中
——它运行在一个高度调优的自定义技术栈中,是专为解决这个问题而创建的,而不是一个通用的Web框架
——该自定义的技术栈通过Netty使用了NIO构建,这意味着不同于每个客户端一个进程的LAMP技术栈,它可以扩展到上万甚至几十万的并发链接
——再也没有以两个单独的,先接收一个完整的文件,然后再将其上传到S3的步骤带来的开销,现在文件直接流向S3
——服务器对文件进行了流式处理,他再也不会花时间在I/O操作上,即将数据写入临时文件,并在稍后的第二阶段上传中读取它们,对于每个上传也将消耗更少的内存,这意味着可以进行更多的并行上传
——缩略图生成变成了一个异步的后处理。
2、Firebase——实时的数据同步服务
实时更新是现代应用程序中用户体验的一个组成部分。随着用户期待这样的行为,越来越多的应用程序都正在实时地向用户推送数据的变化。通过传统的3层架构很难实现实时的数据同步,其需要开发者管理他们自己的运维、服务器以及伸缩。通过维护到客户端的实时的、双向的通信,Firebase提供了一种即使的直观体验,允许开发人员在几分钟之内跨越不同的客户端进行应用程序数据的同步——这一切都不需要任何的后端工作、服务器、运维或者伸缩。
实现这种能力提出了一项艰难的技术挑战,而Netty则是用于在Firebase内构建用于所有网络通信的底层框架的最佳解决方案。这个案例研究概述了Firebase的架构,然后审查了Firebase使用Netty以支撑它的实时数据同步服务的3种方式:长轮询、HTTP 1.1 keep-alive和流水线化、控制SSL处理器
2.1、Firebase的架构
Firebase允许开发者使用两层体系结构来上线运行应用程序。开发者只需要简单地导入Firebase库,并编写客户端代码。数据将以JSON格式暴露给开发者的代码,并且在本地进行缓存。该库处理了本地高速缓存和存储在Firebase服务器上的主副本(master copy)之间的同步。对于任何数据进行的更改都将会被实时地同步到与Firebase相连接的潜在的数十万个客户端上。
Firebase的服务器接收传入的数据更新,并将它们立即同步给所有注册了对于更改的数据感兴趣的已经连接的客户端。为了启用状态更改的实时通知,客户端将会始终保持一个到Firebase的活动连接。该连接的范围是:从基于单个Netty Channel的抽象到基于多个Channel的抽象,甚至是在客户端正在切换传输类型时的多个并存的抽象。
因为客户端可以通过多种方式连接到Firebase,所以保持连接代码的模块化很重要。Netty的Channel抽象对于Firebase继承新的传输来说简直是梦幻般的构件块,此外,流水线和处理器模式使得可以简单地传输相关的细节隔离开来,并为应用程序代码提供了一个公共的消息流抽象。同样的,这也极大地简化了添加新的协议支持所需要的工作。Firebase只通过简单地添加几个新的ChannelHandler到ChannelPipeline中,便添加了对一种二进制传输的支持。对于实现客户端和服务器之间的实时连接而言,Netty的速度、抽象的级别以及细粒度的控制都使得它成为了一个卓绝的框架。
2.2、长轮询
Firebase同时使用了长轮询和WebSocket传输。长轮询传输是高度可靠的,覆盖了所有的浏览器、网络以及运营商;而基于WebSocket的传输、速度更快,但是由于浏览器/客户端的局限性,并不总是可用的。开始时,Firebase将会使用长轮询进行连接,然后在WebSocket可用时再升级到WebSocket。对于少数不支持WebSocket的Firebase流量,Firebase使用Netty实现了一个自定义的库来进行长轮询,并且经过调优具有非常高的性能和响应性。
Firebase的客户端库逻辑处理双向消息流,并且会在任意一端关闭流时进行通知。虽然这在TCP或者WebSocket协议上实现起来相对简单,但是在处理长轮询传输时它仍然是一项挑战。对于长轮询的场景来说,下面两个属性必须被严格地保证:
——保证消息的按顺序投递
——关闭通知
1-保证消息的按顺序投递
可以通过使得在某个指定的时刻有且只有一个未完成的请求,来实现长轮询的按顺序投递。因为客户端不会在它收到它的上一个请求的响应之前发出另一个请求,所以这就保证了它之前所发出的所有消息都被接收,并且可以安全地发送更多的请求了。同样,在服务器端,直到客户端收到之前的响应之前,将不会发出新的请求。因此,总是可以安全地发送缓存在两个请求之间的任何东西。然而,这将导致一个严重的缺陷。使用单一请求技术,客户端和服务器端都将花费大量的时间来对消息进行缓冲。例如,如果客户端有新的数据需要发送,但是这是已经有了一个未完成的请求,那么它在发出新的请求之前,就必须得等待服务器的响应。如果这时在服务器上没有可用的数据,则可能需要很长时间。
一个更加高性能的解决方案则是容忍更多的正在并发进行的请求。在实践中,这可以通过将单一请求的模式切换为最多两个请求的模式。这个算法包含了两个部分:
——每当客户端有新的数据需要发送时,它都将发送一个新的请求,除非已经有两个请求正在被处理
——每当服务器接收到来自客户端的请求时,如果它已经有了一个来自客户端的未完成的请求,那么即使没有数据,他也将立即回应第一个请求。
相对于单一请求的模式,这种方式提供了一个重要的改进:客户端和服务器的缓冲时间都被限定在了最多一次的网络往返时间里。
当然,这种性能的增加并不是没有代价的;它导致了代码复杂性的相应增加。该长轮询算法也不再保证消息的按顺序投递,但是一些来自TCP协议的理念可以保证这些消息的按顺序投递。由客户端发送的每个请求都包含一个序列号,每次请求时都将会递增。此外,每个请求都包含了关于有效负载中的消息数量的元数据。如果一个消息跨越了多个请求,那么在有效负载中所包含的消息的序号也会被包含在元数据中。
服务器维护了一个传入消息分段的环形缓冲区,在它们完成之后,如果它们之前没有不完整的消息,那么会立即对它们进行处理,下行要简单点,因为长轮询传输响应时HTTP GET请求,而且对于有效载荷的大小没有相同的限制。在这种情况下,将包含一个对于每个响应都将会递增的序列号,只要客户端接收到了达到指定序列号的所有响应,他就可以开始处理列表中的所有消息;如果它没有收到,那么它将缓冲该列表,直到它接收到这些为完成的响应。
2-关闭通知
在长轮询传输中第二个需要保证的属性是关闭通知。在这种情况下,使得服务器意识到传输已经关闭,明显要重要与使得客户端识别到传输的关闭。客户端所使用的Firebase库将会在连接断开时将操作放入队列以便稍后执行,而且这些被放入队列的操作可能也会对其它仍然连接着的客户端造成影响。因此,知道客户端什么时候实际上已经断开了是非常重要的。实现由服务器发起的关闭操作是相对简单的,其可以通过使用一个特殊的协议级别的关闭消息响应下一个请求来实现。
实现客户端的关闭通知是比较棘手的。虽然可以使用相同的关闭通知,但是有两种情况可能会导致这种方式失效:用户可以关闭浏览器标签页,或者网络连接也可能会消失。标签页关闭的这种情况可以通过iframe来处理,iframe会在页面卸载时发送一个包含关闭消息的请求。第二种情况则可以通过服务器超时来处理。小心谨慎地选择超时值大小很重要,因为服务器无法区分慢速的网络和断开的客户端。也就是说,对于服务器来说,无法知道一个请求是被实际推迟了一分钟,还是该客户端丢失了它的网络连接。相对于应用程序需要多快地意识到断开的客户端来说,选取一个平衡了误报所带来的成本的合适的超时大小是很重要的。
下图演示了Firebase的长轮询传输是如何处理不同类型的请求的。 在这个图中,每个长轮询请求都代表了不同类型的场景,最初,客户端向服务器发送了一个轮询(轮询0)。一段时间之后,服务器从系统内的其它地方接收到了发送给客户端的数据,所以它使用该数据响应了轮询0。在该轮询返回之后,因为客户端目前没有任何未完成的请求,所以客户端有立即发送了一个新的轮询(轮询1)。过了一会儿,客户端需要发送数据给服务器。因为它只有一个未完成的轮询,所以它有发送了一个新的轮询(轮询2),其中包含了需要被递交的数据。根据协议,一旦在服务器同时存在两个来自相同的客户端的轮询时,它将响应第一个轮询。在这种情况下,服务器没有任何已经就绪的数据可以用于该客户端,因此它发送回了一个空响应。客户端也维护了一个超时,并将在超时被触发时发送第二次轮询,即使它没有任何额外的数据需要发送。这将系统从由于浏览器超时缓慢的请求所导致的故障中隔离开来。
2.3、HTTP 1.1 keep-alive和流水线化
通过HTTP 1.1 keep-alive特性,可以在同一个连接上发送多个请求到服务器。这使得HTTP流水线化——可以发送新的请求而不必等待来自服务器的响应,成为了可能。实现对于HTTP流水线化以及keep-alive特性的支持通常是直截了当的,但是当混入了长轮询之后,他就明显变得更加复杂起来。
如果一个长轮询请求紧跟着一个REST(表征状态转移)请求,那么将有一些注意事项需要被考虑在内,以确保浏览器能够正确工作。一个Channel可能会混和异步消息(长轮询请求)和同步消息(REST请求)。当一个Channel上出现了一个同步请求时,Firebase必须按照顺序同步响应该Channel中所有之前的请求。例如,如果有一个未完成的长轮询请求,那么在处理该REST请求之前,需要使用一个空操作对该长轮询传输进行响应。
下图说明了Netty是如何让Firebase在一个套接字上响应多个请求的。 如果浏览器有多个打开的连接,并且正在使用长轮询,那么它将重用这些连接来处理来自这两个打开的标签页的消息。对于长轮询请求来说,这是很困难的,并且还需要妥善地管理一个HTTP请求队列。长轮询请求可以被中断,但是被处理的请求却不能。Netty使服务于多种类型的请求很轻松。
——静态的HTML页面:缓存的内容,可以直接返回而不需要进行处理;例子包括一个单页面的HTTP应用程序、robots.txt和crossdomain.xml
——REST请求:Firebase支持传统的GET、POST、PUT、DELETE以及OPTIONS请求
——WebSocket:浏览器和Firebase服务器之间的双向链接,拥有它自己的分帧协议
——长轮询:这些类似于HTTP的GET请求,但是应用程序的处理方式有所不同。
——被代理的请求:某些请求不能由接收它们的服务器处理。在这种情况下,Firebase将会把这些请求代理到集群中正确的服务器。以便最终用户不必担心数据存储的具体位置。这些类似于REST请求,但是代理服务器处理它们的方式有所不同。
——通过SSL的原始字节:一个简单的TCP套接字,运行Firebase自己的分帧协议,并且优化了握手过程。
Firebase使用Netty来设置好它的ChannelPipeline以解析传入的请求,并随后适当地重新配置ChannelPipeline剩余的其它部分。在某些情况下,如WebSocket和原始字节,一旦某个特定类型的请求被分配给某个Channel之后,它就会在它的整个生命周期内保持一致。在其他情况下,如各种HTTP请求,该分配则必须以每个消息为基础进行赋值。同一个Channel可以处理REST请求、长轮询请求以及被代理的请求。
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代码便可以拦截所有的入站消息和出站消息并且统计字节数成为了可能。
2.5、Firebase小结
在Firebase的实时数据同步服务的服务器端架构中,Netty扮演了不可或缺的角色。它使得可以支持一个异构的客户端生态系统,其中包括了各种各样的浏览器,以及完全由Firebase控制的客户端。使用Netty,Firebase可以在每个服务器上每秒钟处理数以万计的消息。Netty之所以非常了不起,有以下几个原因:
——他很快:开发原型只需要几天时间,并且从来不是生成瓶颈
——它的抽象层次具有良好的定位:Netty提供了必要的细粒度控制,并且允许在控制流的每一步进行自定义。
——它支持在同一个端口上支撑多种协议:HTTP、WebSocket、长轮询以及独立的TCP协议
——它的GitHub库是一流的:精心编写的javadoc使得可以无障碍地利用它进行开发
3、Urban Airship——构建移动服务
随着智能手机的使用以前所未有的速度在全球范围内不断增长,涌现了大量的服务提供商,以协助开发者和市场人员提供令人惊叹不已的终端用户体验。不同于它们的功能手机前辈,智能手机渴求IP连接,并通过多个渠道(3G、4G、WiFi、WIMAX以及蓝牙)来寻求连接。随着越来越多的这些设备通过基于IP的协议连接到公共网络,对于后端服务提供商来说,伸缩性、延迟以及吞吐量方面的挑战变得越来越艰巨了。
值得庆幸的是,Netty非常适用于处理由随时在线的移动设备的惊群效应所带来的许多问题。
3.1、移动消息的基础知识
虽然市场人员长期以来都使用SMS来作为一种触达移动设备的通道,但是最近一种被称为推送通知的功能正在迅速地成为向智能手机发送消息的首选机制。推送通知通常使用较为便宜的数据通道,每条消息的价格只是SMS费用的一小部分。推送通知的吞吐量通常都比SMS高2-3个数量级,所以它成为了突发新闻的理想通道。最重要的是,推送通知为用户提供了设备驱动的对推送通道的控制。如果一个用户不喜欢某个应用程序的通知消息,那么用户可以禁用该应用程序的通知,或者干脆删除该应用程序。
在一个非常高的级别上,设备和推送通知行为的交互类似于下图所述。 在高级别上,当应用程序开发人员想要发送推送通知给某台设备时,开发人员必须要考虑存储有关设备及其应用程序安装的信息。通常,应用程序的安装都将会执行代码以检索一个平台相关的标识符,并且将该标识符上报给一个持久化该标识符的中心化服务。稍后,应用程序安装之外的逻辑将会发起一个请求以向该设备投递一条消息。
一旦一个应用程序的安装已经将它的标识符注册到了后端服务,那么推送消息的递交就可以反过来采取两种方式。在第一种方式中,使用应用程序维护一条到后端服务的直接连接,消息可以被直接递交给应用程序本身。第二种方式更加常见,在这种方式中,应用程序将依赖第三方代表该后端服务来将消息递交给应用程序。在Urban Airship,这两种递交推送通知的方式都有使用,而且也都大量地使用了Netty。
3.2、第三方递交
在第三方推送递交的情况下,每个推送通知平台都为开发者提供了一个不同的API,来将消息递交给应用程序安装。这些API有着不同的协议(基于二进制的或者基于文本的)、身份验证(OAuth、X.509等)以及能力。对于集成它们并且达到最佳吞吐量,每种方式都有着其各自不同的挑战。
尽管事实上每个这些提供商的根本目的都是向应用程序递交通知消息,但是它们各自又都采取了不同的方式,这对系统集成商造成了重大的影响。例如,苹果公司的Apple推送通知服务(APNS)定义了一个严格的二进制协议;而其他的提供商则将它们的服务构建在了某种形式的HTTP之上,所有的这些微妙变化都影响了如何以最佳的方式达到最大的吞吐量。值得庆幸的是,Netty是一个灵活得令人惊奇的工具,它为消除不同协议之间的差异提供了极大的帮助。
3.3、使用二进制协议的例子
苹果公司的APNS是一个具有特定的网络字节序的有效载荷的二进制协议。发送一个APNS通知将涉及下面的事件序列:
(1)通过SSLv3连接将TCP套接字连接到APNS服务器,并用X.509证书进行身份认证;
(2)根据Apple定义的格式,构造推送消息的二进制表示形式
(3)将消息写出到套接字
(4)如果你已经准备好了确定任何和已经发送消息相关的错误代码,则从套接字中读取
(5)如果有错误发生,则重新连接该套接字,并从步骤2继续。
作为格式化二进制消息的一部分,消息的生产者需要生成一个对于APNS系统透明的标识符。一旦消息无效(如不正确的格式、大小或者设备信息),那么该标识符将会在步骤4的错误响应消息中返回给客户端。
虽然从表面上看,该协议似乎简单明了,但是想要成功地解决所有上述问题,还是有一些微妙的细节,尤其是在JVM上。
——APNS规范规定,特定的有效载荷值需要以大端字节序进行发送(如令牌长度)。
——在前面的操作序列中的第3步要求两个解决方案二选一。因为JVM不允许从一个已经关闭的套接字中读取数据,即使在输出缓冲区中有数据存在,所以你有两个选项。
——在一次写出操作之后,在该套接字上执行带有超时的阻塞读取动作。这种方式有多个缺点:
阻塞等待错误消息的时间长短是不确定的,错误可能会发生在数毫秒或者数秒之内 由于套接字对象无法在多个线程之间共享,所以在等待错误消息时,对套接字的写操作必须立即阻塞。这将对吞吐量造成巨大的影响。如果在一次套接字写操作中递交单个消息,那么在直到读取超时发生之前,该套接字上都不会发出更多的消息。当你要递交数千万的消失时,每个消息之间都有3秒的延迟是无法接受的。
依赖套接字超时是一项昂贵的操作。它将导致一个异常被抛出,以及几个不必要的系统调用。
——使用异步I/O。在这个模型中,读操作和写操作都不会阻塞。这使得写入者可以持续地给APNS发送消息,同时也允许操作系统在数据可供读取时通知用户代码。
Netty使得可以轻松地解决所有的这些问题,同时提供了令人惊叹的吞吐量。首先,让我们看看Netty是如何简化使用正确的字节序打包二进制APNS消息的,如下代码。
public final class ApnsMessage {
//APNS消息总是以一个字节大小的命令作为开始,因此该值被编码为常量
private static final byte COMMAND = (byte)1;
public ByteBuf toBuffer(){
//因为消息的大小不一,所以出于效率考虑,在ByteBuf创建之前将先计算它
short size = (short)(1 +//Command
4 + //Identifier
4 + //Expiry
2 + //DT length header
32 + //DS length
2 + //body length header
body.length);
//在创建时,ByteBuf的大小正好,并且指定了用于APNS的大端字节序
ByteBuf buf = Unpooled.buffer(size).order(ByteOrder.BIG_ENDIAN);
buf.writeByte(COMMAND);
//来自于类中其它地方维护的状态的各种值将会被写入到缓冲区中
buf.writeInt(identifier);
buf.writeInt(expiryTime);
//这个类中的deviceToken字段是一个Java的byte[]
buf.writeShort((short)deviceToken.length);
buf.writeBytes(deviceToken);
buf.writeShort((short)body.length);
buf.writeBytes(body);
//当缓冲区已经就绪时,简单地将它返回
return buf;
}
}
关于该实现的一些重要说明如下。
——Java数组的长度属性值始终是一个整数,但是,APNS协议需要一个2-byte值。在这种情况下,有效负载的长度已经在其他的地方验证过了,所以在这里将其强制转换为short是安全的。注意,如果没有显式地将ByteBuf构造为大端字节序,那么在处理short和int类型的值时则可能会出现各种微妙的错误。
——不同于标准的java.nio.ByteBuffer,没有必要翻转缓冲区,也没有必要关心它的位置:Netty的ByteBuf将会自动管理用于读取和写入的位置。
使用少量的代码,Netty已经使得创建一个格式正确的APNS消息的过程变成小事一桩了。因为这个消息现在已经被打包进了一个ByteBuf,所以当消息准备好发送时,便可以很容易地被直接写入连接了APNS的Channel。
可以通过多重机制连接APNS,但是最基本的,是需要一个使用SslHandler和解码器来填充ChannelPipeline的ChannelInitializer,如下代码所示。
public class ApnsClientPipelineInitializer extends ChannelInitializer<Channel>{
private final SSLEngine clientEngine;
public ApnsClientPipelineInitializer(SSLEngine clientEngine) {
//一个X.509认证的请求需要一个javax.net.ssl.SSLEngine类的实例
this.clientEngine = clientEngine;
}
@Override
protected void initChannel(Channel channel) throws Exception {
final ChannelPipeline pipeline = channel.pipeline();
//构造一个Netty的SslHandler
final SslHandler handler = new SslHandler(clientEngine);
//APNS将尝试在连接后不久重新协商SSL,需要允许重新协商
//handler.setEnableRenegotiation(true);
pipeline.addLast("ssl",handler);
//这个类扩展了Netty的ByteToMessageDecoder,并且处理了APNS返回一个错误代码并断开连接的情况
pipeline.addLast("decoder",new ApnsResponseDecoder());
}
}
值得注意的是,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以及套接字缓冲区大小)的集成如何生效。
3.4、直接面向设备的递交
除了通过第三方来递交消息之外,Urban Airship还有直接作为消息递交通道的经验。在作为这种角色时,单个设备将直接连接Urban Airship的基础设施,绕过第三方提供商。这种方式也带来了一组截然不同的挑战。
——由移动设备发出的套接字连接往往是短暂的。根据不同的条件,移动设备将频繁地在不同类型的网络之间进行切换,对于移动服务的后端提供商来说,设备将不断地重新连接,并将感受到短暂而又频繁的连接周期。
——跨平台的连接性是不规则的。从网络的角度来说,平板设备的连接性往往表现得和移动电话不一样,而对比于台式计算机,移动电话的连接性的表现又不一样。
——移动电话向后端服务提供商更新的频率一定会增加。移动电话越来越多地被应用于日常任务中,不仅产生了大量常规的网络流量,而且也为后端服务提供商提供了大量的分析数据。
——电池和带宽不能被忽略。不同于传统的桌面环境,移动电话通常使用有线的数据流量包。服务提供商必须要尊重最终用户只有有限的电池使用时间,而且他们使用昂贵的、速率有限的(蜂窝移动数据网络)带宽这一事实。滥用两者之一都通常会导致应用被卸载,这对于移动开发人员来说可能是最坏的结果。
——基础设施的所有方面都需要大规模的伸缩。随着移动设备普及程度的不断增加,更多的应用程序安装量将会导致更多的到移动服务的基础设施的连接。由于移动设备的庞大规模和增长,这个列表中的每一个前面提到的元素都将变得愈加复杂。
随着时间的推移,Urban Airship从移动设备的不断增长中学到了几点关键的经验教训:
——移动运营商的多样性可以对移动设备的连接性造成巨大的影响;
——许多运营商都不允许TCP的keep-alive特性,因此许多运营商都会积极地剔除空闲的TCP会话;
——UDP不是一个可行的向移动设备发送消息的通道,因为许多的运营商都禁止它。
——SSLv3所带来的开销对于短暂的连接来说是巨大的痛苦。
鉴于移动增长的挑战,以及Urban Airship的经验教训,Netty对于实现一个移动消息平台来说简直就是天作之合。
3.5、Netty擅长管理大量的并发连接
Netty使得可以轻松地在JVM平台上支持异步I/O。因为Netty运行在JVM之上,并且因为JVM在Linux上将最终使用Linux的epoll方面的设施来管理套接字文件描述符中所感兴趣的事件(interest),所以Netty使得开发者能够轻松地接受大量打开的套接字——每一个Linux进程将近一百万的TCP连接,从而适应快速增长的移动设备的规模。有了这样的伸缩能力,服务提供商便可以在保持低成本的同时,允许大量的设备连接到物理服务器上的一个单独的进程。
在受控的测试以及优化了配置选项以使用少量的内存的条件下,一个基于Netty的服务得以容纳略少于100万的连接。在这种情况下,这个限制从根本上来说是由于linux内核强制硬编码了每个进程限制100万个文件句柄。如果JVM本身没有持有大量的套接字以及用于JAR文件的文件描述符,那么该服务器可能本能够处理更多的连接,而所有的这一切都在一个4GB大小的堆上。利用这种效能,Urban Airship成功地维持了超过2000万的到它的基础设施的持久化的TCP套接字连接以进行消息递交,所有的这一切都只使用了少量的服务器。
值得注意的是,虽然在实践中,一个单一的基于Netty的服务便能处理将近1百万的入站TCP套接字连接,但是这样做并不一定就是务实的或者明智的。如同分布式计算中的所有陷阱一样,主机将会失败、进程将需要重新启动并且将会发生不可预期的行为。由于这些现实的问题,适当的容量规划意味着需要考虑到单个进程失败的后果。
3.6、Urban Airship小结——跨越防火墙边界
(1)内部的RPC框架
Netty一直都是Urban Airship内部的RPC框架的核心,其一直都在不断进化。今天,这个框架每秒钟可以处理数以十万计的请求,并且拥有相当低的延迟以及杰出的吞吐量。几乎每个Urban Airship发出的API请求都经由了多个后端服务处理,而Netty正是所有这些服务的核心。
(2)负载和性能测试
Netty在Urban Airship已经被用于几个不同的负载测试框架和性能测试框架。例如,在测试前面所描述的设备消息服务时,为了模拟数百万的设备连接,Netty和一个Redis实例相结合使用,以最小的客户端足迹(负载)测试了端到端的消息吞吐量。
(3)同步协议的异步客户端
对于一些内部的使用场景,Urban Airship一直都在尝试使用Netty来为典型的同步协议创建异步的客户端,包括如Apache Kafka以及Memcached这样的服务。Netty的灵活性使得我们能够很容易地打造天然异步的客户端,并且能够在真正的异步或同步的实现之间来回地切换,而不需要更改任何的上游代码。
总而言之,Netty一直都是Urban Airship服务的基石。其作者和社区都是及其出色的,并成为任何需要在JVM上进行网络通信的应用程序,创造了一个真正意义上的一流框架。