点击上方小坤探游架构笔记可以订阅哦
前面我已经聊了高可用架构下不同的复制方式以及格式, 同时也讲述到不同的复制模型算法以及由于复制延迟带来的一致性问题, 现在主要是对前面的一致性问题做一个总结与梳理, 今天主要围绕单主复制模型的线性一致性实现问题展开.
最终一致性保障
不论基于Leader的复制模型如Leader-Based Replication & Multi-Leader Replication 还是无领导模型如Leaderless Replication模型都会因Replication Lag 导致一致性问题的产生. 然而我们大多数具备复制架构的数据库都能够实现最终一致性(Eventual Consistency).
怎么理解最终一致性呢? 如果我们对最终一致性一词换另一个词来进行表述, 那就是Convergence, 即我们期望所有的副本最终都能够趋于/收敛于相同的值, 即:
We expect all replicas to eventually converge to the same value.
也就是说我们允许容忍这种故障, 什么故障呢? 就是如果我停止向数据库写入数据, 那么经过一段未明确指定的时间T, 在这段时间T内, 我们应用程序能够容忍读取数据的不一致性, 但经过T之后我们应用程序能够读取到相同的值.
这是我们对最终一致性的理解, 它提供一种比较弱的一致性保障, 为什么称之为弱的一致性保障呢? 因为对于Replica的值如何收敛至相同的值并没有提及, 而是一个通过“最终”一词来描述, 也就是这个时间T是不确定的, 我们无法保证所有的Replica的值什么时候是相同的, 那么在此T时间之前, 我们的读取请求并不能保证能够读取到最新修改的值, 因为请求可能会路由到其他未被应用值的Replica中甚至是网络故障, 抑或是节点高负载等因素导致什么都不返回.
说到这里,其实我们也看到了最终一致性的不足, 即when是一个问题, 什么时候收敛并没有提及, 那么当我们使用一个提供弱一致性保障的数据库时候, 我们要时刻识别到其局限性所在, 相比普通单线程程序中变量的行为大不相同:
单线程程序模型: 如果你给一个变量赋值,然后在短时间后读取它,你不会期望读到旧值,或者读取失败。
而作为应用开发者的我们在最终一致性却很难处理, 要么是旧值, 要么什么都不返回, 尤其是后者, 我们有时很难知道系统内部发生了什么事情, 而这些错误往往很隐蔽, 很难通过测试发现, 往往平时运行得良好, 但是一旦出现系统故障或者弹性高并发流量的时候就很容易暴露出来.数据库表面看起来是一个读写的变量, 但它内部实现可靠性以及故障容忍机制的语义却比较复制, 比如我们的事务, 还有复制延迟的一致性问题.
那么这个时候我们再回来看, 相比最终一致性模型, 强一致性模型会更易于正确使用, 尽管它对故障容忍度较差、性能层面也存在损耗, 但也不妨碍我们对强一致性模型的落地实现就束手无策, 对此我们需要了解不同强一致性模型的实现代价以及问题所在, 并针对其中的问题提供一些思考解决方案的思路.
重新看待线性一致性模型
讲述线性一致性的时候, 我想我们或多或少都会存在一些疑问, 因为有时候我们真的被所谓的术语名词搞混淆了, 因此讲述线性一致性的时候, 我们可以先回顾下什么是线性一致性模型:
单对象操作一致性, 如果是用户独享的数据, 那么我们期望的一致性就是读己之所写; 如果是共享数据, 那么我们期望的一致性就是看起来只有一份Replica.
因此当我们谈及线性一致性的时候, 如果是用户的独享数据, 对于这种我们称之为读己之所写模型, 那么这个时候我们再来考虑拆分不同的数据复制架构下实现线性一致性可能的代价以及问题.
在实现读己之所写模型 + Leader-Based复制架构的数据库中, 我们能否实现线性一致性呢? 同样我们看之前的复制架构下用户请求的表现;

由于Replication Lag问题, User1234如果请求路由到Leader 以及 Follower1 节点那么是可以实现线性一致性, 但是如果路由到Follower2 节点那么就会发现没有数据结果; 如果说我们系统什么样的应用场景需要读己之所写模型, 那么其实很多, 其一是自己发布朋友圈消息我们希望自己看到最新的数据、用户进行注册后重新登录不能告诉我说还没有注册、用户修改密码之后重新登录你不能跟我说密码错误要重新修改等等这些场景.
那么如果是共享数据, 这个时候就会面临多个客户端并发读写问题, 首先是读取问题, 这个时候我们的线性一致性模型需要增加一个时效约束, 即在时间上我们需要确保在某个时刻 T 之后始终能够读取到一份相同最新的数据, 即:

也就是说在上述过程是基于时间的依赖, 我们看到ClinetA率先读取到x = 1之后, 后续不论是ClientA还是其他Client都能够保证读取到x = 1.
其次是共享数据的写维度, 这个时候存在多个并发写问题, 那么这个时候我们就需要针对写入值添加一个原子性保证, 即在执行多个并发写的过程中, 先读取到最新的值, 然后基于最新的值进行CAS操作, 如果数据库已经被其他值更改那么将返回错误,即:

上述也同样印证了我们线性一致性模型的时效性约束: 一旦写入或读取了一个新值,所有后续的读取操作都会看到所写入的值,直到该值再次被覆盖。上述的连接操作在时间序列上组成了一个有序的操作, 那么我们就可以通过记录所有请求和响应的时间,并检查它们是否能被排列成一个有效的顺序序列,尽管计算成本很高(需要全局时钟), 但时间操作序列上组成有序是测试一个系统的行为是否具备线性一致性的一个手段.
因此当我们思考线性一致性模型的时候, 我们先需要思考的是数据是独享还是共享的属性, 从这个方向去建立我们思考一致性模型的机制.
单主复制的线性一致性
通过上述一致性模型的分析, 我们如果要在复制存储架构实现线性一致性, 那么可以拆分两个大维度去考虑问题, 其一是独享数据, 即读己之所写模型, 那么这个时候我们采用不同的复制模型会在实现线性一致性上会存在哪些问题呢?
第一种是我们的Leader-Based复制模型, 即单主复制算法模型, 那么这个时候由于存在Replication Lag, 我们会出现读取不到最新的一份数据, 即:

你会发现用户insert一条数据之后发现自己看不到insert之后的结果, 对于用户独享的数据, 我们的线性一致性并不需要保证其他用户能够在用户insert并将数据持久之后立马看到当前用户的数据更新, 但要保证当前用户看到更新的数据, 即确保用户的操作已经被正确应用到持久存储上.
虽然我们可以让其他用户晚些时候看到用户更新的数据, 比如评论系统或者朋友圈信息, 但不能出现User2345第一次查询看见一条新的数据结果, 第二次查询就看不到原有的数据, 即产生数据在时间序列上倒流的现象, 即:

那么怎么解决呢? 只读leader节点, 其他仅作为backup节点, 不提供读能力, 这个时候我们的架构就降级为主备架构, 同时数据复制如果是异步需要保证可靠性, 如果是同步方式那么又会进一步降低写性能处理能力, 那么这个时候采用的复制方式还取决于我们应用程序的性能评估, 即是否满足生产上在读写并发请求QPS以及RT响应延迟的性能要求, 并建立对应的性能模型针对不同架构方案设计进行Trade-Off. 与此同时, 我们的主备架构由于存在人工手动切换, 这期间间接导致我们的系统在RTO指标(Recovery Time Objecttive)的增加, 如下:

除此之外, 还有什么方式呢? 也许我们会想到仍然采用主从架构, 但是这个时候选取一个follower1节点提供外部读操作, 前提是写入leader节点的时候需要同步数据到follower1节点, 即:

但是如果leader节点发生不可用, 选择Follower1节点作为leader节点, follower2节点代替原有Follower1节点, 那么也会产生数据不一致性, 因为Follower2节点数据并不能与Leader节点保持一致, 那么这个时候怎么办呢? 那就是在重新选举的过程牺牲对外的可用性, 等待Follower2节点将数据同步至与leader节点一致才对外提供服务.或许这是一个办法, 但期间我们牺牲了在故障切换过程中无法保证对外提供服务,即牺牲选举leader过程中的可用性.同样地, 基于主备架构如果是异步复制方式, 也是需要等待数据与leader节点一致才提供对外服务.
如果是共享数据呢? 比如会议室预定, 那么这个时候的线性一致性就是在一个会议室R1在11-12点被UserA预定, 那么其他用户也必须要看到会议室R1在11-12点时间段被占用, 那么这个时候我们的共享数据就是需要保证全局的时效性而非单个用户的时效性. 同样地我们采用基于Leader-Based复制算法模型, 这个时候我们只能采用主备架构, 为什么?

假如现在UserA以及UserB同时需要预定R1会议室11-12点, 由于存在并发读写问题, 因此我们的数据库会采用一个称之为事务隔离的机制来避免由于并发执行事务而导致的竞态条件, 如果需要进行写同步机制, 那么这个时候Follower1节点也需要同步开启事务, 这个时候我们的数据存储仅靠Leader-Based模型是无法保证数据一致性的, 因为这个时候相当于分布式事务, 我们还要保证原子事务提交机制.
因而我们会采用主备架构方式,同时事务隔离机制我们有一个非常经典的实现, 比如基于版本的快照隔离, 这个时候UserA以及UserB都有一个自己版本的快照, 都看到可以预定会议室, 但是当UserA以及UserB分别提交事务的时候我们发现存在冲突导致预定失败,如果数据库能够自动解决冲突, 比如基于LWW机制, 那么这里的先后顺序就依赖于我们的事务操作的先后顺序, 而这个先后顺序在上述的一致性模型中提到, 是一种基于时间的全序关系. 比如UserA成功提交事务, 那么UserB只能看到提交失败的数据,那么必然存在UserA提交事务的TxId要大于UserB的事务TxId.这个就需要依赖我们采用是同步时钟机制或者是全局逻辑时钟方式.
可以看到我们基于Leader-Based实现线性一致性是有可能的, 但并不是完全可行的, 其中还取决于我们的场景以及实现机制, 不论是独享数据还是共享数据, 我们是有办法实现线性一致性, 但中间也会损失部分性能以及可用性, 但要注意只是可能性, 为什么呢? 如果是单数据中心那问题应该不大, 但是如果是多数据中心的Leader-Based架构, 那么就会存在问题, 即:

为什么呢? 很简单, 就是多数据中心相比单数据中心在网络延迟和故障上概率会更大, 会导致IDC2认为IDC1的leader节点不可用重新进行leader选举并当选Follower2节点作为leader节点, 即出现双主的“脑裂”, 这个时候不论是独享还是共享数据都会存在数据一致性, 甚至会出现数据错乱等安全性问题.
最后谈到实现线性一致性, 在我们实际应用场景中, 我们熟悉的存储中间件, 比如ZK抑或是ectd存储服务, 都通过共识算法实现了线性一致性, 他们都是基于Leader-Based的方式实现数据复制, 但牺牲了部分性能以及高可用机制来完成线性一致性.
同时我们也看到不论是ectd也好, 或者zk也好, 都是用于我们企业集群架构进行节点状态协调的协调者服务, 相比我们2C应用场景, 其并发以及读写QPS都相对较低, 因而得以在我们的实际工作落地实现.换而言之,共识算法是可以实现线性一致性, 比如Raft、Paxos以及ZAB算法.那Quorums呢? 只能说是有可能实现线性一致性, 并不能完全实现线性一致性, 这个我们后面再聊.
总结
今天先谈单主复制模型在实现线性一致性带来的问题以及挑战, 同时我们也看到了在实现线性一致性方面, 存在损失性能、降低可用性的问题, 后续当我们要实现线性一致性的时候, 首先需要明确我们的数据是用户独享还是共享层面,从不同的角度去看待我们实现线性一致性的可行性以及成本是我们在架构设计过程中不可或缺的能力.
后续几篇也将围绕一致性以及复制模型展开,可能2-3篇作为总结收尾. 最后非常感谢您的耐心阅读, 如果有收获欢迎点赞转发!!!

你好,我是疾风先生, 主要从事互联网搜广推行业, 技术栈为java/go/python, 记录并分享个人对技术的理解与思考, 欢迎关注我的公众号, 致力于做一个有深度,有广度,有故事的工程师,欢迎成长的路上有你陪伴,关注后回复greek可添加私人微信,欢迎技术互动和交流,谢谢!