1 问题描述:
下面是应用上线后的线程个数的曲线图:
通过观察线程数:从0.2k增加到1.4k,然后突然又变成0.2k。 所有增加的线程均为Damon的线程。
有如下几个疑问:
1:为什么线程数会一直增加,出现了线程泄露?
2:在某个时间点线程数突然降了1.2k,why?
1.1 线程泄露排查
通过jstack打印栈信息,发现大部分的线程描述如下。(省略了部分栈信息)
"MySQL
Statement Cancellation Timer"
#2952daemonin
Object.wait()
java.lang.Thread.State: TIMED_WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
at
java.util.TimerThread.run(Timer.java:505)
通过线程名字可知该线程为mysql的线程(这个也说明了自定义线程的时候,起一个容易识别的名字是多么重要)。
从信息描述可知是JDK的一个Timer线程,并且该线程处于TIMED_WAITING的状态。
疑问:
Statement Cancellation Timer 线程是干嘛的,为什么是一个Timer的线程?
1.2 线程数突降排查
首先去观察当前系统的各种资源消耗(比如 cpu,内存,jvm信息),看能否从中得到一些信息,通过查看GC时发现,YGC的时间点和线程数突降的时间点完全一致。如下图(GC的曲线图)
疑问:
难道YGC会把TIMED_WAITING状态的线程给GC掉? 非常挑战之前对GC的理解。
2 基础知识
2.1 数据库连接池的心跳检测
1:数据连接池是对连接进行管理和维护,保证连接的可用性。为了保证连接的可用性 ,需要对连接进行心跳。
2:心跳一般分为两个层面:tcp层面的心跳,应用层面的心跳。 tcp层面是使用keepalive进行心跳,而应用层面是通过应用层协议进行心跳。
3:有同学会问:TCP有keepalive的心跳机制,为什么还需要上层应用的心跳。(这里笔者推荐使用应用层的心跳来进行保活)
keepalive的局限:
保证TCP层面的保活,如果应用挂掉,但是端口还正常,则连接还是有效。
对于IO应用,如果TCP层保活失败,会关闭掉socket,但是上层应用感知不到,会认为socket还是有效的。
操控性比较弱。
应用层保活优点:
灵活,可操控性强。
可以针对业务特性设置心跳策略。
可以保证连接是有效的。
4:数据库连接池采用的是应用层心跳来进行保活,主要有如下三种心跳方式。
Statement
数据库的查询方式进行心跳。连接池会提供心跳检查的sql配置,比如配置为:select 1 。那么心跳的时候,就会执行statement.execute("select 1") 。
Ping
mysql driver提供了一个ping的方法。mysql协议提供了一个ping的命令(类似于select命令),专门用于心跳检测。
isValid
jdbc提供了isValid接口,mysql driver对该接口实现,是采用mysql原生的ping命令。
对比
statement ping接口 isValid接口
参数 select 1 无参 有timeout参数
性能 低 高 高
接口提供 JDBC原生 mysql driver私有 JDBC4原生
返回值 boolean void boolean
心跳失败 抛SQLException 抛SQLException 返回false
总结:
注意:ping协议是指mysql的协议, ping接口是driver提供的接口内部用来实现ping协议的发送
1:mysql原生ping协议的性能是statement的将近2倍(笔者进行过测试),推荐使用原生的ping协议
2:ping接口并不是JDBC接口,内部实现了发送ping协议的功能。如果出错,会将错误异常抛出
3:isValid的内部实现也是ping方法,只是进行了try catch的封装。如果try通过,则返回true,在catch处返回false,同时会将当前Connection的状态置为false(该处后面会重点讲解)。
2.2 业务超时处理方案
一般访问第三方接口或者可能执行会比较长的接口,一般都会设置一个超时时间,进行容错的处理。
比如:
1:访问http接口,设置超时时间,在规定的时间未返回数据,则会报timeout异常。
2:执行数据库命令,设置超时(调用statement的setQueryTimeout接口),在规定的时间未返回数据,则会报timeout异常。
2.2.1 实现超时控制流程:
1:访问接口前,将当前状态封装为一个定时任务,通过定时框架设定任务的启动时间。
2:收到返回数据后,则会取掉定时任务。
3:如果在规定的时间内,数据还未返回,定时任务便会启动。处理策略:(1)业务处理 (2)抛出timeout的异常。
4:业务处理一般为:记录日志,发送取消命令到服务端(mysql driver便是发送kill query connectionid到数据库端取消执行的sql)。
2.2.2 定时框架
定时框架JDK实现的主要有两种可选择:Timer和ScheduledExecutorService
(1) Timer
主要有两个方法:schedule和scheduleAtFixedRate,其中参数均为:(TimerTask task, long delay, long period) 。
区别: 比如period为5s,schedule是当前任务执行完成后再过5s开始执行下一次。scheduleAtFixedRate为任务间隔为固定的5s,包含任务执行的时间。
主要包含三个组件:
TimerThread:用于运行task的线程
TaskQueue:存放所有的task,该队列会保证存放的task顺序是按照执行时间先后存放
TimerTask:运行的task,是一个Runnable,主要控制任务的状态和执行的时间点。
上图仅大概描述Timer的结构,逻辑并不是很严谨。
1:调用schedule方法进行任务的调度。将任务添加到queue中,queue会对队列进行重排,保证最先执行的任务在队列的最前面。
2:对task进行cancel时,仅仅是设置task的状态为cancel,在thread线程读取queue的时候,如果发现task状态为cancel时,则从queue中移除掉。(很多取消操作均是这样设计)
3:timerThread异步读取queue(任务是按照执行的先后进行顺序存放),如果queue为empty,则wait。否则执行任务。
4:调用Timer的cancel,则会设置一个标记位,TimerThread执行完成(即TimerThread线程的退出)。如果执行任务的时候出现异常,则TimerThread线程也会进行退出(使用Timer一定要注意任务的异常处理)
5:对于?处的信息,后面会详细描述。
(2) ScheduledExecutorService
ScheduledExecutorService 具体原理本文将不做描述(其实定时实现的原理和Timer差不多)。
下面主要描述一下两者的区别:
1:线程模型:Timer是单线程,一个线程轮询执行所有的任务。 ScheduledExecutorService是多线程,其实本质是一个Executor。
2:异常检查:Timer未对运行的任务进行异常检查,如果任务出现异常,Timer线程会运行结束。ScheduledExecutorService会对异常捕获处理
如果需要设置定时任务,笔者这里还是推荐ScheduledExecutorService 。
2.3 Caelus连接池
唯品会平台架构部自研的分布式数据库连接池。主要解决如下问题:
1:高性能:基于无锁的连接池设计模型来提升连接池性能;
2:在分库较多的场景下,减少线程数。 假如有128个分库,现有连接池模型下则需要使用128个独立的连接池,每个连接池都需要线程(1-4个,不同的连接池不同)处理任务。则总共需要维护128到128*4个线程,开销巨大。而Caelus连接池会大大减少线程数。
3:连接复用。 对于 一个mysql 的instance上面有多个schema场景下。现有连接池不同的schema的连接不可复用。而Caelus可以复用不同schema的连接,提升性能。
4:过多的事务指令。如果是事务语句,则从连接池拿到连接后,需要先开启事务(set autocommit=0),归还时需要再设置(set autocommit=1)。每使用一次连接,均需要额外执行两条事务指令。Caelus能有效减少事务指令。
具体Caelus细节在这里先不做描述。
3 线程泄露解析
下面将解析线程不断增加的原因。
3.1 问题描述
1:使用JDBC接口时,如果设置queryTimeout,则driver会启动一个Timer线程,来进行超时控制。Timer线程便是前面通过jstack抓取到一直在增加的线程:MySQL Statement Cancellation Timer 。
2:可以推测应用端肯定设置了queryTimeout。 通过查看用户配置,确定了在每个查询的时候,均设置了queryTimeout。
3:Timer线程是连接维度的。每新建一个连接,如果进行了超时控制,则会新建一个线程;在连接物理关闭时,再将该线程给close掉。(一般系统连接超时控制均是全局的设置,只需要开启一个Timer线程运行超时任务)。mysql driver是每一个连接开启一个Timer线程,效率较低,存在频繁的开启和关闭线程的操作。如果连接数过多,也会带来线程数过多的问题。 此处可以考虑进行优化。
4:连接物理关闭的方式为:调用Connection.close方法,该方法会先判断当前Connection不是close状态,不是close状态,才会cancel掉Timer线程。
既然连接关闭的时候,会将Timer线程给cancel掉,那为什么Timer线程数一直在增长,并且所有的线程都处于等待状态。下面先分析一下那些操作会对连接进行关闭。
3.2 连接关闭的场景
1:连接池一般会设置minIdle,空闲连接超过minIdle的话,则会关闭连接。(关于连接池的配置可参考:http://blog.csdn.net/hetaohappy/article/details/51861015)
2:连接池设置minEvictableIdleTimeMillis,即如果在规定的时间内一直空闲,则会关闭连接。
3:如果访问发现是IO异常,则会关闭掉连接。mysql会设定wait_timeout,也就是mysql对连接的空闲超时时间。如果空闲超时,mysql则会关闭掉连接。 由于访问端是io机制,无法感知socket关闭,所以当拿到该socket进行访问时,便会报IO的异常。
前面两个场景并未复现bug;第三个场景可以复现bug。 复现的流程为;
1:新建连接,设置queryTimeout,然后访问数据库。
2:在数据库端把对应的连接给kill掉(类似空闲超时的处理)
3: 客户端使用该连接再进行访问。发现多了一个Timer的线程。
3.3 原因分析
执行业务sql之前,会先进行心跳检查。心跳采用的是isValid接口(具体可参考前面描述) ,isValid方法先进行心跳,发现心跳失败,会将Connection的连接状态置为close。
连接池发现心跳失败,则会调用Connection的close接口,由于之前已经将Connection状态设置为close 。close接口发现连接状态已经是close,则直接返回。导致没有cancel 掉Timer线程。
现有连接池排查:
连接池 是否有线程泄露
DBCP 有
Druid 无
HicariCP 有
Caelus 有
有线程泄露的连接池,均是使用了isValid来进行心跳检查。而Druid是采用的mysql driver的ping方法,所以没有问题。这个可以更改为和Druid一样的机制(ping方法)进行心跳检查。
4 线程数突降解析
在YGC的时候,线程数突然下降很多。理论上GC和线程是没有关系,应该是YGC触发了什么条件,然后该条件将线程给关闭了。
4.1 Timer线程关闭场景
1:调用Timer的cancel方法;
2:任务运行异常(Timer未对异常做封装处理)。
通过排查确定YGC和上述两个场景没有关闭。
4.2 原因分析
先分析一下GC和应用主要有哪些交互的场景:
1:weak引用:GC后,会将weak引用进行回收掉;
2:finalize:GC的时候会调用需要被GC对象的finalize 方法。
通过分析Timer源码,未发现有weak引用,却发现Timer有一个 threadReaper 属性实现了finalize 方法,该方法便是操作让Timer线程运行结束(也就是关闭)。这里也就清楚为什么YGC的时候,线程数会跟着下降。(可见JDK源码对各种异常情况考虑的确实很多,研究JDK源码的设计思想,能给我们写代码带来很大的借鉴)
YGC导致线程关闭的流程:
YGC-->回收Connection对象->回收Timer对象-->回收threadReaper 对象->执行threadReaper的finalize 方法->设定线程结束状态,并进行notify->Timer线程运行结束。
5 总结
问题出现在两个地方:
1:mysql driver的isValid方法存在bug,导致Timer线程未关闭。
2:连接池选择了isValid方法进行心跳处理,可以考虑使用mysql driver的ping方法。
该问题也是可以通过配置进行避免的。