7.6 线性一致(Linearizability)

接下来我们看一些更偏概念性的东西。目前为止,我们还没有尝试去确定正确意味着什么?当一个多副本服务或者任意其他服务正确运行意味着什么? 绝大多数时候,我都避免去考虑太多有关正确的精确定义。但事实是,当你尝试去优化一些东西,或者当你尝试去想明白一些奇怪的corner case,如果有个正式的方式定义什么是正确的行为,经常会比较方便。例如,当客户端通过RPC发送请求给我们的多副本服务时,可能是请求重发,可能是服务故障重启正在加载快照,或者客户端发送了请求并且得到了返回,但是这个返回是正确的吗?我们该如何区分哪个返回是正确的?所以,我们需要一个非常正式的定义来区分,什么是对的,什么是错的。

我们对于正确的定义就是线性一致(Linearizability)或者说强一致(Strong consistency)。通常来说,线性一致等价于强一致。一个服务是线性一致的,那么它表现的就像只有一个服务器,并且服务器没有故障,这个服务器每次执行一个客户端请求,并且没什么奇怪的是事情发生。

一个系统的执行历史是一系列的客户端请求,或许这是来自多个客户端的多个请求。如果执行历史整体可以按照一个顺序排列,且排列顺序与客户端请求的实际时间相符合,那么它是线性一致的。当一个客户端发出一个请求,得到一个响应,之后另一个客户端发出了一个请求,也得到了响应,那么这两个请求之间是有顺序的,因为一个在另一个完成之后才开始。一个线性一致的执行历史中的操作是非并发的,也就是时间上不重合的客户端请求与实际执行时间匹配。并且,每一个读操作都看到的是最近一次写入的值。(这里的定义可能比较晦涩,后面会再通过例子展开介绍,并重新回顾这里定义里面的两个限制条件。)

首先,执行历史是对于客户端请求的记录,你可以从系统的输入输出理解这个概念,而不用关心内部是如何实现的。如果一个系统正在工作,我们可以通过输入输出的消息来判断,系统的执行顺序是不是线性一致的。接下来,我们通过两个例子来看,什么是线性一致的,什么不是。

线性一致这个概念里面的操作,是从一个点开始,到另一个点结束。所以,这里前一个点对应了客户端发送请求,后一个点对应了收到回复的时间。我们假设,在某个特定的时间,客户端发送了请求,将X设置为1。

过了一会,在第二条竖线处,客户端收到了一个回复。客户端在第一条竖线发送请求,在第二条竖线收到回复。

过了一会,这个客户端或者其他客户端再发送一个请求,要将X设置为2,并收到了相应的回复。

同时,某个客户端发送了一个读X的请求,得到了2。在第一条竖线发送读请求,在这个点,也就是第二条竖线,收到了值是2的响应。

同时,还有一个读X的请求,得到值是1的响应。

如果我们观察到了这样的输入输出(执行历史),那么这样的执行历史是线性一致的吗?生成这样结果的系统,是一个线性一致的系统吗?或者系统在这种场景下,可以生成线性一致的执行历史吗?如果执行历史不是线性一致的,那么至少在Lab3,我们会有一些问题。所以,我们要分析并弄清楚,这里是不是线性一致的?

要达到线性一致,我们需要为这里的4个操作生成一个线性一致的顺序。所以我们现在要确定顺序,对于这个顺序,有两个限制条件:

  1. 如果一个操作在另一个操作开始前就结束了,那么这个操作必须在执行历史中出现在另一个操作前面。

  2. 执行历史中,读操作,必须在相应的key的写操作之后。

所以,这里我们要为4个操作创建一个顺序,两个读操作,两个写操作。我会通过箭头来标识刚刚两个限制条件,这样生成出来的顺序就能满足前面的限制条件。第一个写结束之后,第二个写才开始。所以一个限制条件是,在总的顺序中,第一个写操作必须在第二个写操作前面。

第一个读操作看到的是值2,那么在总的顺序中,这个读必然在第二个写操作后面,同时第二个写必须是离第一个读操作最近一次写。所以,这意味着,在总的顺序中,我们必须先看到对X写2,之后执行读X才能得到2。

第二个读X得到的是值1。我们假设X的值最开始不是1,那么会有下图的关系,因为读必须在写之后。

第二个读操作必须在第二个写操作之前执行,这样写X为1的操作才能成为第二个读操作最近一次写操作。

或许还有一些其他的限制,但是不管怎样,我们将这些箭头展平成一个线性一致顺序来看看真实的执行历史,我们可以发现总的执行历史是线性一致的。首先是将X写1,之后是读X得到1,之后将X写2,之后读X得到2。(这里可以这么理解,左边是一个多副本系统的输入输出,因为分布式程序或者程序的执行,产生了这样的时序,但是在一个线性一致的系统中,实际是按照右边的顺序执行的操作。左边是实际时钟,右边是逻辑时钟。)

所以这里有个顺序且符合前面两个限制条件,所以执行历史是线性一致的。如果我们关心一个系统是否是线性一致的,那么这个例子里面的输入输出至少与系统是线性一致的这个假设不冲突。

学生提问:听不清。

Robert教授:每个读操作,得到的值,都必须是顺序中的前一个写操作写入的值。在上面的例子中,这个顺序是没问题的,因为这里的读看到的值的确是前一个写操作。读操作不能获取旧的数据,如果我写了一些数据,然后读回来,那么我应该看到我写入的值。

让我再写一个不是线性一致的例子。我们假设有一个将X写1的请求,另一个将X写2的请求,还有一些读操作。

这里我们也通过箭头来表示限制,最后得到相应的执行顺序。因为第一个写操作在第二个写操作开始之前就结束,在我们生成的顺序中,它必须在第二个写操作之前。

第二个写操作,写的值是2,所以必须在返回2的读操作之前,所以我们有了这样一条箭头。

返回2的读操作,在返回1的读操作开始之前结束,所以我们有这样的箭头。

因为返回1的读操作必须在设置1的写操作之后,并且更重要的是,必须要在设置2的写操作之前。因为我们不能将X写了2之后再读出1来。所以我们有了这样的箭头。

因为这里的限制条件有个循环,所以没有一个线性一致的顺序能够满足前面的限制条件,因此这里的执行历史不是线性一致的,所以生成这样结果的系统不是线性一致的系统。但是只要去掉循环里面的任意一个请求,就可以打破循环,又可以是线性一致的了。

学生提问:听不清。

Robert教授:我不太确定。我不知道如何处理非常奇怪的场景,例如某个请求读到了27,但是之前又没有写27的操作。至少我写出来的规则没有对应的限制,或许你可以构建一些反依赖的规则。

好的,我们下节课继续这里的讨论。

最后更新于