Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:热点数据更新一致性与性能优化
在大多数业务系统里,缓存不是“要不要上”的问题,而是“什么时候不够用”。
尤其是商品详情、用户画像、配置中心这类热点数据,一旦流量上来,只靠数据库硬扛,基本就是在等报警。
很多同学第一次做缓存,通常是这样的路径:
- 先加 Redis,挡住一部分读流量
- 再发现应用实例一多,Redis 也开始吃紧
- 然后想加本地缓存,结果更新时出现脏数据
- 最后线上出现“有些机器显示新值,有些机器还是旧值”的诡异问题
这篇文章我想带你完整做一遍:在 Spring Boot 里,基于 Spring Cache + Caffeine(本地缓存)+ Redis(分布式缓存)搭一个多级缓存方案,并重点解决两个实战里最容易翻车的问题:
- 热点数据更新时的一致性
- 高并发下的性能优化与缓存保护
整篇文章偏 tutorial,我会尽量用“能直接跑、能直接改”的方式来讲。
背景与问题
先看一个典型场景:商品详情页。
假设你的接口是:
GET /products/{id}
它有这些特点:
- 读多写少
- 热点明显,少量商品会被频繁访问
- 详情数据查询可能涉及多表或远程调用
- 用户对“刚更新完就要看到新值”比较敏感
如果只查数据库,会遇到:
- 数据库 QPS 高
- 慢 SQL 被热点放大
- 高峰期线程堆积
如果只用 Redis,会遇到:
- Redis 成为所有应用实例的共享瓶颈
- 网络 IO 和序列化反序列化有成本
- 极热点 Key 仍然集中打到 Redis
所以自然会想到:
- 一级缓存:应用内本地缓存(Caffeine)
- 二级缓存:Redis
- 最终数据源:MySQL / PostgreSQL 等数据库
这就是常说的多级缓存。
但这里最难的并不是“把缓存加上”,而是下面这几个问题:
1. 本地缓存和 Redis 怎么协同?
理想路径通常是:
- 先查本地缓存
- 本地没有,再查 Redis
- Redis 没有,再查数据库
- 查到后回填 Redis 和本地缓存
2. 数据更新时怎么避免脏读?
比如商品价格更新了:
- 数据库已经是 99
- Redis 还是 88
- 某台机器的本地缓存还是 77
这时候用户到底看到哪个值?如果更新策略不对,答案可能是“随机”。
3. 高并发下如何防止缓存击穿、穿透、雪崩?
这是上线后经常碰到的三连问题:
- 击穿:热点 Key 失效,大量请求同时打 DB
- 穿透:查不存在的数据,每次都穿透到 DB
- 雪崩:一批 Key 同时过期,瞬时把后端打垮
所以,多级缓存不是简单“堆两个缓存层”这么轻松,它本质上是一个一致性 + 性能 + 可观测性的组合题。
前置知识与环境准备
本文示例基于以下环境:
- JDK 17
- Spring Boot 3.3.x
- Spring Cache
- Caffeine
- Redis 7.x
- Maven
依赖
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Caffeine -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- 可选:Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
启用缓存
package com.example.multicache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class MultiCacheApplication {
public static void main(String[] args) {
SpringApplication.run(MultiCacheApplication.class, args);
}
}
核心原理
先别急着写代码,先把流程图理顺。多级缓存最关键的是:读路径和写路径。
读路径
flowchart TD
A[请求查询商品] --> B{本地缓存命中?}
B -- 是 --> C[直接返回]
B -- 否 --> D{Redis 命中?}
D -- 是 --> E[回填本地缓存]
E --> C
D -- 否 --> F[查询数据库]
F --> G{查到数据?}
G -- 是 --> H[写入 Redis]
H --> I[写入本地缓存]
I --> C
G -- 否 --> J[缓存空值/短TTL]
J --> K[返回空结果]
这条路径的目标是:
- 让最热的数据尽量停留在本地缓存
- Redis 作为跨实例共享缓存
- 数据库只处理真正 miss 的请求
写路径
更新时的原则,不是“让所有缓存同步写新值”,而是:
先更新数据库,再删除缓存,而不是先更新缓存。
这是缓存一致性里非常经典的策略:Cache Aside Pattern(旁路缓存模式)。
sequenceDiagram
participant Client as 客户端
participant App as 应用服务
participant DB as 数据库
participant Redis as Redis
participant Local as 本地缓存
Client->>App: 更新商品信息
App->>DB: 更新数据库
DB-->>App: 更新成功
App->>Redis: 删除二级缓存Key
App->>Local: 删除一级缓存Key
App-->>Client: 返回成功
为什么不推荐“更新数据库后顺手把缓存更新成新值”?
因为缓存更新会面临更多问题:
- 并发覆盖:旧请求可能把新值覆盖掉
- 多实例同步复杂
- 更新失败与部分成功更难处理
删缓存通常比改缓存更稳。
多级缓存的一致性边界
这里要很现实地说一句:
只要你不是走强一致分布式事务,多级缓存基本上是“最终一致性”,不是“绝对实时一致”。
你真正能做到的是:
- 绝大多数情况下快速收敛
- 在极短窗口内接受旧值
- 用消息通知或版本控制缩小不一致时间
这个边界一定要和业务方说清楚。
比如库存扣减、账户余额这类强一致场景,不应该靠普通缓存来“赌运气”。
方案设计:Spring Cache + Caffeine + Redis
本文采用下面这个组合:
- Caffeine:一级缓存,速度快,减少 Redis 压力
- Redis:二级缓存,跨实例共享
- Spring Cache:统一注解与缓存抽象
- 手动失效通知:更新后删除 Redis + 清本地缓存
- 可选增强:Redis Pub/Sub 广播本地缓存失效事件
为什么不直接只用 Spring Cache 默认实现?
因为 Spring Cache 的抽象很好用,但它本身不提供完整的“多级缓存一致性协调”。
也就是说:
- 单独配一个 RedisCacheManager 很方便
- 单独配一个 CaffeineCacheManager 也方便
- 但你想要“先本地、再 Redis、再 DB”的联动,就需要自己扩展一层
这也是很多项目里真正落地时会写一个 Composite Cache 或 MultiLevelCache 的原因。
实战代码(可运行)
下面我们做一个最小可运行版本。
目录结构建议
src/main/java/com/example/multicache
├── MultiCacheApplication.java
├── config
│ ├── CacheConfig.java
│ └── RedisConfig.java
├── controller
│ └── ProductController.java
├── domain
│ └── Product.java
├── repository
│ └── ProductRepository.java
├── service
│ └── ProductService.java
└── cache
├── MultiLevelCache.java
└── MultiLevelCacheManager.java
1. 配置文件
spring:
data:
redis:
host: localhost
port: 6379
timeout: 3s
server:
port: 8080
logging:
level:
com.example.multicache: info
2. 实体与模拟仓储
为了让示例可跑,我们先用内存 Map 模拟数据库。
Product.java
package com.example.multicache.domain;
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.multicache.repository;
import com.example.multicache.domain.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> database = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
database.put(1L, new Product(1L, "机械键盘", new BigDecimal("299.00"), 1L));
database.put(2L, new Product(2L, "显示器", new BigDecimal("1499.00"), 1L));
}
public Product findById(Long id) {
simulateSlowQuery();
Product p = database.get(id);
if (p == null) {
return null;
}
return new Product(p.getId(), p.getName(), p.getPrice(), p.getVersion());
}
public Product updatePrice(Long id, BigDecimal newPrice) {
Product old = database.get(id);
if (old == null) {
return null;
}
Product updated = new Product(
old.getId(),
old.getName(),
newPrice,
old.getVersion() + 1
);
database.put(id, updated);
return updated;
}
private void simulateSlowQuery() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
3. Redis 序列化配置
我建议显式配置 JSON 序列化,别偷懒用 JDK 默认序列化。
默认序列化体积大、可读性差、跨语言也不友好。
RedisConfig.java
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.serializer.*;
@Configuration
public class RedisConfig {
@Bean
public RedisSerializer<Object> redisValueSerializer() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
return new GenericJackson2JsonRedisSerializer(objectMapper);
}
@Bean
public RedisSerializationContext.SerializationPair<Object> redisValueSerializationPair(
RedisSerializer<Object> redisValueSerializer) {
return RedisSerializationContext.SerializationPair.fromSerializer(redisValueSerializer);
}
}
4. 多级缓存实现
这是本文最核心的部分。
MultiLevelCache.java
package com.example.multicache.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 localCache;
private final Cache remoteCache;
public MultiLevelCache(String name, Cache localCache, Cache remoteCache) {
this.name = name;
this.localCache = localCache;
this.remoteCache = remoteCache;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
@Override
public ValueWrapper get(Object key) {
ValueWrapper localValue = localCache.get(key);
if (localValue != null) {
return localValue;
}
ValueWrapper remoteValue = remoteCache.get(key);
if (remoteValue != null) {
localCache.put(key, remoteValue.get());
return remoteValue;
}
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 (type != null && !type.isInstance(value)) {
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();
}
synchronized (this.internKey(key)) {
wrapper = get(key);
if (wrapper != null) {
return (T) wrapper.get();
}
try {
T value = valueLoader.call();
put(key, value);
return value;
} catch (Exception e) {
throw new ValueRetrievalException(key, valueLoader, e);
}
}
}
@Override
public void put(Object key, Object value) {
remoteCache.put(key, value);
localCache.put(key, value);
}
@Override
public ValueWrapper putIfAbsent(Object key, Object value) {
ValueWrapper existing = get(key);
if (existing == null) {
put(key, value);
return null;
}
return existing;
}
@Override
public void evict(Object key) {
remoteCache.evict(key);
localCache.evict(key);
}
@Override
public boolean evictIfPresent(Object key) {
boolean remote = remoteCache.evictIfPresent(key);
boolean local = localCache.evictIfPresent(key);
return remote || local;
}
@Override
public void clear() {
remoteCache.clear();
localCache.clear();
}
@Override
public boolean invalidate() {
boolean remote = remoteCache.invalidate();
boolean local = localCache.invalidate();
return remote || local;
}
private String internKey(Object key) {
return (name + "::" + key).intern();
}
}
这个实现做了几件事:
- 优先读本地缓存
- 本地 miss 时读 Redis
- Redis 命中后回填本地缓存
- 最终 miss 时通过
valueLoader查库再双写缓存 evict时同时删除本地和 Redis
注意:这里的
synchronized(intern())是教学示例,适合单机演示。
生产环境对热点 key 更推荐细粒度锁、CaffeineCache#get(key, mappingFunction)、或分布式锁做保护,避免字符串常量池风险。
MultiLevelCacheManager.java
package com.example.multicache.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 localCacheManager;
private final CacheManager remoteCacheManager;
public MultiLevelCacheManager(CacheManager localCacheManager, CacheManager remoteCacheManager) {
this.localCacheManager = localCacheManager;
this.remoteCacheManager = remoteCacheManager;
}
@Override
public Cache getCache(String name) {
Cache local = localCacheManager.getCache(name);
Cache remote = remoteCacheManager.getCache(name);
if (local == null || remote == null) {
return null;
}
return new MultiLevelCache(name, local, remote);
}
@Override
public Collection<String> getCacheNames() {
Set<String> names = new LinkedHashSet<>();
names.addAll(localCacheManager.getCacheNames());
names.addAll(remoteCacheManager.getCacheNames());
return names;
}
}
5. 缓存管理器配置
CacheConfig.java
package com.example.multicache.config;
import com.example.multicache.cache.MultiLevelCacheManager;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Primary;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.*;
import java.time.Duration;
import java.util.List;
@Configuration
public class CacheConfig {
public static final String PRODUCT_CACHE = "productCache";
@Bean("localCacheManager")
public CacheManager localCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager(PRODUCT_CACHE);
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(30))
.recordStats());
return cacheManager;
}
@Bean("remoteCacheManager")
public CacheManager remoteCacheManager(
RedisCacheConfiguration redisCacheConfiguration) {
RedisCacheManager.RedisCacheManagerBuilder builder = RedisCacheManager
.builder()
.cacheDefaults(redisCacheConfiguration)
.initialCacheNames(List.of(PRODUCT_CACHE));
return builder.build();
}
@Bean
public RedisCacheConfiguration redisCacheConfiguration(
RedisSerializationContext.SerializationPair<Object> redisValueSerializationPair) {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5))
.disableCachingNullValues()
.serializeValuesWith(redisValueSerializationPair);
}
@Bean
@Primary
public CacheManager cacheManager(
@Qualifier("localCacheManager") CacheManager localCacheManager,
@Qualifier("remoteCacheManager") CacheManager remoteCacheManager) {
return new MultiLevelCacheManager(localCacheManager, remoteCacheManager);
}
}
这里的 TTL 我故意设置成:
- 本地缓存:30 秒
- Redis:5 分钟
这是一种常见策略:
- 本地缓存更短,降低脏数据停留时间
- Redis 更长,保证整体命中率
6. 业务服务
ProductService.java
package com.example.multicache.service;
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;
import java.math.BigDecimal;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(cacheNames = CacheConfig.PRODUCT_CACHE, key = "#id", sync = true)
public Product getById(Long id) {
return productRepository.findById(id);
}
@CacheEvict(cacheNames = CacheConfig.PRODUCT_CACHE, key = "#id")
public Product updatePrice(Long id, BigDecimal newPrice) {
return productRepository.updatePrice(id, newPrice);
}
}
这里为什么用 @CacheEvict 而不是 @CachePut?
这是个重点。
@CachePut:更新 DB 后把新值写回缓存@CacheEvict:更新 DB 后删缓存,让下一次读重新加载
对于热点数据、多实例部署场景,我更推荐先用 @CacheEvict。原因前面提过:删缓存比改缓存更稳。
7. 控制器
ProductController.java
package com.example.multicache.controller;
import com.example.multicache.domain.Product;
import com.example.multicache.service.ProductService;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public Product getById(@PathVariable Long id) {
return productService.getById(id);
}
@PutMapping("/{id}/price")
public Product updatePrice(@PathVariable Long id,
@RequestParam BigDecimal price) {
return productService.updatePrice(id, price);
}
}
逐步验证清单
项目启动后,可以按这个顺序验证。
1. 第一次查询,应该走“数据库”
curl http://localhost:8080/products/1
第一次会比较慢,因为仓储里有 200ms 的模拟慢查询。
2. 第二次查询,应该命中本地缓存
curl http://localhost:8080/products/1
这次会明显快很多。
3. 重启一个新实例,查询应优先命中 Redis
如果你多开一个应用实例,本地缓存为空,但 Redis 已经有值。
这时第二个实例查询时,会走:
- 本地 miss
- Redis hit
- 回填本地缓存
4. 更新价格,缓存应被清理
curl -X PUT "http://localhost:8080/products/1/price?price=399.00"
然后再查询:
curl http://localhost:8080/products/1
应该能读到新价格。
一致性增强:多实例下如何清理本地缓存
上面的示例在单实例下已经够用了。
但如果是多实例部署,会有一个很现实的问题:
- A 实例更新了商品,删了 Redis 和自己本地缓存
- B 实例的本地缓存还在
- 用户打到 B 实例时,仍然读到旧值
这也是多级缓存最常见的一类线上问题。
解决思路:Redis Pub/Sub 广播失效事件
更新成功后:
- 删除 Redis key
- 发布一个“缓存失效消息”
- 所有实例收到消息后,删除各自本地缓存
sequenceDiagram
participant A as 实例A
participant Redis as Redis
participant B as 实例B
participant C as 实例C
A->>Redis: 删除 productCache::1
A->>Redis: 发布失效消息 productCache::1
Redis-->>B: 收到失效消息
Redis-->>C: 收到失效消息
B->>B: 删除本地缓存 productCache::1
C->>C: 删除本地缓存 productCache::1
简化实现思路
这里给一个关键代码骨架,方便你继续扩展。
CacheInvalidationMessage.java
package com.example.multicache.cache;
import java.io.Serializable;
public class CacheInvalidationMessage implements Serializable {
private String cacheName;
private String key;
public CacheInvalidationMessage() {
}
public CacheInvalidationMessage(String cacheName, String key) {
this.cacheName = cacheName;
this.key = key;
}
public String getCacheName() {
return cacheName;
}
public String getKey() {
return key;
}
public void setCacheName(String cacheName) {
this.cacheName = cacheName;
}
public void setKey(String key) {
this.key = key;
}
}
发布失效消息
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:invalidation";
private final StringRedisTemplate stringRedisTemplate;
public CacheInvalidationPublisher(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void publish(String cacheName, Object key) {
String payload = cacheName + ":" + key;
stringRedisTemplate.convertAndSend(CHANNEL, payload);
}
}
订阅失效消息
package com.example.multicache.cache;
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 localCacheManager;
public CacheInvalidationSubscriber(CacheManager localCacheManager) {
this.localCacheManager = localCacheManager;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String payload = new String(message.getBody());
String[] parts = payload.split(":");
if (parts.length < 2) {
return;
}
String cacheName = parts[0];
String key = parts[1];
var cache = localCacheManager.getCache(cacheName);
if (cache != null) {
cache.evictIfPresent(key);
}
}
}
提醒一下:这里为了演示直接用了字符串拼接。生产环境建议用 JSON,并处理 key 中可能带分隔符的问题。
更进一步的做法
如果业务对一致性要求更高,可以考虑:
- 延迟双删
- 基于 MQ 的失效通知
- 数据版本号校验
- 逻辑过期 + 异步刷新
这些策略不一定都要上,但至少要知道它们各自解决什么问题。
常见坑与排查
这部分我尽量写得像线上排障手册,因为这些坑真的很常见。
坑 1:@Cacheable 不生效
常见原因
- 没加
@EnableCaching - 方法不是
public - 同类内部自调用,绕过了 Spring AOP 代理
- Bean 没被 Spring 管理
排查方式
先打日志确认方法是否真的执行了数据库查询。
如果每次都查库,十有八九是代理没走到。
解决建议
把缓存方法放到独立的 Service Bean 中,通过 Spring 注入调用,而不是类内 this.xxx()。
坑 2:更新后仍然读到旧值
可能原因
- 本地缓存没清
- 多实例下只有当前实例删了本地缓存
- Redis 删除失败
- 查询和更新并发交错,旧值被重新回填
典型并发时序
sequenceDiagram
participant T1 as 查询线程
participant T2 as 更新线程
participant DB as 数据库
participant Cache as 缓存
T1->>DB: 读取旧值
T2->>DB: 更新新值
T2->>Cache: 删除缓存
T1->>Cache: 回填旧值
这就是经典的“旧值回填”问题。
解决建议
- 删除缓存后增加延迟双删
- 对热点 key 采用互斥加载
- 给数据带上
version字段,回填时做版本校验 - 如果是超高一致性要求,考虑不要走本地缓存
坑 3:缓存击穿
热点 key 一过期,几千个请求同时查 DB。
解决建议
@Cacheable(sync = true):同一实例内合并加载- 本地缓存短 TTL + Redis 长 TTL
- 对极热点数据设置逻辑过期并后台刷新
- 必要时对单 key 加分布式锁
sync = true只保证 Spring Cache 在当前实例内减少并发加载,不是全局分布式锁,这个边界要清楚。
坑 4:缓存穿透
大量请求查不存在的商品 ID,比如 /products/99999999。
解决建议
- 缓存空值,TTL 短一些
- 参数合法性校验
- 布隆过滤器拦截明显非法 ID
不过要注意,本文示例里 Redis 配置用了 disableCachingNullValues(),这是为了简单。
如果你的业务穿透问题明显,可以改成支持空值缓存,并约定好短 TTL。
坑 5:缓存雪崩
一批 key 同时过期,瞬间流量打到 DB。
解决建议
- TTL 加随机抖动
- 热点数据永不过期 + 逻辑过期
- 做服务降级和限流
- 将缓存预热纳入发布流程
例如 Redis TTL 可以做成:
Duration base = Duration.ofMinutes(5);
long randomSeconds = ThreadLocalRandom.current().nextLong(30, 90);
Duration ttl = base.plusSeconds(randomSeconds);
坑 6:序列化报错或 class 变更后反序列化失败
表现
- Redis 里有历史数据
- 应用升级后读取时报类型转换异常
- 多模块/多服务读同一个缓存 key 时结构不一致
解决建议
- 尽量用 JSON 序列化
- 缓存 value 使用稳定 DTO,不直接暴露复杂领域对象
- 升级时考虑 cache namespace 版本化,比如
product:v2:1
安全/性能最佳实践
这部分是我比较建议真正带到项目里的。
1. 缓存 Key 要规范化
建议统一格式:
业务前缀:实体类型:主键[:版本]
例如:
mall:product:1
mall:product:1:v2
好处:
- 便于排查
- 便于批量治理
- 降低 key 冲突风险
2. 本地缓存不要无脑开太大
Caffeine 很快,但不是不要钱。
如果你把本地缓存设得过大,会遇到:
- JVM 堆膨胀
- Full GC 变频繁
- 热点数据和冷数据混在一起,命中收益不明显
建议按业务热点规模来配:
- 明确 key 数量级
- 配
maximumSize - 打开
recordStats()观察命中率
3. TTL 不要拍脑袋设
可以按数据类型分层:
- 用户基础信息:5~30 分钟
- 商品详情:1~10 分钟
- 配置类数据:30 秒~5 分钟
- 超热点数据:逻辑过期 + 异步刷新
一个实用原则:
更新越频繁,TTL 越短;重算成本越高,TTL 可以适当拉长。
4. 热点 key 要重点保护
如果某个 key 占了 30% 以上流量,不要把它当普通缓存处理。
建议:
- 单独设置本地缓存策略
- 不和普通 key 使用同一 TTL
- 必要时预热
- 必要时增加请求合并与异步刷新
5. 不要缓存敏感数据明文
如果缓存里有这些内容,就要格外小心:
- 手机号
- 身份证号
- token / session
- 支付相关信息
建议:
- 敏感字段脱敏后再缓存
- Redis 做访问控制和网络隔离
- 禁止把调试用 key 打进公共日志
- 设置合理 TTL,避免敏感数据长期驻留
6. 为缓存建立可观测性
如果你上线后只能看到“接口变慢了”,那排查会非常痛苦。
至少建议监控这些指标:
- 本地缓存命中率
- Redis 命中率
- Redis 慢查询 / 网络耗时
- DB 回源次数
- 热点 key TOP N
- 缓存删除失败次数
- 缓存重建耗时
我自己的经验是:
没有命中率和回源率监控,优化缓存基本靠猜。
7. 更新链路要保证“先 DB 后删缓存”
这是底线。
错误示范:
先删缓存 -> 再更新DB
如果删完缓存、DB 更新还没完成,这段窗口内新请求会把旧值重新查出来并写回缓存。
正确顺序:
先更新DB -> 再删缓存
如果你担心删缓存失败,可以进一步:
- 重试删除
- 记录失败日志并告警
- 用消息队列补偿
- 做延迟双删
8. 延迟双删适合哪些场景?
延迟双删的思路是:
- 更新 DB
- 删除缓存
- sleep 一小段时间
- 再删一次缓存
它主要用来缓解“旧值并发回填”的问题。
但也别神化它,它不是银弹。
如果你系统延迟波动很大、链路复杂,单纯 sleep 50ms/100ms 未必能完全兜住。
更稳的方案通常是:
- MQ 异步失效
- 版本号机制
- 热点 key 互斥加载
方案取舍分析
多级缓存不是所有系统都必须上,这里简单做个取舍。
只用 Redis 的适用场景
- 应用实例不多
- 热点不极端
- 读流量中等
- 一致性要求比性能更重要
优点:
- 结构简单
- 容易维护
- 一致性问题相对少一点
缺点:
- Redis 压力会集中
- 网络开销不可避免
本地缓存 + Redis 的适用场景
- 多实例部署
- 热点 key 明显
- 追求极低延迟
- Redis 成本或压力需要进一步控制
优点:
- 性能更高
- Redis 压力更低
- 热点抗压更强
缺点:
- 一致性更复杂
- 排障难度更高
不建议上多级缓存的场景
- 数据强一致要求极高
- 更新频繁、读取收益不明显
- 团队缺少缓存治理经验
- 缺少监控、告警、压测能力
一句话总结就是:
缓存不是越多级越高级,而是越适合业务越高级。
一个更稳的落地建议
如果你准备在真实项目落地,我建议分三步走,而不是一口气把所有高级策略都堆上:
阶段 1:先用 Redis 单层缓存
先解决数据库压力问题,完成:
- key 规范
- TTL 设计
- 空值缓存
- 基本监控
阶段 2:给热点接口加本地缓存
仅对少量热点接口启用 Caffeine + Redis 多级缓存,重点观察:
- 命中率变化
- Redis QPS 下降幅度
- 更新后一致性问题
阶段 3:做失效广播与热点治理
当多实例下开始出现旧值问题,再上:
- Redis Pub/Sub 或 MQ 广播失效
- 延迟双删
- 逻辑过期
- 版本控制
这样演进成本最低,也最不容易把系统搞复杂。
总结
这篇文章我们做了几件关键的事:
- 用 Spring Cache 统一缓存入口
- 用 Caffeine 做一级本地缓存
- 用 Redis 做二级共享缓存
- 通过自定义
MultiLevelCache实现“先本地、再 Redis、最后 DB” - 用 先更新 DB,再删除缓存 的方式处理更新一致性
- 分析了多实例下本地缓存脏数据问题,并给出 Redis Pub/Sub 广播失效 的思路
- 总结了击穿、穿透、雪崩、旧值回填等常见坑及排查方法
如果你只记住三条,我建议是这三条:
- 多级缓存的核心不是“快”,而是“快的同时可控”。
- 更新链路优先用删缓存,不要迷信直接改缓存。
- 多实例本地缓存一定要考虑失效通知,否则早晚会踩一致性坑。
最后给几个可执行建议,方便你直接带回项目:
- 读多写少的热点接口,优先考虑多级缓存
- 先从单层 Redis 开始,再逐步演进到本地 + Redis
- 本地缓存 TTL 设短一点,Redis TTL 设长一点
- 给热点 key 做单独策略,不要一刀切
- 上线前一定压测“缓存失效瞬间”的流量峰值
- 没有监控就不要轻易做复杂缓存
如果你的业务是商品详情、首页聚合、活动配置、字典数据这类场景,这套方案通常非常实用。
但如果是余额、库存、订单状态这种强一致核心链路,请谨慎评估,必要时宁可少缓存,也不要把一致性赌在线上事故上。