自学内容网 自学内容网

Redis——双写一致性


1. 问题介绍

1.1 定义

在 Redis 中,双写一致性 指的是当数据更新时,既要更新数据库中的数据,又要更新 Redis 中的数据,并且要保证这两份数据的一致性。相对而言,双写不一致 就指的是数据更新后,数据库中的数据和缓存中的数据不一致。

1.2 起因

更新操作 和 查询操作 可能是并发的,从而导致在更新操作删除 Redis 的旧数据之后,查询操作再次将旧数据缓存到 Redis 中,从而造成两份数据不一致。

假设查询的流程如下:

Created with Raphaël 2.3.0 开始 从 Redis 中读取 Redis 中的数据是否为 null 从数据库中读取 数据库中的数据是否为 null 返回数据 结束 将数据库中的数据缓存到 Redis 中 yes no yes no

更新有两种方案:

  • 第一种方案:先删除缓存,再更新数据库。
  • 第二种方案:先更新数据库,再删除缓存。

对于 查询 和 更新方案一 的并发执行,如果按照如下的时序图,则会缓存旧数据:

更新操作 数据库 Redis 查询操作 删除缓存 查询缓存 发现缓存为 null,查询数据库 由于某些操作浪费了一些时间 更新数据库 缓存旧数据 更新操作 数据库 Redis 查询操作

对于 查询 和 更新方案二 的并发执行,如果按照如下的时序图,则会缓存旧数据:

更新操作 数据库 Redis 查询操作 查询缓存 发现 缓存为 null,查询数据库 更新数据库 删除缓存 由于某些操作浪费了一些时间 缓存旧数据 更新操作 数据库 Redis 查询操作

上面这两种情况在生产中 都有可能发生,双写一致性就是要避免这个问题。

2. 解决方案

2.1 方案一:延迟双删

2.1.1 思想

如果更新操作在完成更新数据库和删除缓存 之后再删除一遍缓存,那么就能解决这个问题,从而得出 延迟双删 的解决方案:在更新操作的最后一步执行延迟删除缓存的操作。

2.1.2 实现方式

  • 通过 ScheduledExecutorServiceschedule() 实现:在更新操作的末尾,使用 ScheduledExecutorServiceschedule(Runnable command, long delay, TimeUnit unit) 方法,指定时间延迟删除 Redis 中的缓存。
  • 通过消息队列的延迟消息实现:在更新操作的末尾,生产一条删除 Redis 中指定缓存的延迟消息,然后让消费者去消费这条消息,删除 Redis 中指定的缓存。

2.1.3 存在的问题

1s 之后再次删除可以避免绝大多数双写不一致问题,因为很少有查询操作的时间会超过 1s。但由于生产中的 MySQL 往往是以集群模式部署的,会有 主从同步 的时间消耗,如果在从节点没有更新数据之前执行查询操作,就会读到旧数据,这时可以相对增加一点延迟时间,比如延迟 3s 后再次删除。所以 延迟双删有双写不一致的风险

2.1.4 优点

  • 性能高:由于读和写是并发的,所以性能会很高。
  • 实现比较简单:由于可以使用定时任务实现,所以实现比较简单。
  • 保证了数据的最终一致性:由于延迟删除缓存,所以缓存中的数据和数据库中的数据最终是一致的。

2.1.5 缺点

  • 无法保证数据的强一致性:由于 延迟删除缓存的时刻 可能与 数据更新完毕(主从同步之后)的时刻 间隔了不少时间,在这期间数据的一致性无法保障。

2.1.6 适用场景

本方案适用于 允许数据短暂不一致、对性能要求较高 的场景,大多数生产场景都是如此,比如文章浏览量的更新。

2.2 方案二:使用 Canal 监听 MySQL 从节点的数据变化

2.2.1 介绍

Canal 是阿里巴巴开源的一个基于 MySQL 数据库增量日志解析的中间件,它提供了增量数据订阅和消费的功能,主要用于捕获数据库数据变更,将其发送给其他系统进行处理。

2.2.2 工作原理

类似于 MySQL 的 主从复制 机制:将主数据库的 binlog (二进制日志) 传输到从数据库,从数据库根据 binlog 修改数据,从而实现数据的同步。

Canal 也是解析 MySQL 的 binlog,不过它不是用于数据同步到另一个数据库,而是把变更数据以消息的形式发送给下游的应用程序。

2.2.3 解决双写不一致的思想

当 MySQL 从节点中的数据被更新时,Canal 通知 Redis 删除缓存。

2.2.4 优点

  • 无代码侵入性:其它方案多少都需要在更新操作中添加代码,而使用本方案无需在更新操作中添加代码。
  • 数据的一致性相较方案一更强:当 MySQL 从节点中的数据被更新时,Canal 通知 Redis 删除缓存,这样依赖,本方案的删除时刻 就会比 方案一中延迟删除时刻 早一点,删除时刻 是 数据更新完毕(主从同步之后)的时刻。
  • 数据库的压力小:Canal 通过直接解析 MySQL 的 binlog 文件来获取数据变更,避免了频繁地查询数据库表,减少了对数据库的压力。

2.2.5 缺点

  • 数据一致性仍不是很强:虽然本方案对比方案一提升了数据的一致性,但在 主节点修改数据 到 从节点同步数据 的时间段内,数据仍是不一致的。
  • 配置和管理复杂:Canal 的配置相对复杂,需要对 MySQL 的 binlog 配置、Canal 自身的服务器配置、客户端配置等多个方面进行正确设置。

2.2.6 适用场景

本方案也适用于 允许数据短暂不一致、对性能要求较高 的场景。

2.3 方案三:读写锁

2.3.1 思想

既然查询和更新操作并发会影响数据的一致性,那么直接禁止查询和更新操作并发即可,这时就可以给查询操作加上 读锁,给更新操作加上 写锁

以下是读锁和写锁的特性:

  • 读锁:共享锁,只会与排他锁发生排斥,与共享锁不会发生排斥。
  • 写锁:排他锁,与所有锁发生排斥。

它们之间的排斥关系如下表所示:

排斥关系读锁写锁
读锁不排斥排斥
写锁排斥排斥

这样一来,查询操作可以并发,但会被更新操作阻塞,从而避免了双写不一致的问题。

2.3.2 做法

使用 Redisson 框架提供的读写锁,代码如下所示:

RReadWriteLock rwLock = redisson.getReadWriteLock("lock");// 获取读写锁
RLock readLock = rwLock.readLock();// 从读写锁中获取读锁
readLock.lock();// 使用读锁
try {
    // 执行查询操作
} finally {
    readLock.unlock();// 释放读锁
}
RReadWriteLock rwLock = redisson.getReadWriteLock("lock");// 获取读写锁
RLock writeLock = rwLock.writeLock();// 从读写锁中获取写锁
writeLock.lock();// 使用写锁
try {
    // 执行更新操作
} finally {
    writeLock.unlock();// 释放写锁
}

注意:获取读锁和写锁之前都需要先获取读写锁,而且读写锁的键必须一致。

2.3.3 优点

  • 保证了数据的强一致性:查询操作和更新操作不是并发的,从根源上避免了双写不一致的问题。

2.3.4 缺点

  • 性能相对较差:由于查询操作和更新操作是相互阻塞的,但查询操作却是可以并发的,所以性能相对较差。
  • 对代码的侵入性比较大:相对于方案二 (无侵入) 和方案一 (只在更新操作的末尾加了一段代码),本方案要求在查询时获取读锁,在更新时获取写锁,对代码的侵入性比较大。

2.3.5 适用场景

本方案适用于 允许性能不是很高、要求数据强一致 的场景,尤其是与钱相关的业务。

3. 总结

Redis 中,双写不一致问题发生在 查询操作 和 更新操作 并发的时候,当更新操作只删除一次缓存时,查询操作可能会把旧数据缓存起来,从而导致双写不一致。

解决方案主要有三种:

  • 延迟双删:在删除一遍缓存后,间隔一段时间再次删除缓存。两次删除间隔的时间段内,查询到的所有数据都是旧数据。
  • 使用 Canal 监听 MySQL 从节点的数据变化:使用阿里巴巴开发的 Canal 中间件,监听 MySQL 从节点的数据变化,变化之后通知 Redis 删除数据。在 主节点更新数据 到 从节点同步数据后通知 Redis 删除数据 的时间段内,查询到的所有数据都是旧数据。
  • 读写锁:给查询操作加上读锁,给更新操作加上写锁,从根源上避免读写并发问题。保证了数据的强一致性,但相对前两种方案,性能比较低。

原文地址:https://blog.csdn.net/qq_61350148/article/details/144710560

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!