当我们的系统引入缓存组件之后,性能得到了大幅度提升,但是随之而来的是代码需要引入一定的复杂度,比如缓存的更新策略,写入策略,过期策略等,而其中最可能导致程序员加班的莫过于缓存和数据库的一致性问题了。
使用最广泛的标准设计模式,Facebook在2013年发表的论文《Scaling Memcache at Facebook》中提到,后进一步为业内所熟知。
读场景
1、命中:直接从Cache中取到数据,取出后直接返回
2、未命中:先从Cache中取数据,未取到,则从DB中取,再放到缓存中,然后返回
对于Cache Aside Pattern读场景的流程,基本没有异议,关于写场景有一些讨论,在数据更新的时候是采用先更新数据库,再失效缓存,为什么不是更新缓存,而是失效(删除)缓存呢?
假设A、B两个线程并发,A先更新缓存后B紧接着也更新了缓存,然而B更新数据库的事务先提交,A更新数据库的事务后提交,这样就导致了缓存与数据库不一致。
假设A、B两个线程,A先更新数据库后B再更新数据库,然后分别进行缓存更新操作,但是B先更新缓存成功,A后更新缓存成功,这样就导致数据库是最新的数据但是缓存中是旧的脏数据。
读写请求并发的情况下,在写线程删除缓存后且更新数据库的事务提交之前,这时读线程进来了,读线程此时查不到缓存,就去数据库里查到了旧数据然后将数据放入缓存。这种情况下,直到下一次新的写操作进来之前,缓存中的数据将一直是脏数据。
如时序图所示,线程A在失效缓存成功后,线程B读请求发现缓存数据为空的话,就会从数据库中读取旧值放入到缓存中,这样就导致后续的读请求读到的都是缓存中的脏数据。另外,数据库如果采用的是主从复制+读写分离的架构,线程B读出来的数据也有可能是主从未同步完成造成的脏数据。
针对这样的情况可以采用延时双删的策略来有效避免,伪代码如下:
cache.delKey(key);
db.update(data);
Thread.sleep(xxx);
cache.delKey(key);
主要是在写请求更新完数据库后休眠一段时间(休眠时间=读数据耗时+主从同步耗时),然后再删除一次缓存,将可能由并发读请求带来的脏数据失效掉。这种通过延时双删的方式需要线程休眠,因此很显然会降低系统吞吐量,并不是一种优雅的解决方式,也可以采用异步删除的方式。当然也可以设置缓存过期时间,到期后缓存自动失效,但这样做需要系统能够容忍一段时间的数据不一致。
这是Cache Aside Pattern推荐的方式,实际上这种方式也存在数据不一致的情况。
上图流程:
这就导致缓存中存储了脏数据,这种情况发生导致的不一致,是因为缓存突然失效了,而且还要保证请求B的更新操作比请求A的查询操作还要快。实际上这种情况发生的概率很低,要发生这种情况的前提条件是写数据库要先于读数据库完成,一般而言DB读相比于DB写耗时更短,这种前提条件成立的概率很低。采用上文提到的延时双删方法可以达到最终一致。
相对于先失效缓存再更新数据库,先更新数据库再失效缓存依然会有问题,不过,问题出现的概率变得比较低。
没有办法做到绝对的一致性,这是由CAP理论决定的,缓存系统适用的场景就是非强一致性的场景,所以它属于CAP中的AP。所以我们得委曲求全,去做到BASE理论中说的最终一致性。事实上也没有多少业务场景是要求强一致性,我们日常工作中面对的绝大部分业务场景追求的都是最终一致性。
该方案的核心点在于延迟时间T,通常我们把T设置为相同业务中一次查询操作的耗时+几百毫秒,这样保证了第二次的删除可以清除掉因并发导致的缓存脏数据。该方案的缺点在于:需要针对性地评估延迟时间,并增加二次删除逻辑,代码强耦合,增加了复杂度;而且二次删除也可能出现操作失败(当然操作失败一定要有重试机制)。
流程如下:
采用这种方案,业务系统只负责处理业务逻辑,更新MySQL,完全不用管如何去更新缓存。有一个专门负责更新缓存的服务,订阅MySQL的Binlog,解析Binlog之后,可以得到实时的数据变更信息,然后根据这个变更信息去更新缓存。
阿里开源的Canal可以完成Binlog的增量订阅及消费。这个方案的缺点是:引入新组件增加了系统复杂度,会出现短时间内的数据不一致,所以业务层要做校验。
缓存和数据库一致性问题的出现在于高并发请求下缓存操作和数据库操作不是原子性的导致,一个系统只要离开了数据的单点存储,那么必然存在数据不一致的问题。分布式系统的一致性设计本质都是权衡取舍,如果一定要保证数据的强一致性,那么可以采用如二阶段事务、Paxos、Raft等强一致性的协议来保证,但是相应的必须牺牲一定的系统可用性或者复杂度,绝大部分场景最终一致性就够用了,保证缓存与数据库最终一致性的方案很多,但无论哪种方案都大大增加了系统的复杂度,同时也会引入其他问题。因此需要合理的评估业务,对数据一致性的敏感程度来选择合适的方案。