Design Data-Intensive Applications 读书笔记二十八 第九章:顺序保障

顺序保障

排序是本书中一再提及的问题。排序,线性化和一致性关联很深

 

排序与因果

排序的一个很重要的原因就是它能帮助维持因果。例如:

1、“一致性前缀”读取中,用户可能先看到答案,再看到提问,这不符合因果逻辑。

2、多主节点备份中,可能因为网络延迟,旧的写入会覆盖掉新的写入。

因果规定了事件发生的顺序:原因先于结果,消息先发送,然后才收到;先提出问题,再回答问题。如果系统的排序遵从因果,那么称之为因果一致。

 

因果排序不是全序

全序意味着集合中任意两个元素都可以了拿来比较。

线性化:在线性化系统中所有的操作都有整体的顺序:所有的数据都好像只有一个数据备份,每个操作都是原子操作,任意两个操作都能分清先后。

因果:如果两个操作同时发生,我们就称之为并发。换句话说,如果两个操作可以排序,那么它们就是因果相关,如果无法比较,就是并发的。这意味着因果性是部分排序的:一些操作可以与其他操作比较,但是一些是不能比较的。

所以,在线性化数据存储中,没有并发操作,必须有一个单一的时间线贯穿所有的操作。并发可以意味着时间线的分岔和合并,在不同分支上的操作是无法比较的。

 

线性一致强过因果一致

线性化和因果排序的关系是什么?线性化包含逻辑性,任何线性化系统都能保障逻辑性。但是线性化的代价是性能下降,很多系统不使用线性化。当然,有其他方法保障逻辑一致。

 

捕捉逻辑依赖

为了维护因果一致,你需要那些操作之间有依赖关系。并发操作可以任意顺序处理,但是如果操作间有先行发生关系,需要按照顺序处理。为了确定依赖,我们需要描述系统中节点的信息。如果一个节点在写入Y的时候已经看过X,那么X和Y之间很可能有因果关系。确定操作间的先行发生关系类似于之前讨论的“检测并发写入”。那章讨论了无主节点数据存储情况下,需要检测相同key数据的并发写入来防止更新丢失。逻辑一致则更近一步,需要追踪整个数据库的因果依赖,版本向量在这里不适用。为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。

 

序列号排序

实际上,跟踪所有的因果依赖并不实际。很多应用里,客户端在写之前会读取很多东西,并不清楚写入是依赖之前所有的读取还是只是依赖部分。追踪所有的数据意味着大量的消耗。

一个更好的方法就是使用序列号或者时间戳(逻辑时钟)来确定顺序。序列号或者时间戳占空间小,而且能够提供全序:每个操作都有独特的序列号,你可以比较那个序列号更大(哪个操作更晚发生)。我们可以在全序中创造与逻辑一致的序列号。如果操作A逻辑上先行发生于B,那么A在全序中先于B(A的序列号比B小)。并发操作可能任意的排序。这种全序包含了因果信息,并且比因果多了排序信息。

在单主机备份的数据库中,备份日志定义了与逻辑一致的序列号。对于每个操作,主节点可以简单地增加计数器然后赋予单调递增的序列号。如果从节点要按顺序应用备份日志中的写入,那么从节点一直是因果一致的。

 

非逻辑序列号生成器

如果没有一个单一主节点(多主节点或者无主节点),那么无法清晰地生成序列号。可以使用其他方法:

1、每个节点生成独特的序列号。例如,如果有两个节点,一个节点生成奇数,另一个生成偶数。一般而言,在二进制的数字上可以留出一些位,用来识别节点和确保两个节点不会生成相同的序列号。

2、可以给每个操作附加从日期时钟得到的时间戳。类似的时间戳不是连续的,但是如果有足够高的精密度就能给操作进行全序。实际上这就是最后写入胜出策略使用的。

3、可以先预留出一部分序列号。例如,A安排1至1000,B安排1001至2000.每个节点可以在自己的范围内独立地分配序列号,然后在号码不够时分配新的号码空间。

这三种方法比起单主节点计数器的方法,性能上都要好,而且可以扩展,但是它们都有一个问题:序列号无法与因果一致。

1、如果每个节点生成不同的序列号。那么,如果一个生成偶数,另一个生成奇数,那么偶数可能会落后于奇数或者相反。如果有一个标注偶数的操作和一个标注奇数的操作,你没法清晰地分辨哪个发生在前。

2、使用生成时间戳的时钟会有偏移,会导致与逻辑不一致。

3、如果分配号码区间,一个操作可能在1001-2000区间,晚些的操作可能分配在1-1000区间,序列号会与逻辑不一致。

 

Lamport时间戳

有一种能与逻辑保持一致的序列号生成方法,称为Lamport时间戳。如图9-8,每个节点都有一个id,每个节点对于操作都有一个计数器。Lamport时间戳简单来说就是一组数据(counter, node ID)。两个节点可能有相同的计数,但是包含了node ID的时间戳都是不同的。

Lamport时间戳与物理时钟无关,但是他能全序:如果有两个时间戳,计数大的时间戳大,如果计数相同,那么node ID更大的时间戳大。如图9-8,Client A从节点2收到了值为5的计数,然后将其发送到至节点1.同时节点1的计数器只有1,收到计数后立即增加至5,下一步就增加至6. 只要每个操作都携带者尽可能大的计数,这个模式就能保证Lamport时间戳能与逻辑性一直,因为每个逻辑依赖都会产生一个只增的时间戳。

 

时间戳排序并不够

尽管Lamport排序能够定义因果一致的全序,但是在分布式系统中,还是无法解决一些普遍的问题。

例如,系统需要确保用户名是唯一的。表面上看全序可以解决这个问题,比如比较时间戳,选取较小的那个。但是使用这个方法需要收集系统中所有创建用户名的操作,然后才能比较。但是当一个节点收到创建用户的请求时,需要马上进行处理,需要判断是否应该成功;这时,节点不知道其他节点是否同时在处理创建相同用户名的请求,不知道其他节点给创建操作打上的时间戳。

为了确保没有其他节点同时进行有着较小时间戳,有相同用户名的操作,需要检测其他节点在进行什么操作。如果一个其中一个节点因为网络问题而无法访问,系统就会缓慢停止,这不符合我们对故障容忍系统的要求。

问题在于只有收集到所有操作后才能全序。如果另一个节点生成了一些你不知道的操作,你没法将它们加入到全序中。

总结:为了实现类似于唯一用户名这种唯一性约束,有操作的全序是不够的,还需要知道什么时候排序最终确定了。如果你要创建用户名,需要确定没有其他节点进行着插入相同用户名操作(拍下你的操作之前),然后才能安全地声明操作成功了。知道全序确定的方法就是全序广播

 

全序广播

在单主节点备份体系中,通过单一主节点来确定操作的全序。面对的挑战就是吞吐量超过系统限制时怎么扩展,主节点故障时怎么进行故障转移。在分布式系统中,这个问题就是全序广播或者原子广播。

全序广播通常描述成节点间交换信息的协议。一般来说,它需要满足两个安全特性:

1、可靠传输。没有消息丢失,如果一个信息能发送到一个节点,那么它就能传送至所有节点。

2、全序传输。消息传输到每个节点上的顺序是一致的。

一个正确的全序广播算法必须确保可靠性和全序性,即便是发生了网络故障。当然了,发生网络故障时,信息发送会受阻,但是网络最终被修复时,需要保证信息能按正确顺序发送完成。

 

使用全序广播

类似Zookeeper这种一致性服务实际上就是实现了全序广播。全序广播和一致性之间有强关联。

全序广播就是数据库备份所需要的:如果每个信息代表一个写入,那么每个备份按照相同顺序处理希尔,最终每个备份就能取得一致(忽略掉备份延迟)。这个概念就是状态机备份。

类似的全序广播可以用来实现序列化事务:如果每个信息代表一个确定性事务,每个节点按照相同的顺序处理这些信息,那么数据库的分区和备份都能保持一致。

全序广播一个重要的方面就是,发送信息的顺序是在发送时就确定的:如果部分消息已经发送,那么一个节点是无法在那部分消息之前插入一个消息。另一个看待全序广播的方法就是这能生成日志(备份日志,事务日志,或者预先写入日志)。因为所有的节点需要按照相同顺序传输相同消息,所有的节点读取日志就能看到相同序列的消息。

全序广播也可以用来实现锁服务,来实现围栏令牌。每个获取锁的请求作为消息追加至日志中,所有的消息在日志中都是按顺序出现的。序列号就可以作为围栏令牌,因为它是单调递增的。在Zookeeper中,序列号被称为zxid。

 

使用全序广播实现线性存储

线性化系统的操作都是全排序的,那么全序广播是否等价于线性一致。不完全是。

全序广播是异步的:消息可以保证按照特定顺序被发送,但是不保证什么时候被发生(一个节点可能落后的其他节点)。作为比较,线性一致是新近保证:读取保证能看到最新的写入。但是如果有全序广播,你可以建立线性一致存储,可以确保用户名的唯一性。

想象一下,对于每个可能的用户名,你都有一个执行cas操作的线性化注册器。每个注册器初始值为null。当用户想创建一个用户名时,你可以在那个用户名的注册器上执行cas操作,将其设置为用户账号id,这个场景下注册器之前看到的值是null。如果多个用户想要使用相同的用户名,那么只会有一个cas操作会成功,因为其他的会看到null。

你可以实现如下操作,使用全序广播来作为只增日志。

1、将消息添加进日志,表明你想使用哪个用户名

2、读取日志,等待已经添加消息后的回复。

3、检测任何包含你想使用的用户名的消息。如果第一个需要使用用户名的消息是你自己的,那么就成功了:你可以提交对用户名的宣称(可能是添加另一个消息至日志)然后收到客户端的承认。如果第一个宣称使用用户名的消息不是你的,那么你的操作就废弃了。

因为日志项是按照相同顺序传输至所有节点的,如果有多个并发写入,所有的节点看到的日志顺序都是相同的。选取并发写入的第一个作为胜者,然后丢弃掉后续写入确保所有节点在写入的提交或者丢弃取得一致。类似地可以使用日志实现序列化多对象事务。

当进程确保线性化写入时,它并不能保证线性化读取。如果你从使用日志来异步更新的数据存储中读取数据,可能得到过期的数据。准确来说,这里描述的进程提供了顺序一致,也叫时间线一致,比现行一致弱。为了实现读取线性化,有一些方法:

1、你可以追加消息,读取日志,然后再消息返回时做出实际的读取。日志中消息的位置定义了读取发生的时间点。

2、如果日志允许你访问消息日志中最新的位置,等待到该位置为止的消息项传输给你,然后进行读取。(Zookeeper中sync()方法的思想)

3、你可以从同步更新的备份中读取。

 

使用线性存储实现全序广播

上一章展示了如何从全序广播中构建线性化CAS操作。我们可以反过来,从全序广播中构建线性化存储。

最简单的方法就是假设你有一个线性化注册器存储了一个整数,有一个原子的cas操作。一个原子cas操作也可以完成操作。

算法很简单:对于每个你想通过全序广播发送的消息,先通过线性化操作,增长-获取一个整数,将从注册器中获取的数字附加到消息上作为序列号。然后将消息发送到所有节点(如果有丢失就重新发送), 接收者后续发送消息也会附加序列号。

不同于Lamport时间戳,从线性注册器获取的序列号没有间隔。因此,如果一个节点发送消息4,然后收到消息6,它必须在发送消息6之前等待消息5. 这不同于Lamport时间戳,实际上这是全序广播和时间戳排序的关键不同。

实现一个整数线性化的原子的增长-获取操作有多难?如果没发生故障,不难,只需要在一个节点保存一个变量。问题在于如果网络中断了,节点故障了要如何恢复计数。如果你仔细思考线性序列号生成器,你会不可避免地想到一致性算法。

所以:可以证明有线性CAS操作的注册器和全序广播都等同于一致性。如果你解决了其中一个问题,等同于解决了其他问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值