本地缓存的进阶之路:从“脑子一热”到“生产级硬核”

19 阅读5分钟

本地缓存的进阶之路:从“脑子一热”到“生产级硬核”

写在前面的废话:各位Javaer大家好,今天咱们聊聊本地缓存

很多人对缓存的态度是:“哦,用个 HashMap存一下就行了嘛,这么简单还要讲?”

兄弟,如果你在生产环境敢这么干,那恭喜你,离成为“背锅侠”不远了。


第一阶段:青铜时代 —— 你的 HashMap正在制造内存泄漏

刚入门时,我们的代码通常是这样的:

public class SimpleCache {
    private static final Map<String, Object> CACHE = new HashMap<>();
    
    public static void put(String key, Object value) {
        CACHE.put(key, value);
    }
    
    public static Object get(String key) {
        return CACHE.get(key);
    }
}

吐槽时刻​ :

这就像是你家买了个垃圾桶,只往里扔垃圾,从来不扔出去。这是“内存泄漏”的温床! ​ 随着业务运行,Map会无限膨胀,直到 OutOfMemoryError把你叫醒:“醒醒,半夜了,起来扩容JVM!”

痛点总结:

  1. 无大小限制:迟早撑爆内存。
  2. 无淘汰策略:数据只会进不会出。
  3. 线程不安全:多线程环境下,你的数据可能会变成“薛定谔的数据”。

第二阶段:白银时代 —— ConcurrentHashMap + 过期时间

稍微进阶一点,我们会加上过期时间:

public class ExpireCache {
    private static final Map<String, CacheObject> CACHE = new ConcurrentHashMap<>();
    
    static class CacheObject {
        private Object value;
        private long expireTime;
        // ... constructor
    }
    
    public Object get(String key) {
        CacheObject obj = CACHE.get(key);
        if (obj == null || obj.expireTime < System.currentTimeMillis()) {
            CACHE.remove(key); // 惰性删除
            return null;
        }
        return obj.value;
    }
}

点评​ :

进步了!用了 ConcurrentHashMap,线程安全了。加了时间戳,数据会“过期”了。

但是,坑还在

  1. 清理不及时:如果没人调用 get(),那些过期数据就会像僵尸一样躺在内存里吃资源。
  2. 定时任务风险:如果你开个线程定时扫描清理,扫得太快会 CPU 100%,扫得太慢内存扛不住。

第三阶段:黄金时代 —— Guava Cache (经典永流传)

当你开始用 Guava Cache,说明你已经脱离了“萌新”行列。

LoadingCache<String, User> userCache = CacheBuilder.newBuilder()
    .maximumSize(1000) // 最多存1000个
    .expireAfterWrite(10, TimeUnit.MINUTES) // 写后10分钟过期
    .recordStats() // 开启统计
    .build(new CacheLoader<String, User>() {
        @Override
        public User load(String userId) {
            // 这里查数据库
            return getUserFromDB(userId);
        }
    });

为什么推荐它?

  • LRU/LFU:自带淘汰算法,内存不够会自动踢掉“最没用”的数据。
  • 线程安全:底层封装得很好。
  • 自动加载:没有值的时候自动调用 load方法加载。

生产小Tips​ 💡:

一定要加上 .recordStats()!上线后通过 cache.stats()看看命中率(Hit Rate)。如果命中率低于 50%,那你加缓存干嘛?给自己增加心理负担吗?


第四阶段:钻石时代 —— Caffeine (性能怪兽)

现在的业界标杆是 Caffeine。它是 Guava Cache 的进化版,基于 Window TinyLFU​ 算法。

为什么说它是王者?

简单来说,Guava 的 LRU 可能会把刚进来还没来得及热起来的数据踢掉。Caffeine 更聪明,它能分辨“真热点”和“临时客”。

Cache<String, Object> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(5, TimeUnit.MINUTES)
    // 异步刷新,防止缓存击穿
    .refreshAfterWrite(1, TimeUnit.MINUTES) 
    .build();

// 如果是LoadingCache
CaffeineLoadingCache<String, User> loadingCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .build(key -> loadFromDB(key));

落地方案​ :

在 Spring Boot 项目中,直接用 @Cacheable配合 Caffeine,简直丝滑。


第五阶段:生产级进阶优化方案 (避坑指南)

光会用库不行,还得懂套路。下面是为你整理的生产避坑指南

1. 防止缓存击穿 (Cache Breakdown)

现象:某个热点 Key 突然过期,瞬间几万请求直接打到 DB。

解法互斥锁 (Mutex Lock)

// 伪代码
value = cache.get(key);
if (value == null) {
    if (lock.tryLock()) { // 拿到锁的人才去查DB
        try {
            value = db.load();
            cache.put(key, value);
        } finally {
            lock.unlock();
        }
    } else {
        Thread.sleep(100); // 没拿到锁的睡一会儿再试
        return get(key);
    }
}

Caffeine 自带 refreshAfterWrite,它会后台异步刷新,不会阻塞读取,强烈推荐!

2. 防止缓存穿透 (Cache Penetration)

现象:黑客疯狂请求一个不存在的 Key(比如 -1),缓存没有,每次都打 DB。

解法布隆过滤器 (Bloom Filter) ​ 或 空值缓存

  • 布隆过滤器:在缓存之前加一层滤网,说“这个ID肯定没有”,直接拦截。
  • 空值缓存:查不到 DB,也在缓存里存个 null,设置短过期时间(如 30秒)。

3. 缓存雪崩 (Cache Avalanche)

现象:一大批 Key 在同一秒失效,或者 Redis/Cache 服务挂了。

解法过期时间加随机值

别让所有 Key 都是 expireAfterWrite(60s),改成 60 + Random.nextInt(30)秒。

4. 监控与告警 (Monitoring)

别以为上线就完事了。

  • 命中率:低于 80% 就要报警。
  • 平均加载时间:如果查 DB 变慢,缓存层要感知。
  • JVM 堆内存:本地缓存是吃堆内存的大户,盯着点老年代。

终极总结:怎么选?

场景推荐方案理由
单体应用/小流量ConcurrentHashMap(慎用)简单粗暴,但要有自知之明。
一般 Web 应用Guava Cache稳定,够用,生态好。
高并发/大流量Caffeine性能天花板,必须上。
分布式系统Redis + 本地缓存Redis 做中心存储,本地缓存做二级加速。

最后的鸡汤​ :

本地缓存虽好,可不要贪杯哦。

  1. 一致性问题:本地缓存在多机部署时,数据很难同步。修改数据后,其他机器的缓存还是旧的。解决办法:要么接受最终一致性,要么用消息队列通知清除,要么干脆别用本地缓存(只用 Redis)。
  2. 重启即失:服务器重启,本地缓存全清空。如果你的预热很慢,记得做好启动预热

好了,今天的“胡说八道”到此结束。如果你觉得有用,别忘了点赞收藏。