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);
});
我的经验: 曾经有个项目,我们把工单的静态属性(如物料编码、BOM版本)用Caffeine缓存,QPS从200直接飙到2000。但注意,千万别缓存那些频繁更新的数据,比如工单状态——你想想看,产线报工后状态变了,缓存还是旧的,那数据就乱套了。

4.1.2 分布式缓存(Redis)

Redis 就不用多介绍了,MES集群部署的标配。它的核心优势是:所有节点共享同一份缓存数据。

适用场景:

  • 多节点部署,需要缓存数据全局一致
  • 缓存数据量较大(超过单机内存容量)
  • 需要缓存持久化或过期策略更灵活

选型对比表:

维度 Caffeine(本地) Redis(分布式)
访问速度 纳秒级(内存直接访问) 微秒级(网络IO开销)
数据一致性 节点间不一致 全局一致
容量上限 受限于JVM堆内存 可扩展至TB级
复杂度 低,零依赖 高,需维护Redis集群
典型场景 字典数据、静态配置 工单状态、库存数量
避坑指南: 我曾经见过一个团队,把所有数据都塞进Redis,结果网络抖动时整个系统都卡住了。我的建议是:能用本地缓存解决的,就别上Redis。本地缓存+Redis二级缓存,才是MES系统的黄金组合。

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;
}
我的经验: 布隆过滤器虽然好用,但有个缺点——它只能判断「一定不存在」,不能判断「一定存在」。也就是说,它可能会误判。所以对于MES这种对数据准确性要求极高的场景,我建议用缓存空对象+短过期时间,简单可靠。

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);
避坑指南: 我曾经遇到过一个案例,Redis集群因为网络分区导致主从切换,缓存全部失效。幸好我们做了本地缓存兜底,数据库才没被打垮。所以,永远不要依赖单一缓存层。

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); // 递归重试
    }
}
我的经验: 互斥锁虽然能解决问题,但会降低并发性能。对于MES场景,我更推荐「逻辑过期」方案——缓存永不过期,后台定时刷新。这样既保证了高并发,又避免了击穿问题。当然,代价是数据会有短暂的不一致,但MES里很多场景是可以接受的。

4.3 核心知识体系

下面这张图,是我总结的缓存策略核心逻辑。你可以把它当作一个决策树,遇到缓存问题时照着走一遍。

MES缓存策略决策树 缓存策略选择 缓存穿透:数据不存在 缓存雪崩:大量同时过期 解决方案 ① 缓存空对象(短过期) ② 布隆过滤器(前置过滤) 解决方案 ① 过期时间加随机值 ② 多级缓存(本地+Redis) 缓存击穿:热点key过期 解决方案 ① 互斥锁(只让一个线程查库) ② 逻辑过期(异步刷新)

4.4 总结一下

缓存设计没有银弹,关键是根据业务场景做取舍。我个人总结了三句话:

  • 本地缓存扛高频,Redis 保一致。 静态数据用 Caffeine,动态数据用 Redis。
  • 穿透用空对象,雪崩加随机,击穿上锁或逻辑过期。 这三个口诀记住了,面试和实战都能用上。
  • 永远要有兜底方案。 缓存挂了,系统不能挂。限流、降级、熔断,该上就上。

最后说一句:MES系统里,数据一致性比性能更重要。缓存可以快,但不能错。每次上线前,记得做缓存失效的压测,看看你的系统能不能扛住。

专注资料整理