1. 为什么需要分布式事务
首先,复杂的业务场景是需要事务的,这是毋庸置疑的,至于需要分布式事务则可能是因为业务数据太多,进行了分库,或者newsql数据酷中对数据进行了shard,将数据存放在了不同的服务器上,对这些不属于同一进程或不属于同一台物理机的数据进行操作时,也希望可以支持事务功能,这就是分布式事务。
2. 二阶段提交
目前来讲,比较常见的分布式事务实现方式是二阶段提交。主要实现方式如下:
- 从分布式集群中选择一个节点作为协调者
- 客户端将本次事务请求发给协调者
- 协调者收到请求后,在本地记录事务状态,然后将写入请求分发给所有需要参与该事物的其他节点,这些其他节点叫作参与者。
- 参与者收到请求后,在本地进行prepare操作,即对需要写入的数据加锁,然后将新版本数据写入,在本地记录事务状态,完成后给协调者回复一个prepareOk。如果失败则给协调者恢复rollback
- 协调者收到所有的prepareok之后,就可以提交事务,将事物状态改为commit,然后向所有参与者发起commit请求。
- 参与者收到commit请求后,完成事务提交,释放锁信息,给协调者进行回commitOk。
- 协调者收到所有commitOk之后,就可以回复客户端,删除事务信息,本次事务以成功提交而结束,。
- 如果在第5步中收到了任意参与者的rollback信息,那么协调者都将决定回滚该事物,流程和commit一致。
可以看到在步骤中,每次事务状态改变后,就需要持久化那么事务上下文的信息什么时候才能删除? 参与者在完成commit之后,就可以清理事务上下文。协调者需要在收到所有的commitOk请求后,才能清理事务上下文。协调者这时候才能清理的原因是要确保所有的参与者都收到了commit/rollback请求。看个例子,协调者决定提交一个事务,提交后向所有参与者发送commit请求,发送之后清理事务上下文,这时候有个参与者挂了,过了两分钟才重启,这时候该参与者永远也无法知道事务该提交还是回滚,因为协调者上的事务上下文已经被清理了。所以这是不安全的,协调者的上下文必须在知道所有节点都完成commit/rollback之后才能清理。
3. 二阶段提交能够满足ACID吗
首先分析一下2PC为什么符合原子性。虽然有多个参与者参与事务,但是事务状态的推动都是靠协调者来进行了,只有协调者可以推动事物的整体状态,所以当协调者做出决定的时候,事务要么是commit,要么是rollback,不可能存在一部分参与者提交,一部分参与者回滚的情况,不论如何,事务总是符合原子性的。 分析一下隔离性,在二阶段提交的过程中,所有的写入操作都会使用锁保护,如果不考虑性能,所有的数据读取也需要加锁,那么最终的对数据读写就能达到串行化,这是靠二阶段提交中加上SS2PL来实现的。当然也不一定非要使用锁的方式,和单机事务一样,也可以在二阶段提交中使用OCC等方式来进行并发控制。 持久性,上述算法协调者会在收到所有的commitOk请求后,完成事务提交,这时候所有参与者都已经将数据写入了,保证了持久性。
4. 异常处理
二阶段提交这种方案乍一看很简单,就是靠协调者根据各种情况,推动事物状态,但是在分布式场景中,可能出现各种预期之外的情况,下面简单分析一下:
4.1 协调者宕机
- 在prepare状态宕机。协调者收到客户端时候请求后会持久化事务上下文,那么重启之后可以恢复出来,继续给所有的参与者发送prepare消息,之后流程继续。只要协调者没进入到commit或者rollback状态,宕机重启流程都是一样的,只是参与者可能会收到相同的prepare请求,幂等处理即可(不能上次prepare进行了commit,这次相同的prepare请求却需要rollback)
- 在进入commit状态后宕机。其实和prepare相似,协调者会持久化commit状态,之后给所有参与者发送commit请求,如果这时候宕机了,能够恢复出commit事务上下文,给所有参与者发送commit消息。
4.2 参与者宕机
- 参与者宕机对事物状态就更没影响了,因为状态只靠协调者推进。但是参与者宕了之后,写调整需要进行一些处理,比如长期收不到所有节点的prepareOk请求,那么超时后参与者可以决定rollback。如果协调者长期收不到commitOk请求, 那么参与者需要一直尝试给未完成的节点发送commit请求,直到收到所有的commitOk,在此之前协调者也无法清理事务上下文。
2PC的故障处理非常麻烦,而且存在一些问题,比如某个事物协调者宕机了,但是所有参与者完成了prepare,这时候参与者就只能等协调者恢复,在此之前都必须持有之前上的锁,会阻塞集群的其他事务,这是2PC让人很不喜欢的一点,所以在spanner中进行了改进,使用paxos为每个节点进行3副本冗余,当某个节点宕机后其他副本迅速顶上,防止2PC阻塞住。
5. 优化
简单分析一下2PC提交的延迟:
- 客户端发送给协调者请求(1次网络)
- 协调者存储事务上下文(1次落盘)
- 协调者给所有参与者发请求(1次网络)
- 参与者收到prepare请求后写入数据并持久化事务状态(一次落盘)
- 参与者回复prepareOk(1次网络)
- 协调者完成提交,更新并持久化事务状态(1次落盘)
- 协调者给所有参与者发送commit(1次网络)
- 参与者更新事务状态为commit(1次落盘)
- 参与者回复commitOk(1次网络)
10.协调者结束事务,回复客户端(1次网络)
相当于6/2次网络来回,然后4次落盘。来看看如何优化延迟:
优化一,没必要等到所有节点都commitOk之后再回复请求,协调者更新了事务状态为commit之后,就可以对客户端进行提交,剩下的参与者提交和事务上下文回收可以异步进行。即上述的7,8,9步可以不需要,通过异步执行。
优化二,并行提交,客户端没必要把所有请求都发给参与者,让参与者给协调者发信息。客户端可以直接将请求发送给所有参与者,从所有参与者中选出一个协调者来处理事务即可。即1234可以合成为1步(客户端向所有参与者发起事务请求,并指定其中一个是协调者。)
优化三,第6步是收到了所有prepareOk后更新事务状态,然后持久化状态,根据优化1这时候就可以回复客户端。这一步的持久化状态可以不需要吗?理论上是可以行的,因此如果参与者进入了commit或者rollback状态,那一定是因为参与者全部完成了prepare,而参与者prepare的上下文在每个参与者中一定存在,所以这协调者持久化commit/rollback状态可以异步执行,即使宕机了,该信息丢失了,可以重启后,重新尝试向所有的参与者发起prepare请求,参与者会回复commit或者rollback请求,这样协调者可以回复出来commit或者rollback状态。
所以优化后最终逻辑为:
- 客户端向所有参与者发起请求,选择其中一个为协调者(1次网络)
- 所有参与者完成prepare阶段,更新事务状态(1次写盘)
- 所有参与者向协调者发起prepareOk请求(1次网络)
- 协调者收到所有prepareOk之后,完成提交,回复客户端(1次网络)
- 后面全是异步执行,定期给所有节点发送commit请求,收到所有commitOk后进入事务上下文清理阶段。
整体来说只需要1次写盘,就可以完成提交。
|