第06课:分布式微服务架构体系详解——分布式存储集群的事务

前言

在微服务架构下,比较典型的一个分布式问题就是并发,并且并发访问的资源可能分布在不同的数据存储的实例上,比如,很多电商系统的下单减库存流程。订单的微服务和商品的微服务,底层的数据库是分离的。如果没有并发的事务控制,并发场景下会出现“超卖”。如果订单的 DB 和商品的 DB 是同一个库,可以使用关系型数据库支持的本地事务做处理。但分布式场景下类似的并发问题要怎么解决?这个也是分布式架构必须要处理的课题之一。为了理解并解决应用系统层面的分布式事务问题,我们需要先从最底层的存储服务的事务讲起,后续的章节基本都是围绕“分布式事务”话题展开。从底层存储到微服务系统层面,从理论算法到分布式一致性以及分布式事务的实践解决方案。为大家解开分布式事务以及分布式一致性的谜团。

本课内容先从存储服务的事务讲起,先理解事务的特性,然后对于存储服务的隔离级别的实现进行介绍。了解不同存储服务可以实现哪种隔离级别,以及不同隔离级别存在的问题。事务本身一般都是基于单库实例的。如果我们有一个存储集群,比如第03课:微服务架构下的分布式场景及方案——数据复制 中介绍的多主复制集群,就无法保证事务 ACID 特性的严格定义。文章最后会总结在分布式存储集群下,不被完全保证的事务特性。

事务

目前在单库的数据存储服务中,大多数的关系型数据库都支持本地事务,如 MySQL、Oracle、PostgreSQL,而大多数 NoSQL 数据库一般都不支持完整的事务特性。下面先看一下事务的特性,了解事务能为我们保证什么,以及应该怎么正确地认知 ACID。

认知 ACID

对于事务的 ACID 来说,很多单个数据节点的关系型数据库都能保证。在集群中,对于事务的 ACID 会弱化一些语义,下面先来简单回顾下事务的特性——ACID。

原子性(Atomicity)——其实也可以叫舍弃性

从结果上说,原子性代表着一个事务的一系列操作,要么都成功完成,要么都被舍弃掉,舍弃就是当这个事务没有执行过。从开始到完成,没有一个中间态,所以没有任何线程可以看到执行一半的结果(对,在事务的原子性语义里。中间态不可见,这个是原子性的保证,不在一致性里面)。原子性对于事务很重要,如果没有原子性,就会因为能看到中间态的结果,产生一些因看到错误的数据导致错误的逻辑写入,或者导致重复提交。

一致性(Consistency)——事务的一致性不一定是你理解的一致性

一致性这个词,很容易被混淆,前面第03课中提到了《数据复制》,提到了同步复制模型以及集群中如何保证数据的一致性。后面还会专门有一篇文章讲解分布式系统中 CAP 中的(C)一致性。一致性哈希算法……各种一致性……但是以上的一致性含义都是不同的。

事务中的一致性,跟上述含义也不同,所以一般也最容易被误解。按照教科书的说法是“在事务开始之前和事务结束以后,数据库的完整性没有被破坏”,其实这里的完整性还不如说是一种平衡性或者不变性。比较典型的例子是银行转账:A 转给 B 100 元,A 账户少了 100 元,B 账户多了 100元。数据库可以保证执行的事务语句前后,数据整体的不变性(折腾来折腾去还是这 100 元),而真正的不变性,应该是由应用程序自己控制的。如果你的代码因为 bug,把转账逻辑写成了 A 少了 100 元,B 多了 90 元,数据库并不会拦着你,也不会替你背锅。所以 ACID 可以说是 AID,C 应该是由应用程序通过原子性和隔离性来控制实现数据库完整性。

隔离性(Isolation)——跟并发息息相关的其实是我

真正对并发进行处理的,不是一致性,而是隔离性,所以说事务中最重要和复杂的就是 A + I,掌握了 AI 才是真正理解了事务。隔离从字面上理解就好,事物之间彼此是隔离的、互不影响。单纯看隔离性的定义是没有意义的,因为这里的定义是一种狭义上的概念,传统定义隔离性其实只是指最高级别的:Serializable(可串行化)的级别,也是最严格的隔离级别。虽然严格的隔离可以预防幻读(会在下文具体介绍),保证了不同事务处理数据的一致性和正确性。但不是所有的数据存储服务都实现了串行的事务隔离级别,因为实现串行隔离对性能的代价很高。

因为不同的数据库会实现不同的隔离级别,所以应用开发的同学需要知道所用的数据库存储服务默认支持什么隔离级别,最高支持到什么级别,这些级别分别可以应对哪些并发场景?选择了这种隔离级别会有什么潜在的问题,这些问题都会在下文详细介绍。

持久性(Durability)——没有绝对的持久

持久性应该是最好理解的了,数据库系统会为事务提交的数据提供持久化的存储,也就是存到磁盘上。但是也没有绝对的持久化,如第01课中介绍的 Node fail 的场景。如果请求已经发送给 DB,DB 成功接受请求了,但是持久化过程中 crash 了,这个数据就可能会丢失了。所以有的 DB,如 PostgreSQL 提供了 WAL,可以在故障后恢复数据,尽量保证持久化。

对于在数据集群中的异步复制模型,如果在复制给其他节点之前,主库先挂了,可能会丢失最新写入的数据。再悲惨点,SSD 也可能有问题,硬件跟软件一样也会出错。机房忽然停电,忽然火灾……所以应该理解持久性,而不是信任持久性。(如果应用做了日志,在 DB 崩溃恢复之后,就可以手动地补救一部分数据了。)

综上,在并发的场景,主要关注数据库可以为我们做的事情,那就是 A+I。事务的原子性、隔离性描述的一般是指对多条(行)数据的并发读写时,存储服务的处理。原子性描述了客户端的并发写请求时,数据库会让发生错误的事务的写数据全部失败而非部分失败,提供一种 All-Or-Nothing 的保证。隔离性保证了并发运行事务彼此隔离,不会互相影响,比如可以防止脏读(一个事务读到另外一个事务未提交的数据)。

隔离级别

了解了事务的特性之后,来看一下事务的隔离级别。因为不同数据库对于隔离级别的实现略有差异,隔离级别也不一定是越高越好。作为分布式架构微服务的底层存储,哪个隔离级别才更适合不同的微服务的场景,这个不仅仅是数据库工程师,也是在微服务架构体系下的工程师们需要了解的。

下面结合不同 DB 的隔离级别的实现来看一下四种隔离级别的概念和会产生的问题。

Read unCommitted 未提交读

从字面意思理解即可,Read unComitted 是最弱的隔离级别,可能会发生脏读,但是不会发生脏写。脏读也即事务 A 可以读到事务 B 还未提交的数据,如果事务 A 会根据中间未提交的数据进行一定的判定,则会产生很多错误的逻辑结果。

没有脏写问题即这种隔离级别下,写入请求只会覆盖已提交的数据。

Read Committed 提交读

概念

为了防止脏读,很多 DB 都将默认的隔离级别设置为 Read Comitted,如 PostgreSQL、Oracle 11g,并且它们都不提供未提交读的语义,所以 PostgreSQL、Oracle 都不存在脏读的情况。提交读比未提交读更加严格的是,读只能读到已经提交的事务的数据,所以不存在脏读、脏写。

实现

数据库对于 Read Committed 的实现,可以从防脏读和脏写的语义出发来理解。对于脏写,一般可以通过行锁实现。开启 Read Committed 的事务,或者更高的隔离级别事务,会默认开启行锁。当事务要往一行数据写入时,先申请行锁,一行数据的行锁只能被一个事务持有,持有期间,所有要对这行数据的写入请求都会阻塞。事务执行后会释放行锁。MySQL InnoDB 使用这种方式来防止脏写。 对于脏读,一种比较简单的方式就是对于读数据事务加锁,读取数据前后加锁以及释放锁,持锁过程中其他对于记录的写操作全部阻塞,比如 MySQL InnoDB 提供的 SELECT... FOR UPDATE 或者 (LOCK IN SHARE MODE) 。通过 Record Locks 排他锁可以锁住索引记录,这种方式可以控制脏读,但是性能也很差,并且如果处理不当,很容易造成死锁。

另外一种比较好的实现,也是被大多数 DB 采用的方式,就是数据库在写数据的事务中持有锁,并且数据库会存储事务处理前的记录值,叫“old value”,事务提交前,所有读这部分数据的请求只能从“old value”中读取。

问题

1. 不可重复读

虽然 Read Committed 已经可以满足大部分业务场景了,但是也存在着问题,即不可重复读。比如 PostgreSQL 的 Read Committed 的语义的实现就是类似上述的,通过读 old value 的方式。只不过在 PostgreSQL 中,这个 old value 叫“Snapshot”(快照)。为了理解不可重复读的问题,简单举个例子,如下图所示:

1-33

B 事务在读一条记录是 X=1,然后,A 事务修改了这条数据为 X =2,那么 B 还未提交事务时再次查询发现 X=2 了。所以对于查询复杂的,执行周期长的情况,提交读的隔离级别不能满足需求。比如很多初级程序员会犯的错,在使用类似于分页查询的功能时,另外的事务在更新这些查询的数据,然后分页查询发现越查数据越不对,所以 Read Committed 的问题之一就在于,不可重复读。什么叫不可重复读?就是在一个事务中,对于同一行(记录)数据不能读两次,这是因为这个隔离级别对于“Snapshot”(快照)的更新是控制在 Command 粒度的,两次读取(Command)结果可能不一致。

2.Lost Update 更新丢失

上述的例子只是针对 read-only 的场景,如果对于在读之后还要 Update 读出来的数据时,还会发生Lost Update。如下图所示:

2-23
B 事务读取到 X=1,并且做了一些逻辑处理,将 X 的值 +1,对于事务 B 来说,X 的值应该被更新成 X=X+1=2,而另外一个事务 A 在更新 X=10。可后来 A 发现 X=2 了,之前更新的记录仿佛丢失了,这种场景就是更新丢失。

所以 Read Committed 除了会产生两次读取不一致,还会导致读后更新数据丢失。对于 Lost Update,MySQL InnoDB 可以采用上述的 Select ... for update 的显式锁,来阻塞其他事务对相同索引数据的写入。还有像 PostgreSQL 的 Repeatable read 级别的隔离中提供一种对于丢失更新的检测机制。下面来看一下更严格一层的隔离级别:Repeatable read。

Repeatable read 可重复读

概念

Repeatable read 可以避免不可重复读的问题,并且没有脏写、脏读。可重复读从字面理解就是,可以允许在一个事务中对于同一记录多次读取,并且读取的数据结果始终一致。

实现

对于脏写的处理,实现方式和 Read Committed 类似,通过锁的方式处理写数据请求。对于脏读以及不可重复读的问题,通过快照隔离的方式实现,快照隔离可以使读、写事务互相不阻塞。不同的数据库对于快照隔离的实现方式也会有所区别,但基本都是基于 MVCC(multi-version concurrency control) 来实现。快照的本质就是保存多个版本的数据,事务的读取只从一个版本的快照中读取数据,直到事务结束。因此,在一个事务内的查询命令看到的是相同的数据,保证了在一个事务中读取相同数据记录的一致性。

Repeatable read 的快照的粒度是控制在 Transaction 级别,而 Read committed 是控制在 Command 粒度的,这也就是为什么在同一个事务中可重复读了。目前大多数的关系型数据库都实现了 Repeatable read,如 PostgreSQL、MySQL InnoDB、Oracle 等。PostgreSQL 是通过提供连续的数据快照来实现的,其会对更新的数据的旧版本数据保存,当更新数据提交后,会将原来的快照数据标记过期。这种方式会产生很多快照版本的数据,所以 PostgreSQL 也提供了快照数据的旧版本回收机制。而 MySQL InnoDB、Oracle 的实现方式是类似的,索引文件只保存最新的数据,通过 undo log(回滚段) 来管理事务,动态构建旧版本的数据。这种实现会相较“连续数据快照”节约存储空间。

虽然 Repeatable read 可以支持重复读,但一般大多数 DB(如 PostgreSQL、Oracle)的默认隔离级别都是 Read Committed,Read Committed 几乎可以满足很多应用场景。但如果应用场景是类似一些数据分析类型的只读(read-only)的后台程序,还是要使用 Repeatable read 来保证数据读取的一致。MySQL InnoDB 的默认隔离级别就是 Repeatable read。所以,使用 InnoDB 的同学们不用担心脏读,不可重复读的问题。

问题

下面来看一下,Repeatable read 隔离级别还存在的一些问题。

1. Write skew 写偏序

写偏序是在不同的事务间的一种写冲突的现象。虽然可重复读可以保证单个事务的多次读取一致,但是对于 Write Skew 的场景还是没法从可重复读的语义中处理。下面先简单示意图画一下写偏序的触发场景。假设有一个手办商店,对于某类手办(例子中叫 items,手办类型的商品 type = 'GK'),该商店要保证至少有一个手办模型作为陈列品。所以如果要售卖手办,需要查询手办(type = 'GK'的商品)的数量,然后再进行售卖。如下图所示,有两个销售员分别在前台结算,有两个用户分别要购买名称为“小薰”以及“Saber”的手办,在并发场景会发生如下情况:

3-18

两个事务分别查询总的手办个数 > 1,然后判断,即使用户买走一个手办,也还有一个可以作为陈列。所以,将用户要买的手办状态置为售出(status=false)。在事务彼此隔离的情况,最后会导致手办全被售空,违反了原有的查询条件的约束“至少有一个作为陈列”。

所以,写偏序可以理解为:不同的事务读取相同的对象(手办个数),但是更新着各自的对象(不同的手办进行售出)。其跟 Lost update、脏写的不同点是,这两者是更新同一个对象,而写偏序是更新不同的对象。Write skew 比 Lost update 更讲究触发的时机,尤其是高并发场景。所以很多同学会觉得自己写的程序在测试环境、本地环境都是好的,为什么到了生产环境,这种看上去对的逻辑的代码就会产生问题。

对于写偏序的解决方式,一种是直接将隔离级别上升为下文要介绍的 Serializable,不同的 DB 有的也会支持通过添加约束或者触发器来处理。如果 DB 不支持 Serializable 隔离级别,也可以用排他锁。以 MySQL InnoDB 为例,可以在事务中用 Select... for update 先查询,再使用其他写语句来处理。代码示例如下:

begin transaction;

select * from items where status=true and type  = 'GK' for update ;

update items set status=false where type = 'GK' 

commit;
2. Phantoms

对于都是 read-only(只读)的场景,Repeatable read 可以防止幻读,但是如果是 read-write 的情况,极端情况会导致幻读。比较典型的就是一个事务 A 读取满足条件的 row=2 行(比如查询年龄在 8-12 之间的同学数量)。然后事务 B 往里面插入了 3 条数据(忽然插入了 3 个 8~12 岁的新同学),当 A 再去读取数据的时候,会发现,数据 row=5 了。对于事务 A 来说,新增加的 3 个同学像是“幻行”。

幻读常常发生在对于一个范围数据的在同一事务中的多次查询。所以,对于幻读的情况,可以找到冲突对象,并且对其加锁。MySQL InnoDB 通过 Next-key lock 可以处理幻读。Next-key lock 可以看做是 Index-record lock 和 Gap-lock 两种锁的合并应用。除了可以给查询的索引记录加锁,还会对查询的范围加锁,并且阻塞其他相对这个范围内数据的插入、更新。

Serializable 串行化

概念

串行化的隔离级别是最高级别的隔离,也即完全满足事务中 Isolation 的语义。Serializable 的定义也可以直接从字面上理解即可,其相当于事务就像串行着执行一样,不会有以上各种隔离级别会出现的并发问题。

实现

目前,除了像传统的存储过程(已经很少有在线实时应用系统使用,逻辑代码在 DB 层,很难调试)可以支持实际的串行执行外,比较常见的实现 Serializable 方式如下。

(1)Two-phase locking(2PL,2 阶段锁)

通过前文知道,一般的脏写可以通过对写请求的记录数据加锁来实现。2PL 是采用读、写都加锁的方式来实现,2PL 的两阶段主要是指 加锁、解锁,后续文章会介绍 2PC,希望大家不要混淆这两个完全不同的概念。2PL 主要保证以下两个逻辑:

  • 事务 A 读的数据对象,如果事务 B 想要写入,则 B 必须阻塞等待事务 A 提交或者回滚之后才能执行。
  • 事务 A 在写数据对象,如果事务 B 想要读取,则 B 必须阻塞等待事务 A 提交或者回滚之后才能执行。
    MySQL InnoDB 中可以通过共享锁、排它锁来实现。在使用锁的时候,要注意锁竞争以及死锁的情况,InnoDB 提供了死锁检测机制,并且会自动放弃其中一个造成死锁的事务。

2PL 的方式从逻辑上属于悲观锁,可以在并发时保护事务数据。因为会读、写互相阻塞,所以并发的性能会很差。类似地,PostgreSQL 提供了谓词锁也可实现 2PL。

(2)Serializable Snapshot Isolation(SSI)

SSI,串行快照隔离是一种基于乐观锁的多版本并发控制技术,既可以提供顺序执行事务的保证,也能最大程度降低性能损耗。PostgreSQL 在 9.1 版本开始支持了基于 SSI 的可串行化隔离实现。

SSI 也是基于快照数据,不过是在快照隔离的基础上增加了在处理写请求时,对于串行冲突检测的算法,并且对于检测出存在数据冲突的事务采取放弃(abort)该事务的策略。SSI 的乐观体现在其会让事务继续执行,事务处理时乐观期望事情会按照顺序执行,只在事务提交的时候才会检查是否发生了冲突,并对有冲突的事务进行放弃,或者重试。只有检测出顺序执行的事务才允许最终 Commit。

SSI 只是一种语义上的概念,目前最典型的实现方式就是基于 MVCC(Multiversion Concurrency Control)。

MVCC 的实现是比较复杂的,这里可以理解两个概念:

  • 检测“读请求”的数据,会使用一个固定的 MVCC 的对象版本,这个对象版本主要基于前文所述的可重复读的对象的快照数据。
  • 检测“写请求”是否会影响前一个“读请求”的数据,如果有 write skew 的可能,则放弃事务或进行整体事务的重试。
    所以可见,MVCC 主要是会处理“写操作”的检测,当有写操作的事务要提交时,会检查是否会发生 write skew,如果检测到,则会放弃当前事务。在实际数据库实现 MVCC 时,细节会更加复杂,目前比较全面的实现方式,可以看这篇关于 Paper 《Serializable Snapshot Isolation in PostgreSQL》 的文章。

问题

使用 Serializable 隔离级别,并发的数据问题不会产生。但就像前文所述一样,隔离级别越高,对性能的影响就会越大,所以一般也不会被使用。SSI 的实现会比 2PL 性能更好。SSI 不会阻塞等待其他事务持有的锁,写不阻塞读,所以读的延迟会降低。但是 SSI 不适合于大型事务,事务执行周期越长,事务被 abort 的几率也越高。

集群的处理

上文对于事务的介绍,只是基于单个存储实例。在一个多存储实例的分布式集群环境,很难保证事务的完整语义。所以分布式环境下,要考虑的是需要实现什么程度的事务语义,在事务 ACID 特性中,我们也只要重点考虑原子性和隔离性即可。下面简单分析几个集群模型不能保证的事务语义。

主从复制

如果所有写请求都在主库,读请求都在从库。如果事务都在主库处理,则可以保证主库事务的原子性和隔离级别。如果所有的读请求都在从库读取,一主一从时,因为从库数据一致所以不会有不一致读的问题。如果是多个从库,则可能因为延迟等原因导致读数据不一致。对于这种情况可以使用第 03 课介绍的一些方案来路由请求。

多主复制

基本上无法保证,原子性和隔离性。

综上,可以确认的是,在分布式的场景下,几乎无法指望通过一个事务语句可以实现事务的完整语义。MySQL 对于本地事务有一个本地事务 ID,但是在集群环境,无法提供一个全局的事务 ID,所以分布式事务也是一个非常复杂的话题。后续的文章我们会继续展开分布式事务的话题,在了解了存储服务的限制后,需要从一些分布式一致性算法以及分布式事务理论中找答案。

小结

本文主要介绍了存储实例的事务的语义以及重点讲解了不同 DB 对于不同的隔离级别的实现。无论应用系统是否使用分布式存储集群环境,我们都应理解事务的真正语义,尤其是隔离级别。不同隔离级别可以防止的并发问题,可以通过如下表格再回顾一下:

4-12

对于分布式存储集群的事务,在一主一从的节点分布下,主写从读,是可以实现写事务的语义的。对于更加复杂的集群拓扑结构中,存储服务已经无法为我们提供更好的事务保证,所以下文会继续探讨分布式环境下的一致性问题以及分布式事务的可实践方法。

资料
《Designing Data-Intensive Applications》Author:Martin Kleppmann

A Quick Primer on Isolation Levels and Dirty Reads
PostgreSQL transaction isolation intro
Oracle consist intro
Serializable Snapshot Isolation in PostgreSQL

(全文完)