了解分布式事务
分布式事务主要有两部分组成。第一个是并发控制(Concurrency Control)第二个是原子提交(Atomic Commit)。
之所以提及分布式事务,是因为对于拥有大量数据的人来说,他们通常会将数据进行分割或者分片到许多不同的服务器上。假设你运行了一个银行,你一半用户的账户在一个服务器,另一半用户的账户在另一个服务器,这样的话可以同时满足负载分担和存储空间的要求。对于其他的场景也有类似的分片,比如说对网站上文章的投票,或许有上亿篇文章,那么可以在一个服务器上对一半的文章进行投票,在另一个服务器对另一半进行投票。
可以这么理解事务:程序员有一些不同的操作,或许针对数据库不同的记录,他们希望所有这些操作作为一个整体,不会因为失败而被分割,也不会被其他活动看到中间状态。事务处理系统要求程序员对这些读操作、写操作标明起始和结束,这样才能知道事务的起始和结束。事务处理系统可以保证在事务的开始和结束之间的行为是可预期的。
例如,假设我们运行了一个银行,我们想从用户Y转账到用户X,这两个账户最开始都有10块钱,这里的X,Y都是数据库的记录。
这里有两个交易,第一个是从Y转账1块钱到X,另一个是对于所有的银行账户做审计,确保总的钱数不会改变,因为毕竟在账户间转钱不会改变所有账户的总钱数。我们假设这两个交易同时发生。为了用事务来描述这里的交易,我们需要有两个事务,第一个事务称为T1,程序员会标记它的开始,我们称之为BEGIN_X,之后是对于两个账户的操作,我们会对账户X加1,对账户Y加-1。之后我们需要标记事务的结束,我们称之为END_X。
同时,我们还有一个事务,会检查所有的账户,对所有账户进行审计,确保尽管可能存在转账,但是所有账户的金额加起来总数是不变的。所以,第二个事务是审计事务,我们称为T2。我们也需要为事务标记开始和结束。这一次我们只是读数据,所以这是一个只读事务。我们需要获取所有账户的当前余额,因为现在我们只有两个账户,所以我们使用两个临时的变量,第一个是用来读取并存放账户X的余额,第二个用来读取并存放账户Y的余额,之后我们将它们都打印出来,最后是事务的结束。
这里的问题是,这两个事务的合法结果是什么?这是我们首先想要确定的事情。最初的状态是,两个账户都是10块钱,但是在同时运行完两个事务之后,最终结果可能是什么?我们需要一个概念来定义什么是正确的结果。一旦我们知道了这个概念,我们需要构建能执行这些事务的机制,在可能存在并发和失败的前提下,仍然得到正确的结果。
所以,首先,什么是正确性?数据库通常对于正确性有一个概念称为ACID。分别代表:
Atomic,原子性。它意味着,事务可能有多个步骤,比如说写多个数据记录,尽管可能存在故障,但是要么所有的写数据都完成了,要么没有写数据能完成。不应该发生类似这种情况:在一个特定的时间发生了故障,导致事务中一半的写数据完成并可见,另一半的写数据没有完成,这里要么全有,要么全没有(All or Nothing)。
Consistent,一致性。我们实际上不会担心这一条,它通常是指数据库会强制某些应用程序定义的数据不变,这不是我们今天要考虑的点。
Isolated,隔离性。这一点还比较重要。这是一个属性,它表明两个同时运行的事务,在事务结束前,能不能看到彼此的更新,能不能看到另一个事务中间的临时的更新。目标是不能。隔离在技术上的具体体现是,事务需要串行执行,我之后会再解释这一条。但是总结起来,事务不能看到彼此之间的中间状态,只能看到完成的事务结果。
Durable,持久化的。这意味着,在事务提交之后,在客户端或者程序提交事务之后,并从数据库得到了回复说,yes,我们执行了你的事务,那么这时,在数据库中的修改是持久化的,它们不会因为一些错误而被擦除。在实际中,这意味着数据需要被写入到一些非易失的存储(Non-Volatile Storage),持久化的存储,例如磁盘。
今天的课程会讨论,在考虑到错误,考虑到多个并发行为的前提下,什么才是正确的行为,并确保数据在出现故障之后,仍然存在。这里对我们来说最有意思的部分是有关隔离性或者串行的具体定义。我会首先介绍这一点,之后再介绍如何执行上面例子中的两个事务。
通常来说,隔离性(Isolated)意味着可序列化(Serializable)。它的定义是如果在同一时间并行的执行一系列的事务,那么可以生成一系列的结果。这里的结果包括两个方面:由任何事务中的修改行为产生的数据库记录的修改;和任何事务生成的输出。所以前面例子中的两个事务,T1的结果是修改数据库记录,T2的结果是打印出数据。
所以,我们刚刚例子中的事务,只有两种一次一个的串行顺序,要么是T1,T2,要么是T2,T1。我们可以看一下这两种串行执行生成的结果。
另一种可能的顺序是,先执行T2,再执行T1,这种情况下,T2可以看到更新之前的数据,但是更新仍然会在T1中发生,所以最后的结果是X=11,Y=9。但是这一次,T2打印的是字符串“10,10”。
所以,这是两种串行执行的合法结果。如果我们同时执行这两个事务,看到了这两种结果之外的结果,那么我们运行的数据库不能提供序列化执行的能力(也就是不具备隔离性 Isolated)。所以,实际上,我们在考虑问题的时候,可以认为这是唯二可能的结果,我们最好设计我们的系统,并让系统只输出两个结果中的一个。
如果你同时提交两个事务,你不知道是T1,T2的顺序,还是T2,T1的顺序,所以你需要预期可能会有超过一个合法的结果。当你同时运行了更多的事务,结果也会更加复杂,可能会有很多不同的正确的结果,这些结果都是可序列化的,因为这里对于事务存在许多顺序,可以被用来满足序列化的要求。
现在我们对于正确性有了一个定义,我们甚至知道了可能的结果是什么。我们可以提出几个有关执行顺序的假设。
例如,假设系统实际上这么执行,开始执行T2,并执行到读X,之后执行了T1。在T1结束之后,T2再继续执行。
如果不是T2这样的事务,最后的结果可能也是合法的。但是现在,我们想知道如果按照这种方式执行,我们得到的结果是否是之前的两种结果之一。在这里,T2事务中的变量t1可以看到10,t2会看到减Y之后的结果所以是9,最后的打印将会是字符串“10,9”。这不符合之前的两种结果,所以这里描述的执行方式不是可序列化的,它不合法。
另一个有趣的问题是,如果我们一开始执行事务T1,然后在执行完第一个add时,执行了整个事务T2。
这意味着,在T2执行的点,T2可以读到X为11,Y为10,之后打印字符串“11,10”。这也不是之前的两种合法结果之一。所以对于这两个事务,这里的执行过程也不合法。
可序列化是一个应用广泛且实用的定义,背后的原因是,它定义了事务执行过程的正确性。它是一个对于程序员来说是非常简单的编程模型,作为程序员你可以写非常复杂的事务而不用担心系统同时在运行什么,或许有许多其他的事务想要在相同的时间读写相同的数据,或许会发生错误,这些你都不需要关心。可序列化特性确保你可以安全的写你的事务,就像没有其他事情发生一样。因为系统最终的结果必须表现的就像,你的事务在这种一次一个的顺序中是独占运行的。这是一个非常简单,非常好的编程模型。
可序列化的另一方面优势是,只要事务不使用相同的数据,它可以允许真正的并行执行事务。我们之前的例子之所以有问题,是因为T1和T2都读取了数据X和Y。但是如果它们使用完全没有交集的数据库记录,那么这两个事务可以完全并行的执行。在一个分片的系统中,不同的数据在不同的机器上,你可以获得真正的并行速度提升,因为可能一个事务只会在第一个机器的第一个分片上执行,而另一个事务并行的在第二个机器上执行。所以,这里有可能可以获得更高的并发性能。
在我详细介绍可序列化的事务之前,我还想提出一个小点。有一件场景我们需要能够应付,事务可能会因为这样或那样的原因在执行的过程中失败或者决定失败,通常这被称为Abort。对于大部分的事务系统,我们需要能够处理,例如当一个事务尝试访问一个不存在的记录,或者除以0,又或者是,某些事务的实现中使用了锁,一些事务触发了死锁,而解除死锁的唯一方式就是干掉一个或者多个参与死锁的事务,类似这样的场景。所以在事务执行的过程中,如果事务突然决定不能继续执行,这时事务可能已经修改了部分数据库记录,我们需要能够回退这些事务,并撤回任何已经做了的修改。
实现事务的策略,我会划分成两块,在这门课程中我都会介绍它们,先来简单的看一下这两块。
另一个有关实现的大的话题是原子提交(Atomic Commit)。它帮助我们处理类似这样的可能场景:前面例子中的事务T1在执行过程中可能已经修改了X的值,突然事务涉及的一台服务器出现错误了,我们需要能从这种场景恢复。所以,哪怕事务涉及的机器只有部分还在运行,我们需要具备能够从部分故障中恢复的能力。这里我们使用的工具就是原子提交。我们后面会介绍。
第一种主要策略是悲观并发控制
那就是在事务使用任何数据之前,它需要获得数据的锁。如果一些其他的事务已经在使用这里的数据,锁会被它们持有,当前事务必须等待这些事务结束,之后当前事务才能获取到锁。在悲观系统中,如果有锁冲突,比如其他事务持有了锁,就会造成延时等待。所以这里需要为正确性而牺牲性能。
第二种主要策略是乐观并发控制
这里的基本思想是,你不用担心其他的事务是否正在读写你要使用的数据,你直接继续执行你的读写操作,通常来说这些执行会在一些临时区域,只有在事务最后的时候,你再检查是不是有一些其他的事务干扰了你。如果没有这样的其他事务,那么你的事务就完成了,并且你也不需要承受锁带来的性能损耗,因为操作锁的代价一般都比较高;但是如果有一些其他的事务在同一时间修改了你关心的数据,并造成了冲突,那么你必须要Abort当前事务,并重试。这就是乐观并发控制。
实际,这两种策略哪个更好取决于不同的环境。如果冲突非常频繁,你或许会想要使用悲观并发控制,因为如果冲突非常频繁的话,在乐观并发控制中你会有大量的Abort操作。如果冲突非常少,那么乐观并发控制可以更快,因为它完全避免了锁带来的性能损耗

两阶段提交
这里假设系统中的每一个消息都被打上唯一的事务ID作为标记。这里的ID在事务开始的时候,由事务协调器来分配。这样事务协调器会发出消息说:这个消息是事务95的。同时事务协调器会在本地记录事务95的状态,对事务的参与者(例如服务器S1,S2)打上事务ID的标记。
接下来,让我画出两阶段提交协议的一个参考执行过程。我们将Two-Phase Commit简称为2PC。参与者有:事务协调者(TC),我们假设只有两个参与者(A,B),两个参与者就是持有数据的两个不同的服务器。
事务协调者运行了整个事务,它会向A,B发送Put和Get,告诉它们读取X,Y的数值,对X加1等等。所以,在事务的最开始,TC会向参与者A发送Get请求并得到回复,之后再向参与者B发送一个Put请求并得到回复。
这里只是举个例子,如果有一个复杂的事务,可能会有一个更长的请求序列。
释放所有的锁,
并使得事务的结果对于外部是可见的,
再向客户端回复。
我们假设有一个外部的客户端C,它在最最开始的时候会向TC发请求说,请运行这个事务。并且之后这个客户端会等待回复。
在开始执行事务时,TC需要确保,所有的事务参与者能够完成它们在事务中的那部分工作。更具体的,如果在事务中有任何Put请求,我们需要确保,执行Put的参与者仍然能执行Put。TC为了确保这一点,会向所有的参与者发送Prepare消息。
当A或者B收到了Prepare消息,它们就知道事务要执行但是还没执行的内容,它们会查看自身的状态并决定它们实际上能不能完成事务。或许它们需要Abort这个事务因为这个事务会引起死锁,或许它们在故障重启过程中并完全忘记了这个事务因此不能完成事务。所以,A和B会检查自己的状态并说,我有能力或者我没能力完成这个事务,它们会向TC回复Yes或者No。
事务协调者会等待来自于每一个参与者的这些Yes/No投票。如果所有的参与者都回复Yes,那么事务可以提交,不会发生错误。之后事务协调者会发出一个Commit消息,给每一个事务的参与者,
之后,事务参与者通常会回复ACK说,我们知道了要commit。
当事务协调者发出Prepare消息时,如果所有的参与者都回复Yes,那么事务可以commit。如果任何一个参与者回复了No,表明自己不能完成这个事务,或许是因为错误,或许有不一致性,或许丢失了记录,那么事务协调者不会发送commit消息,
它会发送一轮Abort消息给所有的参与者说,请撤回这个事务。
在事务Commit之后,会发生两件事情。首先,事务协调者会向客户端发送代表了事务输出的内容,表明事务结束了,事务没有被Abort并且被持久化保存起来了。另一个有意思的事情是,为了遵守前面的锁规则(两阶段锁),事务参与者会释放锁(这里不论Commit还是Abort都会释放锁)。
实际上,为了遵循两阶段锁规则,每个事务参与者在参与事务时,会对任何涉及到的数据加锁。所以我们可以想象,在每个参与者中都会有个表单,表单会记录数据当前是为哪个事务加的锁。当收到Commit或者Abort消息时,事务参与者会对数据解锁,之后其他的事务才可以使用相应的数据。这里的解锁操作会解除对于其他事务的阻塞。这实际上是可序列化机制的一部分。
https://mit-public-courses-cn-translatio.gitbook.io/mit6-824/lecture-12-distributed-transaction/12.4-cuo-wu-chu-li
故障恢复
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本