Spring Boot 中基于 Spring Cache + Redis 的多级缓存设计与实战优化
在 Spring Boot 项目里,很多同学第一次做缓存,往往是“先把 Redis 接上再说”。这样做短期能扛住一部分读压力,但系统一旦进入高并发、热点数据、复杂失效、实例横向扩容这些场景,单级 Redis 很快就会暴露出几个问题:
- 应用每次都要走网络访问 Redis,延迟还是不够低
- Redis 扛住了数据库,却自己成了新的热点
- 本地缓存和分布式缓存一混用,数据一致性开始变得“玄学”
- Spring Cache 用起来很方便,但一到多级缓存,就容易写成“表面优雅,实际难控”
这篇文章我从架构设计的角度,带你把一个基于 Spring Cache + Redis 的多级缓存方案搭起来,并重点讲清楚:
- 为什么需要多级缓存
- Spring Cache 在其中扮演什么角色
- 本地缓存 + Redis 如何协同
- 实战中如何处理一致性、穿透、击穿、雪崩
- 落地时有哪些常见坑,怎么排查
如果你已经会 Spring Boot、Redis、Spring Cache 的基础注解,这篇内容会比较适合你。
背景与问题
先看一个典型场景:商品详情查询。
请求链路通常是:
- 用户请求商品详情
- 应用先查缓存
- 缓存没有,再查数据库
- 查到后回填缓存
单机、低并发阶段,这么干没问题。但一旦有以下情况,单级缓存就不够用了:
1. 热点 Key 访问量极高
比如首页爆款商品、活动配置、用户权限数据,这类数据会被反复读取。
如果每次都去 Redis,即便 Redis 很快,也仍然有:
- 网络开销
- 序列化/反序列化开销
- Redis 连接池竞争
当 QPS 上来后,这些成本会被无限放大。
2. 应用实例增多,缓存失效逻辑变复杂
你加了本地缓存后,请求更快了,但问题马上变成:
- A 实例更新了数据
- B、C、D 实例的本地缓存怎么同步失效?
如果没有一个可靠的失效通知机制,本地缓存迟早脏。
3. Spring Cache 默认更偏“单层缓存抽象”
Spring Cache 的优势是统一注解、降低样板代码,但它本身并不天然等于“多级缓存架构”。
换句话说:
@Cacheable很方便- 但多级缓存的“先查本地,再查 Redis,再查 DB,再回填多层,并广播失效”这些逻辑,需要你自己补齐
所以,多级缓存不是“多配一个依赖”就完成的,而是一个缓存体系设计问题。
方案对比与取舍分析
在进入实现前,我先把几种常见方案摆出来。做架构时,先知道为什么选它,比直接贴代码更重要。
方案一:只用 Redis
优点:
- 实现最简单
- 数据共享天然支持集群
- 一致性相对容易控制
缺点:
- 所有读请求都经过网络
- 热点访问下 Redis 压力大
- 延迟不如 JVM 本地缓存稳定
适用:
- 中小流量
- 读性能要求没那么极致
- 数据一致性要求高于极致性能
方案二:只用本地缓存
优点:
- 访问速度最快
- 无网络开销
- 对热点 Key 极其友好
缺点:
- 多实例之间数据难同步
- 重启即丢
- 容量受 JVM 内存限制
- 不适合作为全局共享缓存
适用:
- 配置类、字典类、极低变更频率的数据
- 单实例或一致性要求不高的场景
方案三:本地缓存 + Redis 多级缓存
优点:
- 热点请求优先命中本地缓存,延迟低
- Redis 作为分布式共享缓存,弥补本地缓存一致性和容量不足
- 更适合高并发读多写少业务
缺点:
- 实现复杂度上升
- 需要处理多实例本地缓存失效同步
- 调试和监控要求更高
适用:
- 读多写少
- 热点明显
- 应用多实例部署
- 对性能与可控性都有要求
推荐取舍
如果你的业务是:
- 商品详情
- 店铺信息
- 用户画像只读视图
- 配置中心读缓存
- 权限菜单快照
那多级缓存通常是值得做的。
如果你的业务是:
- 高频写入
- 强一致交易状态
- 库存扣减
- 资金类数据
那缓存最多做辅助,不能把多级缓存当核心方案。
核心原理
我们先统一一下本文的多级缓存模型:
- 一级缓存(L1):应用本地缓存,使用 Caffeine
- 二级缓存(L2):Redis 分布式缓存
- 数据源:MySQL 或其他持久化存储
整体访问流程如下。
flowchart TD
A[请求进入] --> B{L1 本地缓存命中?}
B -- 是 --> C[直接返回]
B -- 否 --> D{L2 Redis 命中?}
D -- 是 --> E[写入 L1]
E --> C
D -- 否 --> F[查询数据库]
F --> G[写入 Redis]
G --> H[写入 L1]
H --> C
这个流程看起来很标准,但真正难点不在查,而在写和失效。
缓存更新的关键原则
在业务更新时,常见做法是:
- 先更新数据库
- 再删除缓存,而不是直接更新缓存
原因很现实:
- 更新数据库通常是事实来源
- 直接更新多级缓存容易漏掉某一级
- 删除缓存可以让后续读请求重新加载最新值
多级缓存下,删除也不是只删 Redis,而是:
- 删除 Redis 缓存
- 通知所有应用实例删除对应本地缓存
这就是我们常说的缓存失效广播。
一致性策略
这里先说结论:
多级缓存很难做到严格强一致,一般追求最终一致 + 可控延迟。
常见策略:
- Cache Aside(旁路缓存):最常见
- 读:查缓存,miss 查库并回填
- 写:先更新库,再删缓存
- 消息通知失效:
- 更新后发 MQ / Redis PubSub 通知各实例清理 L1
- 短 TTL + 主动删除结合:
- 避免极端情况下脏数据长期存在
Spring Cache 在方案中的位置
Spring Cache 最适合作为:
- 统一缓存编程模型
- 统一 key 管理
- 注解化缓存读写
- 统一接入监控和扩展
但多级缓存落地时,我更建议你把它理解为:
“一个缓存门面,而不是完整的多级缓存治理系统”
也就是说:
@Cacheable负责声明式读缓存- 自定义
CacheManager/Cache负责实现多级逻辑 - 失效通知、热点保护、TTL 策略、空值缓存等能力,由你在底层补齐
多级缓存架构设计
下面给出一个比较实用的组件划分。
classDiagram
class ProductController {
+getById(Long id)
+update(Product product)
}
class ProductService {
+getProduct(Long id)
+updateProduct(Product product)
}
class MultiLevelCache {
+get(key)
+put(key, value)
+evict(key)
}
class CaffeineCache {
+getIfPresent(key)
+put(key, value)
+invalidate(key)
}
class RedisCache {
+get(key)
+set(key, value, ttl)
+delete(key)
}
class CacheInvalidationPublisher {
+publish(cacheName, key)
}
class CacheInvalidationSubscriber {
+onMessage(message)
}
ProductController --> ProductService
ProductService --> MultiLevelCache
MultiLevelCache --> CaffeineCache
MultiLevelCache --> RedisCache
ProductService --> CacheInvalidationPublisher
CacheInvalidationSubscriber --> CaffeineCache
这个设计的重点是:
- 读路径:L1 -> L2 -> DB
- 写路径:DB -> 删除 L2 -> 广播删除 L1
- 广播机制:Redis Pub/Sub 或 MQ 都可以
为什么不建议“更新缓存值”而优先“删除缓存”
我自己在业务里踩过一个坑:
更新商品信息时,代码里同步去更新了 Redis,但忘了删部分实例中的本地缓存,结果线上偶发读到旧数据。这个问题非常隐蔽,因为不是所有实例都有问题。
所以对于多级缓存,更稳妥的方式一般是:
- 更新数据库
- 删除 Redis
- 广播本地缓存失效
- 让下一次读请求重新构建缓存
这套方式虽然看起来“多走一步”,但在复杂系统里可维护性更高。
实战代码(可运行)
下面给出一个可运行的 Spring Boot 示例思路。
技术栈:
- Spring Boot
- Spring Cache
- Redis
- Caffeine
- Jackson
为了把重点放在多级缓存逻辑上,示例用内存仓库模拟数据库,你替换成 JPA / MyBatis 都可以。
1. 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>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
2. 配置文件
spring:
redis:
host: localhost
port: 6379
timeout: 3000ms
server:
port: 8080
3. 启动缓存能力
package com.example.cachedemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@EnableCaching
@SpringBootApplication
public class CacheDemoApplication {
public static void main(String[] args) {
SpringApplication.run(CacheDemoApplication.class, args);
}
}
4. 实体类
package com.example.cachedemo.model;
public class Product {
private Long id;
private String name;
private Long price;
public Product() {
}
public Product(Long id, String name, Long price) {
this.id = id;
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public Long getPrice() {
return price;
}
public void setId(Long id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(Long price) {
this.price = price;
}
}
5. 模拟 Repository
package com.example.cachedemo.repository;
import com.example.cachedemo.model.Product;
import org.springframework.stereotype.Repository;
import jakarta.annotation.PostConstruct;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class ProductRepository {
private final Map<Long, Product> store = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
store.put(1L, new Product(1L, "iPhone", 6999L));
store.put(2L, new Product(2L, "MacBook", 12999L));
}
public Product findById(Long id) {
return store.get(id);
}
public Product save(Product product) {
store.put(product.getId(), product);
return product;
}
}
6. 多级缓存配置
这里的关键是:实现一个自定义 CacheManager,让 Spring Cache 底层使用“Caffeine + Redis”组合缓存。
6.1 自定义 MultiLevelCache
package com.example.cachedemo.cache;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.Duration;
import java.util.concurrent.Callable;
public class MultiLevelCache implements Cache {
private final String name;
private final com.github.benmanes.caffeine.cache.Cache<Object, Object> localCache;
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final Duration ttl;
public MultiLevelCache(String name,
com.github.benmanes.caffeine.cache.Cache<Object, Object> localCache,
StringRedisTemplate redisTemplate,
ObjectMapper objectMapper,
Duration ttl) {
this.name = name;
this.localCache = localCache;
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
this.ttl = ttl;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
private String buildRedisKey(Object key) {
return name + "::" + key;
}
@Override
public ValueWrapper get(Object key) {
Object localValue = localCache.getIfPresent(key);
if (localValue != null) {
return new SimpleValueWrapper(localValue);
}
String redisValue = redisTemplate.opsForValue().get(buildRedisKey(key));
if (redisValue != null) {
try {
Object value = objectMapper.readValue(redisValue, Object.class);
localCache.put(key, value);
return new SimpleValueWrapper(value);
} catch (JsonProcessingException e) {
throw new RuntimeException("Redis value deserialize error", e);
}
}
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();
}
try {
T value = valueLoader.call();
if (value != null) {
put(key, value);
}
return value;
} catch (Exception e) {
throw new ValueRetrievalException(key, valueLoader, e);
}
}
@Override
public void put(Object key, Object value) {
localCache.put(key, value);
try {
String json = objectMapper.writeValueAsString(value);
redisTemplate.opsForValue().set(buildRedisKey(key), json, ttl);
} catch (JsonProcessingException e) {
throw new RuntimeException("Redis value serialize error", e);
}
}
@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(buildRedisKey(key));
}
@Override
public void clear() {
localCache.invalidateAll();
}
}
6.2 自定义 CacheManager
package com.example.cachedemo.cache;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class MultiLevelCacheManager implements CacheManager {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
public MultiLevelCacheManager(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, n -> new MultiLevelCache(
n,
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofSeconds(30))
.build(),
redisTemplate,
objectMapper,
Duration.ofMinutes(5)
));
}
@Override
public Collection<String> getCacheNames() {
return Collections.unmodifiableSet(cacheMap.keySet());
}
}
6.3 注册配置
package com.example.cachedemo.config;
import com.example.cachedemo.cache.MultiLevelCacheManager;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
return new MultiLevelCacheManager(redisTemplate, objectMapper);
}
}
7. 业务 Service
这里使用 @Cacheable 和 @CacheEvict 演示读写路径。
package com.example.cachedemo.service;
import com.example.cachedemo.model.Product;
import com.example.cachedemo.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 productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product getProduct(Long id) {
System.out.println("load from repository, id=" + id);
return productRepository.findById(id);
}
@CacheEvict(cacheNames = "product", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
}
8. Controller
package com.example.cachedemo.controller;
import com.example.cachedemo.model.Product;
import com.example.cachedemo.service.ProductService;
import org.springframework.web.bind.annotation.*;
@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.getProduct(id);
}
@PutMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody Product product) {
product.setId(id);
return productService.updateProduct(product);
}
}
9. 运行验证
第一次查询
curl http://localhost:8080/products/1
控制台会看到:
load from repository, id=1
说明首次查库并回填缓存。
第二次查询
curl http://localhost:8080/products/1
控制台没有查库日志,表示命中缓存。
如果是同一实例下的连续请求,先命中本地缓存;本地失效后,还能从 Redis 补回来。
更新后再查询
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"iPhone 15","price":7999}'
然后再次查询:
curl http://localhost:8080/products/1
会重新查 repository,并回填新值。
多实例下的本地缓存失效同步
上面的代码已经能跑,但它还缺最后一块拼图:多实例本地缓存同步失效。
因为 @CacheEvict 当前只会清理当前实例里的本地缓存和 Redis,其他实例的 L1 还可能保留旧数据。
实际生产中,一般会增加“失效消息广播”。
一种常见实现:Redis Pub/Sub
流程如下:
sequenceDiagram
participant Client as 客户端
participant AppA as 应用A
participant DB as 数据库
participant Redis as Redis
participant AppB as 应用B
participant AppC as 应用C
Client->>AppA: 更新商品
AppA->>DB: 更新数据库
AppA->>Redis: 删除 L2 缓存
AppA->>Redis: 发布失效消息
Redis-->>AppB: 订阅到失效消息
Redis-->>AppC: 订阅到失效消息
AppB->>AppB: 删除本地 L1
AppC->>AppC: 删除本地 L1
失效消息对象
package com.example.cachedemo.cache;
public class CacheMessage {
private String cacheName;
private String key;
public CacheMessage() {
}
public CacheMessage(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.cachedemo.cache;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@Component
public class CacheInvalidPublisher {
private static final String CHANNEL = "cache:evict";
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
public CacheInvalidPublisher(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
}
public void publish(String cacheName, Object key) {
try {
String msg = objectMapper.writeValueAsString(new CacheMessage(cacheName, String.valueOf(key)));
redisTemplate.convertAndSend(CHANNEL, msg);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
订阅消息并清理本地缓存
这里为了简单,我们直接暴露一个本地缓存清理入口。
package com.example.cachedemo.cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;
@Component
public class LocalCacheEvictor {
private final CacheManager cacheManager;
public LocalCacheEvictor(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
public void evictLocal(String cacheName, String key) {
if (cacheManager.getCache(cacheName) instanceof MultiLevelCache cache) {
cache.evict(key);
}
}
}
严格来说,上面的
evict会同时删 Redis 和本地,实际生产里最好区分evictLocalOnly和evictAll,避免重复删除和广播回环。这里是为了示意整体思路。
更推荐的生产做法
在生产环境里,我建议你把 MultiLevelCache 再补两个能力:
evictLocalOnly(key):只删本地evictRemoteAndLocal(key):删 Redis + 本地
这样可以避免:
- 收到订阅消息后再次删除 Redis
- 形成重复广播
- 排查链路变复杂
容量估算与 TTL 设计
多级缓存要稳定,不能只写代码,还要做一点容量估算。
本地缓存大小怎么定
可以先用这个思路估算:
本地缓存容量 ≈ 热点 Key 数量 × 单对象平均大小 × 安全系数
例如:
- 热点商品 5000 个
- 每个对象序列化后约 2KB
- 安全系数取 1.5
那么大概需要:
5000 × 2KB × 1.5 ≈ 15MB
对单个 JVM 来说,这个量通常可接受。
但别忘了:
- Java 对象在堆内存中通常比序列化体积更大
- 如果缓存对象嵌套多、字段多,实际内存占用会明显放大
所以我自己的经验是:
- 本地缓存尽量缓存读模型/轻量 DTO
- 不要直接缓存庞大的 ORM 实体和复杂对象图
TTL 怎么配
一般建议:
- L1 本地缓存 TTL < L2 Redis TTL
- 比如:
- L1:30 秒
- L2:5 分钟
这样做的好处是:
- 本地缓存更快淘汰,降低脏数据停留时间
- Redis 继续承担跨实例共享缓存角色
- 热点数据仍然有较高命中率
常见经验值:
| 数据类型 | L1 TTL | L2 TTL |
|---|---|---|
| 商品详情 | 30s ~ 60s | 5min ~ 15min |
| 配置字典 | 1min ~ 5min | 10min ~ 1h |
| 用户权限快照 | 10s ~ 30s | 1min ~ 5min |
不要迷信统一 TTL。
更合理的做法是按业务特征分 cacheName 配置不同 TTL。
常见坑与排查
这一部分非常重要。很多缓存问题,不是“不会写”,而是“出了问题不知道从哪查”。
1. @Cacheable 不生效
这是 Spring Cache 经典问题。
常见原因:
- 没加
@EnableCaching - 方法是
private - 同类内部调用,绕过了代理
- 方法不是 Spring Bean 管理的对象
例如:
@Service
public class ProductService {
public Product outer(Long id) {
return inner(id); // 同类调用,缓存可能不生效
}
@Cacheable(cacheNames = "product", key = "#id")
public Product inner(Long id) {
return load(id);
}
private Product load(Long id) {
return null;
}
}
排查建议:
- 先确认方法有没有被 Spring AOP 代理
- 把缓存方法放到独立 Service 中
- 打开 debug 日志看缓存拦截器是否执行
2. 序列化类型丢失
如果 Redis 里统一存 JSON,而你在反序列化时直接 Object.class,复杂对象很容易变成 LinkedHashMap。
比如本文示例里,为了演示简单用了:
objectMapper.readValue(redisValue, Object.class)
生产环境更好的方式:
- 按缓存类型保留目标 Class
- 或者封装统一序列化器
- 或者直接使用 Spring Data Redis 的
GenericJackson2JsonRedisSerializer
否则你会遇到:
- 取出来不是目标对象
- 强转报错
- 嵌套对象字段丢失类型信息
3. 本地缓存与 Redis Key 类型不一致
有时本地缓存用的是 Long 键,Redis 广播消息里传的是字符串 "1",结果删本地缓存时删不掉。
这类问题很隐蔽,现象通常是:
- Redis 已删除
- 某些实例本地仍返回旧值
排查建议:
- 统一 key 生成规则
- 统一将 key 序列化为字符串
- 本地缓存和远程缓存都使用同一种 key 表达形式
4. 空值缓存缺失导致缓存穿透
如果某个 id 根本不存在,而你不缓存 null,那么每次请求都要打数据库。
处理方案:
- 缓存空对象或特殊标记
- TTL 设置短一点,比如 30 秒到 1 分钟
Spring Cache 可以通过 unless = "#result == null" 控制是否缓存 null。
但在防穿透场景下,有时你反而需要“显式缓存空值”。
所以这里没有绝对标准,要看业务:
- 数据新增频率很低:可以缓存空值
- 数据可能很快创建:空值 TTL 就要更短
5. 缓存雪崩
大量 Key 在同一时间过期,瞬间全部回源数据库。
解决方案:
- TTL 加随机值
- 热点数据永不过期 + 后台异步刷新
- 多级缓存分担 Redis 和 DB 压力
- 增加限流和降级
例如:
基础 TTL = 300s
随机抖动 = 0 ~ 60s
实际 TTL = 300s + random(60)
6. 热点 Key 击穿
一个超热点 Key 突然过期,大量请求同时回源。
处理方式:
- 单飞机制(single flight)
- 分布式锁
- 逻辑过期 + 异步重建
- 热点数据预热
如果你的热点很集中,我建议不要只靠 Spring Cache 默认行为。
最好对“缓存 miss 后的加载过程”做并发保护。
7. 监控缺失,出了问题只能猜
很多团队做了缓存,却没有下面这些指标:
- L1 命中率
- L2 命中率
- DB 回源次数
- Redis 平均耗时
- 缓存重建次数
- 缓存删除广播成功率
没有这些指标,线上出现“数据库突然被打高”“某些接口慢了”时,几乎只能靠猜。
我的建议是:
做多级缓存,就顺手把观测能力一起做掉。
安全/性能最佳实践
这一节给你一些我认为真正能落地的建议。
1. 缓存的数据要做分层,不要什么都往里塞
优先缓存这些数据:
- 读多写少
- 查询代价高
- 热点明显
- 可接受短暂不一致
谨慎缓存这些数据:
- 强一致交易状态
- 高并发写入数据
- 涉及权限边界的敏感动态数据
2. 本地缓存不要缓存过大对象
过大的对象会带来:
- GC 压力
- 堆内存抖动
- 命中虽高但整体吞吐下降
建议:
- 缓存 DTO,而不是完整聚合对象
- 去掉无用字段
- 控制嵌套深度
3. 缓存 Key 要可读、稳定、可约束
推荐格式:
业务前缀:数据类型:业务主键[:扩展维度]
例如:
product:detail:1001
user:permission:20001
shop:config:12
如果是 Spring Cache 的 cacheName::key 模式,也建议 key 本身不要拼得太随意。
4. Redis 必须设置超时与连接池参数
生产里最怕 Redis 抖动把应用线程拖死。
至少要配置:
- 连接超时
- 读写超时
- 连接池大小
- 最大等待时间
否则在高峰期,应用可能并不是“缓存慢”,而是“线程在等 Redis 连接”。
5. 删除缓存优先于更新缓存值的复杂联动
对于多级缓存:
- 优先考虑“更新 DB + 删除缓存”
- 少做“更新 DB + 精确更新多层缓存值”
后者看似更快,实则更容易把系统带进不一致泥潭。
6. 对热点数据做预热
系统刚启动时,本地缓存为空,如果热点请求瞬间涌入,会对 Redis 和 DB 形成冲击。
可以做:
- 启动后预热热点数据到 Redis
- 应用实例启动时加载核心热点到 L1
- 大促前预加载活动商品
7. 做好权限与敏感信息隔离
有些数据不适合直接缓存原始对象,比如:
- 用户手机号
- Token
- 敏感配置
- 隐私字段
建议:
- 缓存脱敏后的只读视图
- Redis 开启访问控制和网络隔离
- 不在缓存中落敏感明文
8. 为缓存失败设计兜底路径
缓存系统不是绝对可靠的。
你需要明确:
- Redis 挂了,应用是否允许直接查库?
- 查库后是否要限流?
- 是否要返回降级数据?
一个成熟的方案,不是“缓存永远命中”,而是“缓存失效时系统也不会崩”。
一个更稳妥的生产级演进方向
如果你准备把这套方案真正上生产,我建议按下面路径演进:
第一步:先做单级 Redis + Spring Cache
目标:
- 跑通注解式缓存
- 统一 key 和 TTL
- 补齐监控
第二步:增加 Caffeine 本地缓存
目标:
- 吃掉热点请求
- 降低 Redis 压力
- 观测 L1/L2 命中率
第三步:接入失效广播
目标:
- 多实例数据最终一致
- 降低本地脏数据风险
第四步:补齐高并发保护
目标:
- 防击穿
- 防雪崩
- 防穿透
- 加限流、空值缓存、TTL 抖动
第五步:分业务精细化治理
目标:
- 不同 cacheName 独立配置 TTL、容量、序列化方式
- 区分热点缓存、短期缓存、空值缓存
- 接入指标平台做持续优化
这个路径的好处是:
不会一开始就把缓存系统做得过重,而是随着业务复杂度逐步增强。
总结
基于 Spring Cache + Redis 做多级缓存,真正的价值不在“把两个缓存拼在一起”,而在于建立一套兼顾性能、可维护性和可观测性的缓存体系。
你可以把全文压缩成这几条核心原则:
- L1 用本地缓存,L2 用 Redis,热点请求优先打本地
- 读走 Cache Aside,写优先更新 DB 后删除缓存
- 多实例必须有本地缓存失效广播机制
- L1 TTL 要短于 L2 TTL,并加抖动防雪崩
- 不要迷信注解就能解决所有问题,Spring Cache 只是门面
- 把监控、容量、序列化、一致性一起设计进去
最后给几个可执行建议,方便你直接落地:
- 如果你现在只有 Redis,先别急着全面上多级缓存,先挑一个热点明显、读多写少的接口试点
- 本地缓存建议优先用 Caffeine
- 更新路径尽量采用删缓存而不是更新缓存值
- 业务上要接受“短时间最终一致”,不要把多级缓存用在强一致核心链路
- 上线前一定做压测,重点看:
- L1 命中率
- Redis QPS
- DB 回源峰值
- 更新后脏数据窗口时间
如果你把这些点都考虑到了,多级缓存不仅能“跑起来”,还能在高并发场景下真正稳住系统。