一般来说,一个业务系统一般会经历以下几个阶段。本系列文章,是针对缓存与数据库一致性场景,提出实用可行的技术方案。
阶段1:单库阶段
此时系统的读写流量很小,这个时候所有的读写操作都在主库;此时,从库的角色只是作为灾备。
风险分析:从数据一致性的角度来看没有任何问题,所有读写操作都在主库
阶段2:多库分片阶段
阶段2.1: 分库分表阶段
随着业务的前进和流量的激增,会出现大表和数据库写入性能下降的问题。我们可以通过分库的方式,提升数据库单机的QPS压下来;通过分表的方式,降低单表的数据量,提升查询性能
风险分析:从数据一致性的角度来看没有任何风险,所有操作仍然走主库
阶段2.2:读写分离阶段
随着业务的进一步发展,在阶段2.1时我们已经解决了写性能的问题;但业务发展到此时,读问题也会逐渐成为性能瓶颈。这时,我们需要把从库利用起来;进行读写分离,写走主库,读走从库
风险分析:读写分离意味着,读到的数据很有可能不是最新的。这对实时性要求较高的交易类场景是不合适的,但是足以应对90%的业务场景;关于数据库主从同步的延时问题,我们之后再进行深入讨论。
阶段3:数据库+缓存
对于大多数业务场景来说,我们都面临着读多写少的情况;核心交易类应用,更多是面临写多读少的场景(此时将核心流程全部走写库,避免了为了处理数据一致性的复杂性)。而数据库的资源是很宝贵的,疯狂增加从库节点,会带来资源成本激增;同时,分库分表也会带来系统设计的复杂度上升和数据迁移的困难。
所以,此时我们会通过添加缓存的方式,来缓解数据库的读写压力。
风险分析:数据库与缓存数据一致性问题。
上面的问题,引发出之后的思考与讨论的问题:缓存与数据库一致性如何保证?在提出方案之前,我们需要分析自己业务系统的架构与设计方案,可以接受什么粒度的数据不一致?不同的方案,实现难度和设计是不一样的
我们的业务系统目标是怎样的?
- 要求最终一致性,还是强一致性?
- 对缓存一致性的时间要求到底有多高?1ms?1min?
- 当前有没有做分库分表,读写分离?是不是可以经受住当前及未来业务的增长
- 缓存数据结构是怎么样的?是否有多表合并的数据结构
- 如何灾备?更新、删除缓存失败的话,系统能否容忍?写入数据库失败怎么办?
- 如果删除缓存失败,还更不更新数据库?
- 。。。。。
只有我们自己清楚了现状、知道自己想要什么,才能设计出最合理缓存数据库一致性方案。
在本章节,我们分析一下缓存与数据库数据一致性场景中,被使用最多的方案:
写流程:
当我们更新缓存key时,首先做删除缓存操作;当删除完成之后再更新数据库;最后再进行异步(或同步)刷新缓存操作
读流程:
首先读取缓存的key,如果存在即返回;否则读取DB,再异步(或同步)刷新缓存
方案分析
优点分析
1.实现简单
这个。。没什么好说的,逻辑简单,代码实现也不会很复杂
2.“先淘汰缓存,再写数据库”合理
我们试想一下,将上述的写流程做一下改造:
数据变化后,首先更新缓存,再更新DB。如果缓存更新成功,数据库更新失败,会导致DB中的数据完全是错误的,这个错误是绝大多数业务系统完全不能接受的。业务系统也许可以接收数据的延迟,但是绝对不能接收数据的错误。
数据变化后,不更新缓存,先更新DB,最后更新缓存。如果缓存更新失败,会导致缓存中的数据一直是旧数据(其实也是一种错误),而且数据无法达到最终一致性的要求
所以,我们首先将缓存淘汰,更新DB后再刷新缓存的方案,是比较合理的。
3.异步刷新,补缺补漏
在大多数业务系统中,缓存是做辅助工作而不是完全做存储角色。所以在很多场景中,缓存的读写失败不能影响到主流程。其实我们可以在每次写或者读操作后,同步刷新缓存;但异步刷新,可以进一步在写流程的步骤1(DEL缓存)失败后的补偿,保证数据一致性。
缺点分析
1.容灾不足
如果del缓存失败,整个流程是否还需要继续?这个需要针对每一个不同场景进行不同的考量;如果异步刷新的过程失败,会导致缓存中的数据一致保持一个旧状态,这个问题就相对比较严重了
2.并发问题
写写并发:
如上述的流程,Server A和Server B在写流程先后更新数据库记录;之后刷新缓存,因为在分布式场景下,我们没有办法保证顺序(无论是同步刷新还是异步刷新)。这时,如果Server B优先于Server A完成缓存的更新,会造成最后缓存中的数据是Server A的旧数据。也就是,不能排除先更新的DB操作,反而会很晚刷新缓存,这时,数据也是错的。
读写并发:
T
|
Server A(写操作)
|
Server B(读操作)
|
---|---|---|
T1 | del 缓存 | |
T2 | 查询缓存key,不存在 | |
T3 | 查询数据库 | |
T4 | 更新数据库 | |
T5 | 刷新缓存成功 | |
T6 | 刷新缓存成功 |
如上述的流程,如果读操作查询的时间早于写操作、刷新缓存的时间晚于写操作。会造成最终写入到缓存的数据仍然是旧数据。
方案总结
本章节介绍的方案,适合绝大部分业务场景,实现起来也比较简单。适用于并发量、一致性要求都不是很高的情况。但是这个方案最大的问题在于更新可能会失败,失败的话缓存中的数据一直是错误的,不能保证最终一致性;在并发量较高的时候,数据也很难保证一致性。
是否可以解决?答案是肯定的,我们在下一章节继续设计与分析。
在上一篇文章中,我们的方案虽然实现起来简单,而且可能绝大多数场景都可以适用,但是有一个最大的缺陷:当缓存异步刷新失败时,缓存中的数据永远无法与数据库保持一致性。
在分布式系统中,我们经常使用最终一致性的方案来解决数据一致性问题。我们可以基于MQ,将读数据库+写数据的流程串行化,进而解决并发问题和实现数据的最终一致性。
写操作:
第一步先删除缓存,删除之后再更新数据库;将数据标识写入MQ,接下来Consumer消费MQ,从读库中查询数据并刷新到缓存。(在本文中,我们默认主从同步延时忽略)
MQ实现消息顺序化,可以参考RocketMQ中,同一个queue中的消息是有序的;KafKa中,同一个分区的消息是有序的机制来实现。
读操作:
首先读取缓存,如果缓存中不存在,则读取DB主库(或从库)。之后与写操作一致,下传标志位到MQ,Consumer消费MQ,从读库中刷新数据到缓存中。
方案分析
1.容灾机制
写流程容灾分析:
- del缓存的流程:如果失败,可以靠最后的数据更新覆盖
- MQ发送失败:基于中间件的重试机制来保证消息投递
- 消费MQ失败:重新消费即可
读流程容灾分析:
- 查询完成后写MQ失败:缓存中没有数据,每次都查询数据库,可以保证一致性
2.并发问题
该方案将“读数据库+刷新缓存”的过程串行化,这样就不存在老数据覆盖刷新新数据的问题
3.方案缺点
- 最终一致性方案,如果数据变动频繁会有一定的数据库查询压力;且缓存查询时候,会有查询到旧数据的可能
- 引入MQ中间件,系统健壮性降低;但是可以通过集群、灾备等运维方式来避免
方案总结
经过前面由浅入深的讨论,我们已经实现了“最终一致性”。这个方案的优点还是比较明显的,解决了我们之前方案的“容灾问题”和“并发问题”,整体思路也是将并行的问题串行化解决。保证了缓存和数据库中的数据在最后是一致的。如果你的业务只需要达到最终一致性的话,这个方案已经是比较合理得了。
那我们再进一步,如何实现“强一致性”?下一篇文章继续讨论。
经过前面三篇文章的讨论,我们已经实现了缓存与数据库的“最终一致性”。在本文中,我们再进一步,在上文的基础上实现“最终一致性”。
什么是强一致性?
- 缓存和数据库中的数据永远一致
- 缓存中没有数据,始终查询数据库
首先,我们先分析一下,“强一致性”和“最终一致性”的区别在哪里?关键点在于“时间差”
“强一致性”=“最终一致性”+“时间差”
那我们的工作,就是在“最终一致性”的基础上,加上“时间差”。实现方式:我们增加一个缓存key值,将近期要被修改的数据进行标记锁定(类似于分布式锁);读取的时候,如果数据处于被标记的状态,则强行走DB;没有锁定的话,则先走缓存。
写流程
当我们更新数据时,首先写入缓存标记位以锁定该记录。如果标记成功,则流程继续往下走即可。如果标记失败,则放弃本次修改。
如何标记锁定?
比如你可以设定一个有效期为10s的key,key存在即为锁定。一般来说,10s对于后面的同步操作时间基本够用。这个时间可以根据业务系统的忍耐度来配置
读流程
先读缓存锁定标志位,看一下要读取的记录是否已经锁定。如果多锁定,则直接查询数据库(我们默认主从延迟忽略,查询走从库);如果没有被标记,则流程继续往下走。
方案分析
优点分析
1. 容灾完备
我们一步一步来分析,该方案如何容灾完备:
写流程容灾分析:
- 写入标记锁定位失败:没关系,放弃修改即可
- 删除缓存key失败:没关系,后面的流程会覆盖
- 写MQ失败:基于中间件的重试和ack机制,保证消息至少一次投递成功
- MQ消费失败:理由同上,重新消费即可
读流程容灾分析:
- 读取标记锁定位失败:直接查询数据库
- 写MQ失败:没关系,反正缓存值为空,下次还走数据库即可
2.无并发问题
所有流程基本全部串行化,不存在老数据覆盖新数据的问题
缺点分析
1.增加对缓存标记锁定位的强依赖
其实这个问题是没有办法的,实现强一致性,一定要牺牲一些性能和稳定性的。毕竟架构是一门妥协的艺术。如同分布式系统的CAP定律,无法三者同时满足一样。
但是呢,可以用热点key的思路来解决这个问题。将缓存标记位分片至多个节点上,即使部分节点挂了,也只有很少的流量进入到数据库查询。
2.复杂度增加
毕竟引入了这么多逻辑,编程复杂度一定会上升的。世界上没有完美的事情
方案总结
至此,“缓存与数据库”的强一致性已经实现。本文也是基于在可实现和尽量简单的基础上,完成了这么一次架构方案的设计。如果大家有更好的思路,可以一起交流。