Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:缓存穿透、击穿与雪崩的完整治理方案
在业务量还不大时,很多项目的缓存方案都很“朴素”:
查 Redis,没有就查数据库,再回填 Redis。
一开始没问题,但一旦流量上来,问题会一个个冒出来:
- 某些不存在的数据被频繁查询,数据库白白挨打:缓存穿透
- 某个热点 key 恰好过期,大量请求同时打到数据库:缓存击穿
- 一批 key 同时过期,整个缓存层像“塌方”一样失效:缓存雪崩
- 只有 Redis 一层缓存时,应用本地重复反序列化、重复网络 IO,也会让吞吐上不去
这篇文章我会带你从一个可运行的 Spring Boot 项目出发,搭出一个常见而实用的方案:
- Spring Cache 统一缓存编程模型
- Caffeine 作为本地一级缓存
- Redis 作为分布式二级缓存
- 配套治理:
- 空值缓存防穿透
- 布隆过滤器进一步拦截非法 key
- 分布式锁/逻辑过期防击穿
- TTL 加随机值、防雪崩
- 热点 key 预热与监控
这不是“概念堆砌”的文章,我尽量按“真要上线该怎么做”的顺序来讲。
背景与问题
先明确一个常见误区:
用了 Redis,不等于缓存问题解决了。
Redis 解决的是“远程高性能缓存”,但在高并发业务里,通常还会遇到两个现实问题:
-
每次都走网络访问 Redis
- 对于极热数据,网络 RTT、序列化/反序列化仍有开销
- 同一个应用实例内频繁访问同一个 key,其实很适合先走本地缓存
-
单靠一个缓存层难以扛住异常流量
- 恶意或脏请求会穿透缓存直达数据库
- 热点 key 失效时会形成“瞬时并发洪峰”
- 大量 key 统一过期会造成数据库雪崩式压力
所以,多级缓存的目标不是“把架构搞复杂”,而是为了同时解决:
- 低延迟
- 高命中
- 可控失效
- 异常流量兜底
前置知识与环境准备
本文示例技术栈:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Redis
- Caffeine
- Maven
示例场景:
我们实现一个商品查询接口:
GET /products/{id}
缓存策略:
- L1 本地缓存:Caffeine
- L2 分布式缓存:Redis
- 最终数据源:MySQL(这里用内存 Map 模拟)
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-aop</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
核心原理
先看整体流程图。
flowchart TD
A[请求 /products/id] --> B{L1 Caffeine 命中?}
B -- 是 --> C[直接返回]
B -- 否 --> D{L2 Redis 命中?}
D -- 是 --> E[回填 L1 并返回]
D -- 否 --> F{布隆过滤器存在?}
F -- 否 --> G[直接返回空/404]
F -- 是 --> H{尝试获取分布式锁}
H -- 成功 --> I[查询数据库]
I --> J[写入 Redis 含随机TTL]
J --> K[写入 L1]
K --> L[释放锁并返回]
H -- 失败 --> M[短暂等待后重试 Redis]
M --> D
1. 多级缓存的职责划分
L1:Caffeine 本地缓存
适合存放:
- 超高频热点数据
- 访问延迟要求非常低的数据
- 单实例内重复请求很多的数据
优点:
- 速度快,基本是进程内访问
- 减少 Redis 压力
- 减少网络与序列化开销
缺点:
- 数据只在当前实例有效
- 多实例之间不天然一致
L2:Redis 分布式缓存
适合存放:
- 多实例共享的数据
- 容量比本地缓存大得多
- 具备统一失效与共享能力
优点:
- 跨实例共享
- 性能高
- 生态成熟
缺点:
- 仍是远程访问
- 大规模失效、热点争抢时仍可能出问题
2. Spring Cache 在这里扮演什么角色
Spring Cache 的价值不是“性能更高”,而是:
- 统一注解式缓存入口
- 解耦业务代码与缓存实现
- 方便扩展不同 CacheManager
典型注解:
@Cacheable:查缓存,未命中则执行方法并缓存结果@CachePut:执行方法并更新缓存@CacheEvict:删除缓存
不过要注意一点:
Spring Cache 默认并不直接提供“多级缓存联动”能力。
我们通常会自己实现一个 Cache,内部组合 Caffeine + Redis。
3. 三大缓存问题的治理思路
缓存穿透
现象:查询一个根本不存在的数据,每次缓存都 miss,最终都打到数据库。
治理手段:
- 缓存空对象(短 TTL)
- 布隆过滤器拦截不存在 ID
- 参数校验,拦掉明显非法请求
缓存击穿
现象:某个热点 key 过期瞬间,大量并发同时查库。
治理手段:
- 分布式锁
- 热点 key 逻辑过期 + 后台异步刷新
- 永不过期 + 主动更新(适合少量核心热点)
缓存雪崩
现象:大量 key 同时过期,短时间内数据库被打爆。
治理手段:
- TTL 加随机抖动
- 多级缓存兜底
- 缓存预热
- Redis 高可用、限流降级
实战代码(可运行)
下面我们实现一个简化版但结构完整的示例。
1. application.yml
server:
port: 8080
spring:
cache:
type: none
data:
redis:
host: localhost
port: 6379
timeout: 2000ms
logging:
level:
root: info
这里把 spring.cache.type 设为 none,是因为我们会手动注册多级 CacheManager。
2. 启动类
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);
}
}
3. 商品实体
package com.example.multicache.model;
import java.io.Serializable;
import java.math.BigDecimal;
public class Product implements Serializable {
private Long id;
private String name;
private BigDecimal price;
public Product() {
}
public Product(Long id, String name, BigDecimal price) {
this.id = id;
this.name = name;
this.price = price;
}
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;
}
}
4. 模拟数据库
为了让示例可直接运行,我们先用内存 Map 模拟数据库。
package com.example.multicache.repository;
import com.example.multicache.model.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> db = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
db.put(1L, new Product(1L, "iPhone", new BigDecimal("6999")));
db.put(2L, new Product(2L, "MacBook", new BigDecimal("12999")));
db.put(3L, new Product(3L, "AirPods", new BigDecimal("1499")));
}
public Product findById(Long id) {
try {
Thread.sleep(100); // 模拟数据库耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return db.get(id);
}
public Product save(Product product) {
db.put(product.getId(), product);
return product;
}
public void deleteById(Long id) {
db.remove(id);
}
public boolean exists(Long id) {
return db.containsKey(id);
}
}
5. Redis 序列化配置
这是很多人第一次接 Spring Cache 会踩的坑:
默认序列化不友好,调试困难,还可能出现类型转换问题。
建议直接用 JSON。
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.serializer.*;
@Configuration
public class RedisSerializerConfig {
@Bean
public RedisSerializer<Object> redisValueSerializer() {
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
return new GenericJackson2JsonRedisSerializer(mapper);
}
@Bean
public RedisSerializer<String> redisKeySerializer() {
return new StringRedisSerializer();
}
}
6. 简单布隆过滤器实现
生产环境建议用 Redisson BloomFilter 或 RedisBloom。
这里为了示例自包含,我们实现一个极简版本地布隆过滤器。
package com.example.multicache.support;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
import java.util.BitSet;
@Component
public class SimpleBloomFilter {
private final BitSet bits = new BitSet(1 << 24);
private static final int SIZE = 1 << 24;
@PostConstruct
public void init() {
add("1");
add("2");
add("3");
}
public void add(String value) {
int h1 = hash1(value);
int h2 = hash2(value);
bits.set(h1 % SIZE);
bits.set(h2 % SIZE);
}
public boolean mightContain(String value) {
int h1 = hash1(value);
int h2 = hash2(value);
return bits.get(h1 % SIZE) && bits.get(h2 % SIZE);
}
private int hash1(String value) {
return Math.abs(value.hashCode());
}
private int hash2(String value) {
int h = 0;
for (char c : value.toCharArray()) {
h = 31 * h + c + 7;
}
return Math.abs(h);
}
}
7. 分布式锁工具
这里用 Redis SETNX + EXPIRE 的方式简化实现。
package com.example.multicache.support;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.UUID;
@Component
public class RedisLockSupport {
private final StringRedisTemplate stringRedisTemplate;
public RedisLockSupport(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public String tryLock(String key, Duration duration) {
String token = UUID.randomUUID().toString();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(key, token, duration);
return Boolean.TRUE.equals(success) ? token : null;
}
public void unlock(String key, String token) {
String value = stringRedisTemplate.opsForValue().get(key);
if (token != null && token.equals(value)) {
stringRedisTemplate.delete(key);
}
}
}
严格来说,释放锁最好用 Lua 保证“比较 token + 删除”原子性。后面“常见坑”会专门讲。
8. 多级缓存核心实现
我们自定义一个 Cache,内部同时操作 Caffeine 和 Redis。
package com.example.multicache.cache;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.support.AbstractValueAdaptingCache;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;
import java.util.concurrent.Callable;
import java.util.concurrent.ThreadLocalRandom;
public class MultiLevelCache extends AbstractValueAdaptingCache {
public static final String NULL_VALUE = "__NULL__";
private final String name;
private final Cache<Object, Object> caffeineCache;
private final RedisTemplate<String, Object> redisTemplate;
private final Duration ttl;
private final Duration nullTtl;
public MultiLevelCache(String name,
Cache<Object, Object> caffeineCache,
RedisTemplate<String, Object> redisTemplate,
Duration ttl,
Duration nullTtl) {
super(true);
this.name = name;
this.caffeineCache = caffeineCache;
this.redisTemplate = redisTemplate;
this.ttl = ttl;
this.nullTtl = nullTtl;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
private String buildKey(Object key) {
return name + "::" + key;
}
@Override
protected Object lookup(Object key) {
Object local = caffeineCache.getIfPresent(key);
if (local != null) {
return fromStoreValue(local);
}
Object remote = redisTemplate.opsForValue().get(buildKey(key));
if (remote != null) {
caffeineCache.put(key, remote);
return fromStoreValue(remote);
}
return null;
}
@Override
public void put(Object key, Object value) {
Object storeValue = toStoreValue(value);
caffeineCache.put(key, storeValue);
Duration expire = (value == null) ? nullTtl : withJitter(ttl);
redisTemplate.opsForValue().set(buildKey(key), storeValue, expire);
}
@Override
public ValueWrapper putIfAbsent(Object key, Object value) {
ValueWrapper existing = get(key);
if (existing != null) {
return existing;
}
put(key, value);
return null;
}
@Override
public void evict(Object key) {
caffeineCache.invalidate(key);
redisTemplate.delete(buildKey(key));
}
@Override
public void clear() {
caffeineCache.invalidateAll();
}
@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 RuntimeException(e);
}
}
private Duration withJitter(Duration base) {
long extraSeconds = ThreadLocalRandom.current().nextLong(30);
return base.plusSeconds(extraSeconds);
}
}
9. CacheManager 配置
package com.example.multicache.config;
import com.example.multicache.cache.MultiLevelCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.transaction.AbstractTransactionSupportingCacheManager;
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.Collection;
import java.util.List;
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
return new AbstractTransactionSupportingCacheManager() {
@Override
protected Collection<? extends Cache> loadCaches() {
MultiLevelCache productCache = new MultiLevelCache(
"product",
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build(),
redisTemplate,
Duration.ofMinutes(10),
Duration.ofMinutes(2)
);
return List.of(productCache);
}
@Override
protected Cache getMissingCache(String name) {
return null;
}
};
}
}
10. RedisTemplate 配置
package com.example.multicache.config;
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.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory factory,
RedisSerializer<String> redisKeySerializer,
RedisSerializer<Object> redisValueSerializer) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(redisKeySerializer);
template.setHashKeySerializer(redisKeySerializer);
template.setValueSerializer(redisValueSerializer);
template.setHashValueSerializer(redisValueSerializer);
template.afterPropertiesSet();
return template;
}
}
11. 业务服务
这里是重点:
- 先用
@Cacheable走多级缓存 - 再结合布隆过滤器 + 分布式锁治理穿透与击穿
package com.example.multicache.service;
import com.example.multicache.model.Product;
import com.example.multicache.repository.ProductRepository;
import com.example.multicache.support.RedisLockSupport;
import com.example.multicache.support.SimpleBloomFilter;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
@Service
public class ProductService {
private final ProductRepository repository;
private final SimpleBloomFilter bloomFilter;
private final RedisLockSupport redisLockSupport;
public ProductService(ProductRepository repository,
SimpleBloomFilter bloomFilter,
RedisLockSupport redisLockSupport) {
this.repository = repository;
this.bloomFilter = bloomFilter;
this.redisLockSupport = redisLockSupport;
}
@org.springframework.cache.annotation.Cacheable(
cacheNames = "product",
key = "#id",
unless = "#result == null"
)
public Product getById(Long id) {
if (id == null || id <= 0) {
return null;
}
if (!bloomFilter.mightContain(String.valueOf(id))) {
return null;
}
String lockKey = "lock:product:" + id;
String token = redisLockSupport.tryLock(lockKey, Duration.ofSeconds(5));
try {
if (token == null) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return repository.findById(id);
} finally {
redisLockSupport.unlock(lockKey, token);
}
}
@CachePut(cacheNames = "product", key = "#result.id")
public Product updatePrice(Long id, BigDecimal price) {
Product product = repository.findById(id);
if (product == null) {
return null;
}
product.setPrice(price);
repository.save(product);
return product;
}
@CacheEvict(cacheNames = "product", key = "#id")
public void delete(Long id) {
repository.deleteById(id);
}
}
这里我故意保留了一个值得讨论的点:
@Cacheable(unless = "#result == null") 会导致 null 不缓存。
而我们上面的 MultiLevelCache 是支持 null 缓存的。
这两者矛盾怎么办?
答案是:在穿透治理场景里,不要写 unless = "#result == null"。
否则空值缓存能力根本没启用。
所以更合理的版本应该是:
@org.springframework.cache.annotation.Cacheable(
cacheNames = "product",
key = "#id"
)
public Product getById(Long id) {
...
}
这也是 Spring Cache 实战中非常容易被忽略的地方。我自己第一次接手别人项目时,就因为这个表达式把“防穿透”能力直接写没了。
12. Controller
package com.example.multicache.controller;
import com.example.multicache.model.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 get(@PathVariable Long id) {
return productService.getById(id);
}
@PutMapping("/{id}/price")
public Product updatePrice(@PathVariable Long id, @RequestParam BigDecimal price) {
return productService.updatePrice(id, price);
}
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
productService.delete(id);
}
}
逐步验证清单
你可以按这个顺序验证方案是否生效。
1. 验证缓存命中路径
第一次请求:
curl http://localhost:8080/products/1
预期:
- L1 miss
- L2 miss
- 查“数据库”
- 写 Redis
- 写 Caffeine
第二次请求同一个 key:
curl http://localhost:8080/products/1
预期:
- 直接命中 L1
2. 验证缓存穿透
请求不存在的商品:
curl http://localhost:8080/products/999
如果布隆过滤器里没有 999:
- 直接返回空
- 不查数据库
如果你把它加入布隆过滤器,但数据库仍不存在:
- 第一次查库后缓存 null
- 后续短时间内不再打数据库
3. 验证缓存更新一致性
更新价格:
curl -X PUT "http://localhost:8080/products/1/price?price=7999"
再次查询:
curl http://localhost:8080/products/1
预期:
- 返回新价格
- L1 和 L2 都已更新
4. 验证缓存删除
curl -X DELETE http://localhost:8080/products/1
curl http://localhost:8080/products/1
预期:
- 删除后缓存被驱逐
- 后续查询返回空
多级缓存与击穿治理时序图
下面这张图更适合理解热点 key 失效时的行为。
sequenceDiagram
participant U as User
participant A as App
participant L1 as Caffeine
participant L2 as Redis
participant B as BloomFilter
participant R as RedisLock
participant DB as Database
U->>A: GET /products/1
A->>L1: get(1)
L1-->>A: miss
A->>L2: get(product::1)
L2-->>A: miss
A->>B: mightContain(1)
B-->>A: true
A->>R: tryLock(lock:product:1)
R-->>A: success
A->>DB: select by id = 1
DB-->>A: Product
A->>L2: set(product::1, value, ttl+jitter)
A->>L1: put(1, value)
A-->>U: Product
常见坑与排查
这一节我尽量说“真坑”。
1. @Cacheable 不生效
常见原因:
- 启动类没加
@EnableCaching - 方法是
private - 同类内部自调用,绕过 Spring 代理
- 异常被吞掉导致你以为缓存没命中
排查建议:
- 先在方法里打日志,看是否每次都进入方法体
- 再看 Bean 是否由 Spring 管理
- 检查是不是
this.getById()这种内部调用
2. 空值缓存失效
症状:
- 不存在的数据每次都查数据库
- 你明明写了 null 缓存逻辑,但没有生效
高频原因:
@Cacheable(unless = "#result == null")
这句会直接阻止 null 写入缓存。
如果你要防穿透,请把它删掉,或者单独封装结果对象。
3. 本地缓存与 Redis 不一致
比如:
- 某实例更新了 Redis
- 另一实例的 Caffeine 还保留旧值
这是多级缓存的经典问题。
解决思路:
- 更新时主动删除/更新 L1 + L2
- 借助 Redis Pub/Sub 通知其他实例清理本地缓存
- 缩短 L1 TTL,接受短暂不一致
- 对强一致要求高的数据,不要用本地缓存
如果你是订单状态、库存这类敏感数据,我一般建议: 少做本地缓存,宁可多走 Redis。
4. 分布式锁释放不安全
前面的示例里:
String value = stringRedisTemplate.opsForValue().get(key);
if (token != null && token.equals(value)) {
stringRedisTemplate.delete(key);
}
这不是原子操作。
极端情况下可能发生:
- 线程 A 锁超时
- 线程 B 获取了新锁
- 线程 A 再执行 delete,把线程 B 的锁删了
生产建议使用 Lua:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
5. TTL 设计不合理
我见过两种极端:
- TTL 太短:缓存刚建好就频繁失效
- TTL 太长:脏数据留很久
经验建议:
- 热点数据:5~30 分钟,配随机值
- 空值缓存:1~5 分钟
- 本地缓存:比 Redis 更短,一般 1~5 分钟
- 强一致数据:优先删缓存 + 回源,不要一味拉长 TTL
6. Redis 序列化兼容问题
症状:
- 反序列化报错
- 类型变成
LinkedHashMap - 升级版本后历史缓存读不出来
建议:
- 统一 JSON 序列化策略
- 缓存对象尽量用稳定 DTO
- 大版本变更时带版本号前缀,例如
v2:product::1
安全/性能最佳实践
这一节偏落地。
1. 参数校验是第一道防线
不要把所有非法请求都交给缓存系统处理。
例如:
id <= 0直接拦截- 过长字符串、非法字符直接拒绝
- 对公开接口增加限流和黑名单策略
缓存不是防火墙,别让它背锅。
2. 布隆过滤器适合“大量不存在 key”场景
适用场景:
- 商品 ID、用户 ID、券 ID 这类可枚举主键
- 恶意扫描、脏请求较多
不适用场景:
- 条件查询、组合查询
- key 变化频繁,维护成本高
边界条件要清楚:
布隆过滤器有误判,但不能漏判。
也就是“可能把不存在判断成存在”,但不会把存在判断成不存在。
3. 热点 key 用“逻辑过期”比“硬过期”更稳
对于极热点数据,推荐逻辑过期模式:
- Redis 中保存数据 + 逻辑过期时间
- 读取时即使过期,也先返回旧值
- 后台异步刷新缓存
- 避免大量请求同时阻塞在查库上
适合:
- 商品详情
- 配置中心数据
- 首页聚合信息
不太适合:
- 强一致金融数据
- 实时库存扣减结果
逻辑过期状态可以理解为这样:
stateDiagram-v2
[*] --> Fresh
Fresh --> LogicalExpired: 到达逻辑过期时间
LogicalExpired --> Rebuilding: 后台线程获得锁
Rebuilding --> Fresh: 刷新成功
LogicalExpired --> LogicalExpired: 未获得锁,继续返回旧值
4. 更新策略优先“删缓存”,再“由读请求回填”
很多人喜欢“先更新 DB,再更新缓存”。
问题是,一旦缓存更新失败,脏数据会留很久。
更稳妥的通用思路:
- 更新数据库
- 删除缓存
- 后续查询时重新加载
如果是超热点数据,再配合主动预热。
5. 监控指标一定要上
没有监控,缓存问题往往是“数据库先报警”。
至少监控这些指标:
- L1 命中率
- L2 命中率
- Redis QPS / RT
- 数据库回源 QPS
- 热点 key 排行
- 锁竞争次数
- 空值缓存数量
- 布隆过滤器误判率(可抽样)
我自己的经验是:
只看 Redis 命中率远远不够。因为很多时候 Redis 命中率看着不错,但数据库回源峰值仍然异常,原因可能是某一类 key 集中失效了。
6. 多实例场景下建议加本地缓存失效广播
如果项目有多个 Spring Boot 实例,推荐在更新数据后做一层通知:
- Redis Pub/Sub
- RocketMQ / Kafka
- Canal 监听 binlog 后驱动缓存失效
这样各实例的 Caffeine 才不会“各自保留旧值”。
一个更贴近生产的配置建议
如果你的业务是典型读多写少,可以参考下面这个思路:
| 层级 | 类型 | TTL | 用途 |
|---|---|---|---|
| L1 | Caffeine | 1~3 分钟 | 实例内热点读 |
| L2 | Redis | 10~30 分钟 + 随机值 | 多实例共享缓存 |
| Null Cache | Redis | 1~5 分钟 | 防穿透 |
| Hot Key | 逻辑过期 | 自定义 | 防击穿 |
| 更新策略 | 删除缓存 | 即时 | 控制不一致窗口 |
一个经验法则:
- 越靠近用户,请求越快,但一致性越弱
- 越靠近数据库,一致性越强,但代价越高
所以别追求“所有数据都多级缓存”。
真正值得缓存的,往往只是那 20% 的热点读场景。
总结
我们把这套方案再压缩成一句话:
Spring Cache 负责统一编程入口,Caffeine 做本地一级缓存,Redis 做分布式二级缓存,再用空值缓存、布隆过滤器、分布式锁、随机 TTL 和逻辑过期去治理穿透、击穿与雪崩。
你可以按下面这个顺序落地:
- 先接入 Spring Cache + Redis
- 再加 Caffeine 本地缓存 做多级缓存
- 增加 空值缓存 + 参数校验 防穿透
- 对明显非法 ID 加 布隆过滤器
- 对热点 key 加 分布式锁或逻辑过期
- 所有 TTL 加 随机抖动
- 最后补齐 监控、广播失效、限流降级
最后给几个很实用的边界建议:
- 强一致场景:减少本地缓存,优先 Redis 或直接查库
- 超热点数据:优先逻辑过期,不要只靠硬 TTL
- 公开接口:必须配参数校验和限流,别让缓存单独抗攻击
- 多实例部署:要考虑本地缓存失效通知,否则迟早遇到脏读
如果你现在项目里只有“查 Redis,miss 就查库”这一层,别急着一次把方案堆满。
先把空值缓存、随机 TTL、热点锁这三件事补齐,收益通常就已经很明显了。