Skip to content

1. 数据库和缓存的一致性

万字图文讲透数据库缓存一致性问题

1.1 缓存

缓存的意义

所谓缓存,就是用更高速的空间来换时间,从而整体上提升读的性能

缓存不一致性无法客观地完全消灭

只要使用了缓存,就必然会有不一致的情况出现,只是说这个不一致的时间窗口是否能做到足够的小。

由于数据库和 Redis 之间是没有事务保证的,无法保证两个存储介质同时更新成功或失败,即便成功了,两个介质都更新的这个中间时段也肯定是不一致的,所以说这个时间窗口是没办法完全消灭的,除非我们付出极大的代价,使用分布式事务等各种手段去维持强一致,但是这样会使得系统的整体性能大幅度下降,甚至比不用缓存还慢。

但是我们能做到的是缓存与数据库达到最终一致,而且不一致的时间窗口我们能做到尽可能短,按照经验来说,如果能将时间优化到 1ms 之内,这个一致性问题带来的影响我们就可以忽略不计。

常见的缓存更新策略共有3种:

  • Cache Aside(旁路缓存)策略;应用程序直接与「数据库、缓存」交互,并负责对缓存的维护。
  • Read/Write Through(读穿 / 写穿)策略;
  • Write Back(写回)策略;

实际开发中,Redis 和 MySQL 的更新策略用的是 Cache Aside,另外两种策略应用不了。其它两种策略参考 操作系统->硬件结构->《CPU Cache》。

1.2 更新缓存的手段

cache aside 的策略(旁路缓存策略),也是最常用的策略:优先查询缓存,查询不到才查询数据库。

一致性的问题一般不来源于此,而是出现在处理写请求的时候。

为了应对一致性问题演变出 4 个策略

  • 先更新数据库,再更新缓存
  • 先更新缓存,再更新数据库
  • 先删除缓存,再更新数据库
  • 先更新数据库,再删除缓存

1.2.1 先更新数据库,再更新缓存

存在的问题

  1. 缓存更新失败,只能等到下一次数据库更新或者缓存失效才可能修复。

  2. 读写并发,A 执行缓存更新之前 B 读取了缓存中的旧值

应对方案:可以忽略

  1. 写写并发,A 执行中间 B 执行完了整个流程,造成缓存是脏数据

8febac10b14bed16cb96d1d944cd08da

应对方案:分布式锁(操作很重)。

1.2.2 先更新缓存,再更新数据库

存在的问题

  1. 数据库更新失败,缓存写入错误的数据

应对方案:利用 MQ 确认数据库更新成功(较复杂)

  1. 写写并发,A 执行数据库更新之前 B 执行完了,导致数据库的数据错误

454a8228a6549176ad7e0484fba3c92b

应对方案:分布式锁(操作很重)。

应该避免使用这种策略

1.2.3 先删除缓存,再更新数据库

存在的问题

写写并发没问题,都会删掉缓存。

  1. 读写并发,A 更新数据库之前,B 读取操作读取了数据库中的旧值,并把旧值写入缓存,导致缓存中还是旧值

cc208c2931b4e889d1a58cb655537767

应对方案:延迟双删策略

在写请求处理完之后,等到差不多的时间 N 延迟再重新删除这个缓存值。

但是这种策略延迟的时间不好估计,且延迟的过程中依旧有不一致的时间窗口, N 延迟期间仍有可能会有读操作读取旧值。

1.2.4 先更新数据库,再删除缓存

存在的问题

写写并发没问题,都会删掉缓存。

  1. 读写并发(缓存命中),A 更新中间,B 读取出缓存中的旧数据,返回了旧值。注意⚠️,此时 B 不会写缓存。

在这个场景下,仅在更新数据库成功到缓存删除之间的时间差内存在一个不一致窗口,内网状态下通常不过 1ms,在大部分业务场景下我们都可以忽略不计。因为大部分情况下一个用户的请求很难能再 1ms 内快速发起第二次。

应对方案:可以忽略。

  1. 读写并发(缓存未命中),B 读取时缓存不存在,然后查询出旧值,此时 A 更新并删除缓存,最终 B 返回并缓存了旧值。

1cc7401143e79383ead96582ac11b615

这个不一致场景出现条件非常严格,因为并发量很大时,缓存不太可能不存在;如果并发很大,而缓存真的不存在,那么很可能是这时的写场景很多,因为写场景会删除缓存。

应对方案:分布式锁(操作重)。

1.3 合适的策略

从一致性的角度来看,采取更新数据库后删除缓存值,是更为适合的策略。因为出现不一致的场景的条件更为苛刻,概率相比其他方案更低。

删除缓存值意味着对应的 key 会失效,那么这时候读请求都会打到数据库。如果这个数据的写操作非常频繁,就会导致缓存的作用变得非常小。而如果这时候某些 Key 还是非常大的热 key,就可能因为扛不住数据量而导致系统不可用。

简单总结,足以适应绝大部分的互联网开发场景的决策:

  • 针对大部分读多写少场景,建议选择更新数据库后删除缓存的策略。
  • 针对读写相当或者写多读少的场景,建议选择更新数据库后更新缓存的策略。

1.4 最终一致性如何保证

1.4.1 缓存设置过期时间

有些时候 MySQL 的数据无法刷到 Redis 了。为了避免这种不一致性永久存在,使用缓存的时候,我们必须要给缓存设置一个过期时间。

这是我们最终一致性的兜底方案,万一出现任何情况的不一致问题,最后都能通过缓存失效后重新查询数据库,然后回写到缓存,来做到缓存与数据库的最终一致。

1.4.2 如何减少缓存删除/更新的失败

为了减少这种不一致的情况,一个不错的选择就是借助可靠的消息中间件

因为消息中间件有 ATLEAST-ONCE 的机制,如下图所示。

极端场景下,是否存在更新数据库后 MQ 消息没发送成功,或者没机会发送出去机器就重启的情况?

这个场景的确比较麻烦,如果 MQ 使用的是 RocketMQ,我们可以借助 RocketMQ 的事务消息,来让删除缓存的消息最终一定发送出去。而如果你没有使用 RocketMQ,或者你使用的消息中间件并没有事务消息的特性,则可以采取消息表的方式让更新数据库和发送消息一起成功。

1.4.3 复杂的多缓存场景

有些时候,真实的缓存场景并不是数据库中的一个记录对应一个 Key 这么简单,有可能一个数据库记录的更新会牵扯到多个 Key 的更新。还有另外一个场景是,更新不同的数据库的记录时可能需要更新同一个 Key 值,这常见于一些 App 首页数据的缓存。

也就是说存在 数据库记录 和 缓存的 多对一 或 一对多 关系。

针对这个场景,解决方案和上文提到的保证最终一致性的操作一样,就是把更新缓存的操作以 MQ 消息的方式发送出去,由不同的系统或者专门的一个系统进行订阅,而做聚合的操作。

不同业务系统订阅 MQ 消息单独维护各自的缓存 Key:

专门更新缓存的服务订阅 MQ 消息维护所有相关 Key 的缓存操作:

1.4.4 订阅 MySQL binlog

上面讲到的 MQ 处理方式需要业务代码里面显式地发送 MQ 消息。还有一种优雅的方式便是订阅 MySQL 的 binlog,监听数据的真实变化情况以处理相关的缓存。无论是在什么系统什么位置去更新数据,都能做到集中处理。

阿里巴巴开源的 Canal 中间件就是基于这个实现的。

Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

利用 Canel 订阅数据库 binlog 变更从而发出 MQ 消息,让一个专门消费者服务维护所有相关 Key 的缓存操作。