MySQL与缓存一致性问题

引入缓存带来的问题

引入缓冲实际上是一个空间换时间的问题, 意味着数据同时存在于多个空间(最常见就是 Redis 和 MYSQL)。

实际上,最权威最全的数据还是在 MySQL 里的。而万一 Redis数据没有得到及时的更新(例如数据库更新了没更新到Redis),就出现了数据不一致。

大部分情况下,只要使用了缓存,就必然会有不一致的情况出现,只是说这个不一致的时间窗口是否能做到足够的小。有些不合理的设计可能会导致数据持续不一致,这是我们需要改善设计去避免的。

缓存不一致情况分析

更新缓存的方式

在处理查询请求的时候, 逻辑一般如下:

1
2
3
4
5
6
7
data = queryDataRedis(key);
if (data ==null) {
data = queryDataMySQL(key); //缓存查询不到,从MySQL做查询
if (data!=null) {
updateRedis(key, data);//查询完数据后更新MySQL最新数据到Redis
}
}

流程如图:

查询缓存的逻辑: 优先查询缓存,查询不到才查询数据库。如果这时候数据库查到数据了,就将缓存的数据进行更新。 这个策略叫 cache aside, 也是最常用的策略。

引发数据不一致的来源主要是处理写请求的时候, 当处理写请求的时候, 会产生两个问题:

  1. 更新缓存还是删除缓存
  2. (如果是删除缓存)先更新数据库还是先删除缓存

更新缓存还是删除缓存

先说结论, 选择删除缓存。

如果选择更新缓存, 又分为两个情况:

  1. 先更新数据库, 再更新缓存
  2. 先更新缓存, 再更新数据库

针对第一种情况, 先更新数据库:

时序线程A线程B问题
T1更新数据库为5
T2更新数据库为 6
T3更新缓存为 6
T4更新缓存为5此刻缓存数据为5, 数据库的值是6, 发生了缓存覆盖

在看看先更新缓存的情况:

时序线程A线程B问题
T1更新缓存为 5
T2更新缓存为 6
T3更新数据库为 6
T4更新数据库为5此时缓存为5, 数据库为6, 还是发生了缓存覆盖

另外从事务的角度来看, 先更新缓存再更新数据库的代码:

1
2
updateRedis(key, data);//1. 先更新缓存
updateMySQL();//2. 再更新数据库

可以看出, 如果第一步成功, 第二步失败, 就算没有并发的情况下, 数据库发生了回滚, 而缓存已经更新了, 这里缓存就存的是脏数据了, 是业务无法接受的。

删除缓存

先删除缓存再更新数据库

首先看先删除缓存的情况

两个线程同时做更新操作, 由于网络问题可能发生如下时序:

时序线程A线程B问题
T1删除数据X的缓存
T2读取X,缓存MISS, 从数据库读取 6
T3更新数据库中X的值为 5
T4将数据库读取的6写回缓存此刻数据库中X是5,而缓存中的值是6

这里可以看到读请求可能回把一个旧值给写回了缓存, 导致不一致, 但是这里可以使用延迟双删的策略来处理:

时序线程A线程C线程D问题
T5Sleep(N)读取到缓存旧值此刻所有线程读取到的都是脏数据
T6删除缓存数据
T7更新数据库中X的值缓存miss, load数据库值到缓存

我们在更新完数据库后, 做了一个操作 Sleep(N), 然后再做了一次删除, 把缓存中的脏数据删除了。
这个方法的难点在于对 N 的时间的判断,如果 N 时间太短,线程 A 第二次删除缓存的时间依旧早于线程 B 把脏数据写回缓存的时间,那么相当于做了无用功。而 N 如果设置得太长,那么在触发双删之前,新请求看到的都是脏数据。

先更新数据库再删除缓存

时序线程A线程B问题
T1查询缓存 X Miss, 从数据库读取5
T2更新数据库 X=6
T3删除缓存
T4将5写回缓存 X此刻数据库中X是6,而缓存中的值是5

虽然这种场景出现的概率比较严格(很少出现并发量大的时候缓存 miss 的情况), 但是也会造成不一致的情况。

这种情况我们一般为业务设置了一个过期时间, 这种极少量的脏数据会在缓存过期后再次 load 到正确的值。

缓存操作的其他问题

删除缓存失败

先更新数据库,再删除缓存的场景中, 如果更新数据库的操作成功, 但是由于网络或其他原因删除缓存失败了(不讨论回滚数据库事务的情况, 本身事务的粒度要越小越好, 而且因为缓存操作失败而回滚已经更新好的数据库得不偿失), 也会造成一段时间的数据库不一致。

数据库主从同步产生延时

在查询请求的操作中, 回源缓存是从数据库中 select 数据, 读请求一般是读的从库, 如果发生主从延迟, 这里可能主库已经更新了, 但是从库还是原来的数据, 也会造成脏数据。

如果考虑兼容这种情况, 还是可以使用延迟双删, sleep 的时间在加上主从延迟的时间即可

综合性较强的方案:解析 binlog 来操作缓存

有一种业界比较常用, 但是设计和编码成本是最高的方案, 如果对一致性的要求非常高 ,可以使用这种方案:

这里有几个注意点:

  1. 从从库拉 binlog 而不是主库。 防止主从同步延迟 ,主库更新了,MQ 将主库的值更新了缓存, 但是查询请求的时候将从库的值覆盖了缓存造成不一致。
  2. 引入 MQ 串行消费 binlog 解析后的数据, 防止更新缓存并行覆盖。另一点, 也防止操作缓存失败, MQ 可以重复消费, 成功再提交 offset

MySQL与缓存一致性问题
https://haobin.work/2021/10/18/架构源码/MySQL与缓存一致性问题/
作者
Leo Hao
发布于
2021年10月18日
许可协议