缓存是高并发系统架构中的利器。通过使用缓存,系统可以轻松处理数千个并发访问请求,但是在享受缓存带来的便利的同时,如何保证数据库和缓存之间的数据一致性一直是一个难题。在本文中,我们分享了如何在系统架构中保证缓存的一致性。概述在介绍如何解决数据库和缓存的一致性问题之前,我们先来了解两个问题——什么是数据库和缓存的一致性问题(What)以及为什么会出现数据库和缓存的数据一致性问题(为什么)。数据库和缓存的数据一致性问题是什么?首先,了解一下我们一直在说的数据一致性问题是什么。CAP理论相信大家都不陌生。只要是做分布式系统开发的,基本都应该听说过。C表示一致性(Consistency),A表示可用性(Availability),P表示分区容忍度(Partitiontolerance),CAP理论说明这三个要素最多只能同时实现两个,无法兼顾三个全部。这里一致性的定义是——分布式系统中所有的数据备份是否同时具有相同的值。因此,我们可以将数据库中的数据和缓存中的数据理解为数据的两份副本。数据库和缓存之间的数据一致性问题相当于如何保证数据库和缓存中的两个数据副本之间的数据一致性。为什么会出现数据库和缓存的数据一致性问题?在业务开发中,我们一般会利用数据库事务的四大特性(ACID)来保证数据的一致性。在分布式环境中,由于没有类似事务的保证,所以容易出现局部失败,比如数据库更新成功,缓存更新失败,或者缓存更新成功,数据库更新失败等。综上所述,会导致数据库和缓存失败。数据不一致的原因。分布式系统中的默认网络是不稳定的。因此,在CAP理论下,一般认为由网络引起的故障是不可避免的,系统的设计一般选择CP或AP,就是这个道理。数据库和缓存操作都涉及到网络I/O,很容易因为网络不稳定导致一些请求失败,导致数据不一致。并发性在分布式环境中,如果没有明确的同步,请求将由多个服务器节点并发处理。看下面的例子,假设有两个并发请求同时更新数据库中的字段A,进程1先将字段A更新为1并将缓存更新为1,进程2将字段A更新为2并更新cache到2,由于并发情况下,时序无法保证,会出现如下情况。最终结果是字段A在数据库中的值为2,缓存中的值为1,数据库和缓存中的数据不一致。进程1进程2时间点T1更新数据库字段A=1时间点T2更新数据库字段A=2时间点T3更新缓存KEYA=2时间点T4更新缓存KEYA=1读写缓存的模式在工程实践中,读写缓存有几种常见的模式。CacheAsideCacheAside应该是最常用的模式,在很多业务代码中用于更新数据库和缓存。其主要逻辑如下图所示。首先判断请求的类型,对读请求和写请求做不同的处理:写请求:先更新数据库,成功后使缓存失效。读请求:先查询缓存中数据是否命中,命中则直接返回数据,未命中则查询数据库,成功后更新缓存,最后返回数据。这种模式实现起来比较简单,逻辑上似乎也没有什么问题。Java中读请求逻辑的实现一般采用AOP来避免代码重复。CacheAside模式在并发环境下会存在数据一致性问题,比如下表描述的并发读写场景。读请求和写请求时间点T1查询缓存中字段A的值miss时间点T2查询数据库得到字段A=1时间点T3更新数据库字段A=2时间点T4无效缓存时间点T5设置值ofcacheAto1读请求查询字段A的缓存,命中失败,再查询数据库获取字段A的值为1,同时写请求更新字段A的值to2.由于是并发请求,先在写请求中使缓存失效的操作在读请求中设置缓存的操作导致读请求中字段A的缓存值被设置为1而失败正确失效,导致缓存中出现脏数据。如果这里没有设置缓存过期时间,数据会一直不正确。ReadThroughReadThrough模式与CacheAside模式非常相似。不同的是,在CacheAside模式下,如果读请求没有命中缓存,我们需要实现查询数据库,然后更新缓存的逻辑。在ReadThrough模式下,不需要关心这些逻辑,我们只和缓存服务打交道,缓存服务实现缓存的加载。以Java中常用的GuavaCache为例进行说明,见如下代码。LoadingCachegraphs=CacheBuilder.newBuilder().maximumSize(1000).build(newCacheLoader(){publicGraphload(Keykey)throwsAnyException{returncreateExpensiveGraph(key)};};;...try{returngraphs.get(key);}catch(ExecutionExceptione){thrownewOtherException(e.getCause());}在这段代码中,我们使用Guava中的CacheLoader为我们加载缓存。在读请求中,如果在调用get方法时发生CacheMiss,CacheLoader负责加载缓存,我们的代码只处理graphs对象,不关心底层加载缓存的细节。这是通读模式。ReadThrough模式和CacheAside模式在逻辑上没有本质区别,只是ReadThrough模式的实现代码会更加简洁。所以ReadThrough模式也会导致并发数据库和CacheAside模式下缓存的数据不一致。问题。WriteThroughWriteThrough模式的逻辑与ReadThrough模式有些相似。在WriteThrough模式下,所有的写操作都必须缓存起来,然后根据写操作是否命中缓存来执行后续逻辑。Write-through:对缓存和后备存储同步写入。维基百科上WriteThrough模式的定义是强调在这种模式下,在写请求中,缓存和数据库会同步写入,只有缓存和数据库写入成功才算成功。主要逻辑如下图所示。在WriteThrough模式下,当CacheMiss发生时,缓存只会在读请求中被更新。当CacheMiss发生时写请求不会更新缓存,而是直接写入数据库。如果缓存命中,先更新缓存,缓存自己将数据写回数据库。如何理解缓存自己将数据写回数据库,这里以Ehcache的使用为例。在Ehcache中,CacheLoaderWriter接口实现了WriteThrough模式。该接口定义了Cache生命周期的一系列钩子函数,方法如下两个:publicinterfaceCacheLoaderWriter{voidwrite(Kvar1,Vvar2)throwsException;voidwriteAll(Iterable<?进入底层数据库,也就是说只需要和代码中的CacheLoaderWriter进行交互,不需要同时实现更新缓存和写入数据库的逻辑。回过头来看WriteThrough模式的逻辑,发现读请求的处理和ReadThrough模式基本一样,所以ReadThrough模式和WriteThrough模式可以结合使用。那么WriteThrough模式与ReadThrough模式在并发场景下是否存在一致性问题呢?显然是有的,而且造成不一致问题的原因和ReadThrough模式类似,是并发场景下更新数据库和更新缓存的时机不能保证造成的。WriteBackWrite-back(也称为后写):最初,只对缓存进行写入。对后备存储的写入被推迟,直到修改的内容即将被另一个缓存块替换。我们先看一下维基百科上面对WriteBack模式的定义——这种模式只会在写请求中写入缓存,然后只有当缓存中的数据要被替换出内存时才会写入底层数据库。WriteBack模式和WriteThrough模式主要有两个区别:WriteThrough模式是同步写入缓存和数据库,而WriteBack模式是异步的。写入请求只写入缓存,数据会异步从缓存中写入底层数据库,并且是批量的。当写请求发生CacheMiss时,WriteBack模式会将数据重写到缓存中,这也不同于WriteThrough模式。所以WriteBack模式下ReadCacheMiss和WriteCacheMiss的处理是类似的。WriteBack模式的实现逻辑比较复杂。主要是因为这种模式需要跟踪什么是“脏”数据,必要时写入底层存储,如果有多次更新,还需要合并并分批写入。写Back模式的实现逻辑图这里就不贴了。有兴趣的可以参考维基百科上的图。由于是异步的,WriteBack模式的优点是性能高。缺点是不能保证缓存和数据库之间的数据一致性。思考通过观察以上三种模式的实现,我们可以看出一些实现上的差异——是删除缓存还是更新缓存,先操作缓存还是先更新数据库。下表列出了所有可能的情况,其中1表示缓存与数据库中的数据一致,0表示不一致。缓存操作失败数据库操作失败先更新缓存,再更新数据库10先更新数据库,再更新缓存01先删除缓存,再更新数据库11先更新数据库,再删除缓存01以上几种情况是都没有考虑将缓存操作放入数据库事务中(一般不建议将非数据库操作放入事务中,如RPC调用、Redis操作等,因为这些外部操作往往依赖于网络等不可靠因素,而且一旦出现问题,很容易导致数据库事务提交失败或者造成“长事务”问题)。可见,只有先删除缓存,再更新数据库的模式才能在部分故障的情况下保证数据的一致性,所以我们可以得出结论1——“先删除缓存,再更新数据库”是最好的优秀解决方案。但是先删后更新的模式容易造成缓存崩溃的问题,后面会详细讨论。另外我们也可以观察到CacheAside/ReadThrough/WriteThrough这三种模式在并发场景下都存在缓存和数据库数据不一致的问题,原因是在并发场景下,无法保证数据库的更新和更新缓存的时机,导致数据库的更新发生在写入缓存之前,写入的缓存是旧数据,导致数据不一致。基于此,我们可以得出结论2——只要某种模式能够解决这个问题,那么这种模式就可以保证并发环境下缓存和数据库之间的数据一致性。观察以上三种模式,最后发现的一点是,一旦缓存和数据库发生数据不一致,如果数据不再更新,那么缓存中的数据永远是错误的,缺少一个补救机制,所以可以得出结论3-需要有一些自动缓存刷新的机制。最简单的方法就是给缓存设置一个过期时间,这是一种掩饰的方法,防止数据不一致时数据总是错误的。基于以上三个结论,引入以下两种模式。延迟双删除延迟双删除模式可以认为是CacheAside模式的优化版,主要实现逻辑如下图所示。在写请求中,先按照我们上面得出的最佳实践结论删除缓存,然后更新数据库,再发送MQ消息。这里的MQ消息可以由服务自己发送,也可以通过一些中间件监听DB的binlog变化消息来实现。听完留言后,需要延迟一段时间。延迟可以通过使用消息队列的延迟消息功能来实现,或者消费者收到消息后,休眠一段时间,然后再次删除缓存。伪代码如下。//删除缓存redis.delKey(key);//更新数据库db.update(x);//发送延迟消息,延迟1smq.sendDelayMessage(msg,1s);...//消费延迟消息mq.consumeMessage(msg);//删除缓存redis.delKey(key);读请求的实现逻辑与CacheAside模式相同。当发现缓存未命中时,会在读请求中重新加载缓存,并为缓存设置时间设置一个合理的过期时间。与CacheAside模式相比,这种模式在一定程度上降低了缓存和数据库数据不一致的可能性,但只是降低了,问题依然存在,只是条件更加苛刻。看下面的情况。读请求和写请求时间点T1查询缓存中字段A的值,缺失时间点时间点T2查询数据库得到字段A=1时间点T3更新数据库字段A=2时间点T4无效缓存时间点T5发送延迟消息时间点T6消费延迟消息并使缓存失效时间点T7将缓存A的值设置为1由于消息的消费和读取请求是同时发生的,消费完延迟消息并设置缓存后失效缓存的时机读请求中的cache仍然不能保证,还是会有数据不一致的可能,只是概率变低了。同步失败与更新结合以上模式的优缺点,我在实际项目实践中采用了另一种模式。我将其命名为“同步失败和更新模式”。主要实现逻辑如下图所示。这种模式的思路是在读请求中只读取缓存,在写请求中放入操作缓存和数据库,这些操作是同步的,并且为了防止写请求的并发,需要一个分布式锁要添加到写操作中的后续操作只能在获取到锁后进行。这样就杜绝了所有并发导致数据不一致的可能性。这里的分布式锁可以根据缓存的维度来确定。不需要使用全局锁。比如缓存在序纬度,那么锁也可以在序纬度。如果缓存在用户纬度,那么分布式锁就可以是用户纬度。这里我们以订单为例。写请求的伪代码如下://获取订单的分布式锁latitudelock(orderID){//先删除缓存redis.delKey(key);//然后更新数据库db.update(x);//最后重新更新缓存redis.setEx(key,60s);}这种模式的好处是可以基本保证缓存和数据库的数据一致性。性能方面,读请求基本与性能无关,而写请求由于需要同步写入数据库和缓存会有一定的影响,但是由于大部分互联网业务读多写少,影响相对较小小的。当然这种模式也有缺点,主要有以下两点:写请求强烈依赖分布式锁。在这种模式下,写请求强烈依赖分布式锁。如果第一步获取分布式锁失败,则整个请求全部失败。在正常的业务流程中,一般会使用数据库事务来保证一致性。在一些关键的业务场景中,除了事务之外,还会使用分布式锁来保证一致性。所以这种方式的分布式锁很多。在业务场景中会用到,不能算作附加依赖。而且各大公司基本都有成熟的分布式锁服务或组件。即使不做,单纯使用Redis或者ZK实现分布式锁的成本也不高,稳定性也基本能在一定程度上得到保证。在我个人使用这种模式的项目实践中,基本上没有出现过分布式锁带来的问题。更新缓存的写请求失败会导致缓存崩溃。为了追求缓存和数据库的数据一致性,同步失效和更新模式将缓存和数据库的写操作放在写请求中,避免了并发环境。缓存和数据库多次操作导致的数据不一致的问题,缓存在读请求中是只读的,即使发生缓存Miss也不会重新加载缓存。但也正是因为这样的设计,如果在写请求的时候更新缓存失败,如果没有后续的写请求,就不会再加载缓存中的数据,所有后续的读请求都会直接去数据库,导致缓存崩溃问题。基于互联网的业务具有读多写少的特点,所以这种缓存崩溃的可能性比较大。解决这个问题的办法就是采用补偿的方式,比如定时任务补偿或者MQ消息补偿,可以是增量补偿,也可以是全量补偿。个人经验建议最好加补偿。其他一些需要注意的问题在有了合理的缓存读写方式之后,我们再来看一些其他需要注意的问题,以保证缓存和数据库之间的数据一致性。避免其他问题导致缓存服务器崩溃,导致数据不一致。缓存穿透、缓存击穿、缓存雪崩。前面提到,“先删除缓存,再更新数据库”的模式会存在缓存崩溃问题。Wear,相关问题包括缓存穿透和缓存雪崩。这些问题都会导致缓存服务器崩溃,造成数据不一致。我们先来看看这些问题的定义和一些常规的解决方案。问题描述解决方案缓存穿透查询一个不存在的key,不可能命中缓存,导致每次请求到DB,导致数据库崩溃1.缓存空对象2.布隆过滤器缓存击穿设置时有过期时间的缓存key在某个时间点过期了,就会有大量的并发请求这个key,可能会导致大量的并发请求瞬间压垮数据库。1.使用互斥锁(分布式锁):每次只有一个请求可以抢到锁并重新加载缓存2.永不过期:物理上不会过期,但逻辑上会过期(比如后台任务定时刷新等)缓存雪崩使用在设置缓存过期时间时设置相同的值,缓存在某个时刻大量过期,导致大量请求命中数据库。cacheavalanche和cachebreakdown的区别是:cachebreakdown是针对单个key,cacheavalanche是针对多个key1。以去中心化的方式设置缓存过期时间,比如增加随机数等。2、使用互斥锁(分布式锁):每次只需要一次请求就可以抢到锁并重新加载缓存。在实际项目实践中,一般不会追求100%的缓存命中率。其次,在使用“先删除缓存,再更新数据库”模式时,一般情况下,两步操作的间隔时间很短,不会有大量请求突破到数据库,所以一些缓存故障是可以接受的。但是如果是在秒杀这样并发特别高的系统中,当没有办法接受缓存击穿时,可以使用抢占式互斥锁更新或者把缓存操作放在数据库事务中,这样就可以使用“update“先数据库,再更新缓存”的模式,避免了缓存穿透问题。大键/热键大键和热键问题基本上都是业务设计的问题,需要从业务设计的角度去解决。大键对性能的影响更大。大键的解决方案是将大键拆分成多个键,这样可以有效减少一次网络传输的数据量,从而提高性能。热键很容易导致缓存服务器单点负载过高,导致服务器崩溃。解决热键的方法是增加副本数,或者将一个热键拆分成多个键。综上,需要说明的是,以上所描述的模式都不是完全符合强数据的。只能说是尽量做到业务意义上的数据最终一致性。如果一定要保证强一致性,需要使用2PC和3PC、Paxos、Raft等分布式共识算法。最后,总结一下上述模式。并发量小或者一定时间内缓存和数据库数据不一致的系统:CacheAside/ReadThrough/WriteThrough模式。有一定并发量或对缓存与数据库数据一致性要求适中的系统:延迟双删模式。高并发或对缓存和数据库数据一致性要求高的系统:同步失败,更新模式。对数据库数据一致性要求强一致性的系统:2PC、3PC、Paxos、Raft等分布式共识算法。综上所述,可以看出架构没有灵丹妙药,在设计架构的时候需要做各种权衡。因此,在选择和设计缓存读写模式时,需要结合具体的业务场景,比如大并发量。是否小,数据一致性级别是高还是低等,灵活运用这些模式,必要时做一些修改。确定大方向后,再添加细节,才有好的架构设计。参考缓存更新例程Cache(computing))Read-Through,Write-Through,Write-Behind,Refresh-AheadCaching缓存模式(CacheAside,ReadThrough,WriteThrough)了解MySQL和Redis的数据一致性问题