type
status
date
slug
summary
tags
category
icon
password
AI summary
背景
前一篇文章分析了一个线上使用MyBatis批量插入导致内存溢出的问题,具体的分析过程这里不再赘述,感兴趣的同学可以去阅读原文。其中的一个原因是MyBatis在根据XML文件里的
MappedStatement
动态生成SQL的过程中,会保留很多不必要的空格和换行,导致拼接出来的SQL字符串比实际的要大得多,也就会占用更多的内存,更容易引发内存溢出。我计算了一下前一篇文章中的SQL的有效字符比例只有12%,也就是说88%的字符都是无效的空格和换行。本篇文章的主要目的,就是看看如何把这些不必要的空格和换行给消除掉。分析
引发上一篇文章OOM的就是下面这个
MappedStatement
:这个
batchInsertSelective
是通过com.itfsw:mybatis-generator-plugins:1.3.5
插件结合mybatis-generator
生成出来的,是为了实现批量插入时支持指定字段。这里用到了两层
foreach
标签,第一层是遍历列表元素,第二层是遍历所有字段。而在遍历字段时,每次只会匹配到一个if
标签,其余的都不满足,但是注意,第二层foreach
里不仅有if
标签,还有大量的换行和空格。所以当字段数量越多的情况下,换行和空格也会越多,再叠加上外层还有对列表元素的遍历,这些无用字符又会被放大很多倍。为了让你看得更明显,我在下图标注出了换行和空格的位置。并且在DEBUG窗口中展示了上面这个
MappedStatement
的第二层foreach
被解析出来的SqlNode
。SqlNode
可以理解成MappedStatement
里SQL主体部分的结构化表达,比如if
标签会被转换成IfNode
,而不包含${}
占位符的字符串区域则会被转换成StaticTextSqlNode
。下图里你可以看到,上面这个MappedStatement
解析出来的SqlNode
, StaticTextSqlNode
和IfNode
是交替出现的,而这一层的StaticTextSqlNode
全部都是换行和空格。
那么我们是不是在这个
SqlNode
的生成上做文章?跟踪了一下源码,发现MappedStatement
是在创建SqlSessionFactory
的时候生成的:首先会去扫描mapperLocations
下面的XML文件并解析最终每一个
select/update/insert/delete
标签都会被解析成一个结构化的MappedStatement
,并通过全局唯一的id保存到一个Map里:MappedStatement
里有一个很重要的属性SqlSource
,SqlNode
也是在创建SqlSource
的时候生成的,通过SqlSource
我们可以获取SQL信息。因为MyBatis支持动态SQL的特性(比如if
、choose
、foreach
标签),所以用SqlSource
做了统一抽象。SqlSource
有以下几种实现:
- DynamicSqlSource:针对动态SQL和
${}
占位符的SQL,需要在Mapper调用时才能确定最终的SQL语句
- RawSqlSource:针对
#{}
占位符的SQL,在解析XML配置时就能确定的SQL信息
- ProviderSqlSource:针对
@*Provider
注解提供的SQL
- StaticSqlSource:经过ProviderSqlSource、DynamicSqlSource或者RawSqlSource解析后得到的静态SQL信息,不含各种占位符,最多只会有
?
其中,会产生大量空格和换行的主要是
DynamicSqlSource
,特别是在搭配foreach
标签的使用场景下,更会放大空格和换行的数量。下面的代码展示了MyBatis是如何解析XML里的动态标签生成SqlNode
:上面
parseDynamicTags
其实是一段递归解析,普通的文本会被解析成StaticTextSqlNode
,包含${}
占位符的文本会被解析成TextSqlNode
,而其他的标签都会用各自对应的NodeHandler
来解析,当然解析到最后也都会是StaticTextSqlNode
/TextSqlNode
,最终全部解析完会生成一个MixedSqlNode
。可以看下图有一个直观的感受,不过下图忽略了空格和换行带来的StaticTextSqlNode

上述的这些过程都是在Spring容器启动创建SqlSessionFactory时进行的。而等到真正调用的时候,需要指定对应的
MappedStatement
和调用参数,MyBatis会根据调用参数和MixedSqlNode
做判断和组装,最终产生一个BoundSql
对象,包含完整的SQL以及参数信息:彻底消除不必要的空格和换行
至此,我们搞清楚了MyBatis
MappedStatement
的构建过程以及执行过程。但是过程中的空白和换行我们还没有把它消灭掉。在探究源码的过程中,我发现了MyBatis自带了一个配置项:shrinkWhitespacesInSql
,它就是用来去除SQL中的空白和换行的,由于是基于StringTokenizer
实现的,默认的分隔符是 \\t\\n\\r\\f
,所以这些字符都会被干掉看起来好像满足了我们的需求。但是我们的主要目的是为了减少内存的占用,防止内存溢出。这样能把我们前一篇文章提到的大对象都消除吗?我们来贴一下上一篇文章的大对象:

我们发现,
shrinkWhitespacesInSql
是在org.apache.ibatis.builder.SqlSourceBuilder.parse
里,那么DynamicContext
里的StringBuilder
以及生成出来的originalSql
,这两个对象的大小并没有减小,还是包含了大量的空格和换行。所以这个配置应该可以节约一半的内存,那另外一半有没有办法也节约掉呢?看起来这个必须要从SqlNode
拼接SQL的地方开始入手。最终我们发现SqlNode
分为两类:- 一类是
StaticTextSqlNode
和TextSqlNode
,它们会拼接具体Sql
- 另一类是其他
SqlNode
,基本只做逻辑,不拼接具体SQL,比如IfSqlNode
、ChooseSqlNode
。ForeachSqlNode
也只拼了开闭字符,其他都还是通过第一类节点来拼接SQL
看起来通过重写了
StaticTextSqlNode
,并且对其中的空格和换行做处理就可以达到我们想要的效果:MyBatis的SQL日志
为什么MyBatis打印SQL日志的时候并没有输出换行和空格?原来是采用了和
shrinkWhitespacesInSql
一样的处理:重写MyBatis的SQL
之前有好几个场景都需要重写SQL,虽然都通过查资料实现了,但是不是很了解其原理,这里在研究了源码之后才明白了之前为什么要这么写。
场景主要是2个:
- 权限控制,需要根据当前人的权限拼接查询限制条件
- 敏感字段加解密,需要在查询时解密,新增/修改时加密
首先肯定要用到MyBatis的Plugin拦截
Executor
里对应的方法,比如:但是你可以看到,这里不管是
update
还是query
,接收的参数都是MappedStatement
和执行参数。而根据前面的介绍,MappedStatement
是启动的时候就初始化好的,并且是静态的内容,所以肯定不能修改MappedStatement
。那么只能复制一个新的MappedStatement
,并替换里面的SQL ,让Executor
在执行时拿到我们新创建的MappedStatement
。不过MappedStatement
并没有提供现成的复制方法,我们需要手动复制,并且其中的BoundSql
也需要复制一份,因为它里面的属性都是用final
修饰的,无法修改参考
- Author:黑微狗
- URL:https://blog.hwgzhu.com/article/mybatis-dynamic-sql-implemetation
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!
Relate Posts