《Disigning Data-Intensive Applications》 Part II(3)

Some authors have claimed that general two-phase commit is too expensive to support, because of the performance or availability problems that it brings. We believe it is better to have application programmers deal with performance problems due to overuse of transactions as bottlenecks arise, rather than always coding around the lack of transactions.

—James Corbett et al., Spanner: Google’s Globally-Distributed Database (2012)

事务是伟大的抽象、好用的接口,但不是一个那么好实现的接口。有一种思路是先支持事务,再去提升性能。

对于数据系统而言,严酷的现实是,很多异常都会发生:数据库的软件和硬件都有可能失效;应用程序会崩溃;应用程序和数据库之间网络会中断等等。事务可以用来简化对这些问题的处理,我们可以在事务失败时无脑进行重试,因为事务承诺的原子性,不用担心有副作用。事务能简化对数据库访问的编程模型,这样由数据库本身来保证safety guarantees,对应用来说是透明的。

The Slippery Concept of a Transaction

1975年,第一个关系型数据库,IBM System R支持了事务,之后很多数据库都follow了这个做法,而且MySQL, PostgreSQL, Oracle, SQL Server中对事务的支持都不约而同地很相似。很多NoSQL数据库放弃了对事务的实现,不过也有像FoundationDB这样支持一些比较弱的事务语义。

这里有两种观点:一种认为大规模系统应该放弃对事务的支持来维持高性能和高可用性;另外一种认为关键的业务必须要使用事务。

The Meaning of ACID

ACID是在1983年被定义的,每个数据库系统的支持都是不同的,isolation的概念就非常模棱两可,SQL标准中有定义隔离级别,但是每个系统的实现都不太一样。作者认为,ACID已经变成了一个营销的术语了。

不同于ACID,还有一个定义叫BASE(Basically Available, Soft state, and Eventual consistency),作者认为这个定义更加隐晦,干脆叫Not ACID更容易让人理解 :)

Atomicity

好,这里有一个很重要的理解误区。在并发的领域,我们会接触到atomic这个概念,比如说atomic counter这种数据结构,下层保证了不会出现写丢失。在ACID的概念里面,并发地访问某条数据,并不是A的范畴,而是I负责的。原子性是说某条事务如果需要回滚,那么所有的更改都会被撤销。

所以作者建议这个描述成abortability,这样会更加清晰一些。

Consistency

Consistency这个词被用得太多了:副本一致性,最终一致性;一致性hash;在CAP里面,consistency等同于线性。这里作者又提出了一个新颖的观点,认为ACID里面的C不属于数据库本身的属性,而是应用的属性。

Isolation

在数据库里面,所有的并发访问的问题都属于isolation的范畴,这也是有很多分歧,百花齐放的一个领域。在经典的数据库教科书里面,isolation被描述为serializability(可串行化)的,每个事务都可以认为自己是数据库里面唯一在运行的事务,数据运行的结果就和这些事务串行化的执行是一样的。

但是,在实际使用中,serializable isolation应用得并不多,在Oracle 11g,都没有支持serializable isolation。

Durability

Durability确保了只要事务被提交了,那它所做的更改就不会丢失。在单机的数据库上,durability需要保持事务提交之后,数据会写入到持久化的设备上;在有副本的数据库上,durability意味着数据被拷贝到多个节点上。

Single-Object and Multi-Object Operations

multi-object transactions是一个比较容易产生争议的点。下面只讲关于isolation的部分,atomicity就不讲了。

这是一个很好的例子。假设有一个邮箱的应用,把未读邮件的计数存为一张单独的表,要么就会涉及到邮件的数据 和计数的数据的一致性。通常这两个数据是在一个事务里面被更新的,但是上图的例子中,user2拿到了一个不一致的状态,作者认为这是属于dirty read的范畴。要么问题来了,read commited需要强制一个事务的所有更改都在同一时刻对外可见么。

Single-object writes

对于单object的写入,作者举例说明了in-place更新的一些情况。对于单object,可以使用锁来限制访问。有些数据库会支持更加复杂的原子操作,比如说原子计数,这样就不会出现read-modify-write的困惑。单object的操作并不是我们通常意义上的事务。

The need for multi-object transactions

在很多场景中,对多个object的更新需要协同起来:

  • 在关系型数据库中,外键的更新
  • 在文档数据库中,如果有denormalized的数据存在,也会涉及到保持多条数据的同步
  • 对二级索引的更新

Handling errors and aborts

很多程序员只考虑正常路径,而忽略错综复杂的错误处理。虽然重试aborted的事务是非常简单高效的错误处理手段,但是它并不完美:

  • 如果事务成功了,但是commit的消息回包因为网络的原因丢失了,导致客户端认为没有成功
  • 如果事务失败是由于系统过载,重试可能会导致情况更加恶化
  • 对于暂时的错误(死锁、隔离级别限制等),重试是有效的;对于一些限制条件的错误,重试也是没用的
  • 如果事务有一些其它的side effects,需要保持它们和数据库内的更改一起提交或者回滚,这时候可以使用2PC
  • 如果客户端在重试的时候失败了,那所有的写入就会丢失(当然,这时候对上层也没有返回成功)

Weak Isolation Levels

很少有系统去支持serializable isolation,所以我们有必要对存在的并发问题有一个清晰的认识,然后了解如何去规避它们。作者声明下面对隔离级别的讨论都是informal的,如果需要rigorous严格的定义,可以多看些学术文献。

Read Committed

事务隔离的最基本要求:

  • When reading from the database, you will only see data that has been committed
    (no dirty reads)
  • When writing to the database, you will only overwrite data that has been committed
    (no dirty writes)

脏读这个经常被看到,脏写并不是那么经常地被提到的。

No dirty reads

脏读这个定义乍看是很清晰的,但是在实现中还是有subtle的一些区别。比如说在commit的时候,如果有多条记录需要更新到状态机中,那个这些更新需不需要原子性地生效呢?如果不是的话,在commit的时候,更新了第一条记录的时候,如果这时候被其它的读者读到了,那么这个时候算dirty read么?看起来是不算的。

No dirty writes

下面举的这个例子是dirty write的例子,会让数据库处于一个最终都不一致的状态。

这里也有一篇文章讲dirty write的,里面提到:

Other than at the serializable level, ANSI SQL isolation levels do not exclude dirty writes. However, “ANSI SQL should be modified to require P0 (protection) for all isolation levels.”

Implementing read committed

Read committed是很流行的隔离级别,是很多数据库使用的默认隔离级别。很多数据库使用row-level的锁来避免dirty write;那么是如何来避免dirty read的呢?使用同样是锁是可以避免的,但是开销比较大,所以一般来说都是在数据库中保留多个版本,如果事务没有提交,就返回比较早一些的反馈。

Snapshot Isolation and Repeatable Read

Read committed看起来相对于没有事务的系统,已经提供了很强的约束了,但是,依然会有一些并发问题,比如下图:

可以看到,Alice是一个事务中读自己两个帐户的余额,加起来是少的;这种anomaly我们称之为nonrepeatable read或者read skew。当然,这个错误只是暂时的,如果Alice重新查询一下,金额就又是对的了。但是有些场景是不能够容忍这种暂歇性的失败的:

  • 备份。如果在备份的过程中既有新的数据,又有老的数据,那么我们从备份的数据中恢复时,这个数据就是不一致的。
  • 分析类的查询和完整性检查。如果各类操作遇到上述不一致的情况,就会报错。

Snapshot isolation是一种常见的,用来解决上述问题的手段。 这种方法的思想是每一个事务都从自己的一致性的副本中读取数据,也就是只会看到在事务开始之前被提交的数据,即使这些数据在事务开始之后被其它的数据修改了。

Implementing snapshot isolation

和Read committed一样,Snapshot isolation也使用写锁来避免dirty write。但是读是不需要加锁的,“readers never block writers, and writers never block readers”,而是由数据库来维护多个版本,也被称为multiversion concurrency control(MVCC)。

如果数据库只需要提供Read committed级别的隔离,那么只需要维护两个版本:committed version和overwritten-but-not-yet-committed version。

上面这个图描述了PostgreSQL中的Snapshot isolation是如何实现的,在事务开始的时候,会拿到一个唯一递增的事务id,txid。这个事务写入数据时,都会把数据标上对应的txid。

数据库中的每一行数据都有下面两个字段:

  • created_by,描述将这条记录插入的条目
  • deleted_by,这个字段在初始化的时候为空,将事务要删除某一行的时候,并不是真正的删除,而是把这个字段的值设置为对应的txid,最多由后台的任务做gc

更新操作采用copy-on-write的方式,会被转化为一个删除操作和一个创建操作。

Visibility rules for observing a consistent snapshot

当事务中有读操作时,txid被用来决定哪些对象对这个事务是可见的,设计好这些可见性规则之后,数据库就能够提供一致性的快照给应用:

  • 在事务开始时,数据库会拿到一个目前正在执行的事务的列表,所有其它事务做的更改都会被忽略,即使这些事务后面提交了
  • 所有aborted的事务做的写入都会被忽略
  • 如果写入是比较新的事务执行的,那么会被忽略,不论这个事务是否提交
  • 其它的写入都是可见的

从另外一个角度说,一个对象需要下面两个条件都满足时才会可见:

  • 在reader所在的事务txn1开始时,创建这个对象的事务已经提交了
  • 对象没有被标记为删除;如果被某个事务txn2标记为删除了,那么txn2txn1开始时没有提交

Indexes and snapshot isolation

在MVCC中,索引实现的一种选择是让索引指向所有版本的对象,然后让索引查询来过滤那些对当前事务不可见的版本,当老版本的对象被gc掉的时候,对应的索引的条目也可以被删除掉了。在实际中,很多实现细节会决定MVCC的性能,比如,PostgreSQL会判断如果多个版本的数据可以存放在一个页中时,就不会更新索引(所以,索引中指向的最小粒度是页)。

另外一种选择是像Datomic或者LMDB一样,使用copy-on-write/append-only的B树。对于append-only的B树,每个写事务都会创建一个新的根节点,这样也不需要根据txid来过滤对象了,因为每个后续的写操作都会创建新的根节点。

Repeatable read and naming confusion

Snapshot isolation是一个非常实用的隔离级别,特别是对于只读事务而言。但是,不同的数据库在实现它的时候用了不同的名字:Oracle叫Serializable(Oracle这样叫也是有道理的,在标准中,只要没有幻读就可以被称为Serializable);PostgreSQL和MySQL叫Repeatable read。

全名混乱的原因是SQL标准中并没有Snapshot isolation这么一个概念,标准是基于1975年的System R对于隔离级别的定义,而那个时候Snapshot isolation还没有被发明。但是,标准中定义了Repeatable read,这个看起来和Snapshot isolation是非常像的。

不幸的是,SQL标准中对于隔离级别的定义是有缺陷的:含糊不清、不精确、不是一个和实现无关的标准(当然,好像很多标准都被这样诟病)。虽然很多数据库实现了Repeatable read,但是它们提供的保证有很多区别(什么区别?)。有一些文献中对Repeatable read有正式的定义,但很多实现都不能够满足这些正式的定义。最离谱的是,IBM DB2用Repeatable read来指代Serializability。

最后

事务讲到这里大概讲了一半,后面主要还是围绕着隔离级别来讲,毕竟这是最重要的一部分。现在读写的并发了解得差不多了, 下面了解一下更加复杂的写写并发。