转发自 https://cloud.tencent.com/developer/article/1038871sql
在高并发场景下,分布式储存和处理已是经常使用手段。但分布式的结构势必会带来“不一致”的麻烦问题,而事务正是解决这一问题而引入的一种概念和方案。咱们常把它当作并发操做的基本单位。数据库
提到事务,脑海里第一个反应固然是数据库里的Transaction了。紧接着就是事务的四大特性:ACID (原子性,一致性,隔离性,持久性),因此咱们先从这四大特性提及。编程
原子性是咱们对事务最直观的理解:事务就是一系列的操做,要么所有都执行,要么所有都不执行。缓存
想要保证事务的原子性,就意味着须要在操做发生异常时,对该事务全部以前执行过的操做进行回滚。网络
在MySQL中,这个回滚是经过回滚日志(Undo Log)实现的。简单的说,回滚日志就是记录了你全部操做的逆操做,在须要回滚时,就把这个事务的回滚日志里的操做所有执行一次。架构
好比你的事务里每个create其实都对应了一个效果跟其相反的delete语句,他们被记录在回滚日志里,当事务发生异常触发ROLLBACK时,就按照日志逻辑地将回滚日志里的操做所有执行,从而达到“撤销”操做的效果。并发
宏观上看事务是具备原子性的,是一个密不可分的最小单位。可是它是有几种不一样的状态的:Active,Commited,Failed,它要么在执行中,要么执行成功,要么就失败。异步
深刻事务的内部,他就变为一系列操做的集合,再也不具备原子性了,包括了不少的中间状态,好比部分提交,参考以下的事务状态图:分布式
正常状况下事务都是并行执行的,这就会出现不少复杂的新问题。高并发
首先是事务依赖,举一个直观的例子来讲明:
假设事务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,前者是咱们常常讨论的,也是广泛意义上的数据库事务一致性,然后一个将是以后会展开讨论的,有关分布式事务的一致性。
事务的一致性定义基本能够理解为是事务对数据完整性约束的遵循。这些约束可能包括主键约束、外键约束或是一些用户自定义约束。事务执行的先后都是合法的数据状态,不会违背任何的数据完整性,这就是“一致”的意思。
固然这个含义中也隐含着对开发者的要求,就是不能写出错误的事务逻辑,好比银行的转帐不能只加钱不减钱,这是应用层面的一致性要求。
CAP定理是分布式系统理论的基础。CAP告诉咱们,对于一个分布式系统(或者因为网络隔离等缘由产生的分区系统),它没法同时保证一致性、可用性和分区容忍性,而是必需要舍弃其中的一个。
p.s. 对于分布式系统通常咱们是不可能舍弃分区容忍性的(由于分区的状况是没法避免的),因此通常是根据业务,在一致性和可用性中二选一。
这里说的一致性,具体在数据库上,就是分布式数据库中,每个节点对于同一个数据必须有相同的拷贝(每一个库里的同一个数据内容必须是一致的)。
如今咱们来看一看,当数据分布式储存后,操做所带来的一些问题。
众所周知,如今大型服务出于性能和容灾的考虑,都会使用分布式的服务架构,这意味着一个服务会有多个数据库,分开储存不一样的数据,这种状况下就很容易出现数据不一致的问题了,一个最简单的例子:
A要B给转100元。可是A和B的记录被分在了不一样的数据库实例上,若是这时候执行的某个事务中途出现了bug,若是没有一个好的处理方式,回滚将会是一件难以面对的事情。
因此咱们能够看到,在分布式环境下,事务的设计方案变得更加复杂,也更加剧要了,下面咱们来谈谈分布式事务的一些常见实现方式:
两阶段提交是一种提交协议,在这种协议下,事务的实现被拆分红了几个不一样的模块,通常分为协调器和若干的事务执行者,以下图:
在分布式系统中,每一个节点虽然能够知道本身操做是否成功,可是却没法得知其余节点上操做是否成功,所以当一个事务跨越了多个节点的时候,就须要一个协调者,可以掌控到全部节点的执行状况,进而保证事务的ACID特性。
如今咱们来分析2PC协议条件下,转帐问题是如何被解决的(咱们假设A是你的支付宝余额,B是你的余额宝)。
prepare
信息写到本地日志,这就是回滚日志了。prepare
信息,固然对于不一样的执行者,这个prepare信息是不一样的,这取决于他们的数据实例上要发生什么样的变更,好比这个例子中,A获得的prepare
消息是通知支付宝余额数据库扣除100元,而B获得的prepare
消息是通知余额宝数据库增长100元。prepare
消息以后,执行本机的具体事务,但不会commit,若是成功则向协调者发送yes
回执,不然发送no
。yes
,就向全部的执行者发送commit
消息,执行器收到该消息后就会正式执行提交。反之,若是收到任何一个no
,就向全部的实行者发送abort
消息,执行器收到后会放弃提交并回滚相应的改动。协调器上保存的回滚日志,能够用于某个执行器失败后恢复的工做的场景,此时执行器可能会再次向协调器发送回执来肯定本身的执行状态。
2PC实现的思路却是很简单,不过这个思路中存在着几个很是严重的问题,所以几乎不被使用:
其实分布式事务的种种实现方案基本都借鉴了2PC的思路,但很快人们就发现一个问题,在分布式的系统中,若是仍然采用事务模型来进行数据的修改,性能将受到不可避免的影响,这在高并发的场景下是不能接受的。
刚才咱们讲了分布式事务在高并发场景下的败北,其实根据CAP原则咱们很容易明白,想要保证可用性的同时保证一致性是不可能的,因而如今大多数的分布式系统中都对一致性作出了妥协:
咱们不追求整个操做过程当中每一时刻的一致性(强一致性),转而追求最终结果的一致性(最终一致性)。
也便是说,在整个事务执行的流程中,咱们是能够接受的短暂的数据不一致的,只要最后的结果没问题就行。
至此,咱们对于事务的研究,从知足ACID的刚性事务,拓展到BASE(基本可用,软状态,最终一致性)的柔性事务。
BASE原则是在分布式场景下,为了保证高可用性,而作出的一种“妥协性”思想。总的来讲是容许局部的错误和故障,但要保证全局的稳定。事实上当前大多数的分布式系统,或者说大多数的大型系统里,都在运用这种思想了。
在展开柔性事务以前,咱们先来补充一些基础知识。
在接下来说到的各类思路中,咱们都没法避免一个问题,那就是接口调用或者说操做的失败,分布式状况下系统的状态每每不如单机条件下肯定,因此可能常常须要重试,而不是一失败就回滚。
所以咱们必须尽量的避免重试对系统稳定性和性能的影响,因而有了幂等这个概念:
幂等
f(x) = f(f(x))
的性质而后咱们须要探讨一下保证幂等经常使用的思路,咱们以微博点赞这个操做为实际例子来看一下(点赞是不能重复的):
数据更新时须要比较持有数据的版本号,版本号不一致的话是没法操做成功的。
每一个版本只有一次执行成功的机会,一旦失败了就要从新获取版本号。
这样每次点赞操做都对应着一个不一样的版本号,即使失败重复尝试,也不会出现点赞数错误增长或减小的状况。
这个主要依赖数据库的索引惟一性(键),以点赞操做为例,能够对[`user_id`,`weibo_id`]这个组合作一张“点赞操做表”,若是成功点赞,就添加一条新记录。
若是出现了错误的重试,由于表的索引是惟一的,已经有了记录自后就不会再次插入,天然也就不会出现错误的状况了。
2PC的处理过程当中一个很大的问题是,存在大量的同步等待,这便意味着操做之间的强耦合,一旦发生了失败或是超时,形成的影响每每是灾难性的。可是分布式状况下,超时和失败又是极可能出现的状况,因此2PC手段无法保证系统的可用性。
那么怎么优化呢?能够将操做解耦,使用消息队列(或者某种可靠的通讯机制)来链接不一样的实例上的操做。这样的通讯机制使操做异步化,因而咱们还须要一个可以确保消息执行成功的确保机制,以上两点的综合就是如今最经常使用的柔性事务解决方案,咱们暂且叫它“异步确保”(由于这种方案并不是有一个统一的叫法),核心思路其实就是:用消息队列保证最终一致性。
下面咱们一步一步深刻,了解这种方案的基本思想和流程。
咱们依然使用经典的转帐问题来展开讨论:A要向B转100元,可是A和B的帐户在不一样的实例上存储。
用异步确保的思想,操做的流程应该如此处理:
这是一个很理想的状况,其实咱们有不少的问题要处理。
首先是原子性,其实很容易发现,不管顺序如何,若是1和2这两个操做有任何一个失败了,那另外一个操做也必然变得没有意义,因此必须保证1和2这两个操做的总体原子性。
这里不少人会想,直接利用刚性事务的ACID特性,把1和2放在同一个事务里不就ok了。但这是不可能的,缘由以下:
因此事情没那么简单,因此在咱们得作很多额外的工做才能解决这个问题,下面是如今经常使用的解决思路:消息表。
先说生产方(A的实例)
```
begin transaction;
update account set amount = ($amount - 100) where user = A;
insert into message values('b','account','-100');
end transaction;
```
再说消费方(B的实例)
能够看到这个实现方案对于业务的生产方来讲,须要维护不少额外的操做,尤为是须要设计维护消息表,可能还要作后台任务处理等,某种程度上这会增长业务端没必要要的逻辑耦合,以及性能负担。
简要工做流程以下图所示:
正如上文所说,异步确保的思路中,大多数操做其实与业务无关,能够封装到消息队列中去。因而产生了“事务消息”这一律念,也就衍生了不少可以很好的支持分布式事务消息相关操做的消息队列或者中间件,如RocketMQ和Notify。
咱们来看看事务消息是如何优化和整合异步确保的逻辑的。
首先,把消息发送分红了2个阶段:准备和确认阶段,因而生产方步骤变为以下3步:
这里有一个问题,就是若是1和2失败了,仍是很容易回滚和取消的,可是第三步失败或者超时了,要怎么作呢?
以RocketMQ为例,MQ会按期地扫描全部的prepared消息,询问发送方,究竟是要确认发送这条消息,仍是要取消这条消息?这点底层是经过让生产方实现一个约定好的Check接口来实现的,有点像订阅者模式。
咱们能够看出来,异步回调中,扫描消息表,确认或重发消息这个步骤被消息队列实现了,减小了业务方开发的难度。
对于消费方,事务消息支持重试的特性,也就是说没必要生产者去主动发起重试消息,消息队列能够自动帮你重试这些操做,能够说是很是解放生产力了。
若是有极端状况,好比消费端异常,不管怎么重试都失败,是否要回滚呢?其实最好的办法就是人工介入,人工去处理这种几率极低的case,比开发一个高复杂的自动回滚系统要可靠的多,也更简单。
除了比较经常使用的异步确保,咱们再介绍一种常见的实现柔性事务的思路,称为事务补偿。
总结以前的内容,咱们不难发现,分布式事务的难点在于,一方执行事务成功以后,没法肯定其余参与方对应的事务是否可以成功(除非牺牲系统可用性)。
事务补偿的想法和回滚日志有些相似。既然咱们没办法同时保证全部的参与方事务执行都成功,不如就让他们随意执行,谁成功了就提交本地事务。可是每一个参与方的每一个操做,都要注册(注意是注册,不是自动生成)一个对应的补偿操做,这个补偿操做由人为定义,用于撤销已执行事务带来的影响。
当某一方的事务执行失败时,全部已经成功提交了事务的参与方,须要按照顺序(提交的倒序)去执行各自的补偿事务,来将整个系统“回滚”到以前的状态。
补偿型思路的一个典型实现是TCC(Try-Confirm-Cancel)事务,其实说是事务,不如说是一种业务模式,由于Try,Confirm,Cancel这三个操做都必须由业务方实现。
其实TCC能够认为是应用层的2CP协议。网上关于TCC的相关逻辑说法不少,也比较混乱,这里找到一个比较通俗广泛的例子来解释TCC的流程。固然实际应用中,根据业务的场景不一样,TCC的实现也不一样:它只是一种思路,而并不是是一种规范。
例子仍然是转帐问题,咱们把范围稍微扩大一点,如今咱们有三个用户A,B,C分别位于三个不一样的数据库实例上,如今A,B要分别向C转帐40元(一共80元)。
- 业务检查(一致性):检查A,B,C的帐户状态是否正常,以及A,B的帐户余额是否都不低于40元。 - 预留资源(准隔离性):帐户A、B的余额均冻结40元。这样保证其余并发事务不会把A、B的余额扣成负数。
分布式的结构下,事务的实现依然没有一个放之四海而皆准的标准。可是能够看到一个统一的原则,那就是尽量的让服务变得更具备弹性,可以灵活地应对多种状况。
总的来讲,分布式事务更大的挑战在于,相关业务逻辑的开发思路:可用性与一致性的平衡。