深入理解 Redis 缓存:原理、应用与挑战

一、什么是缓存

1.1 缓存的定义和原理

缓存(Cache)是一种存储介质,用于临时存储经常被访问的数据,以加快数据读取速度、减少对后端服务(如数据库)的访问压力。它通常基于内存实现,具备高性能、高并发的特点。

在后端系统中,缓存位于客户端和数据库之间,起到中间加速层的作用。当用户请求某个数据时,系统优先从缓存中读取,若命中缓存,则快速返回结果;若未命中,则访问数据库并将结果写入缓存,以备下次使用。

1.2 缓存的作用

  • 提高响应速度:内存访问速度远高于磁盘,显著降低用户请求的响应时间。
  • 减轻数据库压力:缓存热点数据可以有效减少数据库访问次数,提升整体系统吞吐量。
  • 增强系统稳定性:在高并发场景下,通过缓存限流减压,避免数据库过载。

1.3 缓存的成本与挑战

  • 内存资源消耗大:缓存通常存储在内存中,成本较高。
  • 一致性维护困难:缓存中的数据可能与数据库不一致。
  • 复杂度提升:需要考虑缓存更新策略、失效策略、缓存预热等问题。

二、为什么选 Redis 作为缓存

2.1 Redis 简介

Redis 是一个开源的高性能键值对存储系统,数据完全存储在内存中,并定期持久化到磁盘,支持丰富的数据结构,如字符串、哈希、列表、集合、有序集合等。

2.2 Redis 的缓存优势

  • 高性能:内存读写速度极快,可轻松支撑百万级 QPS。
  • 丰富的数据结构:支持灵活的数据组织形式,便于实现各种缓存需求。
  • 单线程模型+高并发处理:保证了操作的原子性,避免锁竞争。
  • 支持持久化:通过 RDB 和 AOF 实现数据恢复能力。
  • 成熟的生态系统:支持分布式部署(Redis Cluster)、高可用方案(Sentinel)、丰富的客户端工具和监控体系。

三、常见的缓存模型

在实际开发中,缓存的设计不仅要考虑“存什么”和“存多久”,更需要选择合适的缓存更新模型。不同的缓存模型在数据一致性、系统复杂度、性能表现等方面各有优劣。

3.1 常见缓存更新策略对比

策略 描述 一致性 维护成本
内存淘汰 不主动清除缓存,依赖 Redis 的内存淘汰机制,在内存不足时自动剔除旧数据,下次查询时回源数据库并回填缓存。
超时剔除 为缓存数据设置 TTL,到期自动失效,访问时发现不存在再从数据库加载并回填缓存。 一般
主动更新 在业务代码中主动更新缓存,如在数据库变更时同步刷新或删除相关缓存数据。

选择建议:

  • 低一致性要求:如商品分类、店铺类型列表等冷变数据,可使用内存淘汰机制。
  • 高一致性要求:如商品详情、订单信息等频繁变化的数据,建议使用主动更新 + TTL 兜底。

3.2 常见缓存模型

Cache Aside(旁路缓存)

最常用的模型,缓存由应用层手动维护。

  • 读操作
    • 先查缓存 → 缓存未命中 → 查询数据库 → 回填缓存;
  • 写操作
    • 更新数据库 → 删除缓存(或更新缓存);

优点:可控性高;
缺点:需要开发者显式维护缓存逻辑;
适用场景:读多写少、可接受短暂不一致的业务场景。


Read/Write Through(读/写穿透)

由缓存系统代理所有读写请求。

  • 读操作:应用请求先到缓存,由缓存系统自动判断是否命中;
  • 写操作:写入操作由缓存系统同步写入缓存与数据库;

优点:对调用者简单;缓存更新与数据库同步,保证较高一致性;
缺点:依赖中间缓存框架实现,侵入性高;
适用场景:中大型系统、对一致性要求高、适合使用成熟缓存代理中间件的场景。


Write Behind(异步写回)

写操作先写缓存,再异步写入数据库(延迟双写)。

  • 优势
    • 对调用者简单
    • 写操作性能高(多次缓存操作后,一次性写入数据库);
  • 劣势
    • 维护复杂,需要实时监控缓存的变化
    • 一致性难以保证
      • 对缓存操作期间,尚未写入数据库,如果此时有其他线程访问了数据库,就会有数据不一致的问题出现
    • 可靠性难以保证
      • 缓存基于内存,未持久化时一旦宕机会导致数据丢失

适用场景:日志收集、计数器、埋点数据等对一致性要求较低的高频写业务。


TTL 缓存模型(基于过期时间)

通过为缓存设置过期时间自动清除失效数据。

  • 常与旁路缓存结合,用于短期热点数据或容错兜底;
  • TTL 设计需结合业务特性,如短期活动数据、实时新闻、接口幂等性结果等。

四、缓存的生命周期管理

4.1 缓存失效策略

  • 定期过期(TTL):为每个键设置过期时间,到期自动清除。
  • 逻辑过期:缓存中存储数据+过期时间,由应用判断是否过期。
  • 被动失效:访问数据时发现已过期再清除。

4.2 Redis 淘汰策略

当 Redis 达到最大内存限制时,自动触发淘汰策略,清理部分数据:

  • noeviction:不淘汰,返回错误(默认)
  • volatile-lru:淘汰设置了过期时间的键中最近最少使用的
  • allkeys-lru:淘汰所有键中最近最少使用的
  • volatile-random / allkeys-random:随机淘汰
  • volatile-ttl:优先淘汰最近要过期的数据

4.3 缓存预热和延迟加载

  • 缓存预热:系统启动时提前将热点数据加载到缓存中,避免冷启动带来的性能问题。
  • 延迟加载:缓存未命中时再访问数据库并写入缓存,常与旁路缓存结合使用。

五、缓存一致性问题

5.1 缓存一致性问题来源

在使用缓存的系统中,缓存与数据库的数据存在“非强一致性” 问题,即在某些时间点,缓存中的数据可能与数据库中的实际数据不一致,主要源自以下几个方面:


1. 非原子性更新导致数据不一致

缓存和数据库的更新是两个独立的操作,通常无法保证“原子性”,即无法在一个事务中同时完成。因此,在高并发异常中断场景下,可能发生如下问题:

  • 数据库已更新,但缓存仍为旧值;
  • 缓存已更新,但数据库更新失败;
  • 缓存和数据库分别被不同线程并发修改,导致更新顺序错乱。

2. 并发访问导致缓存脏读

在“缓存删除 + 数据库更新”模式下,若有并发请求在缓存被删除后、数据库尚未更新完成前,读取数据库旧值并重新写入缓存,可能会将旧数据错误写回缓存


3. 缓存延迟更新引发短暂不一致

某些系统为了降低延迟,采用异步刷新缓存延迟双删策略。虽然可以减少实时更新的性能开销,但在缓存与数据库之间仍会存在延迟窗口期,导致数据短时间不一致。


4. 异常或网络故障导致缓存更新失败

在实际部署中,网络异常、服务宕机、GC 卡顿等问题可能使缓存更新操作失败,尤其是在使用异步消息队列或延迟删除等策略时,可能导致缓存“漏删”或“漏更”


5. 多级缓存或本地缓存未同步

在某些复杂架构中,系统会使用多级缓存(如本地缓存 + Redis 分布式缓存)。若这些缓存之间没有统一的失效机制或同步机制,就可能出现缓存层之间的不一致问题


小结

缓存一致性问题的核心原因是缓存与数据库无法原子操作 + 缓存更新延迟或失败。虽然完全强一致性很难实现,但可以通过合理的策略(延迟双删、分布式锁、TTL、MQ 等)在性能与一致性之间取得工程上的平衡

5.2 常见解决方案

缓存与数据库之间存在数据一致性问题的根源在于更新的非原子性,即更新过程中缓存和数据库无法同时成功或失败。以下是常见的应对策略:

方案一:延迟双删策略(Delayed Double Deletion)

更新流程:

  1. 删除缓存
  2. 更新数据库
  3. 延迟一定时间,再次删除缓存`

优点

  • 减少并发场景下缓存脏读的可能性;
  • 弥补数据库更新慢导致的并发查询误缓存的问题。
    缺点
  • 延迟时间难以把握,太短无效,太长影响性能;
  • 依赖定时器或异步任务机制实现。

方案二:消息队列异步同步(Async Sync via MQ)

  • 在数据库更新成功后,向消息队列(如 Kafka、RabbitMQ)发送通知,由消费者异步更新或清除缓存。

优点

  • 解耦服务,支持分布式系统;
  • 可用于批量处理缓存更新,提高性能。
    缺点
  • 实现复杂度高;
  • 存在消息丢失、重复消费等一致性挑战。

适用场景:强一致性要求高的系统,如金融交易、库存系统等。


方案三:设置合理的过期时间(TTL 容错策略)

  • 为缓存设置过期时间(如几分钟),即使出现短暂不一致,后续请求会自动回源数据库并回填缓存。

优点

  • 实现简单,开发成本低;
  • 避免缓存长期脏读。
    缺点
  • 无法立即反映数据库更新;
  • 对数据敏感业务不适用。

适用场景:对一致性要求适中,如商品列表、资讯流等场景。


方案四:使用分布式锁或版本号控制并发写入

  • 在更新缓存前,使用 Redis 分布式锁控制写操作;
  • 或采用版本号机制,确保写入顺序一致性。

优点

  • 减少并发写冲突,保证数据顺序性;
  • 适合热点数据并发更新场景。
    缺点
  • 增加系统复杂度;
  • 锁失效或竞争严重时可能影响性能。

适用场景:高并发场景下的用户信息、商品详情等核心数据。


5.3 缓存与数据库操作顺序分析

问题一:更新缓存还是删除缓存?

  • 更新缓存(✗)
    • 每次数据库变更都同步更新缓存;
    • 如果没有读请求,则浪费性能,带来大量无效写操作。
  • 删除缓存(✓)
    • 更新数据库后直接删除缓存;
    • 下次读请求时再加载最新数据到缓存中。

结论:推荐使用“删除缓存”方式更新缓存数据。


问题二:如何保证缓存和数据库更新操作的一致性?

  • 单体系统
    • 可将缓存和数据库操作封装在一个事务中(如 Spring 的事务传播机制)。
  • 分布式系统
    • 可采用 TCC、SAGA 等分布式事务方案;
    • 或通过消息队列等方式实现最终一致性。

问题三:先操作缓存还是先操作数据库?

方法一:先删除缓存 → 再更新数据库
  • 正常流程示意
初始状态: 缓存 = 10,数据库 = 10 
更新操作: 删除缓存 → 更新数据库为 20 
最终状态: 缓存空,数据库 = 20,下次读取回填新值
  • 异常情况
线程 A:删除缓存 
线程 B:查询缓存为空 → 读数据库(此时数据库尚未更新)→ 缓存回填旧值10 
线程 A:更新数据库为 20  

结果:缓存 = 10,数据库 = 20,数据不一致!
方法二:先更新数据库 → 再删除缓存
  • 正常流程示意
初始状态: 缓存 = 10,数据库 = 10
更新操作: 更新数据库为 20 → 删除缓存
最终状态: 缓存空,数据库 = 20,下次读取回填新值
  • 异常情况
线程 A:读取缓存为空,查询到数据库为10
线程 B:更新数据库为20 → 删除缓存
线程 A:将10写入缓存

结果:缓存 = 10,数据库 = 20
  • 由于缓存操作速度快,一般情况下,A线程缓存写入操作不会过长导致中间还能塞下B的数据库操作+缓存操作,所以这种情况发生很少

最佳实践结论:

  • 两种顺序都存在线程安全问题;
  • “先更新数据库,再删除缓存”方案更安全,因为缓存操作速度更快,先删缓存更容易造成并发读写错误;
  • 建议结合 TTL 机制作为兜底,避免缓存长期不一致。

六、缓存穿透、击穿、雪崩

6.1 缓存穿透

概念

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

风险:由于数据不存在,情况下无法通过数据库里的数据给缓存添加值,所以每次请求都会打到数据库,当有大量此类请求时,数据库可能会瘫痪

产生原因

缓存穿透发生的常见原因有二:

  1. 误删:数据误删导致缓存和数据库都没有数据
  2. 恶意攻击:故意大量访问不存在的数据

常见解决方案

限制非法请求
  • 在API入口对请求参数进行合理性判断:是否含非法值,字段是否存在
  • 如果判断出是恶意请求就直接返回错误,避免进一步访问到缓存和数据库
缓存空对象或默认值

描述

  • 缓存空对象:在缓存中缓存null或默认值,避免请求打到数据库

优缺点

  • 优点:实现简单,维护方便
  • 缺点:
    • 额外的内存消耗(存储一堆乱七八糟的东西)
    • 可能造成短期的不一致
    • 通过设置TTL缓解

缓存空对象.png

布隆过滤

描述

  • 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在

具体实现流程
布隆过滤器部署在​​应用层​​,在访问缓存层(Redis)之前进行拦截:

	客户端请求 → 布隆过滤器 → 缓存层(Redis) → 数据库
  1. ​请求到达应用层​​:当有查询请求到达时,首先检查布隆过滤器
  2. ​布隆过滤器判断​​:
    • 如果布隆过滤器说"不存在" → 直接返回空结果(拦截)
    • 如果布隆过滤器说"可能存在" → 继续查询缓存
  3. ​缓存查询​​:
    • 缓存命中 → 返回结果
    • 缓存未命中 → 查询数据库
  4. ​数据库查询​​:
    • 数据库有数据 → 回填缓存并返回
    • 数据库无数据 → 可选择将空值也写入缓存(设置较短过期时间)

优缺点

  • 优点:内存占用少
  • 缺点:
    • 实现复杂
    • 存在误判可能,但不会漏判(说存在不一定存在,说不存在一定不存在

布隆过滤.png

其他方案
  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

实践

缓存穿透解决实践.png

上图展示了一个查询商铺功能解决缓存穿透的业务调整

核心思路:当被查询的数据不存在时,由直接返回异常变为了在缓存中写入空/默认对象

6.2 缓存击穿

概念

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

缓存击穿.png

  • 上图展示的是由于缓存重建过程较长,期间大量请求到来时导致的数据库压力增加

常见解决方案

互斥锁

互斥锁方案:保证同一时间只有一个业务更新缓存。

  • 未能获取互斥锁的请求。要么等待所释放后重新读取缓存,要么就返回空值或默认值
  • 最好设置超时时间,避免获得锁的请求因意外一直阻塞而不能释放锁导致系统无响应

互斥锁.png

逻辑过期
  • 即不给热点数据设置过期时间(TTL),将只是将过期时间存到VALUE里
  • 一般用于热点key,例如活动期间加入该key,活动结束后再移除
KEY VALUE
vks:game:1

逻辑过期.png

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

互斥锁:一致性
逻辑过期:可用性

实践

基于互斥锁

基于互斥锁方式解决缓存击穿问题
缓存击穿实践互斥锁.png

锁的设计

  • 利用string的setnx
127.0.0.1:6379> help setnx

  SETNX key value
  summary: Set the value of a key, only if the key does not exist
  since: 1.0.0
  group: string
  • 获取锁:setnx lock 1
    • set时需要设置有效期,避免没有正常执行释放锁的操作导致的问题
  • 删除锁:del lock
基于逻辑过期

基于逻辑过期方式解决缓存击穿问题
基于逻辑过期方式解决缓存击穿.png

6.3 缓存雪崩

概念

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

缓存雪崩.png

解决方案

从概念上可以看出,缓存雪崩主要有两个原因:

  • 大量数据同时过期
  • Redis故障宕机
大量数据同时过期
  1. 均匀设置过期时间
    • 缓存预热时,会批量导入数据,这时可能大量数据有效期一致。
    • 我们可以在设置过期时间时,给不同的Key的TTL添加随机值,从而减少同一时间有大量数据过期发生的可能性
  2. 互斥锁
    • 类似上文解决缓存击穿的解决思路,当发现数据不在Redis中,就加个互斥锁,获得锁的线程构建缓存
  3. 后台更新缓存
    • 不给缓存设置有效期,让缓存“永久有效”,将更新缓存的工作交由后台线程定时更新
    • 如何解决内存紧张时,缓存数据被淘汰,业务线程读取缓存失败的问题
      • 方法一:后台线程除了更新缓存外,还要负责频繁地检测缓存是否有效,缓存失效时马上重建缓存。
        • 从失效到被发现进而重构会有时间间隔,用户体验不好
      • 方法二:发现缓存失效后通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,判断缓存是否存在(期间可能有其他线程完成了更新),如不存在则重构缓存
        • 实时性更高,用户体验相对较好
      • 缓存预热:业务上线前提前把数据缓存起来,减少缓存失效的可能,不要等着用户访问才触发重构
Redis故障宕机
  1. 服务熔断或请求限流机制:
    • 启用服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误
    • 为了减少对业务的影响,可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务
  2. 构建Redis缓存高可靠集群
posted @ 2025-05-13 22:51  Vcats  阅读(89)  评论(0)    收藏  举报