Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:一致性、穿透与热点 Key 处理方案
在很多 Spring Boot 项目里,缓存一开始都很简单:先上个 Redis,配个 @Cacheable,接口响应时间立刻好看不少。
但业务一复杂,问题就会接踵而来:
- 本地缓存和 Redis 缓存怎么配合?
- 数据更新后,怎么保证缓存别“脏太久”?
- 遇到缓存穿透、缓存击穿、热点 Key,系统怎么扛?
- Spring Cache 很方便,但默认抽象背后有哪些坑?
这篇文章我会带你从问题出发,一步步做出一个Spring Boot + Spring Cache + Redis + 本地缓存(Caffeine) 的多级缓存方案,并把一致性、穿透和热点 Key 的处理方式一起串起来。文章会尽量贴近真实项目,而不是只停留在“能跑”的 demo。
背景与问题
先看一个典型业务场景:商品详情查询。
调用链通常是:
- 先查缓存
- 缓存没有,再查数据库
- 查到结果后回填缓存
当 QPS 上来以后,仅有 Redis 还不一定够,原因一般有几个:
1. Redis 不是“零成本”
Redis 快,但网络调用、序列化、反序列化都是成本。对于极高频、读多写少的数据,本地缓存依然很有价值。
2. 更新时容易出现数据不一致
比如:
- 先更新数据库,再删缓存
- 或先删缓存,再更新数据库
这两种都不是绝对安全。并发情况下,很容易把旧值重新写回缓存。
3. 缓存穿透与击穿
- 缓存穿透:查询不存在的数据,每次都打到数据库
- 缓存击穿:某个热点 Key 失效瞬间,大量请求同时回源
- 缓存雪崩:大量 Key 同时过期,导致数据库被打爆
4. Spring Cache 用起来简单,但默认行为不够“业务化”
@Cacheable、@CachePut、@CacheEvict 很方便,但多级缓存、一致性控制、空值缓存、热点保护等,通常都需要再补一层能力。
前置知识与环境准备
技术栈
- JDK 8+
- Spring Boot 2.x
- Spring Cache
- Redis
- Caffeine
- Maven
示例业务
我们用一个很常见的模型:
Product:商品- 查询接口:
GET /products/{id} - 更新接口:
PUT /products/{id}
本文实现目标
我们会实现下面这套链路:
- 一级缓存:Caffeine 本地缓存
- 二级缓存:Redis
- 回源:数据库(这里用内存 Map 模拟)
- 空值缓存:防止缓存穿透
- 热点 Key 互斥加载:防止击穿
- 更新时双删策略 + 短 TTL:降低不一致窗口
核心原理
先把整体结构看清楚。
flowchart TD
A[客户端请求] --> B[Spring Cache]
B --> C{一级缓存 Caffeine 命中?}
C -- 是 --> D[返回结果]
C -- 否 --> E{二级缓存 Redis 命中?}
E -- 是 --> F[写入一级缓存]
F --> D
E -- 否 --> G[加互斥锁/热点保护]
G --> H[查询数据库]
H --> I[写入 Redis]
I --> J[写入 Caffeine]
J --> D
多级缓存的基本思路
多级缓存不是“堆技术”,而是分层处理不同问题:
- Caffeine:解决超高频读、降低 Redis 网络开销
- Redis:解决多实例共享缓存
- 数据库:最终数据源
一致性怎么理解
缓存和数据库本质上就是弱一致性方案。只要你不是做分布式事务缓存,通常都只能做到:
- 大多数时候一致
- 极端并发下有短暂不一致窗口
- 通过 TTL、删除策略、异步修复把影响收敛
所以这里别追求“绝对一致”,要追求的是:一致性窗口足够小,业务可接受,异常可恢复。
穿透、击穿、雪崩分别怎么治
缓存穿透
查一个根本不存在的 id,缓存里没有,数据库也没有,请求次次穿透。
常见手段:
- 缓存空对象
- 布隆过滤器
- 参数合法性校验
本文我们先实现最实用的:空值缓存。
缓存击穿
热点 Key 过期瞬间,大量并发同时查库。
常见手段:
- 互斥锁
- 热点永不过期 + 异步刷新
- 请求合并
本文实现:互斥锁 + 短暂等待重试。
缓存雪崩
大量 Key 同时过期。
常见手段:
- TTL 加随机值
- 多级缓存兜底
- 限流降级
方案设计
这里我建议把职责拆开,不要把所有逻辑都硬塞进 @Cacheable 注解里。我们用一个缓存门面服务来控制查询流程。
classDiagram
class ProductController {
+getById(Long id)
+update(Long id, Product product)
}
class ProductService {
+getProduct(Long id)
+updateProduct(Long id, Product product)
}
class ProductRepository {
+findById(Long id)
+save(Product product)
}
class MultiLevelCacheService {
+get(String cacheName, String key, Callable loader, Class type)
+evict(String cacheName, String key)
}
ProductController --> ProductService
ProductService --> ProductRepository
ProductService --> MultiLevelCacheService
核心点有两个:
- 查询走统一门面
- 先查本地缓存
- 再查 Redis
- 再查数据库
- 更新时主动删除多级缓存
- 更新数据库
- 删除 Redis
- 删除本地缓存
- 延迟二次删除,降低并发脏读窗口
实战代码(可运行)
下面给出一个可以直接拼起来运行的示例。为了突出缓存逻辑,数据库部分我用内存 Map 模拟。
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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
2. application.yml
server:
port: 8080
spring:
redis:
host: localhost
port: 6379
timeout: 2000ms
cache:
ttl:
product: 300
null-value: 60
3. 启动类
package com.example.cache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class CacheDemoApplication {
public static void main(String[] args) {
SpringApplication.run(CacheDemoApplication.class, args);
}
}
4. 实体类
package com.example.cache.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;
}
}
5. 模拟 Repository
package com.example.cache.repository;
import com.example.cache.model.Product;
import org.springframework.stereotype.Repository;
import javax.annotation.PostConstruct;
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, "iPhone", new BigDecimal("6999")));
database.put(2L, new Product(2L, "MacBook", new BigDecimal("12999")));
}
public Product findById(Long id) {
sleep(100);
return database.get(id);
}
public Product save(Product product) {
sleep(100);
database.put(product.getId(), product);
return product;
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ignored) {
}
}
}
6. Caffeine 配置
package com.example.cache.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
@Configuration
public class LocalCacheConfig {
@Bean
public com.github.benmanes.caffeine.cache.Cache<String, Object> caffeineCache() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.recordStats()
.build();
}
}
7. Redis 配置
这里用 RedisTemplate<String, Object>,并配置 JSON 序列化,避免默认 JDK 序列化可读性差、兼容性差。
package com.example.cache.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.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
Jackson2JsonRedisSerializer<Object> valueSerializer =
new Jackson2JsonRedisSerializer<>(Object.class);
valueSerializer.setObjectMapper(mapper);
StringRedisSerializer keySerializer = new StringRedisSerializer();
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
}
8. 多级缓存服务
这是本文的核心实现。
package com.example.cache.service;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
@Service
public class MultiLevelCacheService {
private static final String NULL_MARKER = "__NULL__";
private final Cache<String, Object> caffeineCache;
private final RedisTemplate<String, Object> redisTemplate;
@Value("${cache.ttl.product:300}")
private long productTtlSeconds;
@Value("${cache.ttl.null-value:60}")
private long nullValueTtlSeconds;
public MultiLevelCacheService(Cache<String, Object> caffeineCache,
RedisTemplate<String, Object> redisTemplate) {
this.caffeineCache = caffeineCache;
this.redisTemplate = redisTemplate;
}
public <T> T get(String cacheName, String key, Callable<T> loader, Class<T> type) {
String fullKey = buildKey(cacheName, key);
Object localValue = caffeineCache.getIfPresent(fullKey);
if (localValue != null) {
if (NULL_MARKER.equals(localValue)) {
return null;
}
return type.cast(localValue);
}
Object redisValue = redisTemplate.opsForValue().get(fullKey);
if (redisValue != null) {
caffeineCache.put(fullKey, redisValue);
if (NULL_MARKER.equals(redisValue)) {
return null;
}
return type.cast(redisValue);
}
String lockKey = "lock:" + fullKey;
String lockValue = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
T loaded = loader.call();
if (loaded == null) {
redisTemplate.opsForValue().set(fullKey, NULL_MARKER, nullValueTtlSeconds, TimeUnit.SECONDS);
caffeineCache.put(fullKey, NULL_MARKER);
return null;
}
long ttl = productTtlSeconds + (long) (Math.random() * 60);
redisTemplate.opsForValue().set(fullKey, loaded, ttl, TimeUnit.SECONDS);
caffeineCache.put(fullKey, loaded);
return loaded;
} catch (Exception e) {
throw new RuntimeException("load data error", e);
} finally {
Object currentLockValue = redisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(currentLockValue)) {
redisTemplate.delete(lockKey);
}
}
}
try {
Thread.sleep(50);
} catch (InterruptedException ignored) {
}
Object retryValue = redisTemplate.opsForValue().get(fullKey);
if (retryValue != null) {
caffeineCache.put(fullKey, retryValue);
if (NULL_MARKER.equals(retryValue)) {
return null;
}
return type.cast(retryValue);
}
try {
T loaded = loader.call();
if (loaded == null) {
return null;
}
caffeineCache.put(fullKey, loaded);
return loaded;
} catch (Exception e) {
throw new RuntimeException("retry load data error", e);
}
}
public void evict(String cacheName, String key) {
String fullKey = buildKey(cacheName, key);
caffeineCache.invalidate(fullKey);
redisTemplate.delete(fullKey);
}
private String buildKey(String cacheName, String key) {
return cacheName + "::" + key;
}
}
这个实现做了什么?
- 先查本地缓存
- 本地没命中,再查 Redis
- Redis 没命中,再尝试拿分布式锁
- 拿到锁的线程负责查库并回填
- 没拿到锁的线程短暂等待,再读 Redis
- 数据不存在时缓存空值
- Redis TTL 加随机数,防止雪崩
9. 业务服务
package com.example.cache.service;
import com.example.cache.model.Product;
import com.example.cache.repository.ProductRepository;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private static final String CACHE_NAME = "product";
private final ProductRepository productRepository;
private final MultiLevelCacheService cacheService;
public ProductService(ProductRepository productRepository,
MultiLevelCacheService cacheService) {
this.productRepository = productRepository;
this.cacheService = cacheService;
}
public Product getProduct(Long id) {
return cacheService.get(CACHE_NAME, String.valueOf(id),
() -> productRepository.findById(id), Product.class);
}
public Product updateProduct(Long id, Product product) {
product.setId(id);
Product saved = productRepository.save(product);
cacheService.evict(CACHE_NAME, String.valueOf(id));
delayedDoubleDelete(id);
return saved;
}
@Async
public void delayedDoubleDelete(Long id) {
try {
Thread.sleep(500);
} catch (InterruptedException ignored) {
}
cacheService.evict(CACHE_NAME, String.valueOf(id));
}
}
这里用到了延迟双删,所以别忘了开启异步。
10. 异步配置
package com.example.cache.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfig {
}
11. Controller
package com.example.cache.controller;
import com.example.cache.model.Product;
import com.example.cache.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) {
return productService.updateProduct(id, product);
}
}
逐步验证清单
项目启动后,可以按下面顺序验证。
1. 查询已存在商品
curl http://localhost:8080/products/1
第一次通常会稍慢,因为会回源数据库;第二次会明显更快。
2. 查询不存在商品,观察空值缓存
curl http://localhost:8080/products/999
连续请求多次,数据库不应该每次都被打到。
3. 更新商品,验证缓存删除
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
应能看到新值。
4. 并发压测热点 Key
可以用 ab 或 wrk 压测:
ab -n 1000 -c 100 http://127.0.0.1:8080/products/1
如果日志里加上数据库查询输出,你会发现数据库访问次数远低于请求数。
一致性时序分析
更新时最容易出问题。下面这个图描述了“更新数据库 + 删除缓存”的典型并发窗口。
sequenceDiagram
participant C1 as 请求A-更新
participant C2 as 请求B-查询
participant DB as 数据库
participant R as Redis
participant L as 本地缓存
C1->>DB: 更新商品数据
C2->>L: 查询本地缓存
L-->>C2: 未命中
C2->>R: 查询 Redis
R-->>C2: 未命中
C2->>DB: 查询旧值/临界值
C1->>R: 删除 Redis 缓存
C1->>L: 删除本地缓存
C2->>R: 回填旧值
C2->>L: 回填旧值
这就是为什么简单“更新数据库后删缓存”仍然可能脏。
为什么要用延迟双删?
第一次删除是为了尽快把旧缓存清掉;
第二次延迟删除是为了清理由并发查询回填进去的旧值。
它不能彻底解决所有问题,但在大部分读多写少场景里很实用。
更严格的一致性方案
如果业务真的很敏感,比如库存、账户余额,建议考虑:
- 订阅 binlog 异步删缓存
- 基于 MQ 的变更通知
- 只缓存只读视图数据
- 关键链路避免依赖缓存正确性
常见坑与排查
这些坑我基本都踩过,尤其是“看起来缓存加上了,实际上并没有”。
1. Spring Cache 注解不生效
常见原因:
- 没加
@EnableCaching - 同类内部方法调用,AOP 没代理到
- 方法不是
public
排查建议:
- 看启动日志里是否创建了 Cache 相关 Bean
- 打断点确认是否进入代理逻辑
- 避免同类自调用缓存方法
2. Redis 序列化问题
表现:
- Redis 中值乱码
- 反序列化报错
- 类结构变更后旧缓存读不出来
建议:
- 优先 JSON 序列化
- 明确 key/value serializer
- 生产环境要考虑对象版本兼容
3. 空值缓存时间设置不当
如果空值 TTL 太长,数据刚创建出来时,缓存里还保留着“空结果”,用户会短时间查不到。
建议:
- 空值缓存 TTL 明显短于正常值,比如 30~60 秒
- 对新增频繁的数据,空值缓存要更谨慎
4. 本地缓存导致多实例不一致
这是多级缓存里最常见的误区。
Redis 是共享的,但 Caffeine 不是。多实例部署时:
- A 实例刚更新并删除本地缓存
- B 实例本地缓存里可能还保留旧值
解决思路:
- 本地缓存 TTL 设置更短
- 更新时通过 Redis Pub/Sub 广播失效消息
- 对强一致要求高的 Key,绕过本地缓存
5. 热点 Key 锁竞争严重
如果锁设计不好,会出现:
- 大量线程阻塞
- 锁超时误删
- 回源仍然打爆数据库
建议:
- 锁 TTL 不要太短
- 解锁时校验锁值
- 极热点数据考虑逻辑过期 + 后台刷新
6. 延迟双删并不是银弹
它只是降低脏数据概率,不是强一致方案。
如果你遇到以下情况,要谨慎:
- 写非常频繁
- 同一 Key 高频更新
- 对一致性要求极高
这时候更适合事件驱动失效、版本号控制,甚至直接不走缓存。
安全/性能最佳实践
这一节我尽量给可执行建议,不讲空话。
1. Key 设计要规范
推荐格式:
业务名:实体名:主键[:版本]
比如:
mall:product:1
好处:
- 易排查
- 易批量管理
- 降低 key 冲突
2. TTL 不要整齐划一
错误做法:
- 所有商品缓存都 300 秒过期
正确做法:
- 在基础 TTL 上加随机抖动
示例:
long ttl = 300 + ThreadLocalRandom.current().nextInt(60);
这样能有效缓解雪崩。
3. 对不存在参数先做校验
比如:
id <= 0- 非法格式
- 越权访问
这类请求不要放进缓存链路里浪费资源。
4. 热点数据考虑“永不过期 + 异步刷新”
对极热点 Key,过期就容易击穿。一个更稳妥的思路是:
- Redis 中保存数据 + 逻辑过期时间
- 请求读到过期数据时先返回旧值
- 后台线程异步刷新
适合:
- 商品详情
- 配置信息
- 首页聚合数据
不太适合:
- 强一致库存
- 账户余额
5. 多级缓存不是越多越好
我通常的建议是:
- 普通业务:Redis 即可
- 高频热点读:Caffeine + Redis
- 高一致性业务:少用本地缓存,缩短 TTL
别因为“架构看起来高级”就把系统搞复杂。
6. 给缓存留监控
至少要监控这些指标:
- 本地缓存命中率
- Redis 命中率
- DB 回源次数
- 空值缓存数量
- 热点 Key QPS
- 锁等待与失败次数
没有监控,缓存问题往往只能靠猜。
7. 注意缓存中的敏感数据
不要把以下数据直接明文放缓存:
- 身份证号
- 手机号全量信息
- token、session 等认证信息
- 高敏业务字段
建议:
- 能不缓存就不缓存
- 必须缓存时脱敏或加密
- 控制 TTL,限制访问范围
进阶:如果想更贴近 Spring Cache 抽象
上面为了可控性,我们是手写了一个缓存门面。那 Spring Cache 在这套架构里怎么用更合理?
我的建议是:
适合直接用 @Cacheable 的场景
- 读多写少
- 单层缓存
- 一致性要求不高
- Key 规则简单
例如:
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product simpleGet(Long id) {
return productRepository.findById(id);
}
不适合只靠注解的场景
- 多级缓存
- 互斥锁防击穿
- 逻辑过期
- 空值缓存
- 自定义删除与延迟双删
这时更适合像本文一样,用 Spring Cache 提供抽象能力,但核心流程自己掌控。
常见优化取舍
这里给一个简短决策表,方便你在项目里落地时判断。
| 场景 | 推荐方案 | 备注 |
|---|---|---|
| 普通列表、详情页 | Redis + @Cacheable | 成本低,足够用 |
| 高频商品详情 | Caffeine + Redis | 降低 Redis 压力 |
| 不存在数据被频繁查询 | 空值缓存 / 布隆过滤器 | 防穿透 |
| 单个热点 Key 超高并发 | 互斥锁 / 逻辑过期 | 防击穿 |
| 多实例缓存不一致敏感 | 缩短本地 TTL / 广播失效 | 降低本地缓存影响 |
| 强一致业务 | 尽量少缓存或只缓存只读视图 | 不要强行上多级缓存 |
总结
这篇文章我们做了一套比较实用的缓存方案:
- 用 Caffeine 做一级缓存,提升极高频访问性能
- 用 Redis 做二级缓存,实现多实例共享
- 用 空值缓存 处理缓存穿透
- 用 互斥锁 处理热点 Key 击穿
- 用 TTL 随机化 降低雪崩风险
- 用 更新后删除 + 延迟双删 缩小一致性窗口
最后给几个落地建议,都是比较“接地气”的:
-
别一上来就做复杂多级缓存
先确认瓶颈到底是数据库、Redis,还是接口逻辑本身。 -
缓存方案要看业务一致性要求
商品详情和库存扣减不是一回事,别用同一套标准。 -
强依赖缓存的地方一定要可观测
没有命中率、回源次数、热点 Key 监控,线上出问题会很被动。 -
本地缓存要克制使用
它很好用,但天然带来多实例不一致,适合热点读,不适合强一致数据。 -
Spring Cache 很适合做起点,不一定适合做终点
注解能快速起步,但真到复杂场景,还是要回到明确的缓存流程控制。
如果你现在正好在做 Spring Boot 项目的性能优化,这套方案基本可以作为一个中型项目的缓存骨架。先把流程搭起来,再根据业务热点和一致性要求逐步演进,通常比一开始追求“完美架构”更靠谱。