问题凸现
年关到了,商家忙着促销,网站忙着推广,阿里软件的服务集成平台也面临第一次多方大规模的压力考验。根据该平台 5.3 版本的压力测试结果,我们估算了一下现有的推广会带来的压力,基本上确定了服务集成平台年底不需要扩容。SA(System Administrator,系统管理员)为了保险起见还是通过请求方式来做定时的心跳检测,保证服务集成平台的可靠性。结果阿里旺旺推广开始的第一天,SA 的报警短信就在几个忙时段不停地发告警,但是查看生产环境的服务器状况以及应用状况后看不出有什么问题,于是开始怀疑是否告警机制不是很合理。几日的访问记录统计报告看过以后,发现了几个问题,首先由于推广是在 IM 登录时段集中式的推广,因此高峰期比较集中,压力也很大,而告警发生的时刻也是那些时候;另外发现那些推广使用的 API 的处理时间比较长,同时还有些出现了问题,这几天除了服务集成平台告警以外,那些 API 服务器也在告警;因此可以看出问题应该是由于 API 提供商响应速度慢而拖累了服务集成平台的处理能力,监控机制在高峰情况下没有得到及时的响应,就认为是服务器已经处于无效状态。
其实这类问题在我们现在的应用体系架构中常常出现,原因是现在很少再有纯粹“封闭式”应用,对数据库的依赖,对存储的依赖,对第三方系统的依赖等等。这也让我回忆到在前一阵子参加的安全会议中,腾迅的安全技术团队的负责人说安全现在最大的问题就在于合作的第三方的安全不受控而引发的安全潜在影响。Web 应用未尝不是,从最基本的事务处理要小粒度,不要在事务中包含第三方依赖,到心跳检测,容错方案的制定等,都已经让我们对这方面的问题有所注意。但是往往这类问题不是局部设计可以看到的,如果没有一个总体架构设计者对于全局的把握、协调和防范,那么问题出现并且带来的影响将会很大。
从前对于服务集成平台的压力测试主要是在 ISP 服务“基本正常”的情况下做的,但是这次问题的暴露就要求我们在第三方依赖出现边界问题时,及时做出一些措施或者改进设计。
问题分析以及解决方案
问题原因:
- Http 请求处理的阻塞方式。
- 后端服务处理时间过长,服务质量不稳定。
- Web Container 接受请求线程资源有限。
解决方案:
- 改阻塞方式为非阻塞方式来处理请求。
- 设置后端超时时间,主动断开连接,回收资源。
- 修改容器配置,增加线程池大小以及等待队列长度。
解决方案一是最难做到的,后面的篇幅将描述对于这方面技术的探索。
解决方案二比较容易,允许各个 ISP 设置自己 API 容许的最大超时时间。
解决方案三 Tomcat 和 JBoss 在 Connector 中有两个参数配置(maxThreads 和 acceptCount)可以做调整。
第一个方案其实和 JDK 1.5 支持的 NIO 是一种想法,只是我们在 Socket 中都已经采用过了,而在 Http 请求处理中因为要依赖于 Web Container 开发商的实现,所以至今还没有被广泛应用,不过在开源社区已经有用 Mina 实现的 Http 协议处理的框架。需要注意的是,现在 Web 应用对于 Web 请求高效处理的需求仅仅是很小的一方面,其实还有很多类似于安全、缓存、监控等等附加功能也占据着很重要的地位。
Servlet 3 规范经过快一年的推广,已经被各大 Web Container 厂商所接受,Tomcat 6、JBoss 5、Jetty 7 都宣称自己对 Servlet 3 作了较好的支持,而在 Servlet 3 中最广为关注的一个特性就是异步服务处理 Servlet(Async Servlet),这点也是解决我目前面临问题的最好手段。
Servlet 3 与服务异步处理
Servlet 3 主要的新特性分成四部分:内嵌式的使用模式、Annotation 的支持、Async Servlet 的支持和安全提升。内嵌式的使用很早就在 Jetty 中被实现,也成为 Jetty 的优势之一,Annotation 也只能说是锦上添花的部分,而安全暂时没有怎么用到,所以最关心的还是 Async Servlet 部分。Async Servlet 到底是什么样的概念,这里就大致描述一下在 Servlet 3 规范中对它的介绍:
- 支持 Comet(彗星)。最早期的 Http 请求就是无状态的请求和响应,所有的数据一次性在请求后返回给客户端由客户端渲染。后来发展到 AJAX,页面的请求和渲染由全局变成了局部。而 Comet 适合事件驱动的 Web 应用和对交互性和实时性要求很强的应用,通过建立客户端和服务端的长连接通道,在一次请求后可以主动推送服务端数据的变更情况到客户端。长连接建立的策略有两种:Http Streaming 和 Http Long Polling。前者客户端打开一个单一的与服务器端的 HTTP 持久连接。服务器通过此连接把数据发送过来,客户端对它们进行增量处理。后者由客户端向服务器端发出请求并打开一个连接。这个连接只有在收到服务器端的数据之后才会关闭。服务器端发送完数据之后,就立即关闭连接。客户端则马上再打开一个新的连接,等待下一次的数据。
- 支持 Suspending a request。通过在 ServletRequest 中增加 suspend,resume,complete 等,其将 Http 请求处理的 block 模式转变成为 not block 模式,同时支持对于状态的查询(suspend,resume,timeout)。
- 请求处理过程中支持事件机制。响应也支持状态查询。
图 异步服务请求基本流程
现实中的异步服务处理
Tomcat 的异步服务处理
这里使用的是 Tomcat 6.0.14 版本。在 Tomcat 中对于异步处理描述在 Advanced IO 中作了说明,主要分成两部分:Comet 的支持和异步输出。
Comet 的支持作用分成两部分:请求读数据的非阻塞,响应处理的异步执行。前者可以防止在大流量数据上传过程中,信道空闲等待的资源浪费,后者用于在处理请求时,依赖于第三方或者本身处理比较耗时的情况下,悬挂起请求处理线程,提高请求处理能力,完成处理后异步输出结果。
Servlet 不再是原来对于几个标准的 Http 请求类型的方法实现,而是对于事件响应的处理。Comet 定义了 4 个基础的事件:
- EventType.BEGIN:客户端建立起连接时激发的事件,可以用于资源初始化。
- EventType.READ:有数据可以被读入的事件。(熟悉 NIO 的事件模式应该可以了解)
- EventType.END:请求处理结束时激发的事件,可以用于资源清理。
- EventType.ERROR:当请求处理出现问题时激发的事件。(IO 异常,超时等)
还有一些子事件类型,例如超时就属于 ERROR 的子事件类型,可以在事件处理中更加精确地定位事件类型。
必需的配置:在 server.xml 中配置如下(红色部分):
<Connector port="8080" <span>protocol="org.apache.coyote.http11.Http11NioProtocol" </span> connectionTimeout="20000" redirectPort="8443" />
实际代码范例如下:
<span>//CometProcessor 接口必需被实现,一旦实现以后,则该 Servlet 在配置好以后不会再调用 service,get,post 等方法的实现。</span> public class SIPCometTomcatServlet extends HttpServlet implements CometProcessor { @Override <span> // 事件处理响应方法实现 </span> public void event(CometEvent event) throws IOException, ServletException { if (event.getEventType() == CometEvent.EventType.BEGIN) { <span>// 设置事件超时时间 </span> event.setTimeout(10 * 1000); <span>// 另起线程处理后台工作,异步返回结果,事件响应将不等待后台处理直接返回 </span> new Handler(event.getHttpServletRequest(),event.getHttpServletResponse()).start(); } else if (event.getEventType() == CometEvent.EventType.ERROR) { <span>// 结束事件,回收 request,response 资源 </span> event.close(); } else if (event.getEventType() == CometEvent.EventType.END) { event.close(); } } // 另起一个线程异步处理请求。 class Handler extends java.lang.Thread { private HttpServletResponse response; private HttpServletRequest request; public Handler(HttpServletRequest request,HttpServletResponse response) { this.response = response; this.request = request; } @Override public void run() { try { String id; id = request.getParameter("id"); if (id == null) id = "no id"; Thread.sleep(5000); PrintWriter pw = response.getWriter(); pw.write(id); pw.flush(); } catch (Exception e) { e.printStackTrace(); } } } }
使用过程中的一些总结:
- 事件响应框架将服务的请求由完整的一次服务处理切割成为细粒度的多事件处理,为请求多阶段并行处理提供了框架基础。
- Event 对象在事件处理方法结束后就被回收了,但是 request 和 response 在事件处理完以后还可以继续使用,因此可以看出原来的阻塞式的方式已经可以通过事件的切分成为非阻塞的方式。
- 没有提供 Servlet 3 中描述的 suspend,resume,complete 方法,无法主动控制 request 的异步处理。上面的代码可以看出我只使用了 Begin 方法启动了一个线程,但是由于无法主动地结束请求,因此在向客户端返回数据以后还要等到超时才会结束这次会话。(看了 Tomcat 的代码,也想模仿 close 的动作但是由于它使用了 protected 无法获取封装的 request 对象,因此无法释放资源)。当然也可以通过客户端配合,由客户端主动发起再次的数据传输激发 READ 事件来结束会话。这么做对客户端的依赖比较强,同时也增加了客户端的处理复杂度。
- Tomcat 支持异步输出:在 APR 或者 NIO 的模式下,Tomcat 支持在系统压力增大的时候,支持异步回写大文件数据。
总体上来说实现了部分对于 Comet 的支持,但是没有对异步服务流程作很好的支持,无法在开发中使用(简单顺畅的使用)。
JBoss 的异步服务处理
JBoss 4.2.3 版本配置和使用与 Tomcat 6 类似,没有什么差异。
JBoss 5 刚刚发布了 RC 版本,对于异步服务处理作了很大的改动,与 Tomcat 配置很不同,这里具体的说一下 JBoss5 中的异步服务使用。
JBoss 5 已经将 Tomcat 中的 Http11NioProtocol 给删除了,取而代之的是 JBoss 自己 servlet 包内增加的一个 HttpEventServlet 接口,这个接口和 Tomcat 的 CometProcessor 类似。
首先,必须配置 JBoss 内置的 Web 容器为 APR 模式,也就是配置 jbossweb.sar 下面的 server.xml 中 Connector 如下:
<Connector protocol="org.apache.coyote.http11.Http11AprProtocol" port="8080" address="${jboss.bind.address}" connectionTimeout="2000" redirectPort="8443" />
其次异步服务处理的 Servlet 必须实现 HttpEventServlet 接口,接口只有一个方法,就是事件处理方法:public void event(HttpEvent event)
。事件定义与 Tomcat 稍有不同,在 BEGIN,ERROR,READ,END 基础上增加了 TIMEOUT,EOF,EVENT,WRITE 四个事件,同时去掉了 SubType。
- TIMEOUT 其实是从原来的 Error 的 SubType 分离出来的,这个方法是在最后一次处理事件到当前时间超过设定的超时时间而被激发的,同时 TIMEOUT 被激发并不会关闭请求处理流程,必须显示调用事件的 close 方法才会结束会话。
- EOF 事件将会在客户端主动断连的情况下被触发,就好比 IE 窗口在请求过程中被关闭就会被触发。
- EVENT 事件在事件对象被调用 resume 的时候被激发,按照原意应该最好可以附带上一些自定义信息来做一些工作,但是我自己使用过程中还没有发现有什么好的办法可以在事件中附带信息到事件处理中。
- WRITE 方法在调用 isWriteReady 方法时被激发,可以在网络出现问题或者繁忙的时候异步等待输出。
再则,JBoss 的事件对象还支持几个方法来实现异步处理以及 Comet 机制,方法如下:
- close 方法:表示一次请求处理的结束,会告知客户端没有数据返回了,同时也会激发 END 事件。
- setTimeout 方法:设置连接超时时间(单位毫秒),计算超时是从最近的事件处理时间开始记录的,如果发生超时,则会激发 TIMEOUT 事件。
- isReadReady 方法:如果连接有数据可以读取则返回 true,如果这个方法返回 false,servlet 还试图去读去数据,则会阻塞。
- isWriteReady 方法:如果返回 true,则连接可以无阻塞的写出数据,如果返回 false,servlet 必须停止写数据,如果强制写出,则可能会发生 IO 错误或者会采用异步输出。当客户端的输出通道可用以后,则会激发 write 事件。
- suspend 方法:suspend 连接处理线程直到 timeout 发生或者 resume 被调用,实际上意味着 servlet 在 suspend 以后不再收到 READ 事件,READ 事件将会在后台被不断的激发,除非被 suspend.
- resume 方法:会激发 event 事件,可以利用这个方法来结束异步处理。同时也可以激活因为 suspend 停止的 read 事件,同时也可以在 resume 以后再调用 suspend 方法。注意,这里未必是要求必须先 suspend 以后再 resume。
- event,request,response 在事件响应过程中都可以被使用,但是线程不安全,同时在调用了 close 以后,request,response 资源会被释放,可以通过对 event 对象做同步来保证线程安全的问题。当 READ 事件和 END 事件都发生的时候,首先会完成 READ 事件,然后再去完成 END。
具体的实现代码如下:
public class SIPCometJBossServlet extends HttpServlet implements <span>HttpEventServlet</span> { @Override public void event(HttpEvent event) throws IOException, ServletException { switch (event.getType()) { //will be called at the beginning of the processing of the connection case BEGIN: { event.setTimeout(100 * 1000);<span>// 设置超时时间 </span> //event.suspend();<span>//resume 之前不必要一定使用 suspend</span> new Handler(event).start(); break; } //Error will be called by the container in the case //where an IO exception or a similar unrecoverable error occurs case ERROR: { event.close(); break; } //End may be called to end the processing of the request case END: { //event.close();<span>// 可以写也可以不写,因为进入这个方法也就是调用了 close 方法,起码暂时还不知道有其他什么入口 </span> break; } //This indicates that input data is available, //and that at least one read call can be made without blocking case READ: { break; } //The connection timed out according to the timeout value which has been set //,but the connection will not be closed unless the servlet uses the close method of the event case TIMEOUT: { event.close();<span>// 如果不主动关闭,Timeout 方法会被循环调用,会话不会结束 </span> break; } //The end of file of the input has been reached, and no further data is available case EOF: { event.close(); break; } //Event will be called by the container after the resume() method is called, //during which any operation can be performed, including closing the connection using the close() method. case EVENT: { event.close();<span>// 作为 resume 方法调用后主动释放连接资源的一种手段 </span> break; } //Write is sent if the servlet is using the isWriteReady method case WRITE: { break; } } } class Handler extends java.lang.Thread { private HttpEvent event;<span>//event 的生命周期已经不限制于事件处理方法,因此随时可以关闭请求处理 </span> private HttpServletResponse response; private HttpServletRequest request; public Handler(HttpEvent event) { this.event = event; this.response = event.getHttpServletResponse(); this.request = event.getHttpServletRequest(); } @Override public void run() { try { String id; id = request.getParameter("id"); if (id == null) id = "no id"; Thread.sleep(5000); <span><strong>// 危险!!!其实 event,response,request 都是线程不安全的,因此此时可能 response 已经被释放,需要同步住 event 的对象来操作,效率可能会降低 </strong></span> PrintWriter pw = response.getWriter(); pw.write(id); pw.flush(); event.resume();<span>// 发送结束调用 resume 方法,进入 event 方法,结束请求处理 </span> } catch (Exception e) { e.printStackTrace(); } } } }
使用总结:
- 对于 Servlet 描述的异步服务处理有了较好的支持。
- 事件方法比较丰富,但是对于可定义事件支持不够完善。
- 对象并发控制需要开发者自己设计,权衡多线程处理的高效以及资源争夺的消耗。
下面对异步服务处理 Servlet 和普通 Servlet 做了一下简单的性能测试。
首先我原本想用 ab 来做一下简单的压力测试即可,但是 ab 好像对于 apr 模式下的测试支持的不好,一压就报错(apr_poll: The timeout specified has expired (70007)),也可能是自己不会用吧,因此就自己写了一段测试代码来做测试。
测试场景如下:
两类 Servlet 都可以设置处理时 Hold 的时间,来达到消耗连接数的目的。测试客户端可以设置并发多少用户,每个用户发起多少次请求。下表就是测试的结果:
这里设置的是 Servlet 都 hold1 秒钟,APR 启动时配置的最大连接数为默认的 200 个。
客户端设置 普通 Servlet 总耗时 (ms) 异步 Servlet 总耗时 (ms) 普通 Servlet 单个线程耗时 (ms) 异步 Servlet 单个线程耗时 (ms) 100 并发线程,每个线程执行 1 次请求 263866 274430 2638 2744 300 并发线程,每个线程执行 1 次请求 550718 617082 1835 2056 100 并发线程,每个线程执行 10 次请求 1087747 1207920 10877 12079 300 并发线程,每个线程执行 10 次请求 retrying request,connect reject 5193644 retrying request,connect reject 17312 从上表可以看出,就纯粹从处理效率来说,采用事件处理方式在线程切换过程中存在着一定的损失,但是就我们使用异步请求处理的本意来看,对于在高并发下对后端依赖无法避免的性能损耗情况下,异步请求解决了连接耗尽的问题。
最后再来看我在测试过程中用 JProfiler 来截取的一些线程创建和使用状况:
上图是最初的线程创建情况,还没有任何请求被发送到服务端,因此线程池也没有开任何一个连接。
这是普通的 Servlet 在压力测试下的线程状况,线程就开到了 200 最大值,图中由于程序来 Hold 请求处理线程出现了红色阻塞和黄色等待,同时客户端已经开始出现拒绝连接的错误。下图就是错误的截图:
上图是异步服务处理 Servlet 在压力测试开始的情况,可以发现它的 http 线程还是 200,但是其他事件处理线程在不断增长。下图已经增长到了 3000 多个线程。(这里需要注意的就是这种异步处理资源申请没有设置上限,因此对于资源消耗来说也是比较大的,同时要防范攻击性请求造成服务端垮掉)。
结语
多线程、分布式计算、Erlang 等这些编程方式、框架设计、语言其实都在实现这一个理论,那就是分而治之,多线程是站在单应用的角度去考虑解决方案,分布式计算是在多机协作考虑解决方案,Erlang 在单机多处理器的角度去考虑解决方案。但彼此的理念都是一样,将能够分割的不相关联的独立任务并行处理,最终实现最优化的处理效果。
对于服务集成平台是否采用这种技术,我自己还没有最终的决定,首先就如上面的测试结果来看,有得还是有失的,其次这种并发异步处理带来的多线程维护控制复杂度,也需要考虑到成本中。Jetty 的开发者对于是否将异步服务处理 Servlet 来交由开发者控制而不是容器本身来控制表示出了反对意见,的确,这样复杂的控制交给开发者来处理会增加开发者的学习成本以及维护成本。
作者介绍:岑文初,就职于阿里软件公司研发中心平台一部,任架构师。当前主要工作涉及阿里软件开发平台服务框架(ASF)设计与实现,服务集成平台(SIP)设计与实现。没有什么擅长或者精通,工作到现在唯一提升的就是学习能力和速度。个人 Blog 为: http://blog.csdn.net/cenwenchu79 。
给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。
评论