MAT分析内存溢出- ShardingSphere JDBC的缓存泄露问题

MAT分析Dump文件:

1、设置MemoryAnalyzer.ini中的-Xmx为需要用的大小,否则会遇到打开dump文件报错。。

2、dump文件导出配置:在节点配置中增加dump导出

ruby 复制代码
-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/path/to/dump/heapdump.hprof

3、dump文档加载出来后,图示如下:

核心指标解析:

  • Histogram:直方图,列出每个类的实例数量

    • Objects:对象个数
    • Shallow Heap:浅堆,表示对象占用多少内存。
    • Retained Heap:深堆,表示对象依赖的底层所有对象的总内存。
    • with outgoing references:此对象引用了哪些对象
    • with incoming references:此对象被谁引用
  • Dominator Tree:支配树,列出最大的对象以及它们使哪些对象保持存活。

  • Top Consumers:按类和包对最占用资源的对象进行分组打印。

  • Duplicate Classes:检测由多个类加载器加载的类。

  • Leak Suspects:怀疑内存泄露

  • Top Components:列出占堆总大小超过 1% 的组件报告。

  • Component Report:组件报告,分析属于同一个根包或类加载器的对象。

可排查的问题类型:

  1. 内存泄漏(Memory Leak)

    • 特征:内存占用持续增长,最终触发OutOfMemoryError
    • 常见场景:静态集合未清理、监听器未移除、ThreadLocal使用不当、资源未关闭(流、连接)等。
  2. 内存溢出(OOM)

    • 堆内存不足:对象过多且无法回收。
    • 元空间 / 永久代溢出:类加载过多或常量池过大。
  3. 内存使用效率低

    • 大对象频繁创建(如大字符串、大数组)导致 GC 频繁。
    • 缓存设计不合理(如缓存未设置过期策略,导致对象堆积)。
  4. 线程相关问题

    • 线程泄漏(线程创建后未销毁,持有大量资源)。
    • 死锁或阻塞导致的资源无法释放。

排查流程:

1、定位可疑区域:查看报告中的Leak Suspects,每个Suspect会【显示可疑对象占用内存比例】;重点看Problem Suspect 1(最可能的泄漏点),记录其对象类型(如java.util.HashMap)和支配树路径(如MyCache → HashMap → Entry[])。

解析:org.springframework.boot.loader.LaunchedURLClassLoader @0x4c0005138 是一个类加载器,占用了约 2,014,349,632 字节(占比 67.97% ),这些内存主要是因为加载了 com.google.common.cache.LocalCache$Segment[] 这个实例导致的堆积。可能存在内存泄漏或者该部分对象占用内存过大的情况,需要进一步结合代码里对 Guava Cache(即 com.google.common.cache 相关)的使用逻辑,比如缓存对象是否没有合理失效、缓存配置的容量是否过大等,来排查为何会出现这么高的内存占用 。

Keywords:关键信息,列出了关键的类和类加载器等标识,方便定位和关联代码及相关组件,com.google.common.cache.LocalCache$Segment[] 表明是 Guava 缓存的段数组相关,org.springframework.boot.loader.LaunchedURLClassLoader @0x4c0005138 是加载这些类的类加载器实例标识,可辅助在代码和类加载机制层面去深挖问题根源 。点击 Details 通常能查看更详细的对象引用链、内存占用细节等,助力进一步分析内存问题。

2、分析details:

解析:

  1. 从引用链看LocalCacheloadingCachesqlStatementCache ,说明是 ShardingSphere 的 SQL 语句解析缓存(SQLStatementParserEngine 相关) 在占用内存。
  2. 逐层展开后,最终关联到 org.springframework.boot.loader.LaunchedURLClassLoader 加载的类(classes 节点),体现了类加载器持有缓存对象,导致内存无法释放 。
  3. 下方多个线程(如 Log4j 线程、Tomcat 线程、ShardingSphere 元数据加载线程等)的 contextClassLoader 都指向 LaunchedURLClassLoader,说明 这些线程在运行时依赖该类加载器加载的类 。若线程未正确终止或类加载器未被释放,会进一步延长缓存对象的生命周期,加剧内存占用。

从这里就可以看出SQLStatementParserEngine 相关的内存占用非常大,

3、跟踪引用链with outgoing references

关键路径:SQLStatementParserEngineFactoryENGINESConcurrentHashMap) → SQLStatementParserEnginesqlStatementCacheLocalCachesegmentsLocalCache$Segment[])。这是 ShardingSphere SQL 解析缓存的完整引用链,segments 数组是内存堆积的直接载体(对应最初的 LocalCache$Segment[] 占用问题)。

内存占比验证:LocalCacheRetained Heap 高达 1,878,763,216(约 1.75GB),说明缓存对象确实在此大量堆积。

以上只是为了从不同的区域去看,同样也可以从直方图去看,根据Retained Heap降序,找出top对象信息,并看引用链。

4、结合业务代码分析:

  • 组件关联:所有内存堆积都关联 SQLStatementParserEngine(ShardingSphere 负责 SQL 解析缓存的核心类)和 LocalCache(Guava Cache 实现),且 SQLStatementParserEngine 是 ShardingSphere JDBC 的内置组件,说明缓存逻辑由 ShardingSphere 触发。

  • 缓存特性:ShardingSphere JDBC 默认会缓存 SQL 解析结果(通过 SQLStatementCache),若未合理配置缓存容量 / 过期时间,或业务场景中SQL 多样性极高(如动态参数 SQL 过多),就会导致缓存无限堆积,符合 "缓存泄漏" 特征(对象无法被 GC 回收,内存持续增长)。

  • 找到ShardingSphere 缓存配置的核心类,ShardingSphere JDBC 的 SQL 解析缓存由 SQLStatementParserEngine 管理,其缓存初始化逻辑在 SQLStatementParserEngineFactorySQLStatementCache 相关类中。

    • 关键类路径:org.apache.shardingsphere.infra.parser.sql.SQLStatementParserEngine``org.apache.shardingsphere.infra.parser.cache.SQLStatementCache(若存在)
    • 代码定位:在项目依赖中找到 ShardingSphere JDBC 的源码(或反编译 Jar 包),搜索 CacheBuilder.newBuilder(),定位缓存创建逻辑,例如:ShardingSphere JDBC 对 SQL 解析缓存的配置,查找项目中 shardingsphere-jdbc 的配置文件,是否设置了 SQL 解析缓存的参数:
arduino 复制代码
  /**
   * SQL语句解析引擎,负责SQL语句的解析和缓存管理
   * 核心作用是将原始SQL字符串解析为结构化的SQLStatement对象,同时提供缓存机制提升性能
   */
  public final class SQLStatementParserEngine {
      
      /**
       * SQL语句解析执行器,实际执行SQL解析的组件
       * 封装了不同数据库类型的SQL解析逻辑
       */
      private final SQLStatementParserExecutor sqlStatementParserExecutor;
      
      /**
       * SQL语句缓存,使用LoadingCache实现(通常是Guava的缓存实现)
       * 键为SQL字符串,值为解析后的SQLStatement对象
       * 具备自动加载和过期淘汰能力
       */
      private final LoadingCache<String, SQLStatement> sqlStatementCache;

      /**
       * 构造方法,初始化解析引擎
       * 
       * @param databaseType 数据库类型(如MySQL、PostgreSQL等)
       * @param sqlCommentParseEnabled 是否解析SQL中的注释
       */
      public SQLStatementParserEngine(String databaseType, boolean sqlCommentParseEnabled) {
          // 初始化解析执行器,传入数据库类型和注释解析开关
          this.sqlStatementParserExecutor = new SQLStatementParserExecutor(databaseType, sqlCommentParseEnabled);
          
          // 构建SQL语句缓存
          // CacheOption参数说明:
          // 2000:缓存最大条目数(maximumSize)
          // 65535L:缓存过期时间(expireAfterWrite,单位根据实现可能为秒或毫秒)
          // 4:可能是并发级别(concurrencyLevel),允许同时写入缓存的线程数
          this.sqlStatementCache = SQLStatementCacheBuilder.build(
              new CacheOption(2000, 65535L, 4), 
              databaseType, 
              sqlCommentParseEnabled
          );
      }

      /**
       * 解析SQL语句的核心方法
       * 
       * @param sql 原始SQL字符串
       * @param useCache 是否使用缓存:true-优先从缓存获取,未命中则解析并缓存;false-直接解析不使用缓存
       * @return 解析后的SQLStatement对象(包含SQL结构化信息,如表名、条件、排序等)
       */
      public SQLStatement parse(String sql, boolean useCache) {
          // 根据useCache参数决定是否使用缓存
          return useCache 
              ? (SQLStatement)this.sqlStatementCache.getUnchecked(sql)  // 使用缓存:从缓存获取,无则自动加载
              : this.sqlStatementParserExecutor.parse(sql);  // 不使用缓存:直接调用执行器解析
      }
  }

笔者项目中确实没有这个配置的,但默认已有大小,那为什么还会大内存,分析这个缓存存的内容有:

  • Key:原始 SQL 字符串(如"SELECT * FROM t_order WHERE id = ?"
  • Value:解析后的SQLStatement对象(包含表、条件、排序等结构化信息,但不包含路由结果)

以下情况会导致缓存被写入:首次执行新 SQL、缓存淘汰 / 过期后再次执行 SQL 时自动写入。那么缓存sql的大小就决定了sharding里缓存的大小,所以maximumSize的大小要去进行分析,如何配置。

5、确认是否存在sql过大,通过直方图,看到char数据的深堆大小最大,并继续查看引用链 可以看到,大对象都是查询的sql,当sql过大时,会间接导致sharding缓存过大。 因此对maximum-sized的大小设置,要根据业务SQL特征分析:平衡SQL多样性、缓存命中率和内存占用,避免盲目配置过大(导致内存浪费)或过小(缓存失效频繁)。

SQL 多样性低(如固定 SQL 模板,参数变化但 SQL 结构不变) 可适当增大,充分利用缓存 2000-5000
SQL 多样性高(如大量动态生成 SQL,结构频繁变化) 需减小,避免无效缓存占用内存 500-1000
高频 SQL 占比高(少数 SQL 执行次数占总流量 80% 以上) 保证覆盖高频 SQL 即可,无需过大 1000-3000
SQL 结构复杂(单条SQLStatement对象体积大) 适当减小,控制总内存占用 500-1500

通过 ShardingSphere 的缓存统计(需开启sql-parser.cache.statistics-enabled: true)和 JVM 监控,动态调整参数:

    • 缓存命中率

      • 计算公式:命中次数 / (命中次数 + 未命中次数)
      • 合理阈值:建议≥70%。若低于 50%,说明缓存利用率低,需减小maximum-size
      • 示例:1000 次查询中,800 次命中,则命中率 80%,配置合理
    • 缓存实际条目数

      • 若长期稳定在maximum-size的 80% 左右,说明配置适中
      • 若长期远低于maximum-size(如仅使用 500 条但配置 2000),建议下调
      • 若频繁达到maximum-size且淘汰频繁(可通过日志观察 LRU 淘汰次数),可适当上调
    • 内存占用

      • 通过 JVM 监控(如 JVisualVM)观察SQLStatement对象总内存
      • 建议控制在 JVM 堆内存的 5%-10% 以内(如 4GB 堆内存中,缓存占用不超过 400MB)
  1. 初始配置:根据业务类型设置保守值(如 1000),并开启缓存统计

    yaml 复制代码
    yaml
    spring:
      shardingsphere:
        props:
          sql-parser.cache.maximum-size: 1000
          sql-parser.cache.statistics-enabled: true
  2. 运行观测:收集 3-7 天的生产数据,统计:

    • 系统中不同 SQL 的总数量(去重后)
    • 高频 SQL(执行次数前 20%)的数量
    • 缓存命中率和内存占用
  3. 动态调整:

    • 若高频 SQL 数量为 800,可将maximum-size设为 1000(留 20% 冗余)
    • 若命中率低于 60% 且 SQL 多样性高,下调至 500
    • 若内存占用过高(如单条SQLStatement达 10KB,1000 条即 10MB,可接受;若达 100KB,则需下调)
sql 复制代码
 最后:针对这个内存的优化,不仅仅是排查问题后的参数优化,从参数评估到SQL都进行了优化,SQL的优化是按照一定的规范,进行问题SQL的分类,并逐一进行修改优化。具体SQL的规范和优化案例,会单独发布一篇博文
相关推荐
用户68545375977694 小时前
🚀 Transformer:让AI变聪明的"读心术大师" | 从小白到入门的爆笑之旅
人工智能·后端
深圳蔓延科技4 小时前
SpringSecurity中如何接入单点登录
后端
刻意思考4 小时前
服务端和客户端之间接口耗时的差别
后端·程序员
该用户已不存在4 小时前
Python项目的5种枚举骚操作
后端·python
zjjuejin5 小时前
Maven 云原生时代面临的八大挑战
java·后端·maven
木易士心5 小时前
设计模式六大原则 — 列举反例详解各个原则的核心思想和意义
后端
间彧5 小时前
Java Optional类详解与应用实战
后端
用户8356290780515 小时前
告别冗余:用Python删除PDF中的超链接
后端·python
间彧5 小时前
Spring Boot 2.6+版本为什么默认禁止循环引用?
后端