返回 导航

大数据

hangge.com

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 数据库后,通过读取 MySQLbinlog(二进制日志)获取写操作的相关信息,包括被修改的表、记录的主键等。然后异步地删除对应的缓存数据。这样可以在缓存删除失败或者延迟时,通过读取 binlog 重新进行缓存删除操作。
注意:二进制日志(binlog)记录了所有的 DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但不包括数据查询(SELECTSHOW)语句;

2,实现步骤

(1)可以使用阿里的 canalbinlog 日志采集发送到 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)

回到顶部