1,缓存
如果短时间内加载两条相同的记录,MyBatis需要重复执行两次SQL语句,那MyBatis就太傻了——程序只要将第一次加载的记录缓存起来,就可以避免第二次的数据交互。可见,缓存是所有持久层框架都提供的基本功能。
MyBatis提供了两级缓存来提高程序性能:
- 一级缓存(局部缓存):SqlSession级别的缓存。默认打开且不能关闭的缓存。
- 二级缓存:Mapper级别的缓存。
Hibernate的一二级缓存:https://shao12138.blog.csdn.net/article/details/114962881
1.1,一级缓存
每当程序打开一个新的SqlSession对象时,MyBatis就会创建一个与之关联的一级缓存,该一级缓存默认会缓存所有执行过的查询语句,当程序再次调用该SqlSession执行相同传语句且参数相同时,MyBatis将直接使用一级缓存中的数据,从而避免再次访问数据库。
当程序使用SqlSession执行增、删、改等DML语句时,或者提交事务、关闭SqlSession时,SqlSession缓存的数据都会被清空。此外,SqlSession还提供了clearCache()方法来清空缓存。
MyBatis本身需要使用一级缓存来处理循环引用问题,并用于提升重复的嵌套查询性能,因此一级缓存不能被禁用,但是你可以在全局设置(settings)中将localCacheScope设为STATEMENT,这意味着一级缓存仅在语句范围内有效,而不是在整个SqlSession范围内有效。
一级缓存底层的实现步骤:
- 当MyBatis执行某条select语句时,MyBatis将根据该select语句、参数来生成key。
- MyBatis判断在一级缓存(就是HashMap对象)中是否找到对应的key。
- 如果能找到,则称之为“命中缓存”,MyBatis直接从一级缓存中取出该key对应的value并返回。
- 如果没有“命中缓存”,则连接数据库执行select语句,得到查询结果;将key和查询结果作为key-value对放入一级缓存。
- 判断缓存级别是否是STATEMENT,如果是的话,则清空一级缓存。
由于SqlSession级别的一级缓存本质上就是HashMap对象,因此一级缓存只是一个粗粒度的缓存实现,它没有缓存过期的概念,也没有容量的限定,因此不要让SqlSession长时间存活。
List<News> news1 = mapper.findNewsByTitle("燕双嘤"); System.out.println(news1.toString()); List<News> news2 = mapper.findNewsByTitle("燕双嘤"); System.out.println(news2.toString()); --------------------------------------------------- DEBUG [main] com.ysy.MyBatis.Dao.NewsMapper.findNewsByTitle ==> Preparing: select news_id id,news_title title,news_content content from news_inf where news_title like ? DEBUG [main] com.ysy.MyBatis.Dao.NewsMapper.findNewsByTitle ==> Parameters: %燕双嘤%(String) DEBUG [main] com.ysy.MyBatis.Dao.NewsMapper.findNewsByTitle <== Total: 1 [com.ysy.MyBatis.Bean.News@7bba5817] [com.ysy.MyBatis.Bean.News@7bba5817]
1.2,一级缓存的脏读与避免方法
如果将localCacheScope设为SESSION(默认值),通过SqlSession(或Mapper)查询得到的返回值都会被缓存在一级缓存中,这样就带来一个风险:如果程序对这些返回值所引用的对象进行修改——实际上就是修改一级缓存的对象,将会影响整个SqlSession生命周期内通过缓存所返回的值,从而造成一级缓存产生脏数据。因此永远不要对MyBatis返回值的对象进行修改!!!这样才能避免一级缓存产生脏数据。
为了避免一级缓存产生脏数据,MyBatis还进行另一个预防:当SqlSession执行DML语句时会自动flush缓存,这样可以初步避免一级缓存产生的脏数据。
假设使用MyBatis执行DML语句时不会自动flush缓存,会发生如下过程:
- SqlSession执行select语句加载id为1的News对象。
- SqlSession执行update语句更新id为1的News对象——此时id为1的对象已经发生了改变。
- 当 SqlSession想再次获取id为1得到News对象时,如果第二步没有flush缓存,MyBatis将直接返回缓存中id为1的News对象,这个News对象依然是修改之前的脏数据——与数据表中实际记录不一致的数据就是脏数据。
对于使用同一个SqlSession的情形,由于SqlSession执行DML语句总会发生flush缓存,因此上面第3步SqlSession想再次获取id为1的News对象时,MyBatis会让它重新查询数据,这样就避免了产生脏数据。
一级缓存的生命周期与SqlSession一致,这意味着一级缓存不可能跨SqlSession产生作用。在实际应用中,通常存在多条并发线程使用不同的SqlSession访问数据,比如一条线程的SqlSession读取数据,一条线程的Sqlsession修改数据,这样就可能在一级缓存中产生脏数据,这样就可能在一级缓存中产生脏数据。
public class Main { public static void main(String[] args) throws IOException, InterruptedException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); SqlSession session = sqlSessionFactory.openSession(); SqlSession session2 = sqlSessionFactory.openSession(); new MyThread(session2).start(); getPerson(session); } private static void getPerson(SqlSession session) throws InterruptedException { NewsMapper mapper = session.getMapper(NewsMapper.class); List<News> news1 = mapper.findNewsByIds(3); System.out.println(news1.get(0).getTitle()); Thread.sleep(2000); List<News> news2 = mapper.findNewsByIds(3); System.out.println(news2.get(0).getTitle()); session.commit(); session.close(); } static class MyThread extends Thread { private SqlSession session; public MyThread(SqlSession session){ this.session = session; } public void run() { NewsMapper mapper = session.getMapper(NewsMapper.class); mapper.updateNews("燕双嘤223",3); session.commit(); session.close(); } } }
E:\Java\jdk-1.8\bin\java.exe "-javaagent:E:\IDEA\IntelliJ IDEA 2020.2.3\lib\idea_rt.jar=61027:E:\IDEA\IntelliJ IDEA 2020.2.3\bin" -Dfile.encoding=UTF-8 -classpath E:\Java\jdk-1.8\jre\lib\charsets.jar;E:\Java\jdk-1.8\jre\lib\deploy.jar;E:\Java\jdk-1.8\jre\lib\ext\access-bridge-64.jar;E:\Java\jdk-1.8\jre\lib\ext\cldrdata.jar;E:\Java\jdk-1.8\jre\lib\ext\dnsns.jar;E:\Java\jdk-1.8\jre\lib\ext\jaccess.jar;E:\Java\jdk-1.8\jre\lib\ext\jfxrt.jar;E:\Java\jdk-1.8\jre\lib\ext\localedata.jar;E:\Java\jdk-1.8\jre\lib\ext\nashorn.jar;E:\Java\jdk-1.8\jre\lib\ext\sunec.jar;E:\Java\jdk-1.8\jre\lib\ext\sunjce_provider.jar;E:\Java\jdk-1.8\jre\lib\ext\sunmscapi.jar;E:\Java\jdk-1.8\jre\lib\ext\sunpkcs11.jar;E:\Java\jdk-1.8\jre\lib\ext\zipfs.jar;E:\Java\jdk-1.8\jre\lib\javaws.jar;E:\Java\jdk-1.8\jre\lib\jce.jar;E:\Java\jdk-1.8\jre\lib\jfr.jar;E:\Java\jdk-1.8\jre\lib\jfxswt.jar;E:\Java\jdk-1.8\jre\lib\jsse.jar;E:\Java\jdk-1.8\jre\lib\management-agent.jar;E:\Java\jdk-1.8\jre\lib\plugin.jar;E:\Java\jdk-1.8\jre\lib\resources.jar;E:\Java\jdk-1.8\jre\lib\rt.jar;E:\IDEA\workspace\Test\target\classes;E:\apache\apache-maven-3.6.3\maven-allLib\org\mybatis\mybatis\3.5.5\mybatis-3.5.5.jar;E:\apache\apache-maven-3.6.3\maven-allLib\mysql\mysql-connector-java\5.1.40\mysql-connector-java-5.1.40.jar;E:\apache\apache-maven-3.6.3\maven-allLib\org\apache\logging\log4j\log4j-api\2.11.2\log4j-api-2.11.2.jar;E:\apache\apache-maven-3.6.3\maven-allLib\org\apache\logging\log4j\log4j-core\2.11.2\log4j-core-2.11.2.jar com.ysy.MyBatis.Main Sun May 23 09:54:52 CST 2021 WARN: Establishing SSL connection without server's identity verification is not recommended. According to MySQL 5.5.45+, 5.6.26+ and 5.7.6+ requirements SSL connection must be established by default if explicit option isn't set. For compliance with existing applications not using SSL the verifyServerCertificate property is set to 'false'. You need either to explicitly disable SSL by setting useSSL=false, or set useSSL=true and provide truststore for server certificate verification. DEBUG [main] com.ysy.MyBatis.Dao.NewsMapper.findNewsByIds ==> Preparing: select news_id id,news_title title,news_content content from news_inf where news_id =? Sun May 23 09:54:53 CST 2021 WARN: Establishing SSL connection without server's identity verification is not recommended. According to MySQL 5.5.45+, 5.6.26+ and 5.7.6+ requirements SSL connection must be established by default if explicit option isn't set. For compliance with existing applications not using SSL the verifyServerCertificate property is set to 'false'. You need either to explicitly disable SSL by setting useSSL=false, or set useSSL=true and provide truststore for server certificate verification. DEBUG [main] com.ysy.MyBatis.Dao.NewsMapper.findNewsByIds ==> Parameters: 3(Integer) DEBUG [main] com.ysy.MyBatis.Dao.NewsMapper.findNewsByIds <== Total: 1 步鹰 DEBUG [Thread-1] com.ysy.MyBatis.Dao.NewsMapper.updateNews ==> Preparing: update news_inf set news_title=? where news_id=? DEBUG [Thread-1] com.ysy.MyBatis.Dao.NewsMapper.updateNews ==> Parameters: 燕双嘤223(String), 3(Integer) DEBUG [Thread-1] com.ysy.MyBatis.Dao.NewsMapper.updateNews <== Updates: 1 步鹰
上面代码进行了如下过程:
- A线程内的SqlSession先获取id为1的News对象。
- 将CPU切换给B线程,B线程内的SqlSession修改id为1的News对象。
- A线程内的SqlSession再次获取id为1的News对象。
最终的结果:
- A线程内的SqlSession第二次获取id为3的News对象时,MyBatis不应该使用缓存——因为B线程已经更新了底层数据表中的数据,A线程内的SqlSession缓存的SqlSession缓存的News对象是脏数据。
- 实际上,A线程内的SqlSession依然会使用缓存中id为1的News对象——记住:MyBatis的一级缓存最大范围是SqlSession内部,因此它不能跨SqlSession执行缓存flush。
如何避免MyBatis的一级缓存产生上面这种脏数据呢?MyBatis的一级缓存可以关闭吗?
为了避免MyBatis的一级缓存产生这种脏数据,最佳的实践有两个要点:
(1)尽量使用短生命周期的SqlSession:对于对数据实时性要求没那么高(允许有一定的脏数据)的应用,只要项目避免使用长生命周期的SqlSession,即使MyBatis的一级缓存产生了脏数据,但由于SqlSession的生命周期特别短暂,这种脏数据也处于可控范围之内。
(2)避免使用SqlSession一级缓存:对于数据实时性要求非常高的应用,项目基本不允许使用脏数据,此时就应该避免使用MyBatis的一级缓存!但是,MyBatis不允许关闭一级缓存,因为它需要一级缓存来解决循环引用等问题。
- 每个SqlSession永远只执行单次查询。如果要执行第二次查询,请重新打开另一个SqlSession!以上示例之所以产生脏数据,关键就是因为程序用同一个SqlSession两次查询了id为1的News对象。如果每个SqlSession只执行单次查询,那么一级缓存几乎就不产生作用了,这样就可避免一级缓存产生的脏数据。
- 将localCacheScope设置为STATEMENT。这样可避免在SqlSession范围内使用一级缓存,但这种方式依然有产生脏数据的风险。
如果你打算使用MyBatis一级缓存,就要接受它可能产生脏数据的风险,而且无法避免。通常建议避免在对数据实时性要求较高的应用中使用MyBatis的一级缓存。
1.3,二级缓存
二级缓存是Mapper级别的,而默认是关闭的,因此程序必须在Mapper组件中使用<cache.../>元素或@CacheNamespace注解配置、启用二级缓存。在定义<cache.../>元素或@CacheNamespace注解时都可指定如下元素:
- eviction:指定缓存的清除算法。默认值为LRU(最近最少使用的先清除)。
- flushInterval:指定缓存的刷新间隔时间,默认是永不刷新。
- type(在注解中用implementation属性):指定缓存的实现类。除非你打算使用自定义缓存或整合第三方缓存,否则不需要填写该属性。
- readOnly(在注解中用readWrite属性):指定该缓存是否为只读缓存(是否为读写缓存),readOnly属性默认值为false,readWrite属性默认值为true。只读缓存会为所有调用者返回缓存对象的相同实例,因此这些对象不允许被修改,只读缓存的性能很好。读写缓存则通过序列化机制为不同调用者各复制一个缓存对象的副本,因此在性能上会略差一些,但是这样更安全。因此,MyBatis的二级缓存默认是读写缓存。
- size:指定最多缓存多少项,默认缓存1024项。
MyBatis内置的二级缓存实现支持如下缓存算法:
- LRU:最近最少未使用算法,优先清除最长时间不被使用的对象。
- FIFO:先进先出算法,优先清除最先进入缓存的对象。
- SOFT:软引用,基于垃圾回收器状态和软引用规则清除对象。
- WEAK:弱引用,基于垃圾回收器状态和弱引用规则清除对象。
二级缓存实现了SqlSession之间缓存数据的共享,它被绑定到Mapper组件对应的命名空间下,因此同一命名空间下所有SQL语句都会对缓存产生作用。在默认情况下,DML语句会flush缓存,select语句会使用缓存,但不会flush缓存。简单来说,在默认情况下,Mapper组件中所有insert、update、delete、select语句的flushCache和useCache属性配置如下:
操作 flushCache useCache select false true insert true update true delete true 如果希望改变上面的SQL语句默认的缓存行为,比如可能希望将select排除在缓存之外,则应该将<select.../>元素的useCache指定为false;有时可能希望某条insert语句不会flush缓存,则应该将该<insert.../>元素的flushCache设置为false。
配置、启用二级缓存最简单的代码是在Mapper组件中添加:
<cache/>
上面元素就为Mapper组件配置、启用了默认的二级缓存。当然,也可以为<cache.../>元素指定更多的属性来控制二级缓存:
<cache eviction="FIFO" flushInterval="6000" size="512" readOnly="true"/>
上面代码为该Mapper组件配置、启用了二级缓存,这样同一个Mapper组件的查询语句相同且参数相同,将只执行一次——后面将直接使用二级缓存中的数据。
二级缓存通常并不保存在内存中,而是可能存储在磁盘或数据库中,因此被缓存的实体类应该实现Serializable接口,这样才能方便缓存机制将他们进行序列化保存。
public class Main { public static void main(String[] args) throws IOException, InterruptedException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); SqlSession session = sqlSessionFactory.openSession(); NewsMapper mapper = session.getMapper(NewsMapper.class); List<News> news1 = mapper.findNewsByIds(3); System.out.println(news1.get(0).getTitle()); session.close(); SqlSession session2 = sqlSessionFactory.openSession(); NewsMapper mapper2 = session2.getMapper(NewsMapper.class); List<News> news2 = mapper2.findNewsByIds(3); System.out.println(news2.get(0).getTitle()); session.close(); } } =================================================== DEBUG [main] com.ysy.MyBatis.Dao.NewsMapper.findNewsByIds ==> Preparing: select news_id id,news_title title,news_content content from news_inf where news_id =? DEBUG [main] com.ysy.MyBatis.Dao.NewsMapper.findNewsByIds ==> Parameters: 3(Integer) DEBUG [main] com.ysy.MyBatis.Dao.NewsMapper.findNewsByIds <== Total: 1 燕双嘤223 DEBUG [main] com.ysy.MyBatis.Dao.NewsMapper Cache Hit Ratio [com.ysy.MyBatis.Dao.NewsMapper]: 0.5 燕双嘤223
上面程序两次调用NewsMapper组件的getNews(3)方法——即使这两个组件不是同一个实例,它们有不属于同一个SqlSession,但由于二级缓存的缘故,第二次执行getNews(3)时将不会重新执行select语句,而是直接使用二级缓存中的数据。
从运行日志可以看出,其中有包含:“Cache Hit Ratio”字符串的日志,它输出的就是当前方法的二级缓存命中率。在第一次查询id为3的News对象时执行一条select语句,接下来调用SqlSession的close()方法,该方法会关闭SqlSession对象,自然也就关闭了一级缓存。
PS:只有当SqlSession提交或关闭时,MyBatis才会将数据写入二级缓存中,否则还是一级缓存。
如果将配置文件改成注解,只需要使用@CacheNamespace注解代替<cache.../>元素即可。
@CacheNamespace(eviction = FifoCache.class,flushInterval = 60000,size = 512,readWrite = false) public interface NewsMapper { ... }
1.4,二级缓存的脏数据与避免方法
为了避免二级缓存产生脏数据,MyBatis已经做好了预防:Mapper组件执行DML语句时默认会flush二级缓存,因此在同一个Mapper内,只要该Mapper组件执行DML语句更新底层数据,MyBatis就会自动flush二级缓存,这样就避免了产生脏数据。
虽然二级缓存达到了Mapper级别,可以实现SqlSession之间缓存数据的共享,但这也暗示了:不同的Mapper并发访问时同样产生脏数据。
当应用中两个实体存在关联关系时,程序可能出现如下流程:
- A Mapper组件执行select语句加载id为1的A对象,并通过关联关系访问A的关联实体B对象(id=2)。
- B Mapper组件执行update语句更新了id为2的B对象——此时id=2的B对象已经发生了改变。
- 当A Mapper组件再次获取id为1的A对象时,如果第二步没有flush缓存,MyBatis将直接返回二级缓存中id为1的A对象及关联的B对象,这个B仍然是修改之前的脏数据。
在默认情况下,二级缓存的生命周期与Mapper一致,这意味着当多条并发线程使用不同的Mapper访问数据库时,一条线程的A Mapper读取数据,一条线程的B Mapper修改数据,这样就可能在二级缓存中产生脏数据。
如下示例示范了二级缓存产生脏数据的场景。本示例使用到了两个关联实体:Person和Address,它们之间存在1—1关联关系。程序的PersonMapper组件之定义了一个简单的getPerson()方法,该方法用于获取Person实体,当然,也可通过Person实体来获取关联的Address实体。
public class Main { public static void main(String[] args) throws IOException, InterruptedException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); SqlSession session3 = sqlSessionFactory.openSession(); SqlSession session = sqlSessionFactory.openSession(); PersonMapper mapper = session.getMapper(PersonMapper.class); Person person1 = mapper.getPerson(1); System.out.println(person1.getAddress().getDetail()); session.close(); new MyThread(session3).start(); SqlSession session2 = sqlSessionFactory.openSession(); PersonMapper mapper2 = session2.getMapper(PersonMapper.class); Person person2 = mapper2.getPerson(1); System.out.println(person2.getAddress().getDetail()); session.close(); } private static void getPerson(SqlSession session) throws InterruptedException { PersonMapper mapper = session.getMapper(PersonMapper.class); Person person1 = mapper.getPerson(1); System.out.println(person1.getAddress().getDetail()); session.close(); Thread.sleep(2000); Person person2 = mapper.getPerson(1); System.out.println(person2.getAddress().getDetail()); session.commit(); session.close(); } static class MyThread extends Thread { private SqlSession session; public MyThread(SqlSession session){ this.session = session; } public void run() { AddressMapper mapper = session.getMapper(AddressMapper.class); mapper.updateAddress("燕双嘤223",1); session.commit(); session.close(); } } }
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.ysy.MyBatis.Dao.PersonMapper"> <select id="findPersonByAge" resultMap="personMap"> select * from person_inf where person_age>#{age} </select> <select id="getPerson" resultMap="personMap"> select p.*,a.addr_id addr_id,a.addr_detail addr_detail from person_inf p join address_inf a on a.owner_id = p.person_id where person_id =#{id} </select> <resultMap id="personMap" type="com.ysy.MyBatis.Bean.Person"> <id column="person_id" property="id"/> <result column="person_name" property="name"/> <result column="person_age" property="age"/> <association property="address" javaType="com.ysy.MyBatis.Bean.Address" resultMap="com.ysy.MyBatis.Dao.AddressMapper.addressMap"/> </resultMap> <cache/> </mapper> ------------------------------------------------- <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.ysy.MyBatis.Dao.AddressMapper"> <update id="updateAddress"> update address_inf set addr_detail =#{arg0} where addr_id =#{arg1} </update> <cache-ref namespace="com.ysy.MyBatis.Dao.PersonMapper"/> </mapper>
DEBUG [main] com.ysy.MyBatis.Dao.PersonMapper.getPerson ==> Preparing: select p.*,a.addr_id addr_id,a.addr_detail addr_detail from person_inf p join address_inf a on a.owner_id = p.person_id where person_id =? DEBUG [main] com.ysy.MyBatis.Dao.PersonMapper.getPerson ==> Parameters: 1(Integer) DEBUG [main] com.ysy.MyBatis.Dao.PersonMapper.getPerson <== Total: 1 郑州大学 DEBUG [Thread-1] com.ysy.MyBatis.Dao.AddressMapper.updateAddress ==> Preparing: update address_inf set addr_detail =? where addr_id =? DEBUG [Thread-1] com.ysy.MyBatis.Dao.AddressMapper.updateAddress ==> Parameters: 燕双嘤223(String), 1(Integer) DEBUG [main] com.ysy.MyBatis.Dao.PersonMapper Cache Hit Ratio [com.ysy.MyBatis.Dao.PersonMapper]: 0.5 郑州大学 DEBUG [Thread-1] com.ysy.MyBatis.Dao.AddressMapper.updateAddress <== Updates: 1
为了避免这种情况下产生脏数据,可以让有关联关系的Mapper组件共享同一个二级缓存,MyBatis提供了<cache-ref.../>元素或@CacheNamespaceRef注解来引用另一个二级缓存。
在使用<cache-ref.../>元素时,可以指定一个namespace属性(对应于@CacheNamespaceRef注解的name属性),通过该属性指定要引用哪个Mapper的二级缓存。
<cache-ref namespace="com.ysy.MyBatis.Dao.PersonMapper"/>
DEBUG [main] com.ysy.MyBatis.Dao.PersonMapper.getPerson ==> Preparing: select p.*,a.addr_id addr_id,a.addr_detail addr_detail from person_inf p join address_inf a on a.owner_id = p.person_id where person_id =? DEBUG [main] com.ysy.MyBatis.Dao.PersonMapper.getPerson ==> Parameters: 1(Integer) DEBUG [main] com.ysy.MyBatis.Dao.PersonMapper.getPerson <== Total: 1 郑州大学 DEBUG [Thread-1] com.ysy.MyBatis.Dao.AddressMapper.updateAddress ==> Preparing: update address_inf set addr_detail =? where addr_id =? DEBUG [Thread-1] com.ysy.MyBatis.Dao.AddressMapper.updateAddress ==> Parameters: 燕双嘤223(String), 1(Integer) DEBUG [Thread-1] com.ysy.MyBatis.Dao.AddressMapper.updateAddress <== Updates: 1 DEBUG [main] com.ysy.MyBatis.Dao.PersonMapper Cache Hit Ratio [com.ysy.MyBatis.Dao.PersonMapper]: 0.0 DEBUG [main] com.ysy.MyBatis.Dao.PersonMapper.getPerson ==> Preparing: select p.*,a.addr_id addr_id,a.addr_detail addr_detail from person_inf p join address_inf a on a.owner_id = p.person_id where person_id =? DEBUG [main] com.ysy.MyBatis.Dao.PersonMapper.getPerson ==> Parameters: 1(Integer) DEBUG [main] com.ysy.MyBatis.Dao.PersonMapper.getPerson <== Total: 1 燕双嘤223
从日志可以看出,当AddressMapper执行updateAddress()方法更新Address对象之后,程序再次通过Person访问关联的Address对象时,MyBatis重新执行select语句从底层数据表中获取记录,这样就避免了产生脏数据。
1.5,整合Ehcache实现二级缓存
除使用MyBatis本身提供的二级缓存实现之外,MyBatis的二级缓存也支持第三方缓存实现,比如OSCache、Ehcache、Hazelcast等,而且MyBatis还为这些第三方缓存实现提供了对应的插件,因此整合起来非常方便。
- 为了让MyBatis整合Ehcache作为二级缓存的实现,首先需要添加Ehcache的JAR包和MyBatis为Ehcache提供的插件JAR包。
<dependency> <groupId>org.mybatis.caches</groupId> <artifactId>mybatis-ehcache</artifactId> <version>1.0.3</version> </dependency>
- 接下来在Mapper组件中配置、启动Ehcache二级缓存。
<cache type="org.mybatis.caches.ehcache.LoggingEhcache"> <property name="timeToIdleSecond" value="3600"/> <property name="timeToLiveSecond" value="3600"/> <property name="maxEntriesLocalHeap" value="1000"/> <property name="maxEntriesLocalDisk" value="10000000"/> <property name="memoryStoreEvictionPolicy" value="LRU"/> </cache>
- Ehcache缓存本身还需要一个配置文件,该配置文件用于指定Ehcache缓存的存储位置以及默认的缓存设置等。
<?xml version="1.0" encoding="GBK" ?> <ehcache> <diskStore path="java.io.tmpdir"/> <defaultCache maxElementsInMemory="10000" eternal="false" timeToIdleSeconds="120" timeToLiveSeconds="120" maxElementsOnDisk="10000000" diskExpiryThreadIntervalSeconds="120" memoryStoreEvictionPolicy="LRU"/> <cache name="com.ysy.MyBatis.Bean.News" maxElementsInMemory="10000" eternal="false" overflowToDisk="true" timeToIdleSeconds="300" timeToLiveSeconds="600"/> </ehcache>
2,插件
MyBatis提供了插件机制来扩展其功能:通过插件可以让开发者在MyBatis的核心执行流程中“插入”特定的处理过程,这样即可方便地在数据库访问处理中增加某些通用功能,比如分页、日志等。
2.1,拦截器接口及作用
MyBatis插件的本质就是拦截器,因此开发MyBatis插件是就是开发一个拦截器类,该类必须实现MyBatis提供的Interceptor。在Interceptor接口中定义了如下三个方法(拦截器必须实现的三个方法):
- setProperties(Properties properties):与MyBatis的大部分组件类似,该方法用于接收该组件的多个配置参数,所有的配置参数都将以Properties对象的形式传入。
- Object interceptor(Invocation invocation):该方法将会彻底“代替”被拦截的方法。该方法有一个invocation参数,通过该参数可以获取被拦截的目标对象、被拦截的方法、被拦截的方法参数等信息,也可以回调被拦截的方法。
- Object plugin(Object target):该方法用于被拦截的对象生成代理。其中target参数代表了被拦截的对象。
MyBatis插件通常都需要回调被拦截的方法——因为被拦截的方法就是MyBatis框架本身提供的出来,我们只是通过插件为MyBatis增加某种通用处理,并不是完全代替MyBatis的核心功能。
开发自定义拦截器的步骤很简单,只需要两步:
- 开发一个实现Interceptor接口的实现类。
- 在MyBatis核心配置文件中使用如下代码配置:
<plugins> <plugin interceptor="com.ysy.MyBatis.Plugin.XxxPlugin"> <property name="属性名" value="属性值"/> </plugin> </plugins>
MyBatis使用XMLConfigBuilder来解析并处理上面的配置信息,在该类中可以找到如下方法的源代码:
private void pluginElement(XNode parent) throws Exception { //parent代表配置文件中的plugin元素 if (parent != null) { //遍历所有的plugin子元素 Iterator var2 = parent.getChildren().iterator(); while(var2.hasNext()) { XNode child = (XNode)var2.next(); //获取plugin元素的interceptor属性 String interceptor = child.getStringAttribute("interceptor"); //获取plugin元素的所有property子元素,并将他们转换成Properties对象 Properties properties = child.getChildrenAsProperties(); //通过反射创建拦截器对象 Interceptor interceptorInstance = (Interceptor)this.resolveClass(interceptor).getDeclaredConstructor().newInstance(); //调用拦截器对象的setProperties()方法 interceptorInstance.setProperties(properties); //添加拦截器对象 this.configuration.addInterceptor(interceptorInstance); } } }
上面的源代码非常简单,其中while循环体的前两行代码属于XML解析,用于解析<plugin.../>元素的interceptor属性和所有的<property.../>子元素,第三行代码则通过插件类的newInstance()类来创建对象——这是最简单的反射方式。
上面倒数第二行代码调用了拦截器实例的setProperties()方法,将所有<property.../>子元素转换得到的Properties对象作为参数传给该方法——这就是实现插件的setProperties()方法的作用,该方法只在此处被调动。
上面循环体最后一行代码调用了Configuration对象的addInterceptor()方法来添加拦截器,打开Configuration类的源代码就会发现addInterceptor()方法的代码如下:
public void addInterceptor(Interceptor interceptor) { this.interceptorChain.addInterceptor(interceptor); }
InterceptorChain就是一个插件工具类,专门用于管理所有的Interceptor插件,它的源代码很简单,只有短短的几行:
package org.apache.ibatis.plugin; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; public class InterceptorChain { private final List<Interceptor> interceptors = new ArrayList(); public InterceptorChain() { } public Object pluginAll(Object target) { Interceptor interceptor; for(Iterator var2 = this.interceptors.iterator(); var2.hasNext(); target = interceptor.plugin(target)) { interceptor = (Interceptor)var2.next(); } return target; } public void addInterceptor(Interceptor interceptor) { this.interceptors.add(interceptor); } public List<Interceptor> getInterceptors() { return Collections.unmodifiableList(this.interceptors); } }
从上面源代码可以清除地看到,所谓的addInterceptor()方法,无非就是把Interceptor对象添加到List集合中。
- InterceptorChina类中的pluginAll()方法是对目标对象应用拦截器、生成插件的关键方法,该方法遍历List集合中所有的Interceptor对象的plugin()方法为target参数生成代理——新创建的代理将再次作为下一个plugin()方法的目标对象,这是典型的“职责链”。
- 拦截器的plugin()方法负责为目标对象生成代理。为了简化开发者生成代理的实现,MyBatis提供了一个Plugin类,使用该类的wrap(Object target, Interceptor interceptor)方法即可为目标对象生成代理。
- Plugin是一个工具类,该工具类实现了JDK动态代理的InvocationHandler接口、基于JDK动态代理机制为目标对象创建代理。
2.2,可拦截的目标
MyBatis的四大核心对象,拦截器都可拦截:
- Executor:执行器,该执行器负责执行MyBatis的所有数据访问流程,其实SqlSession就是通过调用该对象来执行数据访问的。
- StatementHandler:Statement处理器,它负责创建Statement,为Statement设置参数等。
- ParameterHandler:参数处理器,该处理器负责处理参数。
- ResultSetHandler:结果集处理器,负责处理结果集、传出参数等。
MyBatis使用这四个不同的组件来处理JDBC查询的不同阶段。上面这四个核心组件包含了多个方法,而拦截器可以根据需要来拦截它们不同方法。首先开发一个拦截器类,该类实现了Interceptor接口:
@Intercepts({ @Signature( type = Executor.class, method = "update", args = {MappedStatement.class, Object.class} ), @Signature( type = ParameterHandler.class, method = "setParameters", args = {PreparedStatement.class} ), @Signature( type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class} ), @Signature( type = StatementHandler.class, method = "prepare", args = {Connection.class,Integer.class} ) }) public class FkPlugin implements Interceptor { public Object intercept(Invocation invocation) throws Throwable { System.out.printf("-----------拦截的目标对象为:%s%n", invocation.getTarget()); System.out.printf("-----------拦截的方法为:%s%n", invocation.getMethod()); System.out.printf("-----------拦截的参数为:%s%n", Arrays.toString(invocation.getArgs())); //回调被拦截的目标方法 return invocation.proceed(); } public Object plugin(Object target) { System.out.println("-----------" + target); return Plugin.wrap(target, this); } public void setProperties(Properties properties) { System.out.println("===执行setProperties==="); System.out.println("传入参数为:" + properties); } }
FkPlugin类实现了Interceptor接口,因此它是一个插件类。FkPlugin还使用了@Intercepts注解修饰,该注解指定该插件(本质拦截器)会拦截那些对象的哪些方法。该注解必须且只能指定一个value属性,value属性值是一个@Signature注解数组——每个@Signature注解声明一个拦截点。
@Intercepts注解的value属性值是四个@Signature注解组成的数组,这意味着该注解依次声明四个拦截点。@Signature注解要指定三个属性:
- type:指定那个类。
- method:指定方法名。
- args:指定形参类型列表。
@Signature注解的三个属性正好用于准确地确定一个方法——该方法会由该插件负责拦截。
FkPlugin实现Interceptor接口的plugin()方法时,调用了Plugin工具类提供的wrap()方法来生成代理——这是最方便方式。在实现了拦截器类(插件类)之后,接下来需要在MyBatis-config.xml文件中配置插件:
<plugins> <plugin interceptor="com.ysy.MyBatis.Plugin.FkPlugin"> <property name="maxNum" value="100"/> <property name="testName" value="燕双嘤"/> </plugin> </plugins>
当程序执行Mapper组件的getNews()方法时,MyBatis底层调用select语句从底层数据表中抓取记录,插件(拦截器)将会依次拦截四大核心组件对象的方法:
===执行setProperties=== 传入参数为:{testName=燕双嘤, maxNum=100} SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder". SLF4J: Defaulting to no-operation (NOP) logger implementation SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details. -----------------org.apache.ibatis.executor.CachingExecutor@523884b2 -----------------org.apache.ibatis.scripting.defaults.DefaultParameterHandler@1e397ed7 -----------------org.apache.ibatis.executor.resultset.DefaultResultSetHandler@72d818d1 -----------------org.apache.ibatis.executor.statement.RoutingStatementHandler@59494225 -----拦截的目标对象为:org.apache.ibatis.executor.statement.RoutingStatementHandler@59494225 -----拦截的方法为:public abstract java.sql.Statement org.apache.ibatis.executor.statement.StatementHandler.prepare(java.sql.Connection,java.lang.Integer) throws java.sql.SQLException -----拦截的参数为:[com.mysql.jdbc.JDBC4Connection@184cf7cf, null] -----拦截的目标对象为:org.apache.ibatis.scripting.defaults.DefaultParameterHandler@1e397ed7 -----拦截的方法为:public abstract void org.apache.ibatis.executor.parameter.ParameterHandler.setParameters(java.sql.PreparedStatement) throws java.sql.SQLException -----拦截的参数为:[com.mysql.jdbc.JDBC42PreparedStatement@d706f19: select news_id id,news_title title,news_content content from news_inf where news_id=** NOT SPECIFIED **] -----拦截的目标对象为:org.apache.ibatis.executor.resultset.DefaultResultSetHandler@72d818d1 -----拦截的方法为:public abstract java.util.List org.apache.ibatis.executor.resultset.ResultSetHandler.handleResultSets(java.sql.Statement) throws java.sql.SQLException -----拦截的参数为:[com.mysql.jdbc.JDBC42PreparedStatement@d706f19: select news_id id,news_title title,news_content content from news_inf where news_id=1]
上面日志的前两行表示MyBatis调用了拦截器的setProperties()方法、传入的参数{maxNum=100, testName=燕双嘤}——这个Properties对象就来自<plugin.../>元素内部的多个<property.../>子元素。
从上面的日志可以看出,拦截器依次拦截了RoutingStatementHandler对象(StatementHandler)、DefaultParameterHandler对象(ParameterHandler)、DefaultResultSetHandler对象(ResultSetHandler)——这也就说明了MyBatis的执行流程:StatementHandler—ParameterHandler—ResultSetHandler。
上面的执行流程与JDBC查询过程完全一致:StatementHandler负责根据传入的SQL语句创建PreparedStatement,然后ParameterHandler负责为SQL语句传入参数,最后由ResultSetHandler将查询得到的ResultSet转换成对象或对象List。
为啥没有看到拦截Executor对象呢?因为第一个@Signature注解,该注解声明该插件(本质上是拦截器)只拦截Executor对象的update(MappedStatement, Object)方法,可此处执行的是select语句,自然就看不到拦截该方法了。
让程序执行update方法,就可在控制台看到如下输出:
===执行setProperties=== 传入参数为:{testName=燕双嘤, maxNum=100} SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder". SLF4J: Defaulting to no-operation (NOP) logger implementation SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details. -----------------org.apache.ibatis.executor.CachingExecutor@523884b2 -----拦截的目标对象为:org.apache.ibatis.executor.CachingExecutor@523884b2 -----拦截的方法为:public abstract int org.apache.ibatis.executor.Executor.update(org.apache.ibatis.mapping.MappedStatement,java.lang.Object) throws java.sql.SQLException -----拦截的参数为:[org.apache.ibatis.mapping.MappedStatement@490ab905, {arg1=1, arg0=杜马, param1=杜马, param2=1}] -----------------org.apache.ibatis.scripting.defaults.DefaultParameterHandler@4566e5bd -----------------org.apache.ibatis.executor.resultset.DefaultResultSetHandler@5702b3b1 -----------------org.apache.ibatis.executor.statement.RoutingStatementHandler@4b952a2d -----拦截的目标对象为:org.apache.ibatis.executor.statement.RoutingStatementHandler@4b952a2d -----拦截的方法为:public abstract java.sql.Statement org.apache.ibatis.executor.statement.StatementHandler.prepare(java.sql.Connection,java.lang.Integer) throws java.sql.SQLException -----拦截的参数为:[com.mysql.jdbc.JDBC4Connection@37918c79, null] -----拦截的目标对象为:org.apache.ibatis.scripting.defaults.DefaultParameterHandler@4566e5bd -----拦截的方法为:public abstract void org.apache.ibatis.executor.parameter.ParameterHandler.setParameters(java.sql.PreparedStatement) throws java.sql.SQLException -----拦截的参数为:[com.mysql.jdbc.JDBC42PreparedStatement@45018215: update news_inf set news_title=** NOT SPECIFIED ** where news_id=** NOT SPECIFIED **]
从日志可以看出,该拦截器依次拦截了CachingExecutor对象(Executor)、RoutingStatement对象(StatementHandler)、DefaultParameterHandler对象(ParameterHandler)——这一次没有拦截到ResultSetHandler对象,这是因为update语句不会返回ResultSet,自然不需要ResultSetHandler了。
综合来看,MyBatis框架中核心对象(可拦截对象)的调用关系如图:
开发者可以根据业务需要来开发插件,让插件(本质是拦截器)在指定时机、特定的方法中“插入”某种特定的处理代码,从而通过拦截器为MyBatis在数据库访问层次上增加某些通用功能。
2.3,开发分页插件
MyBatis本身提供了两种分页机制:
- 内存分页:为Mapper组件的查询方法额外定义一个RowBounds参数(就是指定了offset和limit两个参数),MyBatis就会控制该查询方法返回分页后的结果集。MyBatis并没有改变执行的select语句。假如程序要查询的记录总数为10w条,采用这种方式会先从底层数据库总查询得到10w条记录,然后在内存中进行分页。这种方式下,当数据量小时还可以接受;但是当数据量较大时,这种方式就会造成严重的问题——程序一次性就把这10万条记录全部加载到内存,这必然会给程序内存开销带来压力。所以这种内存分页方式在实际项目开发中基本不用。
- SQL分页:这种方式需要开发者在XML Mapper中定义SQL语句时通过SQL语法进行分页。这种方式在select语句层次就完成了分页,即使程序要查询的记录总数为10万条,每次分页查询也只会返回当前页记录,这才是较为实用的分页方式。但这种方式也有问题:它要求开发者每次定义select语句都需要添加分页语法,这样必然会增加实际开发的工作量,比较烦琐。
利用分页插件可以同时解决上面两种问题。程序利用分页插件(拦截器)拦截StatementHandler对象的prepare方法,在该方法中对它所执行的select语句进行修改,增加分页语法。
1、首先需要一个类似于RowBounds的工具类Page,该Page类只用于封装分页参数:当前页码、每页显示的记录数、总页数:
public class Page { private Integer totalPage; private Integer pageSize = 3; private Integer pageIndex = 1; //省略构造器和setter、getter方法 ... }
2、接下来实现分页查询类,该类需要实现Interceptor接口,并在intercept()方法中为目标SQL语句添加分页语法:
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) public class PagePlugin implements Interceptor { @Override @SuppressWarnings("unchecked") public Object intercept(Invocation invocation) throws Throwable { // 获取被拦截的目标对象 StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); // 获取statementHandler对应的MetaObject对象 MetaObject metaObject = SystemMetaObject.forObject(statementHandler); // ① // 通过MetaObject获取本次执行的MappedStatement对象 // MappedStatement代表XML Mapping中的select, insert、update, delete元素 MappedStatement mappedStatement = (MappedStatement) metaObject .getValue("delegate.mappedStatement"); // ② // 获取执行的MappedStatement的id属性值(对应于Mapper组件的方法名) String id = mappedStatement.getId(); // 如果方法名以Page结尾,说明是需要分页的方法 if (id.endsWith("Page")) { BoundSql boundSql = statementHandler.getBoundSql(); // 获取传给Mapper方法的参数 Map<String, Object> paramMap = (Map<String, Object>) boundSql.getParameterObject(); // 定义page变量用于保存分页参数 Page page = null; // 先尝试获取名为page的命名参数(以@Param("page")修饰的参数) try { page = (Page) paramMap.get("page"); } // 如果没找到名为page的命名参数 catch (BindingException ex) { // 遍历paramMap(paramMap代表传给Mapper方法的实际参数) for (String key : paramMap.keySet()) { Object val = paramMap.get(key); // 如果该参数的类型是Page,说明找到了page参数 if (val.getClass() == Page.class) { page = (Page) val; } } } // 如果page依然为null,说明没法找到分页参数 if (page == null) { throw new IllegalArgumentException("Page Parameter can't be null."); } // 获取Mapper组件实际要执行的SQL String sql = boundSql.getSql(); // 生成一条统计总记录数的SQL语句 String countSql = "select count(*) from (" + sql + ") a"; Connection connection = (Connection) invocation.getArgs()[0]; PreparedStatement preparedStatement = connection.prepareStatement(countSql); // 获取ParameterHandler对象 ParameterHandler parameterHandler = statementHandler.getParameterHandler(); // // 也可通过如下代码利用MetaObject获取 // var parameterHandler = (ParameterHandler) metaObject // .getValue("delegate.parameterHandler"); // ③ // 为PreparedStatement中的SQL传入参数 parameterHandler.setParameters(preparedStatement); ResultSet rs = preparedStatement.executeQuery(); if (rs.next()) { int totalRec = rs.getInt(1); // 计算总页数 page.setTotalPage(totalRec / page.getPageSize() == 0 ? totalRec / page.getPageSize() : totalRec / page.getPageSize() + 1); } // 修改SQL语句,增加分页语法(只针对MySQL) String pageSql = sql + " limit " + (page.getPageIndex() - 1) * page.getPageSize() + ", " + page.getPageSize(); // 改变Mapper方法实际要执行的SQL语句 metaObject.setValue("delegate.boundSql.sql", pageSql); // ④ } return invocation.proceed(); } public Object plugin(Object o) { return Plugin.wrap(o, this); } public void setProperties(Properties properties) { } }
该PagePlugin类与前面FkPlugin类的结构完全相同,二者的区别和主要体现在intercept()方法的实现上——FkPlugin的intercept()方法只是简单地演示,因此代码较为简单,而PagePlugin的intercept()方法需要修改select语句,为它增加分页语法,因此该方法略微复杂。
PagePlugin同样使用了@Intercepts注解修饰,该注解的value属性只有一个@Signature注解,这意味着该插件只拦截一个点,而@Signature注解指定了type=StatementHandler.class、method=“prepare”、args={Connection.class, Integer.class},这意味着该插件会拦截StatementHandler的prepare(Connection,Integer)方法。
3、在提供上面的插件类后,接下来在MyBatis核心配置文件中增加如下代码的配置插件:
<plugins> <plugin interceptor="com.ysy.MyBatis.Plugin.PagePlugin"/> </plugins>
4、使用如下测试方法调用Mapper组价的查询方法:
public class Main { public static void main(String[] args) throws IOException, InterruptedException { String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); SqlSession session = sqlSessionFactory.openSession(); NewsMapper mapper = session.getMapper(NewsMapper.class); //每页3条记录,当前为第二页 Page page = new Page(3, 2); List newList = mapper.findNewsByTitlePage("%", page); System.out.println("总页数:" + page.getTotalPage()); System.out.println("查询得到的结果:" + newList); session.close(); } }