微服务架构下,比较典型的一个分布式问题就是并发。并且并发访问的资源可能分布在不同的数据存储的实例上。比如很多电商系统的下单减库存流程。订单的微服务和商品的微服务,底层的数据库是分离的。如果没有并发的事务控制,并发场景下会出现“超卖”。如果订单的DB和商品的DB是同一个库,可以使用关系型数据库支持的本地事务做处理。但分布式场景下类似的并发问题要怎么解决?这个也是分布式架构必须要处理的课题之一。为了理解并解决应用系统层面的分布式事务问题,我们需要先从最底层的存储服务的事务讲起,后续的章节基本都是围绕“分布式事务”话题展开。从底层存储到微服务系统层面,从理论算法到分布式一致性以及分布式事务的实践解决方案。为大家解开分布式事务以及分布式一致性的谜团。
本文先从存储服务的事务讲起,先理解事务的特性。然后对于存储服务的隔离级别的实现进行介绍。了解不同存储服务可以实现哪种隔离级别,以及不同隔离级别存在的问题。事务本身一般都是基于单库实例的。如果我们有一个存储集群,比如第03章介绍的多主复制集群,就无法保证事务ACID特性的严格定义。文章最后会总结分布式存储集群下,不被完全保证的事务特性。
目前单库的数据存储服务中,大多数的关系型数据库都支持本地事务,比如Mysql,Oracle,PostgreSQL。而大多数Nosql数据库一般都不支持完整的事务特性。下面先看一下事务的特性,了解事务能为我们保证什么,以及我们应该怎么正确地认知ACID。
对于事务的ACID来说,很多单个数据节点的关系型数据库都能保证。在集群中,对于事务的ACID会弱化一些语义。下面先来简单回顾下事务的特性——ACID。
从结果上说,原子性代表着一个事务的一系列操作,要么都成功完成,要么都被舍弃掉,舍弃就是当这个事务没有执行过。从开始到完成,没有一个中间态,所以没有任何线程可以看到执行一半的结果(对,在事务的原子性语义里。中间态不可见,这个是原子性的保证,不在一致性里面)。原子性对于事务很重要,如果没有原子性,就会因为能看到中间态的结果,产生一些因看到错误的数据导致错误的逻辑写入,或者导致重复提交。
一致性这个词,很容易被混淆,前面第三篇文章中提到了《数据复制》,提到了同步复制模型以及集群中如何保证数据的一致性。后面还会专门有一篇文章讲解分布式系统中CAP中的(C)一致性。一致性哈希算法……各种一致性……但是以上的一致性含义都是不同的。
事务中的一致性,跟上述含义也不同。所以一般也最容易被误解。按照教科书的说法是“在事务开始之前和事务结束以后,数据库的完整性没有被破坏”。其实这里的完整性还不如说是一种平衡性或者不变性。比较典型的例子是银行转账:A转给B 100,A账户少100,B账户多100。数据库可以保证执行的事务语句前后,数据整体的不变性(折腾来折腾去还是这100)。而真正的不变性,应该是由应用程序自己控制的。如果你的代码因为bug,把转账逻辑写成了A 少了100 ,B 多了90,数据库并不会拦着你,也不会替你背锅。所以ACID可以说是AID。C应该是由应用程序通过原子性和隔离性来控制实现数据库完整性。
真正对并发进行处理的,不是一致性,而是隔离性。所以说事务中最重要和复杂的就是 A + I。掌握了AI才是真正理解了事务。隔离从字面上理解就好,事物之间彼此隔离的,互不影响。单纯看隔离性的定义是没有意义的,因为这里的定义是一种狭义上的概念,传统定义隔离性其实只是指最高级别的:Serializable(可串行化)的级别,也是最严格的隔离级别。虽然严格的隔离可以预防幻读(会在下文具体介绍),保证了不同事务处理数据的一致性和正确性。但不是所有的数据存储服务都实现了串行的事务隔离级别,因为实现串行隔离对性能的代价很高。
因为不同的数据库会实现不同的隔离级别。所以应用开发同学需要知道所用的数据库存储服务默认支持什么隔离级别,最高支持到什么级别,这些级别分别可以应对哪些并发场景?选择了这种隔离级别会有什么潜在的问题,这些问题都会在下文详细介绍。
持久性应该是最好理解的了,数据库系统会为事务提交的数据提供持久化的存储,也就是存到磁盘上。但是也没有绝对的持久化。如我们第一篇文章介绍的Node fail的场景。如果请求已经发送给DB,DB成功接受请求了,但是持久化过程中crash了,这个数据就可能会丢失了。所以有的DB,如PostgreSQL 提供了WAL,可以在故障后恢复数据,尽量保证持久化。
对于在数据集群中的异步复制模型,如果在复制给其他节点之前,主库先挂了,可能会丢失最新写入的数据。再悲惨点,SSD也可能有问题,硬件跟软件一样也会出错。机房忽然停电,忽然火灾……所以应该理解持久性,而不是信任持久性。(如果应用做了日志,在DB崩溃恢复之后,就可以手动地补救一部分数据了。)
综上,在并发的场景,我们主要关注数据库可以为我们做的事情,那就是A+I。事务的 原子性、隔离性 描述的一般是指对多条(行)数据的并发读写时,存储服务的处理。原子性描述了客户端的并发写请求时,数据库会让发生错误的事务的写数据全部失败而非部分失败,提供一种All-Or-Nothing的保证。隔离性保证了并发运行事务彼此隔离,不会互相影响,比如可以防止脏读(一个事务读到另外一个事务未提交的数据)。
了解了事务的特性之后,来看一下事务的隔离级别。因为不同数据库对于隔离级别的实现略有差异,隔离级别也不一定是越高越好。作为分布式架构微服务的底层存储,哪个隔离级别才更适合不同的微服务的场景,这个不仅仅是数据库工程师,也是在微服务架构体系下的工程师们需要了解的。
下面结合不同DB的隔离级别的实现来看一下四种隔离级别的概念和会产生的问题。
从字面意思理解即可,Read uncomitted是最弱的隔离级别。可能会发生脏读,但是不会发生脏写。脏读也即事务A可以读到事务B还未提交的数据。如果事务A会根据中间未提交的数据进行一定的判定,则会产生很多错误的逻辑结果。
没有脏写问题即这种隔离级别下,写入请求只会覆盖已提交的数据。
为了防止脏读,很多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”(快照)。为了理解不可重复读的问题,简单举个例子,如下图所示:
B事务在读一条记录是 X=1,然后,A事务修改了这条数据为X =2,那么B还未提交事务时再次查询发现 X=2 了。所以对于查询复杂的,执行周期长的情况,提交读的隔离级别不能满足需求。比如很多初级程序员会犯的错,在使用类似于分页查询的功能时,另外的事务在更新这些查询的数据,然后分页查询发现越查数据越不对。所以Read committed的问题之一就在于,不可重复读。什么叫不可重复读?就是在一个事务中,对于同一行(记录)数据不能读两次,这是因为这个隔离级别对于“snapshot”(快照)的更新是控制在 Command 粒度的,两次读取(Command)结果可能不一致。
2.Lost update 更新丢失
上述的例子只是针对read-only的场景,如果对于在读之后还要update读出来的数据时,还会发生Lost update。如下图所示:
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可以避免不可重复读的问题,并且没有脏写,脏读。可重复读从字面理解就是,可以允许在一个事务中对于同一记录多次读取,并且读取的数据结果始终一致。
对于脏写的处理,实现方式和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”的手办,在并发场景会发生如下情况:
两个事务分别查询总的手办个数 > 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两种锁的合并应用。除了可以给查询的索引记录加锁,还会对查询的范围加锁,并且阻塞其他相对这个范围内数据的插入、更新。
串行化的隔离级别是最高级别的隔离。也即完全满足事务中Isolation的语义。Serializable的定义也可以直接从字面上理解即可,其相当于事务就像串行着执行一样,不会有以上各种隔离级别会出现的并发问题。
目前,除了像传统的存储过程(已经很少有在线实时应用系统使用,逻辑代码在DB层,很难调试)可以支持实际的串行执行外,比较常见的实现Serializable方式还有:
所以可见,MVCC主要是会处理“写操作”的检测,当有写操作的事务要提交时,会检查是否会发生 write skew。如果检测到,则会放弃当前事务。在实际数据库实现MVCC时,细节会更加复杂,目前比较全面的实现方式,可以看这篇Paper 《Serializable Snapshot Isolation in PostgreSQL》。
使用Serializable隔离级别,并发的数据问题不会产生。但就像前文所述一样,隔离级别越高,对性能的影响就会越大,所以一般也不会被使用。SSI的实现会比2PL性能更好。SSI不会阻塞等待其他事务持有的锁,写不阻塞读,所以读的延迟会降低。但是SSI不适合于大型事务,事务执行周期越长,事务被abort的几率也越高。
上文对于事务的介绍,只是基于单个存储实例。在一个多存储实例的分布式集群环境,很难保证事务的完整语义。所以分布式环境下,要考虑的是需要实现什么程度的事务语义,在事务ACID特性中,我们也只要重点考虑原子性和隔离性即可。下面简单分析几个集群模型不能保证的事务语义:
综上,可以确认的是,在分布式的场景下,几乎无法指望通过一个事务语句可以实现事务的完整语义。Mysql 对于本地事务有一个本地事务Id,但是在集群环境,无法提供一个全局的事务Id。所以分布式事务也是一个非常复杂的话题。后续的文章我们会继续展开分布式事务的话题,在了解了存储服务的限制后,我们需要从一些分布式一致性算法以及分布式事务理论中找答案。
本文主要介绍了存储实例的事务的语义以及重点讲解了不同DB对于不同的隔离级别的实现。无论应用系统是否使用分布式存储集群环境,我们都应理解事务的真正语义,尤其是隔离级别。不同隔离级别可以防止的并发问题,可以通过如下表格再回顾一下:
对于分布式存储集群的事务,在一主一从的节点分布下,主写从读,是可以实现写事务的语义的。对于更加复杂的集群拓扑结构中,存储服务已经无法为我们提供更好的事务保证,所以下文我们会继续探讨分布式环境下的一致性问题以及分布式事务的可实践方法。
《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