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

MAT分析Dump文件:
  1、设置MemoryAnalyzer.ini中的-Xmx为需要用的大小,否则会遇到打开dump文件报错。。
  2、dump文件导出配置:在节点配置中增加dump导出
-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. 从引用链看LocalCache → loadingCache → sqlStatementCache ,说明是 ShardingSphere 的 SQL 语句解析缓存(SQLStatementParserEngine 相关) 在占用内存。
  2. 逐层展开后,最终关联到 org.springframework.boot.loader.LaunchedURLClassLoader 加载的类(classes 节点),体现了类加载器持有缓存对象,导致内存无法释放 。
  3. 下方多个线程(如 Log4j 线程、Tomcat 线程、ShardingSphere 元数据加载线程等)的 contextClassLoader 都指向 LaunchedURLClassLoader,说明 这些线程在运行时依赖该类加载器加载的类 。若线程未正确终止或类加载器未被释放,会进一步延长缓存对象的生命周期,加剧内存占用。
  从这里就可以看出SQLStatementParserEngine 相关的内存占用非常大,
3、跟踪引用链with outgoing references

   关键路径:SQLStatementParserEngineFactory → ENGINESConcurrentHashMap) → SQLStatementParserEngine → sqlStatementCache → LocalCache → segmentsLocalCache$Segment[])。这是 ShardingSphere SQL 解析缓存的完整引用链,segments 数组是内存堆积的直接载体(对应最初的 LocalCache$Segment[] 占用问题)。
  内存占比验证:LocalCache 的 Retained 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 管理,其缓存初始化逻辑在 SQLStatementParserEngineFactory 或 SQLStatementCache 相关类中。
    • 关键类路径:org.apache.shardingsphere.infra.parser.sql.SQLStatementParserEngineorg.apache.shardingsphere.infra.parser.cache.SQLStatementCache(若存在)
    • 代码定位:在项目依赖中找到 ShardingSphere JDBC 的源码(或反编译 Jar 包),搜索 CacheBuilder.newBuilder(),定位缓存创建逻辑,例如:ShardingSphere JDBC 对 SQL 解析缓存的配置,查找项目中 shardingsphere-jdbc 的配置文件,是否设置了 SQL 解析缓存的参数:
  • /**
     * 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
    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的规范和优化案例,会单独发布一篇博文
posted @ 2025-07-21 21:30  难得  阅读(139)  评论(0)    收藏  举报