如何保证缓存与数据库的数据一致性

date
Mar 5, 2024
slug
how-to-ensure-data-consistency-between-the-cache-and-the-database
status
Published
tags
编程开发
数据库
summary
如何保证数据库的数据和缓存中的数据的一致性。也就是说,当我们对数据库中的数据进行修改后,此时缓存的数据已经与数据库中的数据不一致,如何修复这种不一致的状态。显而易见,不一致的场景都是出现在缓存中已经存在数据的情况,接下来我们就以这个来分析实现数据一致性的各种方式。
type
Post
大部份场景下,我们都是把redis当作缓存来使用,也就是把数据库的信息放到这个缓存中,等读取请求过来后,会先从Redis里读取,如果没有读取到,才会去数据库读取,读取到之后写入回缓存redis中,最后把数据返回给客户端。这样下一次客户端读取的时候,就能读取到缓存的这部份数据。
notion image
 
而这里有一个场景基本是每个使用这套机制的人要考虑的,如何保证数据库的数据和缓存中的数据的一致性。也就是说,当我们对数据库中的数据进行修改后,此时缓存的数据已经与数据库中的数据不一致,如何修复这种不一致的状态。显而易见,不一致的场景都是出现在缓存中已经存在数据的情况,接下来我们就以这个来分析实现数据一致性的各种方式。

方式一:先删除缓存,再更新数据库(不推荐)

当需要更新数据库时,先删除缓存,删除成功后,再更新数据库。由于缓存被删除了,等下一次查询过来时,则会直接查询数据库得到最新的数据,并写入缓存并返回。
但这种方式很大概率还是会遇到数据不一致的情况。在删除缓存之后,此时可能会有另一个线程来查询数据,此时由于缓存已经缺失了,会去查询数据库,然而此时由于数据库的数据还没被更新,导致读取到了旧数据,而且还被写入到了缓存中。后续所有的查询请求读取到的都是这份脏数据。
请求 1
请求2
请求三
删除缓存
查询缓存,缓存缺失,查询数据库,并写入缓存
更新数据库
查询缓存,查询到脏数据
正因如此,这个方式是不推荐使用的。

方式二:先更新数据库,再更新缓存(不推荐)

顾名思义,在更新完数据库后,同时更新缓存,此时缓存就和数据库保持一致了。但这种方式在遇到并发写的时候,很有可能出现问题。这时候的缓存写的是哪个数据是无法保证的,因此不建议采用。

方式三:先更新数据,再删除缓存(推荐)

在更新完数据库后,直接删除缓存,这种方式是我们推荐的方案。假设遇到并发的查询场景,发生在删除缓存之前,此时读取到的数据虽然是旧的,但是等到缓存被删除后,后续的其他查询请求,便能从数据库查询到最新的数据。也就是说这个不一致性的时间会比较短,大部分场景下这个是可以接受的。这种模式就是所谓的Cache Side模式,也是我们要优先考虑使用的模式。
不过这种场景下,还是可能存在问题,同样也出现在并发查询的场景下。
假设此时缓存中的数据已经失效,当有一个查询的请求过来,由于缓存失效,此时会去查询数据库。如果此时要更新数据,并且也完成了缓存的清除,就在此时之前的那个查询请求把从数据库查询到的数据写入到了缓存中,而这个数据其实是脏数据,导致了数据不一致。
但是明显这个场景发生是有前提的,就是这个查询时对数据库的读操作,要早于并发时的写操作,并且要晚于清除缓存的操作。而本身数据库的写操作是比读操作慢很多的,因此这个场景发生的概率很小。如果真的想避免发生,可以给缓存加上过期时间。
这里可以引发第一个思考:如果在删除缓存的时候失败了怎么办?
我认为第一种方法是可以进行重试,当然也不是无限的重试,可以配合一些非线性重试策略,如时间间隔斐波那契递增或者指数递增等方式来重试删除操作。如果重试几次之后依旧无法删除,则可以进行告警上报,提前暴露问题,是不是redis出问题了,还是网络发生分区了。
第二种是之前看一些课程看到的,我觉得也挺不错,就是在删除失败后,发送一条消息到队列中,让消费去进行负责异步删除,当再次失败时就再丢回队列中。这种方式不会影响到之前发起修改的请求,也是一种可以采取的方案。

方式四:消息队列异步更新

当我们的并发量级已经非常大,一点都无法接受缓存穿透带来的影响(例如我们在数据库完成一次数据查询需要10s),那我们是不是可以考虑只读redis不读数据库。也就是说如果redis中没有查询到数据,就不再去读取数据库了,而是直接返回空给客户端。
那我们就必须解决数据库的数据如何写到缓存中的问题。
我们可以采用一个消息队列来解决。当业务服务向数据库写数据成功后,就向消息队列发送一条消息。然后我们构建一个消费者的服务去订阅这些消息。当收到消息后,就去数据库查询对应的数据,将其写入到redis中。这种方式还能解决方式二中可能带来的脏数据问题。
 
notion image
 
这种方式需要引入消息队列,为了确保消息不丢失(一旦丢失,redis中将不会有数据),还需要确保消息队列的高可靠性。不过现如今的消息队列(如Kafka、RocketMQ)都基本具有可靠性的要求了。

方式五:订阅Binlog实时更新

有时我们并不想在业务服务中接入消息队列,只想让其完成数据库写操作,可以怎么做呢?
我们可以模拟数据库主从复制的方式,例如在MySQL中主从复制是通过传输binlog实现的。那我们也可以实现一个服务,让其伪装成MySQL的从节点去订阅主节点的binlog日志,然后依据binlog中的记录去查询数据库(这里建议是从库),将查询的数据写入到redis中。如果是删除操作,则直接删除redis中的数据。
 
notion image
 
这种方式优点就是减少了一个消息队列集群,更新时延更短,毕竟binlog的同步一般来说都是毫秒级别的,而且出现故障的概率会更低。
难点就是需要去实现binlog的拉取与解析,当然现在社区也有开源的工具,常用的就是Canal。
解决缓存与数据库的数据不一致的方案还是挺多的,我们根据具体的业务场景和请求量级选择即可。