缘由

两个月前就想写篇文章记录下这段思考,奈何一直在忙一些切身相关的事情,没合适的时间和状态,只能抽空写点,可读性相对会低一些,留待过段时间进行部分重写。

一年多前,设计实现过一个 MySQL 的半同步(semi-sync)调度流程,无奈中间一步一步踩的坑太多,而其中的几个大坑几乎都是出在一致性方面。

同期,几个分布式数据库都是宣传的火热,号称使用了 Paxos、Raft 等算法,可以实现分布式强一致性。这不襟让我感到震惊,难道这些算法真的是处理分布式一致性的银弹?

然而,在软件工程中却是没有银弹的,那么一定是我们对某些东西的认知有误,才导致了这个理解上的偏差。本文就是笔者在理解这枚“银弹”过程中的一些思考。

两个困惑点

让我们来简单看两个 5.7 默认配置的半同步其中比较难做的点:

WAIT_AFTER_SYNC

  1. 给 ACK 加上稍大延迟,select 最新的数据。主上不可见,从上可见
    • 真实场景:一主两从,某从 1 宕机,半同步超时前,从 2 比主多可见的数据
    • 从常规角度理解,这里从上多了脏数据(不应该出现的数据)
  2. 复制通路发送数据过程中主宕机,主 recovery 后比从多数据。
    • 整个过程涉及流程偏多,网上已有较多讨论,不细述
    • 从常规角度理解,这是个丢数据的场景

分布式共识算法

PaxosRaft 是解释分布式问题中绕不开的两座大山,这里从工程的角度来回顾这两个算法的基础描述在实现分布式一致性方面的一些困难点。

CAP

常半开玩笑的说,我们的工作主要是和 CAP 做斗争,这话听起来有点吓人,不过在大部分需要分布式一致性的方向上,这么说并不算过分。

CAP 定理本身挺好理解的,一致性(Consistence)、可用性(Availability)、分区容错性(Network partitioning)三者不可兼得,只能择其一或二。并且它的名字也说明它的地位,这是一个经过理论证明的定理

然而,再一些挺有名气的交流活动中、公众号上,时而会看到令人诧异的观点:CAP 定理被打破了,现在 CAP 三者可以兼得。细细问其原因,会听到 PaxosRaft 这两个名字。那么,这是真的么,PaxosRaft 打破了已经被证明了的 CAP 定理?

显然,如果真的打破了 CAP 定理,那它们就真是银弹了,业界也就不会继续为分布式一致性问题操心了。具体这个误读是怎么来的,可以看这篇《被误用的“一致性”》。目前可以看到,维基百科上 PaxosRaft 中文页的描述自身也不一致,一个描述为“一致性算法”,一个描述为“共识算法”,可见这个误读的传播之广、影响之深。

如果有兴趣的话,可以推演这两个算法的具体工作流程。可以看见,在实现最终一直之前,整个系统一直保持 CAP 三者择其二,并且具体哪两项,会随着流程的推进,不停变化。在短短一篇博文中,想推演整个流程是不现实的,不过我们可以看一下之前遇到的两个点,在 PaxosRaft 中是怎样的。

Paxos

Paxos 算法强依赖于 2PCQuorum 这两个机制,2PC 中的坑,已经有很多文章介绍了,介绍 Quorum 的缺陷的文章相对较少,我们不妨来看一个场景。

2PC 的第一阶段不需要确定具体返回 ack 的节点,只需 ack 的数量够就行,第二阶段回复 ack 的节点也无需与第一阶段回复 ack 的节点相同,进入 2PC 第一阶段后,hold 对应读请求直至 2PC 第二阶段完成。

设想这样一个场景,一个 3 个节点(P、A1、A2)的集群,有一个写请求(W)和两个读请求(R1、R2)同时发生:

  1. 同时接收到三个请求
  2. R1 请求先读了 A1 个节点
  3. W 请求写 P、A1
  4. R1 请求读 L2,R2 读 P、A1
  5. 三个请求同时返回
  6. R1 读到的是老数据、R2 读到的是新数据

不能说这个场景有多合理,但并发的访问是一个线上系统必须支持的,网络的不稳定延迟也是分布式场景必然遇到的问题。那么,根据墨菲定律,这个场景一定会发生,并且这个场景还有继续恶化的空间。

回过头来看一下,这个场景和我们遇到的第一个场景是不是有点像。都是卡在请求的时序和顺序上,访问点不同,造成结果的不同。

Raft

Raft 是为了取代 Paxos 而生的,很多思想源于 Paxos,其中的一个重要不同在 2PC 的第二段,用 Raft log 的复制来取代,这样就大大提高了通过性。如果我们从 Follower 节点来读,会遇到和之前描述同样的情况。如果我们选择只从 Leader 节点来读,又会怎样呢? 可以确定的一点是,此时读写都集中在 Master 节点,单机系统的所有问题,这里同样会有,并且更加严重。

提到复制的话,我们不妨来看一下这样一个场景。一个 3 个节点的集群,依次发生一个写(W)请求、两个读(R1、R2)请求:

  1. W 请求过 2PC 第一阶段,更新自身数据
  2. R1 请求获取到 W 更新的数据
  3. 2PC 第二阶段开始前,Leader 节点宕机,发生切换
  4. R2 请求获取到老数据而不是 W 更新的数据

可以明显的看到,整个系统没有满足 ACID 中的 D(durability) 持久性,已经对外承诺(暴露)的数据消失了,从CAP 的角度来看整个系统的话,这就是个 AP 系统。回过头来和之前遇到的第二个难题比较,会发现,是一模一样的场景。唯一的不同是调度策略,半同步采取的策略是承认 W 请求的数据,而 Raft 会认为这个请求的数据不合法。

一致性读与成本

前面列出的场景不能算很合理,因为这些问题在工程中是有办法避免的。实现一致性并不是很难,难的是实现低成本,或者说成本可接受一致性读。以下列出的主要观点来自于《Paxos And Read Consistency》,有兴趣可以前去阅读这一系列文章,其中有很多工程方面的思考与实践。

版本化

版本化可谓是解决一致性的一大法宝,之前一篇文章中介绍的 CAS,本质上也可以理解为用版本化来解决单机场景下的一致性问题。通过必须知道老版本的内容才允许写新的内容,避免了 write after write 的情景,避免写覆盖的发生,保证一致性。

“人不能两次踏进同一条河流”,但将踏进河流的那一刻的状态,封装为一个版本,却是可以实现两次看到同一个版本。在河流每次状态变更时,都封装一个版本,只要当前版本号和我们之前看到版本一致,我们就可以认为当前看的东西是最新的。

我们要做的就是:在读数据时,读取 Follower 本地对应的版本,请求 Leader 的版本号,版本一致,则读取到的数据是最新的。这也解决了只能从 Leader 节点读数据的问题,虽然还是要读 Leader 节点,不过读的成本却是下降了不少。

我们来细化一下版本化的工程化手法。如果将版本号加在全局,那必然经常造成 Leader 节点与 Follower 节点的不一致,增加重试或者直接从 Leader 节点读的成本。如果将版本号加在每一条数据上的话,可大大减少这种不一致的场景,相应的,从 Leader 节点获取当前版本号的成本会有一定层度的增加,具体增加多少,和数据热点的几种层度有关。

既然已经版本化每一条数据了,那么我们就可以考虑在某些业务场景下做退让,部分对一致性要求没那么高的场景,直接读与 Leader 节点数据偏差不大的 Follower 节点的本地数据就好,这样可部分减少 Leader 节点的读压力。

再来细化一下写操作。我们知道 CAS 操作可以实现锁,在分布式的场景中,版本化的写操作同样可以实现分布式锁的效果。不过,锁是一个对一致性要求要求很高的操作,对整个系统 CAP 取舍的偏向有较高的要求。

串行化

串行可以理解为解决一致性问题的一个大招了,无论是单机场景还是分布式场景,遇到没有好的解决方式的难题时,往往会想到用串行来解决问题。当然,之所以被叫做大招,不仅仅是因为它的通用与强大,还因为它对性能的巨大影响。单机场景还好,没有网络方面的开销,分布式的场景中加上串行,往往能提供的性能就很难看了。

熟悉 MySQL 的同学应该知道,MySQL 中对同一数据的读与读操作可以并行,但读与写是不能并行操作的,必须串行进行。虽然通过 MVCC 可以在一定层度上允许了读与写的并行进行,但幻读的问题却是始终无法解决。为了实现完整的事务隔离,必须使用串行化(Serializable)的处理方式。

分布式系统中也一样,前面提到的版本化实现方式,与 MVCC 类似,在一些边边角角的地方无法避免不一致的产生,比如在 Leader 做切换的时候,造成的丢数据问题。

串行化之后,读写不能并行,读在写后,写不返回,读就无法进行。回顾这个过程,hold 读的过程中是牺牲了可用性的,没等到足够 ack 便不能返回数据,本质上也只是个 CP 系统。虽然这个实现的成本与代价有点高,但这确实解决了一致性的问题,也就是之前在介绍 Raft 时提到的问题。

总结

是的,没有银弹。 Paxos 和 Raft 在平时的宣传中呈现出一个很高深的姿态,但分析一下他们在工程中的几个关键点,就会发现,他们也有不少的局限性。

从实用的角度看,这两个算法确实给我们在解决分布式问题中带来了不少想法以及理论支持,绝对是研究分布式系统的人必须过的一关。但它们也只是构建分布式系统中的一环,想只通过这两个算法来完美解决分布式中的很多问题,是不现实的。

回首解决问题

借助分布式共识算法的思想,会发现开头提的两个问题也不难解决。

第一个问题中的系统,实质上是个 AP 系统,呈现出这个样子不出为奇。想要转换为 CP 系统,只要将读写全部过一下主节点就好。

第二个问题本质上是个脑列问题,想解决的话,可以选择再引入一个从,利用 Quorum 的思想,从一个较高的角度对数据做调度,回滚主的最后一条数据就好。