天天看点

分布式系统(一)数据库和缓存一致性深刻理解数据库和缓存一致性

深刻理解数据库和缓存一致性

1. 简介

在软件的发展过程中,我们的数据存储在不同的介质中,比如数据库,Redis缓存中;每个存储介质有其特性,利用好这些特性可以最大程度的发挥程序的性能;而在这些存储介质中,如何保证各个介质中数据的一致性是软件发展中一个非常重要的点;

2. 发展

在早期的系统里面,很多的接口都是直接查询数据库;当请求数量比较小,查询的数据量比较少时,数据库还是可以抗的住这样的查询;

分布式系统(一)数据库和缓存一致性深刻理解数据库和缓存一致性

随着业务量的增多和查询次数的增多,数据库的响应时间会是制约软件响应速度的瓶颈,此时缓存的使用就开始了;我的理解是缓存是一种思想,加快了请求的响应;不仅仅在开发过程中使用到,像许多的硬件,比如CPU 的一二三级缓存,电脑机械硬盘的缓存,都是为了加快响应;

分布式系统(一)数据库和缓存一致性深刻理解数据库和缓存一致性

缓存中存储着常用的数据,要访问数据时,先去缓存中查询有没有数据,没有的话再去数据库中查询数据;当遇到修改数据或者新增数据的时候,我们会去操作数据库;然后再把数据同步到缓存中去;

这里就引出了本文要说的问题,最后的数据库数据同步到缓存中去,这个中间是有时间的间隔的;那在这一段时间内的请求如果是请求到缓存的数据,而这个数据已经在数据库修改还没有来得及更新到缓存,这就涉及到这个请求读取到了脏数据;

3. 问题分析

软件一直处于发展阶段,不会因为上面说到脏数据的问题而去不使用缓存;业界针对上面的问题和使用场景,使用不同的缓存思路;

其实我们在这里可以考虑,在下面写缓存和写DB 本来就是两个操作,而且是并行,肯定在过程中会出现脏数据的情况,我们做的方案就是在减低这种概率,或者保持数据的最终一致性;来降低数据一致性带来的影响;上面说到了并行;还有一种方案就是不并行,对数据的数据加锁,分布式锁,对一个资源,或者一个方法加上一个分布式锁,把非原子性的这种操作变成一个串行的动作,这个就是对数据一致性有较大的保证;但是带来的结果就是效率相对较低;

除此之外,除了并行带来的问题,就是数据存储失败的问题,因为缓存和DB 是两个中间件,有可能发生一个成功,一个失败的问题,那么针对失败,我们也需要做一些措施来尽可能避免失败带来的数据不一致的行为;

综上总结存在的问题:

1.请求并发带来的数据不一致

2.存储DB和缓存其中一个失败带来的数据不一致;

4.请求并发方案

本节针对并发请求不同方案处理的优劣势分析;

4.1 缓存更新

  • 写请求去数据库
  • 读请求先缓存 没有的话 查询数据库,并写入缓存,设置失效时间
分布式系统(一)数据库和缓存一致性深刻理解数据库和缓存一致性

场景 : 线程A,B 并发执行

都是读,没什么好说,不会有数据不一致问题;

A写B读 ,最差的情况是一个请求读取到缓存脏数据或者DB 脏数据

A,B 都是写,问题就较大了,下面就是该设计当请求都是写请求的问题

1.先DB 后缓存

修改请求的同时,在去更新缓存;

比如是先更新DB:

  • Thread A 把 DB 中 X -> 1
  • Thread B 把 DB 中 X -> 2
  • Thread B 把 缓存 中 X -> 2
  • Thread A 把 缓存 中 X -> 1

正常情况 缓存最后是 X = 2 但是确实 X= 1 ,缓存永久(如果没有过期时间)脏数据

2.先缓存 后DB

在如果先修改缓存:

  • Thread A 把 缓存 中 X -> 1
  • Thread B 把 缓存 中 X -> 2
  • Thread B 把 DB 中 X -> 2
  • Thread A 把 DB 中 X -> 1

正常情况 DB 中X=2 但是却 DB 中 X=1 ,DB永久脏数据

4.2 删除缓存

写数据的请求会删除缓存;读取的时候和上面一样判断缓存中数据是否存在;

同样这样的方案也是有两种;

  • 先删除缓存,后写DB
  • 先写DB , 后删除缓存
分布式系统(一)数据库和缓存一致性深刻理解数据库和缓存一致性

场景:线程A,B 并发执行

都是读,没什么好说,不会有数据不一致问题;

A写B读 ,最差的情况是一个请求读取到缓存脏数据或者DB 脏数据

A,B 都是写,都是修改DB,且删除缓存,不会有数据问题

综合:分析到这里可以看到对于 缓存更新的优势了,都是写请求不会出问题,且一个写一个读的情况出现问题的概率也相对较低,看下面分析

1.先删除缓存,后写DB

原本数据 X = 1

  • Thread A 开始执行 X=2
  • Thread A 删除缓存
  • Thread B 获取X ,此时数据已经被删除,然后获取到DB 尚未更新更新的数据 X=1
  • Thread B X=1 放到缓存中去
  • Thread A 执行DB X=2 ,流程结束

正常情况 缓存X=2 但是却最后

Thread B X=1 放到缓存中去

缓存永久脏数据

2.先写DB , 后删除缓存

  • 1.Thread A 获取X ,缓存中不存在,去到DB 中 获取X =1
  • 2.Thread B 写DB X=2 ,
  • 3.Thread B 删除缓存 ,
  • 4.Thread A 在第一步获取的 X =1 更新到缓存

正常情况 缓存X=2 但是却最后

Thread A 在第一步获取的 X =1 更新到缓存

缓存永久脏数据

**不同点就是:**第二种方式 2,3 ;如果 Thread A ,Thread B 在步骤1 ,2 是几乎同时过来的话,1 + 4 的时间是要比 2 + 3 的时间短的,因为正常情况下,对数据的写会比读更加的消耗时间; 所以 2 + 3 正常的时间 是 在 4 后面执行,也就是最后数据正常情况下是一致的;总结就是出现这种情况的同时满足条件

  • 缓存 X 不存在
  • 读写并发
  • 2 + 3 时间 >1+4 时间 ; 所以 3 一般后执行

那么这种方式就是相对前面三种中较好(矮子里面找高个了属于)的一种方式来操作DB 和缓存;

5.单DB/缓存更新失败

5.1 扩展缓存删除

在对删除缓存的思路分析中,可以分析到,缓存删除,缓存更新 其实是缓存删除比较好的;

缓存删除删除中先写DB , 后删除缓存 是比较好的;

======>>>> 那么下面就针对这个方案 在详细看看

  • 1.Thread A 获取X ,缓存中不存在,去到DB 中 获取X =1 ; 执行成功
  • 2.Thread B 写DB X=2 ,;执行成功
  • 3.Thread B 删除缓存 ,
  • 4.Thread A 在第一步获取的 X =1 更新到缓存

A,B 线程第一步就是失败的话,后续就可以不用执行了,就变成一个线程执行,没有一致性的问题,排除第一步就失败;

======>>>> 那么现在讨论第二步失败:

  • 3.Thread B 删除缓存 ,如果缓存删除失败,数据库成功 ---->>>数据不一致,不能以为写缓存失败而回滚,业务上不合理;
  • 4.Thread A 在第一步获取的 X =1 更新到缓存;---->>>失败没有问题 ,最多缓存没有数据,数据一致性还在

且我们讨论,一般情况下 3. 是在 4 后面执行的 (上一节说过);且如果想要数据更趋向于一致,3 要保证在4 后面才数据一致更能有保障(不是一定数据一致);

======>>>> 那么就保证 Thread B 第二步删除成功执行

正常删除什么问题没有,万一删除就失败呢

======>>>> 那么久删除失败

如何防止删除失败,或者说是删除失败后如何补偿,实现方案就是重试

重试的问题是

  • 一般情况下是在执行一次还是会异常(大概率)
  • 或者说重试多少次才合理??
  • 重试是阻塞的,影响程序运行效率

如何解决这些问题; 异步重试删除,那么如何异步,我们在开发中异步操作大概率会想到的就是 消息队列;不错 我们把重试的消息直接放到消息队列;

消息队列优点:

  • 消息队列有持久化机制,数据不会丢失
  • 消费成功才删除 =======>>>> 消息队列保证异步
  • 消费失败继续添加到队列 =======>>>> 这一点 来保证重试

5.2 异步重试方案(一)

上面是推导除了异步重试方案在数据一致性里面比较好的方案了,整体架构就是

分布式系统(一)数据库和缓存一致性深刻理解数据库和缓存一致性

5.3 异步重试方案(二)

上面说的方案,在写请求的时候,在去把对应的修改写到消息队列里面去;

现在有更加简单的方案,就是订阅数据库的

binlog

(数据库修改的逻辑日志),这个订阅的服务现在 有成熟的方案,比如阿里的 Canal;

次方案架构为:

分布式系统(一)数据库和缓存一致性深刻理解数据库和缓存一致性

6.主从DB&缓存 数据不一致

上一章节说的是单机环境的DB 和缓存,那如果DB 是主从结构,一般我们的程序还是读写分离的,这个时候该如何设计;

场景: 原来的循序调换一下

  • 2.Thread B 写DB X=2 ,
  • 3.Thread B 删除缓存 ,
  • 1.Thread A 获取X ,缓存中不存在,去到DB从库 中 获取X 此时数据主从还未同步 获取X =1 ;
  • 4.Thread A X =1 更新到缓存

问题解析:

本来在并发情况下,读操作是比较快的,更新的脏数据缓存被写操作覆盖删除掉;但是由于从库数据同步相对较慢,就又获取到脏数据并加载到缓存;

解决方案:缓存延迟双删

Thread B 写数据库后删除缓存,完成之后,在稍等一会,再次删除缓存;(当然中间可能会短暂的读取到脏数据)

=======>>>> 那么延迟多久?

  • 延迟时间大于主从复制时间
  • 时间要在 其他线程 读取从库+ 写入缓存的时间

大概这个时间是在 1~ 3秒左右;

7.总结

从上面分析的情况可以看到不同场景下的方案的优劣性;采用这样的方案要是基于自己业务系统对脏数据的容忍性,来换取程序执行的效率;

方案的好坏,需要实际的使用场景去验证;,5,6中的方案可能会在短暂的时间内读取到脏数据;但是会在短暂的时间后会达到数据的最终一致性;

我们引入缓存,就必然引入数据一致性的问题,我们所有的方案就是降低一致性带来的系统问题;

继续阅读