找回密码
 立即注册
首页 业界区 安全 JDBC游标读不生效导致OOM问题排查分析

JDBC游标读不生效导致OOM问题排查分析

钿稳铆 昨天 14:45
JDBC游标读不生效导致OOM问题排查分析

问题描述

程序使用游标读分批读取MySQL的数据,但是程序容器却发生OOM
基本信息

MySQL版本:8.0.25
JDBC版本:8.0.25
JDBC配置:
  1. connectionProperties=useUnicode=true;autoReconnect=true;defaultFetchSize=800;useServerPrepStmts=false;rewriteBatchedStatements=true;useCompression=true;useCursorFetch=true;allowMultiQueries=true
复制代码
批量程序的OOM日志:
1.png

2.png

问题分析

获取dump下来的内存快照后,使用jdk自带的Java visualVM打开后,找到右侧最大的对象:
3.png

发现java.lang.Object[]最大,点击后发现里面存的是ByteArrayRow类型对象,它是数据库的游标对象,说明在查询数据库的过程中,内存已经溢出,还没来得及转换成实体类,说明此时游标读失效。
4.png

通过查看堆栈上的线程报错信息
5.png

显示的代码的流程调用的是ClientPreparedStatement类的方法,没有调用ServerPreparedStatement类的方法,调用的是客户端来执行,此时是普通读。
利用游标读demo测试,发现游标读的调用时走ServerPreparedStatement类的方法(下图第3、4行),然后调用ServerPreparedQuery类的ServerPreparedQuery方法(下图第1行)
6.png

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

ClientPreparedStatement:查询是在客户端准备的。这意味着所有的SQL语句处理,包括参数替换,都在客户端完成,然后作为一个整体发送到服务器,只能普通读。
ServerPreparedStatement:查询是在服务器端准备的。这意味着SQL语句和其参数在服务器上被处理,这可以利用服务器的某些优化特性,可以普通读、游标读、流式读。
进一步分析,PreparedStatement的具体实现什么时候确定是ClientPreparedStatement还是ServerPreparedStatement?
在调用Connection.prepareStatement()或Connection.prepareStatement(String sql, int resultSetType, int resultSetConcurrency)等方法时,JDBC驱动会根据当前的配置和数据库服务器的能力来确定使用哪种PreparedStatement实现。
  1. @Override
  2. public java.sql.PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
  3.     synchronized (getConnectionMutex()) {
  4.         checkClosed();
  5.         //
  6.         // FIXME: Create warnings if can't create results of the given type or concurrency
  7.         //
  8.         ClientPreparedStatement pStmt = null;
  9.         boolean canServerPrepare = true;
  10.         String nativeSql = this.processEscapeCodesForPrepStmts.getValue() ? nativeSQL(sql) : sql;
  11.         if (this.useServerPrepStmts.getValue() && this.emulateUnsupportedPstmts.getValue()) {
  12.             canServerPrepare = canHandleAsServerPreparedStatement(nativeSql);
  13.         }
  14.         if (this.useServerPrepStmts.getValue() && canServerPrepare) {
  15.             if (this.cachePrepStmts.getValue()) {
  16.                 synchronized (this.serverSideStatementCache) {
  17.                     pStmt = this.serverSideStatementCache.remove(new CompoundCacheKey(this.database, sql));
  18.                     if (pStmt != null) {
  19.                         ((com.mysql.cj.jdbc.ServerPreparedStatement) pStmt).setClosed(false);
  20.                         pStmt.clearParameters();
  21.                     }
  22.                     if (pStmt == null) {
  23.                         try {
  24.                             pStmt = ServerPreparedStatement.getInstance(getMultiHostSafeProxy(), nativeSql, this.database, resultSetType,
  25.                                     resultSetConcurrency);
  26.                             if (sql.length() < this.prepStmtCacheSqlLimit.getValue()) {
  27.                                 ((com.mysql.cj.jdbc.ServerPreparedStatement) pStmt).isCacheable = true;
  28.                             }
  29.                             pStmt.setResultSetType(resultSetType);
  30.                             pStmt.setResultSetConcurrency(resultSetConcurrency);
  31.                         } catch (SQLException sqlEx) {
  32.                             // Punt, if necessary
  33.                             if (this.emulateUnsupportedPstmts.getValue()) {
  34.                                 pStmt = (ClientPreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
  35.                                 if (sql.length() < this.prepStmtCacheSqlLimit.getValue()) {
  36.                                     this.serverSideStatementCheckCache.put(sql, Boolean.FALSE);
  37.                                 }
  38.                             } else {
  39.                                 throw sqlEx;
  40.                             }
  41.                         }
  42.                     }
  43.                 }
  44.             } else {
  45.                 try {
  46.                     pStmt = ServerPreparedStatement.getInstance(getMultiHostSafeProxy(), nativeSql, this.database, resultSetType, resultSetConcurrency);
  47.                     pStmt.setResultSetType(resultSetType);
  48.                     pStmt.setResultSetConcurrency(resultSetConcurrency);
  49.                 } catch (SQLException sqlEx) {
  50.                     // Punt, if necessary
  51.                     if (this.emulateUnsupportedPstmts.getValue()) {
  52.                         pStmt = (ClientPreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
  53.                     } else {
  54.                         throw sqlEx;
  55.                     }
  56.                 }
  57.             }
  58.         } else {
  59.             pStmt = (ClientPreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false);
  60.         }
  61.         return pStmt;
  62.     }
  63. }
复制代码
通过debug发现,会走到16行的 canServerPrepare = canHandleAsServerPreparedStatement(nativeSql);
说明在jdbc配置useServerPrepStmts=true是生效的,emulateUnsupportedPstmts系统默认值就是true,判断成立。
继续debug,进入canHandleAsServerPreparedStatement方法
  1. private boolean canHandleAsServerPreparedStatement(String sql) throws SQLException {
  2.     if (sql == null || sql.length() == 0) {
  3.         return true;
  4.     }
  5.     if (!this.useServerPrepStmts.getValue()) {
  6.         return false;
  7.     }
  8.     boolean allowMultiQueries = this.propertySet.getBooleanProperty(PropertyKey.allowMultiQueries).getValue();
  9.     if (this.cachePrepStmts.getValue()) {
  10.         synchronized (this.serverSideStatementCheckCache) {
  11.             Boolean flag = this.serverSideStatementCheckCache.get(sql);
  12.             if (flag != null) {
  13.                 return flag.booleanValue();
  14.             }
  15.             boolean canHandle = StringUtils.canHandleAsServerPreparedStatementNoCache(sql, getServerVersion(), allowMultiQueries,
  16.                     this.session.getServerSession().isNoBackslashEscapesSet(), this.session.getServerSession().useAnsiQuotedIdentifiers());
  17.             if (sql.length() < this.prepStmtCacheSqlLimit.getValue()) {
  18.                 this.serverSideStatementCheckCache.put(sql, canHandle ? Boolean.TRUE : Boolean.FALSE);
  19.             }
  20.             return canHandle;
  21.         }
  22.     }
  23.     return StringUtils.canHandleAsServerPreparedStatementNoCache(sql, getServerVersion(), allowMultiQueries,
  24.             this.session.getServerSession().isNoBackslashEscapesSet(), this.session.getServerSession().useAnsiQuotedIdentifiers());
  25. }
复制代码
cachePrepStmts默认值是false,前面的判断是不成立的,直接走到最后的StringUtils类的canHandleAsServerPreparedStatementNoCache方法。
8.png
  1. public static boolean canHandleAsServerPreparedStatementNoCache(String sql, ServerVersion serverVersion, boolean allowMultiQueries,
  2.         boolean noBackslashEscapes, boolean useAnsiQuotes) {
  3.     // Can't use server-side prepare for CALL
  4.     if (startsWithIgnoreCaseAndNonAlphaNumeric(sql, "CALL")) {
  5.         return false;
  6.     }
  7.     boolean canHandleAsStatement = true;
  8.     boolean allowBackslashEscapes = !noBackslashEscapes;
  9.     String quoteChar = useAnsiQuotes ? """ : "'";
  10.     if (allowMultiQueries) {
  11.         if (StringUtils.indexOfIgnoreCase(0, sql, ";", quoteChar, quoteChar,
  12.                 allowBackslashEscapes ? StringUtils.SEARCH_MODE__ALL : StringUtils.SEARCH_MODE__MRK_COM_WS) != -1) {
  13.             canHandleAsStatement = false;
  14.         }
  15.     } else if (startsWithIgnoreCaseAndWs(sql, "XA ")) {
  16.         canHandleAsStatement = false;
  17.     } else if (startsWithIgnoreCaseAndWs(sql, "CREATE TABLE")) {
  18.         canHandleAsStatement = false;
  19.     } else if (startsWithIgnoreCaseAndWs(sql, "DO")) {
  20.         canHandleAsStatement = false;
  21.     } else if (startsWithIgnoreCaseAndWs(sql, "SET")) {
  22.         canHandleAsStatement = false;
  23.     } else if (StringUtils.startsWithIgnoreCaseAndWs(sql, "SHOW WARNINGS") && serverVersion.meetsMinimum(ServerVersion.parseVersion("5.7.2"))) {
  24.         canHandleAsStatement = false;
  25.     } else if (sql.startsWith("/* ping */")) {
  26.         canHandleAsStatement = false;
  27.     }
  28.     return canHandleAsStatement;
  29. }
复制代码
canHandleAsServerPreparedStatementNoCache是在不开启缓存的情况下是否能使用ServerPreparedStatement。
根据后续反馈,游标读不是一直不生效,只是在运行某个sql的时候不生效,为了隐私,这里将这个sql简化为
  1. select * from t;
复制代码
由于sql不是CALL开头而且jdbc的参数allowMultiQueries=true会走到15行的代码,indexOfIgnoreCase方法的意思是在字符串中查找子字符串的位置,忽略大小写,并有选择地跳过由给定标记限定的文本或在注释中的文本。
这行的代码意思在sql语句中查找;的位置,忽略''符号之间的内容,如果不存在,即返回-1,就允许使用ServerPreparedStatement,否则使用****ClientPreparedStatement。经过debug,确实会走到这里。
9.png

问题总结

问题发生路径:开启allowMultiQueries=true且当前sql带分号 ——>
canHandleAsServerPreparedStatementNoCache返回值为false ——>
canHandleAsServerPreparedStatement返回值为false  ——>
执行 (ClientPreparedStatement) clientPrepareStatement(nativeSql, resultSetType, resultSetConcurrency, false)返回ClientPreparedStatement ——>
客户端执行普通读。
使用建议


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

Enjoy GreatSQL
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册