Netty实战十四之案例研究(一)

一、Droplr——构建移动服务前端

Bruno de Carvalho,首席架构师java

在Droplr,咱们在个人基础设施的核心部分、从咱们的API服务器到辅助服务的各个部分都使用了Netty。linux

这是一个关于咱们如何从一个单片的、运行缓慢的LAMP(Linux、Apache Web Server、MySQL以及PHP)应用程序迁移到基于Netty实现的现代的、高性能的以及水平扩展的分布式架构的案例研究。git

1.一、一切的原由web

当我加入这个团队时,咱们运行的是一个LAMP应用程序,其做为前端页面服务于用户,同时还做为API服务于客户端应用程序,其中,也包括个人逆向工程、第三方的Windows客户端windroplr。算法

后来windroplr变成了Droplr for Windows,而我则开始主要负责基础设施的建设,而且最终获得了一个新的挑战:彻底从新考虑Droplr的基础设施。数据库

在那时,Droplr自己已经确立成为了一种工做的理念,所以2.0版本的目标也是至关的标准:后端

——将单片的技术栈拆分为多个可横向扩展的组件数组

——添加冗余,以免宕机浏览器

——为客户端建立一个简洁的API

——使其所有运行在HTTPS上

创始人Josh和Levi对我说:“要不惜一切代价,让它飞起来”

我知道这句话意味的可不仅是变快一点或者变快不少。这意味着一个彻底数量级上的更快。并且我也知道,Netty最终将会在这样的努力中发挥重要做用。

1.二、Droplr是怎样工做的

Droplr拥有一个很是简单的工做流:将一个文件拖动到应用程序的菜单栏图标,而后Droplr将会上传该文件。当上传完成以后,Droplr将复制一个短URL——也就是所谓的拖乐(drop)——到剪贴板。

而在幕后,拖乐元数据将会被存储到数据库中(包括建立日期、名称以及下载次数等信息),而文件自己则被存储在Amazon S3上。

1.三、创造一个更加快速的上传体验

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.四、技术栈

当开始一个新的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操做上,即将数据写入临时文件,并在稍后的第二阶段上传中读取它们,对于每一个上传也将消耗更少的内存,这意味着能够进行更多的并行上传

——缩略图生成变成了一个异步的后处理。

二、Firebase——实时的数据同步服务

实时更新是现代应用程序中用户体验的一个组成部分。随着用户期待这样的行为,愈来愈多的应用程序都正在实时地向用户推送数据的变化。经过传统的3层架构很难实现实时的数据同步,其须要开发者管理他们本身的运维、服务器以及伸缩。经过维护到客户端的实时的、双向的通讯,Firebase提供了一种即便的直观体验,容许开发人员在几分钟以内跨越不一样的客户端进行应用程序数据的同步——这一切都不须要任何的后端工做、服务器、运维或者伸缩。

实现这种能力提出了一项艰难的技术挑战,而Netty则是用于在Firebase内构建用于全部网络通讯的底层框架的最佳解决方案。这个案例研究概述了Firebase的架构,而后审查了Firebase使用Netty以支撑它的实时数据同步服务的3种方式:长轮询、HTTP 1.1 keep-alive和流水线化、控制SSL处理器

2.一、Firebase的架构

Firebase容许开发者使用两层体系结构来上线运行应用程序。开发者只须要简单地导入Firebase库,并编写客户端代码。数据将以JSON格式暴露给开发者的代码,而且在本地进行缓存。该库处理了本地高速缓存和存储在Firebase服务器上的主副本(master copy)之间的同步。对于任何数据进行的更改都将会被实时地同步到与Firebase相链接的潜在的数十万个客户端上。

Firebase的服务器接收传入的数据更新,并将它们当即同步给全部注册了对于更改的数据感兴趣的已经链接的客户端。为了启用状态更改的实时通知,客户端将会始终保持一个到Firebase的活动链接。该链接的范围是:从基于单个Netty Channel的抽象到基于多个Channel的抽象,甚至是在客户端正在切换传输类型时的多个并存的抽象。

由于客户端能够经过多种方式链接到Firebase,因此保持链接代码的模块化很重要。Netty的Channel抽象对于Firebase继承新的传输来讲简直是梦幻般的构件块,此外,流水线和处理器模式使得能够简单地传输相关的细节隔离开来,并为应用程序代码提供了一个公共的消息流抽象。一样的,这也极大地简化了添加新的协议支持所须要的工做。Firebase只经过简单地添加几个新的ChannelHandler到ChannelPipeline中,便添加了对一种二进制传输的支持。对于实现客户端和服务器之间的实时链接而言,Netty的速度、抽象的级别以及细粒度的控制都使得它成为了一个卓绝的框架。

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.三、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.四、控制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.五、Firebase小结

在Firebase的实时数据同步服务的服务器端架构中,Netty扮演了不可或缺的角色。它使得能够支持一个异构的客户端生态系统,其中包括了各类各样的浏览器,以及彻底由Firebase控制的客户端。使用Netty,Firebase能够在每一个服务器上每秒钟处理数以万计的消息。Netty之因此很是了不得,有如下几个缘由:

——他很快:开发原型只须要几天时间,而且历来不是生成瓶颈

——它的抽象层次具备良好的定位:Netty提供了必要的细粒度控制,而且容许在控制流的每一步进行自定义。

——它支持在同一个端口上支撑多种协议:HTTP、WebSocket、长轮询以及独立的TCP协议

——它的GitHub库是一流的:精心编写的javadoc使得能够无障碍地利用它进行开发

三、Urban Airship——构建移动服务

随着智能手机的使用之前所未有的速度在全球范围内不断增加,涌现了大量的服务提供商,以协助开发者和市场人员提供使人惊叹不已的终端用户体验。不一样于它们的功能手机前辈,智能手机渴求IP链接,并经过多个渠道(3G、4G、WiFi、WIMAX以及蓝牙)来寻求链接。随着愈来愈多的这些设备经过基于IP的协议链接到公共网络,对于后端服务提供商来讲,伸缩性、延迟以及吞吐量方面的挑战变得愈来愈艰巨了。

值得庆幸的是,Netty很是适用于处理由随时在线的移动设备的惊群效应所带来的许多问题。

3.一、移动消息的基础知识

虽然市场人员长期以来都使用SMS来做为一种触达移动设备的通道,可是最近一种被称为推送通知的功能正在迅速地成为向智能手机发送消息的首选机制。推送通知一般使用较为便宜的数据通道,每条消息的价格只是SMS费用的一小部分。推送通知的吞吐量一般都比SMS高2-3个数量级,因此它成为了突发新闻的理想通道。最重要的是,推送通知为用户提供了设备驱动的对推送通道的控制。若是一个用户不喜欢某个应用程序的通知消息,那么用户能够禁用该应用程序的通知,或者干脆删除该应用程序。

在一个很是高的级别上,设备和推送通知行为的交互相似于下图所述。 输入图片说明 在高级别上,当应用程序开发人员想要发送推送通知给某台设备时,开发人员必需要考虑存储有关设备及其应用程序安装的信息。一般,应用程序的安装都将会执行代码以检索一个平台相关的标识符,而且将该标识符上报给一个持久化该标识符的中心化服务。稍后,应用程序安装以外的逻辑将会发起一个请求以向该设备投递一条消息。

一旦一个应用程序的安装已经将它的标识符注册到了后端服务,那么推送消息的递交就能够反过来采起两种方式。在第一种方式中,使用应用程序维护一条到后端服务的直接链接,消息能够被直接递交给应用程序自己。第二种方式更加常见,在这种方式中,应用程序将依赖第三方表明该后端服务来将消息递交给应用程序。在Urban Airship,这两种递交推送通知的方式都有使用,并且也都大量地使用了Netty。

3.二、第三方递交

在第三方推送递交的状况下,每一个推送通知平台都为开发者提供了一个不一样的API,来将消息递交给应用程序安装。这些API有着不一样的协议(基于二进制的或者基于文本的)、身份验证(OAuth、X.509等)以及能力。对于集成它们而且达到最佳吞吐量,每种方式都有着其各自不一样的挑战。

尽管事实上每一个这些提供商的根本目的都是向应用程序递交通知消息,可是它们各自又都采起了不一样的方式,这对系统集成商形成了重大的影响。例如,苹果公司的Apple推送通知服务(APNS)定义了一个严格的二进制协议;而其余的提供商则将它们的服务构建在了某种形式的HTTP之上,全部的这些微妙变化都影响了如何以最佳的方式达到最大的吞吐量。值得庆幸的是,Netty是一个灵活得使人惊奇的工具,它为消除不一样协议之间的差别提供了极大的帮助。

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.四、直接面向设备的递交

除了经过第三方来递交消息以外,Urban Airship还有直接做为消息递交通道的经验。在做为这种角色时,单个设备将直接链接Urban Airship的基础设施,绕过第三方提供商。这种方式也带来了一组大相径庭的挑战。

——由移动设备发出的套接字链接每每是短暂的。根据不一样的条件,移动设备将频繁地在不一样类型的网络之间进行切换,对于移动服务的后端提供商来讲,设备将不断地从新链接,并将感觉到短暂而又频繁的链接周期。

——跨平台的链接性是不规则的。从网络的角度来讲,平板设备的链接性每每表现得和移动电话不同,而对比于台式计算机,移动电话的链接性的表现又不同。

——移动电话向后端服务提供商更新的频率必定会增长。移动电话愈来愈多地被应用于平常任务中,不只产生了大量常规的网络流量,并且也为后端服务提供商提供了大量的分析数据。

——电池和带宽不能被忽略。不一样于传统的桌面环境,移动电话一般使用有线的数据流量包。服务提供商必需要尊重最终用户只有有限的电池使用时间,并且他们使用昂贵的、速率有限的(蜂窝移动数据网络)带宽这一事实。滥用二者之一都一般会致使应用被卸载,这对于移动开发人员来讲多是最坏的结果。

——基础设施的全部方面都须要大规模的伸缩。随着移动设备普及程度的不断增长,更多的应用程序安装量将会致使更多的到移动服务的基础设施的链接。因为移动设备的庞大规模和增加,这个列表中的每个前面提到的元素都将变得越发复杂。

随着时间的推移,Urban Airship从移动设备的不断增加中学到了几点关键的经验教训:

——移动运营商的多样性能够对移动设备的链接性形成巨大的影响;

——许多运营商都不容许TCP的keep-alive特性,所以许多运营商都会积极地剔除空闲的TCP会话;

——UDP不是一个可行的向移动设备发送消息的通道,由于许多的运营商都禁止它。

——SSLv3所带来的开销对于短暂的链接来讲是巨大的痛苦。

鉴于移动增加的挑战,以及Urban Airship的经验教训,Netty对于实现一个移动消息平台来讲简直就是天做之合。

3.五、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.六、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上进行网络通讯的应用程序,创造了一个真正意义上的一流框架。

 
相关文章
相关标签/搜索