高并发项目
Redis缓存穿透缓存击穿缓存雪崩
13redis缓存的问题与解决方案
加入缓存后会带来一系列典型问题,主要包括:
- 数据库数据更新了,但缓存中数据未更新,导致数据不一致
- 缓存穿透:请求的数据在缓存和数据库中都不存在
- 缓存雪崩:大量缓存 key 同时失效或 Redis 服务宕机
- 缓存击穿:热点 key 突然失效,瞬间大量请求打到数据库
本章系统讲解这些问题的成因与解决方案。
一、缓存更新策略
缓存更新策略有以下三种:
| 策略 | 说明 | 一致性 | 维护成本 |
|---|---|---|---|
| 内存淘汰 | 不用自己维护,利用 Redis 的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存 | 差 | 无 |
| 超时剔除 | 给缓存数据添加 TTL,到期后自动删除缓存,下次查询时更新缓存 | 一般 | 低 |
| 主动更新 | 编写业务逻辑,在修改数据库的同时,更新缓存 | 好 | 高 |
业务场景选择:
- 低一致性需求:使用内存淘汰机制,例如店铺类型、商品分类等查询缓存
- 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情、商品详情查询的缓存
主动更新的三种方案
- Cache Aside Pattern:由缓存的调用者,在更新数据库的同时更新缓存
- Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性,调用者调用该服务,无需关心一致性问题
- Write Behind Caching Pattern:调用者只操作缓存,由其它线程异步将缓存数据持久化到数据库,保证最终一致
其中 Cache Aside Pattern 最为常用,操作缓存与数据库时需要考虑三个关键问题:
1. 删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存(推荐)
2. 如何保证缓存与数据库的操作同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务中
- 分布式系统,利用 TCC 等分布式事务方案
- 缓存设置过期时间,作为兜底方案
3. 先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库:并发场景下会出现数据不一致
- 先操作数据库,再删除缓存:并发场景下出现不一致的概率较低,推荐使用
缓存更新最佳实践
- 低一致性需求:使用 Redis 自带的内存淘汰机制
- 高一致性需求:主动更新,并以超时剔除作为兜底方案
- 读操作:缓存命中则直接返回;缓存未命中则查询数据库,并写入缓存,设定超时时间
- 写操作:先写数据库,然后再删除缓存,要确保数据库与缓存操作的原子性
二、缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,给数据库带来巨大压力。
解决方案
- 缓存空对象:
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗;可能造成短期的不一致
- 布隆过滤:
- 优点:内存占用较少,没有多余 key
- 缺点:实现复杂;存在误判可能
布隆过滤器原理
布隆过滤是一种数据统计的算法,用于检索一个元素是否存在于一个集合中。它无需存储元素到集合,而是把元素映射到一个很长的二进制数位上:
- 首先需要一个很长的二进制数,默认每一位都是 0
- 然后需要 N 个不同算法的哈希函数
- 将集合中的元素根据 N 个哈希函数做运算,得到 N 个数字,然后将每个数字对应的 bit 位标记为 1
- 要判断某个元素是否存在,只需要把元素按照上述方式运算,判断对应的 bit 位是否都是 1 即可
布隆过滤器不一定 100% 准确,所以建议使用缓存空对象,简单方便。
缓存穿透的成因与综合对策
缓存穿透产生的原因:用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力。
完整的解决方案包括:
- 缓存 null 值
- 布隆过滤
- 增强 id 的复杂度,避免被猜测 id 规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
三、缓存雪崩
缓存雪崩是指在同一时段大量的缓存 key 同时失效或者 Redis 服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案
- 给不同的 Key 的 TTL 添加随机值
- 利用 Redis 集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
四、缓存击穿
缓存击穿问题也叫热点 Key 问题,就是一个被高并发访问并且缓存重建业务较复杂的 key 突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
解决方案
1. 互斥锁
线程查询缓存未命中后,尝试获取互斥锁:
- 获取锁成功:查询数据库、重建缓存数据,写入缓存后释放锁
- 获取锁失败:休眠一段时间后重试,直到缓存命中
互斥锁能保证强一致性,但线程需要等待,可能影响性能。
2. 逻辑过期
在缓存中存储逻辑过期时间字段,不设置 Redis 的 TTL:
- 查询缓存时判断逻辑时间是否过期
- 未过期:直接返回缓存数据
- 已过期:尝试获取互斥锁
- 获取成功:开启独立线程查询数据库、重建缓存数据、重置逻辑过期时间、释放锁;当前线程返回过期数据
- 获取失败:直接返回过期数据
逻辑过期方案线程无需等待,性能较好,但不保证一致性,有额外内存消耗。
两种方案对比
| 解决方案 | 优点 | 缺点 |
|---|---|---|
| 互斥锁 | 没有额外的内存消耗、保证一致性、实现简单 | 线程需要等待,性能受影响;可能有死锁风险 |
| 逻辑过期 | 线程无需等待,性能较好 | 不保证一致性;有额外内存消耗;实现复杂 |
五、缓存工具封装
基于 StringRedisTemplate 封装一个缓存工具类,可以满足以下需求:
- 将任意 Java 对象序列化为 JSON 并存储在 string 类型的 key 中,并且可以设置 TTL 过期时间
- 将任意 Java 对象序列化为 JSON 并存储在 string 类型的 key 中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
- 根据指定的 key 查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
- 根据指定的 key 查询缓存,并反序列化为指定类型,利用逻辑过期解决缓存击穿问题
通过封装统一的缓存工具类,可以将缓存穿透、缓存击穿等解决方案沉淀为可复用的代码,避免在每个业务接口中重复实现。