残云cyun
残云cyun
发布于 2025-10-18 / 12 阅读
0
0

Redis 双写一致性

什么是双写一致性

我们都知道在一个简单的项目中,使用 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,就会导致数据不一致的问题。

  1. 读操作开始时缓存必须未命中

  2. 写操作必须在读操作得到旧数据之后开始

  3. 读操作回写缓存必须在写操作删除缓存之后进行

这种情况理论上可行,但是过于极端,在真实的场景中出现的可能性极低,对比其他方案这种极低的概率是可以接受的。

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


评论