Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:一致性、穿透防护与性能调优
在业务量上来之后,很多系统都会遇到一个很现实的问题:数据库扛不住、接口延迟抖动、热点数据反复查。
这时候,单纯加一个 Redis 往往不够,因为:
- 本地 JVM 内访问仍然比远程 Redis 更快
- Redis 能抗大部分读流量,但热点 key 依然会集中
- 缓存更新不当,很容易出现“数据库是新的,缓存还是旧的”
- 空值、恶意 key、批量失效会带来缓存穿透、击穿、雪崩
这篇文章我不打算只讲概念,而是带你从 0 到 1 做一个 Spring Boot + Spring Cache + Redis 的多级缓存方案,并把一致性、穿透防护、性能调优这些实战里最常踩的坑一起讲透。
背景与问题
先看一个典型场景:商品详情页。
一个接口 /products/{id},读多写少,访问量高。
如果每次都查数据库,会出现这些问题:
- 数据库压力大:大量相同请求反复打到 MySQL
- 接口延迟高:数据库查询链路长,抖动明显
- 热点数据集中:某些商品在秒杀、活动期间会被持续访问
- 数据一致性难控制:商品价格修改后,缓存怎么同步更新?
- 缓存异常场景多:空数据、热点失效、批量过期都需要处理
很多团队的第一反应是:
“加 Redis 缓存就好了。”
但真落地时会发现,仅有 Redis 还不够。
更常见的做法是:
- 一级缓存:本地缓存(Caffeine)
- 二级缓存:Redis
- 最终数据源:MySQL
也就是典型的多级缓存架构。
前置知识与环境准备
本文示例环境:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Redis 7.x
- Caffeine
- 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-cache</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-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
application.yml
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
jpa:
hibernate:
ddl-auto: update
show-sql: true
data:
redis:
host: 127.0.0.1
port: 6379
timeout: 3000ms
cache:
type: none
server:
port: 8080
这里把 spring.cache.type 设为 none,是因为我们后面会自己装配一个多级 CacheManager。
核心原理
什么是多级缓存
多级缓存的目标很直接:
优先走最快的缓存层,逐层回退,最后才查数据库。
完整链路通常是:
- 先查本地缓存 Caffeine
- 本地没有,再查 Redis
- Redis 没有,再查数据库
- 查到后回填 Redis 和本地缓存
Mermaid 图先看整体结构:
flowchart LR
A[客户端请求] --> B[Spring Cache]
B --> C{一级缓存 Caffeine}
C -- 命中 --> D[返回数据]
C -- 未命中 --> E{二级缓存 Redis}
E -- 命中 --> F[写回本地缓存]
F --> D
E -- 未命中 --> G[数据库 MySQL]
G --> H[回填 Redis]
H --> I[回填 Caffeine]
I --> D
Spring Cache 在这里扮演什么角色
Spring Cache 本质上是一个缓存抽象层,它不关心底层到底是 Redis、Caffeine 还是别的,只提供统一注解:
@Cacheable:查缓存,没有则执行方法并写缓存@CachePut:执行方法,并把结果更新到缓存@CacheEvict:删除缓存@Caching:组合使用
也就是说,我们可以通过自定义 CacheManager 和 Cache,把“多级缓存逻辑”塞进 Spring Cache 体系里。
一致性问题的本质
缓存一致性说白了就是一个时间差问题:
- 数据库更新了
- 缓存删除/更新还没完成
- 另一个请求刚好读到了旧缓存
严格意义上的强一致,在高并发缓存系统里代价很高。
大部分业务追求的是:
- 最终一致
- 短时间可接受的弱一致
- 热点关键数据做更严谨控制
更新策略常见有三种:
- 先更新数据库,再删除缓存
- 先删除缓存,再更新数据库
- 更新数据库后,异步通知失效本地缓存
实战里更推荐的是:
更新数据库成功后,删除 Redis 和本地缓存,而不是直接更新缓存值
原因很简单:直接更新缓存容易把复杂逻辑、序列化问题、并发覆盖问题带进来。
删除缓存虽然会导致一次回源,但整体更稳。
为什么要额外处理穿透、击穿、雪崩
这三个词太像了,很多人第一次看会混。我用最直白的话区分:
- 缓存穿透:查一个根本不存在的数据,缓存和数据库都没有,请求每次都打到数据库
- 缓存击穿:一个热点 key 失效瞬间,大量并发同时回源数据库
- 缓存雪崩:大量 key 在同一时间失效,数据库整体被冲垮
应对思路:
- 穿透:缓存空值、布隆过滤器、参数校验
- 击穿:互斥锁、逻辑过期、热点永不过期
- 雪崩:过期时间加随机值、分批失效、多级缓存兜底
方案设计:Spring Cache + Caffeine + Redis
我们这次的实现思路是:
- 使用
CaffeineCache作为一级缓存 - 使用
RedisCache作为二级缓存 - 自定义
MultiLevelCache,内部组合两个缓存 - 自定义
MultiLevelCacheManager - 业务层继续使用
@Cacheable、@CacheEvict
这样业务代码不会被缓存细节污染。
读写流程
sequenceDiagram
participant U as 用户请求
participant S as Service
participant L1 as Caffeine
participant L2 as Redis
participant DB as MySQL
U->>S: 查询商品(id)
S->>L1: get(id)
alt L1命中
L1-->>S: 返回
S-->>U: 响应
else L1未命中
S->>L2: get(id)
alt L2命中
L2-->>S: 返回
S->>L1: put(id, value)
S-->>U: 响应
else L2未命中
S->>DB: select by id
DB-->>S: 数据/空
S->>L2: put(id, value)
S->>L1: put(id, value)
S-->>U: 响应
end
end
实战代码(可运行)
下面给出一套可以直接跑起来的简化版代码。
1. 实体类与仓库
Product.java
package com.example.demo.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.io.Serializable;
import java.math.BigDecimal;
@Entity
@Table(name = "product")
public class Product implements Serializable {
@Id
private Long id;
private String name;
private BigDecimal price;
public Product() {
}
public Product(Long id, String name, BigDecimal price) {
this.id = id;
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public BigDecimal getPrice() {
return price;
}
public void setId(Long id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
}
ProductRepository.java
package com.example.demo.repository;
import com.example.demo.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
}
2. 自定义多级缓存实现
MultiLevelCache.java
package com.example.demo.cache;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import java.util.concurrent.Callable;
public class MultiLevelCache implements Cache {
private final String name;
private final Cache caffeineCache;
private final Cache redisCache;
public MultiLevelCache(String name, Cache caffeineCache, Cache redisCache) {
this.name = name;
this.caffeineCache = caffeineCache;
this.redisCache = redisCache;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
@Override
public ValueWrapper get(Object key) {
ValueWrapper value = caffeineCache.get(key);
if (value != null) {
return value;
}
value = redisCache.get(key);
if (value != null) {
caffeineCache.put(key, value.get());
return value;
}
return null;
}
@Override
public <T> T get(Object key, Class<T> type) {
ValueWrapper value = get(key);
if (value == null) {
return null;
}
Object obj = value.get();
if (type != null && !type.isInstance(obj)) {
return null;
}
return (T) obj;
}
@Override
public <T> T get(Object key, Callable<T> valueLoader) {
ValueWrapper value = get(key);
if (value != null) {
return (T) value.get();
}
try {
T loaded = valueLoader.call();
put(key, loaded);
return loaded;
} catch (Exception e) {
throw new RuntimeException("load cache value failed", e);
}
}
@Override
public void put(Object key, Object value) {
redisCache.put(key, value);
caffeineCache.put(key, value);
}
@Override
public ValueWrapper putIfAbsent(Object key, Object value) {
ValueWrapper existing = get(key);
if (existing == null) {
put(key, value);
}
return existing;
}
@Override
public void evict(Object key) {
redisCache.evict(key);
caffeineCache.evict(key);
}
@Override
public boolean evictIfPresent(Object key) {
redisCache.evict(key);
return caffeineCache.evictIfPresent(key);
}
@Override
public void clear() {
redisCache.clear();
caffeineCache.clear();
}
@Override
public boolean invalidate() {
redisCache.clear();
return caffeineCache.invalidate();
}
}
3. 自定义 CacheManager
MultiLevelCacheManager.java
package com.example.demo.cache;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;
public class MultiLevelCacheManager implements CacheManager {
private final CacheManager caffeineCacheManager;
private final CacheManager redisCacheManager;
public MultiLevelCacheManager(CacheManager caffeineCacheManager, CacheManager redisCacheManager) {
this.caffeineCacheManager = caffeineCacheManager;
this.redisCacheManager = redisCacheManager;
}
@Override
public Cache getCache(String name) {
Cache caffeine = caffeineCacheManager.getCache(name);
Cache redis = redisCacheManager.getCache(name);
if (caffeine == null || redis == null) {
return null;
}
return new MultiLevelCache(name, caffeine, redis);
}
@Override
public Collection<String> getCacheNames() {
Set<String> names = new LinkedHashSet<>();
names.addAll(caffeineCacheManager.getCacheNames());
names.addAll(redisCacheManager.getCacheNames());
return names;
}
}
4. 缓存配置
CacheConfig.java
package com.example.demo.config;
import com.example.demo.cache.MultiLevelCacheManager;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.*;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("product");
cacheManager.setCaffeine(
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(5))
);
return cacheManager;
}
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.activateDefaultTyping(
BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
RedisSerializationContext.SerializationPair<Object> pair =
RedisSerializationContext.SerializationPair.fromSerializer(serializer);
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(pair)
.disableCachingNullValues()
.entryTtl(Duration.ofMinutes(30));
Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
configMap.put("product", defaultConfig.entryTtl(Duration.ofMinutes(30)));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(configMap)
.transactionAware()
.build();
}
@Primary
@Bean
public CacheManager cacheManager(CacheManager caffeineCacheManager,
CacheManager redisCacheManager) {
return new MultiLevelCacheManager(caffeineCacheManager, redisCacheManager);
}
}
这里我先用了
disableCachingNullValues(),后面讲缓存穿透时会说明为什么有时你反而要允许缓存空值。
5. 业务服务
ProductService.java
package com.example.demo.service;
import com.example.demo.entity.Product;
import com.example.demo.repository.ProductRepository;
import jakarta.transaction.Transactional;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Optional;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product getById(Long id) {
System.out.println("query db, id = " + id);
Optional<Product> optional = productRepository.findById(id);
return optional.orElse(null);
}
@Transactional
@CacheEvict(cacheNames = "product", key = "#product.id")
public Product update(Product product) {
return productRepository.save(product);
}
@Transactional
public Product init(Long id) {
Product product = new Product(id, "MacBook Pro", new BigDecimal("19999.00"));
return productRepository.save(product);
}
}
这里有一个关键点:
- 查询用
@Cacheable - 更新后用
@CacheEvict
这就是前面提到的“更新数据库后删除缓存”策略。
6. 控制器
ProductController.java
package com.example.demo.controller;
import com.example.demo.entity.Product;
import com.example.demo.service.ProductService;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/products")
@Validated
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping("/init/{id}")
public Product init(@PathVariable Long id) {
return productService.init(id);
}
@GetMapping("/{id}")
public Product get(@PathVariable @NotNull Long id) {
return productService.getById(id);
}
@PutMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody Product product) {
product.setId(id);
return productService.update(product);
}
}
逐步验证清单
建议你按下面步骤自己跑一遍,体感会特别明显。
1)初始化一条数据
curl -X POST http://localhost:8080/products/init/1
2)第一次查询
curl http://localhost:8080/products/1
控制台会输出:
query db, id = 1
说明回源数据库了。
3)第二次查询
curl http://localhost:8080/products/1
这次不会再打印数据库日志,说明命中缓存。
4)更新数据
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"MacBook Pro M3","price":20999.00}'
5)再次查询
curl http://localhost:8080/products/1
会再次打印数据库日志,然后返回新值。
这说明缓存已被删除,并重新加载。
一致性设计:别追求“看起来很美”的强一致
如果你刚接触缓存,很容易想做成“数据库一更新,缓存立即 100% 正确”。
但高并发系统里,这通常不是免费午餐。
推荐策略:先更新数据库,再删除缓存
这是最经典也最稳的方案。
流程如下:
flowchart TD
A[更新请求] --> B[更新数据库]
B --> C{是否成功}
C -- 否 --> D[返回失败]
C -- 是 --> E[删除Redis缓存]
E --> F[删除本地缓存]
F --> G[下次查询时重建缓存]
优点:
- 实现简单
- 不容易写入脏数据
- 适合绝大多数读多写少场景
为什么不推荐“更新缓存值”?
因为你可能会遇到:
- 缓存对象和数据库对象字段不一致
- 部分字段是动态计算值,不适合直接覆盖
- 分布式环境下多个实例并发更新,覆盖顺序难控制
我自己踩过的坑就是:
数据库里改了 3 个字段,缓存只更新了 2 个,结果接口返回的对象“半新半旧”,排查非常恶心。
本地缓存一致性怎么处理
单机时好办,@CacheEvict 直接删本地缓存即可。
但多实例部署时,一个节点删了自己的 Caffeine,别的节点还留着旧值。
这时要加一个失效通知机制,比如:
- Redis Pub/Sub
- RocketMQ / Kafka 广播缓存失效事件
- 统一缓存变更中心
思路是:
- 更新数据库成功
- 删除 Redis
- 发布
product:1已失效消息 - 所有服务实例收到消息后删除自己的本地缓存
如果不做这一步,多级缓存的一致性就只停留在单机层面。
穿透防护:空值缓存 + 参数校验 + 可选布隆过滤器
1. 参数校验是第一道门
像 id <= 0、超长字符串、明显非法格式,压根不应该进入缓存和数据库。
@GetMapping("/{id}")
public Product get(@PathVariable @NotNull Long id) {
if (id <= 0) {
throw new IllegalArgumentException("invalid id");
}
return productService.getById(id);
}
2. 缓存空值
如果某个 id 根本不存在,数据库查一次后应该短时间记住“它不存在”,否则每次都会打到数据库。
要实现这一点,你可以:
- 允许 Redis 缓存 null 包装对象
- 或者缓存一个特殊占位值,比如
NULL_VALUE
下面给一个简单思路,使用自定义占位对象:
NullValue.java
package com.example.demo.cache;
import java.io.Serializable;
public final class NullValue implements Serializable {
public static final NullValue INSTANCE = new NullValue();
private NullValue() {
}
}
然后在缓存加载时把 null 替换成占位对象,读取时再转回 null。
实际项目里你也可以直接使用 Spring 的 org.springframework.cache.support.NullValue。
注意:空值缓存 TTL 要短,比如 1~5 分钟,避免数据后来插入了却长时间读不到。
3. 布隆过滤器适合超大规模 key
如果你的 key 空间非常大,比如用户 ID、商品 ID、券码,且恶意探测明显,可以在 Redis 前面加布隆过滤器:
- 存在概率高:继续查缓存
- 一定不存在:直接返回
但布隆过滤器不是银弹:
- 有误判率
- 需要初始化和定期维护
- 对中小系统不一定划算
热点 key 击穿:互斥锁与逻辑过期
方案一:互斥锁重建缓存
当 Redis 和本地缓存都失效时,只允许一个线程回源数据库,其它线程等待或快速失败。
伪代码示意:
public Product getProductWithMutex(Long id) {
String lockKey = "lock:product:" + id;
Product product = getFromCache(id);
if (product != null) {
return product;
}
boolean locked = tryLock(lockKey);
if (!locked) {
sleep(50);
return getProductWithMutex(id);
}
try {
product = getFromCache(id);
if (product != null) {
return product;
}
product = loadFromDb(id);
putToCache(id, product);
return product;
} finally {
unlock(lockKey);
}
}
优点:
- 简单直接
- 适合热点 key 不多的场景
缺点:
- 会有线程等待
- 锁设置不当可能死锁或误删
方案二:逻辑过期
逻辑过期的思路是:
- 缓存数据不立刻删除
- 数据中额外存一个“逻辑过期时间”
- 读请求拿到旧值也先返回
- 由后台线程异步重建缓存
这适合对实时性要求没那么极致,但对可用性和低延迟要求更高的场景。
状态图可以这样理解:
stateDiagram-v2
[*] --> Valid
Valid --> Expired : 到达逻辑过期时间
Expired --> Rebuilding : 线程抢到重建锁
Expired --> Expired : 未抢到锁,返回旧值
Rebuilding --> Valid : 重建完成
如果你做的是商品详情、店铺信息、内容页,这种方案通常很香。
但如果你做的是库存、余额,就要非常谨慎,不能为了“快”牺牲关键正确性。
常见坑与排查
这一部分是我觉得最值钱的。因为多级缓存不是“能跑就行”,很多问题都出在边角处。
坑 1:本地缓存和 Redis 数据不一致
现象:
- A 节点已经读到新数据
- B 节点还在返回旧数据
原因:
- 只删了 Redis,没删其他节点的本地缓存
- 本地缓存 TTL 比 Redis 长很多
排查方式:
- 确认是不是多实例部署
- 查看缓存更新链路有没有广播失效消息
- 对比本地缓存 TTL 和 Redis TTL
- 打印 key 的更新时间和来源层级
建议:
- 多实例时一定做本地缓存失效通知
- 本地缓存 TTL 通常要短于 Redis
坑 2:序列化失败或反序列化类型不对
现象:
- Redis 有值,但取出来报类型转换异常
- 对象字段为空或类型错乱
常见原因:
- 用了 JDK 序列化,跨版本兼容差
- 多态对象没带类型信息
- 修改了实体字段结构,旧缓存没清
建议:
- 优先使用 JSON 序列化
- 对缓存对象做版本管理
- 发布前评估是否需要清理历史缓存 key
坑 3:@Cacheable 不生效
这个是 Spring Cache 初学者最常见的坑之一。
典型原因:
- 方法是同类内部调用,没走代理
- 方法不是
public - 没有启用
@EnableCaching - key 表达式写错
- 异常导致方法未正常返回
排查方式:
- 看启动类和配置类是否加了
@EnableCaching - 看调用路径是否经过 Spring 代理 Bean
- 打开 debug 日志观察缓存切面是否执行
坑 4:大对象进入本地缓存导致 Full GC
现象:
- 接口一开始很快,后来频繁 GC
- 堆内存上涨明显
原因:
- 本地缓存塞了过多大对象
maximumSize配置不合理- 把列表、分页大结果直接缓存了
建议:
- 本地缓存只放高频、相对小的热点对象
- 大分页结果更适合放 Redis,不适合放 JVM 堆
- 监控堆内存、GC 次数、缓存命中率
坑 5:缓存雪崩
现象:
- 某个时间点 Redis QPS 暴涨
- 数据库连接池打满
原因:
- 大量 key 用了相同 TTL
- 某批缓存统一重建
解决:
- TTL 加随机值
- 热点 key 单独配置更长 TTL
- 做分批预热
- 用本地缓存做兜底
示例:
Duration ttl = Duration.ofMinutes(30).plusSeconds(ThreadLocalRandom.current().nextInt(0, 300));
安全/性能最佳实践
这部分我按“能直接拿去用”的角度给建议。
1. 缓存 key 设计要可控
推荐格式:
product:{id}
user:{id}
order:{id}
如果使用 Spring Cache 默认 key,也建议统一 cacheName 和 key 策略。
不要把复杂对象直接拼成 key,否则:
- 难排查
- 容易变化
- key 长度失控
2. TTL 分层设计
一个比较稳妥的经验值:
- 本地缓存 Caffeine:30 秒 ~ 5 分钟
- Redis 缓存:5 分钟 ~ 30 分钟
- 空值缓存:1 分钟 ~ 5 分钟
原则是:
- 本地缓存更短,确保多实例旧数据窗口更小
- Redis 更长,减轻数据库压力
- 空值更短,给后续真实数据插入留空间
3. 热点数据单独配置
不要所有缓存都一刀切。
热点商品、配置字典、地区信息、类目树,这些访问模式完全不一样。
可以按 cacheName 维度配置不同 TTL:
configMap.put("product", defaultConfig.entryTtl(Duration.ofMinutes(30)));
configMap.put("dict", defaultConfig.entryTtl(Duration.ofHours(6)));
configMap.put("userProfile", defaultConfig.entryTtl(Duration.ofMinutes(10)));
4. 加监控,不要盲调
缓存优化最怕“凭感觉”。
至少监控这些指标:
- 本地缓存命中率
- Redis 命中率
- 数据库回源次数
- 热点 key 排行
- 缓存重建耗时
- Redis 网络耗时
- JVM 堆内存与 GC
没有监控时,很多人会把问题归咎于 Redis 慢、数据库慢,最后发现其实是 key 根本没命中。
5. 防止缓存存入敏感信息
这一点很容易被忽略。
不要随便把以下数据直接缓存:
- 明文手机号、身份证号
- token、密钥、凭证
- 权限敏感数据且无隔离控制
如果必须缓存:
- 做脱敏
- 做加密
- 控制 TTL
- 严格区分环境
6. 读写比不高时,不要过度设计多级缓存
多级缓存很好,但不是所有系统都值得上。
如果你的场景是:
- 写多读少
- 数据变化极频繁
- 每次读取都要求强一致
那多级缓存可能收益有限,甚至徒增复杂度。
这时候也许:
- 只用 Redis
- 只做局部热点缓存
- 或者直接优化 SQL / 索引
会更合适。
方案边界与取舍
这里我想专门说一句:多级缓存不是默认正确答案。
它适合:
- 读多写少
- 热点数据明显
- 允许短暂最终一致
- 对 RT 很敏感
它不太适合:
- 强一致金融账务
- 高频写入库存扣减
- 实时状态类业务
- 数据体积超大且对象结构复杂
工程上最重要的不是“用了多少组件”,而是: 复杂度和收益是否匹配。
总结
这篇文章我们完成了一套 Spring Boot 下基于 Spring Cache + Redis + Caffeine 的多级缓存实践,并重点讲了三个核心问题:
- 一致性:推荐“先更新数据库,再删除缓存”,多实例下补上本地缓存失效通知
- 穿透防护:参数校验 + 空值缓存,必要时再引入布隆过滤器
- 性能调优:本地缓存兜热点、Redis 抗大多数流量、TTL 分层并配合随机过期
如果你准备把它真正用到项目里,我建议按下面顺序落地:
- 第一步:先做单层 Redis 缓存,保证 key、TTL、序列化规范
- 第二步:再引入本地 Caffeine,解决热点与延迟问题
- 第三步:补齐一致性广播、空值缓存、互斥锁、监控指标
- 第四步:按业务类型拆分 cacheName,单独调参
最后给一个很实用的判断标准:
如果你还不知道系统的热点 key 是谁、命中率多少、数据库回源比例多少,那先别急着“上多级缓存黑科技”,先把监控补齐。
因为缓存优化,从来都不是“配个注解结束了”,而是一个持续观察、持续修正的工程过程。