Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:一致性、穿透与热点 Key 处理
在实际业务里,单纯“上 Redis”通常只能解决一部分性能问题。真正到高并发场景,尤其是详情页、配置查询、用户画像、商品信息这类“读多写少”的接口,往往还会遇到几个老问题:
- Redis 有了,但 RT 还是不够低
- 数据库扛不住缓存穿透
- 热点 Key 被打爆
- 更新后数据不一致,用户看到旧值
- Spring Cache 用起来很爽,但一上多实例就开始出问题
这篇文章我会带你从一个可运行的 Spring Boot 实战出发,搭建一个“本地缓存 + Redis 二级缓存 + Spring Cache 注解”的方案,并且把几个最容易踩坑的点讲透:
- 多级缓存怎么分工
- Spring Cache 怎么和 Redis 配合
- 更新时如何尽量保证一致性
- 怎么处理缓存穿透、击穿、热点 Key
- 怎么排查“明明加了缓存但就是不生效”
整篇文章偏实战,我会尽量按“能直接上手”的方式写。
背景与问题
先看一个典型接口:
根据商品 ID 查询商品详情
这个接口有几个特点:
- 读流量远大于写流量
- 商品详情变更频率不高
- 同一个商品可能会被频繁访问
- 热门商品会形成明显热点
如果只查数据库,会出现:
- 数据库连接池吃紧
- SQL RT 抖动大
- 峰值流量压垮主库
如果只加 Redis,又可能出现:
- 每次请求都走网络,延迟仍高于本地内存
- 热点 Key 集中打 Redis
- Redis 故障时,流量回源数据库,产生雪崩
所以比较稳妥的思路通常是:
- 一级缓存:本地缓存(Caffeine)
- 二级缓存:Redis
- 最终数据源:MySQL / DB
这样分层的目标很明确:
- 本地缓存:追求极致低延迟,减 Redis 压力
- Redis:跨实例共享缓存,减少数据库访问
- 数据库:兜底数据源
前置知识与环境准备
技术栈
本文示例使用:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Spring Data Redis
- Caffeine
- Maven
- Redis 7.x
示例依赖
<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.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
核心原理
先把整体流程看清楚,不然后面代码会显得像“东一块西一块”。
多级缓存访问流程
flowchart TD
A[请求进入] --> B{本地缓存命中?}
B -- 是 --> C[直接返回]
B -- 否 --> D{Redis命中?}
D -- 是 --> E[回填本地缓存]
E --> C
D -- 否 --> F{布隆/空值校验}
F -- 不存在 --> G[返回空结果/防穿透]
F -- 可能存在 --> H[查询数据库]
H --> I{查到数据?}
I -- 是 --> J[写入Redis]
J --> K[写入本地缓存]
K --> C
I -- 否 --> L[写入空值缓存]
L --> G
为什么要用 Spring Cache
Spring Cache 的优点是:
- 注解式开发,上手快
- 统一缓存抽象,不直接耦合具体实现
- 可以扩展 CacheManager / CacheResolver
@Cacheable、@CachePut、@CacheEvict覆盖常见场景
但它也有边界:
- 它主要是声明式缓存工具,不是“一致性框架”
- 本地多级缓存、热点 Key、互斥锁等问题,通常还要自己补方案
- 注解基于 AOP,自调用失效是经典坑
多级缓存一致性思路
缓存和数据库之间,一致性通常追求的是最终一致性,而不是绝对强一致。
常见更新流程有两种:
- 先更新数据库,再删除缓存
- 先删除缓存,再更新数据库
实践里更常用的是:
更新数据库成功后,删除 Redis 和本地缓存
为什么不是“更新缓存”?
- 删除通常更简单,减少脏数据覆盖
- 下次读请求自然会重建缓存
- 多实例下“更新多个缓存副本”成本更高
不过这还不够。多实例环境下,本地缓存需要同步失效,不然 A 节点删了,B 节点还在读旧值。这个问题后面会讲解决方案。
更新流程时序图
sequenceDiagram
participant Client as 客户端
participant App as 应用服务
participant DB as MySQL
participant Redis as Redis
participant Local as 本地缓存
Client->>App: 更新商品
App->>DB: update product
DB-->>App: success
App->>Redis: delete cache:key
App->>Local: evict local key
App-->>Client: 返回成功
Client->>App: 查询商品
App->>Local: get key
Local-->>App: miss
App->>Redis: get key
Redis-->>App: miss
App->>DB: select by id
DB-->>App: product data
App->>Redis: set key
App->>Local: put key
App-->>Client: 返回最新数据
项目结构设计
这次示例我们用下面的结构:
src/main/java/com/example/cache
├── CacheDemoApplication.java
├── config
│ ├── CacheConfig.java
│ └── RedisConfig.java
├── controller
│ └── ProductController.java
├── domain
│ └── Product.java
├── repository
│ └── ProductRepository.java
├── service
│ ├── ProductService.java
│ └── impl/ProductServiceImpl.java
└── cache
├── MultiLevelCache.java
└── MultiLevelCacheManager.java
核心思路是:
- 用 Caffeine 做一级缓存
- 用 RedisCache 做二级缓存
- 自定义一个 MultiLevelCache,先查本地,再查 Redis
- 用 Spring Cache 注解操作统一缓存入口
实战代码(可运行)
1. application.yml
server:
port: 8080
spring:
cache:
type: none
data:
redis:
host: localhost
port: 6379
timeout: 2000ms
logging:
level:
org.springframework.cache: debug
com.example.cache: debug
这里把 spring.cache.type 设成 none,是因为我们要自定义 CacheManager。
2. 启动类
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);
}
}
3. 实体类
package com.example.cache.domain;
import java.io.Serializable;
import java.math.BigDecimal;
public class Product implements Serializable {
private Long id;
private String name;
private BigDecimal price;
private Long version;
public Product() {
}
public Product(Long id, String name, BigDecimal price, Long version) {
this.id = id;
this.name = name;
this.price = price;
this.version = version;
}
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 Long getVersion() {
return version;
}
public void setVersion(Long version) {
this.version = version;
}
}
4. 模拟仓储层
为了让示例可运行,我们先不用数据库,直接用 ConcurrentHashMap 模拟。
package com.example.cache.repository;
import com.example.cache.domain.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> store = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
store.put(1L, new Product(1L, "机械键盘", new BigDecimal("399.00"), 1L));
store.put(2L, new Product(2L, "无线鼠标", new BigDecimal("129.00"), 1L));
store.put(3L, new Product(3L, "显示器", new BigDecimal("1499.00"), 1L));
}
public Product findById(Long id) {
sleep(100);
return store.get(id);
}
public Product updatePrice(Long id, BigDecimal price) {
Product old = store.get(id);
if (old == null) {
return null;
}
Product updated = new Product(old.getId(), old.getName(), price, old.getVersion() + 1);
store.put(id, updated);
return updated;
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ignored) {
}
}
}
这里故意加了 100ms 延迟,方便观察缓存效果。
5. Redis 序列化配置
如果你不配序列化,默认结果往往不够友好,线上排查也痛苦。
package com.example.cache.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.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import java.time.Duration;
@Configuration
public class RedisConfig {
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
return RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(serializer)
)
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues();
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
}
6. 自定义多级缓存实现
6.1 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 localCache;
private final Cache redisCache;
public MultiLevelCache(String name, Cache localCache, Cache redisCache) {
this.name = name;
this.localCache = localCache;
this.redisCache = redisCache;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
@Override
public ValueWrapper get(Object key) {
ValueWrapper localValue = localCache.get(key);
if (localValue != null) {
return localValue;
}
ValueWrapper redisValue = redisCache.get(key);
if (redisValue != null) {
localCache.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();
return value == null ? null : (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();
if (value != null) {
put(key, value);
}
return value;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public void put(Object key, Object value) {
redisCache.put(key, value);
localCache.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) {
redisCache.evict(key);
localCache.evict(key);
}
@Override
public void clear() {
redisCache.clear();
localCache.clear();
}
}
6.2 MultiLevelCacheManager
package com.example.cache.cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.data.redis.cache.RedisCacheManager;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
public class MultiLevelCacheManager implements CacheManager {
private final RedisCacheManager redisCacheManager;
public MultiLevelCacheManager(RedisCacheManager redisCacheManager) {
this.redisCacheManager = redisCacheManager;
}
@Override
public Cache getCache(String name) {
Cache redisCache = redisCacheManager.getCache(name);
Cache localCache = new CaffeineCache(name,
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build());
return new MultiLevelCache(name, localCache, redisCache);
}
@Override
public Collection<String> getCacheNames() {
return Collections.emptyList();
}
}
这里先实现最小可运行版本。
严格来说,getCache每次 new 一个本地缓存实例并不理想,后面“常见坑”里我会专门说这个问题以及修复方式。
7. CacheManager 配置
package com.example.cache.config;
import com.example.cache.cache.MultiLevelCacheManager;
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 org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory,
RedisCacheConfiguration redisCacheConfiguration) {
RedisCacheManager redisCacheManager = RedisCacheManager.builder(factory)
.cacheDefaults(redisCacheConfiguration)
.build();
return new MultiLevelCacheManager(redisCacheManager);
}
}
8. Service 层
这里是 Spring Cache 注解真正发挥作用的地方。
package com.example.cache.service;
import com.example.cache.domain.Product;
import java.math.BigDecimal;
public interface ProductService {
Product getById(Long id);
Product updatePrice(Long id, BigDecimal price);
void deleteCache(Long id);
}
package com.example.cache.service.impl;
import com.example.cache.domain.Product;
import com.example.cache.repository.ProductRepository;
import com.example.cache.service.ProductService;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class ProductServiceImpl implements ProductService {
private final ProductRepository productRepository;
public ProductServiceImpl(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Override
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product getById(Long id) {
System.out.println(">>> 查询数据库: " + id);
return productRepository.findById(id);
}
@Override
public Product updatePrice(Long id, BigDecimal price) {
Product updated = productRepository.updatePrice(id, price);
deleteCache(id);
return updated;
}
@Override
@CacheEvict(cacheNames = "product", key = "#id")
public void deleteCache(Long id) {
System.out.println(">>> 删除缓存: " + id);
}
}
这里故意使用:
@Cacheable:查数据时自动缓存@CacheEvict:更新后删除缓存
这是比较符合实际的模式:更新数据库后淘汰缓存。
9. Controller 层
package com.example.cache.controller;
import com.example.cache.domain.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);
}
@DeleteMapping("/{id}/cache")
public String deleteCache(@PathVariable Long id) {
productService.deleteCache(id);
return "ok";
}
}
10. 运行与验证
启动 Redis
如果本机没有 Redis,可以直接用 Docker:
docker run -d --name redis -p 6379:6379 redis:7
启动应用
mvn spring-boot:run
验证缓存命中
第一次查询:
curl http://localhost:8080/products/1
你会看到控制台打印:
>>> 查询数据库: 1
第二次查询:
curl http://localhost:8080/products/1
正常情况下不会再查数据库,而是从缓存命中。
验证更新后失效
curl -X PUT "http://localhost:8080/products/1/price?price=499.00"
再查一次:
curl http://localhost:8080/products/1
会重新查数据库并写回缓存。
逐步验证清单
如果你想确认“多级缓存真的在工作”,建议按这个顺序验证:
- 第一次查询:一定走 DB
- 第二次查询:命中本地缓存或 Redis
- 重启应用后再次查询:本地缓存丢失,但 Redis 仍可命中
- 更新数据后再查询:应重新回源加载新值
- 查不存在 ID:观察是否发生重复穿透
一致性、穿透与热点 Key 的落地处理
上面的代码能跑,但要进生产,还差几步关键增强。
1. 缓存一致性:更新数据库后删除缓存
最常用的基本原则:
- 先更新 DB
- 再删 Redis
- 再删本地缓存
- 多实例下通过消息通知清除其他节点本地缓存
多实例本地缓存失效方案
如果系统有多个应用实例,仅删除当前节点的本地缓存是不够的。可以通过 Redis Pub/Sub 或 MQ 广播失效事件。
sequenceDiagram
participant A as 应用实例A
participant B as 应用实例B
participant Redis as Redis/MQ
participant DB as MySQL
A->>DB: 更新商品
DB-->>A: success
A->>Redis: 删除二级缓存
A->>Redis: 发布缓存失效消息(product:1)
Redis-->>A: 通知失效
Redis-->>B: 通知失效
A->>A: 删除本地缓存
B->>B: 删除本地缓存
这个思路很常见,也比较实用:
- Redis 保证共享缓存失效
- 消息广播保证各节点一级缓存失效
一个经验提醒
我自己踩过的坑是:只删 Redis,不删本地缓存。单机自测没问题,一上多实例就开始出现“有些用户看到新值,有些用户看到旧值”的诡异现象。本质上就是一级缓存没同步失效。
2. 缓存穿透:空值缓存 + 参数校验
缓存穿透通常是:
- 查询一个根本不存在的数据
- Redis 没有
- DB 也没有
- 每次请求都打到数据库
比如恶意刷:
curl http://localhost:8080/products/99999999
方案一:缓存空值
例如查不到时缓存一个空对象,TTL 设短一点,比如 1~5 分钟。
不过要注意:本文前面配置了 disableCachingNullValues(),如果你要缓存空值,需要自己定义“空对象标记”,而不是直接缓存 null。
例如定义一个占位对象:
public class NullValueMarker implements java.io.Serializable {
public static final NullValueMarker INSTANCE = new NullValueMarker();
private NullValueMarker() {}
}
然后在自定义缓存里识别它。
如果项目不复杂,也可以绕开 Spring Cache 注解,直接手写读写逻辑,这样控制更细。
方案二:入口参数校验
像 ID 这种参数,先做基础校验:
- 不能为空
- 必须大于 0
- 非法格式直接拒绝
方案三:布隆过滤器
当数据集合相对稳定时,可以把合法 ID 放进布隆过滤器。请求进来先判断:
- 不存在:直接返回
- 可能存在:继续查 Redis/DB
这个方案特别适合“商品 ID / 用户 ID / 内容 ID”这类主键查询。
3. 热点 Key:本地缓存 + 互斥重建 + 逻辑过期
热点 Key 的本质问题不是“命中率低”,而是:
某一个 Key 太热,失效瞬间大量请求同时回源
这就是典型的缓存击穿。
处理套路
方案一:本地缓存顶住瞬时流量
一级缓存本身就能吸收大量热点读请求,这也是多级缓存的第一价值。
方案二:互斥锁防止并发重建
当 Redis 和本地缓存都失效时,只允许一个线程去查数据库,其他线程等待或快速失败。
伪代码如下:
public Product queryWithMutex(Long id) {
String key = "product:" + id;
Product cache = getCache(key);
if (cache != null) {
return cache;
}
String lockKey = "lock:" + key;
boolean locked = tryLock(lockKey);
if (!locked) {
sleep(50);
return queryWithMutex(id);
}
try {
cache = getCache(key);
if (cache != null) {
return cache;
}
Product dbData = repository.findById(id);
if (dbData == null) {
setEmptyCache(key);
return null;
}
setCache(key, dbData);
return dbData;
} finally {
unlock(lockKey);
}
}
方案三:逻辑过期
逻辑过期不是立即删除缓存,而是让旧值先继续服务,同时异步刷新。
适合:
- 热点数据
- 可接受短时间旧值
- 读流量特别大
不适合:
- 强一致敏感数据
- 库存、余额、权限这类场景
常见坑与排查
这一节很重要。很多 Spring Cache 问题,不是“不会写”,而是“写了却不生效”。
1. 同类内部方法调用,注解失效
比如:
public Product updatePrice(Long id, BigDecimal price) {
Product updated = productRepository.updatePrice(id, price);
deleteCache(id); // 自调用
return updated;
}
在同一个类里直接调用 deleteCache(id),可能绕过代理,导致 @CacheEvict 不生效。
解决方式
- 把删除缓存的方法拆到另一个 Bean
- 或通过代理对象调用
- 或干脆直接使用
CacheManager手动删除
这是 Spring AOP 的经典问题,不只是缓存会踩。
2. 自定义 CacheManager 每次都创建本地缓存实例
前面的示例代码里:
Cache localCache = new CaffeineCache(name,
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build());
如果 getCache(name) 每次都 new,一个缓存名可能对应多个本地缓存实例,效果就不对了。
修正版
package com.example.cache.cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.data.redis.cache.RedisCacheManager;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
public class MultiLevelCacheManager implements CacheManager {
private final RedisCacheManager redisCacheManager;
private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
public MultiLevelCacheManager(RedisCacheManager redisCacheManager) {
this.redisCacheManager = redisCacheManager;
}
@Override
public Cache getCache(String name) {
return cacheMap.computeIfAbsent(name, cacheName -> {
Cache redisCache = redisCacheManager.getCache(cacheName);
Cache localCache = new CaffeineCache(cacheName,
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build());
return new MultiLevelCache(cacheName, localCache, redisCache);
});
}
@Override
public Collection<String> getCacheNames() {
return cacheMap.keySet();
}
}
这个版本更适合真实使用。
3. Redis 序列化不一致导致反序列化异常
常见表现:
- 能写进去,读不出来
- 类一改字段就报错
- Redis 里全是看不懂的二进制
建议
- 统一使用 JSON 序列化
- 缓存对象尽量保持稳定结构
- 大对象、深层嵌套对象谨慎缓存
- 涉及版本变更时做好兼容
4. TTL 配置不合理
如果一级缓存和二级缓存 TTL 完全一致,可能会出现:
- 同一时刻大面积失效
- 某批 Key 同时回源
建议
给 TTL 加随机抖动,例如:
- Redis: 10 分钟 ± 60 秒
- 本地缓存: 30 秒 ± 5 秒
这样可以降低雪崩风险。
5. 本地缓存过大导致内存压力
本地缓存不是越大越好。
要关注:
- 堆内存占用
- Full GC 次数
- 热点数据是否真有价值
- 是否缓存了大对象列表
有一次我见过有人把分页结果、超大 JSON、用户会话都塞本地缓存,最后不是 Redis 先扛不住,而是应用 JVM 先开始抖。
安全/性能最佳实践
1. Key 设计规范化
建议统一 Key 前缀,例如:
product:detail:1
product:stock:1
user:profile:1001
好处:
- 便于排查
- 便于分业务清理
- 避免 Key 冲突
不要直接把复杂对象 toString() 当 key。
2. 区分数据类型设置 TTL
不同数据应该分开设计:
- 商品详情:10~30 分钟
- 首页配置:1~5 分钟
- 用户权限:短 TTL + 主动失效
- 热点榜单:逻辑过期 + 异步刷新
不要一个全局 TTL 打天下。
3. 缓存与数据库双写时优先删缓存
对于读多写少业务,建议:
- 更新数据库
- 删除缓存
- 让下次读取自动回填
除非你能非常确定“更新缓存”不会覆盖新值,否则不要轻易做复杂双写。
4. 对不存在数据做保护
至少做两层:
- 参数合法性校验
- 空值缓存 / 布隆过滤器
这样才能真正挡住穿透。
5. 热点 Key 做专项治理
如果你已经知道某些商品、活动页、配置项特别热,不要等出问题再处理。建议提前准备:
- 本地缓存
- 互斥锁重建
- 限流降级
- 逻辑过期
- 异步预热
6. 监控必须补齐
缓存方案上线前,至少加这些指标:
- 本地缓存命中率
- Redis 命中率
- 缓存加载耗时
- DB 回源次数
- 热点 Key 排名
- 缓存删除失败次数
- Redis 超时/异常次数
没有监控,缓存问题基本靠猜。
7. 给缓存链路设置边界条件
当 Redis 故障时,系统不应该无限制回源数据库。建议加:
- 接口限流
- 熔断降级
- 热点接口兜底值
- 关键缓存预热
这点特别重要,很多“缓存事故”最后其实是“数据库被缓存故障拖下水”。
一个更贴近生产的优化建议
如果你的业务已经进入中高并发阶段,我更建议把职责拆开:
- Spring Cache 注解:处理大多数标准读写缓存
- 手写缓存逻辑:处理热点 Key、逻辑过期、互斥锁
- 消息通知机制:处理多实例本地缓存失效
- 布隆过滤器:处理穿透
- 监控告警:观察命中率和回源情况
也就是说:
Spring Cache 很适合做“基础层”,但不要指望它一个注解解决所有缓存问题。
总结
这篇文章我们完成了一个基于 Spring Boot + Spring Cache + Redis + Caffeine 的多级缓存实战,并重点处理了三个核心问题:
-
一致性
- 推荐“先更新数据库,再删除缓存”
- 多实例下要同步清理本地缓存
- 接受最终一致性,不追求不必要的强一致
-
缓存穿透
- 参数校验
- 空值缓存
- 布隆过滤器
-
热点 Key / 击穿
- 本地缓存分流
- 互斥锁重建
- 逻辑过期
如果你现在要把它落到项目里,我建议按这个顺序推进:
- 先上 Redis + Spring Cache
- 再补 本地缓存
- 接着解决 多实例本地缓存失效
- 最后针对热点 Key 加 互斥锁 / 逻辑过期 / 预热
这样复杂度是逐步增加的,不会一上来把系统做得太重。
最后给一个很实用的判断边界:
- 读多写少、可接受秒级旧数据:多级缓存非常适合
- 余额、库存、强事务一致性:不要迷信缓存,优先保证正确性
- 超热点业务:一定要专项治理,不能只靠默认注解
如果你已经在用 Spring Cache,但总觉得“够用又不完全够用”,那多半就是该把多级缓存、一致性和热点治理这些“后半段能力”补上了。