如何解决数据库与缓存的一致性问题

786 阅读20分钟

缓存是高并发系统架构中的利器,通过利用缓存,系统可以轻而易举的扛住成千上万的并发访问请求,但在享受缓存带来的便利的同时,如何保证数据库与缓存的数据一致性,一直是一个难题,在本篇文章中分享如何在系统架构中保障缓存一致性问题。

概述

在介绍如何解决数据库与缓存的一致性问题前,先来了解一下两个问题——什么是数据库和缓存的一致性问题(What)和为什么会出现数据库和缓存的数据一致性问题(Why)。

什么是数据库和缓存的数据一致性问题

首先来了解下我们一直在说的数据一致性问题究竟是什么。CAP理论相信大家都已经耳熟能详了,只要是做分布式系统开发的应该基本都听说过,C表示一致性(Consistency)、A表示可用性(Availability)、P表示分区容错性(Partition tolerance),CAP理论阐述了这三个元素最多只能同时实现两个,不可能三者兼顾。这里对一致性的定义是——在分布式系统中的所有数据备份,在同一时刻是否同样的值。

因此,我们可以把数据库和缓存中的数据理解为两份数据副本,数据库与缓存的数据一致性问题等同于如何保证数据库与缓存中的两份数据副本的数据一致性问题。

为什么会出现数据库和缓存的数据一致性问题

在业务开发中我们一般通过数据库事务的四大特性(ACID)来保证数据的一致性。到了分布式环境中,由于没有类似事务的保障,因此容易出现部分失败的情况,比如数据库更新成功,缓存更新失败,或者缓存更新成功,数据库更新失败的情况等等,总结一下会导致数据库和缓存的数据不一致性的原因。

网络

在分布式系统中默认网络是不稳定的。因此在CAP理论下,一般认为网络原因导致的失败是无法避免的,系统的设计一般会选择CP或者AP,就是这个原因。操作数据库和缓存都涉及到网络I/O,很容易因为网络不稳定导致部分请求的失败,从而导致数据不一致。

并发

在分布式环境下,如果不显式同步的话,请求是会被多个服务器结点并发处理的。看下面这个例子,假设有两个并发请求同时更新数据库中的字段A,进程1先更新字段A为1并更新缓存为1,进程2更新字段A为2并更新缓存为2,由于在并发的情况下无法保证时序,就会出现下面的这种情况,最终的结果就是数据库中字段A的值为2,缓存中的值为1,数据库与缓存的数据不一致。

进程1进程2
时间点T1更新数据库字段 A = 1
时间点T2更新数据库字段 A = 2
时间点T3更新缓存KEY A = 2
时间点T4更新缓存KEY A = 1

读写缓存的模式

在工程实践中,读写缓存有几种通用的模式。

Cache Aside

Cache Aside应该是最常用的模式了,在许多的业务代码中都是通过这种模式来更新数据库和缓存的。它的主要逻辑如下图所示。

cacheaside活动图-Cash_Aside.png

先判断请求的类型,针对读请求和写请求分别做不同的处理:

写请求:先更新数据库,成功后再失效缓存。

读请求:先查询缓存中是否命中数据,如果命中则直接返回数据,未命中则查询数据库,成功后更新缓存,最后返回数据。

这种模式实现起来比较简单,在逻辑上看起来也没什么问题,读请求逻辑的实现在Java中为了避免代码重复,一般会通过AOP的方式。 Cache Aside模式在并发环境下是会存在数据一致性问题的,比如下面表格描述的这种读写并发的场景。

读请求写请求
时间点T1查询缓存的字段A的值未命中
时间点T2查询数据库得到字段A=1
时间点T3更新数据库字段A = 2
时间点T4失效缓存
时间点T5设置缓存A的值为1

读请求查询字段A的缓存,但是未能命中,然后查询数据库得到字段A的值为1,同时写请求将字段A的值更新为2,由于是并发的请求,写请求中失效缓存的操作先于读请求中设置缓存的操作,导致在读请求中设置了字段A的缓存值为1且未能被正确失效,造成了缓存脏数据,如果这里还不设置缓存过期时间的话,那么数据就一直是错的。

Read Through

Read Through模式和Cache Aside模式非常类似,区别在于在Cache Aside模式中,如果读请求中未能命中缓存,需要我们自己实现查询数据库再更新缓存的逻辑,而在Read Through模式中,则不需要关心这些逻辑,我们只跟缓存服务打交道,由缓存服务来实现缓存的加载,举个Java中常用的Guava Cache的例子来说明,看下面这段代码。

LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
       .maximumSize(1000)
       .build(
           new CacheLoader<Key, Graph>() {
             public Graph load(Key key) throws AnyException {
               return createExpensiveGraph(key);
             }
           });
...
try {
  return graphs.get(key);
} catch (ExecutionException e) {
  throw new OtherException(e.getCause());
}

在这段代码中我们使用了Guava中的CacheLoader来替我们加载缓存。在读请求中,当调用get方法时,如果发生Cache Miss,那么由CacheLoader来负责加载缓存,而我们的代码只跟graphs这个对象打交道,不用关心底层加载缓存的细节,这就是Read Through模式。

Read Through模式与Cache Aside模式在逻辑上没有本质区别,只不过Read Through模式在实现上代码会更简洁,因此同样的,Read Through模式也会出现Cache Aside模式中的并发导数据库和缓存数据不一致的问题。

Write Through

Write Through模式的逻辑与Read Through模式有点类似,在Write Through模式下所有的写操作都要经过缓存,然后根据写的时候是否命中缓存再执行后续逻辑。

Write-through: write is done synchronously both to the cache and to the backing store.

在Wikipedia上对Write Through模式的定义强调了该模式下在写请求中,会同步写缓存和数据库,只有缓存和数据库都写成功了才算成功。主要逻辑如下图所示。

writethrough活动图-Write_Through.png

Write Through模式在发生Cache Miss的时候,只会在读请求中更新缓存。写请求在发生Cache Miss的时候不会更新缓存,而是直接写入数据库,如果命中缓存则先更新缓存,由缓存自己再将数据写回到数据库中。怎么理解由缓存自己将数据写回到数据库中呢,这里举个Ehcache使用的例子。在Ehcache中,CacheLoaderWriter接口实现了Write Through模式,在这接口中定义了一系列的Cache生命周期的钩子函数,其中有两个方法如下:

public interface CacheLoaderWriter<K, V> {

    void write(K var1, V var2) throws Exception;

    void writeAll(Iterable<? extends Entry<? extends K, ? extends V>> var1) throws BulkCacheWritingException, Exception;
}

只需要实现这两个Write相关的方法,即可以实现在更新缓存时,将数据写入底层的数据库,也就是说在代码中只需要跟CacheLoaderWriter交互即可,不需要同时实现更新缓存和写入数据库的逻辑。

回过头来再看下Write Through模式的逻辑,发现在读请求的处理上跟Read Through模式基本是一样的,所以Read Through模式和Write Through模式可以配合使用。

那么Write Through模式有没有Read Through模式在并发的场景下的一致性问题呢?显然是有的,而且产生不一致问题的原因跟Read Through模式也是类似的,都是由于更新数据库和更新缓存的时序在并发场景下无法保证导致的。

Write Back

Write-back (also called write-behind): initially, writing is done only to the cache. The write to the backing store is postponed until the modified content is about to be replaced by another cache block.

还是先来看下Wikipedia上对Write Back模式的定义——该模式在写请求中只会写入缓存,之后只有在缓存中的数据要被替换出内存的时候,才会写入底层的数据库。Write Back模式与Write Through模式的主要区别有两点:

  1. Write Through模式是同步写入缓存和数据库,而Write Back模式则是异步的,在写请求中只写入缓存,后续会异步地将数据从缓存再写入底层数据库,而且是批量的。
  2. Write Back模式在写请求中发生Cache Miss时,会将数据重新写入到缓存中,这点是与Write Through模式也是不同的。因此,Write Back模式的Read Cache Miss和Write Cache Miss的处理是类似的。

Write Back模式的实现逻辑比较复杂,主要原因是该模式需要track哪些是“脏”数据,在必要的时候写入底层存储,且如果有多次更新的话,还需要做批量的合并写入,Write Back模式实现逻辑的图这里就不贴了,如果有兴趣的话,可以参考Wikipedia上的图。 既然是异步的,那Write Back模式的好处就是高性能,不足之处在于无法保证缓存和数据库的数据一致性。

思考

通过观察以上三种模式的实现,可以看出一些在实现上的差异点——究竟是删除缓存还是更新缓存,先操作缓存还是先更新数据库。下面的表格中列举了所有可能出现的情况,其中1表示缓存与数据库中的数据一致,0则表示不一致。

缓存操作失败数据库操作失败
先更新缓存,再更新数据库10
先更新数据库,再更新缓存01
先删除缓存,再更新数据库11
先更新数据库,再删除缓存01

以上情况都是在不考虑将缓存的操作放到数据库事务中(一般不建议将非数据库操作放到事务中,比如RPC调用、Redis操作等等,原因是这些外部操作往往会依赖网络等不可靠因素,一旦出现问题,容易导致数据库事务无法提交或者造成“长事务”问题)。

可以看到,只有“先删除缓存,再更新数据库”这种模式在部分失败的情况下能保证数据的一致性,因此我们可以得出结论1——“先删除缓存,再更新数据库”是最优的方案。但是,先删除后更新的模式,容易造成缓存击穿的问题,关于这个问题会在后面细说。

除此之外,我们还可以观察到,Cache Aside/Read Through/Write Through三种模式在并发场景下都存在缓存与数据库数据不一致的问题,且原因都是在并发场景下,无法保证更新数据库与更新缓存的时序,导致更新数据库先于写入缓存发生,而写入的缓存是旧数据,从而发生数据不一致问题。基于此,我们可以得出结论2——只要某种模式能解决这个问题,那么这种模式就可以在并发环境下保证缓存与数据库的数据一致性。

观察以上三种模式之后,发现的最后一点是一旦发生缓存和数据库的数据不一致问题之后,如果数据不再更新,那么缓存中的数据一直是错的,缺乏一种补救的机制,因此可以得出结论3——需要有某种缓存自动刷新的机制。最简单的方式是给缓存设置上过期时间,这是一种兜底手段,防止万一发生数据不一致的时候数据一直是错误的。 基于以上三个结论,再来介绍下面两种模式。

延时双删

延迟双删模式可以认为是Cache Aside模式的优化版本,主要实现逻辑如下图所示。

延迟双删.drawio.png

在写请求中,首先按照上面我们得出的最佳实践结论,先删除缓存,再更新数据库,然后发送MQ消息,这里的MQ消息可以由服务自己来发送,也可以通过一些中间件来监听DB的binlog变化的消息来实现,监听消息后需要延迟一段时间,延迟的实现方式可以使用消息队列的延迟消息功能,或者在消费端接收到消息后自行sleep一段时间,然后再次删除缓存。伪代码如下。

// 删除缓存
redis.delKey(key);
// 更新数据库
db.update(x);
// 发送延迟消息,延迟1s
mq.sendDelayMessage(msg, 1s);

...
// 消费延迟消息
mq.consumeMessage(msg);
// 再次删除缓存
redis.delKey(key);

读请求的实现逻辑同Cache Aside模式,当发现未命中缓存时,将在读请求中重新加载缓存,同时要设置给缓存设置上合理的过期时间。

相比Cache Aside模式,这种模式一定程度上降低了出现缓存和数据库数据不一致问题的可能性,但也仅仅是降低,问题依然还是存在的,只不过出现的条件更严苛了,看下面这种情况。

读请求写请求
时间点T1查询缓存的字段A的值未命中
时间点T2查询数据库得到字段A=1
时间点T3更新数据库字段A = 2
时间点T4失效缓存
时间点T5发送延迟消息
时间点T6消费延迟消息并失效缓存
时间点T7设置缓存A的值为1

由于消息的消费和读请求是并发发生的,因此消费延迟消息后失效缓存和读请求中设置缓存的时序依然是无法保证的,还是会出现数据不一致的可能性,只不过概率变得更低了。

同步失效并更新

结合以上几种模式的优势和不足,在实际的项目实践中本人采用了另外一种模式,我把它命名为“同步失效并更新模式”,主要实现逻辑如下图。

同步失效并更新-.png

这种模式的思路是在读请求中只读缓存,把操作缓存和数据库都放在写请求中,并且这些操作都是同步的,同时为了防止写请求的并发,在写操作上需要增加分布式锁,获取到锁之后才能进行后续的操作,这样一来,就消除了所有可能由于并发而导致出现数据不一致问题的可能性。

这里的分布式锁可以根据缓存的维度来确定,不需要使用全局锁,比如缓存是订单纬度的,那么锁也可以是订单纬度的,如果缓存是用户纬度的,那么分布式锁就可以是用户纬度的。这里以订单为例,写请求的实现伪代码如下:

// 获取订单纬度的分布式锁
lock(orderID) {
  	// 先删除缓存
	redis.delKey(key);
  	// 再更新数据库
  	db.update(x);
  	// 最后重新更新缓存
	redis.setEx(key, 60s);
}

这种模式的好处是基本能保证缓存和数据库的数据一致性,在性能方面,读请求基本是性能无损的,写请求由于需要同步写数据库和缓存,会有一定的影响,但是由于互联网大部分业务都是读多写少,相对来说影响也不是很大。当然,这种模式同样也有不足,主要有以下两点:

写请求强依赖分布式锁

在这种模式下写请求是强依赖分布式锁的,如果第一步获取分布式锁失败,那么整个请求都失败了。在正常的业务流程中一般是通过数据库的事务来保证一致性的,在某些关键业务场景,除了事务,还会使用分布式锁来保证一致性,所以这么来看分布式锁本来就有许多的业务场景下会使用,并不能完全算是额外的依赖。而且大厂基本都有成熟的分布式锁服务或者组件,即使没有,使用Redis或ZK简单实现一个分布式锁的成本也并不高,稳定性基本也有一定的保障。在我个人使用这种模式的项目实践中,基本没出现过因为分布式锁而导致的问题。

写请求更新缓存失败会导致缓存击穿

为了追求缓存和数据库的数据一致性,因此同步失效并更新模式将缓存和数据库的写操作都放在的写请求中,这样避免了在并发环境下,由于多处操作缓存和数据库而导致的数据不一致问题,读请求中对缓存是只读的,即使发生缓存Miss也不会重新加载缓存。

但也正是因为这种设计,万一发生在写请求中更新缓存失败的情况,那么如果没有后续的写请求,缓存中的数据就不会再被加载,后续所有的读请求会直接到DB,造成缓存击穿问题。基于互联网业务的特点是读多写少,因此这种缓存击穿的可能性还是比较大的。

解决这个问题的方案是可以使用补偿的方式,比如定时任务补偿或者MQ消息补偿,可以是增量的补偿,也可以是全量的补偿,个人的经验是建议最好要加上补偿。

其它一些需要关注的问题

有了合理的缓存读写模式后,再来看看为了保证缓存和数据库的数据一致性需要关注的一些其他问题。

避免其他问题导致缓存服务器崩溃,从而导致数据不一致问题

  1. 缓存穿透、缓存击穿和缓存雪崩

前面也提到了在“先删除缓存,再更新数据库”的模式下会有缓存击穿问题,除了缓存击穿,相关的问题还有缓存穿透和缓存雪崩问题,这些问题都会导致缓存服务器奔溃,从而导致数据不一致,先来看看这些问题的定义和一些常规的解决方案。

问题描述解决方案
缓存穿透查询一个不存在的key,不可能命中缓存,导致每次请求都到DB中,从而导致数据库奔溃1. 缓存空对象 2. 布隆过滤器
缓存击穿对于设置了过期时间的缓存key,在某个时间点过期的时候,恰好有大量对这个key的并发请求,可能导致瞬间大量并发的请求把数据库压垮1. 使用互斥锁(分布式锁):每次只有1个请求能抢到锁并重新加载缓存 2. 永远不过期:物理不过期,但逻辑上会过期(比如后台任务定时刷新等等)
缓存雪崩设置缓存的过期时间时采用了相同的值,缓存在某一时刻大量过期,导致大量请求访问数据库。缓存雪崩和缓存击穿的区别是:缓存击穿时针对单个key,缓存雪崩是针对多个key1. 分散设置缓存过期时间,比如增加随机数等等。2. 使用互斥锁(分布式锁):每次只有1个请求能抢到锁并重新加载缓存

在实际的项目实践中,一般不会追求100%的缓存命中率,其次在使用“先删除缓存,再更新数据库”的模式时,正常情况下两步操作相隔时间是很短的,不会有大量请求击穿到数据库中,因此有一些缓存击穿也是可接受的。但如果是在秒杀等并发量特别高的系统,完全没办法接受缓存击穿的时候,那可以使用抢占互斥锁更新或者把缓存操作放到数据库事务中,这样就可以使用“先更新数据库,再更新缓存”的模式,避免缓存穿透问题。

  1. 大key/热key

大key和热key问题基本都是业务设计上的问题,需要从业务设计的角度来解决。大key更多影响的是性能,解决大key的思路是将大key拆分为多个key,这样能有效降低一次网络传输数据量的大小,进而提升性能。

热key容易造成缓存服务器单点负载过高,从而导致服务器崩溃。热key的解决方式是增加副本数量,或者将一个热key拆分 多个key的方式来解决。

总结

需要说明的是,上面介绍的这些模式都不是完全的数据强一致性的,只能说是尽量做到业务意义上的数据最终一致性,如果一定要强一致性保证,那么需要使用2PC、3PC、Paxos、Raft这一类分布式一致性算法。 最后来对上面介绍的这几种模式来个总结。

  • 并发量不大或者能接受一定时间内缓存与数据库数据不一致的系统:Cache Aside/Read Through/Write Through模式。
  • 有一定的并发量或者对缓存与数据库数据一致性要求中等的系统:延迟双删模式。
  • 并发量高或者对缓存与数据库数据一致性要求较高的系统:同步失效并更新模式。
  • 对数据库数据一致性要求强一致性的系统:2PC、3PC、Paxos、Raft等分布式一致性算法。

综上可以看到,还是那句话,架构没有银弹,在做架构设计的时候需要做各种取舍,因此在选择和设计缓存读写模式时,需要结合具体的业务场景,比如并发量大还是小、数据一致性级别要求高还是低等等,灵活运用这些模式,必要时可以做一些变通,确定大的方向之后,再来补充细节,才能有一个好的架构设计。

参考