一、什么是缓存
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)
更新流程:
删除缓存
更新数据库
延迟一定时间,再次删除缓存`
优点:
减少并发场景下缓存脏读的可能性;
弥补数据库更新慢导致的并发查询误缓存的问题。
缺点:
延迟时间难以把握,太短无效,太长影响性能;
依赖定时器或异步任务机制实现。
方案二:消息队列异步同步(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 缓存穿透
概念
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
风险:由于数据不存在,情况下无法通过数据库里的数据给缓存添加值,所以每次请求都会打到数据库,当有大量此类请求时,数据库可能会瘫痪
产生原因
缓存穿透发生的常见原因有二:
误删:数据误删导致缓存和数据库都没有数据
恶意攻击:故意大量访问不存在的数据
常见解决方案
限制非法请求
在API入口对请求参数进行合理性判断:是否含非法值,字段是否存在
如果判断出是恶意请求就直接返回错误,避免进一步访问到缓存和数据库
缓存空对象或默认值
描述:
缓存空对象:在缓存中缓存null或默认值,避免请求打到数据库
优缺点:
优点:实现简单,维护方便
缺点:
额外的内存消耗(存储一堆乱七八糟的东西)
可能造成短期的不一致
通过设置TTL缓解
布隆过滤
描述:
使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在
具体实现流程:
布隆过滤器部署在应用层,在访问缓存层(Redis)之前进行拦截:
客户端请求 → 布隆过滤器 → 缓存层(Redis) → 数据库
请求到达应用层:当有查询请求到达时,首先检查布隆过滤器
布隆过滤器判断:
如果布隆过滤器说"不存在" → 直接返回空结果(拦截)
如果布隆过滤器说"可能存在" → 继续查询缓存
缓存查询:
缓存命中 → 返回结果
缓存未命中 → 查询数据库
数据库查询:
数据库有数据 → 回填缓存并返回
数据库无数据 → 可选择将空值也写入缓存(设置较短过期时间)
优缺点:
优点:内存占用少
缺点:
实现复杂
存在误判可能,但不会漏判(说存在不一定存在,说不存在一定不存在
其他方案
增强id的复杂度,避免被猜测id规律
做好数据的基础格式校验
加强用户权限校验
做好热点参数的限流
实践
上图展示了一个查询商铺功能解决缓存穿透的业务调整
核心思路:当被查询的数据不存在时,由直接返回异常变为了在缓存中写入空/默认对象
6.2 缓存击穿
概念
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击
上图展示的是由于缓存重建过程较长,期间大量请求到来时导致的数据库压力增加
常见解决方案
互斥锁
互斥锁方案:保证同一时间只有一个业务更新缓存。
未能获取互斥锁的请求。要么等待所释放后重新读取缓存,要么就返回空值或默认值
最好设置超时时间,避免获得锁的请求因意外一直阻塞而不能释放锁导致系统无响应
逻辑过期
即不给热点数据设置过期时间(TTL),将只是将过期时间存到VALUE里
一般用于热点key,例如活动期间加入该key,活动结束后再移除
KEY
VALUE
vks:game:1
解决方案对比
解决方案
优点
缺点
互斥锁
- 没有额外的内存消耗- 保持一致性- 实现简单
- 线程需要等待,性能受影响- 可能有死锁问题
逻辑过期
- 线程无需等待,性能较好
- 不保证一致性- 有额外内存消耗- 实现复杂
互斥锁:一致性
逻辑过期:可用性
实践
基于互斥锁
基于互斥锁方式解决缓存击穿问题
锁的设计
利用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
基于逻辑过期
基于逻辑过期方式解决缓存击穿问题
6.3 缓存雪崩
概念
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
解决方案
从概念上可以看出,缓存雪崩主要有两个原因:
大量数据同时过期
Redis故障宕机
大量数据同时过期
均匀设置过期时间
缓存预热时,会批量导入数据,这时可能大量数据有效期一致。
我们可以在设置过期时间时,给不同的Key的TTL添加随机值,从而减少同一时间有大量数据过期发生的可能性
互斥锁
类似上文解决缓存击穿的解决思路,当发现数据不在Redis中,就加个互斥锁,获得锁的线程构建缓存
后台更新缓存
不给缓存设置有效期,让缓存“永久有效”,将更新缓存的工作交由后台线程定时更新。
如何解决内存紧张时,缓存数据被淘汰,业务线程读取缓存失败的问题
方法一:后台线程除了更新缓存外,还要负责频繁地检测缓存是否有效,缓存失效时马上重建缓存。
从失效到被发现进而重构会有时间间隔,用户体验不好
方法二:发现缓存失效后通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,判断缓存是否存在(期间可能有其他线程完成了更新),如不存在则重构缓存
实时性更高,用户体验相对较好
缓存预热:业务上线前提前把数据缓存起来,减少缓存失效的可能,不要等着用户访问才触发重构
Redis故障宕机
服务熔断或请求限流机制:
启用服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误。
为了减少对业务的影响,可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务。
构建Redis缓存高可靠集群