Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:一致性、穿透击穿防护与性能调优
在业务量上来之后,很多项目都会遇到一个很现实的问题:数据库扛不住、接口延迟波动大、热点数据抖一下全链路都跟着抖。
这时候,单一 Redis 缓存往往已经不够用了。原因不复杂:
- Redis 能抗不少流量,但网络往返成本仍然存在
- 热点 key 非常集中时,Redis 也可能成为瓶颈
- 本地 JVM 内存其实离业务代码最近,读起来最快
- 但本地缓存一旦引入,又会带来一致性、失效传播、脏数据这些新问题
所以这篇文章,我们就从工程实践角度,带你搭一个 Spring Boot + Spring Cache + Redis + 本地缓存(Caffeine) 的多级缓存方案,并重点解决下面几个痛点:
- 如何把 Spring Cache 用起来,而不是全手写缓存逻辑
- 多级缓存怎么设计:本地缓存 + Redis 如何配合
- 缓存一致性怎么处理
- 如何防止缓存穿透、缓存击穿、缓存雪崩
- 线上调优时,哪些参数最影响性能
- 出问题了怎么排查
这不是一篇只讲概念的文章,我会尽量用“能跑、能改、能定位问题”的方式来讲。
背景与问题
先看一个典型查询链路:
Controller -> Service -> DB
刚开始数据量不大,没问题;一旦 QPS 上来,数据库慢查询增多,接口响应时间就开始抖。最直接的优化是加 Redis:
Controller -> Service -> Redis -> DB
这确实能挡掉大部分读取流量,但当热点数据极端集中时,问题又来了:
- 所有请求都要过网络访问 Redis
- 同一个节点上的相同热点数据被重复获取
- 某个热点 key 失效瞬间,大量请求同时打到 DB
- 分布式环境下,本地缓存更新不及时会出现数据不一致
所以我们通常会进一步演进为:
Controller -> Service -> 本地缓存(Caffeine) -> Redis -> DB
也就是所谓的多级缓存:
- 一级缓存:本地缓存,极低延迟
- 二级缓存:Redis,跨节点共享
- 最终存储:MySQL / PostgreSQL 等数据库
但这套方案要真正可用,不能只停留在“读的时候先查本地,不存在再查 Redis”。真正难的是:
- 写操作之后如何删除/刷新多级缓存
- 多节点如何广播本地缓存失效
- 空值是否缓存,缓存多久
- 热点 key 如何避免击穿
- TTL 如何设计,避免同一时间大批量失效
这篇文章就围绕这些问题展开。
前置知识与环境准备
本文示例使用:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Redis
- Caffeine
- Maven
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-validation</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
application.yml
spring:
cache:
type: none
data:
redis:
host: localhost
port: 6379
timeout: 2000ms
server:
port: 8080
这里我故意把 spring.cache.type 设成 none,因为我们要自己接管 CacheManager,这样可控性更高。
核心原理
先把整体架构画出来。
flowchart TD
A[客户端请求] --> B[Controller]
B --> C[Service @Cacheable]
C --> D{本地缓存 Caffeine 命中?}
D -- 是 --> E[直接返回]
D -- 否 --> F{Redis 命中?}
F -- 是 --> G[回填本地缓存]
G --> E
F -- 否 --> H[查询数据库]
H --> I[写入 Redis]
I --> J[写入本地缓存]
J --> E
这个过程看起来简单,但真正上线要考虑“写”:
sequenceDiagram
participant Client as Client
participant App1 as App-1
participant Redis as Redis
participant DB as DB
participant MQ as Redis Pub/Sub
participant App2 as App-2
Client->>App1: 更新商品
App1->>DB: 更新数据库
App1->>Redis: 删除二级缓存
App1->>MQ: 发布本地缓存失效消息
MQ-->>App1: 清理本地缓存
MQ-->>App2: 清理本地缓存
App1-->>Client: 返回成功
也就是说,多级缓存设计里有三个核心原则:
1. 读路径:逐级回源
- 先查本地缓存
- 本地没有,再查 Redis
- Redis 没有,再查数据库
- 数据查到后逐级回填
这是为了性能。
2. 写路径:先更新数据库,再删缓存
这是经典的 Cache Aside Pattern。
一般推荐:
- 更新数据库
- 删除 Redis 缓存
- 广播删除本地缓存
而不是先更新缓存再更新数据库。因为缓存不是权威数据源,数据库才是。
3. 一致性目标:追求最终一致,而不是强一致
多级缓存天然不适合追求跨节点强一致。实际工程里通常接受:
- 短时间内出现旧值
- 通过 TTL + 主动删除 + 失效广播,将窗口缩到可接受范围
如果你的业务是账户余额、库存扣减这类强一致场景,缓存只能做辅助,甚至不该直接缓存关键值。
多级缓存设计思路
我们这次实现采用下面的策略:
- Spring Cache 注解驱动
- 自定义
Cache,内部组合 Caffeine + Redis - Redis 作为二级缓存与跨节点共享层
- 本地缓存只做热点数据短 TTL 存储
- 更新数据时通过 Redis Pub/Sub 通知所有实例清理本地缓存
- 对空值进行短时缓存,防止穿透
- 对热点 key 增加互斥加载,防止击穿
- TTL 增加随机抖动,避免雪崩
实战代码(可运行)
下面给出一套简化但可运行的实现。
1. 启动类
package com.example.multicache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MultiCacheApplication {
public static void main(String[] args) {
SpringApplication.run(MultiCacheApplication.class, args);
}
}
2. 模拟实体与仓库
package com.example.multicache.domain;
import java.io.Serializable;
public class Product implements Serializable {
private Long id;
private String name;
private Integer price;
public Product() {
}
public Product(Long id, String name, Integer price) {
this.id = id;
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public Integer getPrice() {
return price;
}
public void setId(Long id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(Integer price) {
this.price = price;
}
}
package com.example.multicache.repository;
import com.example.multicache.domain.Product;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Repository;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class ProductRepository {
private final Map<Long, Product> storage = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
storage.put(1L, new Product(1L, "机械键盘", 399));
storage.put(2L, new Product(2L, "显示器", 1299));
}
public Product findById(Long id) {
sleep(100);
return storage.get(id);
}
public Product save(Product product) {
sleep(80);
storage.put(product.getId(), product);
return product;
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
}
3. 多级缓存配置
3.1 Redis 配置
package com.example.multicache.config;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
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.StringRedisTemplate;
import org.springframework.data.redis.serializer.*;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer keySerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(objectMapper());
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
return mapper;
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
}
这里顺手提一句:生产环境里如果对反序列化安全要求高,不建议滥用默认类型信息,后面“安全最佳实践”里会展开说。
3.2 自定义缓存实现
package com.example.multicache.cache;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
public class TwoLevelCache implements org.springframework.cache.Cache {
private static final Object NULL_VALUE = new Object();
private final String name;
private final Cache<Object, Object> localCache;
private final RedisTemplate<String, Object> redisTemplate;
private final Duration redisTtl;
private final String keyPrefix;
private final ConcurrentHashMap<Object, ReentrantLock> keyLocks = new ConcurrentHashMap<>();
public TwoLevelCache(String name,
Cache<Object, Object> localCache,
RedisTemplate<String, Object> redisTemplate,
Duration redisTtl,
String keyPrefix) {
this.name = name;
this.localCache = localCache;
this.redisTemplate = redisTemplate;
this.redisTtl = redisTtl;
this.keyPrefix = keyPrefix;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
private String buildKey(Object key) {
return keyPrefix + name + "::" + key;
}
@Override
public ValueWrapper get(Object key) {
Object localValue = localCache.getIfPresent(key);
if (localValue != null) {
return wrapValue(localValue);
}
Object redisValue = redisTemplate.opsForValue().get(buildKey(key));
if (redisValue != null) {
localCache.put(key, redisValue);
return wrapValue(redisValue);
}
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();
if (value == null) {
return null;
}
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();
}
ReentrantLock lock = keyLocks.computeIfAbsent(key, k -> new ReentrantLock());
lock.lock();
try {
wrapper = get(key);
if (wrapper != null) {
return (T) wrapper.get();
}
T value = valueLoader.call();
put(key, value);
return value;
} catch (Exception e) {
throw new ValueRetrievalException(key, valueLoader, e);
} finally {
lock.unlock();
keyLocks.remove(key, lock);
}
}
@Override
public void put(Object key, Object value) {
Object storeValue = (value == null ? NULL_VALUE : value);
localCache.put(key, storeValue);
redisTemplate.opsForValue().set(buildKey(key), storeValue, 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.invalidate(key);
redisTemplate.delete(buildKey(key));
}
@Override
public void clear() {
localCache.invalidateAll();
}
public void clearLocal(Object key) {
localCache.invalidate(key);
}
private ValueWrapper wrapValue(Object value) {
if (Objects.equals(value, NULL_VALUE)) {
return new SimpleValueWrapper(null);
}
return new SimpleValueWrapper(value);
}
}
这段代码有几个关键点:
- 先查本地,再查 Redis
- 查到 Redis 后回填本地缓存
get(key, Callable)做了单机内互斥加载,减轻热点 key 击穿null用占位对象缓存,避免穿透
注意:这里的锁是单机粒度,对多实例来说只能防本机并发,跨节点击穿需要 Redis 分布式锁或热点永不过期策略。
3.3 CacheManager 配置
package com.example.multicache.config;
import com.example.multicache.cache.TwoLevelCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.support.AbstractCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Configuration
public class CacheConfig {
public static final String PRODUCT_CACHE = "product";
@Bean
public CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
return new AbstractCacheManager() {
@Override
protected Collection<? extends Cache> loadCaches() {
TwoLevelCache productCache = new TwoLevelCache(
PRODUCT_CACHE,
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build(),
redisTemplate,
Duration.ofMinutes(5),
"cache:"
);
return List.of(productCache);
}
};
}
}
4. 本地缓存失效广播
多节点部署时,只删当前节点本地缓存是不够的,所以我们借助 Redis Pub/Sub 广播失效消息。
4.1 消息发布器
package com.example.multicache.cache;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@Component
public class CacheInvalidationPublisher {
public static final String CHANNEL = "cache:invalidate";
private final StringRedisTemplate stringRedisTemplate;
public CacheInvalidationPublisher(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void publish(String cacheName, Object key) {
String message = cacheName + "::" + key;
stringRedisTemplate.convertAndSend(CHANNEL, message);
}
}
4.2 消息订阅器
package com.example.multicache.cache;
import com.example.multicache.config.CacheConfig;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;
@Component
public class CacheInvalidationSubscriber implements MessageListener {
private final CacheManager cacheManager;
public CacheInvalidationSubscriber(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String body = new String(message.getBody());
String[] parts = body.split("::");
if (parts.length != 2) {
return;
}
String cacheName = parts[0];
String key = parts[1];
org.springframework.cache.Cache cache = cacheManager.getCache(cacheName);
if (cache instanceof TwoLevelCache twoLevelCache) {
twoLevelCache.clearLocal(key);
}
}
}
4.3 订阅配置
package com.example.multicache.config;
import com.example.multicache.cache.CacheInvalidationPublisher;
import com.example.multicache.cache.CacheInvalidationSubscriber;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
@Configuration
public class RedisListenerConfig {
@Bean
public RedisMessageListenerContainer redisContainer(
RedisConnectionFactory connectionFactory,
CacheInvalidationSubscriber subscriber) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(subscriber, new PatternTopic(CacheInvalidationPublisher.CHANNEL));
return container;
}
}
5. Service:基于 Spring Cache 的读写
package com.example.multicache.service;
import com.example.multicache.cache.CacheInvalidationPublisher;
import com.example.multicache.config.CacheConfig;
import com.example.multicache.domain.Product;
import com.example.multicache.repository.ProductRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final ProductRepository repository;
private final CacheInvalidationPublisher publisher;
public ProductService(ProductRepository repository, CacheInvalidationPublisher publisher) {
this.repository = repository;
this.publisher = publisher;
}
@Cacheable(cacheNames = CacheConfig.PRODUCT_CACHE, key = "#id", unless = "#result == null")
public Product getById(Long id) {
return repository.findById(id);
}
public Product update(Product product) {
Product saved = repository.save(product);
// 删除 Redis + 当前节点本地缓存
// 注意:Spring AOP 内部调用 @CacheEvict 容易失效,这里直接通过 CacheManager 或拆分 Bean 更稳
publisher.publish(CacheConfig.PRODUCT_CACHE, product.getId());
return saved;
}
}
上面这段有个刻意保留的点:@Cacheable(unless = "#result == null") 和我们底层 TwoLevelCache 的空值缓存策略并不完全一致。
这是我实际项目里经常看到的“配置打架”问题。为了演示穿透防护,后面我们把 Service 改成手动控制写法,更直观。
6. 更稳妥的 Service 写法
如果你既想用 Spring Cache 注解,又想对空值缓存、更新删除做更精细控制,我更建议这样:
package com.example.multicache.service;
import com.example.multicache.cache.CacheInvalidationPublisher;
import com.example.multicache.config.CacheConfig;
import com.example.multicache.domain.Product;
import com.example.multicache.repository.ProductRepository;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final ProductRepository repository;
private final CacheManager cacheManager;
private final CacheInvalidationPublisher publisher;
public ProductService(ProductRepository repository,
CacheManager cacheManager,
CacheInvalidationPublisher publisher) {
this.repository = repository;
this.cacheManager = cacheManager;
this.publisher = publisher;
}
public Product getById(Long id) {
Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_CACHE);
if (cache == null) {
return repository.findById(id);
}
return cache.get(id, () -> repository.findById(id));
}
public Product update(Product product) {
Product saved = repository.save(product);
Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_CACHE);
if (cache != null) {
cache.evict(product.getId());
}
publisher.publish(CacheConfig.PRODUCT_CACHE, product.getId());
return saved;
}
}
这个版本的好处是:
- 读逻辑清晰可控
- 借用了缓存实现里的防击穿逻辑
- 更新后立即删 Redis 和当前节点本地缓存
- 再通过 Pub/Sub 通知其他节点删本地缓存
7. Controller
package com.example.multicache.controller;
import com.example.multicache.domain.Product;
import com.example.multicache.service.ProductService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping("/{id}")
public Product get(@PathVariable Long id) {
return service.getById(id);
}
@PutMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody @Valid UpdateProductRequest request) {
Product product = new Product(id, request.getName(), request.getPrice());
return service.update(product);
}
public static class UpdateProductRequest {
@NotNull
private String name;
@NotNull
private Integer price;
public String getName() {
return name;
}
public Integer getPrice() {
return price;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(Integer price) {
this.price = price;
}
}
}
8. 逐步验证清单
项目启动后,你可以按这个顺序验证。
第一次查询:走 DB
curl http://localhost:8080/products/1
预期:
- 本地缓存 miss
- Redis miss
- DB 查询
- 回填 Redis 和本地缓存
第二次查询:命中本地缓存
curl http://localhost:8080/products/1
预期:
- 直接命中本地缓存
- 响应更快
更新数据后再次查询
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"机械键盘Pro","price":499}'
再查:
curl http://localhost:8080/products/1
预期:
- 更新后缓存被删除
- 查询重新回源
- 读到最新值
一致性到底怎么做才靠谱?
多级缓存最容易被问到的,就是“怎么保证一致性”。
先说结论:大多数业务里,建议用“更新 DB + 删除缓存 + 广播本地失效 + TTL兜底”的组合拳。
推荐时序
flowchart LR
A[写请求] --> B[更新数据库]
B --> C[删除 Redis 缓存]
C --> D[删除当前节点本地缓存]
D --> E[广播其他节点删除本地缓存]
E --> F[后续读请求重新加载]
为什么不是更新缓存?
因为更新缓存需要知道:
- 这个对象有哪些缓存 key
- 是否有多种查询维度
- 本地缓存、Redis、聚合缓存是否都要同步改
- 更新过程中有无并发读写
一旦缓存 key 多、聚合结构复杂,“更新缓存”很容易漏。相比之下,删除缓存让后续请求自然回源重建,风险通常更小。
双删要不要做?
有些场景会用“延迟双删”:
- 更新 DB
- 删除缓存
- 睡眠几十到几百毫秒
- 再删一次缓存
它主要用来缓解下面这个窗口:
- 线程 A 更新数据库
- 线程 B 恰好在删除前后读到旧数据并回填缓存
这个方案有效,但不优雅,而且睡眠时间不好拍脑袋。我的建议是:
- 高并发热点写场景:可考虑延迟双删
- 一般业务:先用删除缓存 + TTL + 失效广播,够用了
- 极致一致性业务:用消息队列、版本号、订阅 binlog 等更稳方案
缓存穿透、击穿、雪崩怎么防?
这是缓存方案绕不开的三件套。
1. 缓存穿透
问题
请求查询一个根本不存在的数据,比如商品 ID = 999999999,缓存和数据库都没有。攻击者或异常流量不断打这个 key,就会一直访问 DB。
解决
- 缓存空值,TTL 设短一点,比如 30 秒
- 参数校验,非法 ID 直接拦截
- 对恶意请求加限流/黑名单
实践建议
- 空值缓存 TTL 不要太长
- 热门不存在 key 也可以缓存
- 对于明显越界 ID,可以在业务层提前返回
2. 缓存击穿
问题
某个热点 key失效瞬间,大量请求同时回源数据库。
解决
- 单机互斥加载
- 分布式锁
- 热点数据逻辑过期 + 后台刷新
- 热点 key 永不过期,靠消息主动删
本文示例里的 get(key, Callable) 已经实现了单机内互斥加载。如果你的服务是 10 个实例,那只是把击穿风险从“10 万请求打 DB”降成“最多 10 个实例一起打 DB”,已经有帮助,但还不够彻底。
更进一步:Redis 分布式锁示意
String lockKey = "lock:product:" + id;
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
if (Boolean.TRUE.equals(success)) {
try {
Product product = repository.findById(id);
cache.put(id, product);
return product;
} finally {
stringRedisTemplate.delete(lockKey);
}
}
Thread.sleep(50);
return cache.get(id, () -> repository.findById(id));
这只是演示思路,生产环境里最好封装成统一组件,并处理:
- 锁续期
- 锁误删
- 超时与降级
- 自旋重试次数
3. 缓存雪崩
问题
大量缓存 key 在同一时间过期,导致回源流量集中压垮数据库。
解决
- TTL 加随机抖动
- 不同业务缓存分层设置 TTL
- 热点数据永不过期 + 主动失效
- 限流、熔断、降级兜底
TTL 抖动示例
long baseSeconds = 300;
long jitter = java.util.concurrent.ThreadLocalRandom.current().nextLong(0, 60);
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(baseSeconds + jitter));
这是很小但很有效的优化,我几乎会默认加上。
常见坑与排查
这部分我想讲得实一点,因为真正折腾人的往往不是原理,而是“为什么明明写了缓存,结果根本没生效”。
1. @Cacheable 不生效
常见原因
- 没加
@EnableCaching - 方法是类内部自调用
- 不是
public方法 - 被代理的 Bean 没被 Spring 管理
- key 表达式写错
正确姿势
package com.example.multicache.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CacheEnableConfig {
}
如果是同类内部调用,比如:
public void test() {
this.getById(1L);
}
那 @Cacheable 往往不会走代理,这是很多人第一次接触 Spring Cache 时都会踩的坑。
2. 本地缓存和 Redis 数据不一致
现象
- Redis 已删,但某个实例仍返回旧数据
- 某个节点长时间命中旧本地缓存
排查方向
- Pub/Sub 是否正常订阅
- 消息频道名是否一致
- 本地缓存 key 类型是否统一
- 是否有多个
CacheManager - 更新后是否真的执行了
evict
一个非常隐蔽的问题是:key 类型不一致。
比如查询时用的是 Long 1L,广播清理时拿的是 "1" 字符串,那么:
- Redis key 可能删掉了
- 本地缓存却删不掉
所以 key 的字符串化规则要统一。
3. 序列化问题
现象
- Redis 里数据可读性差
- 类结构变更后反序列化失败
- 出现
LinkedHashMap无法转换 - 跨服务共享缓存时类型不兼容
建议
- 能约束类型就不要存任意 Object
- 优先使用 JSON 序列化
- 跨服务缓存尽量存 DTO,而不是整个领域对象
- 对缓存对象做版本控制
我在实际项目里一般会避免“通用 Object 缓存一切”的做法,因为一旦类变更、包名变更、字段重命名,老缓存可能直接变成地雷。
4. 热点 key 还是把 DB 打高了
可能原因
- 本地缓存 TTL 太短
- Redis TTL 太短
- 锁粒度不对
- 分布式环境只有本地锁,没有全局锁
- 热点 key 被频繁删除
排查建议
看三个指标:
- Redis 命中率
- 本地缓存命中率
- DB 某类 SQL 的瞬时 QPS
如果发现:
- 本地命中率很低
- Redis 命中率高
- DB 也偶发冲高
说明你的本地缓存设计可能没有真正兜住热点。
安全/性能最佳实践
这一部分直接给建议,方便你上线前对照。
1. 本地缓存只放热点、短 TTL 数据
不要把本地缓存当“第二个数据库”。
建议:
- 只缓存高频读取的小对象
- 设置
maximumSize - TTL 控制在秒级到分钟级
- 避免缓存超大对象和列表
否则 JVM 堆很容易膨胀,GC 一重,反而把延迟拉高。
2. Redis key 设计要规范
建议格式:
业务前缀:环境:cacheName:业务key
例如:
mall:prod:product:1
好处:
- 易排查
- 易统计
- 易做权限隔离
- 多环境不容易串数据
3. TTL 分层,不要一刀切
不同数据 TTL 应该不同:
- 商品详情:5~30 分钟
- 配置项:1~10 分钟
- 用户权限:更短,甚至强制主动失效
- 空值缓存:30 秒左右
一个项目里所有缓存都 30 分钟,通常不是精细化设计,而是懒。
4. 给 TTL 加随机值
这是防雪崩的低成本高收益手段,建议默认开启。
private Duration withJitter(Duration base) {
long jitter = java.util.concurrent.ThreadLocalRandom.current().nextLong(0, 60);
return base.plusSeconds(jitter);
}
5. 避免危险的反序列化配置
虽然示例里用了 activateDefaultTyping,但在生产环境里更推荐:
- 缓存固定类型 DTO
- 使用
Jackson2JsonRedisSerializer<YourType> - 限制反序列化白名单
- 不要对不可信输入做任意类型反序列化
如果你的 Redis 缓存对象来源复杂,这点尤其重要。
6. 指标监控要先于调优
别等线上慢了再看缓存。
至少监控:
- 本地缓存命中率
- Redis 命中率
- 缓存加载耗时
- 缓存 evict 次数
- 热点 key 排行
- DB 回源 QPS
- Redis 慢查询与连接池使用情况
没有监控,调优基本就是猜。
7. 为更新操作设计降级与兜底
缓存失效广播不是 100% 不丢消息的银弹,尤其 Pub/Sub 在消费者短暂断开时可能丢消息。
所以你要有兜底:
- 本地缓存 TTL 不宜过长
- 关键更新场景可增加二次删除
- 更高可靠性要求下改用 MQ/Kafka
- 极端情况下提供手工清缓存入口
一套可执行的落地建议
如果你现在要把这套方案落到一个中型 Spring Boot 项目,我建议按这个顺序做:
- 先用 Spring Cache + Redis 跑通单级缓存
- 确认 key 设计、TTL 策略、序列化方案
- 对热点接口加本地 Caffeine
- 实现更新后的本地缓存广播失效
- 对不存在数据加短 TTL 空值缓存
- 对热点 key 做互斥加载或分布式锁
- 补充命中率、回源率、慢查询监控
- 根据真实流量再调本地缓存大小和 TTL
不要一上来就把所有花活都加满。缓存这东西,越复杂越要克制。
总结
这篇文章我们完整走了一遍 Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战,核心要点可以浓缩成下面几句:
- 多级缓存的目标是降低延迟、减少 Redis 与 DB 压力
- 读走逐级回源,写走更新 DB 后删缓存
- 一致性通常追求最终一致,不要幻想低成本强一致
- 穿透靠空值缓存,击穿靠互斥加载/分布式锁,雪崩靠 TTL 抖动
- 本地缓存一定要配合失效广播和 TTL 兜底
- 真正的性能调优离不开监控数据
如果你的系统是普通商品详情、配置、字典、内容页这类“读多写少”的场景,这套方案非常适合;但如果你做的是库存、余额、交易状态这类高一致业务,就要谨慎使用缓存,至少不能把缓存当真相。
最后给一个我自己比较常用的判断标准:
能删缓存就尽量别更新缓存;能缩短不一致窗口就别硬追强一致;能监控就别靠感觉。
这三句,基本能帮你避开多级缓存里的大部分坑。