文档章节

源码分析 Mybatis 的 foreach 为什么会出现性能问题

TSMYK
 TSMYK
发布于 2018/12/16 21:45
字数 2613
阅读 979
收藏 33

背景

最近在做一个类似于综合报表之类的东西,需要查询所有的记录(数据库记录有限制),大概有1W条记录,该报表需要三个表的数据,也就是根据这 1W 个 ID 去执行查询三次数据库,其中,有一条查询 SQL 是自己写,其他两条是根据别人提供的接口进行查询,刚开始的时候,没有多想,直接使用 in 进行查询,使用 Mybatis 的 foreach 语句;项目中使用的是 jsonrpc 来请求数据,在测试的时候,发现老是请求不到数据,日志抛出的是 jsonrpc 超时异常,继续查看日志发现,是被阻塞在上面的三条SQL查询中。

在以前分析 Mybatis 的源码的时候,了解到,Mybatis 的 foreach 会有性能问题,所以改了下 SQL,直接在代码中拼接SQL,然后在 Mybatis 中直接使用 # 来获取,替换 class 测试了下,果然一下子就能查询出数据。

前提

这里先不考虑使用 in 好不好,如何去优化 in,如何使用 exists 或 inner join 进行代替等,这里就只是考虑使用了 in 语句,且使用了 Mybatis 的 foreach 语句进行优化,其实 foreach 的优化很简单,就是把 in 后面的语句在代码里面拼接好,在配置文件中直接通过 #{xxx} 或 ${xxx} 当作字符串直接使用即可。

测试

在分析 foreach 源码之前,先构造个数据来看看它们的区别有多大。

建表语句:

CREATE TABLE person
(
    id int(11) PRIMARY KEY NOT NULL,
    name varchar(50),
    age int(11),
    job varchar(50)
);

插入 1W 条数据:

POJO 类:

@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class Person implements Serializable {
    private int id;
    private String name;
    private String job;
    private int age;
}

 方式一

通过原始的方式,使用 foreach 语句:

1. 在 dao 里面定义方法:

List<Person> queryPersonByIds(@Param("ids") List<Integer> ids);

2. 配置文件SQL:

<select id="queryPersonByIds" parameterType="list" resultMap="queryPersonMap">
	select * from person where 1=1
	<if test="ids != null and ids.size() > 0">
		and id in
		<foreach collection="ids" item="item" index="index" separator="," open="(" close=")">
			#{item}
		</foreach>
	</if>
</select>

3. 执行 main 方法:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = { "classpath:spring-mybatis.xml" })
public class MainTest {

    @Autowired
    private IPersonService personService;

    @Test
    public void test(){
        // 构造 1W 个 ID
        List<Integer> ids = new ArrayList<>();
        for (int i = 1; i <= 10000; i++) {
            ids.add(i);
        }
        long start = System.currentTimeMillis();
        
        // 执行三次
        personService.queryPersonByIds(ids);
        personService.queryPersonByIds(ids);
        personService.queryPersonByIds(ids);

        long end = System.currentTimeMillis();
        System.out.println(String.format("耗时:%d", end - start));
    }
}
结果:耗时:2853

可以看到通过 foreach 的方法,大概需要 3s

方式二

在代码中封装 SQL ,在配置文件中 通过 ${xxx} 来获取:

1. 在 dao 添加方法:

List<Person> queryPersonByIds2(@Param("ids") String ids);

2. 配置文件SQL:

<select id="queryPersonByIds2" parameterType="String" resultMap="queryPersonMap">
	select * from person where 1=1
	<if test="ids != null and ids != ''">
	  and id in ${ids}
	</if>
</select>

3. 执行 main 方法:

@Test
public void test_3(){
	// 拼接 SQL 
	StringBuffer sb = new StringBuffer();
	sb.append("(");
	for (int i = 1; i < 10000; i++) {
		sb.append(i).append(",");
	}
	sb.deleteCharAt(sb.toString().length() - 1);
	sb.append(")");
    // 最终的 SQL 为 (1,2,3,4,5...)

	long start2 = System.currentTimeMillis();

    // 执行三次
	personService.queryPersonByIds2(sb.toString());
	personService.queryPersonByIds2(sb.toString());
	personService.queryPersonByIds2(sb.toString());

	long end2 = System.currentTimeMillis();
	System.out.println(String.format("耗时:%d", end2 - start2));
}
结果:耗时:360

通过拼接 SQL,使用 ${xxx} 的方式,执行同样的 SQL ,耗时大概 360 ms

方式三

在代码中封装 SQL ,在配置文件中 通过 #{xxx} 来获取:

1. 在 dao 中添加方法:

List<Person> queryPersonByIds3(@Param("ids") String ids);

2. 配置文件SQL:

<select id="queryPersonByIds3" parameterType="String" resultMap="queryPersonMap">
	select * from person where 1=1
	<if test="ids != null and ids != ''">
		and id in (#{ids})
	</if>
</select>

3. 执行 main 方法:

@Test
public void test_3(){
    // 拼接 SQL
	StringBuffer sb2 = new StringBuffer();
	for (int i = 1; i < 10000; i++) {
		sb2.append(i).append(",");
	}
	sb2.deleteCharAt(sb2.toString().length() - 1);
    // 最终的SQL为 1,2,3,4,5....

	long start3 = System.currentTimeMillis();

	personService.queryPersonByIds3(sb2.toString());
	personService.queryPersonByIds3(sb2.toString());
	personService.queryPersonByIds3(sb2.toString());

	long end3 = System.currentTimeMillis();
	System.out.println(String.format("耗时:%d", end3 - start3));
}
结果:耗时:30

通过拼接 SQL,使用 #{xxx} 的方式,执行同样的 SQL ,耗时大概 30 ms

总结

通过上面三种方式可以看到,使用不同的方式,耗时的差别还是麻大的,最快的是 拼接 SQL,使用 #{xxx} 当作字符串处理,最慢的是 foreach。为什么 foreach 会慢那么多呢,后面再分析源码的时候再进行分析;而这里同样是拼接 SQL 的方式,#{xxx} 和 ${xxx} 耗时却相差 10 倍左右;我们知道,Mybatis 在解析 # 和 $ 这两种不同的符号时,采用不同的处理策略;使用过 JDBC 的都知道,通过 JDBC 执行 SQL 有两种方式: Statment 对象和PreparedStatment 对象,  PreparedStatment 表示预编译的SQL,包含的SQL已经预编译过了,SQL 中的参数部分使用 ?进行占位,之后使用 setXXX 进行赋值,当使用 Statement 对象时,每次执行一个SQL命令时,都会对它进行解析和编译。所有 PreparedStatment 效率要高一些。那么 Mybatis 在解析 # 和 $ 的时候,分别对应的是这两种对象,# 被解析成 PreparedStatment 对象,通过 ? 进行占位,之后再赋值,而 $ 被解析成 Statement ,通过直接拼接SQL的方式赋值,所以,为什么同样是通过在代码中拼接 SQL ,# 和 $ 的耗时不同的原因。

PS:上面只是介绍了三种方式,应该没有人问,拼接SQL为 (1,2,3,4,5),在配置SQL中通过 #{xxx} 来获取吧

foreach 源码解析

 下面来看下 foreach 是如何被解析的,最终解析的 SQL 是什么样的:

在 Mybatis 中,foreach 属于动态标签的一种,也是最智能的其中一种,Mybatis 每个动态标签都有对应的类来进行解析,而 foreach 主要是由 ForEachSqlNode 负责解析。

ForeachSqlNode 主要是用来解析 <foreach> 节点的,先来看看 <foreach> 节点的用法:

<select id="queryPersonByIds" parameterType="list" resultMap="queryPersonMap">
	select * from person where 1=1
	<if test="ids != null and ids.size() > 0">
		and id in
		<foreach collection="ids" item="item" index="index" separator="," open="(" close=")">
			#{item}
		</foreach>
	</if>
</select>

最终被 数据库执行的 SQL 为 select  * from person where 1=1 and id in (1,2,3,4,5)

先来看看它的两个内部类:

PrefixedContext

该类主要是用来处理前缀,比如 "(" 等。

private class PrefixedContext extends DynamicContext {   
   private DynamicContext delegate;
    // 指定的前缀
    private String prefix;
    // 是否处理过前缀
    private boolean prefixApplied;
    // .......

    @Override
    public void appendSql(String sql) {
      // 如果还没有处理前缀,则添加前缀
      if (!prefixApplied && sql != null && sql.trim().length() > 0) {
        delegate.appendSql(prefix);
        prefixApplied = true;
      }
       // 拼接SQL
      delegate.appendSql(sql);
    }
}

FilteredDynamicContext

FilteredDynamicContext 是用来处理 #{} 占位符的,但是并未绑定参数,只是把 #{item} 转换为 #{_frch_item_1} 之类的占位符。

  private static class FilteredDynamicContext extends DynamicContext {
    private DynamicContext delegate;
    //对应集合项在集合的索引位置
    private int index;
    // item的索引
    private String itemIndex;
    // item的值
    private String item;
    //.............
    // 解析 #{item}
    @Override
    public void appendSql(String sql) {
      GenericTokenParser parser = new GenericTokenParser("#{", "}", new TokenHandler() {
        @Override
        public String handleToken(String content) {
          // 把 #{itm} 转换为 #{__frch_item_1} 之类的
          String newContent = content.replaceFirst("^\\s*" + item + "(?![^.,:\\s])", itemizeItem(item, index));
           // 把 #{itmIndex} 转换为 #{__frch_itemIndex_1} 之类的
          if (itemIndex != null && newContent.equals(content)) {
            newContent = content.replaceFirst("^\\s*" + itemIndex + "(?![^.,:\\s])", itemizeItem(itemIndex, index));
          }
          // 再返回 #{__frch_item_1} 或 #{__frch_itemIndex_1}
          return new StringBuilder("#{").append(newContent).append("}").toString();
        }
      });
      // 拼接SQL
      delegate.appendSql(parser.parse(sql));
    }
  private static String itemizeItem(String item, int i) {
    return new StringBuilder("__frch_").append(item).append("_").append(i).toString();
  }
}

ForeachSqlNode 

了解了 ForeachSqlNode  它的两个内部类之后,再来看看它的实现:

public class ForEachSqlNode implements SqlNode {
  public static final String ITEM_PREFIX = "__frch_";
  // 判断循环的终止条件
  private ExpressionEvaluator evaluator;
  // 循环的集合
  private String collectionExpression;
  // 子节点
  private SqlNode contents;
  // 开始字符
  private String open;
  // 结束字符
  private String close;
  // 分隔符
  private String separator;
  // 本次循环的元素,如果集合为 map,则index 为key,item为value
  private String item;
  // 本次循环的次数
  private String index;
  private Configuration configuration;

  // ...............

  @Override
  public boolean apply(DynamicContext context) {
    // 获取参数
    Map<String, Object> bindings = context.getBindings();
    final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings);
    if (!iterable.iterator().hasNext()) {
      return true;
    }
    boolean first = true;
    // 添加开始字符串
    applyOpen(context);
    int i = 0;
    for (Object o : iterable) {
      DynamicContext oldContext = context;
      if (first) {
        // 如果是集合的第一项,则前缀prefix为空字符串
        context = new PrefixedContext(context, "");
      } else if (separator != null) {
        // 如果分隔符不为空,则指定分隔符
        context = new PrefixedContext(context, separator);
      } else {
          // 不指定分隔符,在默认为空
          context = new PrefixedContext(context, "");
      }
      int uniqueNumber = context.getUniqueNumber();  
      if (o instanceof Map.Entry) {
        // 如果集合是map类型,则将集合中的key和value添加到bindings参数集合中保存
        Map.Entry<Object, Object> mapEntry = (Map.Entry<Object, Object>) o;
        // 所以循环的集合为map类型,则index为key,item为value,就是在这里设置的
        applyIndex(context, mapEntry.getKey(), uniqueNumber);
        applyItem(context, mapEntry.getValue(), uniqueNumber);
      } else {
        // 不是map类型,则将集合中元素的索引和元素添加到 bindings集合中
        applyIndex(context, i, uniqueNumber);
        applyItem(context, o, uniqueNumber);
      }
      // 调用 FilteredDynamicContext 的apply方法进行处理
      contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));
      if (first) {
        first = !((PrefixedContext) context).isPrefixApplied();
      }
      context = oldContext;
      i++;
    }
     // 添加结束字符串
    applyClose(context);
    return true;
  }

  private void applyIndex(DynamicContext context, Object o, int i) {
    if (index != null) {
      context.bind(index, o); // key为idnex,value为集合元素
      context.bind(itemizeItem(index, i), o); // 为index添加前缀和后缀形成新的key
    }
  }

  private void applyItem(DynamicContext context, Object o, int i) {
    if (item != null) {
      context.bind(item, o);
      context.bind(itemizeItem(item, i), o);
    }
  }
}

所以该例子:

<select id="queryPersonByIds" parameterType="list" resultMap="queryPersonMap">
	select * from person where 1=1
	<if test="ids != null and ids.size() > 0">
		and id in
		<foreach collection="ids" item="item" index="index" separator="," open="(" close=")">
			#{item}
		</foreach>
	</if>
</select>

解析之后的 SQL 为:

select  *  from  person where  1=1 and id in (#{__frch_item_0},  #{__frch_item_1}, #{__frch_item_2}, #{__frch_item_3}, #{__frch_item_4})

之后在通过 PreparedStatment 的 setXXX 来进行赋值。

所以,到这里,知道了 Mybatis 在解析 foreach 的时候,最后还是解析成了 # 的方式,但是为什么还是很慢呢,这是因为需要循环解析 #{__frch_item_0} 之类的占位符,foreach 的集合越大,解析越慢。既然知道了需要解析占位符,为何不自己拼接呢,所以就可以在代码中拼接好,而不再使用 foreach 啦。

所以,Mybatis 在解析 foreach 的时候,底层还是会解析成 # 号的形式而不是 $ 的形式,既然知道了这个,如果 需要 foreach 的集合很大,就可以使用代码拼接 SQL ,使用 (#{xxx}) 的方式进行获取,不要再拼接成 (1,2,3,4,5) 再使用  ${xxx} 的方式啦。

 

关于其他 Mybatis 动态 SQL 的解析可以参考:

Mybatis 解析 SQL 源码分析一

Mybatis 解析 SQL 源码分析二

Mybatis Mapper.xml 配置文件中 resultMap 节点的源码解析

Mybatis Mapper 接口源码解析(binding包)

Mybatis 数据源和数据库连接池源码解析(DataSource)

Mybatis 缓存系统源码解析

Mybatis 解析配置文件的源码解析

Mybatis 类型转换源码分析

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

© 著作权归作者所有

共有 人打赏支持
TSMYK
粉丝 66
博文 79
码字总数 194150
作品 0
成都
程序员
私信 提问
加载中

评论(8)

TSMYK
TSMYK

引用来自“陶铭科”的评论

效率是提升了 但你这样是有sql注入得风险

引用来自“TSMYK”的评论

怎么说?

引用来自“zhyankor”的评论

如果过滤条件是字符串,处理字符串拼接时忘记添加对应的单引号‘’,就可能被构造类似 in(1, drop database information_schema)语句

引用来自“zhyankor”的评论

比如按用户邮箱过滤用户时,前端传来条件为['1@qq.com', '2@qq.com ); drop database information_schema ']这样一个长度为2数组,后端List接受参数,此时用这个方法生成过滤条件就得注意sql注入。
那当然得自己保证撒
TSMYK
TSMYK

引用来自“OSC首席混子”的评论

数据量大 mybatis foreach 没法用的,有内存溢出的风险,老老实实用批处理
哈哈,是的,大招
zhyankor
zhyankor

引用来自“陶铭科”的评论

效率是提升了 但你这样是有sql注入得风险

引用来自“TSMYK”的评论

怎么说?

引用来自“zhyankor”的评论

如果过滤条件是字符串,处理字符串拼接时忘记添加对应的单引号‘’,就可能被构造类似 in(1, drop database information_schema)语句
比如按用户邮箱过滤用户时,前端传来条件为['1@qq.com', '2@qq.com ); drop database information_schema ']这样一个长度为2数组,后端List接受参数,此时用这个方法生成过滤条件就得注意sql注入。
TSMYK
TSMYK

引用来自“陶铭科”的评论

效率是提升了 但你这样是有sql注入得风险

引用来自“TSMYK”的评论

怎么说?

引用来自“zhyankor”的评论

如果过滤条件是字符串,处理字符串拼接时忘记添加对应的单引号‘’,就可能被构造类似 in(1, drop database information_schema)语句
对头,就只能自己去保证了
zhyankor
zhyankor

引用来自“陶铭科”的评论

效率是提升了 但你这样是有sql注入得风险

引用来自“TSMYK”的评论

怎么说?
如果过滤条件是字符串,处理字符串拼接时忘记添加对应的单引号‘’,就可能被构造类似 in(1, drop database information_schema)语句
OSC首席混子
OSC首席混子
数据量大 mybatis foreach 没法用的,有内存溢出的风险,老老实实用批处理
TSMYK
TSMYK

引用来自“陶铭科”的评论

效率是提升了 但你这样是有sql注入得风险
怎么说?
陶铭科
陶铭科
效率是提升了 但你这样是有sql注入得风险
Mybatyis foreache 中的BUG

标签在Mybatis的xml配置中使用集合,主要是用到了foreach动态语句。 foreach的参数: foreach元素的属性主要有 item,index,collection,open,separator,close。 item表示集合中每一个元素...

ahpome
2018/10/26
0
0
Mybatis3.3.x技术内幕(十五):Mybatis之foreach批量insert,返回主键id列表(修复Mybatis返回null的bug)

Mybatis在执行批量插入时,如果使用的是for循环逐一插入,那么可以正确返回主键id。如果使用动态sql的foreach循环,那么返回的主键id列表,可能为null,这让很多人感到困惑;本文将分析问题产...

祖大俊
2016/05/13
9.6K
17
mybatis批量update操作的写法,及批量update报错的问题解决方法

mybatis的批量update操作写法很简单,如下: 如果想学习Java工程化、高性能及分布式、深入浅出。微服务、Spring,MyBatis,Netty源码分析的朋友可以加我的Java高级交流:854630135,群里有阿...

编程SHA
2018/12/16
0
0
手把手带你源码分析 HashMap 1.7

前言 HashMap 在 Java 和 Android 开发中非常常见 今天,我将带来 HashMap 的全部源码分析,希望你们会喜欢。 目录 1. 简介 类定义 3. 具体使用 3.1 主要使用API(方法、函数) 3.2 使用流程...

烂猪皮
2018/04/21
0
0
深入了解MyBatis参数

深入了解MyBatis参数 相信很多人可能都遇到过下面这些异常: "Parameter 'xxx' not found. Available parameters are [...]" "Could not get property 'xxx' from xxxClass. Cause: "The exp......

Liuzh_533
2015/03/02
0
10

没有更多内容

加载失败,请刷新页面

加载更多

OSChina 周一乱弹 —— 白掌柜说了卖货不卖身

Osc乱弹歌单(2019)请戳(这里) 【今日歌曲】 @爱漫爱 :这是一场修行分享羽肿的单曲《Moony》 手机党少年们想听歌,请使劲儿戳(这里) @clouddyy :开不开心? 开心呀, 我又不爱睡懒觉…...

小小编辑
27分钟前
1
0
大数据教程(11.7)hadoop2.9.1平台上仓库工具hive1.2.2搭建

上一篇文章介绍了hive2.3.4的搭建,然而这个版本已经不能稳定的支持mapreduce程序。本篇博主将分享hive1.2.2工具搭建全过程。先说明:本节就直接在上一节的hadoop环境中搭建了! 一、下载apa...

em_aaron
32分钟前
0
0
开始看《JSP&Servlet学习笔记》

1:WEB应用简介。其中1.2.1对Web容器的工作流程写得不错 2:编写Servlet。搞清楚了Java的Web目录结构,以及Web.xml的一些配置作用。特别是讲了@WebServlet标签 3:请求与响应。更细致的讲了从...

max佩恩
今天
2
0
mysql分区功能详细介绍,以及实例

一,什么是数据库分区 前段时间写过一篇关于mysql分表的的文章,下面来说一下什么是数据库分区,以mysql为例。mysql数据库中的数据是以文件的形势存在磁盘上的,默认放在/mysql/data下面(可...

吴伟祥
今天
2
0
SQL语句查询

1.1 排序 通过order by语句,可以将查询出的结果进行排序。放置在select语句的最后。 格式: SELECT * FROM 表名 ORDER BY 排序字段ASC|DESC; ASC 升序 (默认) DESC 降序 1.查询所有商品信息,...

stars永恒
今天
2
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部