缓存与数据库双写一致最佳解决方案分析

写在最前面

在大型互联网应用当中如果你的应用引入了缓存机制,那么有一个大前提就是你的业务场景上必须得接受数据的新鲜度上有可能会有一定时间的延迟。删除缓存失败是一个极小概率事件,且在不能保证所有操作100%成功的几率下,采用JOB补偿的机制是目前比较成熟的解决方案。
大并发量写请求的应用,不可能去实时写DB,基本都采用队列+消息异步写DB的机制,不然会有大量的并发问题


缓存机制介绍

如今利用缓存机制来提高查询效率已被广泛用在各大生产环境,查询数据的一般流程如下所示

使用缓存提高查询效率
  1. 如果数据在缓存里边有,则直接从缓存取数据返回。
  2. 如果缓存中没有想要的数据,则先去查询数据库,然后将数据库查出来的数据写到缓存中再返回

没有更新数据的情况下,数据库和缓存的数据是保持一致的;但是当要执行数据的更新操作的时候,数据库和缓存的数据就会出现不一致的情况

因此,为了解决数据不一致的问题,需要在更新数据库的时候,对缓存做一些额外的操作,有以下几种方案

  1. 先删缓存,再更新数据库
  2. 先更新数据库,再删缓存
  3. 缓存延时双删,更新前先删除缓存,然后更新数据,再延时删除缓存
  4. 监听MySQL binlog进行缓存更新

之所以缓存不采取更新操作而是直接删除是因为:高并发环境下,无论是先操作数据库还是后操作数据库而言,如果加上更新缓存,那就更加容易导致数据库与缓存数据不一致问题。(删除缓存直接且简单很多)

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

该方案在线程A进行数据更新操作,线程B进行查询操作时,有可能出现下面的情况导致数据不一致:

  1. 线程A删除缓存
  2. 线程B查询数据,发现缓存数据不存在
  3. 线程B查询数据库,得到旧值,写入缓存
  4. 线程A将新值更新到数据库

这样一来,缓存中的数据仍然是旧值

如果线程B执行的是更新操作,线程B查询得到的是旧值,A更新到数据库新值,然后B基于旧值计算写入了计算后的值,A的更新操作被抹去了,这种情况下属于更新数据事务原子性问题,需要用分布式锁来解决。

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

当缓存失效时,线程B原子性被破坏时会出现不一致问题:

  1. 缓存失效了
  2. 线程B从数据库读取旧值
  3. 线程A从数据库读取旧值
  4. 线程B将新值更新到数据库
  5. 线程B删除缓存
  6. 线程A将旧值写入缓存

这种情况概率很低,实际上数据库的写操作会比读操作慢得多,读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,这种情况下只需要线程B延时删除缓存就好。另外在数据库主从同步的情况下,延时删除还能防止数据更新还未从主数据库同步到从数据库的情况。

延时双删

延时双删即先删除缓存,然后更新数据,再延时n ms后删除缓存,这个我认为作用和更新数据库再删除缓存的策略几乎是等同的(欢迎讨论)

之所以设计为延时双删的目的在于当最后一次延时删除缓存失败的情况发生,至少一致性策略只会退化成先删缓存再更新数据的策略。

删除缓存失败这种事情个人认为在生产环境缓存高可用的情况下几乎不会出现,且这种情况如果发生了,不如考虑一下重试机制。

异步更新缓存(基于订阅binlog的同步机制)

通过异步更新缓存将缓存与数据库的一致性同步从业务中独立出来统一处理,保证数据一致性

整体技术思路:

  1. 读Redis:热数据基本都在Redis
  2. 写MySQL:增删改都是操作MySQL
  3. 更新Redis数据:订阅MySQ的数据操作记录binlog,来更新到Redis

数据操作分为两大部分:

  • 全量更新(将全部数据一次性写入redis)
  • 增量更新(实时更新)

这样一旦MySQL中产生了新的写入、更新、删除等操作,就可以把binlog相关的消息通过消息队列推送至Redis,Redis再根据binlog中的记录,对Redis进行更新。

这种同步机制类似于MySQL的主从备份机制,可以结合使用阿里的canal对MySQL的binlog进行订阅。

总结

综上所述,异步更新缓存、更新后延迟删与延迟双删都是不错的一致性解决方案,但除了异步更新缓存,其余两个方案都会对业务线的代码有侵入性。