Spring Boot 中基于 Redis 与 Caffeine 的多级缓存实战:一致性、穿透防护与性能优化
在很多业务系统里,缓存不是“要不要上”的问题,而是“怎么上了以后不出事”的问题。
我自己在做商品、配置、用户画像这类读多写少的场景时,最常见的组合就是:
- 本地缓存:Caffeine,快,进程内命中基本就是纳秒到微秒级
- 分布式缓存:Redis,多实例共享,容量更大,也便于失效控制
这套多级缓存方案确实能把数据库压力打下来,但它的难点从来不是“接上 Redis”这么简单,而是下面这些现实问题:
- 本地缓存和 Redis 怎么保持一致
- 热点 key 被打爆时怎么防止 缓存击穿
- key 不存在时怎么避免 缓存穿透
- Redis 挂了或者网络抖动时,系统能不能 优雅降级
- 多级缓存叠起来后,到底怎么排查“缓存明明删了,怎么还是旧数据”
这篇文章不讲太虚的概念,我会带你从一个可运行的 Spring Boot 示例出发,搭一套 Caffeine + Redis + 数据库回源 的多级缓存,并把一致性、穿透防护、性能优化和排查手段一起讲清楚。
一、背景与问题
先看一个典型查询链路:
- 请求查询商品详情
product:1001 - 先查本地 Caffeine
- 本地没命中,再查 Redis
- Redis 也没命中,再查数据库
- 查到结果后,回填 Redis 和 Caffeine
这是最常见的多级缓存模型。
它的优势很明显:
- Caffeine 解决单机超低延迟访问
- Redis 解决多实例共享缓存
- 数据库 只在缓存都没命中时兜底
但真正落地时,马上会遇到几个坑。
1. 本地缓存天然不共享
服务部署了 4 个实例,A 实例更新了一个 key,只删了自己进程里的 Caffeine,B/C/D 机器上的本地缓存还是旧的。
2. Redis 与本地缓存失效顺序不当
如果你先删 Redis,再删本地缓存,在高并发下很容易让旧值重新回填。
3. 空值查询打穿数据库
比如恶意请求不断查询不存在的 productId,Redis 没有,Caffeine 没有,数据库每次都查一次,这就是典型的缓存穿透。
4. 热点数据过期瞬间回源洪峰
同一个热点 key 到期,大量请求一起查数据库,这就是缓存击穿。
所以,多级缓存不是“加一层缓存”这么简单,而是一个读写链路设计问题。
二、前置知识与环境准备
本文示例环境:
- JDK 17
- Spring Boot 3.x
- Spring Data Redis
- Caffeine
- Maven
- Redis 6+
Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
application.yml
spring:
data:
redis:
host: 127.0.0.1
port: 6379
timeout: 2000ms
server:
port: 8080
三、核心原理
先把整体结构理顺,不然后面的代码容易“会写但不理解”。
1. 多级缓存的读路径
flowchart TD
A[请求查询] --> B{Caffeine命中?}
B -- 是 --> C[返回结果]
B -- 否 --> D{Redis命中?}
D -- 是 --> E[回填Caffeine]
E --> C
D -- 否 --> F{是否加锁回源?}
F -- 是 --> G[查询数据库]
G --> H[写入Redis]
H --> I[写入Caffeine]
I --> C
F -- 否 --> J[短暂等待/重试]
J --> B
这条链路的关键点有两个:
- 先本地,后 Redis,最后数据库
- 数据库回源时尽量加锁或合并请求
2. 多级缓存的写路径
写操作和读操作的难点不同。读操作重在命中率,写操作重在一致性。
比较稳妥的思路通常是:
- 先更新数据库
- 再删除 Redis
- 再删除本地 Caffeine
- 通过 Redis Pub/Sub 或消息队列通知其他节点删除本地缓存
因为数据库才是最终事实来源,缓存只是副本。
3. 为什么不是“更新缓存”而是“删除缓存”
这个问题很经典,我建议记住一句话:
读多写少场景里,更新缓存比删除缓存更容易错。
原因在于:
- 删除操作天然幂等
- 更新缓存要考虑序列化、部分字段更新、并发覆盖
- 删除后让后续读请求回源重建,虽然多一次查询,但整体更稳
4. 多级缓存一致性方案
在中级项目里,比较常用的是“延迟双删 + 本地缓存通知失效”或“更新数据库后删除缓存”。
本文示例采用:
- 更新数据库
- 删除 Redis key
- 删除当前节点 Caffeine
- 发布 Redis channel 消息,通知其他节点删除本地缓存
这是工程上相对简单且够用的一种方案。
四、整体架构图
sequenceDiagram
participant Client as 客户端
participant App1 as 应用实例A
participant App2 as 应用实例B
participant Caf as Caffeine
participant Redis as Redis
participant DB as MySQL
Client->>App1: 查询商品
App1->>Caf: 查询本地缓存
alt 本地命中
Caf-->>App1: 返回数据
App1-->>Client: 返回结果
else 本地未命中
App1->>Redis: 查询分布式缓存
alt Redis命中
Redis-->>App1: 返回数据
App1->>Caf: 回填本地缓存
App1-->>Client: 返回结果
else Redis未命中
App1->>DB: 查询数据库
DB-->>App1: 返回数据
App1->>Redis: 写入缓存
App1->>Caf: 写入本地缓存
App1-->>Client: 返回结果
end
end
Client->>App2: 更新商品
App2->>DB: 更新数据库
App2->>Redis: 删除商品缓存
App2->>App2: 删除本地缓存
App2->>Redis: 发布失效消息
Redis-->>App1: 通知删除本地缓存
五、实战代码(可运行)
下面我们做一个简化版的商品缓存服务。
为了让示例聚焦缓存逻辑,数据库层先用内存 Map 模拟。实际项目替换成 JPA、MyBatis 都可以。
5.1 商品实体与仓储
Product.java
package com.example.cachedemo.model;
import java.io.Serializable;
import java.math.BigDecimal;
public class Product implements Serializable {
private Long id;
private String name;
private BigDecimal price;
private Long version;
public Product() {
}
public Product(Long id, String name, BigDecimal price, Long version) {
this.id = id;
this.name = name;
this.price = price;
this.version = version;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public BigDecimal getPrice() {
return price;
}
public Long getVersion() {
return version;
}
public void setId(Long id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public void setVersion(Long version) {
this.version = version;
}
}
ProductRepository.java
package com.example.cachedemo.repository;
import com.example.cachedemo.model.Product;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class ProductRepository {
private final Map<Long, Product> db = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
db.put(1L, new Product(1L, "机械键盘", new BigDecimal("299.00"), 1L));
db.put(2L, new Product(2L, "显示器", new BigDecimal("1299.00"), 1L));
}
public Product findById(Long id) {
sleep(80);
return db.get(id);
}
public Product update(Product product) {
sleep(50);
Product old = db.get(product.getId());
long nextVersion = old == null ? 1L : old.getVersion() + 1;
product.setVersion(nextVersion);
db.put(product.getId(), product);
return product;
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
5.2 Caffeine 与 Redis 配置
CacheConfig.java
package com.example.cachedemo.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import java.util.concurrent.TimeUnit;
@Configuration
public class CacheConfig {
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.recordStats()
.build();
}
@Bean
public ChannelTopic cacheEvictTopic() {
return new ChannelTopic("cache:evict:product");
}
@Bean
public RedisMessageListenerContainer redisContainer(RedisConnectionFactory factory,
MessageListener listener,
ChannelTopic topic) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener(listener, topic);
return container;
}
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
5.3 Redis 失效通知监听
CacheEvictMessageListener.java
package com.example.cachedemo.listener;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;
@Component
public class CacheEvictMessageListener implements MessageListener {
private final Cache<String, Object> localCache;
public CacheEvictMessageListener(Cache<String, Object> localCache) {
this.localCache = localCache;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String key = new String(message.getBody());
localCache.invalidate(key);
}
}
5.4 核心缓存服务
这里是全文最关键的部分:多级查询、空值缓存、互斥锁防击穿、更新后广播失效。
ProductCacheService.java
package com.example.cachedemo.service;
import com.example.cachedemo.model.Product;
import com.example.cachedemo.repository.ProductRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.UUID;
@Service
public class ProductCacheService {
private static final String PRODUCT_KEY_PREFIX = "product:";
private static final String NULL_VALUE = "__NULL__";
private static final String LOCK_PREFIX = "lock:product:";
private static final Duration REDIS_TTL = Duration.ofMinutes(10);
private static final Duration NULL_TTL = Duration.ofMinutes(2);
private static final Duration LOCK_TTL = Duration.ofSeconds(5);
private final Cache<String, Object> localCache;
private final StringRedisTemplate stringRedisTemplate;
private final ProductRepository productRepository;
private final ObjectMapper objectMapper;
private final ChannelTopic cacheEvictTopic;
public ProductCacheService(Cache<String, Object> localCache,
StringRedisTemplate stringRedisTemplate,
ProductRepository productRepository,
ObjectMapper objectMapper,
ChannelTopic cacheEvictTopic) {
this.localCache = localCache;
this.stringRedisTemplate = stringRedisTemplate;
this.productRepository = productRepository;
this.objectMapper = objectMapper;
this.cacheEvictTopic = cacheEvictTopic;
}
public Product getProduct(Long id) {
String key = PRODUCT_KEY_PREFIX + id;
Object local = localCache.getIfPresent(key);
if (local != null) {
if (NULL_VALUE.equals(local)) {
return null;
}
return (Product) local;
}
String redisValue = stringRedisTemplate.opsForValue().get(key);
if (redisValue != null) {
if (NULL_VALUE.equals(redisValue)) {
localCache.put(key, NULL_VALUE);
return null;
}
Product product = fromJson(redisValue);
localCache.put(key, product);
return product;
}
String lockKey = LOCK_PREFIX + id;
String lockValue = UUID.randomUUID().toString();
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, LOCK_TTL);
if (Boolean.TRUE.equals(locked)) {
try {
Product product = productRepository.findById(id);
if (product == null) {
stringRedisTemplate.opsForValue().set(key, NULL_VALUE, NULL_TTL);
localCache.put(key, NULL_VALUE);
return null;
}
String json = toJson(product);
stringRedisTemplate.opsForValue().set(key, json, REDIS_TTL);
localCache.put(key, product);
return product;
} finally {
String currentLockValue = stringRedisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentLockValue)) {
stringRedisTemplate.delete(lockKey);
}
}
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Object retryLocal = localCache.getIfPresent(key);
if (retryLocal != null) {
if (NULL_VALUE.equals(retryLocal)) {
return null;
}
return (Product) retryLocal;
}
String retryRedisValue = stringRedisTemplate.opsForValue().get(key);
if (retryRedisValue != null) {
if (NULL_VALUE.equals(retryRedisValue)) {
localCache.put(key, NULL_VALUE);
return null;
}
Product product = fromJson(retryRedisValue);
localCache.put(key, product);
return product;
}
return productRepository.findById(id);
}
public Product updateProduct(Long id, String name, BigDecimal price) {
Product updated = new Product(id, name, price, 0L);
Product saved = productRepository.update(updated);
String key = PRODUCT_KEY_PREFIX + id;
stringRedisTemplate.delete(key);
localCache.invalidate(key);
stringRedisTemplate.convertAndSend(cacheEvictTopic.getTopic(), key);
return saved;
}
private String toJson(Product product) {
try {
return objectMapper.writeValueAsString(product);
} catch (JsonProcessingException e) {
throw new RuntimeException("serialize product failed", e);
}
}
private Product fromJson(String json) {
try {
return objectMapper.readValue(json, Product.class);
} catch (JsonProcessingException e) {
throw new RuntimeException("deserialize product failed", e);
}
}
}
5.5 控制器
ProductController.java
package com.example.cachedemo.controller;
import com.example.cachedemo.model.Product;
import com.example.cachedemo.service.ProductCacheService;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/products")
@Validated
public class ProductController {
private final ProductCacheService productCacheService;
public ProductController(ProductCacheService productCacheService) {
this.productCacheService = productCacheService;
}
@GetMapping("/{id}")
public Product getById(@PathVariable Long id) {
return productCacheService.getProduct(id);
}
@PutMapping("/{id}")
public Product update(@PathVariable Long id,
@RequestBody UpdateProductRequest request) {
return productCacheService.updateProduct(id, request.getName(), request.getPrice());
}
public static class UpdateProductRequest {
@NotBlank
private String name;
@NotNull
private BigDecimal price;
public String getName() {
return name;
}
public BigDecimal getPrice() {
return price;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
}
}
5.6 启动类
CacheDemoApplication.java
package com.example.cachedemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CacheDemoApplication {
public static void main(String[] args) {
SpringApplication.run(CacheDemoApplication.class, args);
}
}
六、逐步验证清单
这个部分我建议你一定自己跑一遍。缓存方案最怕“代码看懂了,行为没验证”。
1. 查询命中链路
第一次查询:
curl http://localhost:8080/products/1
预期:
- Caffeine 未命中
- Redis 未命中
- 数据库命中
- 回填 Redis + Caffeine
第二次立即查询:
curl http://localhost:8080/products/1
预期:
- 直接命中 Caffeine
2. 验证空值缓存防穿透
查询一个不存在的 id:
curl http://localhost:8080/products/999
第一次会查数据库,后续短时间内再查:
curl http://localhost:8080/products/999
预期:
- 不会次次打到数据库
- Redis 和本地缓存会存一个空标记
__NULL__
3. 验证更新后的缓存失效
更新商品:
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"机械键盘 V2","price":399.00}'
然后再次查询:
curl http://localhost:8080/products/1
预期:
- 先从数据库/Redis 重建新值
- 不再返回旧的本地缓存值
七、缓存一致性到底怎么理解
很多同学会问:这套方案是不是“强一致”?
答案是:不是。它更接近“最终一致性”。
因为在下面这个短窗口里,旧值仍然可能被读到:
- 请求 A 读到了旧本地缓存
- 请求 B 更新数据库并删除缓存
- 请求 A 在更新完成前已经返回了旧数据
这在多数缓存系统里是正常现象,只要这个窗口足够短、业务可接受即可。
适合的场景
- 商品详情
- 配置信息
- 标签、类目、字典数据
- 读多写少、允许短暂旧值
不适合的场景
- 余额、库存强一致扣减
- 核心交易状态
- 对“读到旧值”零容忍的金融类业务
如果你的业务要求特别高,就别指望靠简单缓存删除实现强一致,要引入:
- 版本号控制
- MQ 顺序消息
- Canal/binlog 异步同步
- 甚至直接绕开本地缓存
八、常见坑与排查
这部分很重要。我踩过不少坑,很多都不是代码本身错,而是“看起来没问题,线上却不稳定”。
1. 本地缓存删了,但其他节点还是旧值
现象:
- A 机器更新后正常
- B 机器查出来还是旧值
原因:
- 只删了当前实例的 Caffeine
- 没有广播失效消息
排查思路:
- 检查是否发布了 Redis channel 消息
- 检查其他节点的 listener 是否订阅成功
- 检查 topic 名字是否一致
- 看是否因为网络抖动导致消息消费异常
2. Redis 删除了,旧值又“复活”了
现象:
明明更新时删除了 Redis,但过一会儿 Redis 里又出现旧数据。
常见原因:
- 某个并发读请求在删除前拿到了旧数据库值
- 然后它又把旧值回填进 Redis
这也是为什么在高并发场景里,一致性问题永远不能只看“删没删”,还要看并发下谁在回填。
缓解手段:
- 给缓存数据带
version - 回填时做版本比较
- 更新后采用延迟双删
- 对关键 key 使用单飞(single flight)/ 互斥重建
3. 空值缓存导致新数据短时间不可见
现象:
某个 id 之前不存在,被缓存成空值;后来数据库插入了新数据,但一段时间内还是查不到。
原因:
- 空值缓存 TTL 过长
建议:
- 空值 TTL 一定要短,比如 30 秒到 2 分钟
- 新增数据时主动删除空值缓存
4. Caffeine 命中率很低
现象:
上了本地缓存,性能却没有明显提升。
可能原因:
maximumSize太小,频繁淘汰- TTL 太短
- key 粒度过细,命中分散
- 服务实例太多,流量被分摊,每个实例热度不足
建议:
结合 recordStats() 输出指标,观察:
- hitRate
- evictionCount
- loadSuccess/loadFailure
5. 序列化异常
现象:
Redis 中有值,但反序列化失败,接口报错。
原因:
- 对象结构变更
- Jackson 配置不一致
- 历史缓存数据格式不同
建议:
- Redis 中尽量存 JSON,避免 JDK 原生序列化
- 对缓存对象做向后兼容设计
- 发布新版本前考虑旧缓存清理策略
九、安全/性能最佳实践
这一节给你的是可以直接带到项目里的建议。
9.1 防缓存穿透:空值缓存 + 参数校验
最基础但有效。
- 非法 id 直接拦截,不进缓存层
- 对不存在的数据缓存空值
- 空值过期时间比正常值短
如果系统暴露在公网,还可以加:
- 布隆过滤器
- 接口限流
- 黑名单策略
穿透防护流程图
flowchart LR
A[请求ID] --> B{参数是否合法}
B -- 否 --> C[直接拒绝]
B -- 是 --> D{布隆过滤器存在?}
D -- 否 --> C
D -- 是 --> E[查多级缓存]
E --> F{缓存命中?}
F -- 否 --> G[查数据库并缓存空值/真实值]
F -- 是 --> H[返回结果]
9.2 防缓存击穿:热点 key 互斥重建
本文代码里已经示范了基于 Redis setIfAbsent 的简化锁。
更进一步建议:
- 锁过期时间要合理,避免死锁
- 只对热点 key 加锁,不要全量 key 都加
- 失败线程可短暂重试或返回旧值
9.3 防缓存雪崩:TTL 加随机值
不要让一批 key 在同一秒同时过期。
例如:
long ttlSeconds = 600 + (long) (Math.random() * 120);
让过期时间带抖动,避免大面积同时回源。
9.4 本地缓存不是越大越好
Caffeine 在 JVM 堆内,太大会带来:
- GC 压力上升
- 热数据被冷数据污染
- 实例重启后全部丢失
所以本地缓存适合:
- 高频热点
- 小对象
- 可快速重建的数据
9.5 给缓存加监控,不要“盲飞”
至少要有这些指标:
- Caffeine hit rate
- Redis hit rate
- DB fallback 次数
- 空值缓存数量
- 锁竞争次数
- 缓存重建耗时
- 缓存删除失败次数
如果没有监控,你很难知道到底是 Redis 不稳,还是本地缓存太小,还是业务写入过于频繁。
9.6 更新链路要尽量短
更新流程里,最怕夹杂太多逻辑,比如:
- 更新数据库
- 调多个远程服务
- 最后再删缓存
这样缓存失效窗口会被拉长。
我的建议是:
- 数据库更新与缓存删除尽量靠近
- 能拆异步的后置动作就不要放在主链路里
十、可以继续增强的几个点
如果你的业务再复杂一些,可以考虑继续往下演进。
1. 给缓存对象加版本号
本文 Product 里有 version 字段,就是为了后续演进做铺垫。
可以实现:
- 回填缓存前比较版本
- 防止旧值覆盖新值
2. 延迟双删
一种常见手法:
- 更新数据库
- 删除缓存
- 延迟几百毫秒
- 再删一次缓存
它不是银弹,但在某些读写竞争场景里能降低脏数据复活概率。
3. 热点数据预热
系统启动后先把高频 key 预加载进 Redis 或本地缓存,避免冷启动时直接冲数据库。
4. 读写隔离设计
如果某类数据更新频繁,本地缓存收益不高,甚至会增加一致性复杂度。那就只上 Redis,不上 Caffeine。
这点很现实:不是所有数据都值得做多级缓存。
十一、边界条件与方案取舍
做技术方案时,边界条件比“看起来高级”更重要。
适合上 Caffeine + Redis 的场景
- 读多写少
- 热点明显
- 可容忍秒级内的最终一致
- 单次查询链路不复杂
- 数据体积不大
不建议上多级缓存的场景
- 写多读少
- 每次更新都要求所有节点立即可见
- 数据对象很大
- 节点数量非常多,本地缓存命中收益低
- 团队没有足够监控和排障能力
一句话总结:
多级缓存带来的不是“免费性能”,而是“用复杂度换吞吐量”。
如果你接得住这份复杂度,它很值;接不住,就会变成线上故障制造器。
十二、总结
这篇文章我们从工程实战角度,搭了一套 Spring Boot 下的多级缓存方案:
- Caffeine 负责本地热点命中
- Redis 负责跨实例共享缓存
- 数据库 负责最终数据来源
- 通过 空值缓存 防穿透
- 通过 互斥锁 防击穿
- 通过 更新后删除缓存 + Pub/Sub 广播 做多节点本地缓存失效
- 结合 TTL、监控、版本号等手段优化一致性与性能
最后给你几个可执行建议,都是我觉得最实用的:
- 先从读多写少的数据开始做多级缓存,别一上来全站铺开
- 写操作优先“删缓存”而不是“更缓存”
- 空值 TTL 一定要短,正常值 TTL 要加随机抖动
- 热点 key 回源时做互斥控制
- 本地缓存一定要配合跨节点失效通知,不然迟早读到旧值
- 上线前准备好命中率、回源次数、锁竞争等监控
如果你现在项目里已经用了 Redis,但接口延迟还是高、数据库压力还是大,那往前再加一层 Caffeine 往往是很划算的。但前提是:你要明确接受它是最终一致,而不是强一致。
这条边界想清楚了,多级缓存才会真正成为性能利器,而不是隐藏故障源。