山洪暴发,高流量触发Tomcat bug引起集群崩溃

本文摘自:逆流而上:阿里巴巴技术成长之路

2.4 山洪暴发,高流量触发Tomcat bug引起集群崩溃
背景

中间件M应用在2013年开始采用Async Servlet+HSF Callback调用模型,以提高其性能,从而获得较好的效果。当时Tomcat的连接协议,采用APR模式,但2013年双11前,发现APR的Tcnative存在一个导致JVM Crash的bug,提交了bug给官方。

在2014年阿里巴巴集团容器团队升级Tomcat 7.0.26到7.0.54,同时经过压测比对和对Tcnative的维护成本等考虑,决定替换APR,采用NIO模式,2015年第一季度,M应用线上机器全部升级到Tomcat7.0.54,同时采用NIO。

2015年x月x日,网络上做了个变更,引起大量用户请求重试,形成了几倍的流量高峰,高流量下触发了Tomcat的bug,导致了整个应用集群崩溃。

NIO模式背景介绍

Tomcat共有三种连接器模式,BIO/NIO/APR/NIO2,其中NIO是异步IO模式的简称。在默认的配置下,NIO的模式的实现模型如下。

Acceptor线程:全局唯一,负责接受请求,并将请求放入Poller线程的事件队列。Accetpr线程在分发事件的时候,采用Round Robin的方式来分发。

Poller线程:官方的建议是每个处理器配一个,但不要超过两个,由于现在几乎都是多核处理器,所以一般来说都是两个。每个Poller线程各自维护一个事件队列(无上限),它的职责是从事件队列里面拿出socket,往自己的selector上注册,然后等待selector选择读写事件,并交给SocketProcessor线程去实际处理请求。

SocketProcessor线程:Ali-tomcat的默认配置是250(参见server.xml里面的maxThreads),它是实际的工作线程,用于处理请求。

一个典型的请求处理过程

如图2-23所示,是一个典型的请求处理过程。其中深色框代表线程,无填充色框代表数据。

图2-23 一个典型的请求处理过程
图 2-23 一个典型的请求处理过程

Acceptor线程接受请求,从socketCache里面拿出socket对象(没有的话会创建,缓存的目的是避免对象创建的开销),Acceptor线程标记好Poller对象,组装成PollerEvent,放入该Poller对象的PollerEvent队列,Poller线程从事件队列里面拿出PollerEvent,将其中的socket注册到自身的selector上,Poller线程等到有读写事件发生时,分发给SocketProcessor线程去实际处理请求,SocketProcessor线程处理完请求,socket对象被回收,放入socketCache。

原因定位

本次问题的触发,是瞬间大流量上来,Tomcat直接处于假死状态,不再处理请求,请求失败引起了大量的用户重试(这也是常规表现),问题表现是高QPS流量压力下,M应用机器无法正常服务。

当时,开发人员首先想到在茫茫日志当中,grep一个ConcurrentModificationException的异常,果然发现这个异常信息:

Exception in thread "http-nio-7001-ClientPoller-1" java.util.ConcurrentModificationException

at java.util.HashMap$HashIterator.nextEntry(HashMap. java:793)

at java.util.HashMap$KeyIterator.next(HashMap.java: 828)

at java.util.Collections$UnmodifiableCollection$1. next(Collections.java:1010)

at org.apache.tomcat.util.net.NioEndpoint$Poller. timeout(NioEndpoint.java:1421)

at org.apache.tomcat.util.net.NioEndpoint$Poller. run(NioEndpoint.java:1215)

at java.lang.Thread.run(Thread.java:662)

技术人员都很熟悉这个异常,并发操作HashMap的错误,因为之前已经发现过这个问题,当时也向Tomcat提交了bug,只是排查了很久没有定位到原因,连Tomcat的大神Mark Thomas也仔细分析过,当时准备的解决方案是先捕获这个异常(参考资料:https://bz.apache.org/bugzilla/ show_bug.cgi?id=57943),报了这个异常后的表现还是非常清晰的。

这个异常没有被捕获,Tomcat NIO的Poller进程会退出,熟悉Tomcat的人都知道(如上介绍Tomcat NIO的原理),Tomcat有2个Poller线程,请求被Accept线程接受后,会被分配到这两个Poller线程进行NIO的监听,一个线程退出了,就形成了个黑洞,那个存活的Poller线程可以正常处理业务,而分配到挂掉Poller线程的连接请求都无法处理。

另外Tomcat有个连接计数器,maxConnections, NIO下默认是10000,因为挂掉的Poller线程上的连接请求一直不处理,所以随着时间累计,分配到这个线程上的连接越多(正常的Poller处理完连接后,每次都有50%连接被分配到挂掉的Poller线程,挂掉的Poller线程成为黑洞,慢慢吸收连接过去),直到挂掉的Poller线程占满10000个连接,Accept线程发现连接已满,拒绝接受新的请求,Accept线程进入locksupport.park状态,因Accept线程停止工作,TCP全连接队列满,再引起半连接队列满,最后TCP不再响应TCP连接。开发人员看到的就是TCP握手发数据包无响应,M应用彻底停止服务。

细节分析

现在的问题很清晰,为什么会有并发操作HashMap的问题,而且这是已知的问题,只是一直没有进展,是否捕获这个异常后就全部正常呢?开发人员与Tomcat团队一起进行了深入的分析。

根据异常堆栈,可以知道,Poller1线程在处理超时的时候,会遍历selector上的keys,然后在这个遍历的过程中,另外一个线程修改了keys的数据,导致抛出异常。大致代码如下:

protected void timeout(int keyCount, boolean hasEvents) {

// 超时

Set keys = selector.keys();

for (Iterator iter = keys.iterator(); iter.hasNext();) {

SelectionKey key = iter.next();

}

代码分析看不出端倪,所有对Selector的操作都是在ClientPoller的线程上做的修改,而每个ClientPoller在初始化的过程中各自持有自己的Selector,设计上决定了不应该有多线程的问题。那么到底是谁在修改底层的keys呢?

由于在Tomcat源码中看不出问题所在,开发人员决定在JDK层面打patch,在JDK层面,所有可能修改keys的地方只有两处,sun.nio.ch.EpollSelectorImpl的implRegister方法以及implDereg方法(注意,不同操作系统下的实现类可能不一样,在linux操作系统下是sun.nio.ch.EpollSelectorImpl)。

开发人员下载了openjdk源码,在sun.nio.ch.EpollSelectorImpl的implRegister方法以及implDereg方法中打印相关信息,并通过以下的方式覆盖JDK的原有实现:

JAVA_OPTS="$JAVA_OPTS -Xbootclasspath/p:/var/tmp/ selector-patch.jar"

不幸的是,加上patch之后问题无法重现,看来输出debug信息本身拖慢了线程的执行,导致本来就很小的时间窗口稍纵即逝。不断缩减输出内容也没有任何重现的迹象,怀疑是输出日志的开销影响了问题的复现,当时打的日志比较多,每个日志比较详细导致信息输出量较大,因此不断重试,不断缩减日志,这是磨练耐心的过程,在不断重试过程当中,终于发现了一个信号:

http-nio-7001-ClientPoller-1> sun.nio.ch.EPollSelectorImpl@cc98f2c

http-nio-7001-ClientPoller-1> sun.nio.ch.EPollSelectorImpl@5787ae61

http-nio-7001-ClientPoller-0> sun.nio.ch.EPollSelectorImpl@cc98f2c

看这输出的日志信息,同一个Selector实例被两个ClientPoller线程上引用,这就很可能是并发的问题。

socket.getIOChannel().register(socket.getPoller().getSelector(), SelectionKey.OP_READ, key);

对于这个现象唯一可能的解释是,当ClientPoller调用这行代码的时候,只有当socket.getPoller().getSelector()获得的Selector实例和当前ClientPoller所持有的实例不一致,才有可能造成前面看到的ClientPoller会操作不同Selector实例的现象。

也就是说,socket.getPoller()拿到的Poller的实例,不是自己。

这怎么可能!因为socket所代表的数据结构PollerEvent是放在ClientPoller自身队列里,并且放进来的时候PollerEvent上的Poller实例会被置成当前的ClientPoller。

public void register(final NioChannel socket) {

socket.setPoller(this);

addEvent(r);

}

那为什么当ClientPoller线程再从socket.getPoller()得到的Poller实例不是自己?为了验证这一个看似不可能的猜想,加入了以下的调试信息来观察当前线程ClientPoller和socket所持有的Poller的关系。

if (socket.getPoller() != this) {

System.out.println(">>> t: " + Thread.currentThread().getName() + ", s: " + socket.getPoller().getName() + ", " + socket + ", ts: " + System.currentTimeMillis());

}

socket.getIOChannel().register(socket.getPoller().getSelector(), SelectionKey.OP_READ, key);

果然有收获。这说明了socket所持有的Poller在某些条件下不一定和当前的ClientPoller线程一致。

t: http-nio-7001-ClientPoller-1, s: http-nio-7001-ClientPoller-0

t: http-nio-7001-ClientPoller-0, s: http-nio-7001-ClientPoller-1

t: http-nio-7001-ClientPoller-1, s: http-nio-7001-ClientPoller-0

进一步的调试发现,当Poller线程从自身的事件队列中拿出socket对象的时候,socket.getPoller拿到的实例就已经错乱了,但是Acceptor线程放入事件队列之前,socket.getPoller拿到的实例是没有错乱的。那么可以肯定的是,socket.getPoller拿到的对象在进入事件队列之后,被修改了。

为什么会被修改呢?出现这个情况只有两种可能:

socket对象本身被修改了。

socket里面的Poller对象被修改了。

socket对象封装在PollerEvent对象中,如果是socket对象本身被修改,那么只有一个入口,就是PollerEvent的reset方法,在reset方法里面加入调试信息,却没有任何发现。这个方法在socket对象进入队列后根本没有调用过。

如果socket对象没被改变,那唯一的可能性就是socket.getPoller的对象会发生变化。也就是说同一个socket有可能会被修改两次,而且还是来自两个不同的Poller线程。

通过对ClientPoller上游的分析,发现Tomcat是这么工作的,当请求进来的时候,有一个Acceptor线程负责建立连接,然后递交请求给后面的两个ClientPoller线程来处理。

protected boolean setSocketOptions(SocketChannel socket) {

// 对连接进行处理

try {

// 设置非阻塞,同APR处理模式,采用poll模式

socket.configureBlocking(false);

Socket sock = socket.socket();

socketProperties.setProperties(sock);

NioChannel channel = nioChannels.poll();

if ( channel == null ) {

// 加密SSL连接channel构造

if (sslContext != null) {

channel = new SecureNioChannel(socket, engine, bufhandler, selectorPool);

} else {

// 普通连接channel构造

channel = new NioChannel(socket, bufhandler, Thread.currentThread());

}

} else {

channel.setIOChannel(socket);

if ( channel instanceof SecureNioChannel ) {

SSLEngine engine = createSSLEngine();

((SecureNioChannel)channel).reset(engine);

} else {

channel.reset();

}

}

getPoller0().register(channel);

} catch (Throwable t) {

// 通知上层关闭socket

return false;

}

return true;

}

为了避免垃圾回收的压力,channel并不是每次都创建,而会从一个nioChannels的缓存队列中获取,并在使用完毕后放回该缓存队列。在排除掉nioChannels的数据结构(ConcurrentLinkedQueue)本身的并发问题之后,唯一的可能性就指向了:在某种情况下这个缓存队列中,同一个channel对象在队列中存在多份。

按照这个思路,在nioChannels.offer以及nioChannels.poll之前判断队列中是否已经存在要还回的channel实例,或者有要还回的channel实例,如果有,就打印出以下信息。输出的结果证明了猜想是正确的,这是一个重大的进展。

poll but still found in queue, org.apache.tomcat.util.net.NioChannel@28baf3bf:java.nio.channels.SocketChannel[closed], ts: 1435720813750

offer but already found in queue, org.apache.tomcat.util.net.NioChannel@28baf3bf:java.nio.channels.SocketChannel[closed], ts: 1435720812885

但是这挡不住技术人员刨根问底的好奇心,我们还想知道,socket对象到底在什么地方被重复放了两次,以及有哪些线程会通过nioChannels.offer还回socket对象。由于nioChannel.offer是发生在SocketProcessor的run方法里,并且run方法的总入口是:

public boolean processSocket(NioChannel socket, SocketStatus status, boolean dispatch, int where) {

SocketProcessor sc = processorCache.poll();

if ( sc == null ) sc = new SocketProcessor(socket,status, where);

else sc.reset(socket,status, where);

if ( dispatch && getExecutor()!=null ) getExecutor().execute(sc);

else sc.run();

}

通过改造代码打标(where)并输出当前执行线程,最终得到以下的日志:

thread: Thread[http-nio-7001-ClientPoller-1,5,main], key: , ka: null, where: 6

thread: Thread[http-nio-7001-ClientPoller-1,5,main], key: sun.nio.ch.SelectionKeyImpl@7da286bb, ka: null, where: 1

thread: Thread[HSF-CallBack-11-thread-2,10,main], key: sun.nio.ch.SelectionKeyImpl@7963cb1e, ka: null, where: 10

其中标记1是Poller#processKey,标记2是Poller#timeout,标记3是Http11NioProcessor#actionInternal(注意,这里的线程是HSF-CallBack线程,不是http-nio-7001-ClientPoller线程)。其中唯一有可能并发的地方就是HSF-Callback线程与http-nio-7001-ClientPoller线程在交还nioChannel的时候并发,并导致错误。通过阅读代码,发现HSF-CallBack线程这个时候在做Async Complete的操作。

解决方案

在Tomcat 7.0.58版本以后,官方针对一个类似的对象池污染的问题(Bug57340)做过一个修复。大致的逻辑是通过原子操作来保证多个线程并发的时候最终一个socket对象被offer进入缓存队列。事例代码如下:

public void run() {

SelectionKey key = socket.getIOChannel().keyFor(

socket.getPoller().getSelector());

doRun(key, ka);

}

private void doRun(SelectionKey key, KeyAttachment ka) {

int handshake = -1;

if (key != null) {

handshake = …

}

if (handshake == 0) {

// 官方针对bug57340的修复在这里

} else if (handshake == -1 ) {

if (key != null) {

socket.getPoller().cancelledKey(key, SocketStatus.DISCONNECT, false);

}

nioChannels.offer(socket);

socket = null;

if ( ka!=null ) keyCache.offer(ka);

ka = null;

}

}

虽然说升级到7.0.59后并没有出问题,但是分析发现,在else if (handshake == -1)分支中,也有可能出现重复放入的情况:

  1. thread: Thread[http-nio-7001-ClientPoller-0,5,main], key: , ka: null, where: 1

出现这个情况也是可以理解的。handshake默认值是-1,其中的一种可能性就是入参的key为空,很显然,代码没有考虑到处理这种情况。当key为空的时候,从run方法的第一行代码可以知道,这个时候socket已经被其他线程处理过了。

也就是说,官方在7.0.58上提供的Fix并不能完整的修复这个问题。

为了避免多线程不往同一个队列里重复放同一个对象,有以下几个方案:

(1)在队列放入队列之前检查是否有重复,如果有重复,就不放入,优点是实现简单明了,缺点是必须要加锁,这会极大降低并发度。

(2)在多线程放入队列之前,保证只有一处地方放进去了。好处是,缓存队列还是无锁设计,不会降低并发度。缺点是开发难度比较高,必须要保证所有可能涉及的地方都要修改。

(3)放弃缓存,不再重用对象。这样能根本解决这个问题,但是缺点也是显而易见的,会带来极大的GC开销。

官方在7.0.58上的做法,实际上是方法二,但是正是因为采用这种方法,导致考虑并不周全,造成遗漏。

对于遗漏的地方,团队内部采取的办法是方法二和方法三中间的折中方案,即在key不等于null的时候,再放回缓存队列,对于返回值为null的情况下,不再放入缓存,而是直接丢弃。这是一种在重用和垃圾回收开销之间的权衡方案。

然而到这一步真的就完了吗?并没有!

代码里面不仅仅有nioChannels这个缓存队列,还有以下其他几个缓存队列:

keyCahce

processorCache

eventCache

那么这几个队列里面有没有可能同样有对象重复放置的问题呢?

答案是有的。keyCache就有这个可能。原因很简单,它的放回逻辑跟nioChannels完全一致。所以keyCache的放回逻辑也应该控制起来,避免重复offer。

最终的修复方案如下:

}

}

} else if (handshake == -1 ) {

  • // 如果key为null,则直接丢弃socket,否则我们需要在很多地方加锁控制保障线程安全
  • // 同时还需要在if的条件当中有插入缓存队列逻辑,重用和性能间权衡吧

if (key != null) {

  • socket.getPoller().cancelledKey(key, SocketStatus.DISCONNECT, false);
  • if (socket.getPoller().cancelledKey(key, SocketStatus.DISCONNECT, false) != null) {
  • nioChannels.offer(socket);
  • if (ka != null) keyCache.offer(ka);
  • }

}

  • nioChannels.offer(socket);

socket = null;

  • if ( ka!=null ) keyCache.offer(ka);

ka = null;

} else {

ka.getPoller().add(socket, handshake);

}

经过验证,问题已经不再发生,开发人员逐步发布上线,同时也向Tomcat提交反馈patch,很快官方也修复了这问题。

小结

(1)依据墨菲定律,凡是可能出错的事有很大几率会出错,本文案例表现为在Tomcat切换NIO后一个月已经发现个别机器发生错误,而且开发人员也进行了排查,也有临时解决方案在灰度当中。但是一次网络变更后大范围地触发了这个问题,所以任何的细小问题都不可以忽视。

(2)既然使用Tomcat,那就要对其实现原理有比较清晰的了解,出现问题不能排除任何可能,包括Tomcat、JVM、Linux等处理请求的完整链路,从网络、硬件、内核、软件整体去排查分析。

(3)大集群带来了很多优势,但随着流量的上涨,也形成了一个巨大的单点,后续应用的去中心化方案实施落地,也是针对这个点展开。

(4)高并发的问题的复杂性导致排查难度很大,但没有捷径可走,更需要耐心、细心。本文一些方法可供参考。

发表评论