本文针对当今 webapp 中一种常碰到的问题,介绍相应的性能优化解决方案。
如今的WEB程序不再只是被动地等待浏览器的请求, 他们之间也会互相进行通信。 典型的场景包括 在线聊天, 实时拍卖等 —— 后台程序大部分时间与浏览器的连接处于空闲状态, 并等待某个事件被触发。
这些应用引发了一类新的问题,特别是在负载较高的情况下。引发的状况包括线程饥饿, 影响用户体验、请求超时等问题。
基于这类应用在高负载下的实践, 我会介绍一种简单的解决方案。在 Servlet 3.0成为主流以后, 这是一种真正简单、标准化并且十分优雅的解决方案。
在演示具体的解决方案前,我们先了解到底发生了什么问题。请看代码:
@WebServlet(urlPatterns = "/BlockingServlet") public class BlockingServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) { waitForData(); writeResponse(response, "OK"); } public static void waitForData() { try { Thread.sleep(ThreadLocalRandom.current().nextInt(2000)); } catch (InterruptedException e) { e.printStackTrace(); } } }
此 servlet 所代表的情景如下:
· 每2秒会有某些事件发生, 例如, 报价信息更新, 聊天信息抵达等。
· 终端用户请求对某些特定事件进行监听。
· 线程暂时被阻塞, 直到收到下一次事件。
· 接收到事件时, 处理响应信息并发送给客户端
下面解释一下这个等待场景。 我们的系统, 每2秒触发一次外部事件。当收到用户请求时, 需要等待一段时间,大约是 0 到 2000 毫秒之间, 直到下一次事件发生. 为了演示的需要, 此处通过调用 Thread.sleep() 来模拟随机的等待时间。平均每个请求等待1秒左右。
现在,你可能会觉得这是一个十分普通的servlet。在多数情况下,确实是这样 —— 代码并没有错误, 但如果系统面临大量的并发负载时就会力不从心了。
为了模拟这种负载,我用 JMeter 创建了一个简单的测试, 启动 2000 个线程, 每个线程执行 10 次请求来进行系统压力测试。
请求的URI为 /BlockedServlet, 部署在 Tomcat 8.0.30 默认配置下, 测试结果如下:
· 平均响应时间: 9,492 ms
· 最小响应时间: 205 ms
· 最大响应时间: 11,368 ms
· 吞吐量: 195 个请求/秒
Tomcat 默认配置的是 200个 worker 线程, 再加上模拟的工作量(平均线程休眠 1000 ms ), 很好地解释了吞吐量数据 - 200 个线程每秒应该能够完成200次执行周期, 平均1秒钟左右. 但有一些上下文切换的成本, 所以吞吐量为 195个请求/秒, 很符合我们的预期。
对 99.9% 的应用来说, 这个吞吐量数据看上去也很正常。但看看最大响应时间, 以及平均响应时间, 就会发现问题实在是太严重了。 在最坏情况下客户端居然需要11秒才能得到响应, 而预期是2秒,这对用户来说一点都不友好。
下面我们看另一种实现, 使用了 Servlet 3.0 的异步特性:
@WebServlet(asyncSupported = true, value = "/AsyncServlet") public class AsyncServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { addToWaitingList(request.startAsync()); } private static ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1); static { executorService.scheduleAtFixedRate(AsyncServlet::newEvent, 0, 2, TimeUnit.SECONDS); } private static void newEvent() { ArrayList clients = new ArrayList<>(queue.size()); queue.drainTo(clients); clients.parallelStream().forEach((AsyncContext ac) -> { ServletUtil.writeResponse(ac.getResponse(), "OK"); ac.complete(); }); } private static final BlockingQueue queue = new ArrayBlockingQueue<>(20000); public static void addToWaitingList(AsyncContext c) { queue.add(c); } }
上面的代码稍微有一点复杂, 所以我先透露一下此方案的性能表现: 响应延迟(latency)只有原来的1/5; 而吞吐量(throughput-wise)也提升了 5 倍。 看到这样的结果, 你肯定想深入了解第二种方案了吧。
servlet 的 doGet 方法看起来很简单。有两个地方值得提一下:
一是声明 servlet,以及支持异步方法调用:
@WebServlet(asyncSupported = true, value = "/AsyncServlet")
二是方法 addToWaitingList 中的细节:
public static void addToWaitingList(AsyncContext c) { queue.add(c); }
在其中, 整个请求的处理只有一行代码,将 AsyncContext 实例加入队列中。 AsyncContext 里含有容器提供的 request 和 response 对象, 我们可以通过他们来响应用户请求. 因此传入的请求在等待通知 —— 可能是监视的拍卖组中的报价更新事件, 或者是下一条群聊消息。这里需要注意的是, 将 AsyncContext 加入队列以后, servlet 容器的线程就完成了 ·doGet· 操作, 然后释放出来, 可以去接受另一个新请求了。
现在, 系统通知每2秒到达一次, 当然这部分我们通过 static 块中的调度事件实现了, 每2秒会执行一次 newEvent 方法. 当通知到来时, 队列中所有在等待的请求都由同一个 worker 线程负责处理并发送响应消息。 这次的代码, 没有阻塞几百个线程来等待外部事件通知, 而是用更简洁明了的方法来实现了, 把感兴趣的请求放在一个group中, 由单个线程进行批量处理。
结果不用说, 同样的配置,同样的测试, Tomcat 8.0.30 服务器跑出了以下结果:
· 平均响应时间: 1,875 ms
· 最小响应时间: 356 ms
· 最大响应时间: 2,326 ms
· 吞吐量: 939 个请求/秒
虽然示例是手工构造的, 但类似的性能提升在现实世界中却是很普遍的。
现在, 请不要急着去将所有的 servlet 重构为异步servlet。 因为这种方案, 只在满足某些特征的任务才会得到大量性能提升, 比如聊天室, 或者拍卖价格提醒之类的。 而对于需要请求底层数据库之类的操作, 很可能没有性能提升。 所以,就像以前一样, 我必须重申, 我最喜欢的性能优化忠告 —— 请权衡考虑整件事情,不要想当然。
但如果确实符合此方案适应的情景, 那我就恭喜你啦! 不仅能明显改进吞吐量和延迟, 还能在大量的并发压力下表现出色, 避免可能的线程饥饿问题。
另一个重要信息是 —— 异步请求的处理终于标准化了。兼容 Servlet 3.0 的应用服务器 —— 比如 Tomcat 7+, JBoss 6 或者 Jetty 8+ —— 都支持这种方案. 再也不用陷进那些耦合具体平台的解决方案里, 例如 Weblogic FutureResponseServlet。