事务与一致性:刚性or柔性?

在高并发场景下,分布式储存和处理已经是常用手段。但分布式的结构势必会带来“不一致”的麻烦问题,而事务正是解决这一问题而引入的一种概念和方案。我们常把它当做并发操作的基本单位。

从MySQL事务说起(刚性事务)

提到事务,脑海里第一个反应当然是数据库里的Transaction了。紧接着就是事务的四大特性:ACID (原子性,一致性,隔离性,持久性),所以我们先从这四大特性说起。

原子性

原子性是我们对事务最直观的理解:事务就是一系列的操作,要么全部都执行,要么全部都不执行。

想要保证事务的原子性,就意味着需要在操作发生异常时,对该事务所有之前执行过的操作进行回滚

在MySQL中,这个回滚是通过回滚日志(Undo Log)实现的。简单的说,回滚日志就是记录了你所有操作的逆操作,在需要回滚时,就把这个事务的回滚日志里的操作全部执行一次。

比如你的事务里每一个create其实都对应了一个效果跟其相反的delete语句,他们被记录在回滚日志里,当事务发生异常触发ROLLBACK时,就按照日志逻辑地将回滚日志里的操作全部执行,从而达到“撤销”操作的效果。

事务的状态

宏观上看事务是具有原子性的,是一个密不可分的最小单位。但是它是有几种不同的状态的:Active,Commited,Failed,它要么在执行中,要么执行成功,要么就失败。

深入事务的内部,他就变为一系列操作的集合,不再具有原子性了,包括了很多的中间状态,比如部分提交,参考如下的事务状态图:

  • Active 事务的初始状态,表示正在执行
  • Partially Commited 部分执行,或者说在最后一条语句执行后
  • Failed 发现操作异常,事务无法继续执行后
  • Commited 成功执行整个事务
  • Aborted 事务被回滚,数据库恢复到执行前状态后

并行事务的原子性

正常情况下事务都是并行执行的,这就会出现很多复杂的新问题。

首先是事务依赖,举一个直观的例子来说明:

假设事务T1对数据A进行了读写,然后(T1还没有执行完)在同时,T2读取了数据A,然后成功提交了事务。这时候T1发生了异常,进行回滚。我们可以看到事务T2是依赖于T1所修改的数据的,如果要保证T1的原子性,那就需要同时对T2进行回滚,但是它已经被提交了,我们没法再回滚了,这种问题被称为“不可恢复安排”。

为了避免这种情况的出现,在出现事务的依赖时,必须遵循以下的原则:

如果事务T2依赖于事务T2,那么T1必须在T2提交之前完成提交操作。

接下来我们还不得不面对级联回滚,也就是出现了多个事务都依赖于事务A的时候,如果A回滚,那么这些事务必须也一并回滚。这会导致大量的工作撤回,至于这件事情如何处理才合适,我们会在后面介绍。

持久性

这是理解起来相对简单的一个特性,持久性就是指,事务一旦被提交,那么数据一定会被写入到数据库中并持久储存起来。

另外,当事务被提交后就无法再回滚,如果想要撤销一个已经提交的事务,那就只能执行一个效果与其相反的事务,这也是持久性的一种体现。关于这点,MySQL依然是通过日志实现的。

重做日志

重做日志由两部分组成,一是内存中的重做日志缓冲区,另一个是磁盘上的重做日志文件。

这个缓冲区和日志的关系跟我们日常IO中使用的buffer是差不多的:当我们在事务中尝试对数据进行更改时,首先将数据从磁盘读入内存,更新内存缓存的数据,然后会生成一条重做日志(本次修改的逆操作)缓存,放在重做日志缓冲区中。当事务真正提交时,再将刚才缓冲区中的日志写入重做日志中做持久化保存,最后再把内存中的数据变动同步到磁盘上。

上面这个流程用图片描述如下:

再具体一点,InnoDB中,重做日志都是以512B的块形式储存的,因为磁盘的扇取也是512B,所以重做日志的写入就保证了原子性,即便机器断电也不会出现日志仅仅写入一半而留下脏数据的情况。

另外需要注意的一点是,在原子性一节中提到的回滚日志也是需要持久化储存的,因此他们也会创建对应的重做日志,在发生错误后,数据库重启时,会从重做日志中找出未被更新到的数据库磁盘上的日志,重新执行来满足事务的持久性。

*事务日志

在数据库系统中,事务的原子性和一致性是由事务日志实现的,在具体的实现上,使用的就是之前提到的回滚日志和重做日志,它们保证了两点:

  • 发生错误或者需要回滚的事务能够成功回滚(原子性)
  • 事务提交后,数据还没来得及写入磁盘就宕机时,重启后能够成功恢复数据(一致性)

在数据库中这两者往往一起工作,因此我们可以把他们看作一个整体。一条事务日志的内容可以抽象成下面这样:

一条记录同时保存了对应数据修改前后的值,就可以非常方便的实现回滚和重做两种功能。

隔离性

事务的隔离性会跟并发等相关概念联系的非常密切,因为它主要就是为了保证并行事务处理能够达到“互不干扰”的效果。

我们在一致性中讨论过事务在并发情况下执行时,可能发生的一系列问题:虽然单个事务执行并没有错误,但是它的执行可能会牵连到其他事务的执行,最终导致数据库的整体一致性出现偏差。

谈到这里我们就要看看事务之间的互相干扰都有哪些层级,也就是我们数据库中非常重要的概念:

事务的隔离级别

事务的隔离级别,其实是数据库对数据隔离性能的一种约束,选择不同的隔离级别会影响数据一致性的程度,同时也会影响数据库的操作性能。

标准SQL中定义了以下4种隔离级别:

  • 未提交读
使用查询语句不会加锁,可能会读到未提交的行(脏读)
  • 提交读
只对记录加记录锁,而不会在记录之间增加间隙锁,所以允许新的记录被插入到被锁定记录附近,在多次使用查询语句时,可能会得到不同的结果(不可重复读)
  • 可重复读
多次读取同一范围的数据会返回第一次查询的快照,不会返回不同的数据行,但是可能发生幻读
	幻读 : 是指当事务不是独立执行时发生的一种现象,例如第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。 同时,第二个事务也修改这个表中的数据,这种修改是向表中插入一行新数据。那么,以后就会发生操作第一个事务的用户发现表中还有没有修改的数据行,就好象 发生了幻觉一样。
  • 串行化
隐式地将全部的查询语句都加上了共享锁。

从上到下一致性逐渐增强,但是数据库的读写性能也逐渐变差

大部分数据库中使用提交读作为默认的隔离级别,这是出于性能和一致性的平衡,而MySQL中则默认采用可重复读作为配置。

对于开发者而言,不必去了解每个隔离级别具体的实现,但要能够根据不同的场景选择最合适的隔离级别。

隔离的实现

隔离的实现说到底其实是并发控制,因此不同隔离级别的实现,其实就是采用了不同的并发控制机制。

1.锁

这个自然是最简单的,也是相当常用的并发控制机制了。

不过在一个事务中,自然是不可能把整个数据库都加锁的,而是只对要访问的数据加锁(具体的粒度有行、表等)。而这些资源锁也是理所当然地分为共享锁(读锁)和互斥锁(写锁)两种。

读锁可以保证操作并发执行而不受影响,写锁则保证了更新数据库时不会受到其他事务的干扰。

2.时间戳

用时间戳实现隔离性,需要为记录配置两个字段

  • 读时间戳:用于保存所有访问该记录的事务中的最大时间戳(最后读取时间)
  • 写时间戳:用于保存将记录改到当前值的事务的时间戳(最后修改时间)

这样的事务在并行执行时,用的是乐观锁,先任由事务对数据进行修改,在写回去的时候在判断记录的时间戳有没有修改,如果没有被修改,就写入,否则,就生成一个新的时间戳并再次尝试更新数据。

PostgreSQL就使用了这种思想来控制事务。

3.多版本和快照隔离

通过维护多个版本的数据,数据库便可以允许事务并发执行遇到互斥锁时,转而读取旧版本的数据快照。这样就能显著地提升读取的性能。我们简称这一手段为MVCC。

级联回滚

之前在讨论原子性问题时,讨论过级联回滚的问题,那是因为事务之间产生了依赖而导致的。因此我们将事务隔离之后,就不会再产生需要级联回滚的场景了。

比如一个事务写入了A数据,那么这时候是需要加共享锁的,因此其它的事务无法读取A,当事务A回滚时不用考虑对其它事务的影响,因为其它的事务并不可能读到数据。

一致性

好了,这时候我们终于回归到了本文所想讨论的主题上来。“一致性”在数据库领域有两个意义,一个是ACID中的C,另一个是CAP的C,前者是我们经常讨论的,也是普遍意义上的数据库事务一致性,而后一个将是之后会展开讨论的,有关分布式事务的一致性。

ACID

事务的一致性定义基本可以理解为是事务对数据完整性约束的遵循。这些约束可能包括主键约束、外键约束或是一些用户自定义约束。事务执行的前后都是合法的数据状态,不会违背任何的数据完整性,这就是“一致”的意思。

当然这个含义中也隐含着对开发者的要求,就是不能写出错误的事务逻辑,比如银行的转账不能只加钱不减钱,这是应用层面的一致性要求。

CAP

CAP定理是分布式系统理论的基础。CAP告诉我们,对于一个分布式系统(或者由于网络隔离等原因产生的分区系统),它无法同时保证一致性、可用性和分区容忍性,而是必须要舍弃其中的一个。

p.s. 对于分布式系统一般我们是不可能舍弃分区容忍性的(因为分区的情况是无法避免的),所以一般是根据业务,在一致性和可用性中二选一。

这里说的一致性,具体在数据库上,就是分布式数据库中,每一个节点对于同一个数据必须有相同的拷贝(每个库里的同一个数据内容必须是一致的)。

分布式事务

现在我们来看一看,当数据分布式储存后,操作所带来的一些问题。

众所周知,现在大型服务出于性能和容灾的考虑,都会使用分布式的服务架构,这意味着一个服务会有多个数据库,分开储存不同的数据,这种情况下就很容易出现数据不一致的问题了,一个最简单的例子:

A要B给转100元。但是A和B的记录被分在了不同的数据库实例上,如果这时候执行的某个事务中途出现了bug,如果没有一个好的处理方式,回滚将会是一件难以面对的事情。

所以我们可以看到,在分布式环境下,事务的设计方案变得更加复杂,也更加重要了,下面我们来谈谈分布式事务的一些常见实现方式:

两阶段提交(2PC)

原理

两阶段提交是一种提交协议,在这种协议下,事务的实现被拆分成了几个不同的模块,一般分为协调器和若干的事务执行者,如下图:

在分布式系统中,每个节点虽然可以知道自己操作是否成功,但是却无法得知其他节点上操作是否成功,因此当一个事务跨越了多个节点的时候,就需要一个协调者,能够掌控到所有节点的执行情况,进而保证事务的ACID特性。

现在我们来分析2PC协议条件下,转账问题是如何被解决的(我们假设A是你的支付宝余额,B是你的余额宝)。

  1. A发起请求到协调器,协调器开始工作
  2. 准备凭证
    • 协调器将prepare信息写到本地日志,这就是回滚日志了。
    • 向所有的参与者发起prepare信息,当然对于不同的执行者,这个prepare信息是不同的,这取决于他们的数据实例上要发生什么样的变动,比如这个例子中,A得到的prepare消息是通知支付宝余额数据库扣除100元,而B得到的prepare消息是通知余额宝数据库增加100元。
  3. 执行者收到prepare消息之后,执行本机的具体事务,但不会commit,如果成功则向协调者发送yes回执,否则发送no
  4. 协调者判断收集到的所有回执,如果均为yes,就向所有的执行者发送commit消息,执行器收到该消息后就会正式执行提交。反之,如果收到任何一个no,就向所有的实行者发送abort消息,执行器收到后会放弃提交并回滚相应的改动。

协调器上保存的回滚日志,可以用于某个执行器失败后恢复的工作的场景,此时执行器可能会再次向协调器发送回执来确定自己的执行状态。

问题

2PC实现的思路倒是很简单,不过这个思路中存在着几个非常严重的问题,因此几乎不被使用:

  1. 涉及多次节点间的通信,假设网络延迟比较高,通信时长基本是不可忍受的
  2. 事务时间变长了,也意味着资源上锁的时间变长了,性能大打折扣
  3. 如果参与者多了,协调器的工作效率会下降,而整个流程也变得复杂起来

其实分布式事务的种种实现方案基本都借鉴了2PC的思路,但很快人们就发现一个问题,在分布式的系统中,如果仍然采用事务模型来进行数据的修改,性能将受到不可避免的影响,这在高并发的场景下是不能接受的。

最终一致性(柔性事务)

刚才我们讲了分布式事务在高并发场景下的败北,其实根据CAP原则我们很容易明白,想要保证可用性的同时保证一致性是不可能的,于是现在大多数的分布式系统中都对一致性做出了妥协:

我们不追求整个操作过程中每一时刻的一致性(强一致性),转而追求最终结果的一致性(最终一致性)。

也即是说,在整个事务执行的流程中,我们是可以接受的短暂的数据不一致的,只要最后的结果没问题就行。

至此,我们对于事务的研究,从满足ACID的刚性事务,拓展到BASE(基本可用,软状态,最终一致性)的柔性事务。

BASE

BASE原则是在分布式场景下,为了保证高可用性,而做出的一种“妥协性”思想。总的来说是允许局部的错误和故障,但要保证全局的稳定。事实上当前大多数的分布式系统,或者说大多数的大型系统里,都在运用这种思想了。

在展开柔性事务之前,我们先来补充一些基础知识。

重试与幂等

在接下来讲到的各种思路中,我们都无法避免一个问题,那就是接口调用或者说操作的失败,分布式情况下系统的状态往往不如单机条件下确定,所以可能经常需要重试,而不是一失败就回滚。

因此我们必须尽可能的避免重试对系统稳定性和性能的影响,于是有了幂等这个概念:

幂等

  • 数学定义:f(x) = f(f(x))的性质
  • 编程定义:对同一个系统,使用同样的条件,一次请求和重复的多次请求对系统资源的影响是一致的

然后我们需要探讨一下保证幂等常用的思路,我们以微博点赞这个操作为实际例子来看一下(点赞是不能重复的):

  1. MVCC
数据更新时需要比较持有数据的版本号,版本号不一致的话是无法操作成功的。
每个版本只有一次执行成功的机会,一旦失败了就要重新获取版本号。
这样每次点赞操作都对应着一个不同的版本号,即便失败重复尝试,也不会出现点赞数错误增加或减少的情况。
  1. 去重
这个主要依赖数据库的索引唯一性(键),以点赞操作为例,可以对[`user_id`,`weibo_id`]这个组合做一张“点赞操作表”,如果成功点赞,就添加一条新记录。
如果出现了错误的重试,因为表的索引是唯一的,已经有了记录自后就不会再次插入,自然也就不会出现错误的情况了。

异步确保

2PC的处理过程中一个很大的问题是,存在大量的同步等待,这便意味着操作之间的强耦合,一旦发生了失败或是超时,造成的影响往往是灾难性的。但是分布式情况下,超时和失败又是很可能出现的情况,所以2PC手段没法保证系统的可用性。

那么怎么优化呢?可以将操作解耦,使用消息队列(或者某种可靠的通信机制)来连接不同的实例上的操作。这样的通信机制使操作异步化,于是我们还需要一个能够确保消息执行成功的确保机制,以上两点的综合就是现在最常用的柔性事务解决方案,我们暂且叫它“异步确保”(因为这种方案并非有一个统一的叫法),核心思路其实就是:用消息队列保证最终一致性。

下面我们一步一步深入,了解这种方案的基本思想和流程。

问题

我们依然使用经典的转账问题来展开讨论:A要向B转100元,但是A和B的账户在不同的实例上存储。

用异步确保的思想,操作的流程应该如此处理:

  1. A所在的实例扣除A账户100元
  2. 向B所在的实例发送操作消息,通知它给B的账户增加100元

这是一个很理想的情况,其实我们有很多的问题要处理。

首先是原子性,其实很容易发现,无论顺序如何,如果1和2这两个操作有任何一个失败了,那另一个操作也必然变得没有意义,所以必须保证1和2这两个操作的整体原子性。

这里很多人会想,直接利用刚性事务的ACID特性,把1和2放在同一个事务里不就ok了。但这是不可能的,原因如下:

  • 网络的2将军问题:发送消息如果失败了,发送方并没有办法知道,是接收方没收到消息,还是接收方返回响应的时候出现了故障,其实已经收到了?
  • 在DB事务里插入网络操作,如果出现延迟,会导致事务执行时间变长,对DB性能影响极大,严重的话可能block整个DB。

所以事情没那么简单,所以在我们得做不少额外的工作才能解决这个问题,下面是现在常用的解决思路:消息表。

先说生产方(A的实例)

  1. 生产方添加一张消息表,用于记录发送的消息以及消息的回执等内容。
  2. 生产者在向消费者发送业务操作数据时,同时也要在消息表里增加一个消息记录,这两个都是对生产者DB的操作,我们要把它们放在同一个事务里来保证一致性。举个例子,转账问题在A端上这个操作的sql就是这样的(有点随意,会意即可):
```
begin transaction;
update account set amount = ($amount - 100) where user = A;
insert into message values('b','account','-100');
end transaction;
```
  1. 对于这张消息表,我们需要一个维护者,它的职责是,不断地把表中未发送的消息放入消息队列,另外检测消息的执行是否超时或失败,如果遇到这种异常情况,就进行重试。注意:允许消息重复,但是不能丢失,顺序也不会打乱。

再说消费方(B的实例)

  1. 消费方的接口(我们称为下游接口),必须实现幂等。这是因为生产方可能会发来很多的重试消息,我们必须保证重试操作不会对系统产生不良影响。如果之前说的幂等手段不适用,可以简单的为消费方准备一个判重表,利用判重表的Insert操作来实现幂等(如果这么做,请注意在业务中保证消费操作和Insert判重表操作的原子性)。
  2. 消费方完成操作后,利用消息队列向生产方发送确认消息就ok。

可以看到这个实现方案对于业务的生产方来说,需要维护很多额外的操作,尤其是需要设计维护消息表,可能还要做后台任务处理等,某种程度上这会增加业务端不必要的逻辑耦合,以及性能负担。

简要工作流程如下图所示:

事务消息

正如上文所说,异步确保的思路中,大多数操作其实与业务无关,可以封装到消息队列中去。于是产生了“事务消息”这一概念,也就衍生了很多能够很好的支持分布式事务消息相关操作的消息队列或者中间件,如RocketMQ和Notify。

我们来看看事务消息是如何优化和整合异步确保的逻辑的。

首先,把消息发送分成了2个阶段:准备和确认阶段,于是生产方步骤变为如下3步:

  1. 发送prepared消息给MQ
  2. 执行本地事务
  3. 根据本地事务执行结果,确认或者取消prepared消息

这里有一个问题,就是如果1和2失败了,还是很容易回滚和取消的,但是第三步失败或者超时了,要怎么做呢?

以RocketMQ为例,MQ会定期地扫描所有的prepared消息,询问发送方,到底是要确认发送这条消息,还是要取消这条消息?这点底层是通过让生产方实现一个约定好的Check接口来实现的,有点像订阅者模式。

我们可以看出来,异步回调中,扫描消息表,确认或重发消息这个步骤被消息队列实现了,减少了业务方开发的难度。

对于消费方,事务消息支持重试的特性,也就是说不必生产者去主动发起重试消息,消息队列可以自动帮你重试这些操作,可以说是非常解放生产力了。

如果有极端情况,比如消费端异常,无论怎么重试都失败,是否要回滚呢?其实最好的办法就是人工介入,人工去处理这种概率极低的case,比开发一个高复杂的自动回滚系统要可靠的多,也更简单。

事务补偿(TCC)

除了比较常用的异步确保,我们再介绍一种常见的实现柔性事务的思路,称为事务补偿。

总结之前的内容,我们不难发现,分布式事务的难点在于,一方执行事务成功之后,无法确定其他参与方对应的事务是否能够成功(除非牺牲系统可用性)。

事务补偿的想法和回滚日志有些类似。既然我们没办法同时保证所有的参与方事务执行都成功,不如就让他们随意执行,谁成功了就提交本地事务。但是每个参与方的每个操作,都要注册(注意是注册,不是自动生成)一个对应的补偿操作,这个补偿操作由人为定义,用于撤销已执行事务带来的影响。

当某一方的事务执行失败时,所有已经成功提交了事务的参与方,需要按照顺序(提交的倒序)去执行各自的补偿事务,来将整个系统“回滚”到之前的状态。

补偿型思路的一个典型实现是TCC(Try-Confirm-Cancel)事务,其实说是事务,不如说是一种业务模式,因为Try,Confirm,Cancel这三个操作都必须由业务方实现。

  • Try:资源预留&锁定。事务发起方将调用服务提供方的Try方法来锁定业务所需要的所有资源。
  • Confirm:确认执行业务逻辑操作。这里使用的资源一定都是在Try中预留的资源,Try + Confirm 组合起来是一次完整的业务逻辑。
  • Cancel:取消执行业务逻辑。这里和普通的补偿性事务不同,因为Try阶段只是预留资源,并未真正执行操作,因此取消操作只需要释放Try阶段预留的资源,而不需要执行数据库操作来补偿。

其实TCC可以认为是应用层的2CP协议。网上关于TCC的相关逻辑说法很多,也比较混乱,这里找到一个比较通俗普遍的例子来解释TCC的流程。当然实际应用中,根据业务的场景不同,TCC的实现也不同:它只是一种思路,而并非是一种规范。

例子仍然是转账问题,我们把范围稍微扩大一点,现在我们有三个用户A,B,C分别位于三个不同的数据库实例上,现在A,B要分别向C转账40元(一共80元)。

  1. Try阶段:尝试执行。
- 业务检查(一致性):检查A,B,C的账户状态是否正常,以及A,B的账户余额是否都不低于40元。
- 预留资源(准隔离性):账户A、B的余额均冻结40元。这样保证其他并发事务不会把A、B的余额扣成负数。
  1. Confirm阶段:确认执行。
    • 真正执行事务:执行实际的业务操作:A、B账户减少40元,C账户增加80元。(这一步还是需要消息传递机制)
  2. Cancel阶段:取消执行。
    • 释放A,B账户上被成功冻结的金额。

小结

分布式的结构下,事务的实现依然没有一个放之四海而皆准的标准。但是可以看到一个统一的原则,那就是尽可能的让服务变得更具有弹性,能够灵活地应对多种情况。

总的来说,分布式事务更大的挑战在于,相关业务逻辑的开发思路:可用性与一致性的平衡。

参考文章

本文是学习和整理自下列文章:

如有侵权,请联系我删除相关内容。如有错误,欢迎评论纠正。

原创声明,本文系作者授权云+社区-专栏发表,未经许可,不得转载。

如有侵权,请联系 zhuanlan_guanli@qq.com 删除。

发表于 17天前
10

刘明的小酒馆

7 篇文章1 人订阅

0 条评论

相关文章