自定义缓存
导学
本节学习目标:
- 掌握MyBatis
Cache接口的契约与实现规范 - 能够编写自定义缓存实现并接入MyBatis
- 理解MyBatis与Ehcache、Redis集成的配置方式
- 认识分布式缓存场景下的序列化、过期策略与一致性挑战
定义
MyBatis内置的二级缓存基于单机内存HashMap,容量和可靠性受限于JVM堆空间。自定义缓存机制解决的核心痛点是:将MyBatis缓存层外接到企业级缓存中间件(如Ehcache、Redis),实现分布式共享、持久化存储、大容量缓存和更精细的过期控制,使缓存从开发工具升级为生产基础设施。
核心原理
Cache接口契约
任何自定义缓存都必须实现org.apache.ibatis.cache.Cache接口,该接口定义了6个核心方法:
| 方法 | 说明 |
|---|---|
String getId() | 返回缓存的唯一标识,通常为Mapper命名空间 |
void putObject(Object key, Object value) | 将键值对存入缓存 |
Object getObject(Object key) | 根据Key获取缓存值 |
Object removeObject(Object key) | 移除指定Key的缓存项 |
void clear() | 清空整个缓存 |
int getSize() | 返回当前缓存项数量 |
MyBatis在创建缓存时,会通过装饰器模式层层包装:
SynchronizedCache → LoggingCache → SerializedCache → LruCache → 自定义Cache
SynchronizedCache:同步锁,保证线程安全LoggingCache:记录命中率日志SerializedCache:序列化存储(readOnly=false时)LruCache:LRU淘汰策略- 最底层:自定义Cache实现
自定义Cache集成架构
完整示例
场景说明
乐途公司人事系统需要将所有员工数据缓存到Redis,实现多节点共享。本节先实现一个带日志打印的自定义缓存(便于教学观察),再演示Ehcache集成,最后给出Redis集成思路。
操作前的数据库表结构及初始数据
CREATE TABLE student (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20),
age INT,
major VARCHAR(20),
score DECIMAL(5,2)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO student (name, age, major, score) VALUES
('大翔', 22, '计算机科学', 95.5),
('白歌', 21, '软件工程', 88.0),
('小崔', 20, '计算机科学', 92.0),
('黄俪', 21, '信息安全', 90.5),
('李眉', 22, '软件工程', 87.0);
当前数据状态:
| id | name | age | major | score |
|---|---|---|---|---|
| 1 | 大翔 | 22 | 计算机科学 | 95.50 |
| 2 | 白歌 | 21 | 软件工程 | 88.00 |
| 3 | 小崔 | 20 | 计算机科学 | 92.00 |
| 4 | 黄俪 | 21 | 信息安全 | 90.50 |
| 5 | 李眉 | 22 | 软件工程 | 87.00 |
完整代码与配置
示例1:自定义日志缓存实现
LoggingCache.java
package com.fly.cache;
import org.apache.ibatis.cache.Cache;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* 自定义缓存:带操作日志的内存缓存
* 用于教学演示,观察MyBatis何时put、何时get、何时clear
*/
public class LoggingCache implements Cache {
private final String id;
private final Map<Object, Object> cache = new HashMap<>();
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
public LoggingCache(String id) {
this.id = id;
System.out.println("[自定义缓存] 创建缓存实例,ID=" + id);
}
@Override
public String getId() {
return id;
}
@Override
public void putObject(Object key, Object value) {
System.out.println("[自定义缓存] PUT key=" + key.hashCode() + ", value类型=" + value.getClass().getSimpleName());
cache.put(key, value);
}
@Override
public Object getObject(Object key) {
Object value = cache.get(key);
if (value != null) {
System.out.println("[自定义缓存] HIT key=" + key.hashCode());
} else {
System.out.println("[自定义缓存] MISS key=" + key.hashCode());
}
return value;
}
@Override
public Object removeObject(Object key) {
System.out.println("[自定义缓存] REMOVE key=" + key.hashCode());
return cache.remove(key);
}
@Override
public void clear() {
System.out.println("[自定义缓存] CLEAR 清空全部缓存,当前size=" + cache.size());
cache.clear();
}
@Override
public int getSize() {
return cache.size();
}
@Override
public ReadWriteLock getReadWriteLock() {
return readWriteLock;
}
}
StudentMapper.xml
<?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.fly.mapper.StudentMapper">
<!-- 使用自定义缓存类型 -->
<cache type="com.fly.cache.LoggingCache"/>
<select id="selectById" resultType="com.fly.entity.Student">
SELECT id, name, age, major, score FROM student WHERE id = #{id}
</select>
<update id="updateScoreById">
UPDATE student SET score = #{score} WHERE id = #{id}
</update>
</mapper>
CustomCacheDemo.java
package com.fly.demo;
import com.fly.entity.Student;
import com.fly.mapper.StudentMapper;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import java.io.InputStream;
public class CustomCacheDemo {
public static void main(String[] args) throws Exception {
String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
System.out.println("========== 第一次SqlSession 查询 ==========");
SqlSession session1 = sqlSessionFactory.openSession();
StudentMapper mapper1 = session1.getMapper(StudentMapper.class);
Student s1 = mapper1.selectById(4); // 查询黄俪
System.out.println("查询结果:" + s1.getName() + ",专业:" + s1.getMajor());
session1.commit();
session1.close();
System.out.println("\n========== 第二次SqlSession 查询(应命中缓存)==========");
SqlSession session2 = sqlSessionFactory.openSession();
StudentMapper mapper2 = session2.getMapper(StudentMapper.class);
Student s2 = mapper2.selectById(4); // 应命中二级缓存
System.out.println("查询结果:" + s2.getName() + ",专业:" + s2.getMajor());
session2.close();
System.out.println("\n========== 第三次SqlSession 更新(应清空缓存)==========");
SqlSession session3 = sqlSessionFactory.openSession();
StudentMapper mapper3 = session3.getMapper(StudentMapper.class);
mapper3.updateScoreById(4, 95.0); // 更新黄俪分数
session3.commit(); // commit触发缓存清空
session3.close();
System.out.println("\n========== 第四次SqlSession 再次查询 ==========");
SqlSession session4 = sqlSessionFactory.openSession();
StudentMapper mapper4 = session4.getMapper(StudentMapper.class);
Student s4 = mapper4.selectById(4); // 缓存已清空,重新查库
System.out.println("查询结果:" + s4.getName() + ",分数:" + s4.getScore());
session4.close();
}
}
示例2:集成Ehcache
pom.xml依赖
<dependency>
<groupId>org.mybatis.caches</groupId>
<artifactId>mybatis-ehcache</artifactId>
<version>1.2.1</version>
</dependency>
ehcache.xml
<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd"
updateCheck="false">
<diskStore path="java.io.tmpdir"/>
<defaultCache
maxElementsInMemory="1000"
eternal="false"
timeToIdleSeconds="300"
timeToLiveSeconds="600"
overflowToDisk="true"/>
<!-- 为学生数据单独配置缓存区 -->
<cache name="com.fly.mapper.StudentMapper"
maxElementsInMemory="500"
eternal="false"
timeToIdleSeconds="300"
timeToLiveSeconds="600"
overflowToDisk="true"/>
</ehcache>
StudentMapper.xml(Ehcache版)
<mapper namespace="com.fly.mapper.StudentMapper">
<!-- 集成Ehcache -->
<cache type="org.mybatis.caches.ehcache.EhcacheCache"/>
<select id="selectById" resultType="com.fly.entity.Student">
SELECT id, name, age, major, score FROM student WHERE id = #{id}
</select>
</mapper>
示例3:Redis集成思路
package com.fly.cache;
import org.apache.ibatis.cache.Cache;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import java.io.*;
/**
* Redis缓存实现思路(教学版,未完整实现连接池管理)
*/
public class RedisCache implements Cache {
private final String id;
private static JedisPool jedisPool;
static {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(100);
jedisPool = new JedisPool(config, "localhost", 6379, 2000, "password");
}
public RedisCache(String id) {
this.id = id;
}
@Override
public String getId() {
return id;
}
@Override
public void putObject(Object key, Object value) {
try (Jedis jedis = jedisPool.getResource()) {
// 使用序列化存储
byte[] keyBytes = serialize(key);
byte[] valueBytes = serialize(value);
// 设置过期时间600秒
jedis.setex(keyBytes, 600, valueBytes);
} catch (Exception e) {
throw new RuntimeException("Redis缓存写入失败", e);
}
}
@Override
public Object getObject(Object key) {
try (Jedis jedis = jedisPool.getResource()) {
byte[] valueBytes = jedis.get(serialize(key));
return valueBytes != null ? deserialize(valueBytes) : null;
} catch (Exception e) {
throw new RuntimeException("Redis缓存读取失败", e);
}
}
@Override
public Object removeObject(Object key) {
try (Jedis jedis = jedisPool.getResource()) {
byte[] keyBytes = serialize(key);
Object value = getObject(key);
jedis.del(keyBytes);
return value;
} catch (Exception e) {
throw new RuntimeException("Redis缓存删除失败", e);
}
}
@Override
public void clear() {
try (Jedis jedis = jedisPool.getResource()) {
// 按前缀删除该命名空间的所有缓存
// 实际生产应使用Redis Scan或Hash结构
jedis.del((id + ":*").getBytes());
} catch (Exception e) {
throw new RuntimeException("Redis缓存清空失败", e);
}
}
@Override
public int getSize() {
// Redis中统计该命名空间key数量
return 0; // 简化示意
}
private byte[] serialize(Object object) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(object);
oos.close();
return baos.toByteArray();
}
private Object deserialize(byte[] bytes) throws IOException, ClassNotFoundException {
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bais);
Object obj = ois.readObject();
ois.close();
return obj;
}
}
实际执行结果(自定义日志缓存版)
控制台输出:
[自定义缓存] 创建缓存实例,ID=com.fly.mapper.StudentMapper
========== 第一次SqlSession 查询 ==========
[自定义缓存] MISS key=-1423456789
[com.fly.mapper.StudentMapper.selectById] - ==> Preparing: SELECT id, name, age, major, score FROM student WHERE id = ?
[com.fly.mapper.StudentMapper.selectById] - ==> Parameters: 4(Integer)
[com.fly.mapper.StudentMapper.selectById] - <== Total: 1
[自定义缓存] PUT key=-1423456789, value类型=Student
查询结果:黄俪,专业:信息安全
========== 第二次SqlSession 查询(应命中缓存)==========
[自定义缓存] HIT key=-1423456789
查询结果:黄俪,专业:信息安全
========== 第三次SqlSession 更新(应清空缓存)==========
[com.fly.mapper.StudentMapper.updateScoreById] - ==> Preparing: UPDATE student SET score = ? WHERE id = ?
[com.fly.mapper.StudentMapper.updateScoreById] - ==> Parameters: 95.0(Double), 4(Integer)
[com.fly.mapper.StudentMapper.updateScoreById] - <== Updates: 1
[自定义缓存] CLEAR 清空全部缓存,当前size=1
========== 第四次SqlSession 再次查询 ==========
[自定义缓存] MISS key=-1423456789
[com.fly.mapper.StudentMapper.selectById] - ==> Preparing: SELECT id, name, age, major, score FROM student WHERE id = ?
[com.fly.mapper.StudentMapper.selectById] - ==> Parameters: 4(Integer)
[com.fly.mapper.StudentMapper.selectById] - <== Total: 1
[自定义缓存] PUT key=-1423456789, value类型=Student
查询结果:黄俪,分数:95.0
分析
- 缓存创建:
LoggingCache实例化时打印日志,ID为Mapper命名空间 - 第一次查询:缓存未命中(MISS),查库后PUT写入缓存,value类型为Student
- 第二次查询:缓存命中(HIT),无SQL输出,直接从自定义缓存获取黄俪的信息
- 更新操作:
commit()触发clear(),打印CLEAR日志,当前缓存size=1被清空 - 第四次查询:缓存再次MISS,重新查库,获取更新后的分数95.0
自定义缓存让开发者完全掌控缓存的存储介质和行为,是接入外部中间件的必经之路。
易错场景与常见误区
| 误区 | 正解 |
|---|---|
| 自定义Cache不需要处理线程安全 | MyBatis外层有SynchronizedCache包装,但自定义实现内部若用HashMap等结构,仍需考虑并发;建议返回ReentrantReadWriteLock |
自定义缓存可以忽略getId() | getId()必须返回Mapper命名空间,MyBatis靠它识别缓存归属,不可随意返回 |
| Redis缓存不需要序列化 | 自定义缓存接收的是Java对象,存入Redis必须序列化;且实体类需实现Serializable |
clear()只需清空内存 | 接入外部中间件时,clear()必须同步清空外部存储,否则造成数据不一致 |
| 自定义缓存比内置缓存更快 | 网络型缓存(Redis)有IO开销,单次查询可能比内存缓存慢;优势在分布式共享和大容量 |
反例:未实现getReadWriteLock导致并发问题
// 反例:不实现getReadWriteLock,返回null
public class BadCache implements Cache {
// ... 其他方法省略 ...
@Override
public ReadWriteLock getReadWriteLock() {
return null; // 错误!MyBatis外层SynchronizedCache依赖此锁
}
}
正解:始终返回有效的
ReadWriteLock实例,如new ReentrantReadWriteLock()。
面试考点
Q1:实现MyBatis自定义缓存需要实现哪些方法?
必须实现
Cache接口的6个方法:getId()、putObject()、getObject()、removeObject()、clear()、getSize()。此外建议实现getReadWriteLock()返回有效锁对象。
Q2:MyBatis如何集成Ehcache?
引入
mybatis-ehcache依赖,在Mapper中配置<cache type="org.mybatis.caches.ehcache.EhcacheCache"/>,并编写ehcache.xml定义缓存策略。Ehcache支持内存+磁盘两级存储,以及丰富的过期策略。
Q3:自定义Redis缓存需要注意哪些问题?
四方面:① 序列化(Java对象需转为字节数组存储);② Key设计(需包含命名空间避免冲突);③ 过期策略(利用Redis TTL替代flushInterval);④ 一致性(MyBatis的clear()需同步删除Redis中对应数据)。
Q4:MyBatis缓存装饰器有哪些?执行顺序是什么?
从外到内依次是:
SynchronizedCache(线程同步)→LoggingCache(命中率日志)→SerializedCache(序列化,readOnly=false时)→LruCache/FifoCache/SoftCache/WeakCache(淘汰策略)→ 自定义Cache实现。数据流从外向内到达存储层。
小结
自定义缓存是MyBatis二级缓存的"最后一公里",通过实现Cache接口,开发者可以将缓存层无缝对接到Ehcache、Redis、Caffeine等任意存储中间件。核心要点是:遵守接口契约、处理好序列化、保证clear()的一致性、合理设计Key与过期策略。自定义缓存让MyBatis从单机ORM框架进化为分布式架构中的标准数据访问层。
下一章引子
缓存是提升读性能的核心手段,但写操作的性能优化同样重要。MyBatis提供了三种Executor执行器类型——SIMPLE、REUSE、BATCH,分别对应不同的Statement创建与执行策略。下一节将深入讲解三种执行器的工作流程,并通过批量插入1000条记录的实测对比,揭示BATCH模式在写密集型场景下的巨大优势。