4. 缓存策略设计与实现:本地缓存(Caffeine)与分布式缓存(Redis)的选型对比,缓存穿透、缓存雪崩、缓存击穿的解决方案
聊到MES系统的性能优化,缓存绝对是绕不开的一环。我个人习惯把缓存比作系统的「肾上腺素」——用好了,系统响应飞快;用不好,反而会引发连锁故障。今天我们就来拆解一下,在MES这种高并发、高数据一致性的场景下,缓存到底该怎么玩。
4.1 本地缓存 vs 分布式缓存:选型背后的逻辑
先问大家一个问题:你的MES系统是单机部署还是集群部署?这个问题的答案,直接决定了你该选本地缓存还是分布式缓存。
4.1.1 本地缓存(Caffeine)
Caffeine 是目前 Java 生态里性能最强的本地缓存库。我做过压测,它的读写吞吐量比 Guava Cache 高出 30% 以上。
适用场景:
- 数据量不大,但访问极其频繁(比如工艺路线、设备参数)
- 对数据一致性要求不高,允许短时间的不一致
- 单机部署,或者每个节点独立缓存
代码示例:
// 构建一个Caffeine缓存,最大容量10000,过期时间5分钟
Cache<String, WorkOrder> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats() // 记录命中率,方便监控
.build();
// 获取数据,如果缓存中没有则从数据库加载
WorkOrder order = cache.get("WO-2024-001", key -> {
return workOrderMapper.selectByPrimaryKey(key);
});
4.1.2 分布式缓存(Redis)
Redis 就不用多介绍了,MES集群部署的标配。它的核心优势是:所有节点共享同一份缓存数据。
适用场景:
- 多节点部署,需要缓存数据全局一致
- 缓存数据量较大(超过单机内存容量)
- 需要缓存持久化或过期策略更灵活
选型对比表:
| 维度 | Caffeine(本地) | Redis(分布式) |
|---|---|---|
| 访问速度 | 纳秒级(内存直接访问) | 微秒级(网络IO开销) |
| 数据一致性 | 节点间不一致 | 全局一致 |
| 容量上限 | 受限于JVM堆内存 | 可扩展至TB级 |
| 复杂度 | 低,零依赖 | 高,需维护Redis集群 |
| 典型场景 | 字典数据、静态配置 | 工单状态、库存数量 |
4.2 缓存三大难题:穿透、雪崩、击穿
这三个问题,说白了就是缓存失效时引发的连锁反应。我一个个来讲,顺便说说我在项目中是怎么解决的。
4.2.1 缓存穿透
现象: 请求的数据在缓存和数据库中都不存在。每次请求都直接打到数据库,缓存形同虚设。
举个例子: 黑客故意请求一个不存在的工单号「WO-999999」,缓存里没有,数据库里也没有,每次都要查库。如果并发量一大,数据库直接挂掉。
解决方案:
- 缓存空对象: 即使数据库查不到,也缓存一个空值(比如 null),并设置较短的过期时间(30秒)。
- 布隆过滤器: 在缓存前面加一层布隆过滤器,判断 key 是否存在。不存在直接返回,不用查缓存和数据库。
// 缓存空对象示例
public WorkOrder getWorkOrder(String woId) {
// 先从缓存获取
WorkOrder order = cache.get(woId);
if (order != null) {
return order;
}
// 缓存中没有,查数据库
order = workOrderMapper.selectByPrimaryKey(woId);
if (order == null) {
// 缓存空对象,过期时间30秒
cache.put(woId, new WorkOrder(), 30, TimeUnit.SECONDS);
return null;
}
// 正常数据缓存5分钟
cache.put(woId, order, 5, TimeUnit.MINUTES);
return order;
}
4.2.2 缓存雪崩
现象: 大量缓存同时过期,或者Redis宕机,导致所有请求瞬间打到数据库。
为什么会这样? 很多新手喜欢给所有缓存设置相同的过期时间,比如统一5分钟。结果5分钟一到,所有缓存同时失效,数据库瞬间被压垮。
解决方案:
- 过期时间加随机值: 比如基础过期时间5分钟,再随机加1-3分钟,避免集体过期。
- 多级缓存: 本地缓存+Redis,即使Redis挂了,本地缓存还能扛一阵。
- 限流降级: 当数据库压力过大时,直接返回旧缓存数据或错误提示,别让数据库死掉。
// 过期时间加随机值
int baseExpire = 300; // 5分钟
int randomExpire = new Random().nextInt(120); // 随机0-2分钟
int totalExpire = baseExpire + randomExpire;
cache.put(key, value, totalExpire, TimeUnit.SECONDS);
4.2.3 缓存击穿
现象: 某个热点 key 突然过期,大量并发请求同时访问这个 key,全部打到数据库。
和雪崩的区别: 雪崩是大量 key 同时过期,击穿是单个热点 key 过期。比如双11的秒杀商品,或者MES里的热门工单。
解决方案:
- 互斥锁: 当缓存失效时,只让一个线程去查数据库,其他线程等待。查完后把结果写回缓存,其他线程直接读缓存。
- 逻辑过期: 缓存永不过期,但数据里带一个过期时间字段。后台异步线程检查并更新。
// 互斥锁实现(伪代码)
public WorkOrder getHotWorkOrder(String woId) {
WorkOrder order = cache.get(woId);
if (order != null) {
return order;
}
// 尝试获取分布式锁
String lockKey = "lock:wo:" + woId;
boolean locked = redisLock.tryLock(lockKey, 3, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查,防止其他线程已经更新了缓存
order = cache.get(woId);
if (order != null) {
return order;
}
// 查数据库
order = workOrderMapper.selectByPrimaryKey(woId);
cache.put(woId, order, 5, TimeUnit.MINUTES);
return order;
} finally {
redisLock.unlock(lockKey);
}
} else {
// 没拿到锁,等待一段时间后重试
Thread.sleep(100);
return getHotWorkOrder(woId); // 递归重试
}
}
4.3 核心知识体系
下面这张图,是我总结的缓存策略核心逻辑。你可以把它当作一个决策树,遇到缓存问题时照着走一遍。
4.4 总结一下
缓存设计没有银弹,关键是根据业务场景做取舍。我个人总结了三句话:
- 本地缓存扛高频,Redis 保一致。 静态数据用 Caffeine,动态数据用 Redis。
- 穿透用空对象,雪崩加随机,击穿上锁或逻辑过期。 这三个口诀记住了,面试和实战都能用上。
- 永远要有兜底方案。 缓存挂了,系统不能挂。限流、降级、熔断,该上就上。
最后说一句:MES系统里,数据一致性比性能更重要。缓存可以快,但不能错。每次上线前,记得做缓存失效的压测,看看你的系统能不能扛住。