介绍三种缓存读写策略,各有优劣
Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。<!--more-->
写
先查缓存,缓存中不存在,直接更新DB。
缓存中存在,则先更新缓存,然后缓存服务自己更新 DB(同步更新缓存和 DB)。
读(Read Through):
从缓存中读取数据,读取到就直接返回 。
读取不到的话,先从 DB 加载,写入到缓存后返回响应。
Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。
这种方式下,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用。但是它适合频繁写的场景,例如MySQL的InnoDB Buffer Pool机制就使用到了这种模式
接下来登场的这位是本文的重量级选手
我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。
写 :
先更新 DB
然后直接删除缓存。
读 :
从缓存中读取数据,读取到就直接返回
缓存中读取不到的话,就从DB中读取数据返回
再把数据放到缓存中。
这里就引出一个面试的时候常见的一个问题写操作的时候执行的顺序问题导致的缓存和数据库的数据一致性问题
以下问题是从数据库单机的情况下分析的
这种情况首先淘汰,如果我们先更新缓存,再更新数据库,如果数据库回滚,缓存就形同虚设了
线程A写操作,更新数据库
线程B写操作,更新数据库
此时线程A由于某些原因没有线程B快,线程B先更新缓存
线程A更新缓存,此时脏数据出现了,缓存和数据库数据不一致
更新缓存相对于删除缓存还有两点劣势
如果你写入的缓存值,是经过复杂计算才得到的话,更新缓存频率高的话就浪费性能了
在写的场景多的情况下,数据很多时候还没有读取到就更新了
线程A写操作,先删除缓存
线程B读操作,缓存未命中
线程B读数据库中的值
线程B将数据更新到缓存中
此时线程A将新的数据写入数据库中,此时缓存和数据库数据不一致
针对这种情况我们一般使用缓存延时双删,具体来讲就是进行写操作的时候在更新数据库前后两次删除缓存
其实这种情况也有一定概率,但是概率非常小
缓存失效的情况线程A读操作直接查询数据库
线程B写操作更新数据库
线程B删除缓存
此时线程A将数据写入缓存
为什么说这种概率比较小呢,因为学过MySQL的直到,select是没有锁的只有update,delete等才会加锁,不加锁的语句肯定是比较快的,而且缓存的写入速度是比数据库的写入速度快很多!。
而先更新数据库再删除缓存这样的写操作步骤也是Cache Aside Pattern所采用的
删除缓存重试机制
不管是延时双删还是先更新数据库再删除缓存,都有可能存在第二步的删除缓存失败,导致数据的不一致性问题,可以使用这种方案优化:删除失败就多删除几次,保证删除成功即可(本质就是删除缓存重试机制保证缓存删干净了)
异步消息队列重试
写操作更新数据库
缓存因为某些原因删除失败
把删除失败的key放入到消息队列
消费者消费消息队列中的信息
重试删除缓存操作
通常都是通过消息队列异步重试
消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者
读取binlog异步删除缓存
订阅数据库变更日志,再操作缓存。
拿 MySQL 举例,当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。
订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal,使用这种方案的优点在于:
无需考虑写消息队列失败情况:只要写 MySQL 成功,Binlog 肯定会有
自动投递到下游队列:canal 自动把数据库变更日志「投递」给下游的消息队列
线程A执行写操作,更新主库数据库,binlog中记录好更新语句但是在主从同步的时候主从库延迟
删除缓存
线程B执行读操作
缓存为空直接查询从库
此时由于主从库延迟从库读到的是未同步的数据
线程B将未同步的数据更新到缓存中
线程A此时才将binlog中的数据同步到从库导致缓存数据不一致
解决方法如下
读取binlog异步删除缓存
延时双删
具体看这篇凭借经验评估
看到这里你可能会想,这些方案还是不够完美,我就想让缓存和数据库「强一致」,到底能不能做到呢?
其实很难。
要想做到强一致,最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议,但它们的性能往往比较差,而且这些方案也比较复杂,还要考虑各种容错问题。
相反,这时我们换个角度思考一下,我们引入缓存的目的是什么?
没错,性能。
一旦我们决定使用缓存,那必然要面临一致性问题。性能和一致性就像天平的两端,无法做到都满足要求。
而且,就拿我们前面讲到的方案来说,当操作数据库和缓存完成之前,只要有其它请求可以进来,都有可能查到「中间状态」的数据。
所以如果非要追求强一致,那必须要求所有更新操作完成之前期间,不能有「任何请求」进来。
虽然我们可以通过加「分布锁」的方式来实现,但我们要付出的代价,很可能会超过引入缓存带来的性能提升。
所以,既然决定使用缓存,就必须容忍「一致性」问题,我们只能尽可能地去降低问题出现的概率。
同时我们也要知道,缓存都是有「失效时间」的,就算在这期间存在短期不一致,我们依旧有失效时间来兜底,这样也能达到最终一致。