回到顶部 暗色模式

分布式数据系统:主从节点

1. 主从模式

1.1 同步复制

        对于关系型数据库,同步或者异步通常是一个可选项,而其他系统可能是硬性指定二选一。
        同步复制的优点是:一旦向用户确认,从节点可以保证已经完成了与主节点的同步,数据已经处于最新版本。如果主节点发生故障,总是可以确保之后继续访问从节点的数据一定是最新的。缺点是:如果同步的从节点无法确认成功,整个写入就不能成功,主节点会阻塞之后的所有写操作,直到同步副本确认。由于该缺点的存在,把所有从节点都配置为同步复制有些不切实际。在实践中,如果数据库启用了同步复制,通常意味着其中某个从节点是同步的,其他从节点则是异步的。如果同步的从节点不可用或者性能下降,则将另一个从节点升级为同步。这样可以保证至少有两个节点拥有最新的数据,这种配置也被称为半同步
        在主从模式下,如果要提高读性能,需要添加更多的从节点。但是,这种方式实际上只能用于异步模式,因为随着从节点的增加,全同步模式需要同步的从节点数量也会增加,任何单节点的故障或者网络中断都会导致整个集群无法写入,节点的增加也会提高故障机率,所以全同步模式在实践中是非常不可靠的。

1.2 异步复制

        主从复制通常会被配置为全异步模式。此时,如果主节点失败且不可恢复,则所有尚未同步的从节点的写请求都会丢失,即使已经向客户端确认了写操作完成,仍然无法保证数据的持久化。全异步模式的优点是,不管从节点的数据多么滞后,总是可以继续响应写请求,具有较高吞吐性。异步模式听起来很不靠谱,但是却被广泛使用,特别是那些从节点数据巨大,或者分布于广域地理环境的情况。
        异步模式下节点的数据同步可能存在滞后,意味着对主节点和从节点同时发起相同的查询,返回的结果可能是不一致的。但是,这种不一致只是暂时的状态,在不写数据库的情况下,从节点最终会与主节点的数据保持一致,这种效应也被称为最终一致性
        理论上,复制的滞后并没有上限。正常情况下,这个延迟可能不到 $1s$ ,实践中通常不会有太大影响。但是,如果系统的性能抵达上限,或者存在网络问题,延迟可能会达到几秒甚至几分钟。

1.2.1 读写一致性

        许多应用让用户提交数据,并在之后查询这些数据。用户向主节点提交数据后,之后的查询可能是在从节点上,在大多数情况下,这是个很合适的方案。然而,异步模式下,同步可能存在滞后,意味着,返回的数据是旧数据,在用户看来,代表他刚刚提交的数据丢了。
        这种情况,我们需要读写一致性,或者叫写后读一致性。该机制保证,如果用户重新加载页面,总是能看到最近提交的更新。有几种方式可以实现读写一致性:

        如果同一用户从多端访问 ( 比如Web端和移动端 ),情况就更加复杂了,不仅要提供跨设备的读写一致性,还有新问题:

1.2.2 单调读

        假定用户发起多次读取,读请求可能会被路由到不同节点,则可能会出现请求返回不同结果的情况。单调读一致性可以确保不会发生这种异常。单调读一致性比强一致性弱,但是比最终一致性强。读取数据时,单调读保证同一个用户一次发起的多次读取不会看到回滚 ( 数据不一致 ) 现象。一种实现单调读的方式是:确保每个用户总是固定读同一个节点,例如基于用户ID哈希。

1.2.3 前缀一致读

        用户发起两个请求,后一个请求的内容依赖于前一个请求,比如用户先写入 $1$ ,再递增为 $2$ 。从用户的角度,这个顺序没有问题。但是在其他观察者的角度,可能会存在逻辑问题,比如由于网络延迟,观察者先观察到后一个请求,这时顺序就变成了用户先写入 $2$ ,再写入 $1$ 。为了防止这种异常,需要引入前缀一致性,即对于一系列按照某个顺序发起的写请求,读取的时候也应该按照这个顺序。
        这个问题是存在于分区数据库的一个特殊问题。如果数据库总是以相同的顺序写入,那么读取的时候看到的会是一致的序列。但是,分区数据库的不同分区之间是独立运行的,所以没有一个全局的写入顺序,导致用户读取的时候,会读到一部分新值和一部分旧值。一种解决方案是:将所有具有因果关系的写入都交给同一个分区完成,但是会导致效率大打折扣。

1.3 节点失效

1.3.1 从节点失效

        从节点的磁盘上保存了数据变更日志,如果从节点崩溃,或者与主节点之间发生暂时性的网络中断,可以通过该日志获取故障前处理的最后一个事务,向主节点请求该事务之后的中断期内所有数据变更,将其应用到本地即可,之后就可以像正常情况一样持续接收来自主节点的数据流变化。

1.3.2 主节点失效

        主节点失效,则需要选择某个从节点,将其提升为主节点。同时,客户端也要将之后的写请求发送给新的主节点。故障切换可以是手动的,也可以是自动的,自动切换的步骤如下:

  1. 确认主节点失效。主节点可能出于多种原因失效,比如系统崩溃、停电、网络中断等,并没有什么办法可以检测出失效原因,所以大多数系统都采用了超时机制判断。节点间会持续地互相发送心跳消息,如果发现某个节点在一段时间 ( 比如 $30s$ ) 内都没有响应,就认为该节点已经失效;
  2. 选举新的主节点。新的主节点可以通过一种共识算法来选举,或者由控制节点指定。候选节点最好是与主节点之间数据差异最小的,从而最小化数据丢失的风险;
  3. 重新配置系统使得新主节点生效。客户端需要将写请求发送给新的主节点,如果原来的主节点之后重新上线,需要将其降级为从节点,并认可新的主节点。

        切换过程中可能存在很多变数:

1.4 日志

1.4.1 语句复制

        主节点记录执行的写请求语句,将该操作语句作为日志,发送给从节点。在关系型数据库中,意味着发送 $INSERT$ 、$UPDATE$ 等语句,从节点分析并执行这些语句,像来自客户端那样。基于语句复制存在一些不适用场景:

        这些问题可以采取某些措施来解决,比如主节点记录操作语句时将非确定性函数替换为确定结果。遗憾的是,存在太多边界条件,因此基于语句的复制通常不是首选。

1.4.2 WAL

        通常,数据库的每个写操作,都会以追加形式写入WAL日志中,可以使用完全相同的日志在另一个节点上构建副本,即主节点不仅将WAL日志写入磁盘,还会通过网络发送给从节点。
        基于WAL复制的缺点是日志描述的数据结果非常底层,比如关系型数据库,WAL会记录磁盘块的哪些字节发生改变,以及其他细节等,从而与具体的存储方案高度耦合,如果数据的存储格式发生了改变,之前的同步方式也会失效。如果复制协议允许从节点的软件版本比主节点更新,那么可以实现数据库软件的不停机升级;相反,如果要求版本严格一致,那么升级就只能以停机为代价。

1.4.3 行复制

        基于行的复制将复制和存储引擎分离,复制与存储引擎采用不同的日志,这时复制日志称为逻辑日志。

        如果一个事务涉及多行数据修改,就会产生多条行日志,并在之后跟着一条记录,指出该事务已经提交。MySQLbinlog支持该种方式。由于逻辑日志与存储引擎解耦,因此数据库存储可以向后兼容,主从节点可以基于不同版本的软件,甚至是不同存储引擎运行。
        对于外部应用,逻辑日志也更容易被解析。解析逻辑日志也被称为变更数据捕获。

1.4.4 触发器

        在某些时候,我们可能需要一种具有更高灵活性的复制方式,比如只复制一部分数据,或者从一个数据库复制到另一种数据库,或者指定冲突解决逻辑等,这种情况下,可以借助许多关系数据库都支持的功能:触发器和存储过程。触发器支持注册自己的应用层代码,当数据库系统发生数据改变时,自动执行这些代码。基于触发器的复制通常具有比其他方式更高的开销,也更容易出错。

2. 多主模式

        目前为止,我们只考虑了单主节点的主从复制架构,这也是较为常见的方式。但是,也存在一些其他方式。主从复制的缺点很明显:只存在一个主节点,所有写入都必须经过主节点。如果存在某种原因,比如网络中断等,导致主节点无法连接,那么整个集群的写入都会受到影响。

2.1 多主节点

        多主节点是一种对主从复制模型的扩展,允许配置多个主节点,每个主节点都可以接收写操作,并将数据更改转发到其他节点。这样,每个主节点既扮演主节点,也扮演其他主节点的从节点。
        多主节点存在以下适用场景:

2.2 写冲突

        多主复制的最大问题是存在写冲突。

2.2.1 同步与异步检测

        如果是主从复制,发生冲突时,第二个写请求会被阻塞,直到第一个完成,或者被中止。在多主节点模式下,这两个写请求都会成功,并且只能在之后的异步检测时才能发现冲突。理论上,同步检测冲突也是可行的,但是需要写请求等待所有副本完成同步,反而会失去多主节点的优势。

2.2.2 冲突避免

        如果应用层可以保证对某个特定记录的写总是经过同一节点,就不会发生冲突。实践中,不少主节点复制模型锁实现的冲突解决方案存在瑕疵,因此,冲突避免通常是首选方案。比如,一个用户需要更新自己的数据,我们可以通过用户ID哈希的方式,保证同一个用户的写请求总是发送到同一个数据中心。从用户角度来看,等同于主从复制。有时候,可能需要改变指定的节点,比如数据中心故障,或者用户漫游到另一个位置,需要换到更近的数据中心,这时这种方案就不再有效了。

2.2.3 一致收敛

        主从模型下,数据更新符合顺序性原则,即对同一个字段的多次更新,字段最终值由最后一个更新操作决定。多主节点模型中,由于不存在这个顺序,所以最终值也是不确定的。如果每个副本都是按照其看到的顺序写入,那么数据最终可能会不一致。因此,数据库必须以一种收敛趋同的方式解决冲突,这也意味着所有更改被复制、同步之后,所有副本的最终值相同。一致收敛有如下解决方案:

2.2.4 自定义冲突解决逻辑

        解决冲突最合适的还是依赖于应用层代码,所以大多数多主模型都支持用户编写应用代码解决冲突:

        冲突解决通常针对某一行或者某个文档,而不是整个事务。如果有一个原子事务包含多个不同写请求,每个写请求仍然需要分开来考虑。

2.3 拓扑结构

        多主模式下,写请求从一个节点传播到其他节点的路径,可以通过拓扑结构描述。最常见的拓扑结构是全链路拓扑:主节点将写入同步到其他所有主节点。除此之外,还存在着其他一些拓扑结构,例如,默认情况下MySQL只支持环形拓扑结构,即所有主节点围成一个环,每个节点只接收来自前序节点的写入,只会写后续节点。星形结构也是另一种流行的拓扑结构,一个指定的根节点会将写入转发给所有其他节点,其他节点只会写该根节点。类似的,也可以对星形结构进行推广,拓展到树形结构。
        环形结构和星形结构下,写请求需要通过多个节点才能到达所有副本,中间节点需要转发从其他节点收到的数据变更。为了防止循环,每个节点需要一个唯一ID,并在变更日志中标识已经经过的节点ID。环形和星形的问题是,如果某个节点发生故障,会影响其他节点的转发。可以通过重新配置的方式排除故障节点。全链路拓扑也有自己的问题,通常发生在网络拥塞的情况下,某些网络链路可能会比其他网络链路更快,导致变更日志的互相覆盖。

3. 无主模式

        目前为止讨论的所有复制方法,都要求客户端先向主节点发送写请求,再由数据库复制到其他副本。主节点决定写顺序,从节点重放。一些数据存储系统则采取了不同的思路,放弃主节点,允许任何节点接收来自客户端的写请求。最早的数据复制系统就是无主节点的,但是在后来被渐渐遗忘了,当亚马逊采用Dynamo系统之后,无主模式又成为了一种时髦的架构,RiakCassandraVoldemort都是受Dynamo启发设计的无主节点的开源数据库系统,也被称为Dynamo风格数据库。
        在一些无主节点实现中,客户端直接将写请求发送给多副本。而在一些其他实现中,会有一个协调者代表客户端写入。与主节点不同,协调者并不负责维护写入顺序。

3.1 节点失效

        假设存在一个三副本数据库,一个副本不可用。用户向三个副本发起写请求,有两个副本成功确认,一个副本无法处理。用户在接收到两个成功确认后,认为写入成功。之后,失效节点重新上线,由于滞后数据尚未同步,客户端读取该节点数据,会得到过期数据。一种解决方式是,客户端读取时,不是只向一个副本发起请求,而是并行向多个副本发起请求,并通过版本号来确定哪些值更新。

3.1.1 读修复和反熵

        节点失效滞后重新上线,由于数据滞后,所以需要重新同步。Dynamo风格数据库通常使用以下两种机制:

        要注意的是,并非所有的数据库都支持以上机制,例如Voldemort没有反熵。

3.2 quorum

        如果有 $n$ 个副本,写入需要 $w$ 个节点确认,读取必须至少要查询 $r$ 个节点,则只需要 $w + r$ > $n$ 就可以确保读取的节点一定会包含最新值。满足这些的 $r$ 、$w$ 值称为法定票数读 ( 或仲裁读 ) 或法定票数写 ( 或仲裁写 )。也可以认为 $r$ 和 $w$ 是用于判定读、写是否有效的最低票数。
        在Dynamo风格数据库中,$n$ 、$r$ 和 $w$ 通常是可以配置的,常见的是配置 $n$ 为奇数,$w = r = \lceil (n + 1) / 2 \rceil$ ,也可以根据需求灵活调整。$w + r$ > $n$ 定义了系统可容忍的失效节点数:

        如果可用节点数小于 $r$ 或者 $w$ ,写入或者读取就会返回错误。通常 $r$ 和 $w$ 会被设置为简单多数节点,但是quorum并不一定要求多数,只要读写节点之间存在重合节点即可。即使在 $w + r$ > $n$ 的情况下,也可能存在返回旧值的边界条件:

        Dynamo风格数据库是针对最终一致性场景设计的,$w$ 和 $r$ 不应该是一种绝对保证,而是一种可以灵活调配的配置。配置适当quorum的数据库系统可以容忍某些节点故障,也不需要执行故障切换。它还可以容忍某些节点变慢,因为请求并不需要等待所有 $n$ 个节点响应。对于一些需要高可用和低延迟的场景,还可以容忍偶尔读取旧值。
        但是,quorum并不是总会提供高容错能力,一个网络中断可以很容易切断客户端到多数节点的连接。在断掉连接的客户端看来,相当于集群全部失效。在一个大规模集群中,客户端可能在网络中断时还可以连接到某些节点,但是这个节点数量又不能满足仲裁,数据库就面临一个选择:直接返回错误还是接收写请求并写入暂时可访问的节点?后一种方式称为宽松的仲裁:读写仍然需要 $r$ 和 $w$ 个成功的响应,但包含了那些并不在先前指定的 $n$ 个节点的响应。一旦网络问题解决,临时节点需要把接收到的所有写入发送给原始节点,即数据回传。$sloppy\ quorum$ 并非传统 $quorum$ ,更像是为了保证数据持久性设计的一个保证措施,除非回传结束,否则无法保证客户端一定能从 $r$ 个节点中读到新值。

4. 并发写

        Dynamo风格数据库允许多个客户端对相同主键发起写操作,意味着写冲突的存在。此外,读修复或者反熵也会导致写冲突。一个核心问题是,由于网络延迟或者部分节点失效,请求在不同节点上的顺序是不同的。如果节点每次收到写请求时就直接覆盖原有值,那么这些节点将永远也无法一致。

4.1 最后写入者获胜

        一种实现收敛的方法是,每个副本总是保留最新的值。假定每个写请求都会最终同步到每个副本,我们需要一个明确的方法来确定哪个写入是最新的。多个客户端向节点发送请求时,一个客户端无法发现另一个客户端,也不知道哪个请求先发起。既然无法确定请求的自然排序,我们可以强制以某种方式排序,比如以时间戳排序。这种解决方法称为最后写入者获胜 ( $last\ write\ wins$ , $LWW$ )。LWW可以实现最终收敛,代价是数据持久性。如果同一个主键有多个并发写,即使这些写都返回成功,但是最后只会有一个值获胜,其他的将被丢弃。
        在一些场景,比如缓存中,覆盖写是可以接受的。如果覆盖、丢失不可接受,LWW就不是很好的解决方案了。确保LWW安全无副作用的唯一方法是,只写一次然后将写入值视为不可变。例如,Cassandra的一个推荐使用方法就是使用UUID作为主键,这样每个写操作都针对不同、系统唯一的主键。

4.2 Happens-before

        如何判断两个操作是否并发,可以通过以下两个例子分辨:

        如果操作 $B$ 知道操作 $A$ ,或者 $B$ 依赖于 $A$ ,则称 $A$ 和 $B$ 之间存在Happens-before ( 先序发生 ) 关系,这就是定义并发的关键。因此,对于两个操作 $A$ 和 $B$ ,存在三种可能:$A$ 先发于 $B$ 、$B$ 先发于 $A$ 、$A$ 和 $B$ 并发。如果属于并发,就需要解决潜在的冲突问题。

4.3 合并并发写的值

        通过版本号,服务器可以在不依赖值的情况下判断写是否并发。流程如下:

        如果写请求带有版本号,说明它修改的是以前的状态。如果没有版本号,将会与其他操作同时进行,不会覆盖已有值。
        上述算法适用于单副本的情况,但是对于多副本的情况,它们之间的版本号并不相同。为了保证一致,我们需要为每个副本和每个主键均定义一个版本号。每个副本在写入时增加自己的版本号,跟踪从其他副本看到的版本号。所有副本的版本号集合称为版本矢量。当读取数据时,版本矢量会被返回给客户端,并在随后写入时包含在请求中一起发送给数据库。

分布式数据系统:主从节点