返回文章列表
高并发项目
Redis缓存穿透缓存击穿缓存雪崩

13redis缓存的问题与解决方案

加入缓存后会带来一系列典型问题,主要包括:

  1. 数据库数据更新了,但缓存中数据未更新,导致数据不一致
  2. 缓存穿透:请求的数据在缓存和数据库中都不存在
  3. 缓存雪崩:大量缓存 key 同时失效或 Redis 服务宕机
  4. 缓存击穿:热点 key 突然失效,瞬间大量请求打到数据库

本章系统讲解这些问题的成因与解决方案。

一、缓存更新策略

缓存更新策略有以下三种:

策略 说明 一致性 维护成本
内存淘汰 不用自己维护,利用 Redis 的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存 差 无
超时剔除 给缓存数据添加 TTL,到期后自动删除缓存,下次查询时更新缓存 一般 低
主动更新 编写业务逻辑,在修改数据库的同时,更新缓存 好 高

业务场景选择:

  • 低一致性需求:使用内存淘汰机制,例如店铺类型、商品分类等查询缓存
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情、商品详情查询的缓存

主动更新的三种方案

  1. Cache Aside Pattern:由缓存的调用者,在更新数据库的同时更新缓存
  2. Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性,调用者调用该服务,无需关心一致性问题
  3. Write Behind Caching Pattern:调用者只操作缓存,由其它线程异步将缓存数据持久化到数据库,保证最终一致

其中 Cache Aside Pattern 最为常用,操作缓存与数据库时需要考虑三个关键问题:

1. 删除缓存还是更新缓存?

  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存(推荐)

2. 如何保证缓存与数据库的操作同时成功或失败?

  • 单体系统,将缓存与数据库操作放在一个事务中
  • 分布式系统,利用 TCC 等分布式事务方案
  • 缓存设置过期时间,作为兜底方案

3. 先操作缓存还是先操作数据库?

  • 先删除缓存,再操作数据库:并发场景下会出现数据不一致
  • 先操作数据库,再删除缓存:并发场景下出现不一致的概率较低,推荐使用

缓存更新最佳实践

  • 低一致性需求:使用 Redis 自带的内存淘汰机制
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案
  • 读操作:缓存命中则直接返回;缓存未命中则查询数据库,并写入缓存,设定超时时间
  • 写操作:先写数据库,然后再删除缓存,要确保数据库与缓存操作的原子性

二、缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,给数据库带来巨大压力。

解决方案

  1. 缓存空对象:
    • 优点:实现简单,维护方便
    • 缺点:额外的内存消耗;可能造成短期的不一致
  2. 布隆过滤:
    • 优点:内存占用较少,没有多余 key
    • 缺点:实现复杂;存在误判可能

布隆过滤器原理

布隆过滤是一种数据统计的算法,用于检索一个元素是否存在于一个集合中。它无需存储元素到集合,而是把元素映射到一个很长的二进制数位上:

  1. 首先需要一个很长的二进制数,默认每一位都是 0
  2. 然后需要 N 个不同算法的哈希函数
  3. 将集合中的元素根据 N 个哈希函数做运算,得到 N 个数字,然后将每个数字对应的 bit 位标记为 1
  4. 要判断某个元素是否存在,只需要把元素按照上述方式运算,判断对应的 bit 位是否都是 1 即可

布隆过滤器不一定 100% 准确,所以建议使用缓存空对象,简单方便。

缓存穿透的成因与综合对策

缓存穿透产生的原因:用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力。

完整的解决方案包括:

  • 缓存 null 值
  • 布隆过滤
  • 增强 id 的复杂度,避免被猜测 id 规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

三、缓存雪崩

缓存雪崩是指在同一时段大量的缓存 key 同时失效或者 Redis 服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案

  • 给不同的 Key 的 TTL 添加随机值
  • 利用 Redis 集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

四、缓存击穿

缓存击穿问题也叫热点 Key 问题,就是一个被高并发访问并且缓存重建业务较复杂的 key 突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

解决方案

1. 互斥锁

线程查询缓存未命中后,尝试获取互斥锁:

  • 获取锁成功:查询数据库、重建缓存数据,写入缓存后释放锁
  • 获取锁失败:休眠一段时间后重试,直到缓存命中

互斥锁能保证强一致性,但线程需要等待,可能影响性能。

2. 逻辑过期

在缓存中存储逻辑过期时间字段,不设置 Redis 的 TTL:

  • 查询缓存时判断逻辑时间是否过期
  • 未过期:直接返回缓存数据
  • 已过期:尝试获取互斥锁
    • 获取成功:开启独立线程查询数据库、重建缓存数据、重置逻辑过期时间、释放锁;当前线程返回过期数据
    • 获取失败:直接返回过期数据

逻辑过期方案线程无需等待,性能较好,但不保证一致性,有额外内存消耗。

两种方案对比

解决方案 优点 缺点
互斥锁 没有额外的内存消耗、保证一致性、实现简单 线程需要等待,性能受影响;可能有死锁风险
逻辑过期 线程无需等待,性能较好 不保证一致性;有额外内存消耗;实现复杂

五、缓存工具封装

基于 StringRedisTemplate 封装一个缓存工具类,可以满足以下需求:

  1. 将任意 Java 对象序列化为 JSON 并存储在 string 类型的 key 中,并且可以设置 TTL 过期时间
  2. 将任意 Java 对象序列化为 JSON 并存储在 string 类型的 key 中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
  3. 根据指定的 key 查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
  4. 根据指定的 key 查询缓存,并反序列化为指定类型,利用逻辑过期解决缓存击穿问题

通过封装统一的缓存工具类,可以将缓存穿透、缓存击穿等解决方案沉淀为可复用的代码,避免在每个业务接口中重复实现。