本篇文章记录了一次接口慢查问题排查过程,该问题产生的现象迷惑性较高。同时因为问题偶发性高,排查难度也比较大。排查过程从 druid 数据源“致使”的一个慢查现象做为切入点,逐步分析,排除诸多可能性后仍无解。以后从新审视故障现象,换个角度分析,找到了问题根因。最后对问题缘由进行了验证确认,结果符合预期。到此,排查过程算是结束了,本文对问题进行记录归档。java
前段时间收到业务同事反馈,说他们应用的某台机器连续两天多个接口出现了两次慢查状况(偶发性较高)。但持续时间比较短,但很快又恢复了。Pinpoint 调用链信息以下:git
图1:长 RT 接口调用链信息github
上图是业务同窗反馈过来的信息,能够看出从 MyBatis 的 SqlSessionTemplate#selectList 方法到 MySQL 的 ConnectionImpl#prepareStatement 方法之间出现了 2111 毫秒的间隔,正是这个间隔致使了接口 RT 升高。接下来针对这个现象进行分析。redis
从全链路追踪系统给出的链路信息来看,问题的缘由彷佛很明显,就是 selectList 和 prepareStatement 之间存在着长耗时的操做。理论上只要查出哪里出现了长耗时操做,以及为何发生,问题就解决了。因而先无论三七二十一,直接分析 MyBatis 的源码吧。数据库
public class SimpleExecutor extends BaseExecutor { public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException { Statement stmt = null; try { Configuration configuration = ms.getConfiguration(); StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql); stmt = prepareStatement(handler, ms.getStatementLog()); // 预编译 return handler.<E>query(stmt, resultHandler); } finally { closeStatement(stmt); } } private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException { Statement stmt; Connection connection = getConnection(statementLog); // 获取数据库链接 stmt = handler.prepare(connection, transaction.getTimeout()); // 执行预编译 handler.parameterize(stmt); return stmt; } }
MyBatis 的 SqlSessionTemplate#selectList 方法的调用链比较长,这里就不一一分析,感兴趣能够看一下我三年前的文章 MyBatis 源码分析 - SQL 的执行过程。SqlSessionTemplate#selectList 最终会执行到 SimpleExecutor#doQuery,该方法在执行后续逻辑前,会先调用 SimpleExecutor#prepareStatement 进行预编译。prepareStatement 方法涉及到两个外部操做,一个是获取数据库链接,另外一个是执行调用 MySQL 驱动相关方法执行预编译。apache
从图1的监控上看,预编译速度很快,能够肯定预编译没有问题。如今,把注意力移到 getConnection 方法上,这个方法最终会向底层的 druid 数据源申请数据库链接。druid 采用的是生产者消费者模型来维护链接池,更详细的介绍参考个人另外一篇文章。若是链接池中没有可用链接,这个时候业务线程就会等待 druid 生产者建立链接。若是生产者建立链接速度比较慢,或者活跃链接数达到了最大值,此时业务线程就必须等待了。好在业务应用接了 druid 监控,咱们能够经过监控了解链接池的状况。数组
图2:druid 监控图缓存
上图是用 excel 绘制的,数据通过编辑,与当时状况存在必定的误差,但不影响后续的分析。从监控上来看,链接池中的空闲链接所有被借出去了,但仍然不够,因而生产者不停的建立链接。这里引起了咱们的思考,为何活跃链接数会忽然上升这么多?多是出现了慢查。与此同时,业务线程的等待次数和等待时间大幅上涨,这说明 druid 链接生产者的“产能”彷佛不足,以致于部分业务线程出现了等待。什么状况下生产者建立链接会出现问题呢?咱们当时考虑到的状况以下:bash
先说第二种状况,RDS 负载的问题很好确认,阿里云上有 RDS 的监控。咱们确认了两次问题发生时的监控曲线,发现实例负载并不高,没有明显波动,因此状况二能够排除。那状况一呢,网络会不会出现问题呢?这个猜测很差排除。缘由以下:网络
活跃链接数正常状况下会很低,暴涨通常都不是正常现象。假如一个 Java 线程从发出 SQL 请求到收到数据耗时 5ms,那么一条链接能够达到 200 的 QPS。而这个单机 QPS 还不足 200,不该该用掉这么多链接,除非出现了慢查。可是咱们用阿里云的 SQL 洞察服务里也没发现慢 SQL,所以能够排除掉慢查的可能性。不是慢查,彷佛只能甩锅给网络了,必定是当时的网络(接锅好手)出了问题。
若是真是网络出问题了,那么 druid 生产者“产能”不足的问题彷佛也能获得合理的解释。可是通过咱们的分析,发现了一些矛盾点,以下:
图3:某个长 RT 请求的链路追踪数据
从链路数据上来看,SQL 的预编译和执行时间都是很快的,没有出现明显的等待时间。若是说上面的状况是偶然,可是咱们翻看了不少链路数据,都发现 SQL 的预编译和执行速度都很快,不存在明显的延迟。所以,把锅甩给网络是不合适的。
排查到这里,思路断了。首先没有发现慢查,其次数据库资源也是充足的,最后网络彷佛也没问题。都没有问题,那问题出在哪里呢?
首先查看了问题机器的 QPS,发现没有突发流量。虽有必定波动,但整体仍然平稳。
图4:QPS 折线图
接着看了应用的 CPU 使用率,发现了一点问题,使用率忽然上升了不少。
图5:CPU 使用率折线图
询问了业务同窗,这个点没有定时任务,QPS 与以往类似,没有什么异常。目前不知道 CPU 为何会忽然上升这么多,这个信息暂时没法提供有效的帮助,先放着。最后再看一下网络 I/O 监控。
图6:网络流量监控图
入站流量与出站流量在问题发生时间段内都出现了上升,其中出站流量上升幅度很大,说明当时应该有大量的数据发出去。但具体是哪些接口的行为,目前还不知道。
接着分析了一下业务日志,咱们发现了一些 WARN 级别信息:
# 业务线程打出的 WARN 日志,表示等待链接超时,重试一次 2021-07-20 09:56:42.422 [DubboServerHandler-thread-239] WARN com.alibaba.druid.pool.DruidDataSource [DruidDataSource.java:1400] - get connection timeout retry : 1 2021-07-20 09:56:42.427 [DubboServerHandler-thread-242] WARN com.alibaba.druid.pool.DruidDataSource [DruidDataSource.java:1400] - get connection timeout retry : 1 2021-07-20 09:56:42.431 [DubboServerHandler-thread-240] WARN com.alibaba.druid.pool.DruidDataSource [DruidDataSource.java:1400] - get connection timeout retry : 1 # Dubbo TimeoutFilter 答疑超时日志 2021-07-20 09:56:42.432 [DubboServerHandler-thread-254] WARN org.apache.dubbo.rpc.filter.TimeoutFilter [TimeoutFilter.java:60] - [DUBBO] invoke time out. method: xxx arguments: [yyy] , url is dubbo://172.***.***.***:20880/**** 2021-07-20 09:56:42.489 [DubboServerHandler-thread-288] WARN org.apache.dubbo.rpc.filter.TimeoutFilter [TimeoutFilter.java:60] - [DUBBO] invoke time out. method: **** arguments: [***, ***] , url is dubbo://172.***.***.***:20880/****
这两种日志说实话没什么用,由于这些日志是结果,本就应该发生的。除了 WARN 信息,业务日志里找不到任何异常信息。须要说明的是,咱们并无设置业务线程获取链接重试次数,默认重试次数是0。但这里却进行了一次重试,而咱们预期是业务线程在指定时间内获取链接失败后,应抛出一个 GetConnectionTimeoutException 异常。这个应该是 druid 的代码有问题,我给他们提了一个 issue (#4381),可是没人回复。这个问题修复也很简单,算是一个 good first issue,有兴趣的朋友能够提个 Pull Request 修复一下。
业务日志没有太多可用信息,咱们继续寻找其余的线索,此次咱们从 JVM 监控里发现了端倪。
问题发生的那一小段时间内,JVM 发生了屡次的 YGC 和 FGC,并且部分 GC 的耗时很长。
图7:GC 次数与耗时监控
那么如今问题来了,GC 是缘由仍是结果?因为 GC 发生的时间与接口慢查出现的时间都在 9.56:30 以后,时间上你们是重叠的,谁影响了谁,仍是互相影响,这都是不清楚的。GC 的引入彷佛让排查变得更为复杂了。
到这里信息收集的差很少了,可是问题缘由仍是没有推断出来,十分郁闷。
若是咱们仍然从 druid 这个方向排查,很难查出缘由。时间宝贵,如今要综合收集的信息从新思考一下了。既然发生了 GC,那么应用的内存消耗必定是上升了。再综合网络 I/O 的状况,短期内出现了比较大的波动,好像也能说明这个状况。但当时网络流量并非特别大,彷佛还不足以引起 GC。支撑力不足,先放一边。另外,应用的 QPS 没有多大变更,可是 CPU 负载却忽然上升了不少。加之几回 GC 的耗时很长,搞很差它们俩之间有关联,即长时间的 GC 致使了 CPU 负载上升。目前,有两个排查方向,一个是从网络 I/O 方向排查,另外一个是从 GC 方向排查。就现象而言,GC 的问题彷佛更大,所以后续选择从 GC 方向排查。
JVM 进行 GC,说明内存使用率必定是上去了。内存上升是一个累积过程,若是咱们把排查时间从发生长耗时 GC 的时刻 9:57:00 向前推一分钟,没准会发现点什么。因而我到全链路追踪平台上按耗时倒序,拉取了问题应用在 9:56:00 这一分钟内的长 RT 接口列表,发现耗时靠前的十几个都是 queryAll 这个方法。以下:
图8:queryAll 方法耗时倒序列表
咱们看一下耗时最长请求的调用链信息:
图9:耗时最长请求的链路信息
首先咱们能够看到 MySQL 驱动的 execute 执行时间特别长,缘由后面分析。其次 redis 缓存的读取耗时很是短,没有问题。但 redis 客户端写入数据耗时很是长,这就很不正常了。
因而当即向应用开发要了代码权限,分析了一下代码。伪代码以下:
public XxxService { // 备注:该方法缓存实际上使用了 Spring @Cacheable 注解,而非显示操做缓存 public List queryAll(int xxxId) { // 一、检查缓存,命中则当即返回 List xxxDTOs = customRedisClient.get(xxxId); if (xxxDTOs != null) return list; // 二、缓存未命中,查询数据库 List xxxDOs = XxxDao.queryAll(xxxId); xxxDTOS = convert(xxxDOs) // 三、写入缓存 customRedisClient.set(xxxId, xxxDTOs, 300s); return list; } }
代码作的事情很是简单,那为何会耗时这么多呢?缘由是这样的,若是缓存失效了,queryAll 这个方法一次性会从数据库里取出上万条数据,且表结构包含了一些复杂的字段,好比业务规则,通信地址等,因此单行记录相对较大。加之数据取出后,又进行了两次模型转换(DO → DTO → TO),转换的模型数量比原始模型数量要多一半,约 1.5 万个,TO 数量与 DTO 一致。模型转换完毕后,紧接着是写缓存,写缓存又涉及序列化。queryAll 方法调用一次会在新生代生成约五份比较大的数据,第一份是数据集 ResultSet,第二份是 DO 列表,第三份是 DTO 列表,第四份 TO 列表,最后一份是 DTO 列表的序列化内容。加之两秒内出现了二十屡次调用,加重了内存消耗,这应该能解释为何 GC 次数会忽然上升这么多。下面还有几个问题,我用 FAQ 的方式解答:
Q:那 GC 耗时长如何解释呢?
A:我猜想多是垃圾回收器整理和复制大批量内存数据致使的。
-------------------------------------------------✂-------------------------------------------------
Q:还有 execute 方法和 set 方法之间为何会间隔这么长时间内?
A:目前的猜想是模型类的转换以及序列化自己须要必定的时间,其次这期间应该有多个序列化过程同时在就行,不过这也解释不了时间为何这么长。不过若是咱们把 GC 考虑进来,就会获得相对合理的解释。从 9:56:33 ~ 5:56:52 之间出现了屡次 GC,并且有些 GC 的时间很长(长时间的 stop the world),形成的现象就是两个方法之间的间隔很长。实际上咱们能够看一下 9:56:31 第一个 queryAll 请求的调用链信息,会发现间隔并非那么的长:
图10:queryAll 正常状况下的耗时状况
所以,咱们能够认为后续调用链 execute 和 set 方法之间的超长间隔是由于 CPU 使用率,GC 等因素共同形成的。
-------------------------------------------------✂-------------------------------------------------
Q:第一个 set 和第二个 set 间隔为何也会这么长?
A:第一个 set 是咱们自定义的逻辑,到第二个 set 之间彷佛没有什么特别的东西,当时没有查出问题。不过好在复现的时候,发现了端倪,后面章节给予解释。
-------------------------------------------------✂-------------------------------------------------
最后,咱们把目光移到初始的问题上,即业务同事反馈部分接口 RT 上升的问题。下面仍用 FAQ 的方式解答。
Q:为何多个接口的 RT 都出现了上升?调用链参考下图:
图11:某个长 RT 接口的链路信息
A:部分业务线程在等待 druid 建立数据库链接,因为 GC 的发生,形成了 STW。GC 对等待逻辑会形成影响。好比一个线程调用 awaitNanos 等待3秒钟,结果这期间发生了5秒的 GC(STW),那么当 GC 结束时,线程当即就超时了。在 druid 数据源中, maxWait 参数控制着业务线程的等待时间,代码以下:
public class DruidDataSource extends DruidAbstractDataSource { private DruidPooledConnection getConnectionInternal(long maxWait) throws SQLException { // ... final long nanos = TimeUnit.MILLISECONDS.toNanos(maxWait); if (maxWait > 0) { // 尝试在设定时间内从链接池中获取链接 holder = pollLast(nanos); } else { holder = takeLast(); } // ... } }
通过前面的排查,不少现象都获得了合理的解释,是时候给个结论了:
本次 xxx 应用多个接口在两天连续出现了两次 RT 大幅上升状况,排查下来,初步认为是 queryAll 方法缓存失效,短期内几十个请求大批量查询数据、模型转换以及序列化等操做,消耗量大量的内存,触发了屡次长时间的 GC。形成部分业务线程等待超时,进而形成 Dubbo 线程池被打满。
接下来,咱们要按照这个结论进行复现,以证实咱们的结论是正确的。
问题复现仍是要尽可能模拟当时的状况,不然可能会形成比较大的偏差。为了较为准确的模拟当时的接口调用状况,我写了一个能够控制 QPS 和请求总数的验证逻辑。
public class XxxController { @Resource private XxxApi xxxApi; public Object invokeQueryAll(Integer qps, Integer total) { RateLimiter rl = RateLimiter.create(qps.doubleValue()); ExecutorService es = Executors.newFixedThreadPool(50); for (Integer i = 0; i < total; i++) { es.submit(() -> { rl.acquire(); xxxApi.queryAll(0); }); } return "OK"; } }
复现的效果符合预期,CPU 使用率,网络 I/O 都上去了(因为监控属于公司内部系统,就不截图了)。同时 GC 也发生了,并且耗时也很长。GC 日志以下:
2021-07-29T19:09:07.655+0800: 631609.822: [GC (Allocation Failure) 2021-07-29T19:09:07.656+0800: 631609.823: [ParNew: 2797465K->314560K(2831168K), 2.0130187 secs] 3285781K->1362568K(4928320K), 2.0145223 secs] [Times: user=3.62 sys=0.07, real=2.02 secs] 2021-07-29T19:09:11.550+0800: 631613.717: [GC (Allocation Failure) 2021-07-29T19:09:11.551+0800: 631613.718: [ParNew: 2831168K->314560K(2831168K), 1.7428491 secs] 3879176K->1961168K(4928320K), 1.7443725 secs] [Times: user=3.21 sys=0.04, real=1.74 secs] 2021-07-29T19:09:13.300+0800: 631615.467: [GC (CMS Initial Mark) [1 CMS-initial-mark: 1646608K(2097152K)] 1965708K(4928320K), 0.0647481 secs] [Times: user=0.19 sys=0.00, real=0.06 secs] 2021-07-29T19:09:13.366+0800: 631615.533: [CMS-concurrent-mark-start] 2021-07-29T19:09:15.934+0800: 631618.100: [GC (Allocation Failure) 2021-07-29T19:09:15.934+0800: 631618.101: [ParNew: 2831168K->2831168K(2831168K), 0.0000388 secs]2021-07-29T19:09:15.934+0800: 631618.101: [CMS2021-07-29T19:09:17.305+0800: 631619.471: [CMS-concurrent-mark: 3.668/3.938 secs] [Times: user=6.49 sys=0.01, real=3.94 secs] (concurrent mode failure): 1646608K->1722401K(2097152K), 6.7005795 secs] 4477776K->1722401K(4928320K), [Metaspace: 224031K->224031K(1269760K)], 6.7028302 secs] [Times: user=6.71 sys=0.00, real=6.70 secs] 2021-07-29T19:09:24.732+0800: 631626.899: [GC (CMS Initial Mark) [1 CMS-initial-mark: 1722401K(2097152K)] 3131004K(4928320K), 0.3961644 secs] [Times: user=0.69 sys=0.00, real=0.40 secs] 2021-07-29T19:09:25.129+0800: 631627.296: [CMS-concurrent-mark-start] 2021-07-29T19:09:29.012+0800: 631631.179: [GC (Allocation Failure) 2021-07-29T19:09:29.013+0800: 631631.180: [ParNew: 2516608K->2516608K(2831168K), 0.0000292 secs]2021-07-29T19:09:29.013+0800: 631631.180: [CMS2021-07-29T19:09:30.733+0800: 631632.900: [CMS-concurrent-mark: 5.487/5.603 secs] [Times: user=9.29 sys=0.00, real=5.60 secs] (concurrent mode failure): 1722401K->1519344K(2097152K), 6.6845837 secs] 4239009K->1519344K(4928320K), [Metaspace: 223389K->223389K(1269760K)], 6.6863578 secs] [Times: user=6.70 sys=0.00, real=6.69 secs]
接着,咱们再看一下那段时间内的接口耗时状况:
图12:问题复现时的 queryAll 耗时倒序列表
全部接口的消耗时间都很长,也是符合预期的。最后再看一个长 RT 接口的链路信息:
图13:某个长 RT 接口的链路信息
会发现和图片1,也就是业务同窗反馈的问题是一致的,说明复现效果符合预期。
验证到这里,能够证实咱们的结论是正确的了。找到了问题的根源,这个问题能够归档了。
出于性能考虑,Pinpoint 给出的链路信息力度比较粗,以至于咱们没法详细得知 queryAll 方法的耗时构成是怎样的。为了搞清楚这里面的细节,我对 queryAll 方法耗时状况进行比较详细的测算。在应用负载比较低的状况下触发一个请求,并使用 Arthas 的 trace 命令测算链路耗时。获得的监控以下:
图14:正常状况下 queryAll 方法链路信息
这里我对三个方法调用进行了编号,方法 ① 和 ② 之间存在 252 毫秒的间隔,方法 ② 和 ③ 之间存在 294 毫秒的间隔。Arthas 打印出的链路耗时状况以下:
图15:queryAll 方法耗时测量
这里的编号与上图一一对应,其中耗时比较多的调用我已经用颜色标注出来了。首先咱们分析间隔1的构成,方法 ① 到 ② 之间有两个耗时的操做,一个是批量的模型转换,也就是把 DO 转成 DTO,消耗了约 79.6 毫秒。第二个耗时操做是 Object 的 toString 方法,约 171.6。二者加起来为 251.2,与全链路追踪系统给出的数据是一致的。这里你们确定很好奇为何 toString 方法会消耗掉这么多时间,答案以下:
public void put(Object key, Object value) { // 省略判空逻辑 // 把 key 和 value 先转成字符串,再进行判空 if (StringUtils.isBlank(key.toString()) || StringUtils.isBlank(value.toString())) { return; } CacheClient.set(key, value); }
这是方法 ① 和 ② 路径中的一个方法,这段代码看起来人畜无害,问题发生在判空逻辑上(历史代码,不讨论其合理性)。当 value 是一个很是大集合或数组时,toString 会消耗掉不少时间。同时,toString 也会生成一个大字符串,无形中消耗了内存资源。这里看似不起眼的一句代码,实际上倒是性能黑洞。这也提醒咱们,在操做大批量数据时,要注意时空消耗。
最后,咱们再来看一下方法 ② 和 ③ 之间的间隔问题,缘由已经很明显了,就是 value 序列化过程消耗了大量的时间。另外,序列化好的字节数组也会暂时存在堆内存中,也是会消耗很多内存资源的。
到这里整个分析过程就结束了,经过上面的分析,咱们能够看出,一次简单的查询居然能引出了这么多问题。不少在之前看起来稀疏日常的逻辑,偶然间也会成为性能杀手。平常工做中,仍是要常常关注一下应用的性能问题,为应用的稳定运行保驾护航。
因为问题发生时,JVM 只是进行了 FGC,内存并无溢出,因此没有当时的内存 dump 数据。不过好在问题能够稳定复现,经过对 JVM 进行一些配置,咱们可让 JVM 发生 FGC 前自动对内存进行 dump。这里使用 jinfo 命令对正在运行的 JVM 进程设置参数:
jinfo -flag +HeapDumpBeforeFullGC $JVM_PID
拿到内存数据后,接下来用 mat 工具分析一下。首先看一下内存泄露吧:
图16:应用内存泄露分析
从内存消耗比例上来看,确实存在一些问题,主要是与 dubbo 的线程有关。随便选一个线程,在支配树(dominator tree)视图中查看线程支配的对象信息:
图17:dubbo 线程支配树状况
从上图能够看出,dubbo 线程 retained heap 约为 66 MB,主要由两个大的对象 ArrayList 和 StringBuilder 组成。ArrayList 里面存放的是 DTO,单个 DTO 的 retained heap 大小约为 5.1 KB。StringBuilder 主要是 toString 过程产生的,消耗了接近 26 MB 的内存。从 dump 的内存数据中没找到 DO 列表,应该是在 YGC 时被回收了。
好了,关于内存的分析就到这吧,大概知道内存消耗是怎样的就够了,更深刻的分析就不搞了。
因为排查经验较少,这个问题断断续续也花了很多时间。这中间有找不到缘由时的郁闷,也有发现一些猜测符合预期时的欣喜。无论怎么样,最后仍是找到了问题的缘由。在帮助别人的同时,本身也学到了很多东西。总的来讲,付出是值得的。本文的最后,对问题排查过程进行简单的总结吧。
一开始,我直接从具体现象开始排查,指望找到引起现象的缘由。进行了各类猜测,可是都无法得出合理的结论。接着扩大信息面,仍然无果。以后综合各类信息,思考以后,换个方向排查,找到了缘由。最后进行验证,并对一些疑点进行解释,整个过程结束。
最后说说此次排查过程存在的问题吧。第一个问题是没有注意甄别别人反馈过来的信息,没有对信息进行快速确认,而是直接深刻了。花了不少时间尝试了各类猜测,最终均无果。因为别人反馈过来的信息一般都是比较零碎片面的,甚至是不正确的。对于这些信息能够快速确认,幸运的话能直接找到缘由。但若是发现此路不通,就不要钻牛角尖了,由于这个现象可能只是众多现象中的一个。在这个案例中,接口长 RT 看起来像是 druid 致使的,实际上倒是由于 GC 形成 STW 致使的。继续沿着 druid 方向排查,最终必定是南辕北辙。其余的问题都是小问题,就不说了。另外,排查过程当中要注意保存当时的一些日志数据,避免因数据过时而丢失,好比阿里云 RDS SQL 洞察是有时间限制的。
本篇文章到此结束,感谢阅读!
本文在知识共享许可协议 4.0 下发布,转载请注明出处
做者:田小波
原创文章优先发布到我的网站,欢迎访问:https://www.tianxiaobo.com
本做品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。