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的规范和优化案例,会单独发布一篇博文
相关推荐
鬼火儿7 小时前
SpringBoot】Spring Boot 项目的打包配置
java·后端
cr7xin7 小时前
缓存三大问题及解决方案
redis·后端·缓存
间彧8 小时前
Kubernetes的Pod与Docker Compose中的服务在概念上有何异同?
后端
间彧8 小时前
从开发到生产,如何将Docker Compose项目平滑迁移到Kubernetes?
后端
间彧8 小时前
如何结合CI/CD流水线自动选择正确的Docker Compose配置?
后端
间彧8 小时前
在多环境(开发、测试、生产)下,如何管理不同的Docker Compose配置?
后端
间彧8 小时前
如何为Docker Compose中的服务配置健康检查,确保服务真正可用?
后端
间彧9 小时前
Docker Compose和Kubernetes在编排服务时有哪些核心区别?
后端
间彧9 小时前
如何在实际项目中集成Arthas Tunnel Server实现Kubernetes集群的远程诊断?
后端
brzhang9 小时前
读懂 MiniMax Agent 的设计逻辑,然后我复刻了一个MiniMax Agent
前端·后端·架构