JDBC游标读不生效导致OOM问题排查分析
<h1 id="jdbc游标读不生效导致oom问题排查分析">JDBC游标读不生效导致OOM问题排查分析</h1><h2 id="问题描述">问题描述</h2>
<p>程序使用游标读分批读取MySQL的数据,但是<strong>程序容器却发生OOM</strong></p>
<h2 id="基本信息">基本信息</h2>
<p>MySQL版本:8.0.25</p>
<p>JDBC版本:8.0.25</p>
<p>JDBC配置:</p>
<pre><code class="language-Java">connectionProperties=useUnicode=true;autoReconnect=true;defaultFetchSize=800;useServerPrepStmts=false;rewriteBatchedStatements=true;useCompression=true;useCursorFetch=true;allowMultiQueries=true
</code></pre>
<p>批量程序的OOM日志:</p>
<p><img src="https://img2024.cnblogs.com/other/2630741/202507/2630741-20250711102436259-1738499230.png"></p>
<p><img src="https://img2024.cnblogs.com/other/2630741/202507/2630741-20250711102436910-1022322117.png"></p>
<h2 id="问题分析">问题分析</h2>
<p>获取dump下来的内存快照后,使用jdk自带的Java visualVM打开后,找到右侧最大的对象:</p>
<p><img src="https://img2024.cnblogs.com/other/2630741/202507/2630741-20250711102437550-920227067.png"></p>
<p>发现<code>java.lang.Object[]</code>最大,点击后发现里面存的是<code>ByteArrayRow</code>类型对象,它是数据库的游标对象,说明在查询数据库的过程中,内存已经溢出,还没来得及转换成实体类,说明此时游标读失效。</p>
<p><img src="https://img2024.cnblogs.com/other/2630741/202507/2630741-20250711102438082-1027333113.png"></p>
<p>通过查看堆栈上的线程报错信息</p>
<p><img src="https://img2024.cnblogs.com/other/2630741/202507/2630741-20250711102438670-1070887657.png"></p>
<p>显示的代码的流程调用的是<code>ClientPreparedStatement</code>类的方法,没有调用<code>ServerPreparedStatement</code>类的方法,调用的是客户端来执行,此时是普通读。</p>
<p>利用游标读demo测试,发现游标读的调用时走<code>ServerPreparedStatement</code>类的方法(下图第3、4行),然后调用<code>ServerPreparedQuery</code>类的<code>ServerPreparedQuery</code>方法(下图第1行)</p>
<p><img src="https://img2024.cnblogs.com/other/2630741/202507/2630741-20250711102439050-775420620.png"></p>
<p>查看源码,<code>ServerPreparedQuery</code>方法中调用了<code>packet.writeInteger(IntegerDataType.</code><em><code>INT1</code></em><code>, </code><em><code>OPEN_CURSOR_FLAG</code></em><code>)</code>方法进行游标读。</p>
<p><img src="https://img2024.cnblogs.com/other/2630741/202507/2630741-20250711102439367-527127141.png"></p>
<p><code>ClientPreparedStatement</code>:查询是在客户端准备的。这意味着所有的SQL语句处理,包括参数替换,都在客户端完成,然后作为一个整体发送到服务器,只能普通读。</p>
<p><code>ServerPreparedStatement</code>:查询是在服务器端准备的。这意味着SQL语句和其参数在服务器上被处理,这可以利用服务器的某些优化特性,可以普通读、游标读、流式读。</p>
<p><strong>进一步分析,PreparedStatement的具体实现什么时候确定是ClientPreparedStatement还是ServerPreparedStatement?</strong></p>
<p>在调用<code>Connection.prepareStatement()</code>或<code>Connection.prepareStatement(String sql, int resultSetType, int resultSetConcurrency)</code>等方法时,JDBC驱动会根据当前的配置和数据库服务器的能力来确定使用哪种PreparedStatement实现。</p>
<pre><code class="language-Java">@Override
public java.sql.PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
synchronized (getConnectionMutex()) {
checkClosed();
//
// FIXME: Create warnings if can't create results of the given type or concurrency
//
ClientPreparedStatement pStmt = null;
boolean canServerPrepare = true;
String nativeSql = this.processEscapeCodesForPrepStmts.getValue() ? nativeSQL(sql) : sql;
if (this.useServerPrepStmts.getValue() && this.emulateUnsupportedPstmts.getValue()) {
canServerPrepare = canHandleAsServerPreparedStatement(nativeSql);
}
if (this.useServerPrepStmts.getValue() && canServerPrepare) {
if (this.cachePrepStmts.getValue()) {
synchronized (this.serverSideStatementCache) {
pStmt = this.serverSideStatementCache.remove(new CompoundCacheKey(this.database, sql));
if (pStmt != null) {
((com.mysql.cj.jdbc.ServerPreparedStatement) pStmt).setClosed(false);
pStmt.clearParameters();
}
if (pStmt == null) {
try {
pStmt = ServerPreparedStatement.getInstance(getMultiHostSafeProxy(), nativeSql, this.database, resultSetType,
resultSetConcurrency);
if (sql.length() < this.prepStmtCacheSqlLimit.getValue()) {
((com.mysql.cj.jdbc.ServerPreparedStatement) pStmt).isCacheable = true;
}
pStmt.setResultSetType(resultSetType);
pStmt.setResultSetConcurrency(resultSetConcurrency);
} catch (SQLException sqlEx) {
// Punt, if necessary
if (this.emulateUnsupportedPstmts.getValue()) {
pStmt = (ClientPreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
if (sql.length() < this.prepStmtCacheSqlLimit.getValue()) {
this.serverSideStatementCheckCache.put(sql, Boolean.FALSE);
}
} else {
throw sqlEx;
}
}
}
}
} else {
try {
pStmt = ServerPreparedStatement.getInstance(getMultiHostSafeProxy(), nativeSql, this.database, resultSetType, resultSetConcurrency);
pStmt.setResultSetType(resultSetType);
pStmt.setResultSetConcurrency(resultSetConcurrency);
} catch (SQLException sqlEx) {
// Punt, if necessary
if (this.emulateUnsupportedPstmts.getValue()) {
pStmt = (ClientPreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
} else {
throw sqlEx;
}
}
}
} else {
pStmt = (ClientPreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
}
return pStmt;
}
}
</code></pre>
<p>通过debug发现,会走到16行的 <code>canServerPrepare = canHandleAsServerPreparedStatement(nativeSql);</code></p>
<p>说明在jdbc配置<code>useServerPrepStmts=true</code>是生效的,<code>emulateUnsupportedPstmts</code>系统默认值就是<strong>true</strong>,判断成立。</p>
<p>继续debug,进入<code>canHandleAsServerPreparedStatement</code>方法</p>
<pre><code class="language-Java">private boolean canHandleAsServerPreparedStatement(String sql) throws SQLException {
if (sql == null || sql.length() == 0) {
return true;
}
if (!this.useServerPrepStmts.getValue()) {
return false;
}
boolean allowMultiQueries = this.propertySet.getBooleanProperty(PropertyKey.allowMultiQueries).getValue();
if (this.cachePrepStmts.getValue()) {
synchronized (this.serverSideStatementCheckCache) {
Boolean flag = this.serverSideStatementCheckCache.get(sql);
if (flag != null) {
return flag.booleanValue();
}
boolean canHandle = StringUtils.canHandleAsServerPreparedStatementNoCache(sql, getServerVersion(), allowMultiQueries,
this.session.getServerSession().isNoBackslashEscapesSet(), this.session.getServerSession().useAnsiQuotedIdentifiers());
if (sql.length() < this.prepStmtCacheSqlLimit.getValue()) {
this.serverSideStatementCheckCache.put(sql, canHandle ? Boolean.TRUE : Boolean.FALSE);
}
return canHandle;
}
}
return StringUtils.canHandleAsServerPreparedStatementNoCache(sql, getServerVersion(), allowMultiQueries,
this.session.getServerSession().isNoBackslashEscapesSet(), this.session.getServerSession().useAnsiQuotedIdentifiers());
}
</code></pre>
<p><code>cachePrepStmts</code>默认值是<strong>false</strong>,前面的判断是不成立的,直接走到最后的<code>StringUtils</code>类的<em><code>canHandleAsServerPreparedStatementNoCache</code></em>方法。</p>
<p><img src="https://img2024.cnblogs.com/other/2630741/202507/2630741-20250711102439562-1508404755.png"></p>
<pre><code class="language-Java">public static boolean canHandleAsServerPreparedStatementNoCache(String sql, ServerVersion serverVersion, boolean allowMultiQueries,
boolean noBackslashEscapes, boolean useAnsiQuotes) {
// Can't use server-side prepare for CALL
if (startsWithIgnoreCaseAndNonAlphaNumeric(sql, "CALL")) {
return false;
}
boolean canHandleAsStatement = true;
boolean allowBackslashEscapes = !noBackslashEscapes;
String quoteChar = useAnsiQuotes ? "\"" : "'";
if (allowMultiQueries) {
if (StringUtils.indexOfIgnoreCase(0, sql, ";", quoteChar, quoteChar,
allowBackslashEscapes ? StringUtils.SEARCH_MODE__ALL : StringUtils.SEARCH_MODE__MRK_COM_WS) != -1) {
canHandleAsStatement = false;
}
} else if (startsWithIgnoreCaseAndWs(sql, "XA ")) {
canHandleAsStatement = false;
} else if (startsWithIgnoreCaseAndWs(sql, "CREATE TABLE")) {
canHandleAsStatement = false;
} else if (startsWithIgnoreCaseAndWs(sql, "DO")) {
canHandleAsStatement = false;
} else if (startsWithIgnoreCaseAndWs(sql, "SET")) {
canHandleAsStatement = false;
} else if (StringUtils.startsWithIgnoreCaseAndWs(sql, "SHOW WARNINGS") && serverVersion.meetsMinimum(ServerVersion.parseVersion("5.7.2"))) {
canHandleAsStatement = false;
} else if (sql.startsWith("/* ping */")) {
canHandleAsStatement = false;
}
return canHandleAsStatement;
}
</code></pre>
<p><code>canHandleAsServerPreparedStatementNoCache</code>是在不开启缓存的情况下是否能使用<code>ServerPreparedStatement</code>。</p>
<p>根据后续反馈,游标读不是一直不生效,只是在运行某个sql的时候不生效,为了隐私,这里将这个sql简化为</p>
<pre><code>select * from t;
</code></pre>
<p>由于sql不是CALL开头而且jdbc的参数<code>allowMultiQueries=true</code>会走到15行的代码,<code>indexOfIgnoreCase</code>方法的意思是在字符串中查找子字符串的位置,忽略大小写,并有选择地跳过由给定标记限定的文本或在注释中的文本。</p>
<p>这行的代码意思<strong>在sql语句中查找;的位置,忽略''符号之间的内容,如果不存在,即返回-1,就允许使用<strong><strong><code>ServerPreparedStatement</code></strong></strong>,否则使用****<code>ClientPreparedStatement</code></strong>。经过debug,确实会走到这里。</p>
<p><img src="https://img2024.cnblogs.com/other/2630741/202507/2630741-20250711102439757-1366697539.png"></p>
<h2 id="问题总结">问题总结</h2>
<p>问题发生路径:开启<code>allowMultiQueries=true</code>且当前sql带分号 ——></p>
<p><code>canHandleAsServerPreparedStatementNoCache</code>返回值为false ——></p>
<p><code>canHandleAsServerPreparedStatement</code>返回值为false——></p>
<p>执行 <code>(ClientPreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false)</code>返回<code>ClientPreparedStatement</code> ——></p>
<p>客户端执行普通读。</p>
<h2 id="使用建议">使用建议</h2>
<ol>
<li>默认地书写SQL时去掉后面的分号;</li>
<li>不要开启<code>allowMultiQueries=true</code>,其默认值为<strong>false</strong>(默认设置下会影响到需要多语句执行的场景,可根据实际需要临时开启)。</li>
</ol>
<p>全文完。</p>
<hr>
<p>Enjoy GreatSQL 😃</p>
<h2 id="关于-greatsql">关于 GreatSQL</h2>
<p>GreatSQL是适用于金融级应用的国内自主开源数据库,具备高性能、高可靠、高易用性、高安全等多个核心特性,可以作为MySQL或Percona Server的可选替换,用于线上生产环境,且完全免费并兼容MySQL或Percona Server。</p>
<p>相关链接: GreatSQL社区 Gitee GitHub Bilibili</p>
<h2 id="greatsql社区">GreatSQL社区:</h2>
<blockquote>
<p>社区博客有奖征稿详情:https://greatsql.cn/thread-100-1-1.html</p>
</blockquote>
<p><img src="https://img2024.cnblogs.com/other/2630741/202507/2630741-20250711102439941-1110031868.png"></p>
<h2 id="技术交流群">技术交流群:</h2>
<blockquote>
<p>微信:扫码添加<code>GreatSQL社区助手</code>微信好友,发送验证信息<code>加群</code>。</p>
</blockquote>
<p><img src="https://img2024.cnblogs.com/other/2630741/202507/2630741-20250711102440184-1331224040.png"></p><br><br>
来源:https://www.cnblogs.com/greatsql/p/18978367
頁:
[1]