4、缓存架构设计:本地缓存(Caffeine)、分布式缓存(Redis)、缓存穿透/击穿/雪崩

说到审批流的数据访问优化,缓存是绕不开的一环。我个人习惯把缓存比作「数据的高速公路」——用得好,系统飞起;用不好,事故频发。这一章咱们就聊聊本地缓存、分布式缓存,以及那几个让人头疼的缓存问题。

4.1 本地缓存:Caffeine 实战

本地缓存,说白了就是把数据放在应用进程的内存里。优点是快,毫秒级响应;缺点是容量有限,且多实例部署时数据不一致。

我在项目中用过 Ehcache、Guava Cache,但最近两年基本都换成了 Caffeine。为什么?因为它的性能确实能打,而且 API 设计得很舒服。

Caffeine 的核心配置项

  • maximumSize:最大条目数,防止内存溢出
  • expireAfterWrite:写入后过期,适合读多写少场景
  • expireAfterAccess:访问后过期,适合热点数据
  • refreshAfterWrite:写入后自动刷新,避免缓存雪崩

举个例子,审批流中常用的「用户部门信息」就非常适合用 Caffeine 缓存。用户信息变动不频繁,但每次审批都要查,用本地缓存能省掉大量数据库查询。

// 构建一个用户部门信息的本地缓存
Cache<String, DeptInfo> deptCache = Caffeine.newBuilder()
    .maximumSize(10_000)          // 最多缓存 1 万个用户
    .expireAfterWrite(30, TimeUnit.MINUTES)  // 30 分钟自动过期
    .recordStats()                // 开启统计,方便监控
    .build();

// 查询时先走缓存
DeptInfo dept = deptCache.get(userId, key -> {
    // 缓存未命中时,从数据库加载
    return deptMapper.selectByUserId(key);
});

小技巧:Caffeine 的 recordStats() 可以统计命中率、加载耗时等指标。我习惯在监控面板上展示这些数据,一眼就能看出缓存是否有效。

4.2 分布式缓存:Redis 的正确打开方式

本地缓存搞不定多实例共享数据的问题。比如审批流中的「待办任务数量」,每个审批节点都要实时更新,这时候就得用分布式缓存。

Redis 是目前最主流的分布式缓存方案。但说实话,很多人用 Redis 就是简单的 get/set,根本没发挥出它的价值。

我记得有一次线上事故,就是因为 Redis 连接池配置不当,导致大量请求阻塞。后来我总结了一套 Redis 使用规范:

场景 推荐数据结构 注意事项
审批流状态 String + 过期时间 设置合理的 TTL,避免僵尸数据
待办任务列表 List 或 ZSet ZSet 适合按优先级排序
审批人权限 Set 利用 Set 的去重特性
计数器(如审批次数) String + INCR 原子操作,避免并发问题

另外,Redis 的序列化方式也很关键。我建议用 JSON 或 Protobuf,别用 JDK 自带的序列化——性能差,而且容易出兼容性问题。

// Redis 操作示例:缓存审批流状态
String key = "approval:status:" + approvalId;
// 设置 10 分钟过期
redisTemplate.opsForValue().set(key, status, 10, TimeUnit.MINUTES);

// 原子递增审批次数
redisTemplate.opsForValue().increment("approval:count:" + userId);

4.3 缓存穿透、击穿、雪崩

这三个问题,是缓存架构设计中的「三大坑」。我几乎在每个项目里都遇到过,而且每次踩坑都能学到新东西。

4.3.1 缓存穿透

缓存穿透,指的是查询一个根本不存在的数据。比如恶意攻击者故意请求一个不存在的审批单 ID,每次请求都会穿透缓存,直接打到数据库。

我曾经在一个审批流项目中,就因为没处理缓存穿透,导致数据库连接池被打满,整个系统挂了 20 分钟。

解决方案:

  • 缓存空对象:即使数据库查不到,也缓存一个空值,设置较短的过期时间(比如 5 分钟)
  • 布隆过滤器:在缓存层之前加一层布隆过滤器,快速判断数据是否存在
// 缓存空对象示例
String key = "approval:" + approvalId;
Object value = cache.get(key);
if (value == null) {
    value = db.query(approvalId);
    if (value == null) {
        // 缓存空对象,过期时间 5 分钟
        cache.set(key, "NULL", 5, TimeUnit.MINUTES);
    } else {
        cache.set(key, value, 30, TimeUnit.MINUTES);
    }
}

4.3.2 缓存击穿

缓存击穿,指的是一个热点 key 突然过期,大量并发请求同时打到数据库。比如审批流中的「全局配置」缓存,一旦过期,所有审批节点都会去查数据库。

我记得有一次大促活动,就是因为缓存击穿,导致数据库瞬间负载飙升到 90%。

解决方案:

  • 互斥锁:只允许一个线程去加载数据,其他线程等待
  • 逻辑过期:缓存永不过期,但数据里带一个过期时间字段,异步刷新
// 互斥锁示例(伪代码)
String key = "hot:config";
String lockKey = "lock:" + key;
if (cache.get(key) == null) {
    if (redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS)) {
        // 获取锁成功,加载数据
        Object data = db.query();
        cache.set(key, data, 30, TimeUnit.MINUTES);
        redis.del(lockKey);
    } else {
        // 获取锁失败,等待重试
        Thread.sleep(100);
        return cache.get(key);
    }
}

4.3.3 缓存雪崩

缓存雪崩,指的是大量缓存同时过期,或者 Redis 宕机,导致所有请求都打到数据库。这是最严重的情况,往往会导致系统崩溃。

我曾经在凌晨三点被电话叫醒,就是因为缓存雪崩导致数据库被打爆。那次之后,我养成了几个好习惯:

解决方案:

  • 过期时间加随机值:避免大量 key 同时过期
  • 多级缓存:本地缓存 + Redis 组合,即使 Redis 挂了,本地缓存还能扛一阵
  • 限流降级:当缓存失效时,对数据库查询进行限流
// 过期时间加随机值
int baseExpire = 30; // 基础过期时间 30 分钟
int randomExpire = baseExpire + new Random().nextInt(10); // 加 0-10 分钟随机值
cache.set(key, value, randomExpire, TimeUnit.MINUTES);

避坑指南:我曾经在项目中只用了 Redis 单机版,结果 Redis 宕机后整个系统瘫痪。后来我改成了 Redis 哨兵模式,并加了本地缓存作为兜底。记住:任何单点都是风险。

4.4 缓存架构设计总结

说了这么多,咱们来画一张图,把缓存架构的整体设计串起来。

缓存架构设计全景图 客户端请求 布隆过滤器 本地缓存(Caffeine) 分布式缓存(Redis) 数据库 三大缓存问题 • 穿透:查不存在的数据 • 击穿:热点 key 过期 • 雪崩:大量 key 同时过期 解决方案 • 空对象缓存 • 互斥锁 • 过期时间加随机值

从这张图可以看出,一个完整的缓存架构应该是多层的。客户端请求先经过布隆过滤器(可选),然后查本地缓存,再查分布式缓存,最后才落到数据库。每一层都承担着不同的职责,也都有对应的防护策略。

核心要点:

  • 本地缓存(Caffeine)适合高频、变化少的数据,如用户信息、配置项
  • 分布式缓存(Redis)适合共享、实时性要求高的数据,如待办任务、审批状态
  • 缓存穿透、击穿、雪崩是必须提前预防的三大问题,别等出事了再补