一、缓存问题
1.1 缓存穿透
1.1.1 问题来源
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。
1.1.2 解决方案
1.1.2.1 缓存空对象
从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击。
1.1.2.2 使用布隆过滤器
类似于一个hash set,用于快速判某个元素是否存在于集合中,其典型的应用场景就是快速判断一个key是否存在于某容器,不存在就直接返回。布隆过滤器的关键就在于hash算法和容器大小。
1.2 缓存击穿
1.2.1 问题来源
缓存击穿是指缓存某些热点数据失效(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
1.2.2 解决方案
1.2.2.1 设置热点数据永远不过期
可以在刷缓存时,设置热点数据不过期。
1.2.2.2 新增后台定时更新缓存线程(逻辑不过期)
后台新增一个缓存更新线程,缓存快要过期前刷新缓存时间,防止缓存失效。
1.2.2.3 使用分布式互斥锁
可以使用Redis提供的分布式互斥锁,保证只有一个请求查询数据库和更新缓存,其他请求阻塞等待缓存更新完成后在访问缓存。
1.2.2.4 接口限流与熔断,降级
重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。
1.3 缓存雪崩
1.3.1 问题来源
缓存雪崩是指Redis缓存不能正常提供服务了(阻塞、服务宕机、大面积缓存失效等造成),导致所有请求都落到了数据库上,增加了数据库压力或者导致数据库宕机。
1.3.2 解决方案
1.3.2.1 缓存过期时间随机
缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
1.3.2.2 分布式部署
采用分布式部署方式部署缓存,避免缓存服务单节点,同时将热点数据均匀分布在不同的缓存数据库中。
1.3.2.3 设置热点数据永远不过期
可以在刷缓存时,设置热点数据不过期。
1.3.2.4 接口限流与熔断,降级
重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。
二、缓存更新机制
2.1 缓存更新策略分类
内存淘汰 | 超时剔除 | 主动更新 | |
说明 | 重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。 | 给缓存数据添加TTL时间,到期后自动删除缓存,下次查询时更新缓存 | 编写业务逻辑,在修改数据的同时,更新缓存 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
2.2 内存淘汰机制
2.2.1 noeviction
不淘汰,这是默认的淘汰策略;当内存达到限制后,写请求(set)会返回错误,读请求(get)和删除请求(del)可以继续进行
2.2.2 volatile-lru
内存不足时,在设置了过期时间的key中,优先删除最近最少使用的key
2.2.3 volatile-lfu
内存不足时,在设置了过期时间的key中,优先删除使用频率最少的key
2.2.4 volatile-ttl
内存不足时,在设置了过期时间的key中,优先删除存活剩余时间最少的key
2.2.5 volatile-random
内存不足时,在设置了过期时间的key中,随机删除某个key
2.2.6 allkey-lru
内存不足时,在全体key范围内,优先删除最近最少使用的key
2.2.7 allkey-lfu
内存不足时,在全体key范围内,优先删除使用频率最少的key
2.2.8 allkey-random
内存不足时,在全体key范围内,随机删除某个key
2.3 超时剔除
2.3.1 定时删除
设置一个定时任务,随机抽取部分过期时间的key,检查是否过期,过期了就清除掉
2.3.2 惰性删除
查询获取数据时,检查缓存是否过期,过期则删除,没过期不删除
Redis 默认采用惰性删除+定时删除结合的过期策略
2.4 主动更新
2.4.1 主动更新策略
2.4.1.1 Cache Aside Pattern
由缓存的调用者,在更新数据库的同时更新缓存。
2.4.1.2 Read/Write Through Pattern
缓存和数据库整合为一个服务,由服务来维护一致性。调用者调用服务,不用关心一致性问题。
2.4.1.3 Write Behind Caching Pattern
调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库,最终保持一致。
在企业中使用最多的主动更新策略是 Cache Aside Pattern。也就是我们自己编码来保证数据的一致性。
2.4.2 主动更新策略需要考虑的三个问题
2.4.1 删除缓存还是更新缓存?
2.4.1.1 删除缓存
更新数据库时让缓存失效,查询时再更新缓存。(延迟加载)一般选择这个方案。
这个方案比较合理一点,可以避免过多的无效写操作,缓存删除后,只要没人来查询这条数据,数据就不会被写入缓存,这样就可以避免大量无效的写操作
2.4.1.2 更新缓存
每次更新数据库都更新缓存,无效写操作比较多。
这种方式的缺点很明显,举个例子:假如我更新了100次数据库,然后又同时更新了100次缓存,但是在更新的时候并没有人来查这个数据,那么我更新这100次缓存好像也没啥用吧,相当于前99次都是无用功,只有最后一次才是有用的。这就是无效写操作过多的原因。
2.4.2 如何保证缓存与数据库的操作同时成功或失败?
1)单体系统,将缓存与数据库操作放在一个事务中。
2)分布式系统,利用TCC等分布式事务方案。
2.4.3 先操作缓存还是数据库?
2.4.3.1 先删除缓存,再操作数据库
这种方式存在很明显的问题,假设有两个并发操作,线程A更新,线程B查询。线程A先删除缓存,然后还没来得及更新数据库,CPU资源被线程B抢走,线程B查询缓存发现没有命中(因为已经被线程A删除了),查询数据库,然后把结果写入到缓存中。这个时候线程A终于抢到CPU资源了,然后更新数据库,此时就会造成数据不一致问题。
2.4.3.2 先操作数据库,再删除缓存
这种处理方式使用的频率是最高的,因为出错的概率非常小,只有一种比较极端的情况才会出现数据一致性问题。
同样有两个并发请求,线程A查询、线程B更新,当线程A查询的时候,缓存刚好失效,然后就去查询数据库拿到数据,在准备写入缓存的时候,CPU资源被线程B抢走,线程B开始更新数据库,然后删除缓存(这一步其实等于无用,因为缓存已经过期)。此时线程A再次获取到CPU资源,然后写入缓存,此时写入的是更新前的旧数据,会产生数据一致性问题。
看起来这确实也是一个问题,但是我们仔细分析一下这种情况都需要满足哪些条件:
1)并发读写操作
2)读缓存时,缓存刚好失效
3)写数据库操作要比写缓存快
写数据库是操作磁盘,写缓存是操作内存的,所以不太可能会出现写磁盘的速度快于写内存的。因此使用这种方式出现数据一致性的概率是很小的。
2.4.3.3 延时双删策略
延迟双删策略是分布式系统中数据库存储和缓存数据保持一致性的常用策略,但它不是强一致。其实不管哪种方案,都避免不了Redis存在脏数据的问题,只能减轻这个问题,要想彻底解决,得要用到同步锁和对应的业务逻辑层面解决。
前面两种方案的不足点我们进行了分析,第二种方式的使用频率比较高,但是也有一些小缺陷,虽然说发生的概率很低,但是这个概率到了线上会不会发生也不好说,所以就有了延时双删策略对第二种方式做补充。
所谓延时双删就是先进行缓存清除,再执行数据库操作,最后(延迟N秒)再执行缓存清除。延迟N秒的时间要大于一次写操作的时间,这个延时N秒就是了完善保证第二种策略中不足,可以保证线程A的写缓存和线程B的修改数据库、删除缓存都执行完毕,然后再删除缓存一次,就可以保证后面再来的查询请求可以查询到最新数据。
ps: 一般的延时时间设置为3S左右,具体情况要根据业务场景取最佳值。
2.5 缓存更新机制总结
1)内存淘汰:不用自己维护,利用Redis内存淘汰机制,自动删除部分缓存数据,这些被删除的数据在下一次被查询时更新。这种方式一致性最差。
2)超时剔除:给缓存数据加上过期时间 ,到期后自动删除,下次查询时更新,数据一致性问题大概率会出现。维护成本比较低。
3)主动更新:编写业务逻辑,在修改数据库的同时更新缓存,一致性比较好,维护成本比较高。一般采用先操作数据库再更新缓存的方式。
一般在数据一致性要求比较低的场景下可以使用内存淘汰机制,比如商城首页的分类信息,这些东西基本上是不会变化的。如果一致性要求比较高,我们可以采用主动更新+超时剔除兜底的方式来处理。