如何保障缓存和数据库的一致性(超详细案例)

我们来看现在主流的有哪些方案,最后我会分享我自己的最终方案。
方案1:同步删除
核心流程:

  1. 先更新数据库数据
  2. 然后删除缓存数据
    存在的问题:
    1)删除缓存失败存在脏数据
    2)难以收拢所有更新数据库入口
    使用同步删除方案,你必须在所有更新数据库的地方都进行缓存的删除操作,如果你有一个地方漏掉了,对应的缓存就相当于没有删除了,就会导致脏数据问题。
    还有就是如果我们通过命令行直接来更新数据库的数据,或者通过公司提供的数据库管理平台来更新数据库数据,这个时候你就没法删除了,因为你的同步删除其实只是写在你的代码里面,这个时候也就导致脏数据问题了,这也是为什么说同步删除很难覆盖所有的入口,同时存在很大的风险。
    3)并发场景下存在脏数据
    我们看一个例子:
    例子:表A存在数据 a=1,并发情况下可能有以下流程:

    该例子中,由于线程2在查询完数据库数据之后,写入缓存之前,数据库的数据被线程1更新并且执行完同步删除操作了,所以最终导致脏数据问题,并且脏数据可能会持续很久。
    当然,由于更新数据库操作耗时一般比写缓存更久,所以该例子发生的概率并不会太大,但还是有可能的。
    最典型的场景就是,线程2查询完数据库之后,写缓存之前,线程2所在服务器发生了YGC,这个时候线程2可能就需要等待几十毫秒才能执行写缓存操作,这种情况就很容易出现上面这个例子了。
    小结:由于难以收拢所有更新数据库入口,同时可能存在长期的脏数据问题,该方案一般不会被单独使用,但是可以作为一个补充,下面的方案会提到。

方案2:延迟双删
核心流程:

  1. 删除缓存数据
  2. 更新数据库数据
  3. 等待一小段时间
  4. 再次删除缓存数据
    存在的问题:
    1)延迟时间难以确认
    到底是延迟一秒或者是几秒,这个其实很难确认,你总不能延迟几分钟吧,因为你如果延迟几分钟,那这几分钟可能就存在脏数据了,所以这个时间很难确定。
    2)无法绝对保障数据的一致性
    我们看下面这个例子:
    例子:表A存在数据 a=1,并发情况下可能有以下流程

    该例子中,由于数据库主从同步完成之间,存在并发的请求,从而导致脏数据问题,并且脏数据可能会持续很久。
    可能有的同学觉得稍微调大点延迟时间就可以解决这个问题,但是其实主库在写压力比较大的时候,主从之间的同步延迟甚至可能是分钟级的。
    因此,该方案整体来说还是有明显的问题,所以说一般也不会使用这个方案。
    小结:由于延迟时间难以确认,同时无法绝对保障数据的一致性,该方案一般不会使用。

方案3:Canal异步监听binlog删除 + 重试
核心流程:

  1. 更新数据库
  2. 监听binlog删除缓存
  3. 缓存删除失败则通过MQ不断重试,直至删除成功
    整体流程图如下:

    该方案是当前的主流方案,整体上没太大的问题,但是极端场景下可能还是有一些小问题。

存在的问题:
1)脏数据时间窗口“较大”
这个脏数据时间窗口较大,是相对同步删除来说。在你收到binlog之前,他中间要经过:binlog从主库同步到从库、binlog从库到binlog监听组件、binlog从监听组件发送到MQ、消费MQ消息,这些操作每个都是有一定的耗时的,可能是几十毫秒甚至几百毫秒,所以说它其实整体是有一个脏数据的时间窗口。
而同步删除是在更新完数据库后马上删除,时间窗口大概也就是1毫秒左右,所以说binlog的方式相对于同步删除,可能存在的脏数据窗口会稍微大一点。
2)极端场景下存在长期脏数据问题

  • binlog抓取组件宕机导致脏数据。该方案强依赖于监听binlog的组件,如果监听binlog组件出现宕机,则会导致大量脏数据。
  • 拆库拆表流程中可能存在并发脏数据
    拆库拆表流程中并发脏数据问题
    我们来看下面这个例子:
    表A正在进行数据库拆分,当前进行到灰度切读流量阶段:部分读新库,部分读老库
    数据库拆分大致流程:增量数据同步(双写)、全量数据迁移、数据一致性校验、灰度切读、切读完毕后停写老库。
    此时表A存在数据 a=1,并发情况下可能有以下流程

    该例子中,灰度切读阶段中,我们还是优先保障老库的流程,因此还是先写老库,由于写新库和写老库之间存在时间间隔,导致线程2并发查询到新库的老数据,同时在监听binlog删除缓存流程之后将老数据写入缓存,从而导致脏数据问题,并且脏数据可能会持续很久。
    双写的方式有很多种,我们使用的是通过公司的中间件直接将老库数据通过binlog的方式同步到新库,该方案通过监控发现在写压力较大的情况下,延迟可能会达到几秒,因此出现了上述问题。
    而如果是使用代码进行同步双写,双写之间的时间间隔会较小,该问题出现的概率会相对低很多,但是还是无法保障绝对不会出现,就像上面提过的,写老库和写新库2个操作之间如果发生了YGC或者FGC,就可能导致这两个操作之间的时间间隔比较大,从而可能发生上面的案例。
    还有就是代码双写的方式必须收敛所有的写入口,上文提到过的,通过命令行或者数据库管理平台的方式修改的数据,代码双写也是无法覆盖的,需要执行者在新老库都执行一遍,如果遗漏了新库,则可能导致数据问题。
    小结:该方案在大多数场景下没有太大问题,业务比较小的场景可以使用,或者在其基础上进行适当补充。

最终方案:缓存三重删除 + 数据一致性校验 + 更新流程禁用缓存 + 强制读Redis主节点
这个方案也是我现在在我们项目中里面使用的方案,这里面有很多很多的思考,踩了各种坑之后不断优化而来的方案。该方案整体以方案3作为主体,然后增加了各种优化。
整体方案如下:

  • 更新数据库同步删除缓存
  • 监听数据库的binlog异步删除缓存:带重试,保障一定会最终删除成功
  • 缓存数据带过期时间,过期后自动删除,越近更新的数据过期时间越短

    • 主要用于进一步防止并发下的脏数据问题
    • 解决一些由于未知情况,导致需要更换缓存结构的问题
  • 监听数据库的binlog延迟N秒后进行数据一致性校验

    • 解决一些极端场景下的脏数据问题
  • 存在数据库更新的链路禁用对应缓存

    • 防止并发下短期内的脏数据影响到更新流程
  • 强制读Redis主节点
  • 查询异步数据一致性校验、灰度放量

整体流程图
下面我们来细说各个方案点的设计初衷。
1)更新数据库后同步删除缓存
这个同步删除缓存其实是为了解决我们上面说的那个异步binlog删除不一致时间窗口比较大的问题。更新完数据之后,我们马上进行一次同步删除,不一致的时间窗口非常小。

2)监听数据库的binlog异步删除缓存
该步骤是整个方案的核心,也就是方案3,因为binlog理论上是绝对不会丢的,他不像同步删除存在无法收敛入口的问题。因此,我们会保障该步骤一定能删除成功,如果出现失败,则通过MQ不断重试。
通过前面两个方案点,我们其实已经保障了绝大多数场景下数据是正确的。

3)缓存数据带过期时间,过期后自动删除,越近更新的数据过期时间越短
该策略的设计初衷是因为我们前面讲的那些并发问题其实都是在存在并发更新跟一些并发查询的场景下出现的,因此最近刚刚更新过的数据,他出现不一致的概率相对于那种很久没更新过的数据来说会大很多。
例如最近一个小时内更新的数据,我可能给他设计的过期时间很短,当然这个过期时间很多是相对于其他数据而言,绝对时间还是比较长的,例如我们使用的是一个小时。
这边是因为我们整体的请求量和数据量太大,如果使用的过期时间太短,会导致写缓存流量特别大,导致缓存集群压力很大。
因此,如果使用该策略,建议过期时间一开始可以设置大一点,然后逐渐往下调,同时观察缓存集群的压力情况。
该方案可以进一步保证我们数据的一个最终一致性。
同时带过期时间可以解决另一个问题,如果你在缓存上线后发现缓存数据结构设计不合理,你想把该缓存替换掉。如果该缓存有过期时间,你不需要处理存量数据,让他到期自动删除就行了。如果该缓存没有过期时间,则你需要将存量数据进行删除,不然可能会占用大量空间。

4)监听数据库的binlog延迟N秒后进行数据一致性校验
这个操作也是非常关键,方案3存在的问题就可以通过这个操作来解决掉。就如上面提过的,脏数据都是在更新操作之后的很短时间内触发的。
因此,我们对每一个更新操作,都在延迟一段时间后去校验其缓存数据是否正确,如果不正确,则进行修复,这样就保障了绝大多数并发导致的脏数据问题。
至于延迟多久,我个人建议是延迟几分钟,不能延迟太短,否则起不到兜底的效果。

5)存在数据库更新的链路禁用对应缓存
在数据库更新的场景里面,我们可能会有一些查询操作。例如我更新完这个数据之后,我马上又查了一下。这个时候其实如果你去走缓存,很有可能是会存在脏数据。因为他更新完之后,马上读取这个间隔是非常短的。你的缓存其实可能还没有删除完,或者存在短期内的不一致,我们还没有修复。
但是这种更新场景他对数据的一致性要求一般是比较高的。因为更新完之后,他要拿这个查询出来的数据去做一些其他操作。例如记录数据变更的操作日志。
我把一个数据从a=1改成a=2,我在更新之前,我先查出来a=1,更新完之后我立马就去查出来a=2,这个时候我就记录一条操作日志,内容是a从1变成2。
这种情况下,如果你在更新完之后的这个查询去走缓存,就有很大的概率查到a=1,这时候你的操作日志就变成a从1变成1,导致操作日志是错的。
所以说这种更新后的查询,我们一般会让他不走缓存,因为他这个时效性就是太快了,缓存流程可能还没处理完成。
这个方案点其实是借鉴了MySQL事务的设计思想,MySQL中,事务对于自己更新过的内容都是实时可见的。因此,我们这边也做了一个类似的设计。

6)强制读Redis主节点
Redis跟MySQL一样,也会有主从副本,也会有主从延迟。当你将数据写入Redis后,马上去查Redis,可能由于查询从副本,导致读取到的是老数据,因此我们可以通过直接强制读主节点来解决这个问题,进一步增加数据的准确性。
Redis 不像 MySQL 有主节点压力过大的问题,Redis 是分布式的,可以将16384个槽分摊到多个分片上,每个分片的主节点部署在不同的机器上,这样强制读主时,流量也会分摊到多个机器上,不会存在MySQL的单节点压力过大问题。

7)查询异步数据一致性校验、灰度放量
这一步是缓存功能使用前的一些保障措施,保障缓存数据是准确的。
对于查询异步数据一致性校验,我们一般在查询完数据库之后,通过线程池异步的再查询一次缓存,然后把这个缓存的数据跟刚才数据库查出来的数据进行比较,然后将结果进行打点统计。
然后查看数据一致性校验的一致率有多少,如果不一致的概率超过了1%,那可能说明我们的流程还是有问题,我们需要分析不一致的例子,找出原因,进行优化。
如果不一致的概率低于0.01%,那说明整个流程可能基本上已经没啥问题了。这边理论上一定会存在一些不一致的数据,因为我们查询数据库和缓存之间还是有一定的时间间隔的,可能是1毫秒这样,在高并发下,可能这个间隔之间数据已经被修改过了,所以你拿到的缓存数据和数据库数据可能其实不是一个版本,这种情况下的不一致是正常的。

对于灰度放量,其实就是保护我们自己的一个措施。因为缓存流程毕竟还没经过线上的验证,我们一下全切到缓存,如果万一有问题,那可能就导致大量问题,从而可能导致线上事故。
如果我们一开始只是使用几个门店来进行灰度,如果有问题,影响其实很小,可能是一个简单的事件,对我们基本没影响。
在有类似比较大的改造时,通过灰度放量的方式来逐渐上线,是一种比较安全的措施,也是比较规范的措施。

小结:该方案的链路确实比较长,但是高并发下确实会有很多问题,因此我们需要有很多措施去保障。当然,这个方案也不是一下子就是这样的,也是通过不断的实践和采坑才逐渐演进而来的。目前该方案在我们线上环境使用了挺长一段时间了,基本没有什么问题,整体还是比较完善的。

作者:码上小康原文地址:https://segmentfault.com/a/1190000043344720

%s 个评论

要回复文章请先登录注册