Redis - 缓存双写一致性问题解决详解(延时双删、biglog、消息队列、版本号、一致性哈希、分布式锁)
作者:hangge | 2024-12-16 08:42
并发操作可能会导致数据不一致问题,比如说多个线程同时更新数据库和缓存,在这种情况下有可能先更新数据库的线程后再更新缓存,导致数据不一致。本文我将介绍几种方法解决并发操作带来的不一致问题。
一、延迟双删
1,缓存延时双删
(1)延时双删流程如下:
- 先删除缓存
- 再更新数据库
- 休眠一会(比如 1 秒),再次删除缓存。
提示:休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒。为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。
(2)该方案正常来说只有休眠那一会(比如就那 1 秒),可能有脏数据。但是如果第二次删除缓存失败,那么缓存和数据库的数据还是可能不一致。
2,删除缓存重试机制
(1)延时双删可能会存在第二步的删除缓存失败,导致的数据不一致问题,删除缓存重试机制则解决了这个问题。在写入 MySQL 数据库后,立即删除对应的缓存数据。如果删除失败,采用重试机制,等待一定时间后再次尝试删除,直到删除成功。
(2)比如基于消息队列来进行重试的步骤如下:
- 写请求更新数据库
- 缓存因为某些原因,删除失败
- 把删除失败的 key 放到消息队列
- 消费消息队列的消息,获取要删除的 key
- 重试删除缓存操作
二、读取 biglog 异步删除缓存
1,实现原理
(1)删除缓存重试机制会造成许多业务代码入侵。为了解决这个问题,可以通过数据库的 binlog 来异步淘汰 key。
(2)即在写入 MySQL 数据库后,通过读取 MySQL 的 binlog(二进制日志)获取写操作的相关信息,包括被修改的表、记录的主键等。然后异步地删除对应的缓存数据。这样可以在缓存删除失败或者延迟时,通过读取 binlog 重新进行缓存删除操作。
注意:二进制日志(binlog)记录了所有的 DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW)语句;
2,实现步骤
(1)可以使用阿里的 canal 将 binlog 日志采集发送到 MQ 队列里面
(2)然后通过 ACK 机制确认处理这条更新消息,删除缓存,保证数据缓存一致性
三、消息队列方案
1,实现原理
(1)针对一些可以异步更新数据的场景,可以考虑将更新请求都发送到消息队列上。
注意:同一个业务的消息必须是有序的,不然更新数据会出错。
(2)消费者在取出消息之后,执行更新。而且消费者在更新失败之后,可以多次重试,对业务也没有什么影响。
(3)这个方案也是追求最终一致性的,强一致性还是用不了。
2,变种方案
(1)使用消息队列的方案一个变种,就是先更新数据库,再发送消息到消息队列,让消费者去更新缓存。
(2)但是如果消费者依赖消息中的数据来更新缓存,那么就会有并发问题,因为先更新数据库的不一定先发消息。
(3)所以最多就是把消息当成一个触发器,收到消息就去更新缓存,但是是查询数据库中的数据来执行更新。
注意:这种方式还是存在不足。既然要先更新数据库,为什么不用缓存模式中的 Refresh Ahead,引入 Canal 之类的来更新缓存呢,这样效果更好。
四、版本号方案
1,基本思路
(1)该思路是使用版本号来控制并发更新,每个数据都有一个对应的版本号,在更新的时候版本号都要加一。每一次在更新的时候都要比较版本号,版本号低的数据不能覆盖版本号高的数据。
(2)在这种思路之下,前文缓存模式里面出现过的很多数据不一致的场景,都能得到解决。比如说在 Cache Aside 里面,加上版本号之后,就不会出现不一致的问题了。
2,缺点
这个方案的缺点是需要维护版本号,最好是在数据库里面增加一个版本字段。那么后面在更新缓存的时候,比如说更新 Redis,就得使用 lua 脚本,先检测缓存的版本号,再执行更新了。不过也可以考虑用更新时间来替代版本号,一样可以。
五、一致性哈希负载均衡方案
1,基本思路
(1)这个方案主要解决的是并发更新的问题,因为它通过哈希负载均衡算法做到某一个 key 只有一个服务端节点在处理。
(2)在使用了这种负载均衡算法之后,更新缓存的时候要使用 singleflight 模式,那么就可以做到同一个 key 一定落在同一个节点上,并且这个节点上昀多只有一个线程在更新数据。如果有多个更新请求,那么它们会轮流更新数据库和缓存。
2,解决节点上线或者下线引起不一致的问题
(1)节点下线其实是最好处理的,因为下线之后,新的请求都会被打到另外一个节点上,也就是只有一个节点在处理某个 key 的请求。但是如果节点上线,也就是扩容的话,就会引起不一致的问题。
(2)解决这种数据不一致的思路也很简单,就是在新节点上线的一小段时间内,不要读写缓存,等待老节点上的请求自然返回。
- 假设,如果你们公司的响应时间是要求在 1s 以内,那么新节点上来的头 2s,就可以不用读写缓存。虽然这两秒的请求响应时间会很差,但是也能避开数据不一致的问题。
六、分布式锁方案
1,采用先本地事务,后分布式锁思路
(1)这个思路是先执行本地事务,然后加分布式锁,删除缓存,释放分布式锁。
(2)但如果单纯这样做,肯定会有不一致的问题,可以看一下示意图。
(3)要解决这个问题,在读请求来了的时候,先读缓存。如果缓存未命中,再加分布式锁,然后从数据中加载数据回写缓存,再释放分布式锁。
注意:这里的关键点是读请求第一次读缓存的时候,没有加分布式锁,这是保证高性能的关键。
(4)这里也可以应用 double-check 机制进一步优化,在读请求缓存未命中加上了分布式锁之后,再次读一下缓存,看看有没有别的线程已经加载好了数据。
2,采用先删除缓存,再提交事务思路
(1)该思路是尝试在数据库提交事务之前就删除缓存。具体流程是先开启本地事务,执行事务操作。然后获取分布式锁,删除缓存。紧接着提交事务,释放分布式锁。
注意:在缓存未命中回查的时候,还是要先加分布式锁,避免并发更新的问题。
(2)该方案有两种主要失败场景:
- 一是删除失败了,那么事务不会提交,数据是一致的。
- 二是删除成功了,事务提交失败了,数据依旧是一致的,也就是多删了一次缓存而已。
(3)这种解决思路已经非常非常接近强一致性了。不过这里有两个条件,如果满足了就可以认为是达成了强一致性的效果。
- 第一,分布式锁本身必须是可靠的,也就是一个线程拿了分布式锁,其他线程绝对不可能拿到同一个分布式锁。
- 第二,在删除缓存超时的时候会继续重试直到确认删除成功。
(4)这一个方案最大的缺点就是本地事务比较容易超时。比如说分布式锁被人抢占了,那么在等待分布式锁的时候,就可能超时。当然,如果本身网络不稳定导致一直无法删除缓存,也会引起本地事务超时。
(5)超时问题其实有方案来缓解。可以考虑先加分布式锁,再开启本地事务,然后删除缓存,提交事务,最后释放分布式锁。代价就是分布式锁的持有时间变长了。并且删除缓存本身依旧在本地事务里面,还是可能导致本地事务超时。
全部评论(0)