容易混淆的CAP及ACID定义

Published: 23 Jan 2022 Category: concurrent

容易混淆的CAP和ACID概念

CAP及ACID都有一些共同的概念:例如原子性,一致性等等。但也会带来一些问题,这些术语名字虽然是一样的,但背后的含义完全不一样。CAP是分布式系统的理论引申出来的,而ACID指的是数据库系统。而分布式数据库会同时提到CAP和ACID,这就产生了很多困惑。当有人提到“不能放弃一致性”,这到底意味着什么呢?我们先来看下ACID和CAP分别的定义是什么。

ACID与CAP——回顾

ACID里的要素最早是70年代提出的,最终在1983年构成了ACID这个专用术语。这里面:

  • A指的是原子性(Atomicity)
  • C是一致性(Consistency)
  • I是隔离性(Isolation)
  • D是持久性(Durability)

CAP是Eric Brewer在2000年提出的猜想,在2002年由Seth Gilbert与Nancy Lynch完成了证明。这里面:

  • C指的是一致性(Consistency)
  • A指的是可用性(Availability)
  • P指的是分区容错性(Partition-tolerance)

概念澄清

先来看张大图。

名词 数据库 CAP 是否存在概念混淆
事务 一系列操作 未使用
持久化 一旦事务提交,它的修改就是永久性的。 未使用
一致性 数据的一致性约束(数据类型,关系等) 对CAP而言,一致性其实指的是“原子一致性(Atomic Consistency)”。它是一种一致性模型,后面会讲到 同一名词,不同概念
隔离性 事务虽然是并行执行的,但对事务A来说,其它事务要么在前,要么在后 CAP中虽然没有使用,但ACID中的这个隔离性在CAP的一致性模型中有体现 不同名词,同一概念
原子性 要么全部发生,要么都不发生 在CAP中,原子性指是一致性模型,该模型用于证明CAP的理论。 同一名词,不同概念
可用性 数据库里这个概念不太常用。即使提到,和CAP中的也不一样,比如说,数据库的可用性并不要求所有非故障节点都要响应。 分布式系统中的每一个非故障节点对其所收到的请求都必须能够响应。 同词同概念,但定义不同
分区 不常使用该概念。如果提到则和CAP中的含义一样。 节点间会产生分区,分区间所有的消息均会丢失。

下面我们再来展开一下细节部分:关于分布式数据库还有一些其它会引发其它歧义的地方。

事务(仅在ACID中)

事务指的是一组操作。任意的操作都可以读写多条数据。满足了ACID的定义,这组操作可以看作是单一操作。这并不是CAP要实现的目标,它要定义的是针对同一数据的多个操作,通常是同样的操作。

持久性(仅在ACID中)

“一旦事务成功提交,它的修改就是永久的,即使发生故障数据也不会丢失”(Once a transaction completes successfully, its changes to the state survive failures),这个定义已经很清晰了,但关于故障的描述还是不清晰的。解决办法通常是使用冗余存储:单节点上多块磁盘,和/或多节点,和/或多套部署。“数据不会丢失”(原话是“Survive“)并不意味着可用性:也就是说后续只要能够恢复数据就算满足要求。

CAP本身并不提及持久性,它里面是隐含了持久性的要求的:CAP主要讲的是分区,而不是节点故障。

CAP中用可用性

在CAP中,可用性意味着分区系统中的每一个非故障节点都能正确处理请求。许多分布式系统也声称自己是分区下可用的,但只有部分非故障节点能处理请求。这些系统并不满足CAP概念下的可用性。

CAP中的一致性和原子性

CAP中的一致性是原子一致性(Atomic Consistency)的简称。原子一致性是一种一致性模型。这个模型定义的是如何组织系统内的多个操作。具体是什么操作取决于是什么样的CAP系统。比如说,一个事务型系统里的一致性模型,事务的“提交”就是一个操作。CAP的证明是基于一个分布式共享内存模型来完成的,这个模型是由Lynch定义的,并包含了读/写/ACK。

一致性模型的选型并不简单。有多种可选方式,因为这里面有多种权衡取舍的考量:

  • 这个模型是不是简单易用。这也取决于应用程序本身:某类一致性模型对某类应用会更容易使用。
  • 内存模型的的实现效率。这通常也取决于硬件以及物理部署。

事实上ACID和CAP所采用的一致性模型都比较简单:

  • 顺序一致性,正如Lamport所定义的:“从应用程序的表现来看,就好像所有进程的内存访问都是分开并依次执行的。”
  • 原子一致性(也称线性一致性)除了满足顺序一致性外还要满足实时性的约束:“线性一致性和顺序一致性不同的是,它假设有一个对所有进程可见的全局时间。每个操作都对应一个时间段,从开始调用的时间到返回响应的时间,而操作则发生在这个时间段内的某个时间点。”

CAP所说的一致性就是原子一致性,这只是一个简称。CAP中的原子性(操作的顺序)和ACID中的原子性(全部发生或没发生)根本就不是同一个概念。

ACID中的一致性

ACID中的一致性指的是数据一致性。比如说,SQL数据库通常会实现如下的功能:

  • 这个字段不为空
  • 这个字段是一个数值
  • 这个字段引用了别的表中的另一个字段

数据库是不会允许破坏该约束的事务成功提交的。这就是ACID中的一致性的约束。它和CAP中的定义并不相同。

还需要注意的是,数据库(不管是否是SQL数据库),不会实现所有的一致性约束。这里引用下Gray和Reuter说的一段话:“底层系统没有办法检查所有的一致性约束。很多约束一开始都没有正式支持。”

再用下他们的话总结下ACID中的一致性定义就是:“需要记住的是事务范式中定义的一致性主要是句法层面的定义。”

ACID中的原子性

定义上看“要么都执行,要么都不执行”已经很直白了:比如说如下的事务,这是用伪代码模拟的两个账户间的转账交易:

begin
    val1 = read(account1)
    val2 = read(account2)
    newVal1 = val1 - 100
    newVal2 = val2 + 100
    write(account1, newVal1)
    write(account2, newVal2)
commit

原子性意味着这两个账户要么全都更新,要么全都不更新。如果账户1成功而账户2失败了,这两次写操作都会回滚。

然而,ACID中的原子性并不代表事务间是彼此隔离的。也就是说,尽管出现以下现象,你也可以声称自己是满足ACID中的原子性的: - 事务中写的值在提交前就对其它事务可见。 - 在事务中读到的值可能会被其它事务修改。如果多次读取该值,结果可能会不一样。

比方说,上述提交的事务在许多SQL数据库中可能产生出现如下结果:

  • 账户1开始时余额为1000,账户2为0
  • 两个转账事务并行运行
  • 正常应该是账户1有800,账户2是200,但结果账户却是900。

因为原子性并不等同于ACID中的隔离性:原子性并不意味着事务是隔离的。

隔离性

Gray和Reuter对隔离性的定义是“尽管事务是并发执行的,但对每个事务T而言,其它事务要么在它之前执行,要么在它之后执行。”这就和CAP一样,它也定义出了一个一致性模。有了这层定义,任何事务都是完全隔离有。很容易理解也很容易使用。

然而,隔离性很难有效地实现,数据库系统通常都要放松某些约束条件。结果就是ACID中的隔离性产生了诸多级别,包括“串行化(serializable)”,“可重复读(repeatable read)”,“读提交(read committed)”,“读未提交(read uncommitted)”。

默认的级别一般都是“读提交”:事务只能读到已经提交的数据。

这个看起来不难理解,不过也有几点要关注下:

  • 正如Hellerstein、Stonebraker和Hamilton在论文中所提到的:”这两种定义(Gray早期的版本和ANSI官方定义的版本)都假设使用了锁进行并发控制,而不是用乐观锁或多版本控制。这说明这个定义在语义上是有问题的。“
  • 对于一个给定的隔离级别而言,所有数据库的表现都不尽相同。这点Martin Kleppmann在他的文章中也提到了。
  • 隔离性不仅影响了功能正确性,还会影响到技术层面的正确性:大多数据库都用到了锁。并发执行会产生死锁“:各个事务间非预期的依赖或不受控制的依赖,会导致所有事务都会需要其它事务释放它所持有的资源。这种情况下,数据库引擎会停掉其中的一个事务,尽管这个事务从功能上或者语义上来看都是正确的可被执行的。

现在数据库的使用者们需要理解更多关于数据库一致性模型的知识:他们需要理解自己所使用的数据库引擎究竟是如何实现的。

我们再来看下上述的例子。如果一个数据库默认实现的是”读提交“,结果可能会是错误的,最终账户中会多产生了钱。这种情况可以通过显式使用锁来解决。使用了锁的伪代码会是这样的:

begin
    val1 = readAndLock(account1)
    val2 = readAndLock(account2)
    newVal1 = val1 - 100
    newVal2 = val2 + 100
    write(account1, newVal1)
    write(account2, newVal2)
commit // release all locks

正如Java并发编程那样,使用了锁之后会带来一系列的复杂问题。而数据库本身也增加了锁使用的复杂性,比如锁的范围很广(页锁,表锁。。),锁还可以由数据库引擎动态升级。有的事务只是为了读取不可变数据,可能会因为死锁或性能原因而采用“读未提交”的隔离级别。如果在系统运行时什么地方不小心修改了这些不可变数据,则可能会引发复杂的问题。

可以看到CAP中的C和ACID中的I是非常类似的。但CAP作为一个定理,可以严格遵循这个一致性模型,而数据库实现由于性能的要求,不得不增加了多种级别的参数化配置,使用者不得不去理解数据库的隔离机制最终是如何实现的。

结论

ACID中的4个定义,有三个的含义和CAP中的对应的概念是不一样的,这会让人混淆不清。并且这种混淆不仅限于字面上的重复概念,还需要深入实现并发应用程序的实现细节来理解其中的差异。实际上这就是让不少人转向“NoSQL“的原因之一。

英文原文链接