标签归档:Tomcat

多级反向代理下的维护页面配置

Nginx的error_page指令可以用来在服务器临时维护时呈现静态维护页面,增强用户体验,但是在稍微复杂的反向代理部署架构中,只有error_page还是不够的。比如:
proxy
Tomcat前端接Apache,Apache前端接Nginx,流量入口在Nginx。这样一个两级的反向代理结构下,如果服务器程序升级,Tomcat重启,Apache检测到Tomcat服务不可用时,向上级返回503,那么用户通过Nginx看到的,也是丑陋的503 Service Temporarily Unavailable页面,试图在Nginx配置中加上error_page 503 /50x.html页面也不会起作用。探其原因,在于error_page指令是用来处理由Nginx产生的错误,在Nginx看来,Apache工作正常,且能返回响应码和正确页面,只不过返回的页面对用户来说是个服务不可用页面。如果是Apache停掉,那么Nginx是能够正确呈现error_page指定的维护页面的。

所以,需要另外在Nginx配置中加入proxy_intercept_errors指令,官方说法是:

Determines whether proxied responses with codes greater than or equal to 300 
should be passed to a client or be redirected to nginx for processing with the 
error_page directive.

设置proxy_intercept_errors为on后,Nginx会解析被代理服务器的响应码,如果出现大于300的响应码,就会调用error_page指令处理,如果error_page指令中没有指定返回的错误码,那么Nginx仍会依样返回被代理服务器的响应。

以下为简化的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
server {
 server_name domain.com;
 location / {
  ...
  proxy_pass ...;
  proxy_intercept_errors on;
 } 
 
 error_page 503 /50x.html;
 location = /50x.html {
  root /etc/nginx/maintein;
 }
 #50x.html中引入了同级目录下的static目录中的图片文件。
 location ~ /static {
  root /etc/nginx/maintein;
  rewrite ^(.*)/(static/.*)$ /$2 break;
 }
}

--EOF--

一次Java内存泄露问题排查

近期随着管理平台用户增加,后台多次出现OutOfMemoryError错误,尝试着把堆内存从2G加到4G(-Xms4096m -Xmx4096m)也不顶用,隔段时间就OOM,看来是出现内存泄露了。于是在Tomcat启动脚本中加入JVM参数-XX:+HeapDumpOnOutOfMemoryError,使其OOM时把堆内存dump成文件,便于分析。今天,程序又一次OOM,我拿到了这个4G大的java_pid32693.hprof文件。打开 Eclipse Memory Analyzer(MAT),导入dump文件,内存泄露问题显而易见:
 Eclipse Memory Analyzer

根据提示,有一个ConcurrentHashMap数据结构占用了所有4G内存中的99.11%,该Map名为asyncContentMap,后面是它的package路径。

回忆了一下,这个貌似是很早以前实现过的一个实例状态自动刷新功能,管理平台有一个实例列表页面,当实例状态变化时,无需用户刷新页面,状态栏自动变更。为了前端通用,放弃Websocket、socket.io,用了最简单的ajax实现,浏览器端和管理平台间用long polling实现状态推送。下图就是整个状态自动刷新功能的示意图,管理平台接到请求后,使用Servlet 3.0的异步Servlet挂住浏览器端连接,并将请求上下文(AsyncContext)放入队列。另一方面,通过worker线程池处理请求队列里的请求,worker线程做的事情就是依次轮询后端服务(Service1,Service2...Service N)接口,判断其实例状态有无变化,如果状态变化,则取出前端请求上下文,返回响应。用户就能从浏览器得知实例状态变更,之后,再发起一个HTTP long polling请求,重复上述流程。当然,long polling有个超时时间,由管理平台控制,当对后端服务连续polling了一定时间都没有状态变化时,也要将响应返回给浏览器,使之进入下一个long polling周期。
Long Polling

要实现上述描述的功能,管理平台这边需维护两个状态,分别对应两个线程安全的数据结构。一个是请求队列LinkedBlockingQueue asyncContextList,worker线程池会从请求队列中取出浏览器请求;另一个是映射表ConcurrentHashMap asyncContentMap,用于存放异步请求和改请求附带的一些元信息,比如上一次polling后端Service的时间戳、响应值等等。根据MAT的提示asyncContentMap出现内存泄露,那么可以肯定是有些异步请求已经完成响应,但是忘了从映射表里把记录清除。检查了一下程序,的确如此,加入remove操作后问题解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
AsyncContext asyncContext = Container.asyncContextList.take();
AsyncContentDeliver asyncDeliver = Container.asyncContentMap.get(asyncContext);
 
//略去其余部分
 
if (respEntry.getResponseBody().equals(lastResponseBody)) {
  // 与上一次polling相比,实例状态未发生改变,异步请求重新入队。
  Container.asyncContextList.put(asyncContext);
  continue;
} else {// 实例状态发生了改变,进行响应。
  try {
    asyncContext.getResponse().setCharacterEncoding("UTF-8");
    asyncContext.getResponse().getWriter().print(respEntry.getResponseBody());
    asyncContext.complete();
    Container.asyncContentMap.remove(asyncContext); // BugFix!
  } catch (IOException e) {
    e.printStackTrace();
    Container.asyncContextList.put(asyncContext);
  }
}

总结这次Bug修复,深深感受到一点:编程语言始终解决不了业务逻辑层面上的内存泄露问题,资源的分配和释放程序员必须谨记在心。

--EOF--

Valve对异步Servlet的支持

今天发现的一个问题:上线已久的Tomcat7 + Servlet3.0实现的long polling功能异常。相同的代码本地运行正常,远程debug线上代码时发现程序在执行到
AsyncContext asyncContext = request.startAsync(request, response)时抛IllegalStateException异常:

1
2
3
java.lang.IllegalStateException: Not supported.
at org.apache.catalina.connector.Request.startAsync(Request.java)
......

比对了一下代码仓库中异步Servlet的基本配置,没有可疑的地方,于是基本可以确定是Servlet容器的配置问题。

找来线上Tomcat的server.xml文件,与本地的比对了一下,发现问题所在,线上Tomcat配置中的Context节点多了一条Valve配置:

1
<Valve className="org.jboss.web.rewrite.RewriteValve" />

删掉它或者在Valve节点中加一个asyncSupported="true"的属性:

1
<Valve className="org.jboss.web.rewrite.RewriteValve" asyncSupported="true" />

Tomcat所实现的Servlet3.0规定,请求在到达异步Servlet所在的调用链中,所有valve和filter都要显式声明支持asyncSupported="true",无论通过注解还是XML配置的方式。

其实还有一种比较猥琐的方式,在HttpServletRequest对象中加参数,从而绕过上述的对valve和filter的asyncSupported属性的依赖:

1
request.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", true);

它的副作用就是造成了容器依赖,这段代码只对Tomcat容器有效。

--EOF--

CometD通过Tomcat容器运行

CometD官网一直推荐使用Jetty作为Servlet容器,但是公司项目大多还是使用Tomcat,所以将CometD工程从Jetty移植到Tomcat的问题首当其冲,只有在Tomcat下能稳定运行,才有将此框架纳入项目的可能性。

理论上,CometD(最新2.7版)可以运行在任意支持Servlet2.5和Servlet3.0的容器上,并且无需额外配置。今天通过Maven试了下几个标准CometD工程,配合手册完成了从Jetty到Tomcat的移植。

1. 通过Maven生成标准工程。

1
$ mvn archetype:generate -DarchetypeCatalog=http://cometd.org

目前可选的有cometd-archetype-dojo-jetty7、cometd-archetype-spring-dojo-jetty7、cometd-archetype-dojo-jetty8、cometd-archetype-jquery-jetty7、cometd-archetype-spring-jquery-jetty7和cometd-archetype-jquery-jetty8等几个,试了前三个,默认集成Spring的cometd-archetype-spring-dojo-jetty7移植成功,剩下两个默认存在一些包依赖问题,就以cometd-archetype-spring-dojo-jetty7为例记录下过程。

2.1-1. 首先尝试移植到Tomcat6 (Servlet 2.5)。测试的Tomcat版本是6.0.35。在web.xml文件中添加:

1
2
3
4
5
6
7
8
9
10
<filter>
    <filter-name>continuation</filter-name>
    <filter-class>
        org.eclipse.jetty.continuation.ContinuationFilter
    </filter-class>
</filter>
<filter-mapping>
    <filter-name>continuation</filter-name>
    <url-pattern>/cometd/*</url-pattern>
</filter-mapping>

Servlet 2.5容器不支持异步Servlet,因此CometD的做法是通过Jetty Coninuation API来模拟异步Servlet,ContinuationFilter过滤器就是调用Coninuation API来实现的。如果不添加这个过滤器,启动Tomcat时会报一个IllegalStateException异常:

1
2
3
4
java.lang.IllegalStateException: !(Jetty || Servlet 3.0 || ContinuationFilter)
at org.eclipse.jetty.continuation.ContinuationSupport
    .getContinuation(ContinuationSupport.java:150)
...

2.1-2. Done!

2.2-1. 再尝试移植到Tomcat 7 (Servlet 3)。测试的Tomcat版本是7.0.35。Tomcat 7支持Servlet 3.0,本身就支持异步Servlet,所以CometD直接利用容器的异步特性实现异步响应。先将POM文件(pom.xml)的servlet-api依赖版本替换为3.0:

1
2
3
4
5
6
<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>3.0.1</version>
    <scope>provided</scope>
</dependency>

2.2-2. 修改web.xml文件头,将版本号修改为3.0:

1
2
3
4
5
6
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee 
    http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" 
    version="3.0"
>

2.2-3. 声明CometdServlet为异步Servlet。添加<async-supported>true</async-supported>属性:

1
2
3
4
5
<servlet>
    <servlet-name>cometd</servlet-name>
    <servlet-class>org.cometd.server.CometdServlet</servlet-class>
    <async-supported>true</async-supported>  
</servlet>

另外所有在CometdServlet之前执行的Filter也要添加异步支持,例如:

1
2
3
4
5
<filter>
    <filter-name>cross-origin</filter-name>
    <filter-class>org.eclipse.jetty.servlets.CrossOriginFilter</filter-class>
    <async-supported>true</async-supported> 
</filter>

如果过滤器没有声明<async-supported>true</async-supported>属性,启动Tomcat会抛IllegalStateException异常:

1
2
3
java.lang.IllegalStateException: Not supported.
at org.apache.catalina.connector.Request.startAsync(Request.java:1673)
...

2.2-4. Done!

--EOF--

一种缓存服务器宕机的应对之术

假如有一个认证服务应用,作为基础服务需要尽量保证高可用状态,应用服务器层面已经通过Nginx+keepalived实现主备热切换,但是在内部服务中,由于考虑复杂度等因素,缓存服务器和数据库服务器都是单节点的,也就是说多个认证服务应用(Tomcat)共用一个memcached缓存服务器和数据库服务器。

认证服务器的作用是接收用户请求及附带的key、StringToSign、摘要信息等参数,然后判定此请求是否合法。为了TPS能得到保障,认证服务器会将验证签名用的用户信息缓存在memcached里,避免每次从数据库中读取。

程序的流程可以很简单,分三步走:
1. 从缓存中读取数据。如果读到数据,立即返回。否则,进到第2步。
2. 从数据库中读取数据。
3. 将数据写入缓存。返回。

如果缓存服务器运行正常,这个过程没什么问题。但是一旦单节点的缓存宕掉,而服务的访问量又很高时,上面流程就会出问题了。最坏情况下,三个步骤中有两个步骤要跟缓存打交道。

因为,程序会卡在第一步从缓存读取数据,直到抛出读缓存超时异常,这个时间可短可长,但不管怎样TPS都会急剧恶化。这还没完,程序在第二步从数据库读出数据后,进行了一次写缓存的操作,一样的原因,程序会等到超时后返回。这一来性能还远不如直接从数据库中读取数据。实际上,即使不使用缓存,数据库也能撑住这些查询请求,只是TPS会下降%50左右,不会造成缓存雪崩。

其实经过简单的优化,上述过程就可以变得合理。至少,第二次写缓存操作可以省略。另外,当缓存出问题时,可以认为较短的时间内它都不会恢复。因此,我采用的策略是:

程序中维护一个上次缓存服务器故障时间戳的全局变量,同时指定一个缓存故障恢复期。每次在读写操作缓存前,都判断一下当前时间戳减去上次缓存服务器故障时间戳是否大于缓存故障恢复期,如果小于缓存故障恢复期,则认为当前缓存服务器不可用,程序直接绕过缓存从数据库中读取数据,否则认为缓存已经从故障中恢复,走正常流程。不管缓存服务器多久恢复,程序的消耗仅仅在于每经过一个缓存故障恢复期,都会尝试操作缓存一次。同时,每次捕捉到操作缓存的异常时,都更新一下上次缓存服务器故障时间戳。在我的程序里,设置了缓存故障恢复期为5分钟。基本上,这个处理已经将缓存服务器宕机带来的影响降到最低。

以下是Java代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//上次缓存服务器故障时间戳,默认为0。
public static long LAST_CACHED_EXCEPTION_TIMESTAMP = 0;
//缓存故障恢复时间
public static final long CACHED_EXCEPTION_RETRY_PERIOD = 5 * 60 * 1000;//5分钟
 
try {
    //不在缓存故障恢复期内才操作缓存。
    if (System.currentTimeMillis() - LAST_CACHED_EXCEPTION_TIMESTAMP >=
        CACHED_EXCEPTION_RETRY_PERIOD) {
        // 先尝试从cache中读取信息。
        user = getObjectFromCache(cacheKey);
    }
} catch (Exception e) {
    //更新上次缓存服务器故障时间戳。
    LAST_CACHED_EXCEPTION_TIMESTAMP = System.currentTimeMillis();
    e.printStackTrace();
}
 
if (user != null) {
    // cache中已存在该信息,返回。
    return user;  
} else {
    // cache中不存在该信息,先从数据库读,然后存入cache。
    user = getObjectFromDb();
 
    //不在缓存故障恢复期内才操作缓存。
    if (user != null
      && (System.currentTimeMillis() - LAST_CACHED_EXCEPTION_TIMESTAMP >= 
           CACHED_EXCEPTION_RETRY_PERIOD)) {
        try {
            addToCache(cacheKey, user);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
return credential;
}

--EOF--