缓存策略优化
MyBatis 提供一级缓存和二级缓存机制。合理使用缓存可大幅减少数据库查询压力,但设计不当会引发缓存穿透、击穿、雪崩等问题。本文从缓存机制原理出发,给出生产环境可用的缓存设计方案。
MyBatis 缓存体系
| 缓存层级 | 作用范围 | 生命周期 | 默认开启 | 隔离性 |
|---|---|---|---|---|
| 一级缓存 | SqlSession 级别 | SqlSession 关闭或手动 clear | 是 | 线程安全(Session 私有) |
| 二级缓存 | Mapper/Namespace 级别 | 应用运行期间 | 否 | 需手动配置,多线程共享 |
一级缓存
工作原理
一级缓存是 SqlSession 级别的本地缓存,使用 PerpetualCache(基于 HashMap)实现:
// MyBatis 一级缓存源码
public class PerpetualCache implements Cache {
private final String id;
private Map<Object, Object> cache = new HashMap<>();
// ...
}
同一次 SqlSession 中,相同 SQL + 相同参数只执行一次查询:
SqlSession session = sqlSessionFactory.openSession();
UserMapper mapper = session.getMapper(UserMapper.class);
// 第一次查询:执行 SQL
User user1 = mapper.selectById(1);
// 第二次查询:命中一级缓存,不执行 SQL
User user2 = mapper.selectById(1);
// 执行 update/delete/insert 会清空一级缓存
mapper.updateUser(new User(1, "newName"));
// 第三次查询:一级缓存已清除,重新执行 SQL
User user3 = mapper.selectById(1);
注意:一级缓存在执行
commit()、clearCache()或 Session 关闭时自动清除。INSERT/UPDATE/DELETE 操作会自动清除当前 Session 的一级缓存。
一级缓存失效场景
| 场景 | 是否命中缓存 | 说明 |
|---|---|---|
| 同一 Session,相同 SQL + 参数 | 命中 | 基本场景 |
| 同一 Session,不同参数 | 不命中 | Cache Key 不同 |
| 不同 Session | 不命中 | 一级缓存是 Session 私有 |
| 同 Session 中执行 UPDATE | 不命中 | 自动清除一级缓存 |
手动调用 session.clearCache() | 不命中 | 强制清除 |
二级缓存
开启方式
二级缓存是 Mapper/Namespace 级别的共享缓存,需要显式开启:
1. 全局配置
<settings>
<setting name="cacheEnabled" value="true"/>
</settings>
注意:
cacheEnabled=true是二级缓存的总开关,默认已经是true。即使此处为true,仍需在 Mapper XML 中单独声明<cache/>才会生效。
2. Mapper XML 中声明
<mapper namespace="com.example.mapper.UserMapper">
<!-- 开启二级缓存 -->
<cache/>
<select id="selectById" resultType="User" useCache="true">
SELECT * FROM user WHERE id = #{id}
</select>
</mapper>
<cache/> 可配置属性:
<cache
eviction="LRU" <!-- 回收策略:LRU / FIFO / SOFT / WEAK -->
flushInterval="3600000" <!-- 刷新间隔(毫秒),1 小时 -->
size="512" <!-- 最大缓存对象数 -->
readOnly="true" <!-- 只读缓存(返回引用,非序列化副本) -->
/>
缓存回收策略对比
| 策略 | 说明 | 适用场景 |
|---|---|---|
| LRU | 最近最少使用,淘汰最久未使用的数据 | 通用场景,推荐 |
| FIFO | 先进先出,按进入顺序淘汰 | 数据更新频繁、时效性要求高 |
| SOFT | 软引用,GC 时自动回收 | 内存紧张,需自动降级 |
| WEAK | 弱引用,更积极的 GC 回收 | 极端内存限制场景 |
实体类需实现 Serializable
二级缓存在多节点或跨 Session 时需要序列化:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private Integer id;
private String username;
private String email;
// getters and setters
}
注意:若
readOnly=false,MyBatis 会通过序列化/反序列化返回副本,确保线程安全。若readOnly=true,直接返回缓存引用,效率更高但多线程共享对象可能被意外修改。
禁用单个 Statement 的缓存
<!-- 此查询不使用二级缓存 -->
<select id="selectRealTime" useCache="false">
SELECT * FROM user ORDER BY create_time DESC LIMIT 10
</select>
缓存三大问题与解决方案
缓存穿透
问题:查询不存在的数据(如 id = -1),缓存不命中,每次请求都打到数据库。
请求 id=-1 → 缓存不命中 → 查询数据库 → 返回 null → 下次请求又查询数据库
解决方案:缓存空值
public User getUserById(Integer id) {
// 1. 先查二级缓存
User user = (User) cache.get("user:" + id);
if (user != null) {
// 判断是否为空值标记
if ("NULL_OBJECT".equals(user.getUsername())) {
return null;
}
return user;
}
// 2. 查询数据库
user = userMapper.selectById(id);
if (user == null) {
// 3. 缓存空值,设置较短过期时间
User nullMarker = new User();
nullMarker.setUsername("NULL_OBJECT");
cache.put("user:" + id, nullMarker, 60); // 60秒过期
return null;
}
// 4. 缓存真实数据
cache.put("user:" + id, user, 3600);
return user;
}
解决方案:布隆过滤器
// 应用启动时加载所有已存在 ID 到布隆过滤器
BloomFilter<Integer> bloomFilter = BloomFilter.create(
Funnels.integerFunnel(),
expectedInsertions,
falsePositiveProbability
);
// 每次查询前先判断
public User getUserById(Integer id) {
if (!bloomFilter.mightContain(id)) {
// 一定不存在,直接返回,不查缓存也不查数据库
return null;
}
// 可能存在,继续查询流程
return queryFromCacheOrDb(id);
}
缓存击穿
问题:热点 key 在过期瞬间,大量并发请求同时到达数据库,导致数据库压力骤增。
时刻 T:热点 key 过期
→ 请求 1 发现缓存过期 → 查询数据库
→ 请求 2 发现缓存过期 → 查询数据库
→ 请求 3 发现缓存过期 → 查询数据库
→ ... 大量请求同时打到数据库
解决方案:互斥锁(分布式锁)
public User getUserWithMutex(Integer id) {
String cacheKey = "user:" + id;
String lockKey = "lock:user:" + id;
// 1. 尝试从缓存获取
User user = cache.get(cacheKey);
if (user != null) {
return user;
}
// 2. 缓存未命中,尝试获取锁
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 3. 获取到锁,查询数据库
user = userMapper.selectById(id);
if (user != null) {
cache.put(cacheKey, user, 3600);
}
} finally {
// 4. 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 5. 未获取到锁,短暂等待后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getUserWithMutex(id); // 递归重试
}
return user;
}
解决方案:逻辑过期(永不过期)
public class CacheWrapper<T> {
private T data;
private LocalDateTime expireTime; // 逻辑过期时间
private LocalDateTime refreshTime; // 上一次刷新时间
}
public User getUserWithLogicalExpire(Integer id) {
String cacheKey = "user:" + id;
CacheWrapper<User> wrapper = cache.get(cacheKey);
if (wrapper == null) {
// 缓存不存在,异步加载
loadToCacheAsync(id);
return null;
}
if (wrapper.getExpireTime().isBefore(LocalDateTime.now())) {
// 逻辑过期,异步刷新,当前请求仍返回旧值
refreshCacheAsync(id);
}
return wrapper.getData();
}
private void refreshCacheAsync(Integer id) {
executorService.submit(() -> {
String lockKey = "lock:refresh:user:" + id;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
User user = userMapper.selectById(id);
cache.put("user:" + id, new CacheWrapper<>(user, LocalDateTime.now().plusHours(1)));
} finally {
redisTemplate.delete(lockKey);
}
}
});
}
缓存雪崩
问题:大量缓存在同一时间过期,导致数据库瞬间承受巨大压力。
时刻 T:大量 key 同时过期(如设置了相同过期时间 1 小时)
→ 所有请求同时打到数据库 → 数据库连接耗尽 → 服务不可用
解决方案:过期时间加随机值
// 错误:统一过期时间
cache.put(key, value, 3600); // 所有 key 都在 1 小时后同时过期
// 正确:基础时间 + 随机偏移
int baseTime = 3600;
int randomOffset = ThreadLocalRandom.current().nextInt(0, 600); // 0 ~ 10 分钟随机
cache.put(key, value, baseTime + randomOffset);
解决方案:多级缓存
public User getUserWithMultiLevelCache(Integer id) {
String key = "user:" + id;
// 1. L1 缓存:本地 Caffeine 缓存(极快,但容量有限)
User user = localCache.get(key);
if (user != null) return user;
// 2. L2 缓存:Redis 分布式缓存
user = redisCache.get(key);
if (user != null) {
localCache.put(key, user); // 回填 L1
return user;
}
// 3. 数据库查询
user = userMapper.selectById(id);
if (user != null) {
int ttl = 3600 + ThreadLocalRandom.current().nextInt(0, 600);
redisCache.put(key, user, ttl);
localCache.put(key, user);
}
return user;
}
| 层级 | 实现 | 容量 | 延迟 | 适用 |
|---|---|---|---|---|
| L1 | Caffeine / Guava | 数百 MB | < 1ms | 热点数据,单机 |
| L2 | Redis / Memcached | 数 GB | 1 ~ 5ms | 共享数据,分布式 |
| DB | MySQL | 无上限 | 10 ~ 50ms | 全量数据 |
分布式缓存整合
Redis + MyBatis 二级缓存
public class RedisCache implements org.apache.ibatis.cache.Cache {
private final String id;
private static RedisTemplate<Object, Object> redisTemplate;
public RedisCache(String id) {
if (id == null) throw new IllegalArgumentException("Cache id must not be null");
this.id = id;
}
@Override
public String getId() {
return this.id;
}
@Override
public void putObject(Object key, Object value) {
getRedisTemplate().opsForHash().put(id.toString(), key.toString(), value);
}
@Override
public Object getObject(Object key) {
return getRedisTemplate().opsForHash().get(id.toString(), key.toString());
}
@Override
public Object removeObject(Object key) {
return getRedisTemplate().opsForHash().delete(id.toString(), key.toString());
}
@Override
public void clear() {
getRedisTemplate().delete(id.toString());
}
@Override
public int getSize() {
return getRedisTemplate().opsForHash().size(id.toString()).intValue();
}
private static RedisTemplate<Object, Object> getRedisTemplate() {
if (redisTemplate == null) {
redisTemplate = SpringContextUtil.getBean(RedisTemplate.class);
}
return redisTemplate;
}
}
Mapper XML 中使用自定义 Redis 缓存:
<mapper namespace="com.example.mapper.UserMapper">
<cache type="com.example.cache.RedisCache"
eviction="LRU"
flushInterval="3600000"
size="1024"
readOnly="false"/>
</mapper>
缓存一致性
更新时清除缓存
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Transactional
public void updateUser(User user) {
// 1. 先更新数据库
userMapper.update(user);
// 2. 清除对应缓存(让下次查询重新加载)
// MyBatis 二级缓存会自动在 commit 时清除,也可手动处理
}
}
Cache-Aside 模式(推荐)
读取:先读缓存 → 不命中则读数据库 → 写入缓存
更新:先更新数据库 → 删除缓存
public void updateUserWithCacheAside(User user) {
// 1. 更新数据库
userMapper.update(user);
// 2. 删除缓存(而非更新缓存,避免并发写入导致不一致)
redisTemplate.delete("user:" + user.getId());
}
注意:为什么删除缓存而不是更新缓存?因为并发场景下,A 更新数据库后写缓存,B 同时更新数据库,若 B 先删缓存再 A 写缓存,最终缓存是旧值。直接删除缓存让下次查询重新加载更安全。
要点总结
- 一级缓存是 SqlSession 私有缓存,默认开启,commit/update 后自动清除
- 二级缓存是 Namespace 共享缓存,需显式开启,实体类须实现 Serializable
- 缓存穿透:缓存空值或布隆过滤器,避免不存在的数据反复查库
- 缓存击穿:互斥锁或逻辑过期,避免热点 key 过期时并发打库
- 缓存雪崩:过期时间加随机偏移或多级缓存,避免大量 key 同时过期
- 分布式场景推荐使用 Redis 自定义 Cache 实现,支持跨节点共享
- Cache-Aside 模式:更新数据库后删除缓存,而非更新缓存
- 二级缓存与 Spring 事务集成时注意 commit 时机,避免读到脏数据
📝 发现内容有误?点击此处直接编辑