作者:xybaby
正文
思考这个问题的初衷,是有一次给朋友转账,结果我的钱被扣了,朋友没收到钱。而我之前一直认为银行转账一定是由事务保证强一致性的,于是学习、总结了一下分布式事务的各种理论、方法。
事务是一个非常广义的词汇,各行各业解读都不一样。对于程序员,事务等价于Transaction,是指一组连续的操作,这些操作组合成一个逻辑的、完整的操作。即这组操作执行前后,系统需要处于一个可预知的、一致的状态。因此,这一组操作要么都成功执行,要么都不能执行;如果部分成功,部分失败,成功的部分需要回滚(rollback)。
本文地址:
http://www.cnblogs.com/xybaby/p/7465816.html
关系型数据库事务
大多数人可能和我一样,第一次听说事务是在学习关系型数据库(mysql、sql server、Oracle)的时候,在关系型数据库中,如果一组操作满足ACID特性,那么称之为一个事务。关于关系型数据库的ACID特性,不管是教材还是网络上都有大量的资料,这里只简单介绍。
A(Atomic):原子性,构成事务的所有操作,要么都执行完成,要么全部不执行,不可能出现部分成功部分失败的情况
C(Consistency):一致性,在事务执行前后,数据库的一致性约束没有被破坏。这里的一致性含义后面会详细解释
I(Isolation):隔离性,数据库中的事务一般都是并发的,隔离性是指并发的两个事务的执行互不干扰,一个事务不能看到其他事务运行过程的中间状态
D(Durability):持久性,事务完成之后,该事务对数据的更改会被持久化到数据库,且不会被回滚。
我们举一个简单的转账的例子,用户A给玩家B转100块钱,那么涉及到两个操作:玩家A的账户扣100元,玩家B的账户加100元。即
1
2
|
UserA
.
account
-=
100
UserB
.
account
+=
100
|
原子性很好理解,这两个操作要么都成功,要么都不执行(更准确的是从效果上来看等价于都没有执行)。不可能出现用户A的钱减少了而用户B的钱没增加的情况,用户是不允许的;更不可能出现用户B的钱增加 而 用户A的钱没有减少的情况,银行是绝对不干的。
一致性说一起来大家都懂,但是深究起来也是似懂非懂。ACID中的一致性,网络上的介绍都很模糊,都是说要处于一致的状态,那什么是一致的状态呢,比如转账操作中,A扣钱,B加钱,AB的钱的综合是一定的,这个是否属于ACID中的Consistency呢?我觉得不是的,Wiki Transaction_processing和Wiki: ACID分别是这么描述的
1
2
3
|
Consistency
:
A
transaction
is
a
correct transformation of the
state
.
The actions taken
as
a
group
<
strong
>
do
not
violate any of the integrity constraints
associated
strong
>
with the
state
.
The consistency
property
ensures that any transaction will bring the database from one valid state
to
another
.
Any data written
to
the database must be valid according
to
all defined
rules
,
including
constraints
,
cascades
,
triggers
,
and
any combination
thereof
.
<
strong
>
This
does
not
guarantee correctness of the transaction
in
all ways the application programmer might have
wanted
strong
>
(
that
is
the responsibility of
application
-
level
code
),
but merely that any programming errors cannot result
in
the violation of any defined
rules
.
|
上面黑色加粗的部分指出,ACID中的一致性是指完整性约束不被破坏,完整性包含实体完整性(主属性不为空)、参照完整性(外键必须存在原表中)、用户自定义的完整性。用户自定义的完整性比如列值非空(not null)、列值唯一(unique)、列值是否满足一个bool表达式(check语句,如性别只能有两个值、岁数是一定范围内的整数等),例如age smallint CHECK (age >=0 AND age <= 120).数据库保证age的值在[0, 120]的范围,如果不在这个范文,那么更新操作失败,事务也会失败。另外,向mysql中的cascade,以及触发器(trigger)都属于用户自定义的完整性约束。在MongoDB3.2中document validation就是用户自定义的完整性约束,在插入或者更新docuemnt的时候检查,不过用户可以自行设定validationAction,确定当数据不符合约束时的表现,默认为error,即拒绝数据写操作。
因此,用户A,B在这次事务操作前后,账户的总和一定,是应用层面的一致性,而不是数据库保证的一致性,应用层面的一致性事实上是由原子性来保证的。
隔离性说起来简单,但事实上背后的事情很复杂,数据库的隔离性依赖于加锁或者多版本控制。简单来说,如果UserA.account初始值为500,执行完第一条指令(即减去100),但事务还没有提交,其他的事务是不能读到这个中间结果(UserA.account的值为400)的。这就是避免了脏读(Drity Read),对应的隔离级别就是READ_COMMITTED。在SQL标准中,定义了四个隔离级别:
1
2
3
4
|
READ
_
UNCOMMITTED
READ
_
COMMITTED
REPEATABLE
_
READ
SERIALIZABLE
|
来解决事务并发中带来的一下几个问题脏读(Dirty Read)、不可重复读(Non-repeatable Read)、幻读(Phantom Read)
不同的数据库或者说存储引擎默认支持不同的隔离级别,比如InnoDB存储引擎默认支持REPEATABLE_READ,而Mongodb只支持READ_UNCOMMITTED
持久性需要考虑到一个事务在执行过程中的各种情况的异常。一个事务的流程是这样的:
开启一个事务
执行一组操作
如果都执行成功,那么提交并结束事务
如果任何操作失败,那么回滚已经执行的操作,结束事务
在事务执行过程中,如果出现故障,比如断电、宕机,这个时候就要利用日志(redo log或者undo log) 加上 checkpoint来保证事务的完整结束。
分布式事务
当数据的规模越来越大,超出了单个关系型数据库的处理能力,这个时候就出现了关系型数据的垂直分表或者分表,也出现了天然支持水平扩展(sharding)的NoSql。另外,大型网站的服务化(SOA)以及这两年非常火的微服务,往往将服务进行拆分,单独部署,自然也使用独立的数据库,甚至是异构的数据库。这个时候,关系型数据库保证事务的手段,比如加锁、日志就行不通了。当然,本文讨论的不仅仅是数据库,也包含分布式存储、消息队列,以及任何要保证原子性、持久性的逻辑。
分布式事务的最大挑战在于CAP,在《
CAP理论与MongoDB一致性、可用性的一些思考
》一文中有详细介绍。简而言之,由于网络分割(P: Network Partition)的存在,用户不得不在一致性(C Consistency)与可用性(A: Avaliable)之前做权衡。如果要保证强一致性(主要是应用层面的强一致性),那么在网络分割的时候,系统就不可用;如果要保证高可用性,那么就只能提供弱一致性,保证最终一致。下面提到的各种实现分布式事务的方法、协议都需要在一致性与可用性之间权衡。
2PC
提到分布式事务,首先想到的肯定是两阶段提交(2pc, two-phase commit protocol),2pc是非常经典的
强一致性
、
中心化
的原子提交协议。中心化是指协议中有两类节点:一个中心化协调者节点(coordinator)和N个参与者节点(participant、cohort)。
顾名思义,两阶段提交协议的每一次事务提交分为两个阶段:
在第一阶段,协调者询问所有的参与者是否可以提交事务(请参与者投票),所有参与者向协调者投票。
在第二阶段,协调者根据所有参与者的投票结果做出是否事务可以全局提交的决定,并通知所有的参与者执行该决定。在一个两阶段提交流程中,参与者不能改变自己的投票结果。两阶段提交协议的可以全局提交的前提是所有的参与者都同意提交事务,只要有一个参与者投票选择放弃(abort)事务,则事务必须被放弃。
wiki上给出了简要流程:
注意,上图中洗下面一行也表明,两阶段提交协议也依赖与日志,只要存储介质不出问题,两阶段协议就能最终达到一致的状态(成功或者回滚)
而下图(来自slideshare)详细描述了整个流程:
在刘杰的《分布式原理介绍中》,有非常详细的流程介绍,可以配合上图一起看,另外还介绍了在各种异常情况下(比如Coordinator、Participant宕机,网络分割导致的超时)两阶段协议的工作情况。另外,在
这篇文章
中也有比较清晰的流程介绍。在这里只讨论2PC的优缺点:
优点:强一致性,只要节点或者网络最终恢复正常,协议就能保证顺利结束;部分关系型数据库(Oracle)、框架直接支持
缺点:两阶段提交协议的容错能力较差,比如在节点宕机或者超时的情况下,无法确定流程的状态,只能不断重试;两阶段提交协议的性能较差, 消息交互多,且受最慢节点影响
这篇文章
描述了为什么两阶段提交协议在分布式系统中不适用:
系统“水平”伸缩的死敌。基于两阶段提交的分布式事务在提交事务时需要在多个节点之间进行协调,最大限度地推后了提交事务的时间点,客观上延长了事务的执行时间,这会导致事务在访问共享资源时发生冲突和死锁的概率增高,随着数据库节点的增多,这种趋势会越来越严重,从而成为系统在数据库层面上水平伸缩的”枷锁”, 这是很多Sharding系统不采用分布式事务的主要原因。
所言甚是!
3PC
三阶段提交协议(3pc Three-phase_commit_protocol)主要是为了解决两阶段提交协议的阻塞问题,从原来的两个阶段扩展为三个阶段,并且增加了超时机制。
3PC只是解决了在异常情况下2PC的阻塞问题,但导致一次提交要传递6条消息,延时很大。具体流程描述可参见《
关于分布式事务、两阶段提交协议、三阶提交
协议
》一文。
TCC
TCC是Try、Commit、Cancel的缩写,在国内由于支付宝的布道而广为人知,
TCC在保证强一致性的同时,最大限度提高系统的可伸缩性与可用性
。
我们假设一个完整的为业务包含一组子业务,Try操作完成所有的子业务检查,预留必要的业务资源,实现与其他事务的隔离;Confirm使用Try阶段预留的业务资源真正执行业务,而且Confirm操作满足幂等性,以遍支持重试;Cancel操作释放Try阶段预留的业务资源,同样也满足幂等性。“一次完整的交易由一系列微交易的Try 操作组成,如果所有的Try 操作都成功,最终由微交易框架来统一Confirm,否则统一Cancel,从而实现了类似经典两阶段提交协议(2PC)的强一致性。”
与2PC协议比较 ,TCC拥有以下特点:
位于业务服务层而非资源层 ,由业务层保证原子性
没有单独的准备(Prepare)阶段,降低了提交协议的成本
Try操作 兼备资源操作与准备能力
Try操作可以灵活选择业务资源的锁定粒度,而不是锁住整个资源,提高了并发度
当然,TCC需要较的高开发成本,每个子业务都需要有响应的comfirm、Cancel操作,即实现相应的补偿逻辑。
基于消息的分布式事务
这类事务机制将分布式事务分成多个本地事务,这里称之为主事务与从事务。首先主事务本地先行提交,然后通过消息通知从事务,从事务从消息中获取信息进行本地提交。可以看出这是一种
异步事务机制、只能保证最终一致性;但可用性非常高,不会因为故障而发生阻塞
。另外,主事务已经先行提交,如果因为从事务无法提交,要回滚主事务还是比较麻烦,所以这种模式只适用于理论上大概率等成功的业务情况,即从事务的提交失败可能是由于故障,而不大可能是逻辑错误。
基于异步消息的事务机制主要有两种方式:
本地消息表与事务消息
。二者的区别在于:怎么保证主事务的提交与消息发送这两个操作的原子性。
如果用异步消息实现转账的例子,那么操作分为四部:用户A扣钱,发消息,用户B收消息,用户B扣钱。前两步必须保证原子性,如果A扣钱成功但是没有发出消息,那么用户A损失了;如果发消息成功,但是没有扣钱,那么用户B就多得了一笔钱,银行肯定不干。
本地消息表
基于本地消息表的方案是指将消息写入本地数据库,通过本地事务保证主事务与消息写入的原子性。例如银行转账的例子,伪码如下:
1
2
3
4
|
begin
transaction
:
update User set
account
=
account
-
100
where
userId
=
'A'
insert into message
|