服务假死问题解决过程实记(二)——C3P0 数据库连接池配置引发的血案

接上文《服务假死问题解决过程实记(一)——问题发现篇》


三、03.30 Tomcat 假死后续——C3P0 连接池参数配置问题

昨晚上正在看有关 B+Tree 相关的内容,收到业务组的微信消息:

最帅气的大龙龙:现场数据库连接不上,他们排查问题,怀疑与连接池或者日志有关系,最后发现从昨天下午到现在产生 30 万条日志,其中我们就有 22 万条,明天查一下我们服务 @琦小虾

好吧,那就和师父一起查问题好了。第二天早上,果然数据库组的同事过来和我们说了说情况,说现场传来的具体情况:现场忽然之间所有业务都不能连接 Oracle,后来查询了下原因,看到 Oracle 的监听日志过大,导致所有业务不能连接数据库。后来通过某些手段打开 Oracle 监听日志 (listener.log),发现总共产生了 30 万条日志,我们业务组相关的日志占了 20+ 万条。所以建议我们检查一下数据库连接池相关的参数。

注:Oracle 监听日志文件过大导致无法数据库无法连接的相关问题参考连接:
《ORACLE的监听日志太大,客户端无法连接 BUG:9879101》
《ORACLE清理、截断监听日志文件(listener.log)》

数据库组老大专门来我师父的机器上的 C3P0 的数据库连接池相关参数,大佬感觉没什么问题(然而是个小坑)。那是为什么呢?最好的方法还是调过来开发环境的 Oracle 监听日志看看吧。
经过我一番猛如虎的操作,我们把日志分析的准备工作做好了:

  1. 安装 XShell 用 sftp 连接 Oracle 所在的 CentOS 服务器,把数据库监听日志 listener.log 宕到本机;
  2. 监听日志记录了两个月的日志信息,大小大概有 5 个多 G;记事本与 NotePad 都不能打开这么大的日志文件;
  3. 由于不能连接外网下载第三方工具,我在网上找了个 Java 方法,用 NIO 的方法把 5G 的日志文件分成了 200 个文件,这样就可以进行分析了。
public static void splitFile(String filePath, int fileCount) throws IOException {
    FileInputStream fis = new FileInputStream(filePath);
    FileChannel inputChannel = fis.getChannel();
    final long fileSize = inputChannel.size();
    long average = fileSize / fileCount;//平均值
    long bufferSize = 200; //缓存块大小,自行调整
    ByteBuffer byteBuffer = ByteBuffer.allocate(Integer.valueOf(bufferSize + "")); // 申请一个缓存区
    long startPosition = 0; //子文件开始位置
    long endPosition = average < bufferSize ? 0 : average - bufferSize;//子文件结束位置
    for (int i = 0; i < fileCount; i++) {
        if (i + 1 != fileCount) {
            int read = inputChannel.read(byteBuffer, endPosition);// 读取数据
            readW:
            while (read != -1) {
                byteBuffer.flip();//切换读模式
                byte[] array = byteBuffer.array();
                for (int j = 0; j < array.length; j++) {
                    byte b = array[j];
                    if (b == 10 || b == 13) { //判断\n\r
                        endPosition += j;
                        break readW;
                    }
                }
                endPosition += bufferSize;
                byteBuffer.clear(); //重置缓存块指针
                read = inputChannel.read(byteBuffer, endPosition);
            }
        }else{
            endPosition = fileSize; //最后一个文件直接指向文件末尾
        }

        FileOutputStream fos = new FileOutputStream(filePath + (i + 1));
        FileChannel outputChannel = fos.getChannel();
        inputChannel.transferTo(startPosition, endPosition - startPosition, outputChannel);//通道传输文件数据
        outputChannel.close();
        fos.close();
        startPosition = endPosition + 1;
        endPosition += average;
    }
    inputChannel.close();
    fis.close();

}

public static void main(String[] args) throws Exception {
    long startTime = System.currentTimeMillis();
    splitFile("/Users/yangpeng/Documents/temp/big_file.csv",5);
    long endTime = System.currentTimeMillis();
    System.out.println("耗费时间: " + (endTime - startTime) + " ms");
}

好吧,终于可以分析日志了。

既然所有业务组都在和这个 Oracle 连接,那么就统计一下几个流量比较大的服务的 IP 出现频率吧。随手打开分割的 200 个中随便一个日志 (25M),首先用 NotePad 统计了一下所有业务都会访问的共享数据 DAO 服务 IP,总共 300+ 的频率。
我们业务 DAO 服务几个 IP 的频率呢?52796 + 140293 + 70802 + 142 = 264033 次……

文件里看到了满屏熟悉的 IP…… 没错,这些 IP 就是我们曾经以及正在运行过 DAO 服务的四台主机 IP 地址…… (其中 28.1.25.91 就是区区在下臭名昭著的开发机 IP 地址)

...
22-MAR-2019 13:23:41 * (CONNECT_DATA=(SERVICE_NAME=DBdb)(CID=()(HOST=SC-201707102126)(USER=Administrator))) * (ADDRESS=(PROTOCOL=tcp)(HOST=28.1.25.91)(PORT=53088)) * establish * DBdb * 0
22-MAR-2019 13:23:41 * (CONNECT_DATA=(SERVICE_NAME=DBdb)(CID=()(HOST=SC-201707102126)(USER=Administrator))) * (ADDRESS=(PROTOCOL=tcp)(HOST=28.1.25.91)(PORT=53088)) * establish * DBdb * 0
...

“完了,这次真的要背血锅了。哈哈哈哈哈。”这是我第一反应,发现了服务的坑,我竟然这么兴奋哇哈哈哈哈~

随手选了一个文件就是这样,检查了一下其他分割的日志文件,也全都是这种情况。但为什么这个日志文件里,我们四个不同的服务地址总共出现了 26 万次 IP 地址,其中一个只有 142 次,和其他三个 IP 频率差了这么多?
原来这个 142 次的 IP 是我师父的开发机 IP,而且他自己也说不清楚什么时候出于什么思考,把数据库的连接池给改小了(就是数据库组大佬亲自检查后说没有问题的那组参数),然而我和其他小伙伴的 DAO 服务 C3P0 参数没有改过,所以只有我们三台服务的 IP 地址显得那么不正常。哈哈哈我师父果然是个心机 BOY~

注:关于 C3P0 参数设置的相关内容笔者总结到了另一篇博客里:《C3P0 连接池相关概念》

之前的 C3P0 参数是这样的:

# unit:ms
cpool.checkoutTimeout=60000
cpool.minPoolSize=200
cpool.initialPoolSize=200
cpool.maxPoolSize=500
# unit:s
cpool.maxIdleTime=60
cpool.maxIdleTimeExcessConnections=20
cpool.acquireIncrement=5
cpool.acquireRetryAttempts=3

修改后的 C3P0 参数是这样的:

# unit:ms
cpool.checkoutTimeout=5000
cpool.minPoolSize=5
cpool.maxPoolSize=500
cpool.initialPoolSize=5
# unit:s
cpool.maxIdleTime=0
cpool.maxIdleTimeExcessConnections=200
cpool.idleConnectionTestPeriod=60
cpool.acquireIncrement=5
cpool.acquireRetryAttempts=3

04.21 记:cpool.maxIdleTimeExcessConnections=200 依旧是个大坑,后续解析。

可以看出来,差距最大的几个参数是:

  • cpool.checkoutTimeout: 60000 -> 5000 (单位 ms)
  • cpool.minPoolSize: 200 -> 5
  • cpool.initialPoolSize: 200 -> 5
  • cpool.maxIdleTime: 60 -> 0 (单位 s)

所以,可以总结现场出现的现象如下:

  1. 我们的 DAO 服务由于设置了 initialPoolSize 的值为 200,所以 DAO 服务在一开始启动的时候,就已经和 Oracle 建立了 200 个连接;
  2. 由于服务大部分时间都不会有太多人使用,所以运行过程中每超过 maxIdleTime 的时间即 60 秒后,没有被使用到的数据库连接被释放。一般释放的连接数量大约会在 195 ~ 200 个左右;
  3. 刚刚释放了大量的数据库连接(数量计作 size),由于 minPoolSize 设置为 200,所以立即又会发起 size 个数据库连接,使数据库连接数量保持在 minPoolSize 个;
  4. 每 60s (maxIdleTime) 重复 2~3 步骤;

所以现场的 Oracle 的监听日志也会固定每 60 秒 (maxIdleTime) 添加约 200 条,运行了一段时间后,就出现了 Oracle 监听日志过大(一般情况下指一个 listener.log 监听文件大于 4G),Oracle 数据库无法被连接的情况。

所以,前面三月六日我发现的大量出现 1521 端口的 TIME_WAIT,就应该是 DAO 服务端检测到有 200 个空闲连接,便为这些连接向数据库发送关闭请求,然后这些连接在等待 maxIdleTime 时间的过程中就进入了 TIME_WAIT 状态。释放这些连接后由于 minPoolSize 设置值为 200,所以又重新发起了约 200 个新的数据库连接。所以我如果在 cmd 中随一定时间周期 (每 60s) 输入 netstat -ano
| findstr "1521" 的指令,列出来的与数据库 1521 端口应该是变动的。

TCP 四次分手示意图

至此,数据库连接池的问题应该是解决了。但我认为服务假死问题应该不是出在这里。目前怀疑的问题,有因为虚拟机开启了 -XDebug, -Xrunjdwp 参数,也可能是由于我们使用线程池的方式有误。还是需要继续进一步检查啊。


四、04.15 100 插入并发假死问题——C3P0 连接池参数配置问题

参考地址:
《c3p0 不断的输出debug错误信息》

很长一段时间里,在忙一些其他杂事,没有时间开发。终于把杂事忙完之后,笔者和师父在修正了 C3P0 参数之后,开始尝试测试并发性能。
用 LoadRunner 写了一个脚本,同时 50 个用户并发插入一条数据,无思考时间的插入一分钟。脚本跑起来之后,很快服务就出现了问题。

首先,DAO 服务直接完全假死。而且由于笔者在虚拟机参数中添加了 -XX:+PrintGCDetails 参数,观察到打印出来的 GC 日志,竟然有一秒钟三到四次的 FullGC!而且虚拟机的旧生代已经完全被填满,每次 FullGC 几乎完全没有任何的释放。此外,DAO 服务也会偶尔报出 OutofMemoryError,只是没有引起虚拟机崩溃而已。
当然,软件服务也由于大量的插入无响应,报出了大量的 Read Time Out 错误。

开始分析问题的时候,笔者也是一脸懵逼。打开 JVisualVM 监控 Java 堆,反复试了多次,依旧是长时间的内存不释放的现象。正当有一次对着 JVisualVM 监控画面发呆,发呆到执行并发脚本几分钟之后,忽然我看到有一次 FullGC 直接令 Java 堆有了一次断崖式的下降,堆内存直接下降了 80%!!
我当时就意识到这就是问题的突破点。所以由重新跑了一次并发脚本复现问题。再次卡死时,我用 jmap 指令把堆内存 Dump 下来,加载到前几天准备好的 Eclipse 插件 Memory Analyse Tool (MAT) 中进行分析。
果然看到了很异常的 HeapDump 饼图:1.5G 的堆内存,有 70%-80% 的容量都在存着一个名为 newPooledConnection 的对象,这种对象的数量大概有 60 个,每个对象大小 20M 左右。这个对象是在 c3p0 的包里,所以用脚指头想就知道,肯定是我们的 C3P0 配置还有问题。

查了一下 C3P0 的配置参数,观察到有一条信息:

  • maxIdelTimeExcessConnections: 这个配置主要是为了快速减轻连接池的负载,比如连接池中连接数因为某次数据访问高峰导致创建了很多数据连接,但是后面的时间段需要的数据库连接数很少,需要快速释放,必须小于 maxIdleTime。其实这个没必要配置,maxIdleTime 已经配置了。

而此时我看了一眼我们的 C3P0 参数,有这样两个参数:

cpool.maxIdleTime=0
cpool.maxIdleTimeExcessConnections=200

所以由于 cpool.maxIdleTimeExcessConnections=200 这个参数,在并发发生之后,C3P0 持续持有并发后产生的数据库连接,直到 200s 之内没有再复用到这些连接,才会将其释放。所以我之前发呆后忽然的断崖式内存释放,肯定就是因为这个原因……

果然把 maxIdleTime, maxIdleTimeExcessConnections 都设置为 0,并发插入立即变得顺滑了很多。
至此,DAO 服务最重要的问题找到,对它的优化过程基本告一段落。但我们的服务依旧有很多待优化的点,也有很多业务逻辑可以优化,这是后面一段时间需要考虑的问题。


未完待续。下篇《服务假死问题解决过程实记(三)——缓存问题优化》

你可能感兴趣的:(服务假死问题解决过程实记(二)——C3P0 数据库连接池配置引发的血案)