Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:设计、穿透击穿防护与一致性方案
在业务系统里,缓存几乎是“性能优化必答题”。但真正上线后,大家会发现问题并不只是“把数据放进 Redis”这么简单。
我自己做过几次缓存改造,最开始也走过一条很典型的路:
先上 Redis,压住数据库压力;随后发现单机访问频繁的热点数据,其实每次还要走一次网络;再后来又踩到缓存穿透、热点 Key 击穿、更新后短暂脏读这些坑。最后才慢慢把方案演化成:
- 一级缓存:应用内本地缓存
- 二级缓存:Redis 分布式缓存
- 回源:数据库
- 配套机制:空值缓存、互斥加载、逻辑过期/TTL、一致性删除
这篇文章不讲概念堆砌,而是从一个可运行的 Spring Boot 实战方案出发,把核心原理、代码落地、坑点排查和取舍分析一起讲清楚。
背景与问题
假设我们有一个商品查询接口:
- 商品详情读多写少
- 某些热点商品 QPS 很高
- 多个应用实例同时部署
- 数据更新后,希望尽快一致
- 不能因为缓存失效把数据库打挂
如果只使用单层 Redis,会碰到几个典型问题:
1. 网络开销依然存在
Redis 很快,但它仍然是远程调用。
对于“极热数据”,如果每次都走网络,RT 仍然高于本地内存缓存。
2. 缓存穿透
查询一个根本不存在的数据,比如恶意请求 id=-1、随机 ID。
缓存没命中,数据库也查不到,所有请求都打到 DB。
3. 缓存击穿
某个热点 Key 恰好过期,大量并发请求同时回源数据库。
这种情况很常见,尤其是详情页、配置项、首页聚合数据。
4. 缓存雪崩
大量 Key 在同一时间失效,回源流量瞬间冲到数据库。
如果再叠加促销高峰,DB 很容易顶不住。
5. 缓存一致性
更新数据库后,缓存什么时候删?先删缓存还是先写库?
这里不是“有没有标准答案”,而是要基于业务接受度做取舍。
方案目标与整体设计
这次我们采用一个中级项目里比较实用的组合:
- Spring Cache:统一缓存注解与缓存抽象
- Caffeine:本地一级缓存
- Redis:分布式二级缓存
- 自定义 CacheManager / Cache:实现多级缓存联动
- 空值缓存:防缓存穿透
- 本地锁/分布式锁简化版互斥加载:防击穿
- TTL 随机抖动:防雪崩
- 更新后删除缓存 + 可选消息通知:做一致性控制
架构图
flowchart LR
A[客户端请求] --> B[Spring Cache]
B --> C{一级缓存 Caffeine}
C -- 命中 --> R1[直接返回]
C -- 未命中 --> D{二级缓存 Redis}
D -- 命中 --> E[回填一级缓存]
E --> R2[返回结果]
D -- 未命中 --> F[查询数据库]
F --> G[写入 Redis]
G --> H[写入 Caffeine]
H --> R3[返回结果]
更新链路图
sequenceDiagram
participant C as Client
participant S as Service
participant DB as Database
participant R as Redis
participant L1 as Local Cache
C->>S: 更新商品
S->>DB: 先更新数据库
DB-->>S: 更新成功
S->>R: 删除 Redis 缓存
S->>L1: 删除本地缓存
S-->>C: 返回成功
核心原理
这一部分决定你后面写代码时会不会“只是跑起来”。
1. Spring Cache 在这里扮演什么角色
Spring Cache 本身不是缓存实现,它更像一层统一门面。
它帮我们把 @Cacheable、@CachePut、@CacheEvict 这些注解转成具体的缓存操作。
也就是说:
- Caffeine 可以接到 Spring Cache 下面
- Redis 也可以接到 Spring Cache 下面
- 多级缓存也可以包装成 Spring Cache 的一个实现
所以真正的关键,不是会不会写 @Cacheable,而是如何设计底层 Cache 的行为。
2. 多级缓存的读写策略
这里我们采用最常见也最稳的策略。
读策略
- 先查本地缓存(L1)
- L1 未命中,再查 Redis(L2)
- L2 命中,回填到 L1
- L2 也未命中,查数据库
- DB 结果写入 Redis 和 L1
这样做的好处:
- 热点数据优先走本地内存
- 多实例之间还能通过 Redis 共享缓存
- Redis 重启后,本地缓存仍可短暂兜底一部分流量
写策略
更新数据时:
- 先写数据库
- 再删 Redis
- 再删本地缓存
这是常用的Cache Aside 模式变种。
原因是:数据库是真实来源,缓存只是副本。
3. 穿透、击穿、雪崩的防护思路
缓存穿透:空值缓存 + 参数校验
如果数据不存在,可以缓存一个空对象或者特殊标记,TTL 设短一点,比如 1~5 分钟。
这样同样的“无效请求”不会持续打 DB。
缓存击穿:互斥加载
热点 Key 过期时,不要让所有线程同时回源。
典型做法:
- 单机场景:本地锁
- 分布式场景:Redis 分布式锁
本文示例为了清晰,先用按 Key 粒度的本地锁演示思路。
缓存雪崩:TTL 加随机值
不要把所有缓存都设置成整齐划一的 30 分钟。
建议:
- 基础 TTL + 随机偏移
- 比如
30min + random(0~5min)
这样能显著降低同一时刻集中失效的概率。
4. 一致性方案的边界
缓存一致性没有银弹,通常是“最终一致”。
为什么常说“更新数据库后删除缓存”
因为:
- 如果先删缓存再写数据库,期间有并发读请求,可能把旧值重新写回缓存
- 先写数据库再删缓存,旧值窗口会更短
但它也不是 100% 严格一致。
极端情况下仍然可能出现:
- 线程 A 更新 DB
- 线程 B 读旧缓存未命中,查到旧数据
- 线程 A 删除缓存
- 线程 B 把旧数据回填缓存
解决方式通常是增强而不是幻想绝对无误:
- 延迟双删
- 基于 MQ 的失效通知
- binlog 订阅做缓存清理
- 对强一致数据放弃缓存或缩小缓存范围
方案对比与取舍分析
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单 Redis 缓存 | 简单、部署快 | 热点场景仍有网络开销 | 中小系统、非极热读场景 |
| 本地缓存 + DB | 延迟低 | 多实例数据不一致明显 | 单机应用、配置类缓存 |
| 本地缓存 + Redis 多级缓存 | 性能好、可扩展、热点处理更稳 | 实现复杂度更高 | 读多写少、中高并发业务 |
| 旁路缓存 + MQ 通知 | 一致性更好 | 引入消息链路复杂度 | 多实例、大规模系统 |
我的经验是:
- 读多写少:优先多级缓存
- 写频繁、强一致要求高:缓存要保守,别“为了缓存而缓存”
- 热点极高:一定要有 L1,不然 Redis 也会成为瓶颈点
容量估算思路
做多级缓存前,建议至少粗估一下量,不然很容易“缓存比数据库还贵”。
Redis 容量估算
假设:
- 单个商品缓存平均 2 KB
- 计划缓存 50 万商品
- 预留 30% 冗余
估算:
2 KB * 500000 ≈ 1000 MB
预留后约 1.3 GB
再考虑 Redis 自身对象元数据、过期字典、序列化开销,实际需要再多留一些空间。
本地缓存估算
如果单实例缓存 2 万个热点商品:
2 KB * 20000 ≈ 40 MB
再算上对象包装和 JVM 开销,实际可能到 60~100 MB。
所以本地缓存不能无限大,要结合堆内存大小设置 maximumSize。
实战代码(可运行)
下面给一个精简但能跑通的示例。
使用:
- Spring Boot
- Spring Cache
- Caffeine
- Redis
- JPA + H2 演示数据库
说明:为了让代码聚焦核心逻辑,示例里没有把所有生产级细节都展开,比如 Redis Pub/Sub 失效通知、分布式锁重入等。
1. Maven 依赖
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>multi-cache-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.14</version>
</parent>
<properties>
<java.version>1.8</java.version>
</properties>
<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>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
</project>
2. 配置文件
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
redis:
host: 127.0.0.1
port: 6379
server:
port: 8080
3. 启动类
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);
}
}
4. 实体与 Repository
package com.example.multicache.entity;
import javax.persistence.Entity;
import javax.persistence.Id;
@Entity
public class Product {
@Id
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.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
}
5. 初始化测试数据
package com.example.multicache.init;
import com.example.multicache.entity.Product;
import com.example.multicache.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class DataInitializer implements CommandLineRunner {
private final ProductRepository productRepository;
public DataInitializer(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Override
public void run(String... args) {
productRepository.save(new Product(1L, "iPhone", 6999));
productRepository.save(new Product(2L, "MacBook", 12999));
}
}
6. Redis 序列化配置
这个地方非常重要。
如果不配,默认 JDK 序列化很容易让 Key/Value 不直观,也不利于排查。
package com.example.multicache.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.*;
import java.time.Duration;
@Configuration
public class RedisConfig {
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(mapper);
return RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(serializer))
.disableCachingNullValues()
.entryTtl(Duration.ofMinutes(30));
}
}
7. 多级缓存实现
这里是核心。我们实现一个 Cache,内部组合 Caffeine 和 Redis。
7.1 CacheManager
package com.example.multicache.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 Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
private final Cache redisCache;
private final com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache;
public MultiLevelCacheManager(Cache redisCache,
com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache) {
this.redisCache = redisCache;
this.caffeineCache = caffeineCache;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name,
n -> new MultiLevelCache(n, redisCache, caffeineCache));
}
@Override
public Collection<String> getCacheNames() {
return cacheMap.keySet();
}
}
7.2 Cache 实现
package com.example.multicache.cache;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
public class MultiLevelCache implements Cache {
private static final Object NULL_VALUE = new Object();
private static final Map<Object, Object> KEY_LOCKS = new ConcurrentHashMap<>();
private final String name;
private final Cache redisCache;
private final com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache;
public MultiLevelCache(String name,
Cache redisCache,
com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache) {
this.name = name;
this.redisCache = redisCache;
this.caffeineCache = caffeineCache;
}
private String buildKey(Object key) {
return name + "::" + key;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
@Override
public ValueWrapper get(Object key) {
String realKey = buildKey(key);
Object localValue = caffeineCache.getIfPresent(realKey);
if (localValue != null) {
if (localValue == NULL_VALUE) {
return new SimpleValueWrapper(null);
}
return new SimpleValueWrapper(localValue);
}
ValueWrapper redisValue = redisCache.get(realKey);
if (redisValue != null) {
Object value = redisValue.get();
caffeineCache.put(realKey, value == null ? NULL_VALUE : value);
return redisValue;
}
return null;
}
@Override
@SuppressWarnings("unchecked")
public <T> T get(Object key, Class<T> type) {
ValueWrapper wrapper = get(key);
return wrapper == null ? null : (T) wrapper.get();
}
@Override
@SuppressWarnings("unchecked")
public <T> T get(Object key, Callable<T> valueLoader) {
ValueWrapper wrapper = get(key);
if (wrapper != null) {
return (T) wrapper.get();
}
String realKey = buildKey(key);
Object lock = KEY_LOCKS.computeIfAbsent(realKey, k -> new Object());
synchronized (lock) {
wrapper = get(key);
if (wrapper != null) {
return (T) wrapper.get();
}
try {
T value = valueLoader.call();
if (value == null) {
put(key, null);
return null;
}
put(key, value);
return value;
} catch (Exception e) {
throw new RuntimeException("load cache value failed", e);
} finally {
KEY_LOCKS.remove(realKey);
}
}
}
@Override
public void put(Object key, Object value) {
String realKey = buildKey(key);
Object storeValue = value == null ? NULL_VALUE : value;
caffeineCache.put(realKey, storeValue);
redisCache.put(realKey, value);
}
@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) {
String realKey = buildKey(key);
caffeineCache.invalidate(realKey);
redisCache.evict(realKey);
}
@Override
public void clear() {
caffeineCache.invalidateAll();
redisCache.clear();
}
}
注意:这里为了配合 Spring Cache 的接口,示例里把空值在本地缓存中用
NULL_VALUE表示,而 Redis 侧仍然直接存null会受实现限制。
在生产里更推荐显式定义一个NullValue对象或单独封装 Redis 访问逻辑,避免不同缓存层的空值语义不一致。
8. 缓存配置类
package com.example.multicache.config;
import com.example.multicache.cache.MultiLevelCacheManager;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheManager;
import java.time.Duration;
@Configuration
public class CacheConfig {
@Bean
public com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
}
@Bean
public CacheManager cacheManager(RedisCacheManager redisCacheManager,
com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache) {
Cache redisCache = redisCacheManager.getCache("product");
return new MultiLevelCacheManager(redisCache, caffeineCache);
}
}
9. Service 层
这里体现两件事:
- 查询使用
@Cacheable - 更新使用“先写库,再删缓存”
package com.example.multicache.service;
import com.example.multicache.entity.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 javax.transaction.Transactional;
@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) {
simulateSlowQuery();
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 simulateSlowQuery() {
try {
Thread.sleep(300);
} catch (InterruptedException ignored) {
}
}
}
如果你要做“空值缓存”,这里的
unless = "#result == null"需要取消,改为由自定义缓存层显式存储空对象标记。
很多人卡在这里:注解层排除了 null,底层却想缓存 null,二者策略冲突。
10. Controller
package com.example.multicache.controller;
import com.example.multicache.entity.Product;
import com.example.multicache.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 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);
}
}
11. 验证步骤
第一次查询
curl http://localhost:8080/products/1
会慢一些,因为会查 DB。
第二次查询
curl http://localhost:8080/products/1
会明显更快,因为大概率直接命中本地缓存或 Redis。
更新后查询
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"iPhone Pro","price":7999}'
然后再查:
curl http://localhost:8080/products/1
会触发缓存失效后重新加载。
穿透与击穿增强实现建议
上面的代码能跑,但如果直接搬去生产,我会继续补下面两个点。
1. 空值缓存
对于不存在的数据,可以这样设计:
- Redis 中存一个特殊对象,比如
{"__null__":true} - TTL 设短一点,例如 2 分钟
- 本地缓存也同步存特殊对象
伪代码如下:
if (dbResult == null) {
redis.set(key, NULL_MARKER, 120s);
localCache.put(key, NULL_MARKER);
return null;
}
2. TTL 加随机值
比如写入 Redis 时:
int randomSeconds = ThreadLocalRandom.current().nextInt(0, 300);
Duration ttl = Duration.ofMinutes(30).plusSeconds(randomSeconds);
这样能减少集中过期。
3. 分布式锁替代本地锁
如果是多实例部署,仅靠 JVM 内的 synchronized 还不够。
应该用 Redis 锁、Redisson 或者基于 Lua 的安全释放锁方案。
常见坑与排查
这部分很重要,我自己排线上问题时,80% 时间都花在这。
1. @Cacheable 不生效
常见原因
- 方法不是
public - 同类内部调用,绕过了 Spring 代理
- 忘了加
@EnableCaching - Bean 没被 Spring 管理
排查方式
先看日志,再确认是否经过代理对象。
最直接的判断是:第一次调用和第二次调用执行时间是否一致。
2. 本地缓存和 Redis 键不一致
如果本地缓存 Key 是 product::1,Redis 用的却是别的格式,就会出现:
- Redis 明明有值
- 程序却总查不到
建议
统一 Key 生成规则,至少包含:
- cacheName
- 业务 key
- 必要时包含版本或租户信息
3. 空值缓存策略冲突
这个坑特别常见:
- 注解层
unless = "#result == null"不缓存 null - 自定义缓存层又想缓存 null
结果就是“你以为缓存了,其实没缓存”。
建议
空值缓存要么:
- 全部在注解层禁掉,底层不做
- 要么底层统一接管,注解层不要排除 null
不要混搭。
4. 更新后短暂读到旧数据
这不是一定是 Bug,而是缓存一致性的天然窗口。
关键是判断业务是否能接受。
排查路径
- 看更新时间线
- 看删缓存是否成功
- 看是否存在并发回填旧值
- 看是否有多个应用实例本地缓存未同步失效
5. Redis 序列化导致反序列化失败
如果你更换了类结构、包名、字段类型,旧缓存可能会读不出来。
建议
- 优先 JSON 序列化
- 不要依赖 JDK 默认序列化
- 大版本变更时做好缓存清理预案
6. 本地缓存内存膨胀
很多人只盯 Redis,忘了 L1 在 JVM 堆里。
如果对象很大、条目很多,很容易触发频繁 GC。
建议
- 明确
maximumSize - 不要把大对象整包塞缓存
- 监控 hit rate、eviction、heap usage
多实例下的一致性增强
如果服务有多台,本地缓存最大的挑战是:
A 实例更新了缓存,B 实例的本地缓存并不知道。
常见解法有三种:
1. 短 TTL
简单但粗糙。
适合允许几秒到几十秒不一致的场景。
2. Redis Pub/Sub 通知各实例清理本地缓存
删除 Redis 后,顺便发布一个“缓存失效事件”。
所有实例订阅到后删除自己的本地缓存。
flowchart TD
A[实例A更新DB] --> B[删除Redis缓存]
B --> C[发布失效消息]
C --> D[实例A删除本地缓存]
C --> E[实例B删除本地缓存]
C --> F[实例C删除本地缓存]
3. MQ / binlog 订阅
更可靠,但复杂度更高。
适合大规模系统,尤其是多个服务都会修改同一份数据的场景。
安全/性能最佳实践
1. 不要把缓存当权限边界
缓存只是性能组件,不是安全隔离组件。
如果数据有租户、用户维度,Key 必须带上隔离信息,否则会串数据。
例如:
product::tenant_1001::1
而不是简单的:
product::1
2. 对查询参数做合法性校验
缓存穿透很多时候不是技术问题,而是入口没拦好。
建议至少校验:
- ID 是否为空
- ID 是否小于等于 0
- 参数长度是否异常
- 是否存在明显恶意模式
3. 热点 Key 要单独治理
如果某个 Key 非常热,可以考虑:
- 提高本地缓存 TTL
- 单独预热
- 后台异步刷新
- 限流降级
不要把所有 Key 一视同仁。
4. 缓存值尽量瘦身
缓存里不要存“查了 10 张表拼起来的大对象”,除非真的有价值。
更稳妥的做法是:
- 只缓存高频字段
- 大字段拆开缓存
- 以 DTO 为缓存对象,不直接缓存完整实体图
5. 指标监控一定要补齐
至少要监控:
- 本地缓存命中率
- Redis 命中率
- DB 回源 QPS
- Key 数量和内存使用
- 过期/淘汰数量
- 热点 Key 分布
如果没有这些指标,出了问题只能靠猜。
6. 预热和降级要提前设计
上线新版本、重启服务、Redis 切换主从时,缓存命中率都会抖动。
建议提前准备:
- 核心数据预热脚本
- 回源限流
- 熔断降级策略
- 兜底静态数据
这类“非功能设计”,往往比写缓存代码本身更决定系统是否稳定。
什么时候不建议上多级缓存
虽然多级缓存很好用,但也不是所有场景都值得。
以下情况我一般会谨慎:
- 数据写多读少
- 对强一致要求极高
- 数据体量很小,DB 本身已经很快
- 团队没有足够监控和排障能力
- 业务简单,用单 Redis 已经足够
一句话:
缓存是用来解决瓶颈的,不是为了追求“架构高级感”。
总结
基于 Spring Boot 的多级缓存实践,可以概括成一条很实用的链路:
- Caffeine 做一级缓存,吃掉热点请求
- Redis 做二级缓存,承担分布式共享
- Spring Cache 统一编程模型
- 空值缓存防穿透
- 互斥加载防击穿
- 随机 TTL 防雪崩
- 更新后删缓存做最终一致性
如果你准备在项目里真正落地,我建议按这个顺序推进:
- 先做单 Redis 缓存,跑通基础链路
- 再补本地缓存,专门优化热点
- 补空值缓存、互斥加载、随机 TTL
- 多实例时加入本地缓存失效通知
- 最后再根据业务要求增强一致性方案
这样做的好处是,你不会一上来就把系统做得太复杂,也更容易定位每一层带来的收益。
如果只记住一句话,我希望是这个:
多级缓存的重点从来不是“缓存了没有”,而是“失效时是否稳、更新后是否可控、出问题时能不能查明白”。
这才是真正在生产环境里站得住的缓存方案。