Redis与MySQL双写一致性如何保证?
读写分离 + 主从复制延迟情况下,缓存和数据库一致性的问题
在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」其实也会导致不一致:
线程 A 更新主库 X = 2(原值 X = 1)
线程 A 删除缓存
线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 X = 1)
从库「同步」完成(主从库 X = 2)
线程 B 将「旧值」写入缓存(X = 1)
和「先删除缓存,再更新数据库」类似,缓存中都是留下了脏数据
解决方案:
- 先删除缓存,再更新数据库:在线程 A 删除缓存、更新完数据库之后,先「休眠一会」,再「删除」一次缓存。(延迟时间要大于线程 B 读取数据库 + 写入缓存的时间)
- 读写分离 + 主从库延迟:线程 A 可以生成一条「延时消息」,写到消息队列中,消费者延时「删除」缓存。(延迟时间要大于「主从复制」的延迟时间)
三种缓存模式
-
旁路缓存模式(Cache Aside Pattern)
读写步骤:
- 写:先更新
DB
,然后直接删除cache
。。 - 读:从
cache
中读取数据,读取到就直接返回,cache
中读取不到的话,就从DB
读取返回。再把数据写到cache
中。
问题:
-
可以先删除cache,再更新DB吗?
不可以。
-
如果先写BD,再删除cache就不会造成数据不一致了吗?
理论上来说还是会出现数据不一致的问题,不过概率很小,因为缓存的写入速度是比数据库写入速度快很多。
缺点:
-
缓存冷启动问题:首次请求的数据一定不在cache的问题
解决方法:以将热点数据提前写入
cache
中。 -
写操作比较频繁的话导致cache中的数据会被频繁的删除,这样会影响缓存命中率。
解决方法:
- 强一致性场景:更新
DB
的时候同样更新cache
,不过需要加一个锁/分布式锁来保证更新cache
的时候不存在线程安全问题。 - 短暂的允许数据库和缓存数据不一致的场景:更新
DB
的时候同样更新cache
,但是给缓存加一个比较短的过期时间,保证最终一致性。
- 强一致性场景:更新
- 写:先更新
-
读写穿透(Read/Write Through Pattern)
读写步骤:
- 写:先查
cache
,cache
中不存在,直接更新DB
。cache
中存在,则先更新cache
,然后cache
服务自己更新DB
(同时更新DB
和cache
)。 - 读:先从
cache
中读取数据,读取到直接返回。从cache
中读取不到,则先从DB
加载写入到cache
后返回响应。
缺点:
- 读写穿透也存在首次请求数据一定不在
cache
中的问题,对于热点数据可以提前写入缓存中。 - 不经常请求的数据也会写入缓存,从而导致缓存更大、成本更高。
- 写:先查
-
异步缓存写入(Write Behind Pattern)
-
异步缓存写入和读写穿透很相似,两者都是由
cache
服务来负责cache
和DB
的读写。 -
写穿透是同步更新
DB
和cache
,而异步缓存写入则是只更新cache
,不直接更新DB
,而是改为异步批量的方式更新DB
。 -
消息队列中消息的异步写入磁盘、
MySQL
的InnoDB Buffer Pool
机制都用到了这种策略。 -
异步缓存写入的写性能非常高,非常适合写数据经常变化又对数据一致性要求没那么高的场景下使用,比如浏览量、点赞量等。
-
为什么是删除而不是更新
并发读写时会出现脏数据问题,ABBA
- A更新数据库为1
- B更新数据库为2
- B更新缓存为2
- A更新缓存为1
先删除缓存,后更新数据库
- A线程删除缓存
- 请求B查询发现缓存不存在
- 请求B去数据库查询得到旧值
- 请求B将旧值写入缓存
- A线程再更新数据库
上述情况会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
先更新数据库,后删除缓存
- A线程更新数据库
- B线程查询数据,此时还未删除缓存,缓存中还有,得到的就是旧的缓存数据【此时就出现了数据库和缓存中数据不一致的情况】
- A线程再删除缓存
但由于缓存的写入要快于MySQL的写入,一般不可能在2跟4之间,穿插一个3操作【3还操作了数据库】【因为写入缓存很快】
- 如果顺序是【2,4,3】的话,A设置了缓存之后也没关系,因为后续B还会再次删除缓存
- 之后的查询,发现缓存过期会去数据库中查询得到最新的数据
解决办法:
异步延时双删 + 设置缓存过期时间(保证最终一致性)
-
先淘汰缓存
-
再写数据库(这两步和原来一样)
-
休眠一段时间,再次淘汰缓存
这么做,可以将这段时间内所造成的缓存脏数据,再次删除。
如何解决延时双删第二步的删除缓存失败的情况
-
使用消息队列重试:
- 请求 A 先对数据库进行更新操作,同时吧
- 在对 Redis 进行删除操作的时候发现报错,删除失败
- 此时将Redis 的 key 作为消息体发送到消息队列中
- 系统接收到消息队列发送的消息后再次对 Redis 进行删除操作
-
订阅binlog日志:
原理:更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。
具体流程:
- canal模拟mysql slave的交互协议,伪装自己为mysql slave,向mysql master发送dump协议;
- mysql master收到dump请求,开始推送binary log给slave(也就是canal);
- canal解析binary log对象(原始为byte流);
- canal将解析后的对象,根据业务场景,分发到比如 MySQL 、RocketMQ 或者 ES 中。