Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:一致性、穿透防护与性能调优
在业务规模刚起来的时候,很多系统的缓存方案都很“朴素”:Spring Cache + Redis,一把梭。
一开始确实够用,但等到接口 QPS 上来、热点数据集中、数据库偶发抖动时,就会发现只靠单级 Redis 缓存并不总是最优。
这篇文章我想带你从实战角度做一遍多级缓存:
- 一级缓存:应用内本地缓存(Caffeine)
- 二级缓存:Redis
- 统一接入:Spring Cache
- 重点解决:
- 缓存一致性
- 缓存穿透
- 热点 Key 与击穿
- 序列化与性能调优
- 常见坑排查
如果你已经会用 @Cacheable,那这篇文章会帮你把它从“能用”提升到“敢在线上用”。
一、背景与问题
先说一个典型场景。
比如商品详情接口:
- 读多写少
- 同一个商品会被高频访问
- 接口对 RT 比较敏感
- 底层数据库是 MySQL
很多项目的第一版实现是:
- Controller 调 Service
- Service 用
@Cacheable - 缓存落 Redis
- Redis miss 时查库并回填
这个方案没错,但到了中高并发场景,往往会暴露几个问题:
1. 单级 Redis 仍然有网络开销
Redis 再快,也有网络 IO、序列化、反序列化成本。
同一个热点数据如果每次都要跨网络取,延迟还是比本地内存高不少。
2. 热点 Key 击穿
某个热 Key 恰好过期,大量请求同时打到数据库。
如果数据库抗不住,链路就会抖。
3. 缓存穿透
查询一个根本不存在的数据,比如恶意传入不存在的商品 ID。
如果不做处理,缓存永远 miss,请求次次打数据库。
4. 缓存与数据库不一致
数据更新了,但缓存没有及时失效;
或者先删缓存再写库,期间并发读把旧值重新写回缓存。
5. 序列化问题
- JDK 序列化体积大
- JSON 序列化可能有类型丢失
- LocalDateTime、泛型对象常出坑
所以,多级缓存不是“为了炫技”,而是为了更稳、更快、更省数据库。
二、前置知识与环境准备
本文示例基于以下技术栈:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Redis
- 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-validation</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies>
application.yml
spring:
data:
redis:
host: localhost
port: 6379
timeout: 3000ms
cache:
type: none
server:
port: 8080
logging:
level:
org.springframework.cache: debug
这里我把 spring.cache.type 设成 none,原因很简单:
我们自己接管 CacheManager,避免 Spring Boot 自动配置干扰。
三、核心原理
多级缓存的基本思路是:
- L1 本地缓存(Caffeine):极低延迟,适合热点数据
- L2 Redis 缓存:多实例共享,容量更大
- DB:最终数据源
请求链路如下:
flowchart LR
A[请求进入] --> B{L1 Caffeine命中?}
B -- 是 --> C[直接返回]
B -- 否 --> D{L2 Redis命中?}
D -- 是 --> E[写入L1并返回]
D -- 否 --> F[查询DB]
F --> G[写入Redis]
G --> H[写入Caffeine]
H --> I[返回结果]
这个链路解决了两个关键问题:
- 热点数据尽量在 JVM 内存命中
- 多实例之间通过 Redis 保持一定的数据共享能力
但它也引入了新的挑战:一致性。
四、多级缓存一致性怎么理解
先讲结论:
多级缓存做不到绝对强一致,通常追求“最终一致 + 可接受窗口”。
最常见的更新策略有三种:
1. 先更新数据库,再删除缓存
这是线上最常用的策略。
流程:
sequenceDiagram
participant C as Client
participant S as Service
participant DB as MySQL
participant R as Redis
participant L1 as Caffeine
C->>S: 更新商品信息
S->>DB: update
DB-->>S: success
S->>R: delete cache
S->>L1: invalidate cache
S-->>C: 返回成功
优点:
- 简单
- 比“先删缓存再写库”更安全
问题:
- 在极端并发下,仍可能出现旧值短暂回填
2. 延迟双删
更新数据库后先删缓存,过一小段时间再删一次。
适用于:
- 写操作较少
- 对短暂不一致比较敏感
但它不是银弹,更多像“补丁式增强”。
3. 基于消息通知清理本地缓存
如果服务是集群部署,一个实例删了自己 JVM 内的 L1,其他实例的 L1 还在。
所以常见做法是:
- 先删 Redis
- 再通过 MQ / Redis PubSub 广播
- 各实例收到消息后清理本地缓存
flowchart TD
A[更新DB成功] --> B[删除Redis缓存]
B --> C[发送缓存失效消息]
C --> D[实例1清理L1]
C --> E[实例2清理L1]
C --> F[实例3清理L1]
这篇文章先聚焦单服务内可运行方案,集群广播我会在“最佳实践”里说明扩展方式。
五、实战代码:基于 Spring Cache + Caffeine + Redis 的多级缓存
下面我们实现一个可运行的商品查询示例。
5.1 定义实体与模拟数据库
package com.example.cache.model;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class Product implements Serializable {
private Long id;
private String name;
private BigDecimal price;
private Integer stock;
private LocalDateTime updateTime;
public Product() {
}
public Product(Long id, String name, BigDecimal price, Integer stock, LocalDateTime updateTime) {
this.id = id;
this.name = name;
this.price = price;
this.stock = stock;
this.updateTime = updateTime;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
public LocalDateTime getUpdateTime() {
return updateTime;
}
public void setUpdateTime(LocalDateTime updateTime) {
this.updateTime = updateTime;
}
}
package com.example.cache.repository;
import com.example.cache.model.Product;
import org.springframework.stereotype.Repository;
import jakarta.annotation.PostConstruct;
import java.math.BigDecimal;
import java.time.LocalDateTime;
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"), 100, LocalDateTime.now()));
db.put(2L, new Product(2L, "电竞鼠标", new BigDecimal("159.00"), 80, LocalDateTime.now()));
}
public Product findById(Long id) {
slowQuery();
return db.get(id);
}
public void update(Product product) {
product.setUpdateTime(LocalDateTime.now());
db.put(product.getId(), product);
}
private void slowQuery() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
这里我故意加了 Thread.sleep(200),方便你直观看到缓存命中后的性能差异。
5.2 自定义二级缓存管理器
Spring Cache 默认不会帮你做“多级缓存串联”,所以我们自己实现一个 Cache。
MultiLevelCache
package com.example.cache.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 localValue = caffeineCache.get(key);
if (localValue != null) {
return localValue;
}
ValueWrapper redisValue = redisCache.get(key);
if (redisValue != null) {
caffeineCache.put(key, redisValue.get());
return 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 (type != null && !type.isInstance(value)) {
throw new IllegalStateException("缓存值类型不匹配, key=" + key);
}
return (T) value;
}
@Override
public <T> T get(Object key, Callable<T> valueLoader) {
ValueWrapper 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) {
caffeineCache.put(key, value);
redisCache.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) {
caffeineCache.evict(key);
redisCache.evict(key);
}
@Override
public boolean evictIfPresent(Object key) {
boolean local = caffeineCache.evictIfPresent(key);
boolean remote = redisCache.evictIfPresent(key);
return local || remote;
}
@Override
public void clear() {
caffeineCache.clear();
redisCache.clear();
}
@Override
public boolean invalidate() {
boolean local = caffeineCache.invalidate();
boolean remote = redisCache.invalidate();
return local || remote;
}
}
这个实现的核心逻辑很直接:
- 读:先 Caffeine,后 Redis
- 写:同时写两级
- 删:同时删两级
对 tutorial 来说,这个版本够用,也好理解。
但我要先提醒一句:生产环境里,更新策略最好偏向“更新库后删缓存”,而不是业务写入时直接强依赖 cache put”。 这一点后面会讲。
5.3 自定义 CacheManager
package com.example.cache.cache;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class MultiLevelCacheManager implements CacheManager {
private final CacheManager caffeineCacheManager;
private final CacheManager redisCacheManager;
private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
public MultiLevelCacheManager(CacheManager caffeineCacheManager, CacheManager redisCacheManager) {
this.caffeineCacheManager = caffeineCacheManager;
this.redisCacheManager = redisCacheManager;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, key -> {
Cache caffeineCache = caffeineCacheManager.getCache(key);
Cache redisCache = redisCacheManager.getCache(key);
if (caffeineCache == null || redisCache == null) {
return null;
}
return new MultiLevelCache(key, caffeineCache, redisCache);
});
}
@Override
public Collection<String> getCacheNames() {
return redisCacheManager.getCacheNames();
}
}
5.4 缓存配置
package com.example.cache.config;
import com.example.cache.cache.MultiLevelCacheManager;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
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.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;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("product");
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(Duration.ofSeconds(30)));
return cacheManager;
}
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType(Object.class)
.build();
objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
RedisSerializationContext.SerializationPair<Object> valueSerializationPair =
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer(objectMapper)
);
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5))
.disableCachingNullValues()
.serializeValuesWith(valueSerializationPair)
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(configuration)
.initialCacheNames(java.util.Set.of("product"))
.build();
}
@Bean
public CacheManager cacheManager(CacheManager caffeineCacheManager, CacheManager redisCacheManager) {
return new MultiLevelCacheManager(caffeineCacheManager, redisCacheManager);
}
}
这里有几个点值得注意:
- 本地缓存 TTL 30 秒
- Redis TTL 5 分钟
- L1 TTL 一般比 L2 更短,避免本地脏数据停留过久
- Redis 使用
GenericJackson2JsonRedisSerializer,比 JDK 序列化更直观
5.5 Service:查询、更新、穿透防护
package com.example.cache.service;
import com.example.cache.model.Product;
import com.example.cache.repository.ProductRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@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) {
return productRepository.findById(id);
}
@CacheEvict(cacheNames = "product", key = "#id")
public Product updatePrice(Long id, BigDecimal newPrice) {
Product product = productRepository.findById(id);
if (product == null) {
return null;
}
product.setPrice(newPrice);
product.setUpdateTime(LocalDateTime.now());
productRepository.update(product);
return product;
}
}
这里我用了:
@Cacheable:读缓存@CacheEvict:更新后删缓存
为什么不是 @CachePut?
因为在大多数业务中,删缓存比更新缓存更稳妥。
原因是:
- 更新逻辑通常复杂
- 可能涉及多个缓存 Key
- 删缓存可以让后续读走“查库再回填”的标准路径
这是一个我自己比较偏爱的经验法则:
能删就别硬改,能统一回填就别多处写缓存。
5.6 Controller
package com.example.cache.controller;
import com.example.cache.model.Product;
import com.example.cache.service.ProductService;
import jakarta.validation.constraints.DecimalMin;
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 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 @NotNull @DecimalMin("0.01") BigDecimal price) {
return productService.updatePrice(id, price);
}
}
六、逐步验证清单
你可以按这个顺序自己跑一遍。
1. 第一次查询,走数据库
curl http://localhost:8080/products/1
预期:
- 接口响应较慢,约 200ms+
- Redis 出现缓存
- 本地缓存也已建立
2. 再次查询,同实例命中本地缓存
curl http://localhost:8080/products/1
预期:
- 响应明显变快
- 不再触发 repository 的慢查询
3. 更新数据后,缓存被清理
curl -X PUT "http://localhost:8080/products/1/price?price=399.00"
然后再查:
curl http://localhost:8080/products/1
预期:
- 第一次查询重新回源数据库
- 后续再次查询重新命中缓存
4. 查询不存在数据,观察穿透问题
curl http://localhost:8080/products/999
因为我们当前配置里 unless = "#result == null",空值不会缓存。
这在“正常业务”里可能没问题,但在恶意扫描场景里就会形成缓存穿透。
下面我们专门处理它。
七、缓存穿透防护:空对象缓存 + 参数校验
缓存穿透最常见的解决方式:
- 参数校验
- 缓存空值
- 布隆过滤器(适用于大规模 ID 查询)
7.1 参数校验先挡一层
比如商品 ID 不允许小于等于 0。
@GetMapping("/{id}")
public Product getById(@PathVariable @jakarta.validation.constraints.Min(1) Long id) {
return productService.getById(id);
}
这个简单,但很有效。
我见过不少系统被“无效 ID 扫描”打到 DB 飙升,结果加上参数边界校验后,噪音请求直接少一大截。
7.2 空对象缓存
如果你的业务允许,可以缓存“空结果”,TTL 设短一点,比如 30 秒。
思路有两种:
- Spring Cache 直接允许缓存 null
- 返回一个特殊空对象占位
Spring Cache 对 null 的处理不总是直观,我更建议用特殊对象占位。
定义空对象标记
package com.example.cache.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class ProductNullValue extends Product {
public ProductNullValue() {
super(-1L, "NULL", BigDecimal.ZERO, 0, LocalDateTime.MIN);
}
}
Service 中处理
package com.example.cache.service;
import com.example.cache.model.Product;
import com.example.cache.model.ProductNullValue;
import com.example.cache.repository.ProductRepository;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class ProductQueryService {
private final ProductRepository productRepository;
public ProductQueryService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(cacheNames = "product", key = "#id")
public Product getByIdWithNullCache(Long id) {
Product product = productRepository.findById(id);
return product == null ? new ProductNullValue() : product;
}
public Product unwrap(Product product) {
return product instanceof ProductNullValue ? null : product;
}
}
这种方式的优点是:
- 能挡住不存在数据的重复查询
- 逻辑明确,便于调试
缺点是:
- 业务层要处理占位对象
- 需要防止占位对象误传给前端
八、缓存击穿与热点 Key:怎么扛住瞬时并发
当某个热点 Key 失效时,多个线程同时回源 DB,就是典型的缓存击穿。
方案 1:使用 @Cacheable(sync = true)
对于单机内并发,这是最省事的办法。
@Cacheable(cacheNames = "product", key = "#id", sync = true, unless = "#result == null")
public Product getById(Long id) {
return productRepository.findById(id);
}
sync = true 的作用是:
- 同一 JVM 内,同一个 key 只让一个线程执行
valueLoader - 其他线程等待结果
这招我挺推荐,尤其是热点数据明显、且项目先要快速落地时。
但它也有边界:
- 只对当前实例有效
- 多实例部署下,仍然可能多个节点同时回源
方案 2:Redis 分布式锁
如果是多实例热点 Key,可以引入分布式锁。
不过这个方案复杂度会明显提升,包括:
- 锁续期
- 锁超时
- 异常释放
- 高并发下锁竞争
对大部分中级项目来说,我建议优先顺序是:
- 本地缓存
sync = true- TTL 加随机值
- 真有必要再引入分布式锁
九、常见坑与排查
这一节很重要,因为多级缓存“写起来不难,出问题很隐蔽”。
1. @Cacheable 不生效
常见原因:
- 没加
@EnableCaching - 方法不是
public - 同类内部调用,绕过了代理
- 异常被吃掉,导致看起来像缓存没生效
比如下面这种,缓存不会生效:
@Service
public class ProductService {
public Product outer(Long id) {
return inner(id);
}
@Cacheable(cacheNames = "product", key = "#id")
public Product inner(Long id) {
return ...
}
}
因为 outer() 调 inner() 是对象内部调用,没有经过 Spring AOP 代理。
排查建议:
- 开启
org.springframework.cachedebug 日志 - 打断点看是否进入
CacheInterceptor - 把缓存方法拆到独立 Bean 中
2. 序列化失败或反序列化类型错乱
典型报错:
Cannot deserialize valueLinkedHashMap cannot be cast to xxxLocalDateTime反序列化异常
排查重点:
- Redis 序列化器是否统一
- ObjectMapper 是否注册
JavaTimeModule - 是否启用了多态类型信息
如果你项目里多个服务共用同一批 Redis Key,还要特别注意:
- 不同服务用不同序列化格式会出问题
- Key 命名空间最好隔离,比如
app1:product:1
3. 本地缓存与 Redis 数据不一致
这是多级缓存最常见的问题之一。
场景:
- 实例 A 更新了数据并删 Redis
- 实例 A 删了自己的本地缓存
- 实例 B 的本地缓存还没删
- 实例 B 继续返回旧值
单机开发时感觉一切正常,一上集群就翻车,这个坑我见得很多。
解决思路:
- 用 MQ / Redis PubSub 通知各节点清理 L1
- 或让 L1 TTL 更短,控制脏数据窗口
- 对一致性极敏感的数据,不要放本地缓存
4. Key 设计混乱
错误示例:
@Cacheable(cacheNames = "product", key = "#root.args")
这样出来的 key 不可读、不可控、跨版本也容易变。
建议 key 设计:
- 简单、稳定、可观测
- 包含业务前缀和主键
- 避免直接使用对象整体序列化作为 key
例如:
@Cacheable(cacheNames = "product", key = "'product:' + #id")
如果你已经用了 cacheNames,再叠一层业务前缀会更清晰。
5. TTL 设置不合理
两个极端都不行:
- TTL 太短:频繁回源,缓存价值低
- TTL 太长:脏数据时间窗大
我的经验建议:
- L1 本地缓存:10~60 秒
- L2 Redis:1~10 分钟
- 空值缓存:30~120 秒
- 热点 Key:TTL 增加随机值,避免同时失效
十、安全/性能最佳实践
这一节给你一些更贴近线上环境的建议。
1. 不要缓存敏感数据明文
Redis 常被当成“内网组件”,但这不代表可以随便放敏感字段。
比如:
- 用户手机号
- 身份证号
- token 明文
- 支付相关信息
建议:
- 非必要不缓存
- 必要时脱敏或加密
- Redis 开启访问控制与网络隔离
2. 缓存对象尽量轻量
缓存不是对象仓库。
对象越大:
- 网络传输越慢
- 序列化越耗时
- Redis 内存占用越高
建议只缓存查询真正需要的字段,而不是整个聚合对象全塞进去。
比如商品详情页只需要:
- id
- name
- price
- stock
- updateTime
那就不要把一堆无关扩展字段一起缓存。
3. TTL 加随机抖动
防止大量 key 同时过期。
示例思路:
Duration base = Duration.ofMinutes(5);
long randomSeconds = java.util.concurrent.ThreadLocalRandom.current().nextLong(30, 120);
Duration finalTtl = base.plusSeconds(randomSeconds);
如果你的缓存是批量预热进去的,这一步特别重要。
4. 热点数据优先放 L1,本地缓存大小要可控
Caffeine 很快,但 JVM 堆不是无限的。
如果本地缓存无限长、无限大,最终会把 GC 压力拉起来。
建议至少配置:
maximumSizeexpireAfterWrite或expireAfterAccess- 指标监控 hit rate / eviction count
5. 对强一致数据设置“不过本地缓存”
并不是所有数据都适合多级缓存。
比如:
- 库存强一致
- 支付状态
- 秒杀资格
这些数据如果允许几秒旧值,就可能出事故。
建议:
- 只走 Redis
- 或干脆不走缓存
- 或走专门的一致性方案
多级缓存适合的是高频读、可容忍短暂旧值的数据。
6. 加监控,不要“盲用缓存”
至少监控这些指标:
- 缓存命中率
- Redis QPS
- Redis 网络耗时
- DB 回源次数
- 热 Key 分布
- 缓存大小与淘汰次数
- 接口 TP99 / TP999
没有监控的缓存优化,很容易变成“自我感觉优化”。
十一、一个更稳妥的生产落地建议
如果你准备真正上线,我建议按这个优先级实施,而不是一步到位堆复杂度。
第一阶段:先把单级缓存用稳
@Cacheable@CacheEvict- 合理 TTL
- 参数校验
- 空值缓存
- 命中率监控
第二阶段:引入 L1 本地缓存
适用于:
- 读非常频繁
- 同一个实例上的热点明显
- 接口 RT 敏感
第三阶段:解决集群一致性
- Redis PubSub 或 MQ 广播失效
- 对热点 key 做防击穿
- 对关键数据分类治理
这比一开始就上分布式锁、布隆过滤器、复杂一致性协议更现实。
十二、总结
我们这篇文章做了几件事:
- 用 Spring Cache 统一缓存访问入口
- 用 Caffeine + Redis 搭建多级缓存
- 解释了多级缓存的读写链路
- 落地了可运行代码
- 处理了缓存一致性、穿透、击穿与序列化问题
- 给出了线上可执行的调优建议
最后我给你一个实用结论,方便落地时做判断:
什么时候适合多级缓存?
适合:
- 读多写少
- 热点明显
- 可接受短暂最终一致
- 对接口 RT 敏感
不太适合:
- 强一致要求极高
- 数据更新非常频繁
- 本地缓存失效广播成本高
- 缓存对象特别大
我个人建议的默认组合
如果你问我一个中型 Spring Boot 项目,应该怎么起步,我会建议:
@Cacheable + Redis- 更新时优先“先更新库,再删缓存”
- 参数校验 + 空值缓存防穿透
- 热点查询加
sync = true - 高频热点再加 Caffeine
- 集群场景用消息机制清理 L1
这套方案不算花哨,但很实用,也比较容易维护。
如果你照着本文的代码先跑通,再逐步补上监控、广播失效和 TTL 抖动,基本就已经具备线上可用的多级缓存骨架了。