本地缓存
开篇:为什么有了 Redis 还需要本地缓存?
你在北京,数据在杭州的 Redis 服务器上。即使网络再快,一次网络往返(RTT)也要 1~2 毫秒。
但如果数据就在你的应用进程内存里呢?访问时间大约 0.01~0.1 毫秒——比 Redis 快了 10~100 倍。
这就是本地缓存的价值:把最热的数据放在离应用最近的地方,省掉网络开销,实现极致的读取速度。
当然,本地缓存不是来替代 Redis 的,而是和 Redis 配合,形成多级缓存架构:
本地缓存是 L1(一级缓存),Redis 是 L2(二级缓存),数据库是最终的数据源。越往前越快,但容量越小、一致性越难保证。
一、Guava Cache:老牌本地缓存
Guava 是 Google 出品的 Java 工具库,其中的 Cache 模块是 Java 领域最早流行的本地缓存方案之一。
基本用法
Cache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(1000) // 最多缓存 1000 个条目
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后 10 分钟过期
.expireAfterAccess(5, TimeUnit.MINUTES) // 5 分钟没被访问就过期
.build();
// 写入
cache.put("user:1001", "{name: '张三'}");
// 读取
String value = cache.getIfPresent("user:1001");
// 读取,未命中时自动加载
String value2 = cache.get("user:1001", () -> db.queryUser("1001"));淘汰策略
Guava Cache 支持三种淘汰方式:
| 方式 | 说明 |
|---|---|
| 基于容量 | maximumSize(n) —— 超过 n 个条目后淘汰最近最少使用的(LRU) |
| 基于时间 | expireAfterWrite / expireAfterAccess —— 写入或最后一次访问后超时过期 |
| 基于引用 | weakKeys() / softValues() —— 使用弱引用或软引用,让 GC 参与回收 |
自动刷新
Guava Cache 支持 refreshAfterWrite,在缓存过期前自动刷新数据。和 expireAfterWrite 的区别是:过期会让下一次访问变慢(要去加载),而刷新是在后台完成的,用户始终能拿到数据(可能是旧的)。
LoadingCache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(1000)
.refreshAfterWrite(5, TimeUnit.MINUTES) // 5 分钟后触发异步刷新
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return db.query(key); // 从数据库加载
}
});Guava Cache 的优点是简单可靠、与 Guava 生态融合好。缺点是性能不如后来的 Caffeine,而且不支持异步 Cache。
二、Caffeine:新一代本地缓存之王
Caffeine 是 Guava Cache 的"精神续作"——由同一批核心开发者打造,API 设计几乎一模一样,但在底层做了大量优化。Spring 5 默认支持的本地缓存就是 Caffeine。
基本用法
Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(10000) // 最多 10000 个条目
.expireAfterWrite(10, TimeUnit.SECONDS) // 写入后 10 秒过期
.expireAfterAccess(10, TimeUnit.SECONDS) // 10 秒没访问就过期
.build();
cache.put("key", "value");
String value = cache.getIfPresent("key");
cache.invalidate("key"); // 手动删除Caffeine 为什么比 Guava 快?
核心差异在于淘汰算法。Guava 用的是经典的 LRU(最近最少使用),而 Caffeine 用的是 W-TinyLFU——一种融合了 LRU 和 LFU 优点的高级算法。
先回顾一下 LRU 和 LFU 的区别:
- LRU 只看"最后一次访问时间"——越久没被访问的越先淘汰。问题是:一个访问频率很高的 Key,如果最近几秒没被访问,就可能被错误淘汰。
- LFU 只看"访问次数"——访问次数最少的最先淘汰。问题是:需要给每个 Key 维护一个计数器,内存开销大;而且早期高频访问的数据会"赖在缓存里",即使后来不再热门。
W-TinyLFU:两全其美
W-TinyLFU 综合了两者的优点,它的结构分为三部分:
- 窗口缓存(Window Cache):用 LRU 策略,占总容量的约 1%。新数据先进入这里,有机会积累访问频率,避免刚进来就因为频率低被淘汰。
- 过滤器(TinyLFU):当数据从窗口缓存被挤出时,它的访问频率会和主缓存中最容易被淘汰的数据进行 PK。只有频率更高的才能进入主缓存。
- 主缓存(Main Cache):用 SLRU(分段 LRU)策略,占总容量的约 99%。这是真正存储热点数据的地方。
W-TinyLFU 用一种叫 Count-Min Sketch 的概率数据结构来统计访问频率,内存开销极小(每个 Key 只需要几个字节的计数空间)。
Caffeine vs Guava 的完整对比:
| 维度 | Guava Cache | Caffeine |
|---|---|---|
| 淘汰算法 | LRU | W-TinyLFU(命中率更高) |
| 异步支持 | 不支持 | 支持 AsyncCache |
| 即时过期 | 转成 maxSize=0,剔除原因显示为 SIZE | 正确识别为 EXPIRED |
| 替换通知 | 值被替换时总是触发 | 新旧引用相同时不触发 |
| 异步维护 | 同步 | 交给 ForkJoinPool 异步执行 |
| Spring 集成 | Spring 4 默认 | Spring 5 默认 |
结论:新项目直接用 Caffeine,没有理由再选 Guava Cache。
生产级 Caffeine 封装
下面是一个可以直接在项目中使用的本地缓存管理器:
@Component
public class LocalCacheManager implements InitializingBean {
private Cache<String, String> localCache;
public void putIfNotExist(String key, String value) {
if (localCache.getIfPresent(key) == null) {
localCache.put(key, value);
}
}
public String get(String key) {
return localCache.getIfPresent(key);
}
public void del(String key) {
localCache.invalidate(key);
}
@Override
public void afterPropertiesSet() {
localCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.expireAfterAccess(10, TimeUnit.SECONDS)
.maximumSize(1000)
.build();
}
}三、多级缓存架构
3.1 架构设计
多级缓存的核心思想很简单:先查近的,再查远的。
public String query(String key) {
// L1:本地缓存
String localResult = localCache.get(key);
if (localResult != null) return localResult;
// L2:Redis
String redisResult = redis.get(key);
if (redisResult != null) {
localCache.put(key, redisResult); // 回填 L1
return redisResult;
}
// L3:数据库
String dbResult = db.query(key);
if (dbResult != null) {
redis.setex(key, 3600, dbResult); // 回填 L2
localCache.put(key, dbResult); // 回填 L1
}
return dbResult;
}在一些特殊场景中,L1 还可以用布隆过滤器来充当。比如黑名单校验:本地布隆过滤器先过滤一遍,由于布隆过滤器存在"假阳性"(可能误判为存在),命中后再去 Redis 确认;没命中则直接放行。
3.2 一致性问题:多级缓存的最大挑战
本地缓存最大的痛点就是一致性。一个集群有多台服务器,每台服务器上都有自己的本地缓存,它们之间是完全独立的。当一台机器更新了本地缓存,其他机器完全不知道。
先说结论
如果有强一致性要求,就不要用本地缓存。 本地缓存本质上是用一致性换性能的方案(CAP 中选了 AP,放弃了 CP)。所有"解决一致性"的方案,追求的都是最终一致性,而不是强一致性。
3.3 一致性解决方案
方案一:自动过期 + 自动刷新(最常用)
最简单也最实用的方案——给本地缓存设一个较短的 TTL,到期自动失效,下次查询时从 Redis 重新加载。
// 自动失效:写入后 5 秒过期
Cache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.SECONDS)
.build();// 自动刷新:5 秒后触发异步刷新,不影响读取
LoadingCache<String, String> cache = Caffeine.newBuilder()
.refreshAfterWrite(5, TimeUnit.SECONDS)
.build(key -> redis.get(key)); // 刷新时从 Redis 加载适合对一致性要求不高的场景。业务上能接受多长时间的延迟,就设多长的过期时间。比如能接受 10 分钟的不一致,就设 8 分钟的 TTL。
方案二:先更新 Redis,再广播删除本地缓存
当数据发生变更时:
- 先更新 Redis 缓存。
- 删除本机的本地缓存。
- 通过某种广播机制通知集群中所有其他机器删除它们的本地缓存。
广播机制的实现方式:
- MQ 广播消息:发送一条广播消息到消息队列,所有实例订阅同一个 Topic,收到消息后各自删除本地缓存。
- 配置中心:修改配置中心的某个 Key,所有实例监听配置变化后更新本地缓存。
方案三:Canal + MQ 异步失效(大厂方案)
这个方案和 Redis-数据库一致性方案中的 Canal 方案如出一辙:
- 应用只管写数据库。
- Canal 监听数据库 binlog 变更。
- 变更信息发送到 MQ。
- 所有应用实例消费 MQ 消息,同时删除 Redis 缓存和自己的本地缓存。
这个方案的好处是对业务代码零侵入,而且天然借助了 MQ 的广播能力解决多实例一致性问题。缺点是必须依赖数据库(如果是纯缓存-缓存架构就不适用了)。
方案四:Redis Pub/Sub 事件通知(轻量方案)
利用 Redis 的 Keyspace Notifications 功能,当 Redis 中的 Key 被修改、删除或过期时,Redis 会发布事件通知,应用订阅这些事件后清理对应的本地缓存。
// 开启 Redis 的 Keyspace 通知
// config set notify-keyspace-events KEA
// 订阅 Key 的变更事件
jedis.psubscribe(new JedisPubSub() {
@Override
public void onPMessage(String pattern, String channel, String message) {
if ("del".equals(message) || "expired".equals(message)) {
String key = channel.replace("__keyspace@0__:", "");
localCache.invalidate(key); // 清除本地缓存
}
}
}, "__keyspace@0__:product:*");优点是不需要额外引入 MQ,直接用 Redis 就能做。缺点是 Redis 的 Pub/Sub 是"即发即弃"的,客户端掉线期间会漏消息,可靠性不如 MQ。
3.4 实际工作中的最佳实践
说了这么多方案,实际工作中怎么选?
- 评估数据变化频率:只有变化不频繁的数据才适合放本地缓存。频繁更新的数据(如库存)不适合——你见过哪个公司的秒杀系统用本地缓存扣库存的?
- 评估业务容忍度:能接受多久的不一致?能接受 10 分钟就设 8 分钟的 TTL。完全不能接受不一致?那就别用本地缓存。
- 优先用自动过期:大多数场景下,合理的
expireAfterWrite+refreshAfterWrite就够了,简单可靠。 - 需要更强一致性时才上广播:只有在"秒级一致性"要求下才考虑 MQ 广播或 Canal 方案。
四、常见面试题精选
Q1:本地缓存和分布式缓存有什么区别?
本地缓存存在应用进程的 JVM 内存中,访问速度极快(0.01~0.1ms),但只能被当前进程使用,不能跨实例共享,集群环境下会有数据不一致问题。
分布式缓存(如 Redis)部署在独立的服务器上,所有应用实例共享同一份数据,天然一致,但访问需要网络通信(1~2ms),速度比本地缓存慢一个量级。
总结:本地缓存更快但不一致,分布式缓存更一致但更慢。两者配合使用是最佳实践。
Q2:Caffeine 和 Guava Cache 怎么选?
新项目直接选 Caffeine。两者 API 几乎一样(迁移成本极低),但 Caffeine 在淘汰算法(W-TinyLFU vs LRU)、异步支持、性能等方面全面领先。Caffeine 也是 Spring 5 的默认本地缓存实现。
Q3:如何保证多级缓存的一致性?
核心原则是放弃强一致性,追求最终一致性。常见方案有三种:
- 自动过期 + 刷新:设置较短的 TTL,到期自动失效或刷新。最简单,适合大部分场景。
- MQ 广播:数据变更时通过 MQ 广播通知所有实例删除本地缓存。更及时但更复杂。
- Canal + MQ:通过监听数据库 binlog 异步清理缓存。对业务代码零侵入,适合大厂场景。
如果真的需要强一致性——那就不要用本地缓存,直接 Redis + 数据库就好。
小结
| 维度 | Guava Cache | Caffeine | Redis |
|---|---|---|---|
| 存储位置 | JVM 堆内存 | JVM 堆内存 | 独立服务器 |
| 访问速度 | ~0.1ms | ~0.01ms | ~1ms |
| 容量 | 受 JVM 限制 | 受 JVM 限制 | 受服务器内存限制 |
| 跨实例共享 | 不支持 | 不支持 | 支持 |
| 淘汰算法 | LRU | W-TinyLFU | LRU / LFU |
| 推荐度 | 老项目维护 | 新项目首选 | 分布式缓存首选 |
多级缓存架构的设计原则:近水楼台先得月。把最热的数据放在最近的地方(本地缓存),次热的放在稍远的地方(Redis),最冷的留在数据库。但永远记住:本地缓存是一种用一致性换性能的 trade-off,用之前一定要评估业务能否接受不一致。