Appearance
Redis 缓存设计与一致性治理
这篇笔记专门整理 Redis 最常见、也最容易在工程里出问题的一条主线:缓存怎么设计,缓存和数据库怎么协同,以及异常流量怎么治理。
缓存真正的难点,不是“查不到就回源”,而是:在高并发、数据更新和异常流量同时出现时,系统还能不能稳。
1. 为什么缓存不是简单的性能优化
缓存当然能带来两个直接收益:
- 降低数据库压力
- 提高读取速度
但它也会把新的复杂度带进系统:
- 数据一致性窗口
- 失效后的瞬时回源
- 热点 Key 管理
- 异常请求放大
所以缓存从来不是“加个 Redis 就结束”,而是一套完整的治理问题。
2. 🌟 基于 Redis 的常见缓存模式有哪些
缓存模式 不是 Redis 某条单独命令,而是应用、缓存、数据库三者在读写时如何协同的一套固定路径。核心无非 4 个问题:
- 读请求先查谁
- 没命中之后由谁回源
- 写请求先改缓存还是先改数据库
- 缓存何时失效、何时刷新
2.1 Cache Aside(旁路缓存)
Cache Aside 是最常见的模式,本质上就是:缓存放在业务旁边,由业务代码自己决定什么时候查缓存、什么时候查数据库、什么时候删缓存。
读流程
- 先查缓存
- 缓存没命中再查数据库
- 将结果回填到缓存
写流程
最常见的做法通常是:
- 先更新数据库
- 再删除缓存
很多系统更偏向“更新数据库后删缓存”,核心原因通常有两个:
- 数据库才是最终真实数据源
- 主动维护缓存更新路径通常更复杂,也更容易漏
这种模式并不保证绝对实时一致,但通常能在实现复杂度和一致性之间取得比较稳妥的平衡。
商品详情的 Cache Aside 读写路径
如果只想抓住主线,记住下面 4 个动作就够了:
- 命中缓存时直接返回,避免每次都回源数据库
- 数据库里本来就不存在的数据,会写一个短 TTL 的空值,避免缓存穿透
- 正常缓存 TTL 加一点随机值,避免大批 Key 同时过期
- 更新时先改数据库,再删缓存,让数据库继续做最终数据源
执行结果可以这样理解:
- 第一次读商品详情时,大概率会查数据库并回填 Redis
- 后续相同请求会直接命中 Redis,数据库压力会明显下降
- 更新价格后,下一次查询虽然会回源一次,但读到的是数据库新值,并会把新值重新写回缓存
如果场景只是单 Key 详情查询和简单的更新后删缓存,继续看下面的 Spring Boot Starter Cache + Redis 会更直观;如果后面遇到空值缓存、随机 TTL、热点 Key 保护、延迟双删这类问题,再回到手写 Cache Aside 的思路会更自然。
用 Spring Boot Starter Cache + Redis 落地注解缓存
如果不想在每个业务里都手写“查缓存、回源、回填、删缓存”这套模板,工程上很常见的做法是用 Spring Boot Starter Cache + Redis。它提供的是 Spring 缓存抽象的接入能力,不是 Redis 原生自带的缓存模式;更贴近它运行方式的说法是,由 Spring 代理层统一接管“查缓存 -> 方法执行 -> 回填缓存 / 删除缓存”这条通用路径。
最适合这种写法的场景通常是:
- 单 Key 的详情查询缓存
- 更新后删单个缓存 Key
- 读路径比较稳定、Key 规则比较简单的接口
如果是多 Key 联动失效、延迟双删、事务提交后再删缓存、热点 Key 重建保护这类更复杂的场景,往往还是手写 Cache Aside 更稳。
java
/**
* Redis 全局缓存配置。
*
* <p>这里统一定义 Redis 缓存的序列化方式和默认 TTL。
* 实际使用时,还需要在启动类或配置类上打开 `@EnableCaching`。</p>
*/
@Configuration
public class RedisCacheConfig {
/**
* 配置 RedisCacheManager,让 Spring 缓存抽象把数据真正写入 Redis。
*
* @param redisConnectionFactory Redis 连接工厂
* @return 基于 Redis 的缓存管理器
*/
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
// key 使用字符串序列化,方便在 Redis 里直观看到 key
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
// value 使用 JSON 序列化,便于缓存对象
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
// 默认 TTL 给 30 分钟
.entryTtl(Duration.ofMinutes(30))
// 这里不缓存 null,避免把“查不到”的结果长期缓存住
.disableCachingNullValues();
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultConfig)
.transactionAware()
.build();
}
}
/**
* 商品详情返回对象。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductDetailDTO {
// 商品主键
private Long productId;
// 商品名称
private String productName;
// 当前销售价格
private BigDecimal price;
}
/**
* 商品缓存实现示例。
*
* <p>这里把缓存逻辑交给 `@Cacheable` 和 `@CacheEvict`,
* 有意省略 `Mapper / Repository` 等细节,只保留“查库”和“写库”的核心动作。
* 像 `queryProductDetailById`、`updateProductPriceById` 这样的调用,只是表达数据库动作语义,并不展开具体实现。</p>
*/
@Service
public class ProductCacheFacade {
/**
* 查询商品详情。
*
* @param productId 商品主键 ID
* @return 商品详情
*/
@Cacheable(cacheNames = "product:detail", key = "#productId")
public ProductDetailDTO getProductDetail(Long productId) {
return queryProductDetailById(productId);
}
/**
* 更新商品价格,并删除详情缓存。
*
* @param productId 商品主键 ID
* @param newPrice 新价格
*/
@CacheEvict(cacheNames = "product:detail", key = "#productId", beforeInvocation = false)
public void updatePrice(Long productId, BigDecimal newPrice) {
updateProductPriceById(productId, newPrice);
}
}这套方案真正省掉的是这些重复工作:
- 不用每个查询方法都手写 Redis get
- 不用每个查询方法都手写序列化 / 反序列化
- 不用每个更新方法都自己拼删除缓存的模板代码
但它也有明确边界:
- 更适合单 Key 的详情缓存,不适合特别复杂的缓存编排
- 同类内部方法调用时,缓存注解可能不会经过代理
- 多 Key 联动删除、延迟双删、热点 Key 重建保护,通常还得自己写
- 如果你要严格控制“事务提交后再删缓存”的时机,仍然要结合事务同步机制做更细控制
把这层边界记住:Spring Boot Starter Cache + Redis 更适合统一收口简单缓存访问;手写 Cache Aside 更适合处理复杂一致性和治理细节。
2.2 Read Through(穿透式读取)
Read Through 可以理解成:应用不直接关心数据库读取细节,而是统一向缓存层要数据。
它的典型路径是:
- 应用先访问缓存层
- 如果缓存命中,直接返回
- 如果缓存没命中,由缓存层自己去查数据库
- 查到结果后,缓存层把数据写回 Redis,再返回给应用
它和 Cache Aside 的差别在于:
Cache Aside由业务代码自己回源数据库Read Through由缓存层代替业务去回源
也就是说,它把“查不到缓存后怎么办”封装进了缓存组件。
在 Spring Boot Starter Cache + Redis 这类应用层方案里,@Cacheable 的运行效果其实就很接近这种 Read Through 思路:
- 先查 Redis
- 命中就直接返回
- 没命中才执行方法体
- 方法返回后再把结果回填到 Redis
但要注意,Spring Cache 只是通过缓存抽象和代理机制,在应用层实现了接近 Read Through 的效果;它不是 Redis 原生自带的 Read Through 能力。
2.3 Write Through(穿透式写入)
Write Through 可以理解成:应用写数据时,不是自己分别改 Redis 和数据库,而是把写操作交给缓存层,由缓存层同步写入缓存和数据库。
它的典型路径是:
- 应用发起写请求
- 缓存层先更新 Redis
- 同步把变更写入数据库
- 两边都成功后再返回结果
这个模式的特点是:
- 读路径比较稳定,因为缓存通常总是较新
- 一致性会比单纯的“删缓存”更容易收敛
- 但写路径更重,写延迟通常更高
2.4 Write Behind / Write Back(异步回写)
Write Behind / Write Back 可以理解成:先写缓存,后写数据库,而且落库动作不是同步完成,而是异步批量回写。
它的典型路径是:
- 应用先更新 Redis
- Redis 或后台任务把结果返回出去
- 后台线程、消息队列或批处理程序稍后再把数据刷回数据库
它的优点是:
- 写吞吐高
- 应用响应快
- 适合高频、可聚合的写入场景
边界也很明显:
- 数据库可能暂时落后于缓存
- 异步回写链路失败时,需要补偿机制
- 一旦系统异常退出,可能出现部分数据还没来得及落库
2.5 Refresh Ahead(预刷新)
Refresh Ahead 可以理解成:缓存还没真正失效之前,系统就提前把它刷新好。
它更适合热点数据,典型路径通常是:
- 系统发现某个热点 Key 快要过期
- 后台线程提前去查数据库或上游服务
- 在真正过期之前把新值写回缓存
它的目标不是让缓存“永远不失效”,而是尽量避免热点 Key 在同一时刻失效后引发大量请求一起回源。
2.6 多级缓存(Local Cache + Redis)
多级缓存可以理解成:不只用一层 Redis,而是在应用本地再加一层更近的缓存。
最常见的形态是:
- 先查应用本地缓存
- 本地没有再查 Redis
- Redis 没命中再查数据库
它的优点是:
- 速度更快
- 可以进一步降低 Redis 压力
- 对超热点数据更友好
它的难点是:
- 多一层缓存,就多一层一致性治理
- 本地缓存刷新和失效同步会更复杂
2.7 逻辑过期(Logical Expiration)
逻辑过期不是 Redis 官方单独的一种读写协议,而是一种很常见的热点缓存治理模式。它的做法是:Key 本身不一定真的立刻过期,而是在 value 里额外保存一个“业务过期时间”。
典型路径通常是:
- 读取缓存数据
- 发现业务过期时间还没到,直接返回
- 如果业务过期时间已到,先返回旧值或走降级逻辑
- 后台异步刷新缓存
它常常和热点 Key 治理放在一起,因为它重点解决的是:高并发下,热点 Key 不想在某个时间点突然彻底失效。
3. 🌟 缓存和数据库一致性应该怎么理解
Redis 做缓存时,最重要的前提是:数据库通常才是主存储,缓存是加速层。
因此很多一致性问题,不是要做到“缓存和数据库永远完全同步”,而是要回答:
- 能接受多大的不一致窗口
- 哪些数据可以延迟一致
- 哪些数据必须更快地收敛一致
工程上常见的做法包括:
- 更新数据库后删除缓存
- 延迟双删
- 异步重建缓存
- 对关键读直接绕过缓存
选哪种方案,不是看哪种“最高级”,而是看业务容忍度和系统复杂度。
3.1 更新数据库后删缓存 + 延迟双删
如果业务很担心“删完缓存之后,有并发请求又把旧值回填回来”,工程上就会在第一次删除之后,再补一次延迟删除。更稳妥的做法通常不是把删缓存动作放在事务提交前,而是挂到 afterCommit 里执行,避免数据库事务还没提交就把缓存删掉:
java
/**
* 商品缓存一致性服务。
*
* <p>这里演示的是:更新数据库后,不只立即删缓存,还在事务提交后补一次延迟删除。</p>
*/
@Service
@RequiredArgsConstructor
public class ProductCacheConsistencyService {
private static final String PRODUCT_CACHE_KEY = "product:detail:";
private final ProductRepository productRepository;
private final StringRedisTemplate stringRedisTemplate;
private final TaskScheduler taskScheduler;
/**
* 更新库存,并在事务提交成功后执行删缓存与延迟双删。
*
* @param productId 商品主键 ID
* @param newStock 最新库存值
*/
@Transactional
public void updateStock(Long productId, Integer newStock) {
// 第一步先改数据库里的库存
productRepository.updateStock(productId, newStock);
String cacheKey = PRODUCT_CACHE_KEY + productId;
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// 事务真正提交成功之后,先删一次缓存
stringRedisTemplate.delete(cacheKey);
// 第二次删除用于兜住“旧值被并发请求重新写回缓存”的窗口
taskScheduler.schedule(
() -> stringRedisTemplate.delete(cacheKey),
Instant.now().plusSeconds(1)
);
}
});
}
}可以直接把它的执行效果看成:
- 主事务里只负责改数据库,不在事务未提交时提前删缓存
- 事务提交成功后立即删一次缓存,让后续读请求尽快回源新值
- 如果刚好有并发线程在旧值窗口里把缓存重建回去了,1 秒后的第二次删除会再把它清掉
真正上线时,第二次删除常常会改成:
- MQ 延迟消息
- 延迟任务
- 统一的缓存重建/失效平台
核心目标不是“机械地删两次”,而是把旧值可能被回填的时间窗口压到业务能接受的范围内。
4. 🌟 缓存穿透、击穿、雪崩的区别
4.1 缓存穿透
缓存和数据库里都没有这份数据,请求仍然不断打到数据库。
典型治理方式:
- 缓存空值
- 布隆过滤器
- 参数校验和非法请求拦截
4.2 缓存击穿
某个热点 Key 在失效瞬间,大量请求一起回源数据库。
典型治理方式:
- 热点 Key 预热
- 互斥锁或单飞控制
- 逻辑过期
4.3 缓存雪崩
大量 Key 在同一时间段失效,导致整个后端流量瞬间冲向数据库。
典型治理方式:
- 过期时间加随机值
- 多级缓存
- 限流、降级、熔断
这三个问题都和缓存失效相关,但治理重点并不一样:
- 穿透更偏“查不存在的数据”
- 击穿更偏“单个热点失效”
- 雪崩更偏“整体大面积失效”
5. 热点 Key 应该怎么治理
很多缓存问题的根因,最后都会落到热点 Key 上。典型表现包括:
- 某个商品详情访问特别集中
- 首页推荐结果被大量读取
- 某个配置或排行榜被全站频繁访问
治理思路通常包括:
- 提前预热
- 永不过期 + 异步刷新
- 本地缓存 + Redis 多级缓存
- 回源保护和限流
如果热点 Key 失效后没有保护措施,它就很容易从“一个缓存点”变成整个系统的放大器。
6. 逻辑过期和物理过期怎么取舍
6.1 物理过期
直接给 Key 设置 TTL,到时间自然失效。
优点:
- 简单
- Redis 原生支持
缺点:
- 热点 Key 到点失效时,容易引发击穿
6.2 逻辑过期
把“业务过期时间”存进 Value 里,Key 本身不一定立刻删除。
优点:
- 更适合热点数据平滑刷新
- 可以在旧数据和可用性之间做权衡
缺点:
- 代码复杂度更高
- 需要后台刷新或互斥重建策略
这两种方式没有绝对优劣,更重要的是:热点数据是否值得为了平滑性多做一层治理。
7. 缓存设计里经常被忽略的几个细节
7.1 TTL 不要一刀切
不同数据的更新频率和一致性要求不同,不适合所有 Key 都用同样的过期时间。
7.2 空值缓存也要设置合理 TTL
否则会出现长期缓存“空结果”,影响后续真实数据写入后的可见性。
7.3 回源链路必须可控
如果缓存失效后数据库没有限流、降级和回源保护,缓存层反而会把故障放大。
7.4 缓存命中率不是唯一指标
命中率重要,但更关键的是:
- 没命中时系统还能不能扛住
- 热点更新时一致性能不能接受
8. 一份实用的缓存治理检查清单
可以优先检查这些问题:
- 数据库和缓存谁是最终数据源是否明确
- 更新路径是删缓存还是重建缓存是否明确
- 穿透、击穿、雪崩是否各有治理方案
- 热点 Key 是否做了预热和保护
- TTL 是否根据业务特性分层设计
- 回源链路是否有锁、限流或降级保护
Redis 缓存设计真正难的地方,不是命中时有多快,而是未命中、失效、更新交错时还能不能稳。