MyBatis缓存机制

本文介绍了MyBatis一二级缓存机制。MyBatis是常见的Java数据库访问层框架,一级缓存默认开启,生命周期和SqlSession一致,内部设计简单;二级缓存可实现SqlSession间数据共享,粒度更细。但MyBatis缓存机制存在不足,易引发脏数据,建议生产环境关闭缓存。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

MyBatis的前身是iBATIS,它是一款优秀的支持自定义SQL查询、存储过程和高级映射的持久层框架,消除了几乎所有的JDBC代码和参数的手动设置以及结果集的检索。在日常工作中,它是常见的Java数据库访问层框架,开发人员多数情况下是使用MyBatis的默认缓存配置,但是MyBatis缓存机制有一些不足之处,在使用中容易引起脏数据,形成一些潜在的隐患。

目录

1. 基本功

1.1 整体架构

1.2 执行流程

2. 一级缓存

2.1 介绍

2.2 配置

2.3 实验

2.4 工作流程

2.5 源码分析

2.6 小结

3. 二级缓存

3.1 介绍

3.2 配置

3.3 实验

3.4 源码

3.5 小结

4. 总结


1. 基本功

1.1 整体架构

MyBatis整体分为三层:基础支持层、核心处理层、接口层

1.2 执行流程

SQL语句的执行中比较重要的是Executor,StatementHandler,ParameterHandler和ResultSetHandler。

Executor主要负责一级缓存和二级缓存,并提供是事务管理的相关操作,它会将数据库相关操作委托给StatementHandler完成。StatementHandler首先通过ParammeterHandler完成SQL的实参绑定,然后通过java.sql.Statement对象执行sql语句并得到结果集ResultSet,最后通过ResultSetHandler完成结果集的映射,得到对象并返回。

2. 一级缓存

2.1 介绍

在开发中,我们可能在一次数据库会话中多次执行完全相同的查询语句,MyBatis提供了一级缓存的方案优化这部分场景,如果是相同的SQL语句,会优先命中一级缓存,避免直接对数据库进行查询,提高性能。

每个SqlSession中持有了Executor,每个Executor中有一个LocalCache。当用户发起查询时,MyBatis根据当前执行的语句生成MappedStatement,在Local Cache进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入Local Cache,最后返回结果给用户。

2.2 配置

默认情况下一级缓存是开启的,而且是不能关闭的。在MyBatis的配置文件中有两个选项,SESSION或者STATEMENT,默认是SESSION级别,即在一个MyBatis会话中执行的所有语句,都会共享这一个缓存。一种是STATEMENT级别,可以理解为缓存只对当前执行的这一个Statement有效。

<setting name="localCacheScope" value="SESSION"/>

2.3 实验

实验1:开启一级缓存,范围为会话级别,多次查询?

结论:只有第一次真正查询了数据库,后续的查询使用了一级缓存。

CREATE TABLE `student` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(200) COLLATE utf8_bin DEFAULT NULL,
  `age` tinyint(3) unsigned DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
public void getStudentById() throws Exception {
        SqlSession sqlSession = factory.openSession(true); // 自动提交事务
        StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
        System.out.println(studentMapper.getStudentById(1));
        System.out.println(studentMapper.getStudentById(1));
        System.out.println(studentMapper.getStudentById(1));
}

实验2:在一次数据库会话中,如果对数据库发生了修改操作,一级缓存是否会失效?

结论:在修改操作后,执行的相同查询,一级缓存失效了

@Test
public void addStudent() throws Exception {
        SqlSession sqlSession = factory.openSession(true); // 自动提交事务
        StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
        System.out.println(studentMapper.getStudentById(1));
        System.out.println("增加了" + studentMapper.addStudent(buildStudent()) + "个学生");
        System.out.println(studentMapper.getStudentById(1));
        sqlSession.close();
}

实验3:开启两个SqlSession,一个查询数据使一级缓存生效,另一个更新数据库,验证一级缓存只在数据库会话内部共享?

结论:一级缓存只在数据库会话内部共享(session1查询出现了脏数据)

@Test
public void testLocalCacheScope() throws Exception {
        SqlSession sqlSession1 = factory.openSession(true); 
        SqlSession sqlSession2 = factory.openSession(true);
        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2更新了" + studentMapper2.updateStudentName("小岑",1) + "个学生的数据");
        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
}

2.4 工作流程

2.5 源码分析

SqlSession: 对外提供了用户和数据库之间交互需要的所有方法,隐藏了底层的细节。默认实现类是DefaultSqlSession

Executor: SqlSession向用户提供操作数据库的方法,但和数据库操作有关的职责都会委托给Executor。

主要看BaseExecutor的内部实现,它定义若干抽象方法和Local Cache,在执行的时候,把具体的操作委托给子类进行执行。

public abstract class BaseExecutor implements Executor {
    protected ConcurrentLinkedQueue<DeferredLoad> deferredLoads;
    /**
    * 对Local Cache的查询和写入
    * Cache下面有解释
    **/
    protected PerpetualCache localCache;

    protected abstract int doUpdate(MappedStatement ms, Object parameter) throws SQLException;
    protected abstract List<BatchResult> doFlushStatements(boolean isRollback) throws SQLException;
    protected abstract <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException;
    protected abstract <E> Cursor<E> doQueryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds, BoundSql boundSql) throws SQLException;
}
//PerpetualCache 是对Cache接口最基本的实现,其实现非常简单,内部持有HashMap,对一级缓存的操作实则是对HashMap的操作
public class PerpetualCache implements Cache {
  private String id;
  private Map<Object, Object> cache = new HashMap<Object, Object>();
}
/**何时创建呢?
* 
* 首先需要初始化SqlSession,通过DefaultSqlSessionFactory开启SqlSession
**/
private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
    ...
    final Executor executor = configuration.newExecutor(tx, execType);//这里 
    return new DefaultSqlSession(configuration, executor, autoCommit);
}
//使用Configuration类创建一个全新的Executor,作为DefaultSqlSession构造函数的参数
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? defaultExecutorType : executorType;
    executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
    Executor executor;
    if (ExecutorType.BATCH == executorType) {
      executor = new BatchExecutor(this, transaction);
    } else if (ExecutorType.REUSE == executorType) {
      executor = new ReuseExecutor(this, transaction);
    } else {
      executor = new SimpleExecutor(this, transaction);
    }
    // 尤其可以注意这里,如果二级缓存开关开启的话,是使用CahingExecutor装饰BaseExecutor的子类
    if (cacheEnabled) {
      executor = new CachingExecutor(executor);                      
    }
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
}
//SqlSession创建完毕后,根据Statment的不同类型,会进入SqlSession的不同方法中
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
      MappedStatement ms = configuration.getMappedStatement(statement);
      return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
}
//SqlSession把具体的查询职责委托给了Executor。如果只开启了一级缓存的话,首先会进入BaseExecutor的query方法
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameter);
    //根据传入的参数生成CacheKey
    CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
    //query方法:如果查不到的话,就从数据库查,在queryFromDatabase中,会对localcache进行写入
    //在query方法执行的最后,会判断一级缓存级别是否是STATEMENT级别,如果是的话,就清空缓存,这也就是STATEMENT级别的一级缓存无法共享localCache的原因。
    return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

/**CacheKey是怎样的?
* 将MappedStatement的Id、SQL的offset、SQL的limit、SQL本身以及SQL中的参数传入CacheKey
* 除去hashcode、checksum和count的比较外,只要updatelist中的元素一一对应相等,那么就可以认为是CacheKey相等。只要两条SQL的下列五个值相同,即可以认为是相同的SQL。
* Statement Id + Offset + Limmit + Sql + Params
**/
CacheKey cacheKey = new CacheKey();
cacheKey.update(ms.getId());
cacheKey.update(rowBounds.getOffset());
cacheKey.update(rowBounds.getLimit());
cacheKey.update(boundSql.getSql());
//后面是update了sql中带的参数
cacheKey.update(value);

public class CacheKey{
    private static final int DEFAULT_MULTIPLYER = 37;
    private static final int DEFAULT_HASHCODE = 17;

    private int multiplier;
    private int hashcode;
    private long checksum;
    private int count;
    private List<Object> updateList;

    public CacheKey() {
        this.hashcode = DEFAULT_HASHCODE;
        this.multiplier = DEFAULT_MULTIPLYER;
        this.count = 0;
        this.updateList = new ArrayList<Object>();
    }
    //会进行一个hashcode和checksum的计算,同时把传入的参数添加进updatelist中
	public void update(Object object) {
		int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object); 
		count++;
		checksum += baseHashCode;
		baseHashCode *= count;
		hashcode = multiplier * hashcode + baseHashCode;
		
		updateList.add(object);
	}
	@Override
	public boolean equals(Object object) {
		...
		for (int i = 0; i < updateList.size(); i++) {
		  Object thisObject = updateList.get(i);
		  Object thatObject = cacheKey.updateList.get(i);
		  if (!ArrayUtil.equals(thisObject, thatObject)) {
			return false;
		  }
		}
		return true;
	}
}

如果是insert/delete/update方法,缓存就会刷新的原因。

//SqlSession的insert方法和delete方法,都会统一走update的流程
@Override
public int insert(String statement, Object parameter) {
    return update(statement, parameter);
}
@Override
public int delete(String statement) {
    return update(statement, null);
}
//update方法也是委托给了Executor执行
@Override
public int update(MappedStatement ms, Object parameter) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    clearLocalCache();
    return doUpdate(ms, parameter);
}

Cache: MyBatis中的Cache接口,提供了和缓存相关的最基本的操作,它有若干个实现类,使用装饰器模式互相组装,提供丰富的操控缓存的能力,

2.6 小结

  1. MyBatis一级缓存的生命周期和SqlSession一致
  2. MyBatis一级缓存内部设计简单,只是一个没有容量限定的HashMap,在缓存的功能性上有所欠缺
  3. MyBatis的一级缓存最大范围是SqlSession内部,有多个SqlSession或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为Statement

3. 二级缓存

3.1 介绍

在上文中提到的一级缓存中,其最大的共享范围就是一个SqlSession内部,如果多个SqlSession之间需要共享缓存,则需要使用到二级缓存。开启二级缓存后,会使用CachingExecutor装饰Executor,进入一级缓存的查询流程前,先在CachingExecutor进行二级缓存的查询。

二级缓存开启后,同一个namespace下的所有操作语句,都影响着同一个Cache,即二级缓存被多个SqlSession共享,是一个全局的变量。

当开启缓存后,数据的查询执行的流程就是 二级缓存 -> 一级缓存 -> 数据库。

3.2 配置

1. 在MyBatis的配置文件中开启二级缓存,<setting name="cacheEnabled" value="true"/>

2. 在MyBatis的映射XML中配置cache或者 cache-ref 

<cache/>

  • type:cache使用的类型,默认是PerpetualCache,这在一级缓存中提到过。
  • eviction: 定义回收的策略,常见的有FIFO,LRU。
  • flushInterval: 配置一定时间自动刷新缓存,单位是毫秒。
  • size: 最多缓存对象的个数。
  • readOnly: 是否只读,若配置可读写,则需要对应的实体类能够序列化。
  • blocking: 若缓存中找不到对应的key,是否会一直blocking,直到有对应的数据进入缓存。

cache-ref代表引用别的命名空间的Cache配置,两个命名空间的操作使用的是同一个Cache。

<cache-ref namespace="mapper.StudentMapper"/>

3.3 实验

实验1:不提交事务,sqlSession1查询完数据后,sqlSession2相同的查询是否会从缓存中获取数据?

结论:当sqlsession没有调用commit()方法时,二级缓存并没有起到作用。

@Test
public void testCacheWithoutCommitOrClose() throws Exception {
        SqlSession sqlSession1 = factory.openSession(true); 
        SqlSession sqlSession2 = factory.openSession(true); 
        
        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);

        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
}

实验2:当提交事务时,sqlSession1查询完数据后,sqlSession2相同的查询是否会从缓存中获取数据?

结论:sqlsession2的查询,使用了缓存,缓存的命中率是0.5。

@Test
public void testCacheWithCommitOrClose() throws Exception {
        SqlSession sqlSession1 = factory.openSession(true); 
        SqlSession sqlSession2 = factory.openSession(true); 
        
        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);

        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
        sqlSession1.commit();
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
}

实验3:update操作是否会刷新该namespace下的二级缓存?

结论:sqlSession3更新数据库,并提交事务后,sqlsession2StudentMapper namespace下的查询走了数据库,没有走Cache

@Test
public void testCacheWithUpdate() throws Exception {
        SqlSession sqlSession1 = factory.openSession(true); 
        SqlSession sqlSession2 = factory.openSession(true); 
        SqlSession sqlSession3 = factory.openSession(true); 
        
        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
        StudentMapper studentMapper3 = sqlSession3.getMapper(StudentMapper.class);
        
        System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
        sqlSession1.commit();
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
        
        studentMapper3.updateStudentName("方方",1);
        sqlSession3.commit();
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
}

实验4:二级缓存不适应用于映射文件中存在多表查询的情况

结论:通常我们会为每个单表创建单独的映射文件,由于MyBatis的二级缓存是基于namespace的,多表查询语句所在的namspace无法感应到其他namespace中的语句对多表查询中涉及的表进行的修改,引发脏数据问题。

@Test
public void testCacheWithDiffererntNamespace() throws Exception {
        SqlSession sqlSession1 = factory.openSession(true); 
        SqlSession sqlSession2 = factory.openSession(true); 
        SqlSession sqlSession3 = factory.openSession(true); 
    
        StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
        StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
        ClassMapper classMapper = sqlSession3.getMapper(ClassMapper.class);
        
        System.out.println("studentMapper读取数据: " + studentMapper.getStudentByIdWithClassInfo(1));
        sqlSession1.close();
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));

        classMapper.updateClassName("特色一班",1);
        sqlSession3.commit();
        System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));
}

实验5:为了解决实验4的问题呢,可以使用Cache ref,让ClassMapper引用StudenMapper命名空间,这样两个映射文件对应的SQL操作都使用的是同一块缓存了

结论:缓存的粒度变粗了,多个Mapper namespace下的所有操作都会对缓存使用造成影响

3.4 源码

源码分析从CachingExecutorquery方法展开,首先会从MappedStatement中获得在配置初始化时赋予的Cache。本质上是装饰器模式的使用,具体的装饰链是 SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache

他们的组合为Cache赋予了不同的能力:

  • SynchronizedCache:同步Cache,实现比较简单,直接使用synchronized修饰方法。
  • LoggingCache:日志功能,装饰类,用于记录缓存的命中率,如果开启了DEBUG模式,则会输出命中率日志。
  • SerializedCache:序列化功能,将值序列化后存到缓存中。该功能用于缓存返回一份实例的Copy,用于保存线程安全。
  • LruCache:采用了Lru算法的Cache实现,移除最近最少使用的Key/Value。
  • PerpetualCache: 作为为最基础的缓存类,底层实现比较简单,直接使用了HashMap。

然后是判断是否需要刷新缓存:flushCacheIfRequired(ms);

在默认的设置中SELECT语句不会刷新缓存,insert/update/delte会刷新缓存。进入该方法。

private void flushCacheIfRequired(MappedStatement ms) {
    Cache cache = ms.getCache();
    if (cache != null && ms.isFlushCacheRequired()) {      
      tcm.clear(cache);
    }
}

MyBatis的CachingExecutor持有了TransactionalCacheManager,TransactionalCacheManager中持有了一个Map,这个Map保存了Cache和用TransactionalCache包装后的Cache的映射关系。

private Map<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();

TransactionalCache实现了Cache接口,CachingExecutor会默认使用他包装初始生成的Cache,作用是如果事务提交,对缓存的操作才会生效,如果事务回滚或者不提交事务,则不对缓存产生影响。

TransactionalCache的clear,有以下两句。清空了需要在提交时加入缓存的列表,同时设定提交时清空缓存,

@Override
public void clear() {
	clearOnCommit = true;
	entriesToAddOnCommit.clear();
}
//CachingExecutor继续往下走,ensureNoOutParams主要是用来处理存储过程的,暂时不用考虑。
if (ms.isUseCache() && resultHandler == null) {
	ensureNoOutParams(ms, parameterObject, boundSql);
//之后会尝试从tcm中获取缓存的列表
List<E> list = (List<E>) tcm.getObject(cache, key);
//在getObject方法中,会把获取值的职责一路传递,最终到PerpetualCache。如果没有查到,会把key加入Miss集合,这个主要是为了统计命中率。
Object object = delegate.getObject(key);
if (object == null) {
	entriesMissedInCache.add(key);
}
//CachingExecutor继续往下走,如果查询到数据,则调用tcm.putObject方法,往缓存中放入值。
if (list == null) {
	list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
	tcm.putObject(cache, key, list); // issue #578 and #116
}
//tcm的put方法也不是直接操作缓存,只是在把这次的数据和key放入待提交的Map中。
@Override
public void putObject(Object key, Object object) {
    entriesToAddOnCommit.put(key, object);
}

从以上的代码分析中,我们可以明白,如果不调用commit方法的话,由于TranscationalCache的作用,并不会对二级缓存造成直接的影响。因此我们看看Sqlsessioncommit方法中做了什么。

@Override
public void commit(boolean force) {
    try {
      executor.commit(isCommitOrRollbackRequired(force));
//因为我们使用了CachingExecutor,首先会进入CachingExecutor实现的commit方法。
@Override
public void commit(boolean required) throws SQLException {
    delegate.commit(required);
    tcm.commit();
}
//会把具体commit的职责委托给包装的Executor。主要是看下tcm.commit(),tcm最终又会调用到TrancationalCache。
public void commit() {
    if (clearOnCommit) {
      delegate.clear();
    }
    flushPendingEntries();
    reset();
}
//看到这里的clearOnCommit就想起刚才TrancationalCache的clear方法设置的标志位,真正的清理Cache是放到这里来进行的。具体清理的职责委托给了包装的Cache类。之后进入flushPendingEntries方法。
private void flushPendingEntries() {
    for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
      delegate.putObject(entry.getKey(), entry.getValue());
    }
    ...
}
//在flushPendingEntries中,将待提交的Map进行循环处理,委托给包装的Cache类,进行putObject的操作。
//后续的查询操作会重复执行这套流程。如果是insert|update|delete的话,会统一进入CachingExecutor的update方法,其中调用了这个函数,
private void flushCacheIfRequired(MappedStatement ms) 

在二级缓存执行流程后就会进入一级缓存的执行流程。

3.5 小结

  1. MyBatis的二级缓存相对于一级缓存来说,实现了SqlSession之间缓存数据的共享,同时粒度更加的细,能够到namespace级别,通过Cache接口实现类不同的组合,对Cache的可控性也更强。
  2. MyBatis在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用二级缓存的条件比较苛刻。
  3. 在分布式环境下,由于默认的MyBatis Cache实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将MyBatis的Cache接口实现,有一定的开发成本,直接使用Redis、Memcached等分布式缓存可能成本更低,安全性也更高。

4. 总结

本文对介绍了MyBatis一二级缓存的基本概念,个人建议MyBatis缓存特性在生产环境中进行关闭,单纯作为一个ORM框架使用可能更为合适。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值