vlambda博客
学习文章列表

分布式系统的挑战:分布式事务

引言

上一章讲完了分布式系统的一致性问题,最终可引申到共识问题。那什么是共识问题?简单来说就是分布式系统的多个节点对某项决策达成一致的决定。最常见的两大重要应用场景就是:分布式事务和主节点选举。

分布式事务

在多个跨节点或跨分区提交事务时,往往存在部分节点失败部分节点成功的情况,事务需要保证要么全部成功,要么全部失败。我们需要对所有节点达成一致的共识,以此来保证原子性。事务原子性目前是在多次操作中途错误的情况下提供简单的语义,一旦某个节点某个动作失败,则回滚全部子节点事务,置为全部失败并进行回滚。

在介绍分布式事务前,先回顾一下单节点事务,尤其以InnoDB的事务实现来阐述两阶段提交(2PC)。

InnoDB事务的实现

InnoDB事务的原子性和持久性是通过redo log来保证的。 redo log 分为两个部分:一是内存中的redo log buffer,二是redo log file,一个易失一个持久。在事务提交时,所有日志必须先写入到redo log file中进行持久化。redo log是基于存储引擎层面的物理日志格式,其记录的是对于每个页的修改。InnoDB 将事务的commit分为prepare和commit两个阶段:

1、prepare阶段:redo持久化到磁盘,并将回滚段置为prepared状态,此时binlog不做操作。

分布式系统的挑战:分布式事务
2、commit阶段:innodb释放锁,释放回滚段,设置提交状态,binlog持久化到磁盘,然后存储引擎层提交 分布式系统的挑战:分布式事务

上图其实并不太直观,举一个实际例子。当我们要执行update语句时,完成的操作流程图如下:

分布式系统的挑战:分布式事务

通俗的讲,事务的原子性实现靠预写日志的方式,这种设计在实际工作中有相当广泛的应用场景。比如说我们记录了一批增量用户数据,需要在某个时间点推送给下游系统进行消费。在进行数据捞取过滤和组装后将请求数据先记录到日志表中,然后推送给下游,待下游ACK成功后将日志状态记录为完成。照此操作可以方便完成幂等的构建,特别是幂等涉及到类似于时间这样持续变化的信息组装;同时如果数据捞取组装清洗过滤需要耗费大量成本的情况下,实时组装或者全量重试都是待商榷的选择。
InnoDB的事务实现过程其实就是单节点的两阶段提交实现,对于单个数据库节点的事务执行,通常由存储引擎(比如InnoDB)来保证原子性。但考虑在分布式环境下,多节点的提交状态可能存在不一致,这时候想保证原子性,则需要将所有节点的事务全部回滚才行。然而事务提交是不可撤销的,所以必须要有一个协调器对事务涉及的所有节点进行监视和指令控制。

两阶段提交

两阶段提交(two-phase commit, 2PC)是一种用于实现跨多个节点的原子事务提交的算法。2PC提交/终止操作分为两个阶段,而不是单节点事务中的单个提交请求。

分布式系统的挑战:分布式事务

具体来了解一下2PC的实现工作原理:
  1. 当应用启动一个分布式事务时,它向协调者请求一个全局唯一的事务ID。

  2. 应用在每个参与者上启动单节点事务,并在单节点事务上捎带上这个全局事务ID。所有的读写都是在这些单节点事务中各自完成的。如果在这个阶段出现任何问题(例如,节点崩溃或请求超时),则协调者或任何参与者都可以中止。

  3. 当应用准备提交时,协调者向所有参与者发送一个准备请求,并打上全局事务ID的标记。如果任意一个请求失败或超时,则协调者向所有参与者发送针对该事务ID的中止请求。

  4. 参与者收到准备请求时,需要确保在任意情况下都的确可以提交事务。这包括将所有事务数据写入磁盘(出现故障,电源故障,或硬盘空间不足都不能是稍后拒绝提交的理由)以及检查是否存在任何冲突或违反约束。

  5. 当协调者收到所有准备请求的答复时,会就提交或中止事务作出明确的决定(只有在所有参与者投赞成票的情况下才会提交)。协调者必须把这个决定写到磁盘上的事务日志中,如果它随后就崩溃,恢复后也能知道自己所做的决定。这被称为提交点(commit point) 。

  6. 一旦协调者的决定落盘,提交或放弃请求会发送给所有参与者。

上述完整的实现原理存在两个不可撤销的动作:

  • 参与者投“是”后所有的操作不得再做拒绝。通过向协调者回答“是”,节点承诺,只要请求,这个事务一定可以不出差错地提交。换句话说,参与者放弃了中止事务的权利,但没有实际提交。

  • 协调者做“提交”后所有的动作必须成功。如果这个请求失败或超时,协调者必须永远保持重试,直到成功为止。没有回头路:如果已经做出决定,不管需要多少次重试它都必须被执行。如果参与者在此期间崩溃,事务将在其恢复后提交,由于参与者投了赞成,因此恢复后它不能拒绝提交。

如果在propose阶段一个节点做了拒绝,则协调器需要向所有节点执行abort命令以此释放占用的锁,此时满足原子性要么整体成功要么整体失败的要求。

分布式系统的挑战:分布式事务

两阶段提交的缺陷


可以看到两阶段提交的核心是协调器(coordinator),这就避免不了单点故障问题。假设协调器也是集群,单节点故障可以通过下一章要说的共识之主节点选择来解决,但上一章提到了无法保证数据强一致性,恢复后对已有事务的执行阶段仍无法做到强一致性,这意味着一旦单节点的主协调器发送故障,会出现一些意外之外的错误。前几篇文章中强调了一点:无论如何都不要过分相信网络。如果在2PC执行阶段发生故障又会如何呢?

如果在Propose阶段协调器发送奔溃或者网络分区,由于所有节点的事务未提交,数据并未生效,参与者可以安全的终止事务。

如果在Commit阶段某些节点已经收到Commit指令,随后协调器发生奔溃或者网络分区导致消息不可达,其他未提交的节点会因为事务未提交而发生数据不一致的情况。除非协调器从故障中恢复并能判断事务未完成状态,否则就会发现严重的数据不一致问题,同时也不满足事务的原子性要求。再回到上一章提到的预写日志的方式,待协调器恢复后读取完成状态存疑的事务,重新提交未完成的事务便可将事务继续执行到完成状态。如果存在数据丢失,则参与者会一直保持独占锁,直到管理员对事务进行判断手动执行提交或者回滚。

分布式系统的挑战:分布式事务


三阶段提交


通过分析发现两阶段提交会受到协调器的限制,所以2PC也被称为阻塞原子提交协议。为了使原子提交协议变成非阻塞的,一种被称为三阶段提交(3PC)算法被提出用以取代两阶段提交。三阶段提交在两阶段提交的基础上增加了CanCommit阶段,并引入了超时机制。一旦事务参与者迟迟没有收到协调者的Commit请求,就会自动进行本地commit,这样相对有效地解决了协调者单点故障,同时也解决了阻塞提交的问题。分布式系统的挑战:分布式事务

结合2PC遇到如果在Commit阶段某些节点已经收到Commit指令,随后协调器发生奔溃或者网络分区导致消息不可达,由于在PreCommit阶段存在参与者投了“否”的操作,在Commit阶段超时时间结束后如果仍然没有收到协调器abort操作的话,会执行自动提交。这就引入了数据不一致的问题。

同时3PC不能处理网络分区的问题。考虑在PreCommit之前参与者被一分为二,新的协调者接管了voter3,不同的协调者可能做出不一致的决定,最终导致数据不一致问题。

3PC假定了一个有界的网络延迟和节点规定时间内响应,考虑到目前大多数网络具有无限延迟和进程暂停的情况,3PC无法保证原子性。在具有无限延迟的网络中,超时并不是一种可靠的故障检测机制,以为即使节点没有奔溃,请求也可能由于网络问题而超时,所以尽管大家都清楚2PC因为协调者故障导致的阻塞问题,但仍然被广泛使用。


分布式事务的限制


分布式事务解决了多个参与者相互达成一致的现实问题,但从2PC和3PC的实现来看,引入了非常严重的运维问题,同时高昂的网络开销造成的严重性能下降,在实际生活生产中也因此而毁誉参半。尽快已经提出了许多启发式决策,但这只是分布式事务在解决应急场景下的一种承诺,无法作为常规手段使用,数据强一致性在DB层面仍无法保证做到完美,越来越多的技术与应用开始从最终一致性来保障分布式事务的执行,譬如TCC协议——一个从应用层来感知最终提交成功与否并从业务层面进行回滚的模型,以及柔性事务——一个从MQ进行异步消息传递以此来解决阻塞和解耦的事件处理机制。