java db2 数据表定时数据迁移_JAVA服务从物理机迁移到私有云背景下的GC调优实践...

背景

在 Java 服务部署从物理机向公司私有云平台迁移的过程中,发现迁移后的服务质量比迁移之前有所下降。经过排查分析,发现问题的导火索可能是 JVM GC 导致的全局暂停;经过几轮对比调参,服务的 GC 单次耗时和频率都有较大程度的降低,取得了较好的效果。

所负责的项目主要是存储用户的基本信息数据,数据量在亿级。存储底层为MySQL,使用了自研的分库分表中间件;数据库实例个数在5个以上,单实例数据表行数在千万以上。

为了配合公司的云化战略,该服务需要从物理机部署迁移为私有云(docker based)部署。

服务的运行环境是 JDK 1.6,JVM 运行参数为:

-server-XX:+UseConcMarkSweepGC-XX:+UseParNewGC-XX:+DisableExplicitGC-Xms10g-Xmx10g-Xmn4g-Xss1024K-XX:PermSize=256m-XX:MaxPermSize=512m-XX:SurvivorRatio=10-XX:+ParallelRefProcEnabled-XX:+CMSParallelRemarkEnabled-XX:+UseCMSCompactAtFullCollection-XX:+UseCMSInitiatingOccupancyOnly-XX:CMSInitiatingOccupancyFraction=70-XX:CMSMaxAbortablePrecleanTime=30000-XX:SoftRefLRUPolicyMSPerMB=0

即使用的 GC 收集器为 ParNew (new) + ConcMarkSweep (CMS, old)。

在迁移过程中,发现私有云上的服务实例:

  1. 访问 Cache 出现超时,且超时量较明显
  2. 平均请求响应时间增大
  3. 平均响应时间会出现偶发性的突刺

线上的服务出现了问题就需要解决问题,于是开始对服务进行排查。

对比迁移前后的服务变化和环境变化:

  1. 迁移之前是部署到物理机上(32 core + 128 G),迁移之后是部署到私有云上(8 core + 8 G)
  2. 迁移之前的 JVM 参数中配置的内存数为 10 G,迁移之后调整为 7 G

通过定位 Cache 超时问题,很快发现服务会出现较长时间的全局暂停(> 100 ms),很容易联想到 GC 的 stop-the-world 上去。

GC 调优过程

Round 1 - 自适应新生代

首先需要在服务中启用 GC 日志,并去掉了显式的新生代大小设置(-Xmn)。

-Xms7g-Xmx7g-Xloggc:/path-to-gc.log-XX:+PrintGCDetails-XX:+PrintGCDateStamps

实际采集到的 GC 日志片段如下:

4b8f7800b609e3bbb467bf3b0913ecdb.png

可以看到 JVM 自动选择的新生代大小为 156224K,新生代 GC 的暂停时长为 10-13 ms,间隔 ~ 4 s。

作为参考,调用 `jstat -gcutil` 的输出如下:

d757d487213fa5b64ad7acb73ed07469.png

Round 2 - 增大新生代大小以增加 GC 间隔

新生代的 GC 间隔约为 4 s,尝试设置显示的新生代大小为 2G 以增加新生代 GC 的间隔,降低新生代 GC 的频率。

-server-Xms7g-Xmx7g-Xloggc:/path-to-gc.log-XX:+PrintGCDetails-XX:+PrintGCDateStamps...-Xmn2g

新的 GC 日志片段如下:

80cc930c4ea26200ab68b91511fc74a5.png

新生代 GC 的暂停时间增大为 12-17 ms,间隔为 5 s,效果不明显。老年代 GC 的暂停时间分别为 8 ms 和 698 ms,remark 阶段耗时较大,存在优化空间;第一次老年代 GC 发生于 21 个小时后。

Round 3 - 降低并发 GC 线程数以减少线程切换损失

对比迁移前后的部署环境,发现一个差异:JVM 读取到的 CPU 核数的限制是硬件核数。在物理机上,硬件核数与 JVM 运行时可用的核数一致;而私有云上 JVM 的可用核数受到 docker 的限制。而 CMS 的算法在 remark 阶段并发收集的线程数是通过 JVM 收集到的 CPU 核心数相关的。Oracle JDK 默认当 CPU 的核数小于等于 8 时,设置线程数等于核数;当 CPU 的核数大于 8 时,设置线程数为 3 + 5 * CPU / 8。这导致迁移后服务的 JVM 为 GC 并发收集线程数设置了较高的值,产生了较高的不必要的线程切换开销。

尝试设置 GC 并发线程数为可用的 CPU 核数,即 `-XX:ParallelGCThreads`:

-server-Xms7g-Xmx7g-Xmn2g-Xloggc:/path-to-gc.log-XX:+PrintGCDetails-XX:+PrintGCDateStamps...-XX:ParallelGCThreads=8

新的 GC 日志片段如下:

0a31b8673fb24f68ec8679f9d8d2e92c.png

可以发现,新生代的 GC 的耗时降为 8-10 ms,间隔为 4 s,有一定成效。老年代 GC 的 remark 阶段耗时 729 ms,效果变差。

作为参考,`jstat -gcutil` 的输出如下:

ef14c7c381056c26e4c11c8ac090645a.png

Round 4 - full heap dump & analyze

分析 remark 阶段的耗时,发现主要是 `weak refs processing` 过程耗时较大(612 ms)。参考 [PhantomReference导致CMS GC耗时严重](https://www.jianshu.com/p/6d37afd1f072) 里的经验,推测可能是类似的问题:由于 MySQL `com.mysql.jdbc.NonRegisteringDriver` 类内部通过静态变量 `connectionPhantomRefs` 维护了一个 `com.mysql.jdbc.NonRegisteringDriver.ConnectionPhantomReference` 类型(`java.lang.ref.PhantomReference` 的子类型)的哈希表,用于 JDBC 连接的最终释放;该哈希表中元素在连接主动关闭时并不会被清理,而是通过 `java.lang.ref.PhantomReference` 的清理机制来实现连接的清理和释放。猜想这样设计是为了在用户没有主动关闭连接(未调用 `com.mysql.jdbc.Connection#close`)时,还能够清理连接。

标准库的 `java.lang.ref.PhantomReference` 被设计来进行资源的清理和关闭:当 JVM 检测到某对象只被虚引用 `java.lang.ref.PhantomReference` 而没有被别的更强引用(标准强引用、软引用 `java.lang.ref.SoftReference` 或弱引用 `java.lang.ref.SoftReference`)所指向后,会在合适时机将对应的虚引用放入一个阻塞队列。MySQL 的驱动类 `com.mysql.jdbc.Driver` 是 `com.mysql.jdbc.NonRegisteringDriver` 类的子类,在类初始化的时候会启动一个清理线程清理上述的阻塞队列,实现连接的清理和释放的逻辑。

以上是理论。为了验证是否是由于连接为清理的原因导致的`weak refs processing` 过程耗时较大,对线上服务进行了一次 full heap dump (`jmap`),并使用 [Eclipse Memory Analyzer](https://www.eclipse.org/mat/) 对 dump 的结果进行可视化分析。

48fce1c3e7e7a50dd28a35e4d8bcc9fe.png

通过 retained size 分析可以看出,类 `com.mysql.jdbc.NonRegisteringDriver` 导致了 812 M 的内存对象不能及时释放。

287960e3e4fec18d0bdecafcb065a71a.png

通过对象依赖分析可以看出,类 `com.mysql.jdbc.NonRegisteringDriver` 类导致大量 retained size 的罪魁祸首就是上文分析的哈希表。

Round 5 - clean connection reference map

根据以上分析,尝试在服务中启动一个线程定时清理哈希表中的 connection reference。

新的 GC 日志片段如下:

f4009c03d93df1c2469e3edb2bc92d82.png

新生代 GC 的暂停时间减小为 8 ms,间隔为 8 s,效果比之前要好。

第一次老年代 GC 发生于服务器动的 37 个小时后,暂停时间分别为 11 ms 和 131 ms;第二次老年代 GC 发生于服务器动的 73 个小时后,暂停时间分别为 9 ms 和 143 ms;两次的时间间隔是 35 小时。效果明显。

以下是使用 [GCeasy - Universal GC Log Analyzer](https://www.gceasy.io) 对 GC 日志的可视化分析结构,作为参考。

清理连接之前:

dc2f03d41166f000c22b78af5a943589.png

清理连接之后:

ae552e0b3a995b47c7e354fb90f554fd.png

以上的所有结果和分析都是在服务启动之后1-2 天的结果;作为对比,下面是服务运行半个月之后的结果:

b56f59062878f18f52be3ab19d31d68f.png

补充说明

连接清理

由于服务使用的 [Tomcat DBCP](https://tomcat.apache.org/tomcat-7.0-doc/jdbc-pool.html) 连接池组件,会托管所使用的 JDBC 连接,所以不需要额外的 MySQL 自带的清理机制。

连接数

发现服务运行过程中,哈希表中最多会出现 30000+ 的连接的虚引用。

当前服务所使用的数据库中间件通过 [Tomcat DBCP](https://tomcat.apache.org/tomcat-7.0-doc/jdbc-pool.html) 连接池管理超过 5 个的 MySQL 数据库实例,每个 MySQL 实例 3 个连接池(一主两备)。

服务中使用的连接池的配置是:

161640001616

应该不会出现如此大量的连接。而且在当前的服务访问量下,也应该没有空闲连接以致出现连接被自动释放的情况。

此问题可以进一步排查。

升级 JDK 版本

如果条件允许,可以优先升级 JDK 到 1.8。但是由于该服务是老服务,而且调用量巨大,升级风险太大;而且 JDBC 接口跨 JDK 版本存在兼容性问题,服务所使用的分库分表中间件没有做兼容性测试和保证。

所以没有办法升级 JDK 版本。

总结

调整 GC 参数和代码逻辑之后,服务由于 GC 暂停导致的暂停时间大为减少。

b0e380d51765502ec97959b205516d3e.png

由于新生代的 GC 是复制算法,耗时主要在于存活对象的大小,所以调优对新生代的效果不明显;老年代的 GC 是 CMS,通过调整并发收集线程数和修改代码逻辑定时清理 PhantomReference,发现取得了较好的效果。

调优之后的服务,基本上不出现或很少出现 Cache 访问超时,服务的平均响应时间也接近甚至好于迁移之前的表现。


欢迎大家关注“58架构师”微信公众号,定期分享云计算、AI、区块链、大数据、搜索、推荐、存储、中间件、移动、前端、运维等方面的前沿技术和实践经验。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值