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)适合共享、实时性要求高的数据,如待办任务、审批状态
- 缓存穿透、击穿、雪崩是必须提前预防的三大问题,别等出事了再补