缓存与数据库一致性
序言
一般来说,对于一个新的业务,一般会经历这几个阶段:
阶段1:单库阶段
读写流量都比较小,这个时候所有的读写操作都在主库就ok了
这个时候,从库可能只是用来灾备
风险分析:从数据一致性角度来说没有风险,全走主库美滋滋~
阶段2:多库阶段
阶段2.1:
单库扛不住了,这个时候就会考虑到分库分表了,通过增加数据库的方式,把单库的QPS降下来
风险分析: 从数据一致性角度来说没有风险,全走主库依然美滋滋~
阶段2.2:
读流量增加,主库的读QPS偏高,这个时候我们就想着把从库得利用起来,于是读写分离:写主库,读从库
风险分析:读从库就意味着,读到的数据可能不是最新的,在实时性要求比较高的业务中是不能接受的,那应该如何解决呢?由于”DB主从一致性”不是本系列讨论的重点,所以这里推荐一篇文章,它介绍了几种比较不错的解决方案,感兴趣的大家可以去读一下:DB主从一致性架构优化4种方法
阶段3:数据库+缓存
对于互联网公司来说,大部分业务都面临着读多写少的情况(如果你写都有压力的话,那你做的业务一定很赚钱吧),而数据库资源是极其宝贵的,所以我们没有办法一味的添加从库,或者说不断的增加分库分表来解决,一是因为老板不会同意,二是不断分库分表的话,数据迁移过程也是高风险的。
所以,这个时候我们一般会通过添加的缓存的方式,来解决读压力
风险分析:缓存与数据库数据一致性问题
这就引出本系列文章,想要讨论的问题:缓存与数据库数据一致性问题,在提出解决方案之前,我们应该分析自己当前的业务和架构,看看自己的要达到的目标是什么?不同的方案解决的问题是不一样的
所以,你的业务和目标是怎样的呢?
- 你是要求最终一致性、还是强一致性?
- 你对缓存一致性的要求到底有多高?延迟1ms行不行?延迟1min行不行?
- 你当前是单库单表?还是多库多表?
- 你缓存的数据结构是怎样的?是否存在多表合并?是否存在多行合并的情况?甚至多库合并的情况?
- 该如何容灾?更新、删除缓存失败你能不能接受?写数据库失败怎么办?
- 如果删除缓存失败,你还允不允许更新数据库?
- ……
只有我们看清楚自己的系统需要达到一种怎样的要求,才能针对性的制定对应的方案。
第一种情况
今天,我们来分析一下,缓存与数据库被使用次数最多的一种使用方法
写流程:
第一步先删除缓存,删除之后再更新DB,之后再异步将数据刷回缓存
读流程:
第一步先读缓存,如果缓存没读到,则去读DB,之后再异步将数据刷回缓存
方案分析
优点剖析
实现起来简单
“先淘汰缓存,再写数据库” 合理
为什么说这也算优点呢?试想一下
如果把写流程改一下:先更新缓存,再更新DB。 如果我们更新缓存成功,而更新数据库失败,就会导致缓存中的数据是错误的,而我们大部分的业务一般能忍受数据延迟,但是数据错误这是无法接受的,所以先淘汰缓存是比较合理的。 如果把写流程改一下:不删缓存,先更新DB,再更新缓存。 如果我们更新DB成功,而更新缓存失败,则会导致缓存中就会一直是旧的数据(也算是一种错误数据),所以先淘汰缓存是比较合理的。
异步刷新,补缺补漏
在很多业务场景中,缓存只是辅助,所以在很多业务中,缓存的读写失败不会影响主流程,啥意思呢?就是说很多情况下,即使操作缓存失败(比如步骤1.1中的’DEL缓存失败’),程序还是会继续往下走(继续步骤1.2 更新数据库),所以这个时候异步刷新就能在一定程度上,对1.1的失败进行错误数据的修补
说完优点,我们再来看看缺点
缺点剖析
容灾不足
我们来分析一下写流程,第一步,’DEL缓存失败’怎么办?流程是否还继续走?如果继续执行,那么从’更新完DB’到异步’刷新缓存’缓存期间,数据处于滞后状态。而且如果缓存处于不可写状态,那么异步刷新那步也可能会失败,那缓存就会长期处于旧数据,问题就比较严重了
并发问题
写写并发:试想一下,同时有多个服务器的多个线程进行’步骤1.2更新DB’,更新DB完成之后,它们就要进行异步刷缓存,我们都知道多服务器的异步操作,是无法保证顺序的,所以后面的刷新操作存在相互覆盖的并发问题,也就是说,存在先更新的DB操作,反而很晚才去刷新缓存,那这个时候,数据也是错的
读写并发:再试想一下,服务器A在进行读操作,在A服务器刚完成2.2时,服务器B在进行写操作,假设B服务器1.3完成之后,服务器A的1.3才被执行,这个时候就相当于更新前的老数据写入缓存,最终数据还是错的
方案总结
这个方案呢,适合大部分的业务场景,很多人都在用,香还是很香的,实现起来也简单。
适合使用的场景:并发量、一致性要求都不是很高的情况。
但是这个方案有一个比较大的缺陷在于刷新缓存有可能会失败,而失败之后缓存中数据就一直会处于错误状态,所以它并不能保证数据的最终一致性。
第二种情况
在前面我们提到,它的一个比较大的缺陷在于刷新缓存有可能会失败,而失败之后缓存中数据就一直会处于错误状态,所以它并不能保证数据的最终一致性
为了保证数据最终一致性,我们引入binlog,通过解析binlog来刷新缓存,这样即使刷新失败,依然可以进行日志回放,再次刷新缓存
写流程:
第一步先删除缓存,删除之后再更新DB,我们监听从库(资源少的话主库也ok)的binlog,通过分析binlog我们解析出需要需要刷新的数据,然后读主库把最新的数据写入缓存。
读流程:
第一步先读缓存,如果缓存没读到,则去读DB,之后再异步将数据刷回缓存
方案分析
优点剖析
容灾
写步骤1.4或1.5 如果失败,可以进行日志回放,再次重试。
无论步骤1.1是否删除成功,后续的刷新操作是有保证的
缺点剖析
只适合简单业务,复杂业务容易发生并发问题
这里先来解释一下这里说的简单业务是啥意思?
为什么复杂业务就不行呢?我举个例子
我们假设 一个订单 = A表信息 + B表信息
由于A表先变化,经过1,2,3步后,线程1获取了A’B (A表是新数据,B表的老数据),当线程1还没来得及刷新缓存时,并发发生了:
此时,B表发生了更新,经过4,5,6,7将最新的数据A’B’写入缓存,此时此刻缓存数据是符合要求的。
但是,后来线程1进行了第8步,将A’B写入数据,使得缓存最终结果 与 DB 不一致。
缺点1的改进
- 针对单库多表单次更新的改进:利用事务
当AB表的更新发生在一个事务内时,不管线程1、线程2如何读取,他们都能获取两张表的最新数据,所以刷新缓存的数据都是符合要求的。
所以这种方案只针对多表单次更新的情况。
- 针对多表多次更新的改进:增量更新
每张表的更新,在同步缓存时,只获取该表的字段覆盖缓存。
这样,线程1,线程2总能获取对应表最新的字段,而且Databus对于同表同行会以串行的形式通知下游,所以能保证缓存的最终一致性。
依然是并发问题
即使对于缺点1我们提出了改进方案,虽然它解决了部分问题,但在极端场景下依然存在并发问题。
这个场景,就是缓存中没有数据的情况:
- 读的时候,缓存中的数据已失效,此时又发生了更新
- 数据更新的时候,缓存中的数据已失效,此时又发生了更新
这个时候,我们在上面提到的“增量更新”就不起作用了,我们需要读取所有的表来拼凑出初始数据,那这个时候又涉及到读所有表的操作了,那我们在缺点1中提到的并发问题会再次发生
方案总结
适合使用的场景:业务简单,读写QPS比较低的情况。
这个方案呢,优缺点都比较明显,binlog用来刷新缓存是一个很棒的选择,它天然的顺序性用来做同步操作很具有优势;其实它的并发问题来自于Canal 或 Databus。拿Databus来说,由于不同行、表、库的binlog的消费并不是时间串行的。
第三种情况
在第二种情况内容中,我们提到上一个方案的并发问题
那我们我们可以看到,这个问题就来自于 读数据库 + 写缓存 之间的交错并发,那怎么来避免呢?
有一个方法就是:串行化,我们利用MQ将所有 读数据库 + 写缓存 的步骤串行化
写流程:
第一步先删除缓存,删除之后再更新DB,我们监听从库(资源少的话主库也ok)的binlog,通过分析binlog我们解析出需要需要刷新的数据标识,然后将数据标识写入MQ,接下来就消费MQ,解析MQ消息来读库获取相应的数据刷新缓存。
读流程:
第一步先读缓存,如果缓存没读到,则去读DB,之后再异步将数据标识写入MQ(这里MQ与写流程的MQ是同一个),接下来就消费MQ,解析MQ消息来读库获取相应的数据刷新缓存。
方案分析
优点剖析
容灾完善
我们一步一步来分析:
写流程容灾分析
- 写1.1 DEL缓存失败:没关系,后面会覆盖
- 写1.4 写MQ失败:没关系,Databus或Canal都会重试
- 消费MQ的:1.5 || 1.6 失败:没关系,重新消费即可
读流程容灾分析
- 读2.3 异步写MQ失败:没关系,缓存为空,是OK的,下次还读库就好了
无并发问题
这个方案让“读库 + 刷缓存”的操作串行化,这就不存在老数据覆盖新数据的并发问题了
缺点剖析
方案总结
经过3部分由浅入深的介绍,终于实现了“最终一致性”。这个方案优点比较明显,解决了我们前面一直提到的“容灾问题”和“并发问题”,保证了缓存在最后和DB的一致。如果你的业务只需要达到“最终一致性”要求的话,这个方案是比较合理的。
到目前为止,既然已经实现了**“最终一致性”,那我们再进一步,“强一致性”**又该如何实现呢?
第四种情况
经过前面的整理,我们终于的实现了最终一致性,name我们再进一步,在前一个方案的基础上实现强一致性
首先我们来分析一下,既然已经实现了最终一致性,那它和强一致性的区别是什么呢?没错,就是“时间差”,所以:
那我们的工作呢,就是加上时间差,实现方式:我们加一个缓存,将近期被修改的数据进行标记锁定。读的时候,标记锁定的数据强行走DB,没锁定的数据,先走缓存
写流程:
我们把修改的数据通过Cache_0标记“正在被修改”,如果标记成功,则继续往下走,后面的步骤与系列三是一致的;那如果标记失败,则要放弃这次修改。
如果说,还想更严谨一点,怕DB主从延迟太久、MQ延迟太久,或Databus监听的从库挂机之类的情况,我们可以考虑增加一个监控定时任务。
比如我们增加一个时间间隔2S的worker的去对比以下两个数据:
- 时间1: 最后修改数据库的时间
VS - 时间2: 最后由更新引起的’MQ刷新缓存对应数据的实际更新数据库’的时间
如果 时间1 VS 时间2 相差超过5S,那我们就自动把相应的缓存分片读降级。
读流程:
先读Cache_0,看看要读的数据是否被标记,如果被标记,则直接读主库;如果没有被标记,后面的步骤与系列三一致的。
方案分析
优点剖析
容灾完善
我们一步一步来分析:
写流程容灾分析
- 写1.1 标记失败:没关系,放弃整个更新操作
- 写1.3 DEL缓存失败:没关系,后面会覆盖
- 写1.5 写MQ失败:没关系,Databus或Canal都会重试
- 消费MQ的:1.6 || 1.7 失败:没关系,重新消费即可
读流程容灾分析
- 读2.1 读Cache_0失败:没关系,直接读主库
- 读2.3 异步写MQ失败:没关系,缓存为空,是OK的,下次还读库就好了
无并发问题
这个方案让“读库 + 刷缓存”的操作串行化,这就不存在老数据覆盖新数据的并发问题了
缺点剖析
增加Cache_0强依赖
这个其实有点没办法,你要强一致性,必然要牺牲一些的。
但是呢,你这个可以吧Cache_0设计成多机器多分片,这样的话,即使部分分片挂了,也只有小部分流量透过Cache直接打到DB上,这是完全是可接受的
复杂度是比较高的
涉及到Databus、MQ、定时任务等等组件,实现起来复杂度还是有的
方案总结
OK,到此呢,我们已经实现了数据库和缓存强一致性。
参考文献
1. Canal
2. Databus
3. Kafka
4. Redis Expire 命令
5. Databus & Canal 对比