mybatis默认dynamicsqlsource每次执行都重新解析表达式并动态生成ognl代理类,导致元空间持续增长;因其未缓存解析结果、ognl表达式反复编译、boundsql未按条件组合键化缓存,高频复杂动态sql下极易压爆metaspace。

MyBatis 默认的 SqlSource 实现(如 DynamicSqlSource)在每次执行动态 SQL 时都会重新解析 XML 或注解中的表达式、调用 OGNL 表达式求值、拼接 SQL 字符串——这个过程不缓存解析结果,高频请求下会持续生成大量临时 String、Expression 和 BoundSql 对象,最终压爆元空间(Metaspace),尤其在开启大量 <if></if>、<foreach></foreach> 的复杂查询场景中非常典型。
为什么默认 SqlSource 会导致元空间增长过快
关键点不在 SQL 执行本身,而在 SQL 解析阶段:
-
DynamicSqlSource每次调用getBoundSql(Object parameter)都会新建一个GenericTokenParser,反复解析含${}和#{}的模板字符串 - OGNL 表达式(如
test="user.name != null")每次求值都触发ognl.Ognl.parseExpression(),该方法底层会通过ClassLoader.defineClass()动态生成并加载代理类(如ognl.ASTEq$$EnhancerByCGLIB$$xxx),这些类永久驻留元空间 - MyBatis 3.4+ 虽引入了
ExpressionEvaluator缓存,但仅缓存顶层表达式字符串 → 对嵌套<if></if>中的多个test属性仍各自解析,无法复用 - 没有对
BoundSql结果做 key 化缓存(比如按参数类型 + 条件组合哈希),导致相同逻辑条件反复生成等价但内存地址不同的BoundSql实例
替换为自定义 CachingSqlSource 的实操步骤
核心是继承 SqlSource 接口,包装原始 SqlSource,并在 getBoundSql() 中加入两级缓存:一级缓存「表达式解析结果」,二级缓存「最终 BoundSql」。
你需要:
- 定义缓存 key:建议用
parameter.getClass() + SQL 片段哈希 + 条件分支路径(如 if-1-foreach-0)拼接,避免只用参数对象(可能未重写hashCode()) - 使用
ConcurrentHashMap存储,key 类型为自定义SqlCacheKey,value 为BoundSql;注意不要缓存含${}的 SQL(有 SQL 注入风险,且无法安全复用) - 在
getBoundSql()开头判断:若参数中所有test表达式均为常量(如test="true"、test="list.size() > 0"且 list 非 null),才走缓存;否则 fallback 到原SqlSource - 在 MyBatis 启动时,通过
Configuration.addMappedStatement()前置替换掉原始SqlSource,或更稳妥地,在MapperBuilderAssistant构建MappedStatement时拦截(需自定义XMLStatementBuilder)
示例片段(简化版):
public class CachingSqlSource implements SqlSource {
private final SqlSource delegate;
private final ConcurrentHashMap<sqlcachekey boundsql> cache = new ConcurrentHashMap();
@Override
public BoundSql getBoundSql(Object parameter) {
SqlCacheKey key = createCacheKey(parameter);
return cache.computeIfAbsent(key, k -> delegate.getBoundSql(parameter));
}
}</sqlcachekey>
必须绕开的三个坑
不是所有动态 SQL 都适合预编译缓存:
-
${}占位符必须禁止缓存:它直接拼入 SQL,参数不同则 SQL 结构不同(如ORDER BY ${sortField}),缓存会导致错乱。检测方式:在解析前扫描 SQL 字符串是否含未转义的${ -
<bind></bind>标签生成的变量无法跨次复用:比如<bind name="likeName" value="'%' + name + '%'"></bind>,每次执行都依赖运行时name值,不能缓存其计算结果 - 一级缓存(
SqlSession级)与你的CachingSqlSource无关:后者缓存的是「SQL 模板解析结果」,前者缓存的是「查询结果」,两者正交。别误以为加了这个就能替代LocalCache
上线前必须验证的边界点
最易被忽略的是缓存 key 的完备性:
- 当参数是 Map 时,key 必须包含
Map.entrySet().stream().sorted().map(e -> e.getKey()+e.getValue()).collect(...),否则同内容不同插入顺序的 Map 会被视为不同 key - 涉及
<foreach collection="list" item="item"></foreach>时,key 中要记录list.size()和元素类型的 class 名称(而非实例),因为泛型擦除后无法靠list.getClass()区分List<user></user>和List<order></order> - 务必用
-XX:+PrintGCDetails -XX:+PrintMetaspaceStatistics对比启用前后的 Metaspace 使用曲线,重点观察Compressed Class Space是否显著下降
真正起效的标志不是 QPS 提升,而是 Full GC 中 Metaspace 相关的类卸载次数变多、且 used 值趋于平稳——这说明动态生成的 OGNL 类不再无限堆积。










