TT Lab
开始
学习 学习路径 课程

堆还有空间,服务却停了

服务会卡在最小的那个池上

在 TT Lab 中继续学习

一句话总结

服务会在最小的池处停住。即使请求处理线程有 200 个,如果数据库连接池只有 10 个、而且等待没有上限,一条慢查询就会让 190 个线程排在连接前面。池的大小和等待时间上限必须成对确定。

为什么需要它

上一个模块的事件是锁。在现场更常见的是池耗尽。数据库暂时变慢了。10 个连接池连接全被慢查询占住。后面的请求会无限期地等待连接。200 个请求处理线程全都进入这种等待之后,连处理健康检查请求的线程都没有了。即使数据库 1 分钟后恢复正常,在积压的请求全部排空之前,服务都是死的。负载均衡器在这期间会把这个实例摘掉,剩下的实例负载激增,同样的事情就会蔓延开来。问题不在池的大小,而在等待没有上限。

工作原理

Tomcat 的 HTTP 连接器文档用三个数字说明了请求进来的路径。maxThreads——请求处理线程的最大数量,也就是可同时处理的请求数,默认 200。maxConnections——服务器可以接受并以处理中状态持有的连接数;达到这个数量后,连接会被接受但不处理,而是等待。acceptCount——在 maxConnections 被占满时,操作系统在队列中积压的连接请求数,默认 100;如果连这个队列也满了,操作系统就会拒绝连接或使其超时。也就是说,请求按线程 → 连接 → 操作系统队列的顺序被推后。minSpareThreads 是始终保持存活的线程数,默认 10。connectionTimeout 是在接受连接之后等待请求行(request line)到达的毫秒数,文档默认值是 60000,而发行版的 server.xml 写的是 20000。keepAliveTimeout 是等待下一个请求的时间,如果不指定,就沿用 connectionTimeout。如果通过 executor 属性给连接器关联共享的 <Executor>,这些线程属性就会被忽略,使用的是 Executor 的值(tc-threadpool 实验走的就是这条路)。

线程名称是诊断的钥匙。连接器内部池的线程命名为 http-nio-8080-exec-N,在线程转储中统计这个前缀,就能立刻看出现在有多少个线程在运行,其中有几个在哪里等待。

数据库连接池以 JNDI 数据源文档中 DBCP 2 的示例为基准。在 context.xml 的 <Resource type="javax.sql.DataSource" …> 中设置 maxTotal(池的最大连接数,-1 表示无限制)、maxIdle(保持空闲的最大数量),以及 maxWaitMillis——等待连接空出来的最大毫秒数,超过就抛出异常,-1 表示无限期等待。事件的原因正是这个 -1。如果是 maxWaitMillis="2000",2 秒之后就会抛出异常,应用程序可以把它转换为 503,让线程得以返回。

用 Java 代码来看同样的原理,Semaphore(N) 就是连接池。acquire() 无限期等待,tryAcquire(timeout, unit) 则设置上限。在转储中,acquire() 的等待显示为 parking to wait for <…> (a java.util.concurrent.Semaphore$FairSync)。实验中的 PoolDemo 原样制造了这种形态——4 个处理线程、2 个连接、查询 3 秒。同时发送 6 个请求,2 个在工作,其余的排在连接前面。如果指定 -Ddb.pool.timeout.ms=500,500ms 之后就会返回 503。

把管理端口放在另一个线程上,也是一种设计。PoolDemo 的 /stats 由 8087 端口上单独的线程应答,所以即使处理线程全被堵住,也能读到状态。在 Tomcat 中设置 JMX 或单独的连接器,理由也一样——给停住的服务留一条可以询问的路。

运行多套 Tomcat 的惯例,是简介文档中的 CATALINA_HOME 和 CATALINA_BASE。HOME 是安装目录(bin、lib),BASE 是每个实例各自的配置、日志和 Web 应用(conf、logs、temp、webapps、work)。修改配置时不动安装目录,只新建 BASE 来启动,就能把实验用的实例与生产配置隔离开。实验中就是这样启动的。

在现场相遇的样子

最常见的应对是只把大小调大。把 maxThreads 从 200 调到 800,排在连接前面的线程就变成 800 个——停得更久、更严重。池只设成下游(数据库)能承受的大小,并为等待设置上限、让请求快速失败,才是答案。第二种是健康检查经过数据库。数据库一变慢,健康检查也会失败,连正常的实例也被摘掉。健康检查必须走不触碰池的路径。第三种是只在一层设置超时。连接等待、查询执行、HTTP 客户端、负载均衡器——每一层都要有上限,而且外层必须比内层长。

下一项实验要做什么

启动 PoolDemo.java,用并发请求把连接池榨干,并在转储中确认 Semaphore 的等待,然后用 -Ddb.pool.timeout.ms=500 重新启动,看到 503 被返回。接着在 /root/jvm/pool/tc 新建一个 CATALINA_BASE,在 server.xml 的 8080 连接器中设置 maxThreads、acceptCount、connectionTimeout,在 context.xml 中加入带有 maxTotal、maxIdle、maxWaitMillis 的 DataSource,再真正启动这个实例,并在转储中统计 http-nio-8080-exec- 线程。