Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:提升接口性能与一致性控制
很多团队一开始做缓存,通常只有一层 Redis:先查 Redis,没有再查数据库。这个方案足够常见,也足够好用。
但当接口 QPS 上来、热点数据集中、网络抖动变多时,只有 Redis 这一层往往会暴露几个问题:
- 每次请求都要走一次网络
- Redis 扛住了数据库压力,但自己也可能成为瓶颈
- 缓存更新时,多个节点之间容易出现短暂不一致
- 热点 key 失效瞬间,容易打穿到数据库
这篇文章我不想只讲概念,而是带你从一个可运行的 Spring Boot 示例入手,做一个本地缓存(Caffeine) + Redis 分布式缓存的多级缓存方案,并结合 Spring Cache 做统一接入。重点会放在两件事上:
- 怎么提升接口性能
- 怎么控制缓存一致性边界
如果你已经会用 Spring Boot 和 Redis,那么本文正适合你继续往前走一步。
背景与问题
先看一个典型场景:商品详情接口。
业务特点通常是这样的:
- 读多写少
- 热点商品访问集中
- 对实时性有要求,但不是“强一致到毫秒级”
- 希望改造成本低,尽量沿用 Spring Cache 注解能力
如果只有数据库:
- QPS 一高,数据库先吃不消
如果只有 Redis:
- DB 压力降了,但应用每次仍要远程访问 Redis
- 热点 key 会集中打到 Redis
- Redis 失效时,可能形成瞬时流量穿透
这时多级缓存的价值就出来了:
- 一级缓存(L1):应用内本地缓存,命中快,纳秒/微秒级
- 二级缓存(L2):Redis,跨实例共享
- 数据源:数据库或下游服务
目标是:
- 常见热点查询命中本地缓存,减少网络开销
- 应用实例间通过 Redis 共享数据
- 数据更新时,通过统一失效策略尽量控制不一致窗口
前置知识与环境准备
本文示例使用:
- JDK 17
- Spring Boot 3.x
- Spring Cache
- Redis
- Caffeine
- Maven
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>
核心原理
先别急着写代码,先把访问路径理顺。
多级缓存访问流程
- 先查本地缓存 Caffeine
- 本地没命中,再查 Redis
- Redis 没命中,再查数据库
- 查到结果后,回填 Redis 和本地缓存
- 更新数据时,先更新数据库,再删除/刷新缓存
对应关系如下:
flowchart TD
A[请求到达] --> B{L1 本地缓存命中?}
B -- 是 --> C[直接返回]
B -- 否 --> D{L2 Redis 命中?}
D -- 是 --> E[回填 L1 并返回]
D -- 否 --> F[查询数据库]
F --> G[写入 Redis]
G --> H[写入 L1]
H --> I[返回结果]
为什么不用“只靠 Spring Cache 默认实现”
Spring Cache 本质上是一个缓存抽象,它很好用,但默认更偏向“单缓存管理器”思路。
如果要做真正的多级缓存,常见做法有两种:
- 业务代码里手动写 L1/L2 逻辑
- 自定义 Cache / CacheManager,把多级逻辑封装进去
本文采用第二种:
对业务层依然暴露 @Cacheable / @CacheEvict / @CachePut,但底层实现多级缓存。
这样好处很明显:
- 业务代码干净
- 策略统一
- 后续方便调整 TTL、序列化、统计指标
方案设计
组件职责划分
- CaffeineCache:本地热点缓存,速度最快
- RedisCache:分布式共享缓存
- MultiLevelCache:组合两者,对外表现为一个 Spring Cache
- MultiLevelCacheManager:统一创建缓存实例
更新一致性策略
这个问题最容易被轻描淡写,但实际最关键。
本文采用的是工程上最常见、性价比最高的方案:
- 读操作:缓存未命中时回源数据库
- 写操作:先更新数据库,再删除 L1/L2 缓存
- 不要优先更新缓存值,而是优先失效缓存
原因很简单:
- 更新缓存值涉及并发覆盖、序列化差异、局部字段更新等复杂问题
- 删除缓存更稳妥,后续读取自然回填
- 虽然有短暂不一致窗口,但通常是可控的
更新链路如下:
sequenceDiagram
participant Client as 客户端
participant App as 应用服务
participant DB as 数据库
participant L1 as Caffeine
participant L2 as Redis
Client->>App: 更新商品
App->>DB: UPDATE 商品数据
DB-->>App: 更新成功
App->>L1: 删除缓存
App->>L2: 删除缓存
App-->>Client: 返回成功
Client->>App: 查询商品
App->>L1: get
L1-->>App: miss
App->>L2: get
L2-->>App: miss
App->>DB: select
DB-->>App: 商品数据
App->>L2: put
App->>L1: put
App-->>Client: 返回数据
实战代码(可运行)
下面给出一个简化但可运行的示例。为了聚焦缓存逻辑,数据库层我先用内存 Map 模拟,你可以很容易替换成 MyBatis/JPA。
1. 配置文件
spring:
data:
redis:
host: localhost
port: 6379
timeout: 3000ms
server:
port: 8080
cache:
caffeine:
maximum-size: 1000
expire-after-write: 60s
redis:
ttl: 300s
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;
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. 模拟 Repository
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> storage = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
storage.put(1L, new Product(1L, "iPhone", new BigDecimal("6999.00"), 1L));
storage.put(2L, new Product(2L, "MacBook", new BigDecimal("12999.00"), 1L));
}
public Product findById(Long id) {
sleep(100);
return storage.get(id);
}
public Product save(Product product) {
sleep(100);
Product old = storage.get(product.getId());
long version = old == null ? 1L : old.getVersion() + 1;
product.setVersion(version);
storage.put(product.getId(), product);
return product;
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
5. Redis 配置
package com.example.multicache.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@ConfigurationProperties(prefix = "cache")
public class CacheProperties {
private Caffeine caffeine = new Caffeine();
private Redis redis = new Redis();
public Caffeine getCaffeine() {
return caffeine;
}
public void setCaffeine(Caffeine caffeine) {
this.caffeine = caffeine;
}
public Redis getRedis() {
return redis;
}
public void setRedis(Redis redis) {
this.redis = redis;
}
public static class Caffeine {
private long maximumSize = 1000;
private Duration expireAfterWrite = Duration.ofSeconds(60);
public long getMaximumSize() {
return maximumSize;
}
public void setMaximumSize(long maximumSize) {
this.maximumSize = maximumSize;
}
public Duration getExpireAfterWrite() {
return expireAfterWrite;
}
public void setExpireAfterWrite(Duration expireAfterWrite) {
this.expireAfterWrite = expireAfterWrite;
}
}
public static class Redis {
private Duration ttl = Duration.ofMinutes(5);
public Duration getTtl() {
return ttl;
}
public void setTtl(Duration ttl) {
this.ttl = ttl;
}
}
}
package com.example.multicache.config;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.*;
@Configuration
@EnableConfigurationProperties(CacheProperties.class)
public class CacheConfig {
@Bean
public RedisCacheConfigurationSupport redisCacheConfigurationSupport(
RedisConnectionFactory connectionFactory,
CacheProperties cacheProperties
) {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
RedisSerializer<Object> serializer = new GenericJackson2JsonRedisSerializer(objectMapper);
return new RedisCacheConfigurationSupport(
RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory),
serializer,
cacheProperties
);
}
@Bean
public CacheManager cacheManager(RedisCacheConfigurationSupport support) {
return new MultiLevelCacheManager(support);
}
}
6. 多级缓存实现
package com.example.multicache.config;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.serializer.RedisSerializer;
public class RedisCacheConfigurationSupport {
private final RedisCacheWriter redisCacheWriter;
private final RedisSerializer<Object> valueSerializer;
private final CacheProperties cacheProperties;
public RedisCacheConfigurationSupport(
RedisCacheWriter redisCacheWriter,
RedisSerializer<Object> valueSerializer,
CacheProperties cacheProperties
) {
this.redisCacheWriter = redisCacheWriter;
this.valueSerializer = valueSerializer;
this.cacheProperties = cacheProperties;
}
public RedisCacheWriter getRedisCacheWriter() {
return redisCacheWriter;
}
public RedisSerializer<Object> getValueSerializer() {
return valueSerializer;
}
public CacheProperties getCacheProperties() {
return cacheProperties;
}
}
package com.example.multicache.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.lang.NonNull;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class MultiLevelCacheManager implements CacheManager {
private final RedisCacheConfigurationSupport support;
private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
public MultiLevelCacheManager(RedisCacheConfigurationSupport support) {
this.support = support;
}
@Override
public Cache getCache(@NonNull String name) {
return cacheMap.computeIfAbsent(name, this::createCache);
}
private Cache createCache(String name) {
com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache =
Caffeine.newBuilder()
.maximumSize(support.getCacheProperties().getCaffeine().getMaximumSize())
.expireAfterWrite(support.getCacheProperties().getCaffeine().getExpireAfterWrite())
.build();
return new MultiLevelCache(
name,
caffeineCache,
support.getRedisCacheWriter(),
support.getValueSerializer(),
support.getCacheProperties().getRedis().getTtl()
);
}
@Override
public Collection<String> getCacheNames() {
return Collections.unmodifiableSet(cacheMap.keySet());
}
}
package com.example.multicache.config;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.support.AbstractValueAdaptingCache;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.Callable;
public class MultiLevelCache extends AbstractValueAdaptingCache {
private final String name;
private final Cache<Object, Object> localCache;
private final RedisCacheWriter redisCacheWriter;
private final RedisSerializer<Object> valueSerializer;
private final Duration ttl;
public MultiLevelCache(
String name,
Cache<Object, Object> localCache,
RedisCacheWriter redisCacheWriter,
RedisSerializer<Object> valueSerializer,
Duration ttl
) {
super(true);
this.name = name;
this.localCache = localCache;
this.redisCacheWriter = redisCacheWriter;
this.valueSerializer = valueSerializer;
this.ttl = ttl;
}
@Override
public String getName() {
return name;
}
@Override
public Object getNativeCache() {
return this;
}
@Override
protected Object lookup(@NonNull Object key) {
Object localValue = localCache.getIfPresent(key);
if (localValue != null) {
return localValue;
}
byte[] redisKey = buildKey(key);
byte[] bytes = redisCacheWriter.get(name, redisKey);
if (bytes != null) {
Object value = valueSerializer.deserialize(bytes);
localCache.put(key, toStoreValue(value));
return value;
}
return null;
}
@Override
public <T> T get(@NonNull 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 ValueRetrievalException(key, valueLoader, e);
}
}
@Override
public void put(@NonNull Object key, @Nullable Object value) {
Object storeValue = toStoreValue(value);
localCache.put(key, storeValue);
byte[] redisKey = buildKey(key);
byte[] redisValue = valueSerializer.serialize(fromStoreValue(storeValue));
redisCacheWriter.put(name, redisKey, redisValue, ttl);
}
@Override
public ValueWrapper putIfAbsent(@NonNull Object key, @Nullable Object value) {
ValueWrapper existing = get(key);
if (existing == null) {
put(key, value);
}
return existing;
}
@Override
public void evict(@NonNull Object key) {
localCache.invalidate(key);
redisCacheWriter.remove(name, buildKey(key));
}
@Override
public void clear() {
localCache.invalidateAll();
}
private byte[] buildKey(Object key) {
return (name + "::" + key).getBytes(StandardCharsets.UTF_8);
}
}
这里有个边界要说明:
clear()这里只清了本地缓存,没有全量清 Redis。生产上如果要支持按 cacheName 清理 Redis,需要结合 key 前缀扫描或统一 key namespace 设计,但要谨慎使用,避免SCAN带来额外压力。
7. 业务 Service
package com.example.multicache.service;
import com.example.multicache.model.Product;
import com.example.multicache.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 getById(Long id) {
return productRepository.findById(id);
}
@CacheEvict(cacheNames = "product", key = "#product.id")
public Product update(Product product) {
return productRepository.save(product);
}
}
这里采用的是“更新数据库后删除缓存”的思路。
因为@CacheEvict默认在方法成功返回后执行,所以比较符合我们的预期。
8. Controller
package com.example.multicache.controller;
import com.example.multicache.model.Product;
import com.example.multicache.service.ProductService;
import jakarta.validation.Valid;
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 get(@PathVariable Long id) {
return productService.getById(id);
}
@PutMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody @Valid UpdateProductRequest request) {
Product product = new Product();
product.setId(id);
product.setName(request.getName());
product.setPrice(request.getPrice());
return productService.update(product);
}
public static class UpdateProductRequest {
@NotNull
private String name;
@NotNull
private BigDecimal price;
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;
}
}
}
逐步验证清单
跑起来后,建议你按下面顺序验证,我平时也是这么检查的。
1. 首次查询走数据库
curl http://localhost:8080/products/1
第一次会比较慢,因为会 sleep 100ms 模拟数据库查询。
2. 第二次查询命中本地缓存
再次执行:
curl http://localhost:8080/products/1
这次通常明显更快。
3. 重启应用后验证 Redis 命中
- 第一次请求让 Redis 中有数据
- 重启应用
- 再次请求
此时本地缓存已空,但 Redis 仍在,应该走 Redis 再回填本地缓存。
4. 更新后验证缓存失效
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"iPhone 16","price":7999.00}'
然后再次查询:
curl http://localhost:8080/products/1
应该拿到新值,并重新写回缓存。
多级缓存一致性控制:你真正要关心什么
很多文章喜欢说“保证缓存一致性”,但如果你做过线上业务,就会知道这句话其实要加限定词。
大部分互联网业务做的是最终一致性,而不是强一致性。
常见一致性问题
1. 更新数据库成功,但删除缓存失败
这是最常见的问题之一。
结果是旧缓存继续存在,直到 TTL 到期。
解决思路:
- 删除缓存失败时重试
- 结合消息队列做异步补偿
- 关键数据增加短 TTL
- 给缓存值带版本号或更新时间,读时做兜底校验
2. 并发读写导致脏数据回填
场景大致是:
- 线程 A 查询旧数据
- 线程 B 更新数据库并删缓存
- 线程 A 把旧数据重新写回缓存
这就是经典的“旧值回填”。
可以用下面的思路降低风险:
- 写后删除缓存,并延迟二次删除
- 使用版本号校验
- 对极热点 key 做互斥更新
- 缩短热点缓存 TTL
下面是这个问题的时序图:
sequenceDiagram
participant A as 查询线程A
participant B as 更新线程B
participant DB as 数据库
participant Cache as 缓存
A->>DB: 查询旧数据
B->>DB: 更新新数据
B->>Cache: 删除缓存
DB-->>A: 返回旧数据
A->>Cache: 回填旧数据
3. 多实例本地缓存不一致
你有 3 个应用节点,每个节点都有自己的 Caffeine。
某个节点更新后只删除了自己的 L1,本地缓存就会不一致。
这也是多级缓存落地时最容易被忽略的点。
解决办法通常有三类:
- 简单方案:L1 TTL 设短一些,接受短时不一致
- 进阶方案:通过 Redis Pub/Sub 广播失效消息,所有节点同步清理本地缓存
- 重型方案:引入专门缓存同步组件
本文示例为了保持可运行和聚焦核心逻辑,没有把 Pub/Sub 展开。但生产上如果你真要大规模上多级缓存,我建议把它补上。
常见坑与排查
这部分很重要,因为“代码能跑”和“线上稳定”是两回事。
坑 1:Spring Cache 注解不生效
典型原因:
- 没加
@EnableCaching - 方法是
private - 同类内部方法调用,绕过了代理
- 异常导致
@CacheEvict没执行
排查建议:
@Cacheable(cacheNames = "product", key = "#id")
public Product getById(Long id) {
System.out.println("load from repository...");
return productRepository.findById(id);
}
如果第二次调用仍打印日志,大概率缓存没生效。
坑 2:序列化失败或反序列化类型异常
常见报错:
SerializationExceptionClassCastException
原因通常是:
- Redis 使用了 JDK 序列化,版本升级不兼容
- 不同服务对同一个 key 的序列化方式不一致
- 泛型对象反序列化丢类型信息
建议:
- 统一使用 JSON 序列化
- 不同服务共享缓存时,提前约定 key 和 value 格式
- 复杂对象不要随意改字段语义
坑 3:缓存穿透
请求的数据本来就不存在,例如 id = 99999999。
如果每次都查不到,那就会次次打到数据库。
处理方式:
- 对空值做短 TTL 缓存
- 接口层做参数校验
- 对恶意 ID 做限流或拦截
如果你决定缓存空值,要特别注意 TTL 要短,避免误伤“刚新增数据”。
坑 4:缓存雪崩
大量 key 在同一时刻过期,流量瞬间回源数据库。
建议:
- TTL 加随机抖动
- 热点 key 永不过期 + 主动刷新
- 数据库前面加互斥锁或单飞机制
坑 5:热点 key 击穿
某个超级热点 key 失效后,大量请求同时回源。
解决方式:
- 单 key 互斥重建
- 使用逻辑过期
- 后台异步刷新
Spring Cache 默认不直接解决这个问题,必要时要自己扩展。
安全/性能最佳实践
这一节给一些更偏线上实践的建议,尽量务实。
1. Redis key 设计要可读、可治理
建议格式:
{应用名}:{业务}:{环境}:{主键}
例如:
mall:product:prod:1
好处:
- 方便排查
- 避免多系统 key 冲突
- 方便迁移和清理
2. 本地缓存不要设太大
Caffeine 很快,但它吃的是 JVM 堆内存。
本地缓存一旦设太大,会带来:
- Full GC 风险增加
- 内存挤占业务对象
- 热点不明显时收益有限
经验建议:
- 只缓存真正热点数据
- 先从几千到几万条量级试
- 用监控看命中率和堆使用率,不要拍脑袋
3. TTL 分层设计
不要所有缓存都 5 分钟。
不同业务应该有不同生命周期:
- 商品详情:几分钟到十几分钟
- 用户权限:几十秒到几分钟
- 配置字典:更长
- 空值缓存:10~60 秒
如果 TTL 一刀切,通常不是最优。
4. 给缓存加监控
至少关注这些指标:
- L1 命中率
- L2 命中率
- 数据库回源次数
- Redis RTT
- 缓存序列化耗时
- 缓存大小与驱逐次数
如果没有这些指标,你很难知道: “是多级缓存真的优化了性能,还是只是让系统更复杂了。”
5. 不要缓存敏感数据明文
如果缓存内容包含:
- token
- 身份信息
- 权限数据
- 手机号、身份证号等隐私字段
至少要考虑:
- 是否真的需要缓存
- 是否脱敏
- 是否需要加密
- Redis 访问权限是否隔离
- key 是否会泄露业务语义
缓存不是天然安全区,这点特别容易被忽略。
6. 多节点场景下补齐 L1 失效广播
如果系统实例较多,而你又高度依赖本地缓存命中率,我建议加一层本地缓存失效通知。
典型做法:
- 更新后删除 Redis
- 发布失效事件到 Redis Pub/Sub
- 所有应用实例订阅并删除自己的 Caffeine key
结构如下:
flowchart LR
A[实例A 更新数据] --> B[更新DB]
B --> C[删除Redis缓存]
C --> D[发布失效消息]
D --> E[实例A 清理L1]
D --> F[实例B 清理L1]
D --> G[实例C 清理L1]
这一步不是“必须第一天就做”,但如果你要把多级缓存用于核心接口,它通常值得做。
适用边界与取舍分析
多级缓存不是银弹,我建议你先看业务是否适合。
适合的场景
- 读多写少
- 热点明显
- 允许秒级以内最终一致
- 接口对 RT 很敏感
- 应用实例较多,Redis 压力偏高
不太适合的场景
- 强一致要求极高
- 写非常频繁
- 数据变化后必须全节点瞬时一致
- 缓存对象过大,序列化成本高
- 热点不明显,本地缓存收益小
如果是库存、余额、强事务链路这类场景,我通常不会优先推荐这套方案。
一个更稳的生产落地建议
如果你准备把本文方案带到生产,我建议按下面节奏推进:
- 第一阶段:先上 Redis 单层缓存,跑通指标
- 第二阶段:给热点接口加 Caffeine 本地缓存
- 第三阶段:补齐 L1 失效广播
- 第四阶段:增加互斥重建、空值缓存、TTL 抖动
- 第五阶段:完善监控、压测、故障演练
这样做的好处是:
你不会在第一天就把系统搞得非常复杂,但每一步都能看到明确收益。
总结
这篇文章我们完成了一套基于 Spring Cache + Redis + Caffeine 的多级缓存实战,核心思路可以概括成几句话:
- L1 本地缓存负责快
- L2 Redis 负责共享
- 数据库负责最终真实数据
- 写操作优先更新 DB,再删除缓存
- 一致性目标是可控的最终一致,不要幻想零成本强一致
如果你现在要落地,我给你三个直接可执行的建议:
- 先从读多写少的热点接口开始,别一上来全站铺开
- 先实现失效而不是复杂更新,删除缓存通常比更新缓存更稳
- 一定补监控和压测,不然你不知道多级缓存到底是在提速还是在制造复杂度
最后再强调一个边界:
多级缓存很适合“性能优化型问题”,但它不是所有一致性问题的终极答案。
当业务进入强一致、高并发写入、跨节点同步极敏感的场景时,应该优先回到业务建模、事务边界和数据架构本身,而不是把希望全押在缓存上。
如果你愿意在这个基础上继续往前一步,下一个很自然的演进方向就是:给 L1 增加 Redis Pub/Sub 失效广播,以及为热点 key 增加互斥重建机制。 这两步做完,线上可用性会明显更稳。