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