Mybatis二级缓存实现详解

上一篇《Mybatis的Executor和缓存体系》介绍了一二级缓存的特点、启用和区别。二级缓存是Mapper级别,在整个应用中可以跨线程共享,所以有更高的命中率。和一级缓存的PerpetualCache相比,二级缓存机制和体系更加完整。

现在,我们一起来看看mybatis二级缓存的创建和实现。

注:本文中源码来自mybatis 3.4.x版本,地址https://github.com/mybatis/mybatis-3.git

一 Cache接口体系

根接口Cache中定义了缓存的操作方法,有如下约束:

  1. 子类实现必须有一个构造函数,该构造函数接收一个字符串类型的cache id作为参数;
  2. MyBatis 会将Mapper文件的namespace作为cache id,来创建Cache实例;
  3. 每个namespace都有一个cache实例。

在结构设计上,二级缓存采用装饰器模式,提供了很多扩展功能,如:

  • 内存溢出回收机制,如FIFO、LRU、soft和weak引用;
  • 序列化存储,如SerializedCache
  • 缓存命中统计,LoggingCache

1.1 基础缓存PerpetualCache

  • PerpetualCache是基础实现,是所有装饰器的被装饰对象;
  • HashMap存储,简单的键值对存储;
  • 数据永久保存,直到手动清理;
  • 非线程安全。

1.2 淘汰策略装饰器

最近最少使用LruCache

  • LRU算法:基于LinkedHashMap的访问顺序
  • 容量控制:超出容量时淘汰最久未访问的数据
  • 访问更新:每次get操作都会更新访问顺序
  • 默认大小:1024个对象
java 复制代码
public class LruCache implements Cache {
  // 被装饰的Cache对象
  private final Cache delegate;
  // 最近最少使用算法的实现,基于LinkedHashMap
  private Map<Object, Object> keyMap;
  // 最近最少使用的key,用于移除过期的缓存项
  private Object eldestKey;

  public LruCache(Cache delegate) {
    this.delegate = delegate;
    setSize(1024);
  }
    
  public void setSize(final int size) {
    keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
      private static final long serialVersionUID = 4267176411845948333L;

      @Override
      protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
        boolean tooBig = size() > size;
        if (tooBig) {
          eldestKey = eldest.getKey();
        }
        return tooBig;
      }
    };
  }    

  // put时触发移除eldestKey
  @Override
  public void putObject(Object key, Object value) {
    delegate.putObject(key, value);
    cycleKeyList(key);
  }

  // get时触发更新eldestKey
  @Override
  public Object getObject(Object key) {
    keyMap.get(key); //touch
    return delegate.getObject(key);
  }   

先进先出FifoCache

  • FIFO算法:严格按照插入顺序淘汰
  • 队列实现:使用LinkedList维护插入顺序,默认大小为1024
  • 简单高效:不考虑访问频率,只看插入时间
  • 适用场景:数据访问模式相对均匀
java 复制代码
public class FifoCache implements Cache {
    private final Cache delegate;
    private final Deque<Object> keyList;  // LinkedList维护插入顺序
    
    private void cycleKeyList(Object key) {
        keyList.addLast(key);           // 新key加到队尾
        if (keyList.size() > size) {
            Object oldestKey = keyList.removeFirst();  // 移除队首
            delegate.removeObject(oldestKey);
        }
    }
}

1.3 引用类型装饰器

软引用缓存SoftCache

内存不足时(JVM在OOM前)自动清理,使用SoftReference包装缓存项

java 复制代码
public class SoftCache implements Cache {
  // 维护一定数量强引用,避免缓存项都被GC回收
  private final Deque<Object> hardLinksToAvoidGarbageCollection;
  // 监听GC队列,及时清理失效缓存
  private final ReferenceQueue<Object> queueOfGarbageCollectedEntries;
  private final Cache delegate;
    
  @Override
  public void putObject(Object key, Object value) {
    // 先清理queueOfGarbageCollectedEntries中GC的缓存项
    removeGarbageCollectedItems();
    // 缓存项包装为SoftReference
    delegate.putObject(key, new SoftEntry(key, value, queueOfGarbageCollectedEntries));
  }

弱引用缓存WeakCache

类似SoftCache,但使用WeakReference,在下次GC时缓存项就可能被回收。

1.4 功能增强装饰器

序列化缓存SerializedCache

  • 深拷贝:每次get返回新的对象实例
  • 线程安全:避免多线程修改同一对象
  • 序列化要求:对象必须实现Serializable接口
  • 性能开销:序列化/反序列化有性能成本
java 复制代码
public class SerializedCache implements Cache {
    @Override
    public void putObject(Object key, Object object) {
        if (object == null || object instanceof Serializable) {
            delegate.putObject(key, serialize((Serializable) object));  // 序列化存储
        } else {
            throw new CacheException("SharedCache failed to make a copy of a non-serializable object: " + object);
        }
    }
    
    @Override
    public Object getObject(Object key) {
        Object object = delegate.getObject(key);
        return object == null ? null : deserialize((byte[]) object);  // 反序列化返回
    }
}

同步缓存SynchronizedCache

由于PerpetualCache非线程安全,SynchronizedCache 中对所有方法都加synchronized,虽然变得线程安全了,但是高并发时会影响性能。

java 复制代码
@Override
public synchronized void putObject(Object key, Object object) {
    delegate.putObject(key, object);
}

日志缓存LoggingCache

能够统计和输出缓存命中率。

阻塞缓存BlockingCache

适用于高并发环境下防止缓存击穿、数据库查询成本很高的场景。

  • 防止缓存击穿:多线程同时查询同一个不存在的key时,只有一个线程查询数据库
  • 锁机制:每个key对应一个ReentrantLock
  • 超时控制:支持获取锁的超时时间设置
java 复制代码
// 阻塞缓存:当缓存未命中时, 会阻塞当前线程, 直到缓存被填充
public class BlockingCache implements Cache {
  // 锁超时时间
  private long timeout = 1000;
  private final Cache delegate;
  // 每个缓存键对应一个锁对象
  private final ConcurrentHashMap<Object, ReentrantLock> locks;

  // 放入缓存项,同时释放锁对象
  @Override
  public void putObject(Object key, Object value) {
    try {
      delegate.putObject(key, value);
    } finally {
      releaseLock(key);
    }
  }

  @Override
  public Object getObject(Object key) {
    // 先获取缓存键对应的锁对象
    acquireLock(key);
    Object value = delegate.getObject(key);
    // 如果缓存命中, 则释放锁对象
    if (value != null) {
      releaseLock(key);
    }
    // 未命中时返回null,将导致查询数据库、写缓存
    return value;
  }    

定时清理缓存ScheduledCache

在缓存操作时检查是否超时,超时后自动清空所有缓存数据。支持设置时间间隔。适用于数据有明确的时效性要求、定期刷新缓存数据等场景。

1.5 总结

MyBatis的Cache体系通过装饰器模式,实现了高度的灵活性和可扩展性,每个装饰器专注于单一职责,可以根据实际需求灵活组合使用。

二 创建和使用二级缓存

2.1 创建

一级缓存是org.apache.ibatis.executor.BaseExecutor中PerpetualCache localCache,默认启用,在创建Executor时就初始化了。

那么,二级缓存是何时创建的呢?

先说结论:

  • 二级缓存实例在应用启动的Mapper XML解析阶段创建,而不是在运行时动态创建。这保证了缓存的生命周期与应用生命周期一致,实现了跨SqlSession的数据共享。
  • 每个Mapper.xml中<cache>标签,对应一个缓存实例,cacheId就是namespace,创建时就应用了所有装饰器。

详细流程:

  1. 在XMLConfigBuilder.mapperElement方法中,会逐个解析Mapper.xml;
java 复制代码
// XMLConfigBuilder.parse() -> parseConfiguration() -> mapperElement()
private void mapperElement(XNode parent) throws Exception {
    if (parent != null) {
        for (XNode child : parent.getChildren()) {
            if ("package".equals(child.getName())) {
                // 扫描包下的Mapper
            } else {
                // 解析单个Mapper文件
                String resource = child.getStringAttribute("resource");
                InputStream inputStream = Resources.getResourceAsStream(resource);
                XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream</mark>, configuration, resource, configuration.getSqlFragments());
                // 在这里创建二级缓存
                mapperParser.parse(); 
            }
        }
    }
}
  1. 在XMLMapperBuilder.configurationElement方法中,会解析Mapper中<cache>标签;
java 复制代码
public class XMLMapperBuilder extends BaseBuilder {
    public void parse() {
        if (!configuration.isResourceLoaded(resource)) {
            configurationElement(parser.evalNode("/mapper"));
            configuration.addLoadedResource(resource);
            bindMapperForNamespace();
        }
    }
    
    private void configurationElement(XNode context) {
        try {
            String namespace = context.getStringAttribute("namespace");
            if (namespace == null || namespace.equals("")) {
                throw new BuilderException("Mapper's namespace cannot be empty");
            }
            builderAssistant.setCurrentNamespace(namespace);
            
            // 解析cache标签,创建二级缓存实例
            cacheElement(context.evalNode("cache"));
            cacheRefElement(context.evalNode("cache-ref"));
            // ...其他解析
        } catch (Exception e) {
            throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e);
        }
    }
}
  1. 解析缓存配置,准备创建实例
  1. 在MapperBuilderAssistant#useNewCache中
  1. org.apache.ibatis.mapping.CacheBuilder#build中,应用装饰模式
  1. 在Mapper.xml中,可配置size、clearInterval等属性,会添加相应的装饰器。
java 复制代码
// CacheBuilder中的装饰器应用顺序
Cache cache = new PerpetualCache(id);
cache = new LruCache(cache);           // 淘汰策略,默认Lru
cache = new ScheduledCache(cache);     // 配置了clearInterval时
cache = new SerializedCache(cache);    // 配置了readWrite时
cache = new LoggingCache(cache);       // 日志
cache = new SynchronizedCache(cache);  // 同步
cache = new BlockingCache(cache);  	   // 配置了blocking时

2.2 使用

创建的二级缓存实例,会被对应的MappedStatement引用。

java 复制代码
public class MappedStatement {
    private Cache cache; // 引用对应的缓存实例
    
    public Cache getCache() {
        return cache;
    }
}

CachingExecutor在查询时,从MappedStatement中获取二级缓存实例并使用

java 复制代码
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    // 获取Mapper配置文件中二级缓存实例
    Cache cache = ms.getCache();
    // 启用了二级缓存
    if (cache != null) {
      // 使用缓存
    }
    //未启用二级缓存,直接查询数据库
    return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

三 特别的TransactionalCache

TransactionalCache也是Cache接口的实现,是MyBatis二级缓存的事务缓冲区,为二级缓存提供事务语义支持。有以下特性

  1. 延迟写入
  • 缓存操作不直接写入二级缓存
  • 所有写操作暂存在entriesToAddOnCommit中
  • 只有事务提交时才真正写入共享缓存
  1. 事务隔离
  • 每个事务有独立的TransactionalCache实例
  • 未提交的缓存项对其他事务不可见
  • 保证缓存的事务一致性
java 复制代码
private final Cache delegate;
private boolean clearOnCommit;
// 待提交的缓存项
private final Map<Object, Object> entriesToAddOnCommit;
// 缓存未命中的key。TransactionalCache.putObject不会触发BlockingCache释放锁,
// 因此rollback时对value为null的key,需要执行put来解锁
private final Set<Object> entriesMissedInCache;

public void commit() {
    if (clearOnCommit) {
        delegate.clear();
    }
    // 提交时, 将待添加的缓存项添加到二级缓存中
    flushPendingEntries();
    reset();
}

// 回滚时,丢弃待添加的缓存项
public void rollback() {
    // 回滚时, 解锁所有未命中缓存项的锁对象
    unlockMissedEntries();
    reset();
}

3.1 事务隔离

mybatis中二级缓存跨session共享,当使用TransactionalCache装饰时,如何区分不同事务的待提交缓存项呢?

答案是:不同事务的缓存操作完全隔离。MyBatis通过为每个SqlSession创建独立的TransactionalCache实例来实现事务级别的缓存隔离,而不是在单个实例内部区分不同事务。

  1. 实例级别隔离:每个SqlSession在使用二级缓存时,都会创建自己的TransactionalCache包装器

  2. 本地缓存区:每个TransactionalCache维护自己的entriesToAddOnCommit映射,存储该事务的待提交项

  3. 事务边界控制:只有在调用commit()时,待提交的缓存项才会真正写入共享的二级缓存。

    事务A (SqlSession A) 事务B (SqlSession B)
    | |
    TransactionalCache A TransactionalCache B
    | |
    entriesToAddOnCommit entriesToAddOnCommit
    {key1: value1} {key2: value2}
    | |
    commit() rollback()
    | |
    写入二级缓存 丢弃缓存项

注意,SqlSession非线程安全,不应被多线程并发使用;因此某一时刻下某个sqlSession最多处理一个事务。

3.2 源码实现

MyBatis通过实例级别的隔离而非数据级别的区分,来实现不同事务的TransactionalCache隔离。

  • CachingExecutor持有TransactionalCacheManager实例;
  • TransactionalCacheManager会为每个二级缓存创建一个TransactionalCache对象
java 复制代码
  // 二级缓存的TransactionalCache包装
  private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();

  private TransactionalCache getTransactionalCache(Cache cache) {
    TransactionalCache txCache = transactionalCaches.get(cache);
    if (txCache == null) {
      txCache = new TransactionalCache(cache);
      transactionalCaches.put(cache, txCache);
    }
    return txCache;
  }

事务隔离的实现路径

  1. SqlSession级别隔离:每个事务使用独立的SqlSession实例
  2. Executor级别隔离:每个SqlSession创建独立的CachingExecutor
  3. TransactionalCacheManager级别隔离:每个CachingExecutor有独立的TransactionalCacheManager
  4. TransactionalCache级别隔离:每个二级Cache在每个事务中都有独立的TransactionalCache包装
相关推荐
源码获取_wx:Fegn08952 小时前
基于springboot + vue健康茶饮销售管理系统
java·vue.js·spring boot·后端·spring
小帅学编程2 小时前
Spring(侧重注解开发)
java·学习·spring
AutoMQ2 小时前
🎉 庆祝 AutoMQ 在 GitHub 上突破 9k Stars!
架构
早点睡觉好了2 小时前
JAVA中基本类型和包装类型的区别
java·开发语言
Tipriest_2 小时前
linux中的文本分接流tee命令介绍
linux·服务器·数据库
爱喝水的鱼丶2 小时前
SAP-ABAP:在SAP世界里与特殊字符“斗智斗勇”:一份来自实战的避坑指南
运维·服务器·数据库·学习·sap·abap·特殊字符
阿拉伯柠檬2 小时前
MySQL内置函数
linux·数据库·mysql·面试
雅俗共赏zyyyyyy2 小时前
SpringBoot集成配置文件加解密
java·spring boot·后端
小Mie不吃饭2 小时前
2025 Oracle小白零基础到入门的学习路线
数据库·oracle