CONSENSUS:BRIDGING THEORY AND PRACTICE(第6章)

客户端交互

本章介绍了几个客户端和Raft-based的复制状态机交互的问题:

  • 6.1节讲述了客户端如何发现集群,即使集群的成员会随着时间变化;
  • 6.2节讲述了客户端的请求是怎么路由到leader处理的;
  • 6.3节介绍了Raft如何提供线性一致性的;
  • 6.4节介绍了Raft如何更有效地处理只读请求的。

图6.1客户端与复制状态机交互使用的RPCs,这些会在通篇里讨论。所有一致性基础的系统都涉及这些问题,Raft的解决方案和其他系统类似。

本章假定Raft-based的复制状态机直接以网络服务的形式暴露给客户端。Raft也可以直接集成到客户端应用程序中。这种情况下,客户端交互的一些问题可能被推高一个层级到嵌入在应用程序里的网络客户端。例如,嵌入到应用程序的网络客户端会有和Raft作为网络服务时其客户端发现集群类似的问题。


CONSENSUS:BRIDGING THEORY AND PRACTICE(第6章)

6.1 集群发现

当Raft作为一个网络服务暴露的时候,客户端为了和复制状态机交互必须能定位Raft集群。对于固定成员的集群,这是直截了当的;比如可以把网络地址静态地存储在客户端配置文件里。然而,如何发现一个随着时间动态改变成员的集群是一个大的挑战。有两个一般的方法:

  1. 客户端使用广播或者多播去发现所有的集群成员。然而,这样就依赖了特定的网络环境。
  2. 客户端可以通过外部的目录服务发现集群servers,比如有着众所周知地址的DNS。servers没有必要和外部系统保持一致,但是必须包含:客户端一直能够发现所有集群servers,但是其实包含一些额外的不属于集群的servers是无害的。因此,在成员变更期间,外部的目录服务需要在成员变更前更新,等成员变更完成之后再移除不属于cluster的servers。
  3. 译者注:诸如具有服务发现能力的etcd这样的服务。

LogCabin客户端当前使用DNS发现集群。LogCabin目前不会在成员变更前后自动地更新DNS记录(通过管理员脚本)。

6.2 请求的路由

客户端请求是通过Raft leader处理的,因此客户端需要一个发现leader的方法。当一个客户端启动的时候,随机选择一个server连接。如果客户端第一次选择的不是leader,这个server就会拒绝服务。这种情况下,一个非常简单的方法是客户端随机地重试下一个直到发现leader。如果客户端纯随机地选择servers,这种清纯的方法平均情况下会在经过(n + 1) / 2次尝试下发现leader,这对于小的集群已经足够快了。

通过简单的优化也可以把路由到leader的请求优化的更快。Servers通常知道当前leader的地址,由于AppendEntries请求包含leader的身份信息。当一个不是leader的server收到了客户端的请求时,它可以做下面两件事中的一个:

  1. 第一个选项,是我们建议的而且也是LogCabin中实现的,即非leader的server拒绝请求并返回leader的地址给客户端,如果它知道。这允许客户端直接重连到leader,因此以后的请求都可以直接定向处理了。这也需要很少量的额外代码去实现,因为客户端已经需要有当leader故障时重连到一个不同server的能力了。
  2. 可供选择地,server可以代理客户端的请求到leader。

Raft还必须防止过期的领导信息不定期地延迟客户端请求。领导信息可能在leaders、followers和clients中都会过期:

  • Leaders:一个server可能处于leader状态,但是它并不是现在的leader,这就会不必要地延迟客户端请求。例如,假如一个leader和集群网络隔离了,但是它仍然可以和某个客户端通信。没有额外的机制它就会一直延迟那个客户端的请求,它没法把log复制给其他servers。期间可能有另一个新的term的leader能和集群中的大多数通信并且能提交客户端的请求。因此,如果一个选举超时周期内还没能和集群中的大多数做一轮成功的心跳,那么Raft leader会*;这允许客户端把请求重试到其他server上。
  • Followers:Followers保持跟踪leader的身份信息以便它们能重定向或者代理客户端的请求。它们必须在开始新的选举或者term变更时丢弃这个信息。否则它们可能带给客户端不必要的延迟(例如,两个服务互相重定向,是客户端陷入无线循环)。
  • Clients:如果一个客户端丢失了与leader的连接(或者和某个server的),它将简单地随机重试一个server。坚持和最后了解到的leader联系的话一旦那个server故障了就会导致客户端不必要的延迟。

6.3 实现线性化语义

按照目前的描述,Raft对客户端提供了at-least-once语义;复制状态机是可能应用一个命令多次的。例如,假设客户端提交了一个命令到leader并且leader把命令添加到了日志并且提交了日志entry,但是在响应客户端之前就崩溃了。由于客户端没有收到确认,就会重新提交这个命令到新的leader上,就会导致把这个命令作为一个新的entry写入日志中并且也会提交这个entry。尽管客户端期望这个命令被执行一次,但是实际上执行了两次。

如果网络导致客户端请求重复即使在没有客户端牵涉的情况下也会导致请求被应用多次。

这个问题不是Raft独有的;绝大部分有状态的分布式系统都会发生。然而,作为一个一致性为基础的系统仅提供at-least-once的语义是特别不合适的,客户端典型的需要的是更强的保证。重复命令的问题可能一微妙的方式出现,客户端很难从这种情况下回复过来。这些问题会引起或者不正确的结果,或者不正确的状态,或者都有。图6.2显示了一个不正确结果的例子:一个状态机正在提供一个锁,并且一个客户端发现它不能获取到这个锁因为它原始的请求,因为它没有收到上锁的确认。一个状态不正确的例子,比如一个增量操作,客户端打算把一个值增加1但反而增加了2或者更多。网络层级的乱序和客户端的并发能导致更加惊奇的结果。


CONSENSUS:BRIDGING THEORY AND PRACTICE(第6章)

我们在Raft中的目标是实现线性化语义,可以避免这类问题。在线性化语义中,每一个操作都会在调用和响应间的某个点立即并且准确地一次地执行。这是一个很强的一致性,客户端对行为的推理会变得简单,并且不允许命令被执行多次。

Raft为了实现线性化语义,servers必须过滤掉重复的请求。最基本的想法就是servers保存客户端的操作并且使用他们跳过重复的相同请求。为了实现这个,每一个客户端自己需要有一个独一无二的标识,并且客户端对每一个命令都赋予一个独一无二的序号。每一个server的状态机对客户端维护一个session。session最近客户端被处理的序号,同时也关联着响应。如果一个server收到一个序号已经被执行过了的命令,它直接作出响应而不是重执行这个请求。

给定了过滤重复请求的能力,Raft提供了线性一致性。Raft日志提供每个server应用命令的串行化机制。命令根据在Raft日志中第一次出现而立即、精确一次地生效,由于任何后来出现的都会被状态机过滤掉。

这个方法还推广到允许来自单个客户端的并发请求。客户端的session不只跟踪客户端最近的序号和响应,而是包含了一组***和响应对。对于每个请求,客户端包含了它尚未收到的相应的最低序号,状态机就会丢弃所有较低序号的响应。

不幸的是,sessions没办法永远被保持,由于空间是有限的。servers必须最终决定过期一个客户端的session,但是这引来了两个问题:servers怎么一致同意什么时候过期一个客户端的session,他们如何处理一个不幸地被过早过期的活动的客户端?

Servers必须在什么时候决定客户端session过期意见一致;否则servers的状态机会互相偏离。例如,假如一个server过期了某个客户端的session,然后再应用了这个客户端的重复命令;与此同时,其他servers保持了这个session并且不会应用重复命令。复制状态机将会不一致。为了避免这个问题,session过期必须是确定的,就像正常的状态机操作那样。一个想法是设置一个sessions数量的上限,并且依照LRU协议移除session条目。另一个想法是基于一个意见一致的时间源。在LogCabin中,leader对没一个添加到Raft日志的命令增加一个当前时间的属性。作为提交日志的一部分,servers在这个时间上达成一致;然后状态机使用这个确定的时间去过期不活跃的sessions。活着的客户端在不活动期间发起keep-alive请求,同样会附加leader的时间戳并且提交到Raft日志中以便维护它们的sessions。
译者注:这里的时间是另一套servers间的时间,是一个逻辑时间,时间的更新是通过leader日志中带的时间更新的,session的deadline时间也是这个逻辑时间,这样servers就可以在执行到同一条日志时作出同样的session过期行为保保证状态机状态一致。

另一个问题是如何处理session过期的客户端的请求。我们认为这是一个异常的情况;然而总会有一些风险,因为通常没有办法知道客户端何时退出。一个想法是当没有session记录时给客户端分配一个新的session,但是这将导致执行重复命令的风险。为了提供可靠的保证,servers需要辨别一个新的client是不是之前有过session过期。当一个客户端初次启动的时候,它可以通过RegisterClient RPC向集群注册自己。这会分配一个session并返回给客户端作为标识,客户端随后的所有命令都要带上这个标识。如果一个状态机遇到一个没有记录的session的命令,它不会处理这个命令而是返回一个错误给客户端。LogCabin目前在这种情况下会让客户端挂掉(大部分客户端可能没办法正确优雅地处理session过期这种错误,但是系统典型地已经能处理客户端挂掉)。

译者注:译者理解client挂掉不一定就是进程挂掉了,完全可以仅仅是这个client对象本身坏掉了,销毁并重新创建一个对象就是了。之后假如有必要重新执行可以通过状态查询定位之前未完成的命令重新执行。

6.4 更有效地处理只读请求

只读的客户端请求仅仅是查询,它们不会改变复制状态机。因此,一个很自然的想法是查询是否可以绕过Raft日志,Raft日志的目的是以同样的顺序复制改变到server的状态机上。绕过日志提供了吸引人的性能优势:只读查询在很多应用中都很普遍,并且增加entries到日志会需要同步写到disk,会消耗时间。

然而,如果没有额外的预防措施,绕过日志可能导致只读请求得到陈旧的数据。例如,一个leader可能和集群中剩余部分网络分区了,集群中剩余部分选出了一个新的leader并且提交了新的entries到Raft日志中。如果分区的leader没有和其他server商议就返回了只读查询的响应,可能返回旧的结果,这就不是线性化了。线性化语义要求读的结果反映系统在读开始后的某个时间的状态;每个读必须至少返回最近提交写的结果(系统允许stale reads仅是提供了串行化语义,这是弱的一致性形式)。Stale reads的问题已经在两个三方Raft实现中被发现了,所以这个问题应当小心留意。

幸运地是,只读请求是可能绕过Raft日志并仍然提供线性化语义的。为了达到这样的目的,leader需要执行以下步骤:

  1. 如果leader还没有标记的当前任期内被提交的entry,它需要等待直到已经做到了。*完整性保证了leader有着所有已提交的entries,但是在它自己的任期开始的时候,他可能不知道哪些是已提交的条目。为了发现已提交的条目,需要在自己的任期提交一个条目。Raft通过leader在自己的任期开始的时候提交一个空的no-op条目到日志中。一旦这个no-op天木被提交了,leader的commit index在这个任期内将至少和其他server的一样大。
  2. Leader把自己当前的commit index保存到一个本地变量readIndex中。这将用作查询操作所针对的状态版本的下界。
  3. Leader需要确信它没有在不知道的情况下被其他新的leader取代。它发起一轮新的心跳并且等待majority的确认。一旦收到了这些确认,leader就知道在它发送心跳的时刻不存在leader比它的term更大。因此,readIndex在当时是集群中任何服务器所见过的最大的提交索引。
  4. Leader等待它的状态机至少前进到readIndex;这足够能满足线性化语义。
  5. 最后,leader对状态机发起查询并且回复客户端结果。

这个方法比提交一个制度请求的条目到日志中更有效,由于避免了disk写同步。为了进一步地提高只读查询的性能,leader可以分摊需要确认leadership的开销:可以通过一轮心跳确认累积的只读查询。

Follower也可以帮助分担只读查询的处理。这将提升系统的读吞吐,并且也将转移leader的负载,允许leader能够处理更多的请求。然而,如果没有额外的预防机制这些读将导致返回旧数据的风险。例如,follower网络分区或者即便能收到leader的心跳而leader本身也网络分区了自己还不知道。为了提供安全地读,follower可以发起一个请求到leader问一下当前的readIndex(leader将执行上面的1-3步);follower之后可以在自己的状态机执行4和5步。

LogCabin在leader上实现了上述算法,并且在高负载的情况下通过积攒多个只读查询来分摊开销。Follower目前在LogCabin中不提供只读请求。

6.4.1 使用时钟减少只读请求的消息

截止到目前,只读查询的提供线性化语义是通过同步模型的方法实现的(clocks、processors和messages能在任意的速度运作)。这个安全等级要求通信去实现:每一批只读的查询都需要一轮和集群的一半的心跳,增加了查询的延时。剩下的章节探索一个可选择的通过依赖时钟进而避免和大家发送消息的机制。LogCabin当前没有实现这个选择,并且我们不建议使用,除非有性能需求。

为了对只读查询使用时钟代替信息交互,正常的心跳机制需要提供一个租约的形式。一旦leader的心跳收到了majority的确认,leader将假定在选举超时周期内没有其他server将成为leader,并且它能相应地扩大它的租约(见图6.3)。Leader将不用任何额外机制,在租约有效期间直接回复只读查询(*转换机制允许leader被更早地替换;leader需要能在转换*时过期自己的租约)。

租约的方法假定了server间的时钟漂移是有界的(在给定的时间段内,没有任何server的时钟增长超过这个限制的时间)。发现和维护这个界限可能是非常大的操作挑战(比如,由于调度和垃圾回收中断,虚拟机集成,或者为了时间同步的时钟速率调整)。如果假设被违反了,系统可返回任意的旧信息。

幸运地是,一个简单的扩展可以提高对于客户端的保证,因此即使在异步假设下(即使时钟会出错),每个客户端都将看到复制状态机的单调进度(顺序一致性)。例如,一个客户端将不会看到了log n的状态,然后换到别的server并且只看到了log n - 1的状态。为了实现这个保证,servers将把状态机状态相应的log index包含到回复客户端的响应中。客户端将会跟踪最它们看到的结果的相应的最近的index,并且它们将在每个请求中提供这个信息。如果一个server收到了客户端的请求的index大于server最后应用的log index,它将不会对这个请求提供服务。


CONSENSUS:BRIDGING THEORY AND PRACTICE(第6章)

6.5 讨论

这章讨论了几个客户端和Raft交互的问题。提供线性化语义和优化只读查询的问题在正确性方面尤为微妙。不幸地是,当前的一致性文献都是讲集群servers间的沟通,没有涉及这些重要的问题。我们认为这是一个错误。一个完整的系统必须正确地和客户端交互,否则由核心共识算法提供能一致性能力将被浪费。正如我们已经在真正的基于Raft的系统中看到的那样,客户端交互可能是bug的主要来源,但是我们希望更好地理解这些问题以帮助防止将来的问题。