什么是双写一致性
我们都知道在一个简单的项目中,使用 MySQL 数据库作为持久层就能够完成绝大多数需求。但是在高并发场景中,海量的请求可能瞬间让 MySQL 性能达到瓶颈,甚至导致系统宕机。这时候就需要引入 Redis 作为缓存层,减少实际访问 MySQL 的请求次数。
当服务器收到查询请求时,首先查询 Redis 是否命中缓存,若命中则直接返回,反之将从 MySQL 中查询数据写入 Redis 并返回。
flowchart TB
redis[查询 Redis] --> cache{缓存是否命中}
cache -->| 命中 | return[返回结果]
cache -->| 未命中 | mysql[查询 MySQL]
mysql --> update[更新缓存]
update --> return在这种架构中,不可避免的需要考虑 MySQL 与 Redis 数据一致性的问题。
强一致性:在任何时刻,当 MySQL 数据发生变动时,需要立刻同步到 Redis 中。
弱一致性:当 MySQL 数据更新后,不保证能立刻同步到 Redis,也可能数据永远都不一致。
最终一致性:弱一致性的一种特殊情况,允许在短时间内数据不一致,但最终会同步到 Redis 中,保证双方数据一致性。
4 种更新策略
保证数据一致性就需要有两个行为,更新数据库与更新缓存,要如何选择这两种操作的顺序与时机呢?接下来讨论 4 种不同的场景。
先更新数据库,再更新缓存
在正常的情况下,服务器要进行写操作时,先将数据写入 MySQL,成功后再将数据写入 Redis,保证两边数据一致性。
sequenceDiagram
participant Server
participant Redis
participant MySQL
Server ->> MySQL: set data = 100
Note over MySQL: data = 100
Server ->> Redis: set data = 100
Note over Redis: data = 100以上是正常情况,若是异常情况,如服务器先将数据写入 MySQL 后,再向 Redis 写入新数据时发生了网络异常,这时候就会出现 MySQL 存在新数据而 Redis 不同步的问题。
sequenceDiagram
participant Server
participant Redis
participant MySQL
Server ->> MySQL: set data = 100
Note over MySQL: data = 100
Server --x Redis: 网络异常另外,在多线程的环境下,可能线程 A 将数据写入 MySQL 后由于网络异常等原因没有立刻将数据同步到 Redis 中,而后线程 B 将新的数据写入了 MySQL 同时更新了 Redis 中的数据,当线程 B 操作完成后线程 A 又将旧数据写入了 Redis,导致数据不一致的问题。
sequenceDiagram
participant Thread A
participant Thread B
participant Redis
participant MySQL
Thread A ->> MySQL: set data = 100
activate Thread A
Note over MySQL: data = 100
Thread B ->> MySQL: set data = 200
activate Thread B
Note over MySQL: data = 200
Thread B ->> Redis: set data = 200
deactivate Thread B
Note over Redis: data = 200
Thread A ->> Redis: set data = 100
deactivate Thread A
Note over Redis: data = 100先更新缓存,再更新数据库
将数据库与缓存的更新顺序交换,也会导致数据不一致,而且情况更严重!
若数据写入 MySQL 失败导致 MySQL 中保存的不是最新数据,由于所有查询请求都会优先查询 Redis,程序将会使用 Redis 中的新数据继续向下运行,永远都无法发现 MySQL 中保存的还是旧数据。
sequenceDiagram
participant Thread A
participant Thread B
participant Redis
participant MySQL
Thread A ->> Redis: set data = 100
activate Thread A
Note over Redis: data = 100
Thread B ->> Redis: set data = 200
activate Thread B
Note over Redis: data = 200
Thread A ->> MySQL: set data = 100
deactivate Thread A
Note over MySQL: data = 100
Thread B -x MySQL: set data = 200
deactivate Thread B在实际业务中,应当使用 MySQL 作为底单数据库确保数据的准确性,Redis 做作为 MySQL 数据的缓存提升后续请求查询效率。优先更新 Redis 相当于将两者角色互换,极其不推荐这种做法!!
先删除缓存,再更新数据库
根据上面的分析,可以发现无论是在更新数据库的前后更新 Redis 都会出现数据不一致的问题。
那我们换一种思路,既然线程主动去更新缓存不可行,是否可以直接删除缓存?当查询 Redis 时发现缓存未命中,再去 MySQL 中获取最新数据再更新 Redis。
sequenceDiagram
participant Thread A
participant Thread B
participant Redis
participant MySQL
Thread A ->> Redis: del data
activate Thread A
Thread A ->> MySQL: set data = 100
deactivate Thread A
Note over MySQL: data = 100
Thread B ->> Redis: get data
activate Thread B
Redis -->> Thread B: nil
Thread B ->> MySQL: get data
MySQL -->> Thread B: data = 100
Thread B ->> Redis: set data = 100
deactivate Thread B
Note over Redis: data = 100这么看好像很和谐,数据也一致了,但无论是删除更新还是查询更新,这一系列的动作都不是原子的。
在实际高并发的场景下,会出现线程 A 先将缓存删除了还没来得及更新 MySQL,这时线程 B 来查询 Redis 发现缓存未命中,紧接着就查询 MySQL 得到旧数据,接着线程 A 才姗姗来迟将新数据写入 MySQL,最后线程 B 使用旧数据更新 Redis 缓存,发生数据不一致的问题。
sequenceDiagram
participant Thread A
participant Thread B
participant Redis
participant MySQL
Thread A ->> Redis: del data
activate Thread A
Thread B ->> Redis: get data
activate Thread B
Redis -->> Thread B: nil
Thread B ->> MySQL: get data
Note over MySQL: data = 100
MySQL -->> Thread B: data = 100
Thread A ->> MySQL: set data = 200
deactivate Thread A
Note over MySQL: data = 200
Thread B ->> Redis: set data = 100
deactivate Thread B
Note over Redis: data = 100先更新数据库,再删除缓存
最后一种策略, 线程 A 将新数据写入 MySQL 还没来得及删除 Redis 缓存,这时线程 B 就发起了查询请求拿到了旧数据,而后线程 A 才将 Redis 缓存删除。
sequenceDiagram
participant Thread A
participant Thread B
participant Redis
participant MySQL
Thread A ->> MySQL: set data = 200
activate Thread A
Note over MySQL: data = 200
Thread B ->> Redis: get data
activate Thread B
Note over Redis: data = 100
Redis -->> Thread B: data = 100
deactivate Thread B
Thread A ->> Redis: del data
deactivate Thread A在这种情况下,虽然线程 B 获取了一次旧数据,但是在线程 A 将缓存删除之后,Redis 与 MySQL 的数据是一致的。
其实,还有一种情况,若是线程 B 先进行查询操作,并且在更新缓存之前线程 A 开始写操作,当线程 A 完成了删除缓存操作后线程 B 才将缓存写入 Redis,就会导致数据不一致的问题。
读操作开始时缓存必须未命中
写操作必须在读操作得到旧数据之后开始
读操作回写缓存必须在写操作删除缓存之后进行
这种情况理论上可行,但是过于极端,在真实的场景中出现的可能性极低,对比其他方案这种极低的概率是可以接受的。
sequenceDiagram
participant Thread A
participant Thread B
participant Redis
participant MySQL
Thread B ->> Redis: get data
activate Thread B
Redis -->> Thread B: nil
Thread B ->> MySQL: get data
Note over MySQL: data = 100
MySQL -->> Thread B: data = 100
Thread A ->> MySQL: set data = 200
activate Thread A
Note over MySQL: data = 200
Thread A ->> Redis: del data
deactivate Thread A
Thread B ->> Redis: set data = 100
deactivate Thread B
Note over Redis: data = 100主流解决方案
延迟双删策略
根据上面的分析,发现若先删缓存再更新数据库可能会数据不一致,若先更新数据库再删缓存可能读到一次旧数据。
若将两者结合起来,写操作开始时就删除一次缓存,将数据库写入数据库后再删除一次缓存。为了避免数据不一致的问题,两次删除缓存间隔必须大于一次读操作回写缓存所需要的时间。
sequenceDiagram
participant Thread A
participant Thread B
participant Redis
participant MySQL
Thread A ->> Redis: del data
activate Thread A
Thread B ->> Redis: get data
activate Thread B
Redis -->> Thread B: nil
Thread B ->> MySQL: get data
Note over MySQL: data = 100
MySQL -->> Thread B: data = 100
Thread A ->> MySQL: set data = 200
deactivate Thread A
Note over MySQL: data = 200
Thread B ->> Redis: set data = 100
deactivate Thread B
Note over Redis: data = 100
critical timeout
Thread A -->> Redis: del data again
end对于第二次删除缓存需要等待一段时间,若直接使用 Thread.Sleep()
会导致线程阻塞无法处理其他任务,可以采用消息队列或异步线程池避免阻塞线程。对于异步任务还需要有重试机制,确保第二次缓存删除成功。
延迟双删策略实现比较简单,但是也有一个很大的问题:要怎么确定延迟时间?可以通过压测等方式获取相对合适的延迟时间,但是每个接口都需要进行压测判断后才能判断延迟时间也挺麻烦。
订阅 Binlog
MySQL 主从复制主要通过 Binlog 将 Master 的数据同步到 Slave,我们可以使用一个中间件(如 Canal)伪造成 MySQL 的一个 Slave,当 Master 数据发生变动时都会被中间件感知到并主动的将数据同步到 Redis。
sequenceDiagram
participant Server
participant Redis
participant MySQL
participant Middle
Server ->> MySQL: set data = 100
Note over MySQL: data = 100
MySQL ->> Middle: Binlog
Middle ->> Redis: set data = 100
Note over Redis: data = 100参考资料
https://www.bilibili.com/video/BV13R4y1v7sP?p=107http://www.susan.net.cn/desgin/25.RedistoMySQL.htmlhttps://archmanual.com/backend/redis/consistent.html