数据库缓存一致性研究

数据库缓存一致性研究

参考链接:

聊聊数据库与缓存数据一致性问题

美团二面:Redis与MySQL双写一致性如何保证?(缓存延时双删,删除缓存重试机制,读取binlog异步删除缓存)

1.为什么要用缓存

在秒杀实际的业务中,一定有很多需要做缓存的场景,比如要秒杀的商品的一些信息等,属于访问量很大的数据,可以算是“热点”数据,尤其是一些读取量远大于写入量的数据,更应该被缓存,而不应该让请求打到数据库上,防止数据库的压力过大。可以说缓存是为了追求“快”而存在的。

2.两个要点

  1. 缓存必须要有过期时间
  2. 保证数据库跟缓存的最终一致性即可,不必追求强一致性

补充:

强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大

弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态

最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型

为什么必须要有过期时间?首先对于缓存来说,当它的命中率越高的时候,我们的系统性能也就越好。如果某个缓存项没有过期时间,而它命中的概率又很低,这就是在浪费缓存的空间。而如果有了过期时间,且在某个缓存项经常被命中的情况下,我们可以在每次命中的时候都刷新一下它的过期时间,这样也就保证了热点数据会一直在缓存中存在,从而保证了缓存的命中率,提高了系统的性能。

设置过期时间还有一个好处,就是当数据库跟缓存出现数据不一致的情况时,这个可以作为一个最后的兜底手段。也就是说,当数据确实出现不一致的情况时,过期时间可以保证只有在出现不一致的时间点到缓存过期这段时间之内,数据库跟缓存的数据是不一致的,因此也保证了数据的最终一致性。

那么为什么不应该追求数据强一致性呢?这个主要是个权衡的问题。数据库跟缓存,以Mysql跟Redis举例,毕竟是两套系统,如果要保证强一致性,势必要引入2PC或Paxos等分布式一致性协议,或者是分布式锁等等,这个在实现上是有难度的,而且一定会对性能有影响。而且如果真的对数据的一致性要求这么高,那引入缓存是否真的有必要呢?直接读写数据库不是更简单吗?所以能做的就是在最终一致性的基础上尽量的去实现强一致性。

3.数据库和缓存的读写顺序

方案一:Cache-Aside pattern(边缘缓存模式)

image-20220819121759428

思路如下:

  1. 失效:程序先从缓存中读取数据,如果没有命中,则从数据库中读取,成功之后将数据放到缓存中
  2. 命中:程序先从缓存中读取数据,如果命中,则直接返回
  3. 更新:程序先更新数据库,在删除缓存

方案二:Read-Through/Write-Through(读写穿透)

Read/Write Through模式中,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层完成的。

Read-Through:

Read-Through的简要流程如下

image-20220819121803516

  1. 从缓存读取数据,读到直接返回
  2. 如果读取不到的话,从数据库加载,写入缓存后,再返回响应。

其实Read-Through就是多了一层Cache-Provider,·实际只是在Cache-Aside之上进行了一层封装,它会让程序代码变得更简洁,同时也减少数据源上的负载。流程如下:

image-20220819121807231

Write-Through:

Write-Through模式下,当发生写请求时,也是由缓存抽象层完成数据源和缓存数据的更新,流程如下:

image-20220819121909374

方案三:Write behind (异步缓存写入)

Write behindRead-Through/Write-Through有相似的地方,都是由Cache Provider来负责缓存和数据库的读写。它两又有个很大的不同:Read/Write Through是同步更新缓存和数据的,Write Behind则是只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。

image-20220819121914460

这种方式下,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用。但是它适合频繁写的场景,MySQL的InnoDB Buffer Pool机制就使用到这种模式。

4.对于更新的四种选择

  1. 先更新缓存,再更新数据库
  2. 先更新数据库,再更新缓存
  3. 先删除缓存,再更新数据库
  4. 先更新数据库,再删除缓存

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

不管是操作数据库还是操作缓存,都有失败的可能。如果我们先更新缓存,再更新数据库,假设更新数据库失败了,那数据库中就存的是老数据。当然你可以选择重试更新数据库,那么再极端点,负责更新数据库的机器也宕机了,那么数据库中的数据将一直得不到更新,并且当缓存失效之后,其他机器再从数据库中读到的数据是老数据,然后再放到缓存中,这就导致先前的更新操作被丢失了,因此这么做的隐患是很大的。从数据持久化的角度来说,数据库当然要比缓存做的好,我们也应当以数据库中的数据为主,所以需要更新数据的时候我们应当首先更新数据库,而不是缓存。

image-20220819121918480

  1. 写请求1更新缓存,设置age为1
  2. 写请求2更新缓存,设置age为2
  3. 写请求2更新数据库,设置age为2
  4. 写请求1更新数据库,设置age为1

执行结果就是,缓存里age被设置2,数据库里的age被设置成1,导致数据不一致,此方案不可行。

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

这里主要有两个问题,首先是并发的问题:假设线程A(或者机器A,道理是一样的)和线程B需要更新同一个数据,A先于B但时间间隔很短,那么就有可能会出现:

  1. 线程A更新了数据库
  2. 线程B更新了数据库
  3. 线程B更新了缓存
  4. 线程A更新了缓存

按理说线程B应该最后更新缓存,但是可能因为网络等原因,导致线程B先于线程A对缓存进行了更新,这就导致缓存中的数据不是最新的。

第二个问题是,我们不确定要更新的这个缓存项是否会被经常读取,假设每次更新数据库都会导致缓存的更新,有可能数据还没有被读取过就已经再次更新了,这就造成了缓存空间的浪费。另外,缓存中的值可能是经过一系列计算的,而并不是直接跟数据库中的数据对应的,频繁更新缓存会导致大量无效的计算,造成机器性能的浪费。

综上所述,更新缓存这一方案是不可取的,我们应当考虑删除缓存。

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

这个方案的问题也是很明显的,假设现在有两个请求,一个是写请求A,一个是读请求B,那么可能出现如下的执行序列:

  1. 请求A删除缓存
  2. 请求B读取缓存,发现不存在,从数据库中读取到旧值
  3. 请求A将新值写入数据库
  4. 请求B将旧值写入缓存

这样就会导致缓存中存的还是旧值,在缓存过期之前都无法读到新值。这个问题在数据库读写分离的情况下会更明显,因为主从同步需要时间,请求B获取到的数据很可能还是旧值,那么写入缓存中的也会是旧值。

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

这是最常用的方案,但是最常用并不是说就一定不会有任何问题,假设有两个请求,请求A是查询请求,请求B是更新请求,那么可能会出现下述情形:

  1. 先前缓存刚好失效
  2. 请求A查询缓存未命中,继续查询数据库,得到旧值
  3. 请求B更新数据库
  4. 请求B删除缓存
  5. 请求A将旧值写入缓存

上述情况确实有可能出现,但是出现的概率可能不高,因为上述情形成立的条件是在读取数据时,缓存刚好失效,并且此时正好又有一个并发的写请求。考虑到数据库上的写操作一般都会比读操作要慢,(这里指的是在写数据库时,数据库一般都会上锁,而普通的查询语句是不会上锁的。当然,复杂的查询语句除外,但是这种语句的占比不会太高)并且联系常见的数据库读写分离的架构,可以合理认为在现实生活中,读请求的比例要远高于写请求,因此我们可以得出结论。这种情况下缓存中存在脏数据的可能性是不高的。写缓存比写数据库快出几个量级,读写缓存都是内存操作,速度非常快。

那如果是读写分离的场景下呢?如果按照如下所述的执行序列,一样会出问题:

  1. 请求A更新主库
  2. 请求A删除缓存
  3. 请求B查询缓存,没有命中,查询从库得到旧值
  4. 从库同步完毕
  5. 请求B将旧值写入缓存

如果数据库主从同步比较慢的话,同样会出现数据不一致的问题。事实上就是如此,毕竟我们操作的是两个系统,在高并发的场景下,我们很难去保证多个请求之间的执行顺序,或者就算做到了,也可能会在性能上付出极大的代价。那为什么我们还是应当采用先更新数据库,再删除缓存这个策略呢?因为缓存在数据持久化这方面往往没有数据库做得好,而且数据库中的数据是不存在过期这个概念的,我们应当以数据库中的数据为主,缓存因为有着过期时间这一概念,最终一定会跟数据库保持一致。

问:在秒杀场景中,是否有必要保持缓存与数据库的强一致性?

对于秒杀场景,最重要的就是限流和异步处理,因为数据库往往是最脆弱的地方。主要设计是这样的,除去接口本身的限流以外,本地内存和redis都可以对商品库存进行预处理,也就是是否是重复秒杀以及商品库存是否为零。当然这两者存的数据很可能不是最新的,但这个问题不大,因为如果他们存的数据已经显示商品库存为零,则秒杀已经失败,都不用打到数据库层。如果过了本地跟redis这一关,之后就是异步处理,就是将操作数据库作为消息写入mq,由消费端进行消费。消费端执行具体的数据库事务,只有执行成功才意味着秒杀成功,否则都是失败。无论结果如何,都将最终数据写入redis即可。可以看到这里是不需要做到数据库跟缓存的强一致性的。当然写mq这里还有点讲究,就是要保证同一个用户请求只会被同一个消费端处理,这个以Kafka为例只要指定相同的消息key即可做到,且消费端需要做消息去重,以请求ID作为去重依据。

猜你喜欢

转载自juejin.im/post/7133437611242651661