Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:一致性、穿透与热点 Key 优化
在业务量还不大时,很多项目的缓存方案都很“朴素”:
查数据库慢,就在 Redis 前面加一层缓存。
但项目一旦进入真实流量环境,问题很快就冒出来:
- Redis 明明有缓存,为什么接口还是偶发抖动?
- 热门商品详情、配置项、用户画像这类数据,为啥一到高峰期就把 Redis 打得很忙?
- 缓存更新后,为什么总有人读到旧值?
- 某些不存在的 ID 被恶意刷请求时,数据库为什么还能被打穿?
- 本地缓存、Redis、数据库三层数据,怎么尽量做到“够一致”?
这篇文章我会带你从 Spring Boot + Spring Cache + Redis 出发,做一个真正能跑的多级缓存方案,并重点解决三类问题:
- 一致性:更新数据库后,缓存怎么删、怎么更新更稳妥?
- 穿透:请求不存在的数据,怎么避免把数据库打爆?
- 热点 Key:超高频访问的 key,怎么降低 Redis 压力并规避击穿?
文章会以教程方式展开,代码可运行,适合已经会用 Spring Boot 和 Redis,但想把缓存方案从“能用”提升到“可上线”的同学。
一、背景与问题
1.1 为什么单级 Redis 缓存不够了
很多系统最初的写法大概是这样:
请求 -> 应用 -> Redis -> MySQL
看起来已经比直查数据库快很多了,但在中高并发场景会遇到几个典型瓶颈:
- 网络开销:即便 Redis 很快,也比 JVM 本地内存慢一个量级。
- 热点集中:少数热点 key 会被频繁访问,Redis QPS 很高。
- 失效风暴:某个热点 key 同时过期,大量请求穿透到数据库。
- 不一致问题:数据库更新了,Redis 和本地缓存未及时清理。
这时,多级缓存的思路就比较自然了:
请求 -> 本地缓存(Caffeine) -> Redis -> MySQL
也就是:
- 一级缓存(L1):应用本地内存缓存,速度最快
- 二级缓存(L2):Redis,跨实例共享
- 最终存储:MySQL/其他数据库
1.2 多级缓存解决了什么
它主要解决两件事:
- 降低延迟:热点数据优先走本地缓存
- 减轻 Redis 压力:很多读请求不再打到 Redis
但它又引入了一个新问题:
层级越多,一致性越难。
所以多级缓存不是“把 Caffeine 加上就完了”,而是要把下面这些细节想清楚:
- 本地缓存失效如何同步?
- Redis 删除失败怎么办?
- 空值要不要缓存?
- TTL 怎么配?
- 热点 key 同时过期怎么办?
- Spring Cache 的注解默认行为,是否真的符合你的业务?
二、前置知识与环境准备
2.1 技术栈
本文示例基于:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Redis
- Caffeine
- Maven
2.2 依赖配置
<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.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
2.3 application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
jpa:
hibernate:
ddl-auto: update
show-sql: true
data:
redis:
host: localhost
port: 6379
cache:
type: none
server:
port: 8080
这里把 spring.cache.type 设为 none,是因为我们要手动装配一个多级 CacheManager。
三、核心原理
先把整体思路画出来。
flowchart LR
A[客户端请求] --> B{L1 本地缓存 Caffeine}
B -- 命中 --> C[直接返回]
B -- 未命中 --> D{L2 Redis}
D -- 命中 --> E[回填 L1 并返回]
D -- 未命中 --> F[查询数据库]
F --> G[写入 Redis]
G --> H[回填 L1]
H --> I[返回结果]
这个流程看着简单,但真正的难点在于写路径。
sequenceDiagram
participant Client as 客户端
participant App as 应用服务
participant DB as MySQL
participant Redis as Redis
participant L1 as 本地缓存
Client->>App: 更新商品信息
App->>DB: update
DB-->>App: success
App->>Redis: delete(key)
App->>L1: evict(key)
App-->>Client: 返回成功
3.1 为什么推荐“更新数据库 + 删除缓存”
在缓存一致性里,最常见的几种策略是:
- 先更新缓存,再更新数据库
- 先删缓存,再更新数据库
- 先更新数据库,再删缓存
- 更新数据库后异步通知删除缓存
实际项目里,最稳妥的基础方案通常是:先更新数据库,再删除缓存。
原因很简单:
- 如果先删缓存,再更新数据库,更新数据库还没完成时,别的线程可能把旧数据重新写回缓存。
- 如果更新缓存和数据库都做写入,双写一致性很难保证。
- 更新数据库后删缓存,即使删除失败,也可以借助 TTL、重试、消息补偿来兜底。
3.2 多级缓存的一致性本质
多级缓存的一致性不是强一致,而是:
在可接受的时间窗口内做到最终一致。
具体落地时,一般遵循:
- 读多写少场景最适合多级缓存
- 本地缓存 TTL 应该短于 Redis TTL
- 更新时至少要:
- 删除 Redis
- 删除本地缓存
- 多实例部署下,最好通过消息通知广播本地缓存失效
3.3 Spring Cache 在这里扮演什么角色
Spring Cache 的价值不在于“性能魔法”,而在于:
- 统一缓存注解
- 屏蔽底层缓存实现差异
- 把缓存读写逻辑收敛到框架层
常用注解:
@Cacheable:读缓存,未命中则加载@CachePut:更新缓存@CacheEvict:删除缓存@Caching:组合多个操作
但需要注意:
Spring Cache 默认更适合单级缓存;做多级缓存时,通常要自定义
Cache和CacheManager。
四、实战代码(可运行)
这一节我们做一个“商品详情缓存”的例子。
目标:
- L1 用 Caffeine
- L2 用 Redis
- 通过 Spring Cache 注解访问
- 支持空值缓存,防止穿透
- 支持更新后双层删除
4.1 实体与 Repository
Product.java
package com.example.cachedemo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {
@Id
private Long id;
private String name;
private Integer price;
}
ProductRepository.java
package com.example.cachedemo.repository;
import com.example.cachedemo.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
}
4.2 启动初始化数据
package com.example.cachedemo.config;
import com.example.cachedemo.model.Product;
import com.example.cachedemo.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DataInitializer {
@Bean
public CommandLineRunner init(ProductRepository repository) {
return args -> {
repository.save(new Product(1L, "iPhone", 6999));
repository.save(new Product(2L, "ThinkPad", 9999));
};
}
}
4.3 自定义多级缓存实现
MultiLevelCache.java
package com.example.cachedemo.cache;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
import java.util.concurrent.Callable;
public class MultiLevelCache implements Cache {
private final String name;
private final Cache localCache;
private final RedisTemplate<String, Object> redisTemplate;
private final Duration redisTtl;
private final boolean cacheNullValues;
public MultiLevelCache(String name,
Cache localCache,
RedisTemplate<String, Object> redisTemplate,
Duration redisTtl,
boolean cacheNullValues) {
this.name = name;
this.localCache = localCache;
this.redisTemplate = redisTemplate;
this.redisTtl = redisTtl;
this.cacheNullValues = cacheNullValues;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
private String buildKey(Object key) {
return name + "::" + key;
}
@Override
public ValueWrapper get(Object key) {
Cache.ValueWrapper local = localCache.get(key);
if (local != null) {
return local;
}
Object value = redisTemplate.opsForValue().get(buildKey(key));
if (value != null) {
localCache.put(key, value);
return new SimpleValueWrapper(value);
}
return null;
}
@Override
@SuppressWarnings("unchecked")
public <T> T get(Object key, Class<T> type) {
ValueWrapper wrapper = get(key);
if (wrapper == null) {
return null;
}
Object value = wrapper.get();
return (T) value;
}
@Override
@SuppressWarnings("unchecked")
public <T> T get(Object key, Callable<T> valueLoader) {
ValueWrapper wrapper = get(key);
if (wrapper != null) {
return (T) wrapper.get();
}
synchronized ((name + ":" + key).intern()) {
wrapper = get(key);
if (wrapper != null) {
return (T) wrapper.get();
}
try {
T value = valueLoader.call();
if (value != null || cacheNullValues) {
put(key, value);
}
return value;
} catch (Exception e) {
throw new RuntimeException("Cache value loading failed", e);
}
}
}
@Override
public void put(Object key, Object value) {
if (value == null && !cacheNullValues) {
return;
}
localCache.put(key, value);
redisTemplate.opsForValue().set(buildKey(key), value, redisTtl);
}
@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) {
localCache.evict(key);
redisTemplate.delete(buildKey(key));
}
@Override
public void clear() {
localCache.clear();
}
}
这个实现有几个点值得注意:
- 优先查本地缓存
- 本地没命中再查 Redis
- Redis 命中后回填本地缓存
- 加了简单的
synchronized防止单机内同 key 并发回源 - 支持缓存空值
这里的
intern()适合演示,不建议直接原样用于超大规模生产环境。生产上更建议用细粒度锁对象池或分布式锁。
MultiLevelCacheManager.java
package com.example.cachedemo.cache;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.data.redis.core.RedisTemplate;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class MultiLevelCacheManager implements CacheManager {
private final RedisTemplate<String, Object> redisTemplate;
private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
public MultiLevelCacheManager(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, cacheName -> {
CaffeineCache localCache = new CaffeineCache(
cacheName,
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(30))
.build()
);
return new MultiLevelCache(
cacheName,
localCache,
redisTemplate,
Duration.ofMinutes(5),
true
);
});
}
@Override
public Collection<String> getCacheNames() {
return cacheMap.keySet();
}
}
4.4 缓存配置
CacheConfig.java
package com.example.cachedemo.config;
import com.example.cachedemo.cache.MultiLevelCacheManager;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer keySerializer = new StringRedisSerializer();
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(
BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
GenericJackson2JsonRedisSerializer valueSerializer =
new GenericJackson2JsonRedisSerializer(mapper);
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
public CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
return new MultiLevelCacheManager(redisTemplate);
}
}
4.5 Service 层:读写缓存
ProductService.java
package com.example.cachedemo.service;
import com.example.cachedemo.model.Product;
import com.example.cachedemo.repository.ProductRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product getById(Long id) {
sleep(100);
return productRepository.findById(id).orElse(null);
}
@Transactional
@CacheEvict(cacheNames = "product", key = "#product.id")
public Product update(Product product) {
Product saved = productRepository.save(product);
return saved;
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
这里我特意保留了 sleep(100),方便你在本地压测时直观看出缓存命中的效果。
注意:
unless = "#result == null"会阻止 Spring Cache 存 null。
但我们的自定义缓存支持空值缓存,所以如果你真的要防穿透,生产上应根据业务调整这一策略。这里先用最直观的版本,后面会讲如何优化。
4.6 Controller
ProductController.java
package com.example.cachedemo.controller;
import com.example.cachedemo.model.Product;
import com.example.cachedemo.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping("/{id}")
public Product get(@PathVariable Long id) {
return productService.getById(id);
}
@PutMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody Product product) {
product.setId(id);
return productService.update(product);
}
}
4.7 启动验证
先启动 Redis,再启动应用。
第一次读取
curl http://localhost:8080/products/1
预期:
- 查数据库
- 写入 Redis
- 写入本地缓存
第二次读取
curl http://localhost:8080/products/1
预期:
- 直接命中本地缓存,速度明显更快
更新后再次读取
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"iPhone 16","price":7999}'
然后再读:
curl http://localhost:8080/products/1
预期:
- 更新时删除双层缓存
- 再次读取时回源数据库
- 拿到最新值并重新写入缓存
五、穿透、击穿、雪崩:别混了
这三个词经常一起出现,但处理方式不完全一样。
flowchart TD
A[缓存异常访问] --> B[缓存穿透]
A --> C[缓存击穿]
A --> D[缓存雪崩]
B --> B1[请求不存在的数据]
B --> B2[解决: 空值缓存/布隆过滤器]
C --> C1[热点 key 失效瞬间被并发访问]
C --> C2[解决: 单飞锁/互斥重建/逻辑过期]
D --> D1[大量 key 同时过期]
D --> D2[解决: TTL 随机化/限流/降级]
5.1 缓存穿透
定义:请求的数据根本不存在,缓存里没有,数据库里也没有。
如果有人一直拿不存在的 ID 来打接口,每次都会穿过缓存访问数据库。
解决方案 1:缓存空值
例如查不到 id=99999,也缓存一个空对象,TTL 设短一点,比如 1~5 分钟。
思路:
- 命中空值就直接返回不存在
- 避免同一个无效 key 反复查库
可以改造 @Cacheable 的写法,不再排除 null,而是显式包装返回值。更实用的方式是定义一个包装对象。
5.2 缓存击穿
定义:某个热点 key 失效瞬间,海量请求同时回源数据库。
比如首页热门商品详情缓存刚过期,1 秒几千请求一起进来,就很容易打爆数据库。
常用手段
- 本地锁 / 分布式锁
singleflight风格并发合并- 逻辑过期 + 后台异步刷新
- 热点数据永不过期,靠消息主动更新
5.3 缓存雪崩
定义:大量 key 在同一时间过期,整体回源数据库。
优化手段
- 给 TTL 加随机值
- 对核心缓存做预热
- 限流、熔断、降级
- 将热点与普通数据分组配置
六、一致性设计:怎么做到“够稳”
这部分是多级缓存真正的灵魂。
6.1 基础版:更新 DB 后删除缓存
最常见流程:
- 更新数据库
- 删除 Redis
- 删除本地缓存
但在我们示例里,evict() 同时删了本地和 Redis,所以注解上一行就够。
边界问题:删除缓存失败怎么办?
这是线上非常常见、而且经常被忽略的问题。
建议至少做下面几件事:
- 删除失败要打日志并监控
- 关键业务可做重试
- 再高级一点,投递 MQ 做异步重删
例如:
update DB -> delete cache fail -> send MQ -> consumer retry delete
6.2 多实例场景:本地缓存怎么同步失效
单机没问题,多实例就不一样了:
- A 实例更新了商品并删缓存
- B 实例本地缓存里可能还保留旧值
这是多级缓存最容易出问题的地方。
方案:Redis Pub/Sub 或 MQ 广播失效消息
sequenceDiagram
participant A as 应用实例A
participant Redis as Redis/MQ
participant B as 应用实例B
participant C as 应用实例C
A->>Redis: 发布缓存失效消息 product:1
Redis-->>B: 订阅并删除本地缓存
Redis-->>C: 订阅并删除本地缓存
也就是说:
- Redis 仍然删除
- 另外广播一条“key 已失效”的消息
- 所有应用实例收到后删除各自 L1 本地缓存
这一步如果不做,你的多级缓存只能算“单机优化”,还称不上完整方案。
七、热点 Key 优化实战思路
如果你们系统里有:
- 秒杀商品
- 首页推荐位
- 热榜内容
- 配置中心高频项
- 用户会话信息
那热点 key 优化就一定要做。
7.1 方案一:热点 key 放大本地缓存收益
适合:
- 数据变化不频繁
- 读远大于写
- 单 key QPS 很高
建议:
- L1 TTL 设短一些,如 10~30 秒
- Redis TTL 设长一些,如 5~30 分钟
- 配合失效通知做主动剔除
这样能把大量请求挡在 JVM 内存里。
7.2 方案二:逻辑过期
物理过期容易击穿,逻辑过期更稳。
思路:
- 缓存数据结构里带一个
expireAt - 读到过期数据时,先返回旧值
- 只让一个线程异步刷新缓存
伪代码如下:
public Product getHotProduct(Long id) {
CacheData<Product> cacheData = redisGet(id);
if (cacheData == null) {
return rebuild(id);
}
if (cacheData.getExpireAt().isAfter(LocalDateTime.now())) {
return cacheData.getData();
}
if (tryLock(id)) {
asyncRefresh(id);
}
return cacheData.getData();
}
这种方案非常适合:
- 可以接受短暂旧数据
- 但不能接受接口抖动的场景
比如首页榜单、推荐结果、活动配置。
7.3 方案三:TTL 随机化
这是我很推荐的一个“小成本高收益”技巧:
long baseSeconds = 300;
long randomSeconds = ThreadLocalRandom.current().nextLong(0, 120);
long ttl = baseSeconds + randomSeconds;
这样可以避免很多 key 在同一时刻集中过期。
八、常见坑与排查
这一段我尽量讲一些实际会踩到的坑,而不是“理论正确”的那种。
8.1 @Cacheable 不生效
常见原因:
- 方法是
private - 同类内部直接调用,绕过代理
- 没加
@EnableCaching CacheManager没配好
排查方式
看启动日志是否有缓存相关 Bean;
确认调用链是不是通过 Spring 代理对象进入。
错误示例:
@Service
public class ProductService {
public Product a(Long id) {
return b(id); // 同类内部调用,@Cacheable 失效
}
@Cacheable(cacheNames = "product", key = "#id")
public Product b(Long id) {
return queryDb(id);
}
}
8.2 序列化问题
Redis 里如果序列化配置不一致,经常出现:
- 反序列化失败
- 类型丢失
- key 看起来正常,value 读不出来
建议:
- key 使用字符串序列化
- value 使用 JSON 序列化
- 明确版本演进策略,避免对象字段变更导致兼容问题
8.3 空值缓存策略和注解冲突
上面示例里用了:
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
这会导致 null 根本不进缓存。
如果你的目标是防缓存穿透,这个写法就和设计目标冲突了。
该怎么做?
两种思路:
- 去掉
unless,并允许缓存 null - 使用包装对象,比如
CacheValue<Product>
我更推荐第二种,语义更清晰,也更方便扩展逻辑过期字段。
8.4 本地缓存过大导致 GC 压力
Caffeine 很快,但不是“无限快”。
如果你把本地缓存最大容量设得太大,JVM 内存压力会上来,GC 抖动反而影响接口。
建议根据业务压测后定:
- 容量上限
- 访问后过期 or 写入后过期
- 权重淘汰策略
8.5 多实例数据不一致
现象通常是:
- A 机器读到新值
- B 机器一段时间内还读到旧值
几乎可以直接怀疑:
- 本地缓存没做广播失效
- 消息丢了
- 消费失败未重试
排查路径
- 先看 Redis 中的值是不是新的
- 再看某实例本地缓存是否还保留旧值
- 再查失效消息是否发出、是否消费成功
九、安全/性能最佳实践
这一节给的是我认为比较“能落地”的建议。
9.1 Key 设计规范化
推荐 key 格式:
业务前缀:实体:主键[:版本]
例如:
product:detail:1
user:profile:1001
config:homepage:banner:v2
好处:
- 易排查
- 易批量治理
- 便于区分环境和业务域
如果是多环境,还建议加环境前缀:
prod:product:detail:1
9.2 TTL 分层配置
不同数据不要共用一个 TTL。
建议分层:
- 热点详情:30 秒本地 + 5 分钟 Redis
- 用户信息:10 秒本地 + 10 分钟 Redis
- 配置数据:1 分钟本地 + 30 分钟 Redis
- 空值缓存:30 秒 ~ 2 分钟
核心原则:
- L1 TTL < L2 TTL
- 空值 TTL < 正常值 TTL
- 热点 key TTL 加随机抖动
9.3 不要缓存所有查询
适合缓存的通常是:
- 读多写少
- 按主键查询
- 结果相对稳定
- 体积可控
不太适合直接缓存的包括:
- 强一致要求极高的数据
- 复杂组合查询且条件离散度极高
- 大对象、超大列表
- 高频写入数据
9.4 防止缓存成为攻击面
缓存也有安全面:
- 恶意构造海量不同 key,撑爆缓存
- 穿透不存在 ID 打数据库
- 热点 key 恶意打击穿
应对建议:
- 接口层参数校验
- ID 合法性校验
- 对不存在对象做空值缓存或布隆过滤器
- 限流与熔断
- 对关键接口加鉴权和访问频率限制
9.5 监控一定要补齐
上线缓存最怕“看不见”。
建议至少监控这些指标:
- 缓存命中率(L1 / L2 分开看)
- Redis QPS、慢查询、连接数
- 回源数据库 QPS
- 热点 key 排名
- 缓存删除失败次数
- 本地缓存大小与淘汰数
- 接口 P95 / P99 延迟
如果只看接口平均耗时,很容易把问题看丢。
十、逐步验证清单
如果你准备把这套方案用到项目里,我建议按下面顺序验证,而不是一把梭全上。
第一步:验证基本命中链路
- 第一次查数据库
- 第二次命中本地缓存
- 重启实例后命中 Redis
第二步:验证更新一致性
- 更新数据库后,缓存是否删除
- 再次读取是否得到新值
- 删除失败有没有日志和告警
第三步:验证穿透保护
- 连续请求不存在 ID
- 看数据库是否被重复访问
- 确认空值缓存是否生效
第四步:验证热点 key 抗压
- 用压测工具持续打同一 key
- 观察本地缓存命中率
- 观察 Redis QPS 是否明显下降
第五步:验证多实例同步
- 两个实例同时部署
- 在 A 更新数据
- 在 B 读取,确认本地缓存是否及时失效
十一、一个更贴近生产的改造建议
如果你准备把本文示例继续往生产推进,我建议优先补这三项:
1. 本地缓存失效广播
用 Redis Pub/Sub、Kafka、RabbitMQ 都可以。
这是多实例多级缓存的关键。
2. 空值包装与逻辑过期对象
不要直接缓存裸对象,建议统一封装:
public class CacheValue<T> {
private T data;
private boolean empty;
private long expireAt;
}
这样可以同时支持:
- 空值缓存
- 逻辑过期
- 元信息扩展
3. 删除缓存失败补偿
至少做成:
- 同步删除失败 -> 记录日志
- 投递消息重试
- 定时任务兜底扫描
很多“偶发脏读”问题,最后都是补偿机制没做。
十二、总结
Spring Boot 里做 Spring Cache + Redis 的多级缓存,真正的重点不在“怎么加注解”,而在这三件事:
- 读路径足够快:L1 本地缓存挡住热点流量,L2 Redis 承担共享缓存
- 写路径尽量稳:更新数据库后删除缓存,并考虑失败补偿
- 异常流量扛得住:防穿透、抗击穿、避免雪崩
如果你让我给一个实用落地版本,我会建议你这样起步:
- 先上 Caffeine + Redis 两级缓存
- 更新采用 先更新 DB,再删缓存
- 给热点 key 加 TTL 随机化
- 对不存在数据做 空值缓存
- 多实例一定补上 本地缓存失效广播
- 用监控数据持续校准 TTL、容量和命中率
最后说个经验判断:
多级缓存不是银弹,它适合“读多写少、允许短暂最终一致”的场景。
如果你的业务对强一致要求极高,就别硬套缓存,或者至少只缓存非关键读模型。
把边界想清楚,缓存才会帮你,而不是在高峰期反噬你。