分布式系统中一个重要的问题就是数据复制,数据复制一般是为了增强系统的可用性或提高性能。而实现数据复制的一个主要难题就是保持各个副本的一致性。本文首先讨论数据复制的场景中一致性模型如此重要的原因,然后讨论一致性模型的含义,最后分析常用的一致性模型。
为什么需要一致性模型
数据复制主要的目的有两个:可用性和性能。首先数据复制可以提高系统的可用性。在保持多副本的情况,有一个副本不可用,系统切换到其他副本就会恢复。常用的 MySQL 主备同步方案就是一个典型的例子。另一方面,数据复制能够提供系统的性能。当分布式系统需要在服务器数量和地理区域上进行扩展时,数据复制是一个相当重要的手段。有了多个数据副本,就能将请求分流;在多个区域提供服务时,也能通过就近原则提高客户端访问数据的效率。常用的 CDN 技术就是一个典型的例子。
但是数据复制是要付出代价的。数据复制带来了多副本数据一致性的问题。一个副本的数据更新之后,其他副本必须要保持同步,否则数据不一致就可能导致业务出现问题。因此,每次更新数据对所有副本进行修改的时间以及方式决定了复制代价的大小。全局同步与性能实际上是矛盾的,而为了提高性能,往往会采用放宽一致性要求的方法。因此,我们需要用一致性模型来理解和推理在分布式系统中数据复制需要考虑的问题和基本假设。
什么是一致性模型
首先我们要定义一下一致性模型的术语:
- 数据存储:在分布式系统中指分布式共享数据库、分布式文件系统等。
- 读写操作:更改数据的操作称为写操作(包括新增、修改、删除),其他操作称为读操作。
下面是一致性模型的定义:
一致性模型本质上是进程与数据存储的约定:如果进程遵循某些规则,那么进程对数据的读写操作都是可预期的。
上面的定义可能比较抽象,我们用常见的强一致性模型来通俗的解释一下:在线性一致性模型中,进程对一个数据项的读操作,它期待数据存储返回的是该数据在最后一次写操作之后的结果。这在单机系统里面很容易实现,在 MySQL 中只要使用加锁读的方式就能保证读取到数据在最后一次写操作之后的结果。但在分布式系统中,因为没有全局时钟,导致要精确定义哪次写操作是最后一次写操作是非常困难的事情,因此产生了一系列的一致性模型。每种模型都有效限制了在对一个数据项执行读操作所应该返回的值。举个例子:假设记录值 X 在节点 M 和 N 上都有副本,当客户端 A 修改了副本 M 上 X 的值,一段时间之后,客户端 B 从 N 上读取 X 的值,此时一致性模型会决定客户端 B 是否能够读取到 A 写入的值。
一致性模型主要可以分为两类:能够保证所有进程对数据的读写顺序都保持一致的一致性模型称为强一致性模型,而不能保证的一致性模型称为弱一致性模型。
强一致性模型
线性一致性(Linearizable Consistency)
线性一致性也叫严格一致性(Strict Consistency)或者原子一致性(Atomic Consistency),它的条件是:
- 任何一次读都能读取到某个数据最近的一次写的数据。
- 所有进程看到的操作顺序都跟全局时钟下的顺序一致。
线性一致性是对一致性要求最高的一致性模型,就现有技术是不可能实现的。因为它要求所有操作都实时同步,在分布式系统中要做到全局完全一致时钟现有技术是做不到的。首先通信是必然有延迟的,一旦有延迟,时钟的同步就没法做到一致。当然不排除以后新的技术能够做到,但目前而言线性一致性是无法实现的。
顺序一致性(Sequential Consistency)
顺序一致性是 Lamport(1979)在解决多处理器系统共享存储器时首次提出来的。参考我之前写的文章《分布式系统:Lamport 逻辑时钟》。它的条件是:
- 任何一次读写操作都是按照某种特定的顺序。
- 所有进程看到的读写操作顺序都保持一致。
首先我们先来分析一下线性一致性和顺序一致性的相同点在哪里。他们都能够保证所有进程对数据的读写顺序保持一致。线性一致性的实现很简单,就按照全局时钟(可以简单理解为物理时钟)为参考系,所有进程都按照全局时钟的时间戳来区分事件的先后,那么必然所有进程看到的数据读写操作顺序一定是一样的,因为它们的参考系是一样的。而顺序一致性使用的是逻辑时钟来作为分布式系统中的全局时钟,进而所有进程也有了一个统一的参考系对读写操作进行排序,因此所有进程看到的数据读写操作顺序也是一样的。
那么线性一致性和顺序一致性的区别在哪里呢?通过上面的分析可以发现,顺序一致性虽然通过逻辑时钟保证所有进程保持一致的读写操作顺序,但这些读写操作的顺序跟实际上发生的顺序并不一定一致。而线性一致性是严格保证跟实际发生的顺序一致的。
弱一致性模型
因果一致性(Causal Consistency)
因果一致性是一种弱化的顺序一致性模型,因为它将具有潜在因果关系的事件和没有因果关系的事件区分开了。那么什么是因果关系?如果事件 B 是由事件 A 引起的或者受事件 A 的影响,那么这两个事件就具有因果关系。
举个分布式数据库的示例,假设进程 P1 对数据项 x 进行了写操作,然后进程 P2 先读取了 x,然后对 y 进行了写操作,那么对 x 的读操作和对 y 的写操作就具有潜在的因果关系,因为 y 的计算可能依赖于 P2 读取到 x 的值(也就是 P1 写的值)。
另一方面,如果两个进程同时对两个不同的数据项进行写操作,那么这两个事件就不具备因果关系。无因果关系的操作称为并发操作。这里只是简单陈述了一下,深入的分析见我之前写的文章《分布式系统:向量时钟》。
因果一致性的条件包括:
- 所有进程必须以相同的顺序看到具有因果关系的读写操作。
- 不同进程可以以不同的顺序看到并发的读写操作。
下面我们来分析一下为什么说因果一致性是一种弱化的顺序一致性模型。顺序一致性虽然不保证事件发生的顺序跟实际发生的保持一致,但是它能够保证所有进程看到的读写操作顺序是一样的。而因果一致性更进一步弱化了顺序一致性中对读写操作顺序的约束,仅保证有因果关系的读写操作有序,没有因果关系的读写操作(并发事件)则不做保证。也就是说如果是无因果关系的数据操作不同进程看到的值是有可能是不一样,而有因果关系的数据操作不同进程看到的值保证是一样的。
最终一致性(Eventual Consistency)
最终一致性是更加弱化的一致性模型,因果一致性起码还保证了有因果关系的数据不同进程读取到的值保证是一样的,而最终一致性只保证所有副本的数据最终在某个时刻会保持一致。
从某种意义上讲,最终一致性保证的数据在某个时刻会最终保持一致就像是在说:“人总有一天会死”一样。实际上我们更加关心的是:
- “最终”到底是多久?通常来说,实际运行的系统需要能够保证提供一个有下限的时间范围。
- 多副本之间对数据更新采用什么样的策略?一段时间内可能数据可能多次更新,到底以哪个数据为准?一个常用的数据更新策略就是以时间戳最新的数据为准。
由于最终一致性对数据一致性的要求比较低,在对性能要求高的场景中是经常使用的一致性模型。
以客户端为中心的一致性(Client-centric Consistency)
前面我们讨论的一致性模型都是针对数据存储的多副本之间如何做到一致性,考虑这么一种场景:在最终一致性的模型中,如果客户端在数据不同步的时间窗口内访问不同的副本的同一个数据,会出现读取同一个数据却得到不同的值的情况。为了解决这个问题,有人提出了以客户端为中心的一致性模型。以客户端为中心的一致性为单一客户端提供一致性保证,保证该客户端对数据存储的访问的一致性,但是它不为不同客户端的并发访问提供任何一致性保证。
举个例子:客户端 A 在副本 M 上读取 x 的最新值为 1,假设副本 M 挂了,客户端 A 连接到副本 N 上,此时副本 N 上面的 x 值为旧版本的 0,那么一致性模型会保证客户端 A 读取到的 x 的值为 1,而不是旧版本的 0。一种可行的方案就是给数据 x 加版本标记,同时客户端 A 会缓存 x 的值,通过比较版本来识别数据的新旧,保证客户端不会读取到旧的值。
以客户端为中心的一致性包含了四种子模型:
- 单调读一致性(Monotonic-read Consistency):如果一个进程读取数据项 x 的值,那么该进程对于 x 后续的所有读操作要么读取到第一次读取的值要么读取到更新的值。即保证客户端不会读取到旧值。
- 单调写一致性(Monotonic-write Consistency):一个进程对数据项 x 的写操作必须在该进程对 x 执行任何后续写操作之前完成。即保证客户端的写操作是串行的。
- 读写一致性(Read-your-writes Consistency):一个进程对数据项 x 执行一次写操作的结果总是会被该进程对 x 执行的后续读操作看见。即保证客户端能读到自己最新写入的值。
- 写读一致性(Writes-follow-reads Consistency):同一个进程对数据项 x 执行的读操作之后的写操作,保证发生在与 x 读取值相同或比之更新的值上。即保证客户端对一个数据项的写操作是基于该客户端最新读取的值。
总结
数据复制导致了一致性的问题,为了保持副本的一致性可能会严重地影响性能,唯一的解决办法就是放松一致性的要求。通过一致性模型我们可以理解和推理在分布式系统中数据复制需要考虑的问题和基本假设,便于结合具体的业务场景做权衡。每种模型都有效地限制了对一个数据项执行度操作应返回的值。通常来说限制越少的模型越容易应用,但一致性的保证就越弱。
参考资料
《分布式系统原理与范型》