druid的java占位符条件查询,惨遭DruidDataSource和Mybatis暗算,导致OOM

先遭DruidDataSource袭击

事发

一个平凡的工作日,我像往常一样完成产品提出的需求的业务代码,突然收到了监控平台发出的告警信息。本以为又是一些业务上的bug导致的报错,一看报错发现日志写着java.lang.OutOfMemoryError: Java heap space。

接着我远程到那台服务器上,但是卡的不行。于是我就用top命令查了一下cpu信息,占用都快要到99%了。再看看GC的日志发现程序一直在Full GC,怪不得cpu占用这么高。

这里就推测是有内存泄漏的问题导致GC无法回收内存导致OOM。为了先不影响业务,就先让运维把这个服务重启一下,果然重启后服务就正常了。

分析日志

先看一下报错日志详细写了一些什么错误信息,虽然一般OOM问题日志不能准确定位到问题,但是已经打开日志平台了,看一下作为参考也是不亏的。

看到日志中写的OOM事发场景是在计算多个用户的总金额的时候出现的,大致伪代码如下:

/**

* OrderService.java

*/

// 1. 根据某些参数获取符合条件的用户id列表

List customerIds = orderService.queryCustomerIdByParam(param);

// 2. 计算这些用户id的金额总和

long principal = orderMapper.countPrincipal(customerIds);

select

IFNULL(sum(remain_principal),0)

from

t_loan where

customer_id in

close=")" separator=",">

#{item}

这部分感觉出问题的原因是由于计算金额总额时,查询参数customerIds太多了。由于前段时间业务的变更,导致在参数不变的情况下,查询出的customerIds列表由原来的几十几百个id变成了上万个,就我看的报错信息这里的日志打印出来这个list的大小就有三万多个customerId。不过就算查询条件为三万多个而导致sql执行的比较慢,但是这个方法只有内部的财务系统才会调用,业务量没那么大,也不应该导致OOM的出现啊。

所以接着再看一下JVM打印出来的Dump文件来定位到具体的问题。

分析Dump文件

得益于在JVM参数中加了-XX:+HeapDumpOnOutOfMemoryError参数,在发生OOM的时候系统会自动生成当时的Dump文件,这样我们可以完整的分析“案发现场”。这里我们使用Eclipse Memory Analyzer工具来帮忙解析Dump文件。

1460000021636837

从Overview中的饼图可以很明显的看到有个蓝色区域占了最大头,这个类占了245.6MB的内存。再看左侧的说明写着DruidDataSource,好的,罪魁祸首就是他了。

1460000021636839

再通过Domainator_Tree界面可以看到是com.alibaba.druid.pool.DruidDataSource类下的com.alibaba.druid.stat.JdbcDataSourceStat$1对象里面有个LinkedHashMap,这个Map持有了600多个Entry,其中大约有100个Entry大小为2000000多字节(约2MB)。而Entry的key是String对象,看了一下String的内容大约都是select IFNULL(sum remain_principal),0) from t_loan where customer_id in (?, ?, ?, ? ...,果然就是刚才错误日志所提示的代码的功能。

问题分析

由于计算这些用户金额的查询条件有3万多个所以这个SQL语句特别长,然后这些SQL都被JdbcDataSourceStat中的一个HashMap对象所持有导致无法GC,从而导致OOM的发生。

嗯,简直是教科书般的OOM事件。

处理

接下来去看了一下JdbcDataSourceStat的源码,发现有个变量为LinkedHashMap sqlStatMap的Map。并且还有个静态变量和静态代码块:

private static JdbcDataSourceStat global;

static {

String dbType = null;

{

String property = System.getProperty("druid.globalDbType");

if (property != null && property.length() > 0) {

dbType = property;

}

}

global = new JdbcDataSourceStat("Global", "Global", dbType);

}

这就意味着除非手动在代码中释放global对象或者remove掉sqlStatMap里的对象,否则sqlStatMap就会一直被持有不能被GC释放。

已经定位到问题所在了,不过简单的从代码上看无法判定这个sqlStatMap具体是有什么作用,以及如何使其释放掉,于是到网上搜索了一下,发现在其Github的Issues里就有人提出过这个问题了。每个sql语句都会长期持有引用,加快FullGC频率。

sqlStatMap这个对象是用于Druid的监控统计功能的,所以要持有这些SQL用于展示在页面上。由于平时不使用这个功能,且询问其他同事也不清楚为何开启这个功能,所以决定先把这个功能关闭。

根据文档写这个功能默认是关闭的,不过被我们在配置文件中开启了,现在去掉这个配置就可以了

init-method="init" destroy-method="close">

...

修改完上线后一段时间后没有发生OOM了,这时再通过jmap -dump:format=b,file=文件名 [pid]命令生成Dump文件,会发现内存占用恢复正常,并且也不会看到com.alibaba.druid.pool.DruidDataSource类下有com.alibaba.druid.stat.JdbcDataSourceStat$1的占用。证明这个OOM问题已经被成功解决了。

再遭Mybatis暗算

事发

又是一个平凡的工作日,线上告警又出现报错,我一看日志又是OOM。我以为上次DruidDataSource问题还没解决干净,但是这次的现象却有点不一样。首先是这次只告警了一次,不像上次一直在告警。然后远程到服务器看cpu和内存占用正常,业务也没有受影响,所以这次也不用重启服务了。

分析

这次告警的错误日志还是指向着上次DruidDataSource导致OOM异常的位置,由于对其印象深刻,所以这次直接看看Dump文件(由于Dump文件比较大,线上的被清除了,而我也忘记备份,所以这份文件是我时候场景还原的时候生成的)。

1460000021636838

这次没有明显的一个特别大的占用对象了,看来这次的问题确实和上次有所不一样。再去看看Domainator_Tree界面的具体分析。

1460000021636841

虽然没有出现一个对象占用内存,但是可以看到有十几个线程都占用近20M的内存大小,加起来就要占用300多M的内存了。再看一下这些线程中内存占用是怎样的。

1460000021636840

从这个线程的高占用内存情况来看,有几个是String类型的,是拼接SQL后的语句,这些是必不可少的。

还有两个高内存占用对象是org.apache.ibatis.scripting.xmltags.DynamicContext$ContextMap和org.apache.ibatis.builder.SqlSourceBuilder$ParameterMappingTokenHandler。

从这两个对象的内容看似乎是Mybatis拼接SQL的时候生成的占位符和参数对象。就比如下面的这个查询语句

List customerIds = orderService.queryCustomerIdByParam(param);

long principal = orderMapper.countPrincipal(customerIds);

所以虽然用于查询的参数为Long的类型,即使这个List有三万多个其本身也不会占用很大的内存,但是Mybatis在拼接SQL的时候,会把Long类型的对象包装成一个通用的对象类型(类似于AbstractItem的感觉),并且会给每一个通用对象类型起一个别名(比如__frch_item_1, __frch_item_2这样),然后存放在Map中在拼接SQL的时候使用。又由于拼接SQL字符串还是比较消耗资源,当参数多SQL长的时候还是需要一定的时间的,这时候Map就会持有较长时间,一旦有较多线程同时做这种操作,内存占用就高,很容易发生OOM。

查看Mybatis源码分析

首先看org.apache.ibatis.scripting.xmltags.DynamicContext$ContextMap,他是DynamicContext的一个变量,变量名为bindings,是DynamicContext的内部类,继承了HashMap。并且DynamicContext类里用了bind()方法包装了HashMap的put()方法。

1460000021636844

再利用IDEA的Usages of查看功能,看看哪些方法调用了bind()方法。

1460000021636842

可以看到有三个类调用bind()方法,这里只用关注org.apache.ibatis.scripting.xmltags.ForEachSqlNode这个类,因为我们在Mybatis的xml里用了foreach关键字来实现SQL的in查询功能。那我们大致来看一下ForEachSqlNode这个类有什么特别的地方可能导致oom的。

ForEachSqlNode实现了SqlNode接口并实现了apply()方法,这个方法是拼接SQL语句的核心,下面是apply()方法的代码,我为一些关键步骤加了中文注释。

@Override

public boolean apply(DynamicContext context) {

// bindings就是上面说到的占用大内存的对象

Map bindings = context.getBindings();

final Iterable> iterable = evaluator.evaluateIterable(collectionExpression, bindings);

if (!iterable.iterator().hasNext()) {

return true;

}

boolean first = true;

// SQL的开始字符串

applyOpen(context);

int i = 0;

// 遍历参数

for (Object o : iterable) {

DynamicContext oldContext = context;

if (first || separator == null) {

context = new PrefixedContext(context, "");

} else {

context = new PrefixedContext(context, separator);

}

int uniqueNumber = context.getUniqueNumber();

// Issue #709

if (o instanceof Map.Entry) {

// 如果是Map对象则用key value的形式

@SuppressWarnings("unchecked")

Map.Entry mapEntry = (Map.Entry) o;

applyIndex(context, mapEntry.getKey(), uniqueNumber);

applyItem(context, mapEntry.getValue(), uniqueNumber);

} else {

// 以数量i作为key

applyIndex(context, i, uniqueNumber);

applyItem(context, o, uniqueNumber);

}

// FilteredDynamicContext动态生成SQL

contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));

if (first) {

first = !((PrefixedContext) context).isPrefixApplied();

}

context = oldContext;

i++;

}

// SQL的结束字符串

applyClose(context);

context.getBindings().remove(item);

context.getBindings().remove(index);

return true;

}

在每个遍历的时候applyIndex()和applyItem()方法就会将参数和参数的占位符,以及参数SQL前后缀调用上面说的bind()方法存在bindings里。

private void applyIndex(DynamicContext context, Object o, int i) {

if (index != null) {

context.bind(index, o);

context.bind(itemizeItem(index, i), o);

}

}

private void applyItem(DynamicContext context, Object o, int i) {

if (item != null) {

context.bind(item, o);

context.bind(itemizeItem(item, i), o);

}

}

接着用FilteredDynamicContext处理占位符,这是ForEachSqlNode的一个内部类,继承了DynamicContext类,主要重写了appendSql()方法。

private static class FilteredDynamicContext extends DynamicContext {

...

@Override

public void appendSql(String sql) {

GenericTokenParser parser = new GenericTokenParser("#{", "}", content -> {

String newContent = content.replaceFirst("^\\s*" + item + "(?![^.,:\\s])", itemizeItem(item, index));

if (itemIndex != null && newContent.equals(content)) {

newContent = content.replaceFirst("^\\s*" + itemIndex + "(?![^.,:\\s])", itemizeItem(itemIndex, index));

}

return "#{" + newContent + "}";

});

delegate.appendSql(parser.parse(sql));

}

appendSql()用正则来查找替换#{}占位符里的内容,但这里也不是真正的绑定参数,只是替换刚才存在bindings里面的占位符号,例如__frch_item_1, __frch_item_2(在Dump文件中看到的)。

问题分析

由此可以看出问题是,Mybatis的foreach拼接SQL的性能较差,尤其是通过正则之类的操作匹配占位符时需要较多的时间。同时又持有查询参数和占位符在ContextMap中无法被GC释放,所以一旦并发量上去就很容易内存占用过多导致OOM。

场景复现

这个问题在本地很容易复现,我们先创建数据库表

CREATE TABLE user

(

id int(11) PRIMARY KEY NOT NULL,

name varchar(50)

);

创建一个SpringBoot+Mybatis的工程项目。并且模拟线上的JVM配置,在IDEA设置这个工程的VM Option参数-Xmx512m -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError

写出对应模拟线上的foreach语句

select

IFNULL(sum(1),0)

from user where

id in

close=")" separator=",">

#{item}

再写单元测试

@Test

public void count() {

AtomicInteger count = new AtomicInteger(0);

for (int threadId = 0; threadId < 50; threadId++) {

// 起50个线程并发调用countUser()方法

int finalThreadId = threadId;

new Thread(() -> {

long userCount = userMapper.countUser(createIds(10000 + finalThreadId));

log.info("thread:{}, userCount:{}", finalThreadId, userCount);

count.getAndAdd(1);

}).start();

}

// 等待50个查询线程跑完

while (count.get() < 50) {

}

log.info("end!!!!");

}

private List createIds(int size) {

List ids = new ArrayList<>();

for (int i = 0; i < size; i++) {

ids.add((long) i);

}

return ids;

}

接着运行单元测试。由于在JVM配置加了-XX:+PrintGCDetails参数,所以在控制台会显示GC日志,不一会就会看见很多的Full GC,然后程序就会出现OOM报错。

1460000021636843

处理

由于问题是Mybatis通过foreach拼接长SQL字符串性能太差导致的,所以解决思路有两种

通过拆分in的查询条件来减少foreach每次拼接SQL的长度

@Test

public void count2() {

AtomicInteger count = new AtomicInteger(0);

for (int threadId = 0; threadId < 50; threadId++) {

// 起50个线程并发调用countUser()方法

int finalThreadId = threadId;

new Thread(() -> {

List totalIds = createIds(100000 + finalThreadId);

long totalUserCount = 0;

//使用guava对list进行分割,按每1000个一组分割

List> parts = Lists.partition(totalIds, 1000);

for (List ids : parts) {

totalUserCount += userMapper.countUser(ids);

}

log.info("thread:{}, userCount:{}", finalThreadId, totalUserCount);

count.getAndAdd(1);

}).start();

}

// 等待50个查询线程跑完

while (count.get() < 50) {

}

log.info("end!!!!");

}

这样每次拼接查询SQL的时候只用循环1000次,很快就可以把资源释放掉,就不会引起OOM,但是这种方法还是会生成很多不必要的数据占用内存,频繁触发GC,浪费资源。

不使用Mybatis的foreach来拼接in条件的SQL

既然Mybatis的foreach性能不好,那我们通过Java层面自己拼接in条件,特别是针对这种查询条件也比较单一的,更适合自己拼接。

@Test

public void count3() {

AtomicInteger count = new AtomicInteger(0);

for (int threadId = 0; threadId < 50; threadId++) {

// 起50个线程并发调用countUser()方法

int finalThreadId = threadId;

new Thread(() -> {

List ids = createIds(100000 + finalThreadId);

StringBuilder sb = new StringBuilder();

for (long i : ids) {

sb.append(i).append(",");

}

// 查询条件使用String字符串

long userCount = userMapper.countUserString(sb.toString());

log.info("thread:{}, userCount:{}", finalThreadId, userCount);

count.getAndAdd(1);

}).start();

}

// 等待50个查询线程跑完

while (count.get() < 50) {

}

log.info("end!!!!");

}

select

IFNULL(sum(1),0)

from user where

id in (#{ids})

这样就能大大减少使用foreach而生成的对象,同时减少拼接SQL的时间,避免OOM发生的同时优化性能。

后记

这两次遇到OOM问题解决起来还算比较轻松的,除了后续分析得当以外,也离不开预先的环境配置。在服务JVM参数中增加-XX:+HeapDumpOnOutOfMemoryError和-XX:+PrintGCDetails参数,可以在发生OOM的时候输出dump文件,并且能有GC日志查看GC的具体情况,这些都是对于OOM问题非常有帮助的。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/66403.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

上班时间做“副业”被抓,程序员惨遭解雇,还要退还所有工资

大家好&#xff0c;我是校长。 01 做副业被抓 前几天看到 CSDN 报道的一篇新闻。有一个程序员在一个月内两次未能在截止期限之前顺利完成工作&#xff0c;然后呢&#xff0c;主管很生气&#xff0c;很恼火。由于团队项目不能按时完成的话&#xff0c;团队其他的成员的绩效也会跟…

惨遭恶搞的微软与 Github

来源 | 公众号&#xff1a;程序猿 恭喜 GitHub 正式加入 Office 365 大家庭&#xff01; 怎样&#xff1f;有其他开发者吗&#xff1f; 这简直是神预言 GitHub被收购后&#xff0c;GitLab 露出了欣慰的表情 很遗憾&#xff0c;现在不是了 我们的时代要来了 透过屏幕&#xff0c…

惨遭 openssl 不同版本毒打的一天

事情是这样的&#xff0c;是由一个加密的sql文件引发的惨案。 我被这些报错信息毒打了差不多8个小时&#xff0c;然后终于找到了答案&#xff0c;之所以写下来这篇文章&#xff0c;希望能帮到与我遭受同样痛苦的人。 先来看下这个文件: test.des3 里面是个sql文件 我的解密环境…

android wear评测,Android Wear 中国版:惨遭阉割

Android Wear 中国版&#xff1a;惨遭阉割 当得知Moto 360二代要在国内上市的时候&#xff0c;人们开始觉得Moto 360二代这次身负重任&#xff0c;被认为是谷歌重返中国的开端。因此&#xff0c;我们评测过程中就是用安卓手机来作为测试对象&#xff0c;安装的是中国版的Androi…

直播 RTM 推流在抖音的应用与优化

动手点关注 干货不迷路 背景 随着互联网技术以及网络基建的快速发展和普及&#xff0c;视频直播已经成为了一种越来越普遍的娱乐和社交方式。无论是个人还是企业&#xff0c;都可以通过视频直播平台进行直播活动&#xff0c;向观众展示自己的生活、工作或者产品。同时&#xff…

视频号如何使用OBS推流?

一、视频号OBS推流教程 视频号obs推流需要向官方申请开通&#xff0c;请按照如下方法操作开通&#xff1a; 【注意】在申请前一定要完成视频号的认证 第一步&#xff1a;复制链接在浏览器打开&#xff0c;视频号绑定微信扫码登陆视频号助手 画布分辨率&#xff1a;横屏设置比…

【相机】视频推流和拉流

文章目录 来源示意图推流概念主流的推送协议和优缺点RTMPHLSWebRTC 拉流概念 总结实现 来源 https://www.jianshu.com/p/7d0d452063d9 示意图 推流 概念 采集阶段封包好的内容传输到服务器的过程&#xff08;即将客户端录制的视频资源发送到服务器上。&#xff09; 主流的…

音视频采集封装到直播推流的简单原理

那么今天要分享的主要是两个内容&#xff0c;第一个是对硬件采集的资源怎么做一个打包封装&#xff0c;另一个是处理完成的资源如何直播&#xff0c;作为在日常业务测试线的一个业务逻辑扩展&#xff0c;纯粹个人理解&#xff0c;所以不会有一些深入的讲解&#xff0c;毕竟网上…

Android 使用Rtmp音视频推流

前言 本文介绍的是使用Android摄像头、麦克风采集的音、视频进行编码。然后通过librtmp推送到流媒体服务器上的功能。 我所使用的环境&#xff1a;Android Studio 2.2.3 、NDK13。 流程 使用到的Api 音视频采集用到的api有&#xff1a;Camera、AudioRecord编码用的是系统提…

【流媒体】推流与拉流简介

本文目录 一、概念 1.1 推流 1.2 拉流 二、示意图 三、RTMP传输协议 四、流媒体协议与格式 一、概念 话不多说&#xff0c;先了解概念&#xff0c;再看示意图更直观&#xff1a; 1.1 推流 推流&#xff1a;将直播的内容推送至服务器的过程。即指的是把采集阶段封包好的…

【知识拓展】音视频中的推流与拉流

本文目录 一、什么是推流&#xff1f; 二、什么是拉流&#xff1f; 一、什么是推流&#xff1f; 先来看张图片&#xff0c;看着图再配上文字容易理解&#xff1a; 推流&#xff0c;指的是把采集阶段封包好的内容传输到服务器的过程。其实就是将现场的视频信号传到网络的过程…

视频点播RTMP推流直播流媒体服务二次开发集成接口

LiveQing流媒体服务器软件&#xff0c;提供一站式的转码、点播、直播、时移回放服务&#xff0c;极大地简化了开发和集成的工作。 其中&#xff0c;点播功能主要包含&#xff1a;上传、转码、分发。直播功能&#xff0c;主要包含&#xff1a;直播、录像&#xff0c; 直播支持R…

音视频直播推流和拉流到底是什么意思?

为什么直播现场的信息&#xff0c;用户通过手机或者网站就能很快的看到呢&#xff1f;为什么有时候网络不稳定&#xff0c;直播效果会有延迟呢&#xff1f;现场的视频信号又是如何传到网络呢&#xff1f; 这些所有问题的产生&#xff0c;都离不开视频直播中常说的两个词&#…

视频直播推流攻略(整理的各大平台推流界面)

如果我要做一场高端大气的直播活动&#xff0c;需要用高清摄像机拍摄画面&#xff0c;需要接入无人机的高空画面&#xff0c;需要在直播中插入多个镜头&#xff0c;甚至需要把电脑桌面共享到直播画面中...... 该如何将这类现场信号采集并整合起来传输到网络呢&#xff1f; 这个…

音视频流媒体-推流与拉流简介

一、概念 话不多说&#xff0c;先了解概念&#xff0c;再看示意图更直观&#xff1a; 1.1 推流 推流&#xff1a;将直播的内容推送至服务器的过程。即指的是把采集阶段封包好的内容传输到服务器的过程。其实就是将现场的视频信号传到网络的过程。“推流”对网络要求比较高&a…

Feishu(飞书) 聊天机器人应用(3/3)- DevOps 机器人助手,管理 GitLab Issues,BOT 开源示例程序

目录 DevOps 机器人助手命令示例 配置使用创建机器人设置环境变量GITLAB_URLPRIVATE_TOKENVALID_PROJECTS 修改对话使用帮助本系列文章 在上一篇文章&#xff1a;Feishu(飞书) 聊天机器人应用&#xff08;2/2&#xff09;- 定制对话&#xff0c;实现知识库、信息查询、意图识别…

什么是飞书机器人?如何定时发送飞书机器人消息?

什么是飞书机器人&#xff1f; 机器人是飞书应用的一种能力类型。基于飞书的机器人能力&#xff0c;开发者能够以较低的开发成本&#xff08;只需在服务端开发&#xff09;&#xff0c;实现在飞书单聊或群组中的消息推送和简单互动&#xff0c;完成企业系统数据与飞书的互联互通…

RPA工具实现飞书群聊自动发送信息,我终于也有了自己的机器人

一&#xff0e;RPA究竟是个啥&#xff1f; 首先&#xff0c;他的全称是&#xff1a;Robotic Process Automation&#xff0c;嫌太长&#xff0c;我们三个单词拆开看看&#xff1a; Robotic&#xff1a;软件机器人&#xff0c;不是物理上能走能动的机器人哦&#xff0c;而是一…

android面试:面向移动应用开发者的 Android 面试经常提问到的15道面试

Android 软件开发是为运行 Android 操作系统的设备创建应用程序的过程。可以使用 Android 软件开发工具包使用 Kotlin、Java 和 C 语言编写 Android 应用程序。以下是关于 Android 的编码面试问题列表&#xff0c;可帮助您秋招下一次技术面试做好准备。 &#x1f539; 1. 提一…

字节跳动资深面试官亲述:面试应该注意哪些问题?

01面试做匹配 面试官的根本目的在于考察你这个人是否与招聘岗位相匹配&#xff0c;衡量能否胜任工作&#xff0c;我们在面试中要做到的就是让面试官相信我们能够与应聘岗位相匹配。 针对一些面试题做了总结&#xff1a; 1.请简单进行一下自我介绍 首先请报出自己的姓名和身…