查看原文
其他

由浅入深理解Paxos协议(1)

宋利兵 MySQL代码研究 2022-08-17

0 - 分布式系统

这里的分布式系统是指将数据以多个副本的形式存储在不同的节点上的分布式系统。所有节点上存储着同样的数据,通过数据的冗余备份来提升系统的可用性和性能。出于高可用和高性能的考虑,越来越多的系统采用了分布式的结构。

- 保持数据同步(多节点同时更新)

分布式系统中一个核心的工作是保持多个副本之间的数据的同步。保持数据同步的一种方法是在多个节点上同时更新数据。这种方法中通常会有一个请求分发器,客户端将操作请求发送到分发器上,再由分发器发送到多个节点上进行数据操作。分发器会对并发的请求进行排序,然后按照同样的顺序发送到所有的节点上。因此可以保证所有的节点上数据的一致性。

但是模型中的请求分发器又会成为单点故障点,需要做冗余备份。最理想的方式是将分发器集成到每一个副本节点上,形成一个自动冗余的分布式系统,如下图所示。

在这个模型中,每个分发器都可以直接和所有的副本通讯,发送操作指令到各个副本上。


- 并发更新的一致性问题

当多个分发器同时发送操作指令到多个副本时,同时的数据操作很可能发生下图所示的冲突。

两个操作指令同时对A做了更新,结果可能导致一个节点上的数据变成了2,另一个则变成了5,这是不能接受的。


通常来讲当用户同时发起更新时,在应用层面这些更新操作是并发的没有顺序的。也就意味着更新数据时谁先谁后,应用都是可以接受的。但是在数据层面必须要以同样的顺序更新所有节点上的数据副本,这样才能保证多个节点间数据的一致性。合理的操作顺序应该是以下两种:

也就是说不论哪个在前哪个在后,在所有的节点上它们都要保持同样的顺序进行操作才行。


- 解决并发更新的一致性问题

解决并发问题的核心思路是对所有的操作请求进行排序,让所有的节点按照同样的顺序来执行这些操作。排序的功能可以实现在分发器里,那么分布式系统就变成了如下的模型:

数据更新的过程如下:

  1. 客户发送操作请求到任意一个节点的分发器上

  2. 分发器接收到请求后,将请求广播到其他节点上的分发器,并且这些分发器之间会对所有的并发请求进行排序。最终每个节点的分发器上都会有一份完全一样的请求列表。这个功能通常称作原子广播(Atomic Broadcast)或者全局排序广播(Total Order Broadcast)。

  3. 分发器将列表中的操作请求按照顺序送给本节点的数据副本。


在这个模型中,原子广播的逻辑和业务逻辑是分开的。这么做的好处是非常明显的,业务逻辑的实现不再受分布式需求的限制,原子广播的逻辑则不需要考虑业务逻辑的具体需求。独立的原子广播的逻辑可以被重用到很多的分布式的应用上。


- 顺序更新的效率问题

有人会问:原来并发的操作,现在变成了顺序的执行。执行效率不就变差了吗?确实会有这个问题,但这个问题是可以解决的,因为这个问题和具体的应用是相关的,通常会放在应用的逻辑中去解决。当副本收到分发器发送过来的请求后,可以根据一定的逻辑将这些请求中可以并发执行的操作安排并发执行。不能并发执行的部分任然顺序的执行。MySQL的Binlog复制就是一个很好的例子:Master上所有的操作都被顺序的存储到Binlog中。在被复制到Slave上后,Slave会根据Binlog Event中的信息重新安排可以并发执行的事务并发执行。


另外,当顺序更新的效率不影响正常的业务进行时,应用中就不需要实现并发执行的逻辑。


1 - 原子广播(Atomic Broadcast)系统

原子广播系统的简单模型如下图所示:

这个系统中不需要对数据进行更新,而看起来更像是一个分布式的数据存储系统(或日志系统),将用户发送过来的数据(日志)按照同样的顺序存储起来。图中展示的是一个存储队列,你也可以把它设计成日志文件(顺序追加的文件)或者管道等等。这里要强调的是,这个存储队列(日志)的每个位置只能存储一次数据。一旦有任何数据存储进去后,就不能再更改。


- Paxos在分布式系统中的作用

Paxos协议在分布式系统中的作用就是原子广播,它是原子广播的一个具体协议。在理解Paxos协议时,将其看做是一个原子广播系统会更容易理解。


2 - 原子广播(Atomic Broadcast)系统的实现

为了理解原子广播系统(Paxos)的实现原理,下面将从一个最粗糙最简单原型开始,在这个原型基础上一步一步的解决碰到的问题,最终实现一个原子广播协议(Paxos)。


- 初始原型

首先我们将数据的存储过程定义如下:

  1. 收到客户端的数据存储请求后,选择一个存储位置。发送数据储存指令给其他的分发器,同时将数据存储到自己的存储队列中。

  2. 当收到其他分发器发送的存储指令后,将数据存储到自己的存储队列。如果该位置已经存储了数据,则返回失败。

  • 数据存储指令的内容

<存储位置,数据>

  • 存储位置的选择

选择最小的空存储位。


初始原型如下图所示:当收到用户的数据X之后,选择位置1来存储数据。分发器1发送存储指令给分发器2,并将X写入存储队列的位置1中。分发器2收到存储指令后,将X写入存储队列的位置1中。


- 初始原型的问题

以上的做法在并发系统中是无法保证数据的一致性的。当两个分发器同时收到数据,可能会导致同一个位置存储了不同的数据,如下图所示:


- 基于锁的原型

为了避免顺序不一致,首先想到的就是用加锁的方式来保证一致性。这个过程定义如下:

  1. 当收到用户的数据之后,选择一个存储位置。发送加锁指令给其他分发器,并给本地存储队列加锁(假设本地存储位置没有被其他的节点加锁。如果已经被加锁了,选下一个没有被加锁的存储位置)。

  2. 收到加锁指令后,检查指定的存储位置是否已经被加锁。如果没有被加锁,则加锁返回成功。如果已经被别的分发器加锁了,就返回失败。

  3. 当所有的节点返回加锁成功后,发送存储指令给所有的分发器,并将数据写入自己的相应存储位置。

  4. 当收到存储指令后,将数据存储到自己的存储队列中相应的位置。

  • 加锁指令内容:

<存储位置>


加锁原型的执行过程如下图所示。当收到用户的数据X之后,分发器1发送加锁指令给分发器2,并对本地的存储位置加锁。

当分发器2返回加锁成功后,发送存储指令给分发器2。并将X写入自己的存储队列的1的位置。


- 死锁问题

增加了加锁的过程后,虽然不会有数据不一致,但是可能会导致死锁。如下图所示:

图中分发器1、分发器2分别给自己的存储队列位置1加锁成功。当分别去给对方的位置1加锁时,会被阻塞。


- 优先级锁规则

死锁的检测效率在单机系统中已经很不好了,在分布式系统中效率会更差。而且会增加网络通讯的开销。有没有不产生死锁的办法呢?有一个办法,姑且称之为优先级锁规则

  • 每个锁请求都有一个优先级,优先级高的锁请求可以撤销优先级低的锁。

  • 如果一个存储指令的锁被撤销了,就不能被执行。

如图所示,我们假设p1,p2是锁请求的优先级。p2比p1的优先级高。

  • 在分发器1上,当分发器2的请求到达时,分发器1已经加锁成功,由于分发器2的锁优先级p1小于分发器1的锁优先级p2。因此分发器2的请求会被阻塞。

  • 在分发器2上,当分发器1的请求到达分发器2上时,分发器2已经加锁成功。但是由于分发器1锁的优先级p2大于分发器2的锁优先级p1。分发器1的锁请求就会撤销分发器2的锁。最终分发器1能够在所有的节点上加锁成功。


优先级的定义

优先级的具体值是什么,不同的系统中可以随意定义,但是要满足下面两个条件:

  1. 能够区分出大小

  2.  不能有重复

比如我们可以用分发器的ID来做优先级。但由于分发器的ID是固定不变的,就会导致某个分发器始终比别的分发器优先级高。这显然会造成请求处理的不均衡(另外还有故障恢复的考量)。为了更好的均衡各个客户的请求处理,可以采用下面的优先级定义:

  • <数值,分发器ID>

数值是由分发器自己自由指定,这样保证每个分发器有均等的机会去获得锁。而分发器的ID是唯一的,因此可以保证优先级没有重复的情况。比较优先级时,先对比数值。如果数值相同,则比较分发器ID来决定谁的优先级高。


使用优先级锁的存储过程总结如下:

  1. 当收到用户的数据之后,选择一个存储位置,选择一个随机数作为锁的优先级发送加锁指令给其他分发器,并给本地存储队列加锁(假设本地存储位置没有被其他的节点加锁。如果已经被加锁了,选下一个没有被加锁的存储位置)。

  2. 收到加锁指令后,检查指定的存储位置是否已经被加锁。如果没有被加锁,则加锁返回成功。如果已经被别的分发器加锁了,判断锁请求的优先级,是否高于已经加的锁的优先级。如果当前锁请求的优先级更高,就撤销原有的锁,重新加锁,返回成功。

  3. 当所有的节点返回加锁成功后,发送存储指令给所有的分发器,并将数据写入自己的相应存储位置。

  4. 当收到存储指令后,将数据存储到自己的存储队列中相应的位置。

  • 加锁指令内容:

<存储位置,优先级>


- 节点故障导致的问题

前面说过,每个分发器都是严格的按照顺序将存储队列里的数据顺序发送给副本去执行。它隐含了下面的一个要求:

  • 不能跳过任何一个位置。如果某个位置没有数据,就要等待,直到有数据写入。

如图假设:位置1和位置3分别写入了数据X和Y,位置2还没有写入数据。由于位置2没有数据,分发器在将位置1的数据X发送给副本后,就要等待位置2写入数据。即使位置3已经写入了数据,也不能发送给副本。

直到位置2写入数据后,把位置2的数据发送给副本。然后才能将位置3的数据发送给副本。


这个逻辑在所有分发器都正常的工作时,是没有问题的。但是实际环境中,总有节点可能发生故障(或者是网络故障)。当一个分发器发生故障时,就会导致这个系统无法正常工作。因此原子广播系统中必须要有故障的处理机制。常见的做法是当超时一段时间后,就认为某个节点故障了。然后对故障节点所占有的数据位置做相应的处理。故障发生时,故障节点占有的数据位置的状态有以下几种:

  1. 只是加锁成功,没有在任何节点写入数据。

  2. 部分在线节点已经写入数据。


  3. 故障副本的相应存储位置已经写入数据,其他在线节点没有写入数据。


- 接管故障节点占有的存储位置

因为有优先级锁的机制,接管一个故障节点占有的存储位置是相当容易的。只需要任何一个在线节点发起一个更高优先级的锁请求就可以了。对于故障状态1来说,加锁成功的节点写入自己的数据就可以了。


不过考虑到分发器从客户端接收到的数据请求可能是有顺序的依赖关系的,因此数据的顺序在存储位置中不能颠倒。如下图所示,Z和Y有依赖关系,Z必须要在Y之后执行。当分发器1故障后,分发器2接管了位置2,这时不能将Z写入位置2,因为Y已经被存储到了位置3。

不能写入用户数据,这个位置又不能空着,那么该写入什么数据呢?这里我们定义了一个特殊的数据,称作空操作(NOOP)。当给应用发数据时,就知道这不是应用的数据,把它丢掉就可以了。


接上面的例子,分发器2接管位置2后,写入空操作,Z按正常的顺序将被写到Y数据之后的位置中。


- 重播机制

下面来处理第二种故障状态。

  • 部分在线节点已经写入数据。

前面说了,存储队列一旦写入数据,就不能再更改。很显然我们需要一个机制来将部分节点写入的数据传播到其他的节点上,而不是写入空操作。这样才能保证所有节点数据的一致性。我们对原有的数据存储过程做如下修改:

  • 收到加锁指令后,检查指定的存储位置是否已经被加锁。如果没有被加锁,则加锁返回成功。如果已经被别的分发器加锁了,判断锁请求的优先级,是否高于已经加的锁的优先级。如果当前锁请求的优先级更高,就撤销原有的锁,重新加锁,返回成功。如果该位置已经写入数据,则将数据一并返回。

  • 当所有的节点返回加锁成功后,检查是否有数据返回。如果有数据返回,则将数据放入存储指令。发送存储指令给所有的分发器,并将数据写入自己的相应存储位置。如果没有数据返回,则将空操作(NOOP)放入存储指令。发送存储指令给所有的分发器,并将空操作写入自己的相应存储位置。

- 预写入机制

下面来处理第三种故障状态。

  • 故障副本的相应存储位置已经写入数据,其他在线节点没有写入数据。

尽管分发器1故障了,我们希望它恢复之后(也许只是网络的临时中断)能够和其他分发器保持存储数据的一致。但是这种状态下,在线的分发器是没办法获取到X这个数据,没办法把它重播到其他的节点上去。为了能够保持一致,给原有的数据存储流程增加预写入的步骤。

  1. 发送加锁指令。

  2. 接收到加锁指令后,加锁。

  3. 当所有的节点返回加锁成功后,发送“预存储指令给所有的分发器,并将数据写入“预存储队列”。

  4. 当收到“预存储指令”后,将数据存储到“预存储队列”。如果“预存储指令”的锁已经被撤销,返回失败。

  5. 当所有的节点返回预存储成功后,发送存储指令给所有的分发器,并将数据写入自己的存储队列中相应存储位置。

  6. 当收存储指令后,将数据存储到自己的存储队列中相应的位置。

  • 预存储队列

    每个分发器中创建了一个新的数据队列称之为预存储队列,预写入是指将数据存储到预存储队列中。预存储队列中的数据可以被擦除、覆盖。

  • 预存储指令:

<存储位置,数据>


当故障发生时,重播机制则改为:

  • 收到加锁指令后,检查指定的存储位置是否已经被加锁。如果没有被加锁,则加锁返回成功。如果已经被别的分发器加锁了,判断锁请求的优先级,是否高于已经加的锁的优先级。如果当前锁请求的优先级更高,就撤销原有的锁,重新加锁返回成功。如果该位置已经预写入数据,则将数据一并返回

  • 当所有的节点返回加锁成功后,检查是否有数据返回。如果有数据返回,则将数据放入预存储指令。发送预存储指令给所有的分发器,并将数据预写入自己的预存储队列。如果没有数据返回,则将空操作(NOOP)写入预存储指令。发送预存储指令给所有的分发器,并将空操作写入自己的相应存储位置。


预写入的过程可以保证:如果数据被写入了任意分发器的存储队列,那么所有的节点上都能看到这个数据(在预存储队列里)。   

如上图当分发器1故障时,X已经写入了其他分发器的预存储队列。当其他节点接管位置1时,就会重播预存储队列里的X到所有节点。从而保证所有的分发器上的位置1中存储的是X(包括故障的节点)。


- 多数派原则

上面的协议中,加锁和预写入两步都需要所有的节点返回成功。如果任意一个节点故障了,就会导致整个系统无法工作,除非有新的节点加入,很不方便。经过研究发现,上面的协议可以容忍半数以下的节点发生故障。换句话说,在加锁和预写入时,只要超过半数的节点回复成功就可以了。


多数派原则,会带来一些更复杂的中间状态。和“全部派”协议相比,多数派原则下不同的分发器中可能预写入了不同的的值。我们只需要将故障的处理过程合并到正常数据处理的流程中来,就可以解决这个问题。完整的协议过程如下所示:

  1. 发送加锁指令。

  2. 收到加锁指令后,检查指定的存储位置是否已经被加锁。如果没有被加锁,则加锁返回成功。如果已经被别的分发器加锁了,判断锁请求的优先级,是否高于已经加的锁的优先级。如果当前锁请求的优先级更高,就撤销原有的锁,重新加锁返回成功。如果已经预写入数据,则将数据一并返回。

  3. 超过半数的节点返回加锁成功后,检查是否有数据返回。如果有数据返回,则将优先级最高的数据放入预存储指令。如果没有数据返回,则将自己的数据放入预存储指令。发送预存储指令给所有的分发器,并将数据写入“预存储队列”。

  4. 当收到预存储指令后,将数据存储到“预存储队列”。如果预存储指令的锁已经被撤销,返回失败。

  5. 超过半数的节点返回预存储成功后,发送存储指令给所有的分发器,并将数据写入自己的存储队列中相应存储位置。

  6. 当收存储指令后,将数据存储到自己的存储队列中相应的位置。


到此为止,这个原子广播协议已经完美的达成了全局排序的目标。然而性能还不过理想,下一篇的博客会介绍原子广播协议(Paxos)性能的改进。



欢迎订阅我的公众号"MySQL代码研究",这个公众号里的文章,多数是我在开发过程中学习、研究MySQL代码和相关技术的总结和笔记,希望会对你有帮助。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存