Redis与MySQL双写一致性是指在使用缓存和数据库同时存储数据的场景下( 主要是存在高并发的情况),如何保证两者的数据一致性(内容相同或者尽可能接近)。
正常业务流程:

阅读并不成问题,问题所在在于写入操作(更新)。这时就可能出现几个问题,我们需要先更新数据库,再进行缓存操作。在处理缓存时,应该考虑是进行缓存更新还是删除缓存,或者是先更新缓存再更新数据库
总结一下就是到底先操作缓存再操作数据库,还是先操作数据库再操作缓存?
带着这几个问题接着往下讲。
首先讲一下操作缓存,包括两种:更新缓存和删除缓存,如何选择?
更新缓存? 删除缓存?
假设都先更新数据库(因为先操作缓存再操作数据库问题较大,后面会讲)
先更新数据库,再更新缓存。
当两个请求同时对同一条数据进行修改时,缓存中可能会存在旧数据,因为它们的先后顺序可能会颠倒。之后的读请求读到的都是旧数据,只有当缓存失效后,才能从数据库中得到正确的值。

先更新数据库,再删除缓存。
缓存失效时,可能出现请求B从数据库中查询数据并得到旧值的情况。此时请求A更新数据库,将新值写入数据库,并删除缓存。而请求B又将旧值写入缓存中,导致脏数据

从上面看出现脏数据的要求要比更新缓存的要求更多,必须满足以下几个条件:
缓存失效
读请求 + 写请求并发
更新数据库 + 删除缓存的时间要比读数据库 + 写缓存时间短
前面两个很好满足,我们再看看第三点,这个真的会出现吗?
数据库在更新时一般是加锁的,读操作的速度远快于写操作的,所以第三点发生概率极低(当然也可能发生)
注:这里我其实不是很理解,单纯看确实发生概率低,但如果出现网络延迟等情况呢,不也会发生吗?希望好心人解惑,我反正没理解。
因此,在选择删除缓存时,还需要结合其他技术来优化性能和一致性。例如:
对比
在更新缓存中, 每次去更新缓存,但是缓存中的数据不一定会被马上读取,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。而且很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,很有可能是先查询数据库,再经过一系列「计算」得出一个值,才把这个值才写到缓存中。
由此可见,这种更新缓存的方案,不仅缓存利用率不高,还会造成机器性能的浪费。所以我们一般考虑删除缓存
先更新缓存再更新数据库
在更新数据时,先将新数据写入缓存(Redis),再将新数据写入数据库(MySQL)
但其存在一下问题:
例:用户修改了自己的昵称,系统先将新的昵称写入缓存,然后再更新数据库。但是在更新数据库的过程中,发生了网络故障或者数据库宕机等异常情况,导致数据库中的昵称没有被修改。这样就会出现缓存中的昵称和数据库中的昵称不一致的情况。
例:用户下单了一个商品,系统先将订单状态写入缓存,然后再更新数据库。但是在更新数据库的过程中,由于并发量大或者其他原因,导致数据库的写入速度慢于缓存的写入速度。这样就会出现其他请求从缓存中读取到订单状态为已支付,而从数据库中读取到订单状态为未支付的情况。
例:用户A修改了自己的头像,并上传到服务器上。系统先将新的头像地址写入缓存,并返回给用户A显示。然后再将新的头像地址更新到数据库中。但是在这个过程中,用户B访问了用户A的个人主页,并从缓存中读取到了新的头像地址。缓存失效可能是由于缓存过期策略或其他原因,例如重启操作,导致缓存被清空或过期。这时候用户B再次访问用户A 的个人主页,并从数据库中读取到了旧的头像地址,并将其写回缓存中。这样可能导致缓存中的头像地址与数据库中的地址不匹配。
上面说了一堆,其实总结就是缓存更新成功了,数据库没更新(更新失败),导致缓存存的是最新值,数据库存的是旧值。如果缓存失效了,就会拿到数据库中的旧值。
后面我自己也搞疑惑了,既然是因为数据库更新失败导致的问题,那我是不是只要保证数据库更新成功就可以解决数据不一致的问题,当数据库更新失败时,不停的重试更新数据库,直到数据库更新完成。
后面发现自己太天真,其中存在很多问题,比如:
如果数据库更新失败的原因是数据库宕机或者网络故障,那么你不停地重试更新数据库可能会造成更大的压力和延迟,甚至导致数据库恢复困难。
如果数据库更新失败的原因是数据冲突或者业务逻辑错误,那么你不停地重试更新数据库可能会导致数据丢失或者数据错乱,甚至影响其他用户的数据。
如果你不停地重试更新数据库,那么你需要考虑如何保证重试的幂等性和顺序性,以及如何处理重试过程中发生的异常情况。
所以,这种方法并不是一个很好的解决方案。
先更新数据库,再更新缓存
当有一个更新操作时,先更新数据库数据,然后再更新对应的缓存数据
但是,这种方案也有一些问题和风险,比如:
因此,在使用更新缓存操作时,无论谁先谁后,但凡后者发生异常,就会对业务造成影响。(还是上面那张图)

那么如何处理异常情况来保证数据一致性呢
这些问题的源头都是多线程并发所导致的,所以最简单的方法就是加锁(分布式锁)。两个线程要修改同一条数据,每个线程在改之前,先去申请分布式锁,拿到锁的线程才允许更新数据库和缓存,拿不到锁的线程,返回失败,等待下次重试。这么做的原因是限制只有一个线程可以操作数据和缓存,以预防并发问题。
但加锁费时费力,肯定不推荐。并且,每次去更新缓存,但是缓存中的数据不一定会被马上读取,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。而且很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,很有可能是先查询数据库,再经过一系列「计算」得出一个值,才把这个值才写到缓存中。
由此可见,这种更新数据库 + 更新缓存的方案,不仅缓存利用率不高,还会造成机器性能的浪费。
所以此时我们需要考虑另外一种方案:删除缓存
先删除缓存再更新数据库
当有一个更新操作时,先删除对应的缓存数据,然后再更新数据库数据
但是,这种方案也有一些问题和风险,比如:

先更新数据库,再删除缓存
当有一个更新操作时,先更新数据库数据,再删除缓存
上面其实讲过了,我再重复一遍吧
缓存失效时,可能出现请求B从数据库中查询数据并得到旧值的情况。此时请求A更新数据库,将新值写入数据库,并删除缓存。而请求B又将旧值写入缓存中,导致脏数据

从上面看出现脏数据的要求要比更新缓存的要求更多,必须满足以下几个条件:
缓存失效
读请求 + 写请求并发
更新数据库 + 删除缓存的时间要比读数据库 + 写缓存时间短
前面两个很好满足,我们再看看第三点,这个真的会出现吗?
数据库在更新时一般是加锁的,读操作的速度远快于写操作的,所以第三点发生概率极低
针对双写问题,更适合的解决方案是在更新数据库之后再删除缓存,当然具体情况需要具体分析,不能一概而论。
讲解了这些操作后会出现的问题,那么为了避免这些问题,如何做呢?
先删除缓存再更新数据库,然后使用异步线程或消息队列来重建缓存。
先更新数据库再删除缓存,并设置一个合理的过期时间来保证缓存的有效性。
使用分布式锁或乐观锁来控制并发访问,并保证每次只有一个请求能够操作缓存和数据库
……
下面讲几种常见的方法以保证双写一致性
解决方案
1. 重试
上面也提到过,当第二步操作失败时,我就重试嘛,尽可能地补救,但重试的成本太大,上面讲过就不重复了。
2. 异步重试
既然重试方法占用资源,那我就做异步。在删除或更新缓存时,如果操作失败,不立即返回错误,而是通过一些机制(如消息队列、定时任务、订阅binlog等)来触发缓存的重试操作。虽然这种方法可以避免同步重试缓存时的性能损耗和阻塞问题,但是会延长缓存和数据库数据不一致的时间。
2.1 使用消息队列实现重试
使用消息队列异步重试缓存的情况是指,当信息发生变化时,先更新数据库,然后删缓存,如果删除成功就皆大欢喜,如果删除失败,则将需要删除的key发送到消息队列。另外,一个消费者线程会从消息队列中取出要删除的键,并依据该键删除或更新Redis缓存。如果操作失败,则重新发送到消息队列中进行重试。
注:也可以不先尝试删除,直接发送给消息队列,让消息队列
举个例子,如果有一个用户信息表,想要将用户信息存储在Redis里面。以下是可以执行的步骤,以采用消息队列异步重试缓存的方案为例:
当用户信息发生变化时,先更新数据库,并返回成功结果给前端。
尝试去删除缓存,成功则结束操作,失败则将要删除或更新缓存的操作生成一个消息(比如包含key和操作类型),并发送到消息队列中(比如使用Kafka或RabbitMQ)。
另外有一个消费者线程从消息队列中订阅并获取这些消息,并根据消息内容删除或更新Redis中的对应信息。
如果删除或更新缓存成功,则把这个消息从消息队列中移除(丢弃),以免重复操作。
如果删除或更新缓存失败,则执行失败策略,比如设置一个延迟时间或者一个重试次数限制,然后重新发送这个消息到消息队列中进行重试。
如果重试超过一定次数仍然失败,则向业务层发送报错信息,并记录日志。
2.2 Binlog实现异步重试删除
使用binlog实现一致性的基本思路是利用binlog日志来记录数据库的变更操作,然后通过主从复制或者增量备份的方式来同步或者恢复数据。
举例来说,如果我们有一个主数据库和一个从数据库,我们可以在主数据库上开启binlog日志,并设置从数据库作为它的复制节点。这样,当主数据库上发生任何变更操作时,它会将对应的binlog日志发送给从数据库,从数据库则会根据binlog日志来执行相同的操作,从而保证数据一致性。
另外,如果我们需要恢复某个时间点之前的数据,我们也可以利用binlog日志来实现。首先,我们需要找到对应时间点之前的最近一个全量备份文件,并将其恢复到目标数据库。然后,我们需要找到对应时间点之前的所有增量备份文件(即binlog日志文件),并按照顺序将其应用到目标数据库。这样,我们就可以恢复出目标时间点之前的数据状态了。
请您注册登录超级码客,加载全部码客文章内容... |