Redis——双写一致性
文章目录
- 1. 问题介绍
- 1.1 定义
- 1.2 起因
- 2. 解决方案
- 2.1 方案一:延迟双删
- 2.1.1 思想
- 2.1.2 实现方式
- 2.1.3 存在的问题
- 2.1.4 优点
- 2.1.5 缺点
- 2.1.6 适用场景
- 2.2 方案二:使用 Canal 监听 MySQL 从节点的数据变化
- 2.2.1 介绍
- 2.2.2 工作原理
- 2.2.3 解决双写不一致的思想
- 2.2.4 优点
- 2.2.5 缺点
- 2.2.6 适用场景
- 2.3 方案三:读写锁
- 2.3.1 思想
- 2.3.2 做法
- 2.3.3 优点
- 2.3.4 缺点
- 2.3.5 适用场景
- 3. 总结
1. 问题介绍
1.1 定义
在 Redis 中,双写一致性 指的是当数据更新时,既要更新数据库中的数据,又要更新 Redis 中的数据,并且要保证这两份数据的一致性。相对而言,双写不一致 就指的是数据更新后,数据库中的数据和缓存中的数据不一致。
1.2 起因
更新操作 和 查询操作 可能是并发的,从而导致在更新操作删除 Redis 的旧数据之后,查询操作再次将旧数据缓存到 Redis 中,从而造成两份数据不一致。
假设查询的流程如下:
更新有两种方案:
- 第一种方案:先删除缓存,再更新数据库。
- 第二种方案:先更新数据库,再删除缓存。
对于 查询 和 更新方案一 的并发执行,如果按照如下的时序图,则会缓存旧数据:
对于 查询 和 更新方案二 的并发执行,如果按照如下的时序图,则会缓存旧数据:
上面这两种情况在生产中 都有可能发生,双写一致性就是要避免这个问题。
2. 解决方案
2.1 方案一:延迟双删
2.1.1 思想
如果更新操作在完成更新数据库和删除缓存 之后再删除一遍缓存,那么就能解决这个问题,从而得出 延迟双删 的解决方案:在更新操作的最后一步执行延迟删除缓存的操作。
2.1.2 实现方式
- 通过
ScheduledExecutorService
的schedule()
实现:在更新操作的末尾,使用ScheduledExecutorService
的schedule(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 删除数据 的时间段内,查询到的所有数据都是旧数据。
- 读写锁:给查询操作加上读锁,给更新操作加上写锁,从根源上避免读写并发问题。保证了数据的强一致性,但相对前两种方案,性能比较低。