要理解分布式事务,就需要先明白什么是事务(Transaction),在大多数情况下,我们所说的事务都指数据库事务(Database Transaction),后来的各种非数据库的事务也都借鉴和参考了对数据库事务的定义:
事务是数据库运行中的一个逻辑工作单元,工作单元中的一系列SQL命令都具有原子性操作的特点,这些命令要么完全成功执行,要么完全撤销或不执行,如果是后者,则表现为数据库内的最终数据没有发生任何改变。事务通常由数据库中的事务管理子系统负责处理。
数据库事务要满足如下四个要求。
其中原子性(需要记录操作过程和对应的结果,以便回退)、隔离性(产生锁)这两个要求,导致数据库事务的执行代价要远高于非事务性的操作。一般而言,隔离性是通过锁机制来实现的,而原子性、一致性和持久性等三个特性是通过数据库里的相关事务日志文件来实现的,在这个过程中涉及大量的IO操作。在 MySQL里,事务相关的日志文件为redo和 undo文件,简单来说,redo log记录事务修改后的数据,undo log记录事务修改前的原始数据。由于事务随时可能需要回滚,所以在 MySQL执行事务的过程中,这两个文件都会被写入数据。下面是 MySQL里一个事务执行时的简化流程。
(1)先记录undo/redo log,确保日志被刷到磁盘上持久存储。
(2)更新数据记录,缓存操作并异步写入磁盘。
(3)提交事务,在redo log中写入commit记录。
其中第3步为commit事务的操作,在这个过程中主要做以下事情。
(1)清理undo 段信息。
(2)释放锁资源。
(3)刷新redo日志,确保将redo日志写入磁盘,即使修改的数据页没有更新到磁盘,只要日志完成了,就能保证数据库的完整性和一致性。
(4)清理savepoint列表。
我们看到,在事务执行的过程中,大量的费时操作都是在commit 指令之前完成的,包括写相关的事务日志,以备回滚事务或者提交,而commit 指令所做的工作基本上是可以“瞬间”完成的,在整个事务处理的过程中所占的时间比例都非常少,这是事务处理的一个很重要的特点,后面要提到的XA二阶段事务模型也是基于这个特点而设计的。
此外,如果在 MySQL执行事务的过程中因故障中断(比如意外断电)导致数据没有及时持久化到磁盘中,则可以在后面通过redo log重做事务或通过undo log回滚,确保数据的一致性。
如果一个事务内的SQL要分别操作几个独立的数据库服务器上的数据,那么这种事务就变成分布式事务了。由于分布式系统的编程难度大,而事务又是一个非常重要的功能,所以在编程方面不能有半点偏差,否则可能导致灾难性的后果,于是就有一些技术达人来研究并制定了业界首个分布式事务标准规范——X/OpenDTP,此规范提出的二阶段提交模型(2PC)与TCP三次握手一样,成为经典。此后J2EE也遵循X/OpenDTP规范,设计、实现了Java里的分布式事务编程接口规范——JTA。
X/OpenDTP已经存在10年多了(于1994年发布),最早是由银行业很有名的Tuxedo中间件实现的一个内部标准,后来交给X/Open组织进行标准化。
X/OpenDTP设计了一个模型来描述参与分布式事务的各个角色及交互规范,如下图所示。
在X/OpenDTP模型中,参与事务的角色分为以下三种。
(1)AP:用户程序,大部分是CRUD代码的这种应用(我们特别擅长)。
(2)RM:数据库或者很少被使用的消息中间件等。
(3))TM:事务管理器、事务协调者,负责接收用户程序(AP)发起的XA事务指令,并且调度和协调参与事务的所有RM(数据库),确保事务正确完成(或者回滚)。
对这个模型中的几个关键点说明如下。
AP负责触发分布式事务,在这个过程中采用了特殊的事务指令(XA指令),而非普通
的事务指令,这些执行由TM接管并发给所有相关的RM去执行。
RM负责执行XA指令,每个RM只负责执行自己的指令。
TM负责整个事务过程中的协调工作,检查和验证每个RM的事务执行情况。
下面说说X/OpenDTP模型中知名的二阶段提交协议。在X/OpenDTP模型中,当一个分布式事务所涉及的SQL逻辑都执行完成,并到了最后提交事务的关键阶段时,为了避免分布式系统所固有的不可靠性导致提交事务意外失败,TM会果断决定实施两步走的方案。
(1)先发起投票表决,通知所有RM先完成事务提交过程所涉及的各种复杂的准备工作,比如写redo、undo日志,尽量把提交过程中所有消耗时间的操作和准备都提前完成,确保后面100%成功提交事务。如果准备工作失败,则赶紧告诉PM。
(2)真正提交。在该阶段,TM将基于前一阶段的投票结果进行决策,即提交或取消事务。当且仅当所有参与的RM都同意提交时,TM才通知所有RM正式提交事务,否则TM将通知所有参与的RM取消事务。RM在接收到TM发来的指令后将执行相应的操作。
下图给出了二阶段提交协议的通信过程(以两个RM为例)。
二阶段提交的精妙之处在于,它充分考虑到了分布式系统的不可靠因素,并且采用了非常简单的方式(两阶段完成)就把由于系统不可靠导致事务提交失败的概率降到最小!下面给出了一个形象的解释过程,来说明二阶段提交是如何做到这一点的。
假如一个事务的提交过程总共需要30秒的操作,其中Prepare 阶段要28秒(主要是确保事务日志写入磁盘等各种耗时的I/O操作),真正的Commit阶段只需要2秒,那么Commit阶段发生错误的概率与Prepare阶段相比,只是它的2/28 (<10%),也就是说,如果Prepare阶段成功了,则Commit 阶段由于时间非常短,失败的概率很小,会大大增加分布式事务成功的概率!不得不说,二阶段提交的精妙设计洞穿了分布式系统的本质。
但为什么我们在现实中很少会用到二阶段提交的XA事务呢?主要原因有以下几点。·互联网电商应用兴起,对事务和数据的绝对一致性要求并没有传统企业应用那么高。
目前在互联网领域里有几种流行的分布式解决方案,但都没有像之前所说的XA事务一样,形成X/OpenDTP那样的标准工业规范,而是仅仅在某些具体的行业里获得较多的认可。下面就对这些解决方案进行介绍。
第1种解决方案:业务接整合,避免分布式事务
此方案是将一个业务流程中需要在一个事务里执行的多个相关业务接口包装整合到一个事务中,这属于“就具体问题具体分析”的做法。就问题场景来说,可以将服务A、B、C整合为一个服务D来实现单一事务的业务流程服务。如果在项目一开始就考虑到分布式事务的复杂问题,则采用这里的方案,精心规划和设计系统,避免分布式事务;对于实在不能避免的,则采用其他措施去解决,这应该是最好的做法。
第⒉种解决方案:最终一致性方案之eBay模式
这是eBay于2008年公布的关于BASE 准则的论文中提到的一个分布式事务解决方案,在业界影响比较大。eBay的方案其实是一个最终一致性方案,主要采用了消息队列来辅助实现事务控制流程,其核心是将需要分布式处理的任务通过消息队列的方式来异步执行。如果事务失败,则可以发起人工重试的纠正流程。人工重试被更多地应用于支付场景中,通过对账系统对事后的问题进行处理。在该论文中描述了一个很常见的支付交易场景:如果某个用户(user)产生了一笔交易,则需要在交易表(transaction)中增加记录,同时修改用户表的金额(余额),由于这两个表属于不同的远程服务,所以涉及分布式事务与数据一致性的问题。
下面是用户表与交易表的表结构:
user(id, name, amt_sold, amt_bought)
transaction(xid, seller_id,buyer id, amount)
其中user表记录用户交易的汇总信息,transaction表记录每个交易的详细信息。
在进行一笔交易时需要对数据库进行以下操作:
INSERT INTO transaction VALUES (xid, $seller_id,$buyer_id, $amount);
UPDATE user SET amt_sold = amt_sold + $amount WHERE id = $seller_id;
UPDATE user sET amt bought = amt_bought + $amount WHERE id = $buyer_id;
这里不用XA事务模型,而是采用消息队列来分离事务。先启动一个事务,在更新transaction表后,并不直接更新user表,而是将要对user表进行的更新动作作为消息插入消息队列中,如下所示:
begin;
工NSERT INTO transaction VALUES (xid, $seller_id, $buyer_id, $amount);
put_to_queue "update user("seller", $seller_id, amount);
put_to_queue "update user ("buyer",$buyer_id, amount) ;
commit;
注意,消息队列与对transaction 的操作使用了同一套存储资源,因此这里的事务不涉及分布式操作。
另外,开启独立进程,从消息队列中获取上述消息,进行接下来的处理过程:
for each message in queue
begin;
if message.type = "seller" then
UPDATE user SET amt_sold = amt_sold + message.amount WHERE id = message.user_id;
else
UPDATEuser SET amt bought = amt bought + message.amount WHERE id =message.user_id;
dequeue message;end
commit;end
初看这个方案并没有什么问题,但实际上还没有解决分布式问题。为了使第1个事务不涉及分布式操作,消息队列必须与transaction表使用同一套存储资源。但为了使第﹖个事务也是本地的,消息队列存储又必须与user表在一起。这两者是不可能同时满足的,我们假设消息队列与transaction表使用同一套存储资源,则后面从消息队列消费消息的逻辑来看可能会产生不一致的错误:数据库已经更新了user的余额信息,但接下来从消息队列中删除消息时发生异常,比如进程死机或者消息服务突发故障,则此消息还在系统中,下次又会被投递,产生了消息被重复投递的问题。除非此消息的处理逻辑具有幂等性,可以重复触发,否则重复投递消息就会引发事故。
那么,如何解决这个问题呢? eBay给出了一个简单思路:增加一个message_applied( msg_id)表来记录被成功消费过的消息,过滤重复投递的消息。
于是,第2段逻辑被改为下面这种方式:
for each message in queuebegin;
SELECT count (*) as cnt FROM message_applied WHERE msg_id = message.id;if cnt 0then
if message.type - "seller" then
UPDATE user SET amt_sold = amt_sold + message.amount WHERE id = message.user_id;else
UPDATE user SET amt_bought = amt_bought + message.amount WHERE id =message.user_id;
end
INSERT INTO message_applied VALUES(message.id);end
commit;
if上述事务成功dequeue message
DELETE FROM message_applied WHERE msg_id = message.id;
end
end
上述模型中的消息中间件不一定是一个标准的通用的消息中间件,也可以是一个基于数据库存储的简单实现的消息服务,这个消息服务的实现只需保证下面两点即可。
留一个思考题,如果上述交易流程涉及3个或更多的环节,那么这里的消息中间件与数据表之间的本地事务又需要怎样设计?
eBay 的这个分布式事务模型之所以成为一个经典案例,是因为它的思路直观,并且代码和方案简单有效,因此,后来很多人都参考借鉴了它的这一模型,其中,网上公开的蘑菇街的交易订单流程就比较特别,如下图所示。
在交易创建流程中,首先创建一个不可见订单,然后在同步调用锁券和扣减库存时,针对调用异常(失败或者超时)发出废单消息到消息中间件。如果消息发送失败,则本地会做时间阶梯式的异步重试;优惠券系统和库存系统在收到消息后,会判断是否需要做业务回滚,这样就实时保证了多个本地事务的最终一致性。
第3种方案: XIOpenDTP模型的支付宝的DTS框架
DTS(Distributed Transaction Service)框架是由支付宝在X/OpenDTP模型的基础上改进(模仿)的一个设计,定义了类似于2PC的标准两阶段接口,业务系统只需要实现对应的接口就可以使用DTS的事务功能。DTS 从架构上分为xts-client和 xts-server两部分,前者是一个嵌入客户端应用的JAR文件,主要负责事务数据的写入和处理;后者是一个独立的系统,主要负责异常事务的恢复。DTS最大的特点是放宽了数据库的强一致约束,保证了数据的最终一致性(Eventually Consistent)。