引入缓存后的一致性挑战
所谓缓存,实际上就是用空间换时间,准确地说是用更高速的空间来换时间,从而整体上提升读的性能。
这篇文章讨论的是 Redis 与 MySQL 的缓存不一致问题,事实上就算是本地缓存,也是一样的问题。
根据CAP理论,要实现强一致性,就要牺牲可用性,这会极度降低系统性能。而使用缓存,本地就是为了提升性能。如此,目标是实现最终一致性,并且尽可能降低不一致的时间。
一致性方案
根据对于缓存的处理方式(是删除还是更新)以及操作缓存和数据库的顺序的不同,一共可以有四种方案:
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
先更新缓存,再更新数据库
考虑两个写请求。 异常情况是线程1先将缓存数据更新为1,在更新数据库前,线程2将缓存更新为2,紧接着把数据库也更新为2,然后线程1将数据库更新为1。
此时,缓存中的数据为2,但数据库中的数据为1,出现了缓存和数据库的数据不一致现象
先更新数据库,再更新缓存
考虑两个写请求。异常情况是线程1先将数据库数据更新为1,在更新缓存前,线程2将数据库更新为2,紧接着把缓存也更新为2,然后线程1将缓存更新为1。
此时,数据库中的数据为2,但缓存中的数据为1,出现了缓存和数据库的数据不一致现象
无论是【先更新数据库,再更新缓存】,还是【先更新缓存,再更新数据库】,这两个方案都存在并发问题,当两个请求并发更新同一条数据时(写写并发)
先删除缓存,再更新数据库
异常情况说明:
线程1删除缓存后,还没来得及更新数据库,
此时线程2来查询,发现缓存未命中,于是查询数据库,写入缓存。由于此时数据库尚未更新,查询的是旧数据。也就是说刚才的删除白删了,缓存又变成旧数据了。
然后线程1更新数据库,此时数据库是新数据,缓存是旧数据
由于更新数据库的操作本身比较耗时,在期间有线程来查询数据库并更新缓存的概率非常高,即出现异常情况的概率很大。因此不推荐这种方案。
先更新数据库,再删除缓存
异常情况说明:
-
线程1查询缓存未命中,于是去查询数据库,查询到旧数据
-
线程1将数据写入缓存之前,线程2来了,更新数据库,删除缓存
-
线程1执行写入缓存的操作,写入旧数据
从上面的理论上分析,【先更新数据库,再删除缓存】也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。因为缓存的写入通常要远远快于数据库的写入(写缓存的速度在毫秒之间,速度非常快,在这么短的时间要完成数据库和缓存的操作,概率非常之低),所以在实际中发生请求2已经更新了数据库并且删除了缓存,请求 1 才更新完缓存的情况概率极为苛刻。
而一旦请求 1 早于请求 2删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。
所以,【先更新数据库+再删除缓存】的方案,是可以保证数据一致性的,而且为了确保万无一失,还给缓存数据加上了【过期时间】,要就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。
综上:推荐使用【先更新数据库+再删除缓存】的方案
采用【先更新数据库,再删除缓存】带出缓存击穿问题
虽然保证了数据库和缓存的数据一致性,但是每次更新数的时候,都会删除缓存的数据,对于热点key,要引起缓存击穿的问题,导致数据库压力庞大。
为此,可以采用【更新数据库+更新缓存】的方案,因为更新缓存并不会出现缓存未命中的情况。
但是这个方案,会有缓存不一致问题,关键在于并没有
有两种手段解决这个问题:
-
在更新缓存之前加一个【分布式锁】,保证同一时间只有一个更新请求更新缓存,这样就不会产生并发问题了,当然,引入锁以后,对于写入性能就会产生影响。
-
在更新完缓存后,给缓存加上一个较短的【过期时间】,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对于业务还是能接受的。
延迟双删
针对【先删除缓存,再更新数据库】的方案,在【读+写】并发请求而造成缓存不一致的解决办法就是 延迟双删
#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)
加了个睡眠时间,主要是为了确保请求 A在睡眠的时候,请求B能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A睡眠完,再删除缓存。
所以,请求 A 的睡眠时间就需要大于请求 B「从数据库读取数据 +写入缓存」的时间
做个简单总结,足以适应绝大部分的互联网开发场景的决策:
针对大部分读多写少场景,建议选择更新数据库后删除缓存的策略。
针对读写相当或者写多读少的场景,建议选择更新数据库后更新缓存的策略。
更新数据库和删除缓存的最终一致性问题
不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。
新的问题来了,如何保证【先更新数据库,再删除缓存】的这两个操作都能执行成功呢?
当我们无法确定 MySQL 更新完成后,缓存的更新/删除一定能成功,例如 Redis 挂了导致写入失败了,或者当时网络出现故障,更常见的是服务当时刚好发生重启了,没有执行这一步的代码。
这些时候 MySQL 的数据就无法刷到 Redis 了。为了避免这种不一致性永久存在,使用缓存的时候,我们必须要给缓存设置一个过期时间,例如 1 分钟,这样即使出现了更新 Redis 失败的极端场景,不一致的时间窗口最多也只是 1 分钟。
解决办法:
- 消息队列重试机制,有业务侵入
- 使用Canal订阅MySQL binlog + 消息队列重试机制。无业务侵入,运维复杂
消息队列重试机制,有业务侵入
引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
- 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过一定次数,还是没有成功,我们就要向业务层发送报错信息了。
- 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。
这个优化方案的缺点就是,对代码入侵比较强,因为需要改造原本业务的代码。
如何处理复杂的多缓存场景?
有些时候,真实的缓存场景并不是数据库中的一个记录对应一个 Key 这么简单,有可能一个数据库记录的更新会牵扯到多个 Key 的更新。还有另外一个场景是,更新不同的数据库的记录时可能需要更新同一个 Key 值,这常见于一些 App 首页数据的缓存。
我们以一个数据库记录对应多个 Key 的场景来举例。
假如系统设计上我们缓存了一个粉丝的主页信息、主播打赏榜 TOP10 的粉丝、单日 TOP 100 的粉丝等多个信息。如果这个粉丝注销了,或者这个粉丝触发了打赏的行为,上面多个 Key 可能都需要更新。只是一个打赏的记录,你可能就要做:
updateMySQL();//更新数据库一条记录
deleteRedisKey1();//失效主页信息的缓存
updateRedisKey2();//更新打赏榜TOP10
deleteRedisKey3();//更新单日打赏榜TOP100
这就涉及多个 Redis 的操作,每一步都可能失败,影响到后面的更新。甚至从系统设计上,更新数据库可能是单独的一个服务,而这几个不同的 Key 的缓存维护却在不同的 3 个微服务中,这就大大增加了系统的复杂度和提高了缓存操作失败的可能性。最可怕的是,操作更新记录的地方很大概率不只在一个业务逻辑中,而是散发在系统各个零散的位置。
针对这个场景,解决方案和上文提到的保证最终一致性的操作一样,就是把更新缓存的操作以 MQ 消息的方式发送出去,由不同的系统或者专门的一个系统进行订阅,而做聚合的操作。如下图:
不同业务系统订阅MQ消息单独维护各自的缓存Key
专门更新缓存的服务订阅MQ消息维护所有相关Key的缓存操作
通过订阅MySQL binlog的方式处理缓存
上面讲到的 MQ 处理方式需要业务代码里面显式地发送 MQ 消息。还有一种优雅的方式便是订阅 MySQL 的 binlog,监听数据的真实变化情况以处理相关的缓存。
利用Canel订阅数据库binlog变更从而发出MQ消息,让一个专门消费者服务维护所有相关Key的缓存操作