令人头疼的缓存与数据库一致性问题

当我们的系统引入缓存组件之后,性能得到了大幅度提升,但是随之而来的是代码需要引入一定的复杂度,比如缓存的更新策略,写入策略,过期策略等,而其中最可能导致程序员加班的莫过于缓存和数据库的一致性问题了。

缓存使用策略——Cache Aside Pattern

使用最广泛的标准设计模式,Facebook在2013年发表的论文《Scaling Memcache at Facebook》中提到,后进一步为业内所熟知。

读场景
1、命中:直接从Cache中取到数据,取出后直接返回
2、未命中:先从Cache中取数据,未取到,则从DB中取,再放到缓存中,然后返回
令人头疼的缓存与数据库一致性问题_第1张图片

写场景:先更新DB,再失效缓存
令人头疼的缓存与数据库一致性问题_第2张图片

对于Cache Aside Pattern读场景的流程,基本没有异议,关于写场景有一些讨论,在数据更新的时候是采用先更新数据库,再失效缓存,为什么不是更新缓存,而是失效(删除)缓存呢

更新缓存的方案

先更新缓存再更新数据库(不推荐)

假设A、B两个线程并发,A先更新缓存后B紧接着也更新了缓存,然而B更新数据库的事务先提交,A更新数据库的事务后提交,这样就导致了缓存与数据库不一致。

时序图如下:
令人头疼的缓存与数据库一致性问题_第3张图片

先更新数据库再更新缓存(不推荐)

假设A、B两个线程,A先更新数据库后B再更新数据库,然后分别进行缓存更新操作,但是B先更新缓存成功,A后更新缓存成功,这样就导致数据库是最新的数据但是缓存中是旧的脏数据。

时序图如下:
令人头疼的缓存与数据库一致性问题_第4张图片

失效(删除)缓存的方案

先失效缓存再更新数据库(不推荐)

读写请求并发的情况下,在写线程删除缓存后且更新数据库的事务提交之前,这时读线程进来了,读线程此时查不到缓存,就去数据库里查到了旧数据然后将数据放入缓存。这种情况下,直到下一次新的写操作进来之前,缓存中的数据将一直是脏数据。

时序图如下:
令人头疼的缓存与数据库一致性问题_第5张图片

如时序图所示,线程A在失效缓存成功后,线程B读请求发现缓存数据为空的话,就会从数据库中读取旧值放入到缓存中,这样就导致后续的读请求读到的都是缓存中的脏数据。另外,数据库如果采用的是主从复制+读写分离的架构,线程B读出来的数据也有可能是主从未同步完成造成的脏数据。

针对这样的情况可以采用延时双删的策略来有效避免,伪代码如下:

cache.delKey(key);
db.update(data);
Thread.sleep(xxx);
cache.delKey(key);

主要是在写请求更新完数据库后休眠一段时间(休眠时间=读数据耗时+主从同步耗时),然后再删除一次缓存,将可能由并发读请求带来的脏数据失效掉。这种通过延时双删的方式需要线程休眠,因此很显然会降低系统吞吐量,并不是一种优雅的解决方式,也可以采用异步删除的方式。当然也可以设置缓存过期时间,到期后缓存自动失效,但这样做需要系统能够容忍一段时间的数据不一致。

先更新数据库再失效缓存(推荐)

这是Cache Aside Pattern推荐的方式,实际上这种方式也存在数据不一致的情况。

时序图如下:
令人头疼的缓存与数据库一致性问题_第6张图片

上图流程:

  1. 请求A发起查询请求,此时缓存恰好失效了(可能是到期了),直接到数据库查询,查到了数据还没有来得及去设置缓存
  2. 请求B要更新值,先更新数据库,再失效缓存
  3. 请求A这时才设置缓存

这就导致缓存中存储了脏数据,这种情况发生导致的不一致,是因为缓存突然失效了,而且还要保证请求B的更新操作比请求A的查询操作还要快。实际上这种情况发生的概率很低,要发生这种情况的前提条件是写数据库要先于读数据库完成,一般而言DB读相比于DB写耗时更短,这种前提条件成立的概率很低。采用上文提到的延时双删方法可以达到最终一致。

相对于先失效缓存再更新数据库,先更新数据库再失效缓存依然会有问题,不过,问题出现的概率变得比较低

不一致的产生原因

  • 逻辑失败造成的数据不一致:在并发的情况下,无论是先删除缓存后更新数据库,还是先更新数据库后失效缓存,都会有数据不一致的情况,主要是由读写请求在并发情况下的操作时序导致的,这种特殊时序造成的不一致称之为“逻辑失败”,解决这种因为并发时序导致的不一致,核心的解决思想是将操作进行串行化
  • 物理失败造成的数据不一致:在Cache Aside Pattern中先更新数据库后删除缓存,如果删除缓存操作出现失败,也会出现数据不一致的情况。但是数据库更新以及缓存操作不适合放到一个事务中,一般来说,如果使用分布式缓存有网络传输的耗时,如果这个耗时较长,那么将更新数据库以及失效缓存放到一个事务中,就会造成事务耗时过长,很快耗尽数据库的连接池,严重的降低系统性能,导致系统崩溃。像这种因为操作失败导致的数据不一致称之为“物理失败”,大多数物理失败的情况可以用重试的方案解决

最终一致性方案

没有办法做到绝对的一致性,这是由CAP理论决定的,缓存系统适用的场景就是非强一致性的场景,所以它属于CAP中的AP。所以我们得委曲求全,去做到BASE理论中说的最终一致性。事实上也没有多少业务场景是要求强一致性,我们日常工作中面对的绝大部分业务场景追求的都是最终一致性。

延时双删

该方案的核心点在于延迟时间T,通常我们把T设置为相同业务中一次查询操作的耗时+几百毫秒,这样保证了第二次的删除可以清除掉因并发导致的缓存脏数据。该方案的缺点在于:需要针对性地评估延迟时间,并增加二次删除逻辑,代码强耦合,增加了复杂度;而且二次删除也可能出现操作失败(当然操作失败一定要有重试机制)。

订阅Binlog异步更新缓存

令人头疼的缓存与数据库一致性问题_第7张图片

流程如下:

  1. 更新数据库数据
  2. 数据库会将操作信息写入Binlog日志当中
  3. 订阅程序提取出所需要的数据以及key
  4. 另起一段非业务代码,获得该信息
  5. 尝试删除缓存操作
  6. 如果删除缓存操作失败,将这些信息发送至消息队列
  7. 重新从消息队列中获得该数据,重试删除操作。

采用这种方案,业务系统只负责处理业务逻辑,更新MySQL,完全不用管如何去更新缓存。有一个专门负责更新缓存的服务,订阅MySQL的Binlog,解析Binlog之后,可以得到实时的数据变更信息,然后根据这个变更信息去更新缓存。

阿里开源的Canal可以完成Binlog的增量订阅及消费。这个方案的缺点是:引入新组件增加了系统复杂度,会出现短时间内的数据不一致,所以业务层要做校验。

总结

缓存和数据库一致性问题的出现在于高并发请求下缓存操作和数据库操作不是原子性的导致,一个系统只要离开了数据的单点存储,那么必然存在数据不一致的问题。分布式系统的一致性设计本质都是权衡取舍,如果一定要保证数据的强一致性,那么可以采用如二阶段事务、Paxos、Raft等强一致性的协议来保证,但是相应的必须牺牲一定的系统可用性或者复杂度,绝大部分场景最终一致性就够用了,保证缓存与数据库最终一致性的方案很多,但无论哪种方案都大大增加了系统的复杂度,同时也会引入其他问题。因此需要合理的评估业务,对数据一致性的敏感程度来选择合适的方案。

你可能感兴趣的:(数据库,分布式技术及原理,缓存,一致性,数据库)