JDBC游标读取失效引发内存溢出问题的排查剖析

JDBC游标读取失效引发内存溢出问题的排查解析

问题呈现

程序采用游标进行分批次读取MySQL的数据,然而程序所在的容器出现了内存溢出(OOM)的情况。

基础信息

MySQL版本:8.0.25
JDBC版本:8.0.25

JDBC的配置情况如下:

connectionProperties=useUnicode=true;autoReconnect=true;defaultFetchSize=800;useServerPrepStmts=false;rewriteBatchedStatements=true;useCompression=true;useCursorFetch=true;allowMultiQueries=true

批量程序出现OOM的日志情况:

问题剖析

获取到dump下来的内存快照后,使用jdk自带的Java visualVM打开,找到右侧最大的对象:
发现java.lang.Object[]是最大的,点击后看到里面存的是ByteArrayRow类型对象,这是数据库的游标对象,表明在查询数据库的过程中内存已经溢出,还没来得及转换成实体类,说明此时游标读失效。

通过查看堆栈上的线程报错信息,显示代码的流程调用的是ClientPreparedStatement类的方法,没有调用ServerPreparedStatement类的方法,是由客户端来执行的,此时属于普通读。

利用游标读的demo进行测试,发现游标读的调用走的是ServerPreparedStatement类的方法(下图第3、4行),然后调用ServerPreparedQuery类的ServerPreparedQuery方法(下图第1行)。

查看源码,ServerPreparedQuery方法中调用了packet.writeInteger(IntegerDataType.INT1,OPEN_CURSOR_FLAG)方法来进行游标读。

ClientPreparedStatement:查询是在客户端进行准备的。这意味着所有的SQL语句处理,包括参数替换,都在客户端完成,然后作为一个整体发送到服务器,只能进行普通读。

ServerPreparedStatement:查询是在服务器端进行准备的。这意味着SQL语句和其参数在服务器上被处理,能够利用服务器的某些优化特性,可以进行普通读、游标读、流式读。

进一步探究,PreparedStatement的具体实现何时确定是ClientPreparedStatement还是ServerPreparedStatement?

在调用Connection.prepareStatement()Connection.prepareStatement(String sql,
int resultSetType, int
resultSetConcurrency)
等方法时,JDBC驱动会依据当前的配置以及数据库服务器的能力来确定使用哪种PreparedStatement实现。

@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;
    }
}

通过debug发现,会走到16行的 canServerPrepare =
canHandleAsServerPreparedStatement(nativeSql);

这表明在jdbc配置useServerPrepStmts=true是生效的,emulateUnsupportedPstmts系统默认值就是true,判断成立。

继续debug,进入canHandleAsServerPreparedStatement方法:

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());
}

cachePrepStmts默认值是false,前面的判断不成立,直接走到最后的StringUtils类的canHandleAsServerPreparedStatementNoCache方法。

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;
}

canHandleAsServerPreparedStatementNoCache是在不开启缓存的情况下判断是否能使用ServerPreparedStatement

根据后续反馈,游标读并非一直不生效,只是在运行某个sql的时候不生效,为了隐私,这里将这个sql简化为

select * from t;

由于sql不是以CALL开头而且jdbc的参数allowMultiQueries=true会走到15行的代码,indexOfIgnoreCase方法的作用是在字符串中查找子字符串的位置,忽略大小写,并有选择地跳过由给定标记限定的文本或在注释中的文本。
这行代码的意思是在sql语句中查找;的位置,忽略''符号之间的内容,如果不存在,即返回-1,就允许使用ServerPreparedStatement,否则使用ClientPreparedStatement。经过debug,确实会走到这里。

问题总结

问题发生路径:开启allowMultiQueries=true且当前sql带有分号 ——>

canHandleAsServerPreparedStatementNoCache返回值为false ——>

canHandleAsServerPreparedStatement返回值为false ——>

执行 (ClientPreparedStatement) clientPrepareStatement(nativeSql, resultSetType,
resultSetConcurrency, false)
返回ClientPreparedStatement ——>

客户端执行普通读。

使用建议

  1. 默认情况下书写SQL时去掉后面的分号;
  2. 不要开启allowMultiQueries=true,其默认值为false(默认设置下会影响到需要多语句执行的场景,可根据实际需要临时开启)。

全文完。

文章整理自互联网,只做测试使用。发布者:Lomu,转转请注明出处:https://www.it1024doc.com/13058.html

(0)
LomuLomu
上一篇 2025 年 8 月 5 日
下一篇 2025 年 8 月 5 日

相关推荐

  • idea 2025.3 激活码亲测有效

    声明:本指南所涉及的IntelliJ IDEA破解补丁及激活码均来源于网络收集,仅限个人学习研究使用,严禁商业用途。如内容涉嫌侵权,请联系本人删除。经济条件允许的话,强烈建议购买官方正版授权! 话不多说,先上图证明实力。下图是IDEA 2025.2.1版本成功破解后的截图,可以看到激活有效期已经飙到2099年了,相当给力! 接下来,我将通过图文详解的方式,手…

    IDEA破解教程 3天前
    1200
  • 2025年最新PyCharm激活码及永久破解教程(支持2099年)

    适用于JetBrains全家桶的完美破解方案 今天给大家分享一个超实用的教程,适用于PyCharm、IDEA、DataGrip等JetBrains全家桶软件的永久激活方法。先上效果图,可以看到我的PyCharm已经成功激活到2099年了! 下面将详细介绍如何一步步实现PyCharm的永久激活,这个方法同样适用于旧版本,无论你使用Windows、Mac还是Li…

    PyCharm激活码 2025 年 7 月 4 日
    30200
  • 最新datagrip破解方案搭配永久激活码使用

    免责声明:下文所涉 DataGrip 破解补丁与激活码均来自互联网公开渠道,仅供个人学习研究,禁止商业用途。如条件允许,请支持正版!官方正版低至 32 元/年,购买地址:https://panghu.hicxy.com/shop/?id=18 DataGrip 是 JetBrains 打造的一款跨平台数据库 IDE,支持 Windows、macOS 与 Li…

    DataGrip激活码 2025 年 12 月 1 日
    12100
  • 最新datagrip激活码管理工具+破解指南

    申明:本教程 DataGrip 破解补丁、激活码均收集于网络,请勿商用,仅供个人学习使用,如有侵权,请联系作者删除。若条件允许,希望大家购买正版 ! 废话不多说,先上 DataGrip 2025.2.1 版本破解成功的截图,如下图,可以看到已经成功破解到 2099 年辣,舒服的很! 接下来就给大家通过图文的方式分享一下如何破解最新的DataGrip 。 如果…

    DataGrip激活码 2026 年 1 月 24 日
    4500
  • 如何做好软件架构师

    本文以个人视野聊下软件架构师的工作以及软件架构设计知识。做开发工作接近10年了,期间主要做Windows应用开发。在成熟的“华南区最大WPF团队”希沃白板呆了较长一段时间、后面从0到1构建Windows技术栈以及会议屏软件集,在软件设计这块自己成长了很多。之前整理过如何做好技术经理 – 唐宋元明清2188 – 博客园,这里梳理下自己的设计思维,算是自己阶段性…

    未分类 2025 年 1 月 12 日
    39900

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信