全部学科
Python全栈
python
NodeJS全栈
nodejs
小程序首页
📅 2026-05-20 8 分钟 ✍️ juanwangdev

缓存策略优化

MyBatis 提供一级缓存和二级缓存机制。合理使用缓存可大幅减少数据库查询压力,但设计不当会引发缓存穿透、击穿、雪崩等问题。本文从缓存机制原理出发,给出生产环境可用的缓存设计方案。

MyBatis 缓存体系

缓存层级作用范围生命周期默认开启隔离性
一级缓存SqlSession 级别SqlSession 关闭或手动 clear线程安全(Session 私有)
二级缓存Mapper/Namespace 级别应用运行期间需手动配置,多线程共享

一级缓存

工作原理

一级缓存是 SqlSession 级别的本地缓存,使用 PerpetualCache(基于 HashMap)实现:

Java
// MyBatis 一级缓存源码
public class PerpetualCache implements Cache {
    private final String id;
    private Map<Object, Object> cache = new HashMap<>();
    // ...
}

同一次 SqlSession 中,相同 SQL + 相同参数只执行一次查询:

Java
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. 全局配置

XML
<settings>
    <setting name="cacheEnabled" value="true"/>
</settings>

注意:cacheEnabled=true 是二级缓存的总开关,默认已经是 true。即使此处为 true,仍需在 Mapper XML 中单独声明 <cache/> 才会生效。

2. Mapper XML 中声明

XML
<mapper namespace="com.example.mapper.UserMapper">
    <!-- 开启二级缓存 -->
    <cache/>

    <select id="selectById" resultType="User" useCache="true">
        SELECT * FROM user WHERE id = #{id}
    </select>
</mapper>

<cache/> 可配置属性:

XML
<cache
    eviction="LRU"         <!-- 回收策略:LRU / FIFO / SOFT / WEAK -->
    flushInterval="3600000" <!-- 刷新间隔(毫秒),1 小时 -->
    size="512"             <!-- 最大缓存对象数 -->
    readOnly="true"        <!-- 只读缓存(返回引用,非序列化副本) -->
/>

缓存回收策略对比

策略说明适用场景
LRU最近最少使用,淘汰最久未使用的数据通用场景,推荐
FIFO先进先出,按进入顺序淘汰数据更新频繁、时效性要求高
SOFT软引用,GC 时自动回收内存紧张,需自动降级
WEAK弱引用,更积极的 GC 回收极端内存限制场景

实体类需实现 Serializable

二级缓存在多节点或跨 Session 时需要序列化:

Java
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 的缓存

XML
<!-- 此查询不使用二级缓存 -->
<select id="selectRealTime" useCache="false">
    SELECT * FROM user ORDER BY create_time DESC LIMIT 10
</select>

缓存三大问题与解决方案

缓存穿透

问题:查询不存在的数据(如 id = -1),缓存不命中,每次请求都打到数据库。

Java
请求 id=-1 → 缓存不命中 → 查询数据库 → 返回 null → 下次请求又查询数据库

解决方案:缓存空值

Java
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;
}

解决方案:布隆过滤器

Java
// 应用启动时加载所有已存在 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 在过期瞬间,大量并发请求同时到达数据库,导致数据库压力骤增。

Java
时刻 T:热点 key 过期
  → 请求 1 发现缓存过期 → 查询数据库
  → 请求 2 发现缓存过期 → 查询数据库
  → 请求 3 发现缓存过期 → 查询数据库
  → ... 大量请求同时打到数据库

解决方案:互斥锁(分布式锁)

Java
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;
}

解决方案:逻辑过期(永不过期)

Java
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);
            }
        }
    });
}

缓存雪崩

问题:大量缓存在同一时间过期,导致数据库瞬间承受巨大压力。

Java
时刻 T:大量 key 同时过期(如设置了相同过期时间 1 小时)
  → 所有请求同时打到数据库 → 数据库连接耗尽 → 服务不可用

解决方案:过期时间加随机值

XML
// 错误:统一过期时间
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);

解决方案:多级缓存

Java
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;
}
层级实现容量延迟适用
L1Caffeine / Guava数百 MB< 1ms热点数据,单机
L2Redis / Memcached数 GB1 ~ 5ms共享数据,分布式
DBMySQL无上限10 ~ 50ms全量数据

分布式缓存整合

Redis + MyBatis 二级缓存

Java
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 缓存:

text
<mapper namespace="com.example.mapper.UserMapper">
    <cache type="com.example.cache.RedisCache"
           eviction="LRU"
           flushInterval="3600000"
           size="1024"
           readOnly="false"/>
</mapper>

缓存一致性

更新时清除缓存

text
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    @Transactional
    public void updateUser(User user) {
        // 1. 先更新数据库
        userMapper.update(user);
        // 2. 清除对应缓存(让下次查询重新加载)
        // MyBatis 二级缓存会自动在 commit 时清除,也可手动处理
    }
}

Cache-Aside 模式(推荐)

text
读取:先读缓存 → 不命中则读数据库 → 写入缓存
更新:先更新数据库 → 删除缓存
text
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 时机,避免读到脏数据

📝 发现内容有误?点击此处直接编辑

← 上一篇 批量操作性能优化
下一篇 → 连接池调优
想查看更多题目和详细解析?
小程序提供完整的题库、模拟考试和详细解析
马上就来

长按或扫描二维码,立即体验

扫码体验小程序
马上就来
使用微信扫描二维码
立即体验完整题库