缓存减轻了后端的压力,提升了性能,但同时也会带来一些问题,处理不好,可能带来负面影响。这些问题不仅仅是 redis 才会碰到,只要是使用了缓存这种策略都会面临这些问题,比如 oracle 自身提供的缓存机制,linux 对于读写文件的 page cache。
使用缓存意味着就有对应的后端存储,数据同时会在缓存和后端存储存在,那么就存在一致性的问题。缓存与后端存储一般是两个独立的组件,所以总结起来也就是分布式一致性的问题。
当然,对于一致性的需求,还是看业务需求,某些应用可以容忍不一致,则不用考虑一致性的问题;可以容忍一段时间的不一致,则采用最终一致性;不能容易不一致,则采用强一致。
对于缓存的一致性,做得比较完善的或者商业的组件,比如 oracle 的 Coherence 会提供完善的一致性机制,典型的有 read through、fresh ahead、write through、write behind;如果没有提供原生的一致性机制,则需要应用来处理了,也形成了固定的模式:cache aside。
read through 及其他 3 种模式,一般都有专门的缓存管理服务,专门处理缓存相关的功能,使缓存对于应用透明。
该模式解决读的问题,这里的 through 是读操作透过了缓存达到后端存储的意思。
总体思路:
1、尝试从缓存中查询 key
2、如果缓存没有 key,则从后端存储查询、反馈给应用并加载到缓存
3、如果缓存有 key,直接返回
该种模式比较简单,但查询时,如果没有命中缓存,都会有去后端存储查询,性能就与损失。
该模式是解决 read through 缓存没有命中带来的性能问题。
总体思路:
缓存 key 都是有过期时间的,在这种模式下缓存 key 将要过期时,则自动的异步的从后端存储查询最新数据并加载到缓存,这样请求时就不必查询后端存储,从而提升性能。
该模式解决写的问题,即所谓的双写,同时写入到缓存和后端存储,两个写成功才算成功。这个模式和 read through 一样,存在性能上的损失。
该模式是解决 write through 的性能损失。应用将数据写到缓存后算成功,稍后再异步写入到后端存储,性能虽然有提升,但是一致性保障降低了,变成了最终一致。
如果要求比较完善的一致性机制,可以按照上述的 4 种模式实现,但在一般的牵扯到 redis 一类缓存组件的业务场景中,没有必要这么完善,没有必要有专门的缓存管理服务,由应用自身来管理缓存即可。
对于读请求,可以完全按照 read through 模式实现,只不过具体过程由应用来完成。
对于写请求,可以有不同的几种策略,主要的区别还是在并发情况下的一致性保障程度。
每个线程都要执行更新后端存储和更新缓存两步操作,但对于后端存储和缓存这两步操作是独立的,所以,不同线程之间的这些操作时序可能因为网络等原因错乱,导致不一致。
1、先更新后端存储,再更新缓存
线程 A 更新后端存储
线程 B 更新后端存储
线程 B 更新缓存
线程 A 更新缓存
此时后端存储是 B 的数据,缓存是 A 的数据。这种方式一般不考虑。
2、先删除缓存,再更新数据库
线程 A 执行写操作,删除缓存,暂未将新数据写入后端存储
线程 B 执行读操作,没有缓存,查询到后端存储旧数据
线程 A 将新数据写入后端存储
线程 B 将旧数据写入缓存
这种问题可以通过延时双删解决,线程 A 写入后端存储之后,等待一定延时,再次删除缓存。这种方案是不大靠谱的,关键是时延的选择不确定。像上述的情况,时延太短,线程 B 还是会将旧数据写入缓存;时延太长,线程 A 接受不了,而且时延期间其他线程拿到的数据仍然是旧的。
3、更新数据库,再删除缓存
这种模式,大部分情况是没有问题的,但也有特殊情况会存在问题:
线程 A 执行读操作,没有缓存,查询到后端存储旧数据
线程 B 执行写操作并删除缓存
线程 A 将旧数据写入缓存
这种问题出现需要有比较苛刻的触发条件:
1)读操作缓存没有命中
2)线程 B 先于线程 A 执行(写操作一般比读慢,发生的概率比较低)
应用自行管理缓存,尽量设置过期时间,这是一致性底线的保证,避免数据长期不一致。另外,如果对于一致性有更高的要求,可以结合消息队列等方式来及时的通知缓存更新,但也会引入消息队列让系统更加复杂。
穿透指查询一个根本不存在的数据,则必然会穿透缓存达到后端存储,通常处于容错的考虑,不会将空结果写到缓存,所以每次请求都会穿透,并发高时可能直接让后端存储宕掉。
穿透形成,可能是恶意攻击、程序 bug 或者正常业务形成(比如用户查一项不存在的资源)。
解决穿透问题,主要有两个途径:
1、缓存空结果
如果是恶意攻击或者程序 bug 导致的大量穿透,则缓存会存储大量的 key,占用大量的存储空间。从穿透形成的场景来看,缓存数据都不大可能在较长时间后再次使用,此时可以设置较短的过期时间来改善存储空间的问题。
2、布隆过滤器拦截
占用空间比较少,但引入布隆过滤器后的代码维护以及数据加载本身存在一定的复杂度。
缓存不能提供服务时,请求全部打到后端存储,造成后端压力剧增,导致宕掉。
解决方案主要是缓存组件本身的高可用以及降级设计了。
如果一个 key 是热点 key(并发访问非常高),而且重建该 key 的缓存比较耗时,当该 key 失效时,那意味着很多并发线程瞬间都来执行重建工作,会对后端存储带来很大压力。
解决方案:互斥锁,让只有一个线程来执行重建操作,其他线程都等着。比如可以使用 redis 的 setnx 命令来实现,能设置成功的线程就重建,不能就 sleep,再次请求,此时数据可能就已经重建成功了。
领取专属 10元无门槛券
私享最新 技术干货