本文内容主要来源于 A Template Engine

支持的语法

首先来看一下这个模板引擎所支持的语法。

变量

使用 {{ variable }} 来表示变量,例如:

1
<p>Welcome, {{user_name}}!</p>

如果 user_nameTom,则最后渲染的结果为:

1
<p>Welcome, Tom!</p>

对象属性和方法

除了字面量外,模板引擎的变量还支持复杂对象,可以通过点操作符来访问对象的属性或方法,例如:

1
<p>The price is: {{product.price}}, with a {{product.discount}}% discount.</p>

注意如果访问的是对象的方法,则不需要在方法名后添加 (),模板引擎会自动解析并调用方法。

同时,还可以使用管道操作符来链式调用过滤器,从而改变所渲染的变量值,例如:

1
<p>Short name: {{story.subject|slugify|lower}}</p>

条件判断

使用 {% if condition %} body {% endif %} 来表示条件判断,例如:

1
2
3
{% if user.is_logged_in %}
<p>Welcome, {{ user.name }}!</p>
{% endif %}

循环

使用 {% for item in list %} body {% endfor %} 来表示循环,例如:

1
2
3
4
5
6
<p>Products:</p>
<ul>
{% for product in product_list %}
<li>{{ product.name }}: {{ product.price|format_price }}</li>
{% endfor %}
</ul>

注释

使用 `` 来表示注释,例如:

1
{# This is the best template ever! #}

实现

一般来说,一个模板引擎主要做两件事:模板解析和渲染。这里要实现的模板引擎的渲染包括:

  • 管理动态数据
  • 执行逻辑语句,例如 iffor
  • 实现点操作符访问和过滤器执行

类似于编程语言的实现,模板引擎的解析也可以分为解释型和编译型两种。对于解释型来说,模板解析阶段需要生成某个特定的数据结构,然后在渲染阶段遍历该数据结构并执行所遇到的每一条指令;而对于编译型来说,模板解析阶段直接生成可执行代码,而渲染阶段则大大简化,直接执行代码即可。

本文描述的模板引擎采用编译型的方式,原文的作者将模板编译为了 Python 代码,这里为了进一步加深理解,实现了 .NET Core 版本的简单编译。

编译为 C# 代码

在介绍模板引擎实现之前,先来看一下模板引擎编译出的 C# 代码示例,对于如下的模板:

1
2
3
4
5
6
7
8
<p>Welcome, {{userName}}!</p>
<p>Products:</p>
<ul>
{% for product in productList %}
<li>{{ product.Name }}:
{{ product.Price|FormatPrice }}</li>
{% endfor %}
</ul>

模板引擎会生成类似于下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
public string Render(Dictionary<string, object> Context Context, Func<object, string[], object> ResolveDots)
{
var result = new List<string>();
var userName = Context["userName"];
var productList = Context["productList"];
result.AddRange(new List<string> {"<p>Welcome, ", Convert.ToString(userName), "!</p><p>Products:</p><ul>"});
foreach (var product in ConvertToEnumerable(productList)) {
result.AddRange(new List<string> {"<li>", Convert.ToString(ResolveDots(product, new [] { "Name" })), ":", Convert.ToString(FormatPrice(ResolveDots(product, new [] { "Price" }))), "</li>"});
}
result.Add("</ul>");
return string.Join(string.Empty, result);
}

其中 Context 表示全局上下文,用于获取渲染需要的动态数据,例如例子中的 userNameRender 方法会先从 Context 中提取出模板中所有需要的变量;ResolveDots 是一个函数指针,用于执行点操作符调用;而变量的值都会通过 Convert.ToString 转为字符串。

模板引擎的最终产物是一个字符串,所以在 Render 中先使用一个 List 保存每一行的渲染结果,最后再将 List 转换为字符串。

.NET 编译器提供了 Microsoft.CodeAnalysis.CSharp.Scripting 包来将某段字符串当做 C# 代码执行,所以最终模板引擎生成的代码将通过如下方式执行:

1
2
3
4
5
var code = "some code";
var scriptOptions = ScriptOptions.Default.WithImports("System", "System.Collections.Generic");
var script = CSharpScript.RunAsync(code, scriptOptions, yourCustomGlobals);

return script.Result.ReturnValue.ToString();

模板引擎编写

Template

Template 是整个模板引擎的核心类,它首先通过模板和全局上下文初始化一个实例,然后调用 Render 方法来渲染模板:

1
2
3
4
5
6
7
var context = new Dictionary<string, object>()
{
{ "numbers", new[] { 1, 2, 3 } },
};
string text = @"<ol>{% for number in numbers %}<li>{{ number }}</li>{% endfor %}</ol>";
Template template = new Template(text, context);
string result = template.Render();

这里将 text 传入 Template 的构造函数后,会在构造函数中完成模板解析,后续的 Render 调用都不需要再执行模板解析。

CodeBuilder

在介绍 Template 的实现之前,需要先了解下 CodeBuilderCodeBuilder 用于辅助生成 C# 代码,Template 通过 CodeBuilder 添加代码行,以及管理缩进(原文的作者使用 Python 作为编译的目标语言所以这里需要维护正确的缩进,C# 则不需要),并最终通过 CodeBuilder 得到可执行代码。

CodeBuilder 内部维护了一个类型为 List<object> 的变量 Codes 来表示代码行,这里的 List 容器类型不是字符串是因为 CodeBuilder 间可以嵌套,一个 CodeBuilder 可以作为一个完整的逻辑单元添加到另一个 CodeBuilder 中,并最终通过自定义的 ToString 方法来生成可执行代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CodeBuilder
{
private const int IndentStep = 4;

public CodeBuilder()
: this(0)
{
}

public CodeBuilder(int indentLevel)
{
this.Codes = new List<object>();
this.IndentLevel = indentLevel;
}

private List<object> Codes
{
get;
}

private int IndentLevel
{
get;
set;
}
}

CodeBuilderAddLine 方法非常简单,即根据缩进层级补齐空格后添加一行代码(这里 C# 版本保留了 Python 版本缩进的功能):

1
2
3
4
public void AddLine(string line)
{
this.Codes.AddRange(new List<object> { new string(' ', this.IndentLevel), line, "\n" });
}

IndentDedent 用于管理 Python 代码的缩进层级:

1
2
3
4
5
6
7
8
9
public void Indent()
{
this.IndentLevel += IndentStep;
}

public void Dedent()
{
this.IndentLevel -= IndentStep;
}

AddSection 用于创建一个新的 CodeBuilder 对象,并将其添加到当前 CodeBuilder 的代码行中,后续对子 CodeBuilder 的修改都会反应到父 CodeBuilder 中:

1
2
3
4
5
6
7
8
public CodeBuilder AddSection()
{
CodeBuilder section = new CodeBuilder(this.IndentLevel);

this.Codes.Add(section);

return section;
}

最后重写了 ToString() 方法来生成可执行代码:

1
2
3
4
public override string ToString()
{
return string.Join(string.Empty, this.Codes.Select(code => code.ToString()));
}

Template 实现

编译

模板引擎的模板解析阶段发生在 Template 的构造函数中:

1
2
3
4
5
6
7
8
9
public Template(string text, Dictionary<string, object> context)
{
this.Context = context;
this.CodeBuilder = new CodeBuilder();
this.AllVariables = new HashSet<string>();
this.LoopVariables = new HashSet<string>();

this.Initialize(text);
}

Python 版本的代码支持多个 context,会由构造函数统一合并为一个上下文对象,这里只简单实现仅支持一个 contextAllVariables 用于记录模板 text 中需要用到的变量名,例如 userName,然后在代码生成阶段就可以遍历 AllVariables 并通过 var someName = Context[someName]; 生成局部变量;不过由于模板中的变量可能还会有循环语句用到的临时变量,这些变量会记录到 LoopVariables 中,最终代码生成阶段用到的变量为 AllVariables - LoopVariables

接着我们再来看 Initialize 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void Initialize(string text)
{
this.CodeBuilder.AddLine("var result = new List<string>();");

var variablesSection = this.CodeBuilder.AddSection();

// 解析 text
// ...

foreach (string variableName in new HashSet<string>(this.AllVariables.Except(this.LoopVariables)))
{
variablesSection.AddLine(string.Format("var {0} = Context[{1}];", variableName, this.ConvertToStringLiteral(variableName)));
}

this.CodeBuilder.AddLine("return string.Join(string.Empty, result);");
}

Initialize 首先会通过 CodeBuilder 分配一个 List 保存所有的代码行,然后新建一个子 CodeBuilder 来保存所有的局部变量,接着解析 text,在完成 text 的解析后就能知道模板中使用了哪些变量,从而根据 AllVariables - LoopVariables 生成局部变量,最后将所有的代码行转成字符串。

同时,原文作者在这里有一个优化,相比于在生成的代码中不断的调用 result.Add(xxx),从性能上考虑可以将多个操作合并为一个即 result.AddRange(new List<string> { xxx }),从而引出了辅助变量 buffered 和辅助方法 FlushOutput

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var buffered = new List<string>();

private void FlushOutput(List<string> buffered)
{
if (buffered.Count == 1)
{
this.CodeBuilder.AddLine(string.Format("result.Add({0});", buffered[0]));
}
else if (buffered.Count > 1)
{
this.CodeBuilder.AddLine(string.Format("result.AddRange(new List<string> {{{0}}});", string.Join(", ", buffered)));
}

buffered.Clear();
}

在解析 text 时,并不会处理完一个 token 就执行一次 this.CodeBuilder.AddLine,而是将多个 token 的处理结果批量的追加到最终的可执行代码中。

接着,再回到 Initialize 方法中,由于模板中 iffor 可能存在嵌套,为了正确处理嵌套语句,这里引入一个栈 var operationStack = new Stack<string>(); 来处理嵌套关系。例如,假设模板中存在 {% if xxx %} {% if xxx %} {% endif %} {% endif %},每次遇到 if 时则执行入栈,遇到 endif 时则执行出栈,如果出栈时栈为空则说明 if 语句不完整,并抛出语法错误。

那么,如何解析 text 呢?这里使用正则表达式来将 text 分割为 token

1
2
private static Regex tokenPattern = new Regex("(?s)({{.*?}}|{%.*?%}|{#.*?#})", RegexOptions.Compiled);
var tokens = tokenPattern.Split(text);

其中正则表达式中的 (?s) 使得 . 能够匹配换行符。

例如对于模板:

1
<ol>{% for number in numbers %}<li>{{ number }}</li>{% endfor %}</ol>

分割后的 tokens 为:

1
2
3
4
5
6
7
8
9
[
'<ol>',
'{% for number in numbers %}',
'<li>',
'{{ number }}',
'</li>',
'{% endfor %}',
'</ol>'
]

然后我们就可以遍历 tokens 处理了,每种 token 对应一种策略,如果是注释,则忽略:

1
2
3
4
if (token.StartsWith("{#"))
{
continue;
}

如果是变量,则解析变量的表达式(表达式解析会在后面介绍)的值,然后再将其转为字符串:

1
2
3
4
5
6
else if (token.StartsWith("{{"))
{
string expression = this.EvaluateExpression(token.Substring(2, token.Length - 4).Trim());

buffered.Add(string.Format("Convert.ToString({0})", expression));
}

而如果是 `

介绍

Spanner 是一个由 Google 设计,构建和部署的可扩展的全球分布式数据库。从高层次的抽象来看,作为一个数据库,Spanner 会将数据进行分片,每个分片构建在一组 Paxos 状态机之上,同时所有的数据存储在世界各地的各个数据中心内。Spanner 使用副本来保证数据库的全球可用性和客户端读取数据的就近访问性;客户端也能自动的在各个副本之间实现故障转移。当数据量或者服务器数量发生变化时,Spanner 能自动的跨服务器对数据进行重分区;同时,Spanner 也能自动的跨服务器(甚至是跨数据中心)迁移数据来应对负载均衡或者异常。Spanner 的扩展性能够支持上百个数据中心内的几百万台服务器,以及几万亿的数据行。

应用程序可以借助 Spanner 来确保高可用,即使是面对大面积的自然灾害,也可以通过将数据存储在单个大洲或者跨大洲的多个数据中心来保证容错。Spanner 的第一个客户是 F1F1Google 广告后端的一个重构项目。F1 的每份数据在美国境内存有5个副本。大部分其他的应用程序一般会将数据备份在同一个地理区域内的3到5个数据中心中,不过这在应对极端灾害时的容错性要略差一些。因为在能够容忍1到2个数据中心异常的情况下,大多数的应用程序相比于更进一步的高可用来说更看重低延迟。

Spanner 设计的首要关注点是管理跨数据中心的数据副本,不过设计者依然在 Google 已有的分布式系统设施之上花了大量的时间来设计和实现某些重要的数据库特性。虽然 Bigtable 能满足很多项目的需求,不过依然有很多 Bigtable 的用户反馈在某些场景下 Bigtable 难以胜任:例如涉及复杂、不断改变的数据库模式;或者要求在大范围数据复制场景下保证强一致性。由于半关系型数据模型以及同步复制的特性,很多 Google 的应用选择使用 Megastore 来存储数据,尽管 Megastore 的写性能不是很好。因此,Spanner 从一个类似 Bigtable 的带版本号的键值存储演化为了一个基于时间戳的多版本数据库。Spanner 中的数据保存在半关系型的表中;每个数据存有多个版本,每个版本的数据都自动标记着提交时的时间戳;旧版本的数据可以根据可配置的垃圾回收策略进行回收;应用程序可以读取某个旧的时间戳下的数据。Spanner 支持通用的事务,以及提供了一个基于 SQL 的查询语言。

作为一个全球分布式数据库,Spanner 提供了几个有趣的特性。首先,应用程序能以合适的粒度动态的调控数据复制的配置。应用程序可以通过配置指定哪个数据中心保存什么样的数据,数据存储的位置距离终端用户有多远(控制读延迟),数据的各个副本间距离有多远(控制写延迟),每个数据要保存几个副本(控制持久性,可用性和读性能)。同时,系统可以动态和透明的在各个数据中心间迁移数据,从而在各数据中心间实现资源的均衡使用。第二,Spanner 实现了两个在分布式数据库中难以实现的功能:提供了外部一致性的读和写,以及在某个时间戳上跨全球数据库的一致性读。这些特性使得 Spanner 能够在全球多数据中心级别支持一致性备份,一致性的 MapReduce 任务执行,以及原子的数据库模式更新,即使执行这些操作时存在进行中的事务也没有关系。

Spanner 通过对事务记录全球提交时间戳来实现上述特性,即使事务可能会被分布式的执行。事务的时间戳体现了串行顺序性。另外,这个串行顺序性满足外部一致性(或者相当于线性一致性):如果某个事务T1T_1在另一个事务T2T_2开始执行前完成提交,则T1T_1的提交时间戳小于T2T_2的提交时间戳。Spanner 是第一个在全球数据中心级别保证这一特性的系统。

实现上述特性的关键点是一个全新的 TrueTime API 及其实现。这个 API 直接将时间的不确定性暴露给了使用方,而 Spanner 基于 TrueTime 提供的不确定性时间的范围(后面会提到 TrueTime 返回当前时间时不是返回一个单独的值,而是一个范围,TrueTime 会确保当前时间落在这个范围内)实现了事务的时间戳先后顺序保证。如果这个时间的不确定性范围太大,Spanner 会减缓操作来等待不确定性范围变小。Google 的集群管理软件提供了 TrueTime API 的一种实现。这个实现利用多个现代的基准时钟(GPS 和原子钟)能将时间不确定性控制在很小的一个范围内(一般来说小于10毫秒)。

实现

本节描述了 Spanner 的结构及其底层实现。然后会再介绍目录(directory),和文件系统中的目录不同,Spanner 中的目录是一个抽象的概念,用于管理数据副本和访问局部性,同时也是数据迁移的最小单元。最后会介绍 Spanner 的数据模型,相比于键值数据库 Spanner 更像是个关系型数据库,以及描述了应用程序如何控制数据的存储位置来实现访问局部性。

一个 Spanner 的完整部署被称之为 universe。因为 Spanner 在全球级别的数据中心管理数据,所以一共只会有几个运行中的 universeGoogle 目前运行了一个测试/体验环境的 universe,一个开发/生产环境的 universe,以及一个仅生产环境的 universe

一个 Spanner 实例以一组 zone 的形式来组织,每个 zone 差不多等同于部署了一批 Bigtable 服务器。每个 zone 是一个可管理的部署单元。系统在各个 zone 之间进行数据复制。当上线或者下线数据中心时,可以向运行中的系统添加或者删除 zonezone 也是物理隔离的单位:一个数据中心内可能有1个或者多个 zone,例如不同应用程序的数据需要分片到同一个数据中心内的不同服务器上。

alt

上图展示了 Spanner 的一个 universe 中各服务器的职责。每个 zone 有一个 zonemaster 和成百上千台 spanserverzonemasterspanserver 分发数据,spanserver 向客户端提供数据服务。同时,客户端通过每个 zone 内的 location proxy 来确定需要访问哪台 spanserver 获取数据。universe masterplacement driver 目前是单点的。universe master 主要是一个控制台,用于展示所有 zone 的状态信息,从而方便调试。placement driver 负责自动的在各个 zone 之前进行数据迁移,这个的操作耗时一般是分钟级。出于满足数据副本数量的要求以及实现数据访问的负载均衡,placement driver 会周期性的和 spanserver 通信从而确认哪些数据需要迁移。出于篇幅考虑,论文只会描述 spanserver 的实现细节。

Spanserver 软件栈

alt

本节主要关注 spanserver 的实现并展示了如何在 Bigtable 的实现之上构建数据复制和分布式事务。上图展示了 spanserver 的软件栈。在底部,每个 spanserver 负责管理100到1000个被称之为 tablet 的数据结构实例。tablet 类似于 Bigtable 中表的抽象,其内部维护了如下的映射:

1
(key:string, timestamp:int64) -> string

Bigtable 不同的是,Spanner 给每一个数据都标记了时间戳,从而使得 Spanner 更像是一个多版本数据库而不是键值存储。每个 tablet 的状态会保存在一组类似 B 树的文件以及一个预写日志中,所有的文件都会保存在一个称之为 ColossusGoogle File System 的后继者)的分布式文件系统中。

为了支持数据复制,每个 spanserver 在每个 tablet 之上构建了一个单 Paxos 状态机(Spanner 的早期设计支持每个 tablet 对应多个 Paxos 状态机,这能支持更灵活的复制配置。不过由于这种设计的复杂性作者最终放弃了)。每个状态机将其元数据和日志保存到对应的 tablet 中。SpannerPaxos 实现支持长期存活的主节点,每个主节点会分配一个基于时间的租约,租期的默认长度是10秒。当前 Spanner 的实现会记录两次 Paxos 的写操作,一次是在 tablet 的日志中,另一次是在 Paxos 的日志中。不过这个目前只是权宜之计,可能会在未来修复。SpannerPaxos 实现能以管道的方式执行,因此在 WAN 环境的延迟下能提高 Spanner 的吞吐;不过提交到 Paxos 的写操作会按照顺序执行。

Spanner 借助 Paxos 状态机实现了一致性的数据映射复制。每个副本的键值映射状态都会保存在相应的 tablet 中。客户端的写操作必须由主节点发起 Paxos 协议;而读操作可以由任意一个有着最新数据的副本执行。这些副本构成了一个 Paxos group

对于身为主节点的副本来说,每个 spanserver 实现了一个锁表(lock table)来实现并发控制。锁表包含了两阶段锁的状态:它会将某个范围内的键和锁的状态建立映射(长期存活的主节点是高效管理锁表的关键)。在 BigtableSpanner 中,锁表都是专门为长时间运行的事务设计的(例如,对于报表生成这样的事务可能需要花费分钟级的时间才能完成),但在锁竞争激烈的情况下使用乐观并发控制策略会造成性能不佳。诸如事务读这样需要同步的操作需要先从锁表中获取锁;其他不涉及同步的操作则无需操作锁表。

对于身为主节点的副本来说,每个 spanserver 实现了一个事务管理器(transaction manager)来支持分布式事务。事务管理器被用来实现 participant leader;而其他同 Paxos 组内的副本则被称为 participant slaves。如果一个事务只涉及一个 Paxos 组(对于大多数的事务来说),则无需事务管理器介入,因为锁表和 Paxos 一起已经提供了事务功能。如果一个事务涉及多个 Paxos 组,则每个组的主节点需要协同完成两阶段提交。其中某个 Paxos 组会被选为协调者:该组的 participant leader 则会担任 coordinator leader,该组内其他的从节点则担任 coordinator slaves。事务管理器的状态会保存在底层的 Paxos 组中(因此这个状态数据也会存有多个副本)。

目录和数据放置

在键值映射之上,Spanner 的实现支持被称为目录(directory)的桶的抽象,目录是一组有着公共前缀的连续键的集合(命名为目录是由于历史的偶然;一个更好的命名可能是桶(bucket))。目录的支持使得应用程序可以通过设置合适的键来控制数据访问的局部性。

一个目录是数据放置的最小单元。每个目录下的所有数据有着相同的复制配置。数据以目录的形式从一个 Paxos 组迁移到另一个 Paxos 组,下图描述了这个过程。Spanner 可能会移动一个目录来减轻某个 Paxos 组的负载;或者将经常被同时访问的多个目录移动到同一个 Paxos 组中;或者将某个目录移动到距离客户端更近的 Paxos 组中。目录的移动可以和客户端的操作同时进行。一个 50 MB 大小的目录可以在几秒内完成。

alt

一个 Paxos 组可能会包含多个目录说明 SpannertabletBigtabletablet 不同:Spannertablet 没有必要是行空间(row space)内按照字典顺序的连续分区。相反,一个 Spannertablet 可能包含了行空间的多个分区。正是基于此特性才使得多个同时被访问的目录可以被移动到同一个 Paxos 组中。下图展示了各组成部分间的关系:

alt

Spanner 使用 Movedir 这样的后台任务在多个 Paxos 组之间移动目录。Movedir 也被用来向 Paxos 组中添加或者删除副本,因为目前 Spanner 还不支持在 Paxos 内部实现配置变更。Movedir 没有被设计为一个独立的事务,这主要是为了避免在进行大量数据移动时阻塞读写请求。相反,movedir 会在后台开始迁移数据。当 movedir 完成数据迁移,但还剩下一小部分数据未迁移时,则会发起一个事务自动的完成数据的迁移,然后更新涉及的两个 Paxos 组的元数据。

目录是应用程序能够控制副本的地理位置属性(或者简单来说,数据放置)的最小单位。在 Spanner 的设计中,数据放置规范语言(placement-specification language)和管理副本配置的职责相解耦。管理员可以控制两个维度:副本的数量和类型,以及副本所在的地理位置属性。Spanner 为这两个维度提供了可选的选项(例如,North America, replicated 5 ways with 1 witness)。应用程序通过标记每个数据库和/或者单个目录的复制选项组合来控制数据的复制。例如,某个应用程序可能会将每个终端用户的数据保存在自己的目录中,从而使得用户 A 的数据在欧洲有三个副本,用户 B 的数据在北美有5个副本。

出于表达清晰的考虑作者简化了这块的描述。实际上,如果某个目录包含的数据过多,Spanner 会将其拆分为多个段(fragment)。不同的段会由不同的 Paxos 组提供服务(也对应了不同的服务器)。Movedir 实际上是在各个 Paxos 组之间移动段,而不是整个目录。

数据模型

Spanner 为应用程序提供了如下的数据特性:一个基于模式化半关系型表的数据模型,一个查询语言,以及通用型事务。之所以要支持这些特性是受几方面的驱动。支持模式化半关系型表和同步复制的需求来自于 Megastore 的流行。至少有300个 Google 内部的应用程序选择使用 Megastore(尽管它的性能不是很好),因为它的数据模型比 Bigtable 更简单,而且它也支持跨数据中心的同步数据复制(Bigtable 只支持跨数据中心数据复制的最终一致性)。使用 MegastoreGoogle 应用程序中比较有名的有 GmailPicasaCalendarAndroid MarketAppEngine。在 Spanner 中支持类似 SQL 的查询语言的需求同样很明确,因为交互式数据分析工具 Dremel 也很流行。最后,希望 Bigtable 支持跨行的事务的呼声也很强烈;开发 Percolator 的部分原因就是为了解决这个问题。某些作者认为通用的两阶段提交的支持成本太大,因为它存在性能和可用性问题。不过,Spanner 的作者认为最好交给应用开发人员来处理由于过度使用事务而产生的性能瓶颈,而不是始终在缺少事务的环境下编程。而在 Paxos 之上实现两阶段提交则减缓了可用性问题。

应用程序的数据模型构建在目录式的键值数据映射之上。一个应用程序会在一个 universe 中创建一个或者多个数据库。每个数据库可以包含不限制数量的模式化表。Spanner 的表类似于关系型数据库中的表,它同样有行,列,以及带版本的值。本文不会深入探讨 Spanner 的查询语言。它和 SQL 很像不过在这基础之上多了些扩展来支持 protocol-buffer 类型的字段。

Spanner 的数据模型不是纯关系型的,它的行必须有名称。更准确的来说,每张表需要有一个或者多个主键列组成的有序集合。这个要求使得 Spanner 看起来像一个键值存储:主键定义了每行的名称,每个表定义了主键列到非主键列的映射。只有某个主键对应有值(即使值是 NULL)才能被认为某一行存在。采用这个结构使得应用程序能通过选择键来控制数据访问的局部性。

alt

上图展示了 Spanner 数据模式的一个示例,在这个例子中,我们创建了两张表来存储每个用户和每张照片的元数据。Spanner 的模式语言和 Megastore 类似,不过 Spanner 有额外的要求,Spanner 的每个数据库必须由客户端分区为一个或者多个层次化的表。客户端程序通过 INTERLEAVE IN 来声明数据库模式的层次化结构。位于层次化结构顶端的表被称之为 directory tabledirectory table 中以 K 为键的数据,和关联的子孙表中所有键以 K 为起始的行按照字典顺序组成了一个目录。ON DELETE CASCADE 表明如果删除了 directory table 中的一条数据,则需要一并删除关联的子孙表中的数据。上图也展示了示例数据库的交错结构(interleaved layout):例如,Albums(2, 1) 表示 Albums 表中 user_id 为2,album_id 为1的数据。这种由交错的表组成的目录对于客户端来说非常重要,因为它使得客户端能够描述不同的表之间的局部性关联,这对于分片、分布式的数据库的高性能来说非常重要。如果缺少这个特性,Spanner 将无从知晓最重要的局部性关联。

TrueTime

本节主要描述 TrueTimeAPI 及概述其实现。TrueTime 大部分的细节会在另一篇论文中描述,本文主要是展示它对于 Spanner 的重要性。下表列举了 TrueTimeAPITrueTimeTTinterval 的形式表示时间,TTinterval 是一段表示非确定性时间的有界区间(而标准时间接口并不会将时间的不确定性暴露给客户端)。TTinterval 两个端点值的类型为 TTstampTT.now() 会返回一个 TTinterval,并且保证 TTinterval 所表示的时间区间一定包含 TT.now() 被调用时的绝对时间。这个时间类似于带闰秒的 UNIX 时间。定义时间的瞬时误差上限为ϵ\epsilon,其值为 TTinterval 区间长度的一半,以及定义ϵˉ\bar{\epsilon}为平均误差上限。TT.after()TT.before() 是基于 TT.now() 的更易用的封装。

Method Returns
TT.now() TTinterval: [earliest, latest]
TT.after(t) true if t has definitely passed
TT.before(t) true if t has definitely not arrived

记某个事件 e 发生的绝对时间为函数tabs(e)t_{abs}(e)。那么以更正式的术语来说,TrueTime 保证对于某次调用 tt = TT.now(),有tt.earliesttabs(enow)tt.latesttt.earliest \leq t_{abs}(e_{now}) \leq tt.latest,其中enowe_{now}是调用 TT.now() 的事件。

TrueTime 底层使用的时间参照是 GPS 和原子钟。TrueTime 使用两种形式的时间参照是因为它们有着不同的异常模式。GPS 发生异常可能是由于天线或者接收器异常,本地电磁波干扰,某些关联异常(例如设计的缺陷造成无法正确处理闰秒和电子欺骗),以及 GPS 系统瘫痪。原子钟的异常模式和 GPS 无关,不过在经过很长一段时间后可能会因为频率误差造成严重的精度缺失。

TrueTime 的实现由每个数据中心中的一组 time master 机器完成,每个机器上存在一个 timeslave 守护进程。大多数的 time master 安装了具有专用天线的 GPS 接收器;这些机器在物理上相互隔离,从而降低天线异常,电磁波干扰和电子欺骗的影响。剩下的 time master(被称之为 Armageddon masters)则配有原子钟。一个原子钟并不是太昂贵;一个 Armageddon master 的成本和一个 GPS master 的成本相当。各个 time master 会定期的互相对比各自的参照时间。每个 time master 也会对比自己的参照时间和本地时钟,如果两者相差过大则该 time master 会退出集群。在时钟同步期间,Armageddon masters 会保守的根据最差情况的时钟漂移来逐渐增加时间的不确定性。GPS masters 的时间不确定性误差一般接近于0。

每个 timeslave 守护进程会拉取多个 time master 的参照时间来减少单个 time master 异常造成的时间误差。timeslave 轮询的 time master 一部分来自于就近数据中心的 GPS master;剩下的来自于更远的数据中心的 GPS master 以及一些 Armageddon master。获取到其他 time master 的参照时间后,timeslave 守护进程会通过一种 Marzullo 算法的变种来识别出不可信的值,然后根据可信的值同步本地时钟。为了避免异常的本地时钟造成影响,如果某个机器的时钟误差频繁超过组件规范和工作环境下的误差上限,则该机器会从集群中剔除。

在时钟同步期间,timeslave 守护进程也会逐渐增加时间的不确定性。记ϵ\epsilon表示保守最差情况下的本地时钟偏移。ϵ\epsilon的值同时也依赖 time master 的不确定性以及和 time master 的通信延迟。在 Google 的生产环境中,ϵ\epsilon呈现出随时间变化的锯齿形函数,在每次轮询 time master 间隔间ϵ\epsilon的值在1到7毫秒内浮动。因此在大多数时间里ϵˉ\bar{\epsilon}的值为4毫秒。当前 timeslave 守护进程轮询 time master 的时间间隔为30秒,以及时钟漂移速率为200微妙/秒,最后ϵ\epsilon的浮动范围为0到6毫秒。而剩下的1毫秒则来源于和 time master 的通信延迟。当发生异常时,ϵ\epsilon的偏移范围超过7毫秒也是有可能的。例如,有时候 time master 的不可用会造成数据中心范围内ϵ\epsilon值的增加。类似的,服务器过载以及网络链路异常也有可能造成局部范围内ϵ\epsilon的值产生毛刺。

并发控制

本节描述了 Spanner 如何使用 TrueTime 来保证并发控制下的正确性特性,以及如何利用这些正确性特性来实现诸如外部一致性事务,无锁只读事务以及非阻塞式的读取旧数据。例如要在某个时间戳 t 对整个数据库做一次审计读取操作,则借助这些特性可以保证这次操作一定能够读取到在时间戳 t 之前已经提交的事务修改。

另外,将 Paxos 的写操作(除非上下文明确的情况下,后续此操作都被称之为 Paxos writes)和 Spanner 的客户端的写操作区分开非常重要。例如,两阶段提交场景下 Paxos 会在准备阶段执行写操作,这个写操作没有任何相关联的客户端写操作。

时间戳管理

下表列举了 Spanner 支持的操作类型。Spanner 支持读写事务(read-write transactions),只读事务(read-only transactions)(预先声明的快照隔离事务(snapshot-isolation transactions)),和快照读(snapshot reads)。单独的写事务由读写事务实现;单独的非快照读由只读事务实现。两者都会在实现内部执行重试(因此客户端无需编写自己的重试逻辑)。

Operation Timestamp Discussion Concurrency Control Replica Required
Read-Write Transaction Section 4.1.2 pessimistic leader
Read-Only Transaction Section 4.1.4 lock-free leader for timestamp; any for read, subject to section 4.1.3
Snapshot Read, client-provided timestamp —— lock-free any, subject to section 4.1.3
Snapshot Read, client-provided bound Section 4.1.3 lock-free any, subject to section 4.1.3

只读事务借助了快照隔离从而有着较好的性能。一个只读事务必须事先声明为不包含任何写操作;它并不简单是一个没有写操作的读写事务。系统会为只读事务选择一个时间戳从而能够以无锁的方式读取以该时间戳为基准的数据,因此也不会阻塞接下来的写操作。只读事务中的读操作可以由任何有着足够新的数据的副本执行。

快照读指的是读取过去的数据因此也无需加锁。客户端可以为快照读指定一个时间戳或者指定一个期望时间戳的上限,然后由 Spanner 选择一个时间戳。不管在哪种情况下,快照读可以由任何有着足够新的数据的副本执行。

对于只读事务和快照读来说,一旦确定了时间戳事务的提交就不可避免了,除非该时间戳对应的数据已经被垃圾回收了。因此,客户端可以避免在重试循环中缓冲结果。当某个服务器异常时,客户端可以在另一台服务器上重新以期望的时间戳和当前的数据读取位置继续执行查询操作。

Paxos 主节点租约

SpannerPaxos 实现使用了基于时间的租约来确保某个主节点长期存活(租期默认是10秒)。主节点的候选者会向其他节点发送请求来获取基于时间的租约投票(lease votes);当该候选者收到大多数的选票后就知道自己持有了租约。当某个副本成功的执行写入后会同时延长租约选票,而对于主节点来说则会在租期快过期前发起延长租约选票的请求。定义某个主节点的租期区间(lease interval)始于获取了大多数的选票,终于不再持有大多数的选票(因为某些选票已过期)。Spanner 依赖如下的不相交不变式(disjointness invariant):对于每个 Paxos 组来说,每个 Paxos 主节点的租期区间都和任意其他主节点的租期区间不相交。

Spanner 的实现允许某个 Paxos 主节点通过释放从节点的选票来主动发起主节点退位。为了维持不相交不变式(disjointness invariant),Spanner 会限制在什么情况下才能发起主节点退位。定义smaxs_{max}表示某个主节点使用的最大时间戳。后面章节会描述什么时候smaxs_{max}会增加。因此,某个主节点只有等到TT.after(smax)TT.after(s_{max})为真时才能发起退位。

为读写事务分配时间戳

读写事务需要用到两阶段锁。因此,Spanner 可以在获取所有锁之后,释放任意锁之前为事务分配时间戳。对于某个给定的事务来说,Spanner 会以 Paxos 的写操作的时间戳作为事务的提交时间戳。

Spanner 依赖如下的单调不变式(monotonicity invariant):在每个 Paxos 组内,即使是多个不同的主节点之间,Spanner 分配给 Paxos 写操作的时间戳都是单调递增的。对于单个主节点来说,分配单调递增的时间戳没有什么困难。Spanner 通过不相交不变式(disjointness invariant)确保了在多个不同的主节点之间也能保证单调不变式(monotonicity invariant):每个主节点只能分配位于任期区间内的时间戳。每当主节点分配了一个时间戳 ssmaxs_{max}(某个主节点使用的最大时间戳)都会更新为 s 来确保不相交不变式(disjointness invariant)。

Spanner 同时也保证了如下的外部一致性不变式(external-consistency invariant):如果某个事务 T2 的开始时间晚于事务 T1 的提交时间,则 T2 的提交时间戳一定大于 T 的提交时间戳。定义事务TiT_i的开始和提交事件为eistarte_i^{start}eicommite_i^{commit};事务TiT_i的提交时间戳为sis_i。则有不变式tabs(e1commit)<tabs(e2start)    s1<s2t_{abs}(e_1^{commit}) < t_{abs}(e_2^{start}) \implies s1 < s2Spanner 中执行事务和分配时间戳的协议遵循了如下的两条规则,从而确保了上述的不变式。定义两阶段提交协议的 coordinator leader 针对某个写操作TiT_i发起提交请求对应的事件为eiservere_i^{server}。则 Spanner 遵循的两条规则为:

  • 开始(Start):在事件eiservere_i^{server}之后,两阶段提交协议的 coordinator leader 分配给某个写事务TiT_i的提交时间戳sis_i不会小于 TT.now().latest。注意 participant leaders 在这一阶段不会参与;4.2.1节会介绍在实现提交等待(Commit Wait)规则时 participant leaders 的职责。
  • 提交等待(Commit Wait):两阶段提交协议的 coordinator leader 会确保在TT.after(si)TT.after(s_i)返回真之前客户端不会读取到事务TiT_i提交的数据。提交等待(Commit wait)确保了sis_i一定小于事务TiT_i提交的绝对时间,或者说si<tabs(eicommit)s_i < t_{abs}(e_i^{commit})。4.2.1节会描述提交等待(Commit wait)的实现。其证明如下:

s1<tabs(e1commit)(commitwait)tabs(e1commit)<tabs(e2start)(assumption)tabs(e2start)tabs(e2server)(causality)tabs(e2server)s2(start)s1<s2(transitivity)\begin{aligned} s_1 &< t_{abs}(e_1^{commit}) \qquad& (commit \, wait) \\ t_{abs}(e_1^{commit}) &< t_{abs}(e_2^{start}) \qquad& (assumption) \\ t_{abs}(e_2^{start}) &\le t_{abs}(e_2^{server}) \qquad& (causality) \\ t_{abs}(e_2^{server}) &\le s_2 \qquad& (start) \\ s_1 &< s_2 \qquad& (transitivity) \end{aligned}

根据某个时间戳读

4.1.2节描述的单调不变式(monotonicity invariant)使得 Spanner 能正确的判断某个副本的状态是否足够满足某个客户端的读请求。每个副本都会记录一个称之为安全时间(safe time)的变量tsafet_{safe},这表示当前副本拥有到tsafet_{safe}为止所有已提交事务的修改。因此,只要客户端希望读取的数据的时间戳 t 满足ttsafet \leq t_{safe},则当前副本就能够提供读操作。

定义tsafe=min(tsafePaxos,tsafeTM)t_{safe} = min(t_{safe}^{Paxos}, t_{safe}^{TM}),其中tsafePaxost_{safe}^{Paxos}表示每个 Paxos 状态机的安全时间,tsafeTMt_{safe}^{TM}表示每个事务管理器的安全时间。tsafePaxost_{safe}^{Paxos}的实现很简单:它的值等于最近一次提交的 Paxos 的写操作的时间戳。因为 Spanner 会以单调递增的顺序分配时间戳,加上 Paxos 会按顺序应用写操作,因此某个写入操作一定不会在tsafePaxost_{safe}^{Paxos}或其之前发生。

当不存在完成了准备阶段(但事务还未提交)的事务时,tsafeTMt_{safe}^{TM}的值为\infty——即两阶段提交协议中已完成准备阶段,但还未完成提交阶段的场景(对于 participant slave 来说,tsafeTMt_{safe}^{TM}指向的是该副本所属主节点的事务管理器的安全时间,从节点可根据主节点下发的写请求中的元数据推断而来)。如果存在这样的事务,则受这些事务影响的状态是不确定的:因为对于 participant replica 来说并不知道这些事务最终是否会被提交。在4.2.1节会介绍,Spanner 的提交协议确保了每个 participant 能知道某个已准备完成的事务的时间戳的下界。每个 participant leader(对应某个 Paxosg)会为某个事务TiT_i在准备阶段的日志中记录一个时间戳si,gprepares_{i, g}^{prepare}coordinator leader 会确保对于组 g 中的每一个事务的参与者来说,事务的提交时间戳sis_i满足sisi,gprepares_i \geq s_{i, g}^{prepare}。因此,对于组 g 中的每个副本来说,在组 g 内完成准备阶段的所有事务TiT_i,有tsafeTM=mini(si,gprepare)1t_{safe}^{TM} = min_i(s_{i, g}^{prepare}) - 1

为只读事务分配时间戳

只读事务会有两阶段来执行:首先会分配一个时间戳sreads_{read},然后以快照读的方式来执行读取时间戳sreads_{read}处的数据。快照读可以由任何有着足够新的数据的副本执行。

可以简单的选取 TT.now().latest 作为sreads_{read}的值,则类似于4.1.2节中关于写事务的描述,就一定能读取到在sreads_{read}之前提交的事务修改。然而,如果客户端读取的副本的tsafet_{safe}还没有更新(从系统层面来看,某个在sreads_{read}之前提交的事务已执行成功,但当前副本并不一定知道这个信息),为了不破坏外部一致性,避免客户端读取到旧的数据,则可能需要阻塞客户端的读取直到tsafet_{safe}更新完成(另外,sreads_{read}的选取也会导致smaxs_{max}的更新来确保不相交不变式(disjointness invariant))。为了减少阻塞的可能,Spanner 需要选取满足外部一致性前提下最老的时间戳。4.2.2节会介绍如何选取这个时间戳。

细节

本节会描述读写事务和之前介绍只读事务时省略的实现细节,以及某种特殊的事务类型实现用来支持原子的模式修改。然后会再描述某些对基础方案的改进。

读写事务

类似于 Bigtable,在某个事务提交前,其写操作会在客户端中缓冲。因此,某个事务中的读操作不会读取到同一个事务中的写操作。这个设计能很好的适配 Spanner,因为读取操作会返回被读取数据的时间戳,而未提交的写操作还未被分配时间戳。

读写事务中的读操作会使用 wound-wait 来避免死锁。客户端向主节点发起读操作,主节点会获取相应的锁然后读取最新的数据。当客户端的事务还在进行中时,它会定期的发送消息来避免 participant leaders 将其事务超时。当客户端完成所有的读操作以及缓冲了所有的写操作时,它就会开始执行两阶段提交。客户端会首先选择一个协调者组(coordinator group),然后给每一个 participant leader 发送一条提交消息,这个提交消息中包含了协调者的标识符以及所有客户端缓冲的写操作。由客户端发起两阶段提交避免了在广域链路下发送两次数据(如果两阶段提交不由客户端发起,可能的一种情况是客户端先将缓冲的写操作发给某个 participant leader,不管是这个 participant leader 自己成为 coordinator leader 还是让其他的 participant leader 成为 coordinator leader,都需要将客户端缓冲的写操作发送给其他的节点,这就造成发了两次数据)。

非协调者的 participant leader 会先获取写锁。然后它会选取一个比之前所有的事务的时间戳都大的时间戳作为准备阶段的时间戳(为了确保单调不变式(monotonicity invariant)),然后通过 Paxos 记录一条准备阶段的日志。然后每个 participant leader 会通知协调者自己所分配的时间戳。

协调者同样会先获取写锁,但是会跳过准备阶段。在收到所有 participant leader 的时间戳后,它会基于这些时间戳选择一个时间戳作为整个事务的时间戳。所选择的事务提交的时间戳 s 必须大于等于任意一个 participant leader 的准备阶段的时间戳(为了满足4.1.3节的限制约束),同时也要大于协调者收到提交消息时的时间戳 TT.now().latest,以及大于 coordinator leader 所有分配给之前的事务的时间戳(同样是为了确保单调不变式(monotonicity invariant))。然后 coordinator leader 也会通过 Paxos 记录一条提交的日志(或者在等待其他参与者时超时从而放弃当次事务)。

在允许参与事务的副本执行提交命令前,coordinator leader 会先进行等待直到 TT.after(s) 返回真,这就满足了4.1.2节描述的提交等待(commit-wait)规则。因为 coordinator leader 会基于 TT.now().latest 选择时间戳 sTT.now().latest 只是其中的一个参考基准,但是实际的时间戳也必然会大于 TT.now().latest),然后现在需要等待直到当前的时间戳大于 s,则等待的时间至少是2ϵˉ2 * \bar{\epsilon}(时间的瞬时误差上限记为ϵ\epsilon,其值为 TTinterval 区间长度的一半,ϵˉ\bar{\epsilon}表示平均误差上限。因为最差的情况下当前的绝对时间可能正好是 TTinterval 区间的起始位置,从而需要等待整个区间长度)。不过这个等待时间基本上是和 Paxos 的通信重合的。在提交等待(commit-wait)结束后,协调者将事务的时间戳发送给客户端以及其他的 participant leader。每个 participant leader 通过 Paxos 记录事务的结果。每个参与者也同样在相同的时间戳下应用日志然后释放锁。

只读事务

给只读事务分配时间戳需要所有涉及的 Paxos 组进行协商。因此,Spanner 要求每个只读事务需要声明一个 scope 表达式,这个表达式汇总了整个只读事务会读取的键。Spanner 会自动的为独立的查询推导出 scope

如果某个 scope 的值只涉及单个 Paxos 组,则客户端会向该 Paxos 组的主节点发起只读事务(当前 Spanner 的实现中只会由主节点为只读事务选取时间戳)。主节点选取时间戳sreads_{read}之后开始执行读操作。对于单点(single-site)读操作,Spanner 通常能选取一个比 TT.now().latest 更好的时间戳。定义 LastTS() 表示该 Paxos 组中最后一次提交的写操作的时间戳。如果当前没有任何已完成准备阶段的事务,那么选取 LastTS() 作为sreads_{read}的值就已经能满足外部一致性:当前的只读事务能读取到上一次写操作的结果,因此该只读事务也发生在这之后。

如果 scope 的值涉及了多个 Paxos 组,这就有几种选择。其中最复杂的选择就是和每一个 Paxos 组的主节点通信,然后基于每个 Paxos 组的 LastTS() 来选取sreads_{read}Spanner 目前选取了更简单的实现。客户端省略了和所有涉及的 Paxos 组的协商阶段,直接选取 TT.now().latest 作为sreads_{read}的值(当然前面说过这会造成阻塞,需要等待副本的安全时间满足大于等于sreads_{read})。因此该事务中所有的读操作都可以发送给有着足够新的数据的副本处理。

模式变更事务

TrueTime 使得 Spanner 能够支持原子的模式变更。使用标准的事务来处理模式变更是不切实际的,因为涉及的参与者数量(数据库中 Paxos 组的数量)可能有百万级别。Bigtable 支持单个数据中心内的原子模式变更,不过执行模式变更时会阻塞所有的其他操作。

Spanner 的模式变更事务基本上是标准事务的一个非阻塞式的变种。首先,它会被分配一个未来的时间戳,这个时间戳是在准备阶段生成的。因此,涉及几千台服务器的模式变更能够在尽可能少的影响到并发进行的事务的前提下完成。第二,依赖需要变更的模式的读写操作会和分配了时间戳 t 的模式变更事务保持同步:如果读写操作的时间戳小于 t,则这些操作可以继续进行;但是如果读写操作的时间戳大于 t,则需要阻塞等待模式变更事务完成。如果没有 TrueTiime,则定义模式修改发生在时间戳 t 就没有意义。

改进

上述定义的TsafeTMT_{safe}^{TM}存在一个缺陷,某个已完成准备阶段的事务会阻止tsafet_{safe}更新(因为tsafe=min(tsafePaxos,tsafeTM)t_{safe} = min(t_{safe}^{Paxos}, t_{safe}^{TM}),在4.1.3节有描述,当存在完成准备阶段的事务时,tsafeTM=mini(si,gprepare)1t_{safe}^{TM} = min_i(s_{i, g}^{prepare}) - 1,需要依赖各参与者所分配的准备阶段的时间戳)。因此,需要读取后面的时间戳的读操作都无法完成,即使该读操作和当前的事务没有冲突。一种解决方案是建立某个范围内的键到已完成准备阶段的事务的时间戳的映射。这部分的信息可以保存在锁表中,因为锁表本身已经保存了某个范围内的键到锁的元数据的映射。当 Spanner 收到一个读操作时,会先判断要读取的键是否存在已完成准备阶段但还未完成提交的事务,如果不存在这样的事务,则如4.1.3节所述tsafeTMt_{safe}^{TM}的值为\inftytsafet_{safe}的值就只取决于tsafePaxost_{safe}^{Paxos}

LastTS() 也有类似的问题:如果某个事务刚刚提交,一个无冲突的只读事务所分配的时间戳sreads_{read}依然要在刚提交的事务的时间戳之后。因此,由于tsafet_{safe}的存在,该只读事务也有可能延迟。这个问题的解决方案也类似于TsafeTMT_{safe}^{TM},同样是建立某个范围内的键到 LastTS() 的映射(不过目前 Spanner 还未实现这个优化)。当 Spanner 收到某个只读事务时,sreads_{read}的值取决于读操作涉及的键所对应的 LastTS() 的最大值,除非同时还存在已完成准备阶段但还未完成提交的事务(则又回到上面一种情况)。

tsafePaxost_{safe}^{Paxos}的问题在于如果没有写操作,则这个值始终得不到更新。因此,如果某个期望读取时间戳 t 的快照读落在了某个最近一次写操作的时间戳小于 tPaxos 组中,那么在没有新的写操作的情况下,这个快照读始终无法被执行。Spanner 通过主节点租约区间的不相交不变式(disjointness invariant)来解决这个问题。每个主节点维护了一个 Paxos 序号 n 到可能分配给下一个序号 n + 1 的最小时间戳的映射,即 MinNextTS(n)。当某个副本应用了序号 n 的指令后,则可以将tsafePaxost_{safe}^{Paxos}的值更新为 MinNextTS(n) - 1,因为下一个被分配的最小时间戳为 MinNextTS(n),减1就保证了不会超过这个值。

对于单个主节点来说可以很轻易的保证 MinNextTS() 的值的正确性(这里有一种可能的粗暴的方案,例如主节点设定10毫秒后才能提交下一个事务,如果10毫秒内来了一个事务,则直接等待到10毫秒后)。因为 MinNextTS() 的值必然落在当前主节点的租期内,又由于各个主节点租期之间的不相交不变式(disjointness invariant)的存在,使得 Spanner 能够在跨主节点时依然保证 MinNextTS() 的值的正确性(如果 MinNextTS() 的值超过了当前主节点的任期,则 MinNextTS() 的值的正确性就交由下一个主节点保证,从而转为了单主节点问题)。如果某个主节点在当前租期快过期时想要增加 MinNextTS() 的值,那么这个主节点就必须先延长租期。注意smaxs_{max}(某个主节点使用的最大时间戳)始终会更新为 MinNextTS() 的最大值来确保不相交不变式(disjointness invariant)。

主节点默认每8秒增加一次 MinNextTS() 的值(因为如果一直没有写操作,则需要不断更新 MinNextTS() 来确保后续的读请求不会阻塞)。因此,在不存在已完成准备阶段的事务的情况下,某个空闲的 Paxos 组中健康的副本在最差情况下需要等待8秒才能返回数据给客户端。主节点可能也会根据从节点的要求来更新 MinNextTS() 的值。

参考

介绍

这是一篇上世纪九十年代的论文,在当时的环境下,安装新工作站的需求与日俱增,而针对大量工作站的文件系统管理却费时费力。为了保存更多的文件和服务更多的用户,就需要更多的磁盘,并挂载到更多的机器上。某一组文件经常会被手动分配给某些特定的磁盘,当磁盘空间不足,异常或者成为性能热点时,就需要手动移动或者复制文件到其他磁盘上。使用 RAID 技术管理多个磁盘只能解决部分问题;当系统增长到需要多个磁盘阵列和多台服务器时,系统管理问题也随之而来。

Frangipani 是一个可扩展的分布式文件系统,它能统一管理挂载在不同机器上的磁盘,对外来说,这些磁盘构成了一个独立的共享存储池。组成 Frangipani 的机器默认能够被统一管理而且相互间能安全的通信。在 Frangipani 之前已经有了一些分布式文件系统的实现,并且在吞吐和容量上有很好的扩展性。Frangipani 的一个显著特性是它的内部结构非常简单——各台协作的机器共同访问一个通用的存储,并使用锁来保证访问的同步性。这种简单的结构使得只需要少量的机器就能处理系统恢复,重配置和负载均衡。Frangipani 的另一个关键特性是相比于已知的分布式文件系统,它结合了一系列功能使得 Frangipani 更易于使用和管理:

  1. 所有用户读取到的文件内容都相同。
  2. 可以轻易的向 Frangipani 添加更多的服务器来增加存储容量和吞吐,而无需修改已有服务器的配置,或者中断其操作。这些服务器可以像积木一样根据需要搭建来构建更大的文件系统。
  3. 系统管理员添加新用户时无需关心新用户的数据会由哪台服务器管理或者保存在哪个磁盘上。
  4. 系统管理员可以对整个文件系统进行完整和一致的备份,而无需停止服务。备份可以在线进行,使得用户可以快速访问被意外删除的文件。
  5. 文件系统可以在无需人工干预的情况下容忍机器、网络、磁盘异常并自行恢复。

Frangipani 构建于 Petal 之上,Petal 是一个易于管理的分布式存储系统,为客户端提供了虚拟磁盘。和物理磁盘一样,Petal 的虚拟磁盘也是以块(block)的方式来读取和写入。和物理磁盘不同的是,一个 Petal 虚拟磁盘提供了2642^{64}字节的稀疏地址空间,并且只在需要的时候才会分配物理存储。Petal 也支持数据备份来保证高可用。Petal 同时提供了高效的快照功能来支持一致性备份。Frangipani 从下层存储系统继承了扩展性,容错和易于管理的特性,不过将这些特性扩展到文件系统还需要些细致的设计。下一节将会详细描述 Frangipani 的结构以及和 Petal 的关系。

alt

上图展示了 Frangipani 的层级设计。多个可替换的 Frangipani 服务器运行于一个共享的 Petal 虚拟磁盘之上,不同的用户程序可以各自通过连接的 Frangipani 服务器来访问相同的文件,而各 Frangipani 服务器间通过分布式锁服务来保证数据的一致性。通过添加 Frangipani 服务器可以实现对文件系统层的扩展。Frangipani 通过异常服务器的自动恢复和借助依然存活的服务器来提供服务实现了容错。相比于中心化的网络文件服务器,Frangipani 通过将负载分摊到各个正在使用文件的机器上来提供更好的负载均衡。出于扩展性,容错和负载均衡的考虑,PetalFrangipani 用到的锁服务也是分布式的。

Frangipani 服务器默认信任 Petal 服务器和锁服务。Frangipani 的最佳使用场景是在同一个管理域下的工作站集群,虽然它也可以扩展到其他管理域下。因此,Frangipani 可以被看做是一个集群文件系统。

论文的作者在 DIGITAL Unix 4.0 之上实现了 Frangipani。得益于 FrangipaniPetal 之上构建的简洁的层级设计,使得在几个月内实现了一个可用的系统。

Frangipani 的目标运行环境的场景是程序开发和工程。测试表明在这样的负载下,Frangipani 有着优秀的性能并且能很好的扩展,而最终的性能上限则受限于网络能力。

系统结构

alt

上图展示了 Frangipani 系统下各机器的一种典型职责分配。最上方的机器运行着用户程序和 Frangipani 的文件服务模块,这些机器无需挂载磁盘。最下方的机器运行着 Petal 和分布式锁服务。

不过在实际场景中,组成 Frangipani 的机器无需严格按照上图中的描述承担职责。PetalFrangipani 文件服务不一定要运行在不同的机器上;每台运行着 Petal 的机器也可以同时运行着 Frangipani 文件服务,特别是当 Petal 的机器负载不高时。分布式锁服务独立于系统中的其他服务,上图中描述了每个 Petal 机器上运行着一个锁服务,不过它们也可以运行在 Frangipani 或者其他可用的机器上。

组件

如前面的图中所示,用户程序通过标准的系统调用接口来访问 Frangipani。运行在不同机器上的应用程序能访问到相同的文件,而且看到的文件内容也是相同的;也就是说,如果在某台机器上修改了某个文件或者文件夹,那么运行在其他机器上的程序也能马上看到这个修改。对于使用 Frangipani 的程序来说,Frangipani 提供的文件操作语义保证和本地 Unix 文件系统提供的文件操作语义保证相同:程序对文件的修改会先暂存在内核的缓冲区中,在下一次的 fsync 或者 sync 系统调用之前,系统不保证对文件的修改会保存到非易失存储上,不过系统会记录对文件元数据的修改并且可选的保证当系统调用返回时,文件的元数据修改已经保存到了非易失存储上。和本地文件系统的文件操作语义有点小小的不同,Frangipani 中文件的最后访问时间是一个近似值,从而避免了每次读取文件时都需要写元数据。

每台机器上的 Frangipani 文件服务模块运行在操作系统内核中。通过内核的 file system switch Frangipani 将自己注册为一个可用的文件系统实现。Frangipani 的文件服务模块使用了内核的缓冲区来缓存最近使用的文件数据。它通过本地的 Petal 设备驱动来实现对 Petal 虚拟磁盘的读写。每个文件服务器使用相同的数据结构来读取和写入文件到共享的 Petal 磁盘上,不过各服务器会在 Petal 磁盘的不同区域上针对进行中的修改维护各自的重做日志。因为 Frangipani 的重做日志保存在 Petal 中,所以当某个 Frangipani 服务器异常时,其他的服务器可以通过 Petal 访问日志并进行数据恢复。各 Frangipani 服务器之间无需通信;它们只会和 Petal 和分布式锁通信。这就简化了服务器的添加,删除和恢复。

Petal 的设备驱动程序掩盖了 Petal 分布式的特性,对操作系统的上层应用来说,Petal 就等同于是一块本地磁盘。驱动程序负责和正确的 Petal 服务器通信,以及如果当前的服务器发生异常,能切换到另一台可用的服务器。类似 Digital Unix 的文件系统都可以运行在 Petal 之上,不过只有 Frangipani 提供了多客户端下访问同一文件的数据一致性特性。

Petal 的各服务器基于本地挂载的物理磁盘并通过协作来向 Frangipani 提供大型,可扩展,容错的虚拟磁盘。Petal 可以容忍一个或多个磁盘或者服务器异常,只要大多数的 Petal 服务器依然存活并且相互之间可以通信,以及每个数据块都至少有一个副本保存在物理存储上并且能够被访问。

Frangipani 用到的锁服务能够为网络中的客户端提供通用的读写锁服务。出于容错和扩展性考虑,它的实现是分布式的。Frangipani 使用锁服务来协调对虚拟磁盘的访问,以及保证各服务器内文件缓存的一致性。

安全和客户端/服务器配置

Fugure 2 所示,每台运行着用户程序的机器同时运行着 Frangipani 的文件服务模块。虽然这种配置有利于负载均衡和扩展,不过存在安全隐患。每个 Frangipani 机器都可以对共享的 Petal 虚拟磁盘上的数据块进行任意读写,所以 Frangipani 必须运行在受信任的操作系统上;类似于 NFS 的远程文件访问协议中的身份认证还不足以保证安全性。完整的安全性也要求 Petal 和锁服务运行在受信任的操作系统上,并且 FrangipaniPetal、锁服务这三个组件都需要能够互相认证。最后,为了保证文件数据的私有性,也需要保证没有人能够窃听 PetalFrangipani 机器间的网络通信。

一种解决方案是运行用户程序的机器被设置为不允许运行自定义修改的操作系统,同时这些机器间通过一个私有网络连接并且用户程序没有权限访问。不过这并不是说需要将所有的机器放在同一个机房中并通过私有的物理网络相连;可以借助某些加密技术来实现系统的安全启动,以及某些认证技术和加密链路来保证通信安全性。另外,对于某些应用程序来说,一个不完整的解决方案也是可以接受的;典型的如 NFS 就不能防止网络窃听以及杜绝用户在自己的工作站上运行修改后的操作系统。论文的作者并没有实现所有的安全措施,不过 Frangipani 基本也可以达到 NFS 的安全级别,Petal 服务器只会接受来自已知网络地址的 Frangipani 服务器的请求。

alt

如上图所示,Frangipani 文件系统可以扩展到外部非受信的管理域中。图中区分开了 Frangipani 客户端和服务端。只有受信的 Frangipani 服务端可以和 Petal 以及锁服务通信。这三个组件可以放置在一个受限制的环境中并且通过私有的网络连接。而外部的非受信远程客户端只能和 Frangipani 服务端通信,而不能直接访问 Petal 服务器。

客户端可以和 Frangipani 服务端以任何操作系统支持的文件访问协议通信,例如 DCE/DFSNFSSMB,因为对于运行着 Frangipani 服务端的机器来说,Frangipani 就类似于是个本地文件系统。当然,如果访问协议本身支持一致性访问是最好的(例如 DCE/DFS),从而使得 Frangipani 的多服务器间的一致性不会在上一层丢失。理想情况下,客户端的访问协议需要支持故障转移。上述提到的协议并不直接支持故障转移,不过在其他系统中如果某台服务器发生异常,会有另一台服务器接管并复用异常服务器的 IP 地址,因此可以在这里应用同样的手段。

除了安全之外,还有第二个原因要使用上述的客户端/服务端配置。因为 Frangipani 运行在操作系统内核,不能快速的适配不同的操作系统甚至是不同版本的 Unix。所以通过远程客户端的方式就能使得运行不支持的操作系统的客户端也能够使用 Frangipani

讨论

构建文件系统的分层思想——低层提供存储服务,高层提供命名,文件夹和文件服务,并不是 Frangipani 独有的。最早应用这个思想的是 Universal File Server。不过,Petal 提供的存储功能和早先的系统大有不同,从而引申出不同的上层结构设计。

Frangipani 的设计是基于 Petal 提供的抽象存储服务,作者还未充分考虑为了适配其他的存储服务(例如 NASD)需要对 Frangipani 做出哪些修改。

Petal 提供了高可用的存储服务并且能够通过添加资源来实现对吞吐和容量的扩展。不过,Petal 不提供协同功能或者在多个客户端间共享存储。另外,大部分的应用程序不能直接使用 Petal 的接口因为 Petal 面向的是磁盘而不是文件。FrangipaniPetal 之上构建了文件系统层使得在保留和扩展了 Petal 有用的特性的同时对应用程序更加易用。

Frangipani 的一个优势是能够透明的添加服务器,删除服务器以及实现故障恢复。通过将预写日志、锁和提供一致性访问、高可用的存储结合使用,Frangipani 能轻易的实现这个特性。

Frangipani 的另一个特性是能在文件系统运行时生成一致性的备份。这个机制会在后面介绍。

不过 Frangipani 的设计可能在三个方面上存在问题。基于启用了副本的 Petal 虚拟磁盘构建的 Frangipani 有时候会记录重复的日志,一次是 Frangipani 自己写入的日志,这里是 Frangipani 为客户端提供服务;另一次是 Petal记录的日志,这里以 Petal 的视角来说 Frangipani 成为了客户端。第二,Frangipani 无法根据磁盘的位置来选择在哪里保存数据,因为 Petal 提供的是虚拟的磁盘,之所以有这个需求可能是因为类似于 GFS 选择在哪里放置副本一样,如果 Frangipani 能知道具体磁盘的位置,它就能选择一个距离客户端近的磁盘保存文件。最后,Frangipani 会对整个文件或者文件夹加锁而不是对某个数据块加锁。不过作者还没有足够的使用经历来评估这三个问题的影响,不过撇开它们不谈,在作者所处环境下测试出的 Frangipani 的性能还是不错的。

磁盘布局

Frangipani 使用 Petal 提供的巨大、稀疏的磁盘地址空间来简化其数据结构。这个想法是受之前有着巨大内存空间的计算机上的相关工作所启发。因为有着如此巨大的地址空间所以可以将其任意切分。

一个 Petal 虚拟磁盘有2642^{64}字节的地址空间。Petal 只会在物理磁盘空间写入后才会将其提交到虚拟地址中。Petal 同时提供了 decommit 原语用来释放某个范围内的虚拟地址所关联的物理磁盘空间。

为了使内部的数据结构足够小,Petal 会以较大的数据块来提交(commit)和回收(decommit)虚拟地址,目前的数据块大小是 64 KB。也就是说,对于每个 64 KB 的虚拟地址空间[a216,(a+1)216)[a * 2^{16}, (a + 1) * 2^{16}),如果有数据写入且没有被回收,那么同时就需要分配 64 KB 的物理磁盘地址空间。因此 Petal 客户端写入的数据不能太稀疏,否则可能由于碎片化造成物理磁盘空间浪费。下图展示了 Frangipani 如何切分 Petal 的虚拟磁盘空间:

alt

图中的第一个区域用于保存共享的配置参数和其他信息。这个区域的最大大小是 1 T,不过目前实际上只用了几 K

第二个区域用于保存日志。每个 Frangipani 服务器会在这块区域中选择一部分来保存自己的私有日志。这里总共预留了 1 T 的空间,并切分为256个分区,所以可以保存256份日志。这就限制了一个 Petal 虚拟磁盘最多支持256个 Frangipani 服务器,不过这可以轻易的通过调整分区个数来扩展。

第三个区域用于保存分配位图,从而知道余下的虚拟空间中哪些是可用的。每个 Frangipani 服务器会独占式的锁住这块区域中的某一部分。当某台 Frangipani 服务器的分配位图空间不够时,它会再次找到可用的区域然后加锁使用。整个区域的大小是 3 T

第四个区域用于保存 inode。每个文件需要一个 inode 来保存元数据,例如访问的时间戳和指向文件数据位置的指针。对于符号链接来说它们的数据直接保存在了 inode 中。每个 inode 的大小为512字节,和磁盘块的大小相同,从而避免了两个服务器同时访问同一个磁盘块上保存的不同 inode 所带来的竞争(也就是 false sharingFAQ for Frangipani, Thekkath, Mann, Lee, SOSP 1997 中对这个问题有所解释,磁盘数据的读取以块为单位,如果 inode 小于512字节,某个 Frangipani 服务器先读取了磁盘数据块并缓存,此时另一个服务器需要读取和修改同一个磁盘数据块上的 inode,那么为了保证缓存一致性,第一个服务器再次读取 inode 时就需要重新读取磁盘数据块并刷新缓存,造成两个服务器交替的读取修改同一个数据块的内容,缓存也就失去了意义,而本质上两个服务器之间并不应该有竞争。)。整个区域的大小是 1 TB,所以可以保存2312^{31}inode。在位图分配区域中的比特位和 inode 的映射是固定的,也就是说根据位图分配区域中的比特位地址就能推算出对应 inode 的地址,所以每个 Frangipani 为新文件所创建的 inode 地址在第四个区域中的偏移比例和该 inode 对应位图分配区域中的比特位的偏移比例是一致的。不过任何一个 Frangipani 都可能读写或释放某个已经存在的文件的 inode

第五个区域用于保存小数据块,每个数据块大小为 4 KB2122^{12}字节)。一个文件的前 64 KB(16个数据块) 的内容会保存在小数据块中。如果某个文件的大小超过 64 KB,则超过的部分会保存在一个大数据块中。Frangipani 在一个 Fetal 虚拟磁盘上最多可以分配2472^{47}字节(128 T)的小数据块,共计2352^{35}块,是 inode 最大数量的16倍。

Petal 虚拟磁盘剩下的地址空间用于保存大数据块。每个大数据块有 1 TB 空间。

选择 4 KB 作为数据块大小会比更小的数据块的策略更容易产生磁盘碎片。同时,一个 inode 512字节在某种程度上也是空间浪费。可以将小文件直接保存在 inode 中来缓解这个问题。虽然存在碎片和空间浪费的问题,不过出于设计简洁性的考虑,作者认为这是一种合理的折中选择。

在当前的设计下,Frangipani 能保存的大文件个数小于2242^{24}(1600万,大文件需要保存在大数据块中,一个大数据块 1 T,而虚拟空间最大地址2642^{64},即224T2^{24} T,又因为不是整个空间都用来保存大文件,所以实际个数小于2242^{24}),大文件是指大于 64 KB 的文件。另外,Frangipani 能保存文件的最大大小是16个小数据块加上一个大数据块(64 KB1 TB)。如果需要保存更多的文件,可以通过减小大数据块的大小来解决;以及允许一个大文件可以保存在多个大数据块中,这样就可以提高最大能保存文件的大小。如果2642^{64}字节的地址空间不够,则一个 Frangipani 服务器可以支持扩展为多个 Petal 虚拟磁盘组成的 Frangipani 文件系统。

作者基于之前文件系统的使用经验设定了上述的系统参数。作者认为这种配置已经足够满足需求,不过还是需要时间和实际使用来检验。Frangipani 的设计足够灵活所以可以通过备份和恢复来验证合适的磁盘布局。

日志和恢复

Frangipani 通过元数据的预写重做日志来简化异常恢复和提高性能;不过用户的数据并不会记录到日志中。每个 Frangipani 服务器会将自己的日志保存在 Petal 中。当某个 Frangipani 服务器需要修改某个元数据时,它会首先生成一条日志来描述具体的修改内容并将其添加到内存日志中。这些内存中的日志会周期性的按照修改请求发起的顺序写入到 Petal 中(Frangipani 同时也支持将日志同步的写入到 Petal 中,这会稍微提高容错性不过会增加元数据更新操作的延迟。)。只有当某条日志写入 Petal 之后,系统才会真正修改对应文件的元数据。实际文件的元数据更新会交由一个 Unixupdate 守护进程来周期性(大概每隔30秒)的更新。

在当前的实现中,Frangipani 写到 Petal 的日志的最大大小为 128 KB。根据 Petal 的空间分配策略,一份日志会拆分到两个不同的物理磁盘上,每个磁盘上的大小为 64 KBFrangipani 会以环形缓冲(circular buffer)的方式来管理所分配的日志空间。当日志空间满时,Frangipani 会回收25%的最老的日志空间来存放新的日志。一般来说,被回收的日志所对应的元数据修改都应该已经写入到了 Petal 中(通过之前的 sync 操作),因此回收日志时不需要额外的写操作。如果回收日志时发现存在某些待回收的日志所对应的元数据修改还没有写入到 Petal,则需要先执行元数据的写入操作再回收日志。根据日志缓冲区和单条 Frangipani 日志的大小(80-128字节),如果在两个 sync 周期内存在1000-1600个元数据修改操作就能写满整个日志缓冲区。

如果某个 Frangipani 服务器发生异常,系统最终能检测到异常并根据该 Frangipani 服务器的日志进行恢复。Frangipani 服务器异常可以被所访问的客户端发现,或者当锁服务向持有锁的 Frangipani 服务器要求返回锁而没有响应时发现。当异常发生时,负责恢复的守护进程会临时拥有异常的 Frangipani 服务器的日志和锁的所有权。异常恢复进程会先找到异常服务器日志的起始位置和结束位置,然后逐条检查每一条日志,判断哪些日志所对应的元数据更新还没有被执行。当日志处理完成后,异常恢复进程就会释放所持有的锁并清空日志。其他的 Frangipani 服务器就可以在不受异常服务器影响的情况下继续工作,而异常的服务器可以在稍后被重启(对应的日志为空)。只要底层的 Petal 磁盘依然可用,系统就能容忍任意数量的 Frangipani 服务器异常。

为了确保异常恢复进程能找到异常服务器的日志的结束位置(即使磁盘控制器没有按照顺序写数据),系统为每512字节的日志数据块分配了一个递增的序号。只要发现某个数据块的序号小于前一个数据块的序号,那就说明前一个数据块就是日志的结束位置。

Frangipani 确保了日志和异常恢复能正确的处理多条日志。不过这在细节上有几点要注意。

首先,在下一节会介绍到 Frangipani 的锁协议保证了多个服务器对同一个数据的更新请求会被串行执行。某个持有写锁且修改了数据的服务器需要先将修改的数据写回到 Petal 后才能释放锁,所以要么是该服务器在正常情况下数据更新完成后主动释放锁,要么是服务器异常后由异常恢复进程在数据更新完成后释放锁。这说明对于任意的数据块来说,整个系统中最多只可能有一条数据修改的日志还未完成。

第二,Frangipani 确保了异常恢复进程只会处理异常服务器在持有锁之后但还未释放锁期间记录的日志。这是为了确保锁协议保证的更新串行化不会被破坏。Frangipani 使用了更强的条件限制来实现这一保证:异常恢复进程永远不会重新执行一个已经执行完成的数据更新。为了保证这一点,Frangipani 给每512字节的元数据块分配了一个版本号。而类似于文件夹的元数据有可能会跨多个数据块,所以也会有多个版本号。对于每个日志要修改的数据块,日志会记录修改的内容及新的版本号。在异常恢复时,恢复进程会比较当前元数据块最新的版本号和日志中记录的版本号,只有当日志中的版本号大于当前最新的版本号时,恢复进程才会执行重做日志。

由于 Frangipani 记录更新日志时不会记录用户数据,而只有元数据块给版本号预留了空间。这就带来了一个潜在问题。如果某个数据块一开始被用于保存元数据,后来空间被释放,然后又被用来保存用户数据,那么恢复进程就不能正确的跳过依然引用了这个元数据块(现在的用户数据块)的日志,因为原来保存元数据块中的版本号信息已经被用户数据所覆盖,所以恢复进程就无法比较日志中的版本号的大小。Frangipani 通过要求被释放的元数据块只能用于保存新的元数据来避免这个问题。

最后,Frangipani 保证在任一时刻只会有一个异常恢复进程在恢复重做某个异常服务器的日志。Frangipani 通过对日志文件的互斥锁来实现这一保证。

Frangipani 的日志和异常恢复机制假定当出现磁盘写异常时,单个扇区中的内容要么都是旧的,要么都是新的,而不会是两者的混合。如果某个磁盘扇区已损坏并且在读操作时返回 CRC 异常,那么 Petal 内置的副本机制通常能恢复对应的数据。如果某个扇区的副本都损坏了,或者 Frangipani 内部的数据结构由于软件 bug 造成损坏,则需要对元数据进行一致性检查以及需要一个恢复工具(例如 Unixfsck)进行数据恢复。不过论文的作者写论文时还未实现这个工具。

Frangipani 的日志并不是为了给用户提供高层次的执行语义保证。它的目的是为了提高元数据更新的性能以及发生服务器异常时通过避免执行 fsck 这样的恢复工具来加快异常恢复速度。因为 Frangipani 的日志只会记录元数据的更新,不会记录用户数据,所以站在用户的视角来说,当系统发生异常时,文件系统的状态和异常发生前并不能保证一致。论文的作者并不是声明这样的语义是理想的,不过这个行为和标准的本地 Unix 文件系统的行为是一样的。在本地 Unix 文件系统和 Frangipani 中,用户都可以在合适的时间点调用 fsync 来确保更好的数据一致性保证。

Frangipani 所使用的日志技术最早被应用于数据库,并在之后应用到其他某些基于日志的系统中。Frangipani 本身不是个日志结构(log-structured)的文件系统;它不会将所有的数据都保存在日志中,而是将数据按约定维护在磁盘中,通过较少的日志 Frangipani 实现了较好的性能和异常恢复的原子性。和其他基于日志的文件系统不同,但是和例如 Zebra 这样的日志结构文件系统相同,Frangipani 也会保存多份日志。

同步和缓存一致性

由于会有多个 Frangipani 服务器修改 Petal 的共享数据,所以需要一个细致化的同步手段来确保各服务器读取到的数据是一致的,以及当系统负载增加或者添加新的服务器时能通过有效的并发手段来提高性能。Frangipani 使用多读一写的读写锁来实现必要的同步。当锁服务侦测到冲突的锁请求时,它会要求锁的持有者释放锁或者进行锁降级(写锁降级为读锁)来消除冲突。

读锁允许一个 Frangipani 服务器从磁盘中读取相应的数据并缓存。如果该服务器被要求释放锁,则在释放锁前必须先清空缓存。写锁允许一个 Frangipani 服务器读取或者修改数据并将其缓存。只有当某个服务器持有写锁时,它所缓存的数据才有可能和磁盘上保存的数据不同。因此,如果持有写锁的服务器被要求释放写锁或者降级为读锁,则必须先将修改的数据写回到磁盘。如果该服务器降级为了读锁,则依然可以保留缓存,不过如果释放了锁则必须清空缓存。

相比于释放写锁或者降级为读锁时将缓存中的数据写回到磁盘,还可以选择直接将缓存中的数据发送给请求方。不过出于简洁性考虑 Frangipani 并没有这么做。首先,在 Frangipani 的设计中,Frangipani 服务器之间无需通信。它们只会和 Petal 以及锁服务通信。第二,当某台服务器异常时,Frangipani 的设计保证了系统只需要处理异常服务器的日志即可。如果选择将未写入到磁盘中的数据直接发送给请求方,而接收方发生异常时,指向未持久化的数据的日志可能分散在了多台服务器中。这就给系统恢复和日志空间回收都带来了问题。

Frangipani 将磁盘数据结构拆分为了一个个逻辑段,每个逻辑段都对应一把锁。为了避免 false-sharingFrangipani 确保了一个磁盘扇区不会保存超过1个可共享的数据结构。将磁盘数据结构拆分为可加锁的段是为了将锁的数量控制的足够小,同时又能避免正常情况下的锁竞争,从而使得锁服务不会成为系统的瓶颈。

每个 Frangipani 服务器的日志都是一个可加锁的段,因为这些日志都是私有的。磁盘布局中的位图区域也切分为了一个个段,并且相互之间加了互斥锁,所以分配新文件时不会发生竞争,因为每个服务器都在自己持有的段内分配。还未分配给文件的数据块或者 inode 也同时被位图中的同一把锁保护,只是该位置的空间当前被标记为可用状态。最后,每个文件,文件夹,或者符号链接都是一个段;也就是说,inode 和其指向的数据都被同一把锁保护。这种每个文件一把锁的锁粒度对于作者所在的工作负载来说已经足够了,因为文件几乎很少会被并发的修改。而对于其他的工作负载来说则可能需要更细粒度的锁。

有些操作会要求原子的更新被多把锁保护的磁盘数据结构。Frangipani 通过对锁全局排序以及使用两阶段获取锁来避免死锁。首先,某台服务器先确定需要获取哪些锁。这个过程中会涉及获取或者释放某些锁,例如查找文件夹中的某些文件名。然后,服务器对锁按照 inode 的地址排序然后依次获取锁。同时服务器会检查在第一阶段中读取的对象是否在第二阶段发生了修改,如果发生了修改,那么该服务器会释放所有的锁然后重新执行第一阶段。否则,该服务器就可以开始执行具体的操作,在缓存中修改某些数据并记录一条日志。在缓存中的数据写回到磁盘前,该服务器都会持有相关的锁。

上述描述的缓存一致性协议类似于 EchoAndrew File SystemDCE/DFSSprite 中的客户端文件缓存协议。这里使用的避免死锁的技术和 Echo 类似。和 Frangipani 一样,Oracle Parallel Server 同样是将缓存中的数据写回到磁盘,而不是直接将缓存中的数据发送给下一个写锁的持有者。

锁服务

Frangipani 只需要一小部分,通用的锁功能,并且不希望锁服务在日常操作中成为性能瓶颈,有很多种实现可以满足这些需求。在 Frangipani 项目中,一共尝试了三种不同的锁服务的实现,其他已有的锁服务也可以提供需要的功能,只是在其之上可能需要编写额外的代码来适配。

锁服务提供了多读一写的读写锁。这里的锁不会用完就马上释放,只要没有其他客户端请求相同的锁,这把锁就会一直被某个客户端持有(这里锁服务的客户端指的是 Frangipani 服务器)。

锁服务通过租约来处理客户端异常。当某个客户端请求锁服务时,它会先获取一个租约。该客户端获取的所有锁都和这个租约绑定。每个租约有一个过期时间,目前是锁创建或者延期后30秒过期。客户端在租约过期前必须先延期,否则锁服务会认为客户端发生了异常。

网络异常会妨碍 Frangipani 服务器延长租约,即使 Frangipani 服务器没有发生异常。当某个 Frangipani 服务器无法延长租约时,它会释放所有的锁并清空缓存。如果缓存中的数据被修改了,那么该服务器会打开某个内部标记使得后续的客户端请求都返回一个错误。相应的文件系统必须取消挂载才能删除这个异常。Frangipani 使用这种粗暴的方式来报告异常从而避免了异常被忽略。

第一版的锁服务实现使用了单节点中心化的服务器,所有的锁状态都保存在了内存中。这种设计对于 Frangipani 来说是足够的,因为 Frangipani 的日志中记录了足够的信息,所以即使锁服务发生异常丢失了所有的状态系统也能够恢复。不过,锁服务异常会导致严重的性能问题。

第二版的锁服务将锁的状态保存在 Petal 中,每个对锁状态的修改都会先写到 Petal 中,然后才会返回给客户端。如果锁服务的主节点异常,那么会由某个备份节点读取 Petal 中的锁状态然后接管异常的主节点并继续提供服务。在这个设计下,异常恢复更加透明,不过日常操作的性能会低于第一种锁实现。作者还未完全实现所有异常的自动恢复就开始了第三种锁服务的实现。

第三版的锁服务是分布式的,并且能很好的支持容错和性能。它由一组相互间协作的锁节点组成,同时每个 Frangipani 服务器内嵌了一个 clerk 模块。

锁服务将锁以表(tables)的形式组织,每个表以 ASCII 字符串的形式命名。每个表中的锁以64位的整型命名。一个 Frangipani 文件系统只使用一个 Petal 虚拟磁盘,虽然多个 Frangipani 文件系统可以挂载到同一个机器上。每个文件系统都绑定了一个关于锁的表。当一个 Frangipani 文件系统挂载时,Frangipani 服务器会请求内嵌的 clerk,然后 clerk 就会打开绑定的锁表。当 clerk 成功打开锁表时,锁服务会返回一个租约标识符,这个租约标识符会在后续通信中使用。当文件系统取消挂载时,clerk 就会关闭锁表。

clerk 和锁节点间使用异步消息而不是 RPC 来通信,这样做能减少内存的使用并同时有着足够好的灵活性和性能。和锁相关的基础消息类型是 requestgrantrevokereleaserequestrelease 消息是由 clerk 发送给锁节点,而 grantrevoke 消息则是由锁节点发送给 clerk。锁的升级和降级同样由这四种消息类型来处理。

锁服务使用了支持容错,分布式的异常监测机制来检测锁节点的异常。这个机制同时也被用于 Petal。该机制基于各节点间定期的心跳交换,同时使用了共识算法来容忍网络分区。

一把锁会在服务端和 clerk 侧都需要消耗内存。在当前的实现中,服务端会为每个锁分配112字节,每个 clerk 如果有进行中或者已分配的锁请求则额外还需要104字节。所以每个客户端每个锁最多使用232字节。为了避免长时间持有锁带来的内存消耗,clerk 会丢弃长时间(1小时)未使用的锁。

一小部分全局且不经常修改的状态信息会由 LamportPaxos 算法复制到所有的锁服务器上。锁服务复用了为 Petal 实现的 Paxos 算法。全局的状态信息包括锁服务器列表,每个锁服务器负责的锁列表,以及打开还未关闭锁表的 clerk 列表。这些信息用于达成共识,即在各个锁服务器间重新分配锁,当某个锁服务器发生异常时能恢复某个锁的状态,以及协助 Frangipani 服务器的异常恢复。从效率考虑,所有的锁被划分到100个不同的锁组中(lock groups),然后以组的形式分配给锁服务器,而不是以单个锁的形式。

有时候一把锁会被重新分配给其他的锁服务器,一方面是为了故障转移,另一方面是为了充分利用刚异常恢复的锁服务器,避免流量集中。当某个锁服务器被永久的添加到集群或者从集群中删除时,会发生类似的锁重分配。在这种情况下,所有的锁始终会被重分配,因为需要保证每台锁服务器持有的锁的数量是均衡的,锁重分配的次数要尽可能的少,以及每个锁都只会分配给一台锁服务器。锁的重分配也是由两阶段进行。在第一阶段,各个锁服务器丢弃保存在内部状态中的锁。第二阶段,锁服务器会和 clerk 通信,根据其所打开的锁表来重新分配锁。锁服务器根据 clerk 的锁表来重新生成锁的状态,同时通知 clerk 每把锁在重新分配后对应的锁服务器。

当某个 Frangipani 服务器异常时,在正确的恢复操作执行前,它所持有的锁不能被释放。特别的,系统需要先处理异常 Frangipani 服务器的日志并将未持久化的元数据更新写入到 Petal。当 Frangipani 服务器的租约到期时,锁服务会通知另一台 Frangipani 服务器上的 clerk 来执行恢复操作,并撤销原来异常服务器持有的全部锁。负责恢复的 clerk 会获取一把异常服务器的日志的互斥锁。这把锁同样分配了一个租约,所以当负责恢复的服务器异常时锁服务会再找一台服务器重新开始恢复任务。

一般来说,Frangipani 系统能够容忍网络分区,并在可能的情况下继续运行,否则就停止服务。特别的,Petal 可以在网络分区的情况下继续运行,只要大多数的 Petal 服务器依然存活并且相互之间可以通信,不过如果某些 Petal 虚拟磁盘在大多数的 Petal 服务器上没有备份的话,那么这些磁盘无法被继续访问。同样的,只要大多数的锁服务器依然存活并且相互之间可以通信,整个锁服务也依然可用。如果某个 Frangipani 服务器无法和锁服务通信,那么它将再也不能延长租约。此时锁服务会认为这个 Frangipani 服务器发生异常,然后会基于它的日志挑选一个新的 Frangipani 服务器发起恢复流程。如果某个 Frangipani 服务器无法和 Petal 通信,那么它将无法读取和写入虚拟磁盘。不管在哪种情况下,Frangipani 服务器都会拒绝后续受影响的文件系统的用户请求,直到网络分区恢复以及文件系统被重新挂载。

Frangipani 服务器的租约过期时存在一个潜在的问题。如果服务器依然存活而只是由于网络原因造成无法和锁服务通信,那么这台服务器可能依然会在租约过期后访问 PetalFrangipani 服务器会在写入 Petal 前检查租约是否依然有效(并确保在未来的tmargint_{margin}秒内依然有效)。不过,Petal 并不会校验某个写入请求是否还在租约有效期内。所以,如果 Frangipani 服务器检查租约和写请求到达 Petal 的时间大于剩余租约的时间,那就会带来一个问题:当 Petal 收到写请求时,租约已经过期,该服务器持有的写锁已经分配给了其他服务器。Frangipanitmargint_{margin}选择了一个足够大的值(15秒)来确保在正常情况下上述问题不会发生,不过依然不能确保一定不会发生。

在未来 Frangipani 会尝试解决这个问题,论文给出了一个可能的解决方案。Frangipani 会给每一个 Petal 的写请求附加一个过期的时间戳。这个时间戳的值为生成写请求时的租约过期时间减去
tmargint_{margin}。这样 Petal 就可以忽略任何时间戳小于当前时间的写请求。只要 PetalFrangipani 服务器的时钟在tmargint_{margin}内保持同步,Petal 就能够可靠的拒绝租约过期的写请求。

另一种解决方案则不依赖时钟同步,但是需要将锁服务和 Petal 集成,并且将 Frangipani 服务器获取的租约标识符附加到写请求中,Petal 收到写请求后就可以根据租约标识符校验租约是否过期,从而拒绝过期的写请求。

添加和删除服务器

系统管理员有时需要添加或者删除 Frangipani 服务器。Frangipani 被设计成能够轻易的处理这些场景。

添加一台服务器到运行中的系统只需要一点点的系统管理工作。新添加的服务器只需要知道使用哪块 Petal 虚拟磁盘以及锁服务的地址即可。新添加的服务器会和锁服务通信来获取租约,然后根据租约标识符决定使用哪部分的日志空间,然后就开始提供服务。系统管理员不需要修改其他服务器的配置,其他服务器能自动适配新服务器的上线。

删除一台 Frangipani 服务器则更简单。可以直接关闭这台服务器。不过更可取的方式是让这台服务器先将未持久化的数据写入到 Petal,然后释放持有的锁,最后再停机,不过这不是强制要求的。当服务器异常停机时,如果后续该服务器持有的锁需要被使用,则系统会自动发起恢复流程,并最终使得共享磁盘的数据达成一致。同样的,系统管理员也不需要修改其他服务器的配置。

Petal 的论文所描述,Petal 服务器同样可以无缝的添加和删除,锁服务器也同理。

备份

Petal 的快照功能提供了一个简便的方法来备份一份完整的 Frangipani 文件系统快照。Petal 的客户端可以在任意时刻创建一个虚拟磁盘的快照。所创建的快照的虚拟磁盘和普通的虚拟磁盘一样,只不过它是只读的。实际快照实现时采用了写时复制(copy-on-write)技术来提高效率。Petal 创建的快照是崩溃一致的(crash-consistent):也就是说,快照中保存的是在 Petal 虚拟磁盘中的数据,Frangipani 服务器内存中的数据不会记录到快照中。

因此,我们可以简单的通过创建 Petal 快照并将其拷贝到磁带中来备份一个 Frangipani 文件系统。快照会包含所有的日志,所以可以将其复制到一个新的 Petal 虚拟磁盘中然后根据日志运行恢复程序来恢复一个 Frangipani 文件系统。归功于崩溃一致的特性,从快照中恢复系统后要解决的问题就简化成了和发生系统级别的停电后恢复系统所要解决的问题一样。

可以对 Frangipani 稍作修改来改进这个恢复机制,即创建一个系统文件级别一致的快照,从而也无需执行恢复操作。可以让备份程序先强制要求所有的 Frangipani 服务器进入一个栅栏,这个功能可以由锁服务提供的全局锁来实现。每个 Frangipani 服务器以共享的模式获取这把锁然后执行修改操作,而备份程序以互斥的方式来处理请求。当 Frangipani 服务器收到请求要求释放锁时,它会阻塞所有新的修改数据的文件系统调用然后进入栅栏,接着清空缓存中已修改的数据,最后释放锁。当所有的 Frangipani 服务器进入栅栏后,备份程序会以互斥的模式获取锁,然后创建一个 Petal 快照并释放锁。之后各 Frangipani 就可以继续以共享的模式获取锁,然后恢复服务。

在后一种方案下,一个 Frangipani 的快照可以无需进行恢复就直接挂载使用。用户就可以从新的磁盘卷中在线获取单个文件,或者将其以一个更方便的格式转储到磁带中而无需 Frangipani 参与数据恢复。新添加的卷必须以只读的格式挂载,因为底层的 Petal 快照是只读的。在未来作者可能扩展 Petal 的快照使其可写,或者在 Petal 之上再抽象一层来模拟写操作。

参考

陈皓在 什么是工程师文化? 中谈到工程师文化由两点组成:自由和效率。不过我认为可以再加一点,那就是实事求是。实事求是要求尊重客观事实,不弄虚作假,不过现实中往往大相径庭。

不尊重客观事实

福尔摩斯里有一句话:

Once you eliminate the impossible, whatever remains, no matter how improbable, must be the truth.

对应了软件开发中一个烂大街的场景:在尽可能的考虑了所有的因素之后,不管完成一个工程所需要的时间是多么的不符合非执行者的预期,最终完成这个工程的时间也只会只多不少。如果无法正视客观事实,则会使得工程从开始到结束都弥漫着自我焦虑。而工程实施时往往只会拙劣的采用10个女人1个月生10个孩子的方式,最终也容易造成工程的反复返工,不过这倒能在总结大会上提供丰富的演讲素材,以及时间紧、任务重的自我感动,然后下次一定。

避实就虚

优秀的团队能正视问题,如果一个团队在面对问题分析时首先想的是哪些问题该提,哪些问题不该提,哪些问题提了会赢得芳心,那这种问题分析就是表演作秀,最终也继续重蹈覆辙。

形式主义

陈皓在 什么是工程师文化? 中关于工程师文化如何落地提到引入绩效考核,不过这可能会造成形式主义和和团队间无意义的攀比。例如,如果将 Code Review 作为考核指标,难免会出现:快到月末了,还需要再提20个 comment;某部门的人均 commentxx 个,本部门才 yy 个,每个人努努力,提到 zz 个。

移花接木

在成果导向的规则下,如果通过 ABC 达成了 D,则直接对外宣称通过 A 达成了 D

参考

Lab 3 需要我们实现一个基于 Raft 的键值数据库,支持三个操作:

  • Put(key, value)
  • Append(key, value)
  • Get(key)

3A

客户端

客户端要做的只有一件事,就是向某个服务端发送请求。不过由于客户端不知道哪个服务端是主节点,所以需要不断轮询各服务端发送请求。为了避免每次轮询所有服务端浪费时间,客户端可以记录每次请求成功后的服务端编号,这个服务端就是当次请求中的主节点;当客户端再次发起请求时,可以先假定之前的服务端依然是主节点,从而先向该服务端发送请求,如果请求失败并返回 ErrWrongLeader 异常,则再尝试下一个服务端。

服务端

Lab 3 要求服务端将客户端请求成功的结果放到 RPC 响应中,不过 Raft.Start() 的执行成功不代表最终日志的应用成功,所以服务端调用 Raft.Start() 后需要阻塞等待,直到 Raft 将对应日志应用到状态机。等待/唤醒的模式可以想到使用条件变量 sync.Cond,不过 Go 中有 channel 这个更方便的特性来实现。

正常情况下,服务端调用 Raft.Start() 添加日志的顺序和之后从 applyCh 中收到日志的顺序一致,也就是说客户端请求到达服务端并被处理的顺序和服务端从 applyCh 中收到日志的顺序一致。所以,服务端可以维护一个客户端请求的队列,队列中存放的是 channel,每当服务端从 applyCh 中收到日志,就将日志发送到队首的 channel 中,并从队列中移除。这样阻塞等待中的 RPC 服务端线程就能被唤醒,并响应客户端。

不过在异常情况下,客户端请求队列和服务端从 applyCh 中收到日志的顺序并不是一一对应,因此服务端收到日志时需要剔除掉队列中无效的请求,并通过 channel 发送一个 ErrWrongLeader 异常,这样客户端就能换一个服务端来重试。由于通过日志索引无法唯一确定一条 Raft 日志,所以需要在 ApplyMsg 中添加 CommandTerm 来标识日志所属的任期,这样服务端从 applyCh 中收到日志后就能通过比较客户端请求队列中的日志任期和索引来判断请求是否有效。

记客户端请求队列队首日志的任期和索引为 (term_client, index_client),记服务端从 applyCh 收到的日志的任期和索引为 (term_applied, index_applied)。正常情况下有 term_client == term_applied 以及 index_client == index_applied。从服务端角度来说,异常情况有两种,一种是当前服务端不再是主节点,另一种情况是当前服务端依然是主节点,不过中途发生了主从切换可能造成当前的日志和最初的不同。对于第一种情况可以直接清空客户端请求队列,虽然 (term_applied, index_applied) 有可能匹配部分客户端请求,不过由于当前服务端不再是主节点,下次客户端请求的时候本身就要再轮询所有的服务端,所以这里等同于是提前让客户端轮询。对于第二种情况(也考虑原来是从节点后来变成主节点的场景),可以从队首开始遍历客户端请求队列,剔除掉比 (term_applied, index_applied) 小的 (term_client, index_client) 请求,并通过 channel 返回异常(这里需要一个自定义异常,告诉客户端直接重试,因为当前服务端依然是主节点,所以客户端没有必要轮询)。这里的剔除掉比 (term_applied, index_applied) 小的 (term_client, index_client) 请求,指的是仅保留 term_client >= term_appliedindex_client >= index_applied 的请求,因为根据 Raft 日志的性质,其他情况下的客户端请求都已经不可能被提交。

因此,服务端需要开启一个单独的 goroutine,并不断的从 applyCh 中获取日志,然后根据日志的指令内容更新本地的键值数据库,最后唤醒客户端请求队列中的请求。而如何实现本地键值数据库不是本实验的重点,所以简单使用了一个 map

客户端请求去重

6.824 Lab 3: Fault-tolerant Key/Value Service 中提到:

It’s OK to assume that a client will make only one call into a Clerk at a time.

一个客户端一次只发送一个请求,加上请求阻塞的特性,任何时刻每个客户端都最多只有一个进行中的请求。为了对请求去重,每个客户端可以生成一个唯一的客户端 id,每次发请求时生成一个递增的请求序号,而服务端只需要维护每个客户端已提交到状态机的最大请求序号即可,这是因为当前场景下每个客户端的请求序列是个递增的序列(非严格递增,相邻数字之间可能存在重复)。所以,当服务端收到请求时,如果发现请求中的序号小于等于该客户端的最大请求序号,则说明该请求是重复的。

不过,处理重复的读请求有两种方案,一种是返回当前值,另一种是返回第一次收到读请求时的值。两种方式都可解释,本实验中直接返回当前值即可。

那么服务端收到 Raft 日志时如何知道这个日志对应的客户端请求序号?这个属于应用层面的数据,可以将客户端 id 和请求序号放到 Op 中,服务端收到 Raft 的日志后,将 ApplyMsg.Command 进行类型转换,转为 Op 即可。

问题

TestSpeed3A

TestSpeed3A 要求每个心跳周期至少完成三次客户端请求,不过在做 Lab 2 时,Raft 收到日志后不会马上发起共识,而是在下一次发送心跳时批量对收到的日志发起共识。又由于 TestSpeed3A 会循环发起请求,每个请求阻塞,服务端只有在收到 applyCh 的日志后才会通知客户端,所以本质上在这个测试中服务端约等于一个心跳周期只处理一个请求。所以需要修改 Raft.Start(),收到日志后开启一个 goroutine 发起心跳。

客户端请求队列无法被唤醒

服务端收到 Raft 的日志后才唤醒客户端请求队列会造成客户端请求队列永远不会被唤醒,因为这强依赖于某条日志被提交,而客户端的日志不一定会被提交。例如,某个服务端收到客户端的请求,将请求放到队列中,此时服务端发生异常,其他服务端成为新的主节点,而新的主节点并没有收到客户端的日志,在没有其他客户端请求的情况下,最开始的客户端请求永远不会被唤醒。所以,这里也额外开启了一个 goroutine,如果当前服务端不是主节点且客户端请求队列不为空,则清空客户端请求队列,并通知 ErrWrongLeader 异常。

不过,这个策略也会带来一个请求重复执行的问题。当前身为主节点的服务端成功提交了某个客户端的请求,注意这里是 commit,而不是 apply,此时服务端发生异常,另一个服务端成为新的主节点,原来的服务端发现自己不是主节点并且请求队列不为空,则清空了请求队列,然后客户端发起重试,新的主节点收到了请求并成功提交,最后 Raft 的日志中就会有两条内容一样的日志,但是 Raft 并不关心两条日志的内容是否相同。所以这个去重需要在服务端处理,服务端从 applyCh 收到日志后,需要判断日志中对应的请求是否已被处理。造成这个问题的主要原因在于 Raft 处理日志的 commitapply 之间存在时间差,而服务端只通过 applyChRaft 进行交互。

3B

引入快照之后,服务端从 applyCh 收到日志时需要判断是否是快照消息,如果是快照消息则执行快照逻辑。3B 整体难度低于 3A,快照的代码逻辑类似于 Lab 2 中的快照代码,不过要注意两点:

  1. 快照会通过 RPC 发送,所以涉及快照的字段命名注意首字母大写
  2. Raft 收到快照 RPC 后,再通过 applyCh 发送快照,但是服务端从 applyCh 中收到的快照消息不一定是最新的,即快照的最远日志索引有可能会落后于服务端已经应用到状态机的最远日志索引(因为 Raft 层收到的快照可能只覆盖了当前日志的一部分,而 RaftapplyCh 中发送已应用的日志或快照间没有顺序关系,所以对于服务端来说已经应用到状态机的日志索引可能会大于快照中的日志索引。)。如果快照不是最新的,服务端直接忽略即可,避免覆盖当前的状态机。如何知道当前快照不是最新的?服务端可以记录已提交到本地状态机的最大 ApplyMsg.CommandIndex,收到快照消息后将其和快照消息中的 ApplyMsg.SnapshotIndex 比较即可

参考

Students’ Guide to RaftMIT 6.824: Distributed Systems 之前的助教写给学生看的实验生存指南。在 MIT 6.824 - Lab 2 (1): Students’ Guide to Raft 中介绍了关于 Lab 2 的部分,本文将继续介绍关于 Lab 3 的部分。

Lab 3 中,我们需要实现一个基于 RaftKey-Value 数据库,本文描述了某些对实现可能有帮助的细节。

提交客户端操作

实现客户端请求时可能会先直接发一个请求给客户端所认为的主节点,然后对应的服务端等待 Raft 应用日志,接着服务端执行客户端的请求逻辑,最后再把结果返回给客户端。这种方式适合单客户端的系统,不过不适合多客户端并发的系统。在多客户端请求下,每个客户端请求都有可能修改系统状态,即使各 Raft 节点的日志保持一致,由于各客户端请求间可能相互交替执行,服务端本地状态可能和 Raft 节点的最新日志不一致,除非使用全局的锁隔离各客户端请求,不过系统会退化为串行程序。

文中建议将服务端当做状态机处理,每个客户端的请求本质上都是将状态机从一个状态转变为另一个状态。服务端中有一个专门的线程来处理客户端请求,该线程每次获取一个客户端请求,然后将其提交给 Raft,之后收到 Raft 应用日志的通知后,按顺序将客户端命令应用到服务端的本地状态机中,这里虽然看起来也是串行处理客户端请求,不过由于 Raft.Start() 方法会立即返回,当有大量请求时,Raft 在实现时会批量发送日志。这个线程是整个服务端中唯一能修改本地状态机的地方,所以服务端的 RPC 就简化为了向任务队列中提交任务,并且当 applyCh 接收到可以执行的日志时,将日志所对应的命令应用到本地状态机中,然后响应客户端。

不过,这也带来了一个问题:什么时候知道某个客户端请求执行完成了?这在一切正常的情况下非常简单,因为我们是按序将客户端请求提交给 Raft,所以最后从 applyCh 中出来的日志的顺序就是提交客户端请求的顺序。不过,当前客户端所通信的服务端有可能在中途不再是主节点,所以客户端所发送的日志有可能被丢弃,此时客户端需要能够知道发生了异常,然后尝试换一个服务端。

一个简单的方法是记录提交客户端请求时 Raft 返回的日志索引,然后从 applyCh 收到对应索引的日志时,判断该条日志是否对应最初的客户端请求(可以向 ApplyMsg.Command 添加额外的信息来标识是否是当初的请求)。如果不是同一条请求,则说明发生了异常。

识别重复请求

因为客户端异常重试的机制存在,所以服务端需要能识别出重复的客户端请求:例如某个客户端发送 APPEND 请求,当前服务端成功执行但是客户端没有收到响应,客户端会选择一个新的服务端发送请求,新的服务端需要确保 APPEND 请求不会被执行两次。因此,每个客户端请求需要一个唯一的标识,使得服务端能够识别已经执行的请求。另外,由于客户端会选择不同的服务端发送请求,各服务端需要对已执行的客户端请求达成共识。

有很多方法来为客户端请求生成唯一的标识符。一种简单并且相对有效的方法是先给每个客户端分配一个唯一的标识符,然后给每一个请求附带一个递增的序列号。如果某个客户端重新发送请求,则会复用之前的请求序列号。各服务端需要维护每个客户端最新的请求序列号,如果服务端发现客户端的请求序列号已处理,则直接忽略该请求。

难以定位的边界条件

如果按照上述的方式实现,有可能会遇到两个难以定位的问题。

重复出现的日志索引

Raft.Start() 会返回所添加的日志的索引,不过在实际实现时可能会认为这个索引不会重复返回,或者遇到重复的索引时会认为前一个相同索引的日志所对应的请求已经执行失败。不过实际上这两种看法都不正确,即使没有个任何一个服务端发生异常。

假设有 S1S5 五个节点,一开始 S1 是主节点,并且没有日志,然后系统发生以下交互:

  1. S1 收到两个客户端请求 C1C2
  2. S1 分别返回日志索引1和2给 C1C2
  3. S1 发送包含了 C1C2AppendEntries 请求给其他从节点,其中 S2 收到请求,其余节点均未收到
  4. S3 成为候选节点
  5. S1S2 不会投票给 S3,但是 S4S5 会,所以 S3 成为新的主节点
  6. S3 收到新的客户端请求 C3
  7. S3 调用 Start() 方法并返回日志索引1给 C3
  8. S3 发送包含 C3AppendEntries 请求给 S1S1 丢弃 C1C2 的日志后添加 C3
  9. S3 在给其他从节点发送 AppendEntries 请求前发生异常
  10. S1 成为候选节点,由于它的日志最新,所以再次成为主节点
  11. S1 收到新的客户端请求 C4
  12. S1 调用 Start() 方法并返回日志索引2给 C4(在之前的步骤中,日志索引2也返回给了 C2
  13. S1 在给其他从节点发送 AppendEntries 请求前发生异常,此时 S2 成为候选节点
  14. S1S3 不会投票给 S2,但是 S4S5 会,所以 S2 成为新的主节点
  15. S2 收到新的客户端请求 C5
  16. S2 调用 Start() 方法并返回日志索引3给 C5
  17. S2 成功将 AppendEntries 请求发送给其他所有从节点,在后续的心跳中,leaderCommit = 3

最终 S2 的日志为 [C1, C2, C5],此时所有节点在索引位置2处的日志为 C2,这就为开头的两个观点提供了反例:Start() 方法可能返回重复的日志索引,以及遇到重复的索引时不代表前一个相同索引的日志所对应的请求已经执行失败。

四方死锁

课程的另一个助教 Steven Allen 发现在实现 Lab 3 时很容易遇到一个四方死锁问题。

不管具体的 Raft 代码如何实现,一般来说都会有一个类似于 Raft.Start() 的方法来使得应用程序添加日志,以及很有可能有一个单独的线程将位于 [lastApplied + 1, commitIndex] 范围内的日志通过 apply() 方法发送给应用程序(Students’ Guide to Raft 这篇文章写于2016年,在最新的课程中 Raft 通过 applyCh 来发送日志)。这两个方法很可能都需要持有锁 a。而在应用程序中,很可能会在某个 RPC 中调用 Raft.Start() 方法,然后同样可能有个线程会等待 Raft 的日志应用通知,当这个线程收到通知后,就可以响应客户端。由于这两个方法需要通信(例如,RPC 方法需要知道什么时候客户端请求执行完成),所以很可能也都需要持有锁 b

上述的方法用 Go 描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
func (a *App) RPC(args interface{}, reply interface{}) {
// ...
a.mutex.Lock()
i := a.raft.Start(args)
// update some data structure so that apply knows to poke us later
a.mutex.Unlock()
// wait for apply to poke us
return
}

func (r *Raft) Start(cmd interface{}) int {
r.mutex.Lock()
// do things to start agreement on this new command
// store index in the log where cmd was placed
r.mutex.Unlock()
return index
}

func (a *App) apply(index int, cmd interface{}) {
a.mutex.Lock()
switch cmd := cmd.(type) {
case GetArgs:
// do the get
// see who was listening for this index
// poke them all with the result of the operation
// ...
}
a.mutex.Unlock()
}

func (r *Raft) AppendEntries(...) {
// ...
r.mutex.Lock()
// ...
for r.lastApplied < r.commitIndex {
r.lastApplied++
r.app.apply(r.lastApplied, r.log[r.lastApplied])
}
// ...
r.mutex.Unlock()
}

假设此时系统处于以下状态:

  • App.RPC 获取锁 a.mutex 然后调用 Raft.Start
  • Raft.Start 正在等待锁 r.mutex
  • Raft.AppendEntries 持有锁 r.mutex,然后调用 App.apply

此时就发生了死锁,因为:

  • Raft.AppendEntriesApp.apply 返回前无法释放锁 r.mutex
  • App.apply 在获取锁 a.mutex 前无法返回
  • a.mutexApp.RPC 返回前无法被释放
  • App.RPCRaft.Start 返回前无法返回
  • Raft.Start 在获取锁 r.mutex 前无法返回
  • Raft.Start 需要等待 Raft.AppendEntries 释放锁 r.mutex

有几种方法来避免死锁。其中最简单的就是在 App.RPC 中,调用 a.raft.Start 之后再尝试获取锁。不过这可能会带来个问题,在 a.raft.Start(args)a.mutex.Lock() 执行之间可能触发 app.Apply,造成错失日志通知。所以另一种方法是从 Raft.AppendEntries 中分离出 r.app.apply,由一个单独的线程来调用 r.app.apply,这就保证了服务端不会错过日志的通知,同时又避免了死锁。

参考

介绍

一个存储系统一般来说会实现一些接口使得客户端能够存储,获取,或者修改数据。文件系统和数据库系统是最广为人知的例子。对于文件系统来说,对单个文件的操作(读和写)是原子的;对于数据库系统来说,每个操作(事务)可能会访问多个对象,并且是可串行化的。

本文关注的存储系统介于文件系统和数据库系统之间。特别的,本文关注的存储系统,在这之后称之为存储服务,有以下功能:

  • 存储对象
  • 支持查询操作,能够返回单个对象的衍生数据
  • 支持更新操作,能原子的基于单个对象之前的状态根据某些预编程的计算(可能是非确定性的)来修改对象的状态

文件系统的写操作是上述存储服务的更新操作的一个特例,而上述存储服务的更新操作又是数据库事务的一个特例。

越来越多的在线零售商(例如 Amazon.com),搜索引擎(例如 GoogleFAST),以及很多信息密集型服务通过将大型存储系统使用网络互联来提供服务。相对于文件系统和数据库系统来说,存储系统对于这些应用来说是较为适合的方案,因为数据库系统成本和代价过大,而文件系统则缺少丰富的操作语义。

构建大型存储服务的一个挑战是如何伴随着异常和配置更改(异常组件能被检测到并被替换)的同时维持系统的高可用和高吞吐。

一致性保证对于存储服务来说同样很重要。即使不重要,如果有了强一致性的保证,则能简化基于存储服务的应用程序构建,强一致性保证了:

  1. 对单个对象的读写操作会按照某个顺序执行
  2. 读操作一定能读取到之前的更新操作的结果

强一致性保证经常被认为和高吞吐、高可用是冲突的。系统设计者一般不会牺牲系统的吞吐或者可用性,而是牺牲强一致性保证。The Google File SystemGFS)就体现了这样的思想。实际上,大型存储服务的强一致性保证和高吞吐、高可用并不是冲突的。本文介绍的 chain replication 方式在对 fail-stop 类型异常容错的同时,能同时支持高吞吐,高可用和强一致性。

存储服务接口

客户端会向存储系统发起查询和更新操作的请求。虽然能做到每一个到达存储服务的请求都能保证被执行,不过在论文 End-to-end arguments in system design 中提到这样做意义不大。存储服务可以简单的只处理能到达的请求,并在请求完成时响应客户端,这样就不用区分对待请求丢失和响应丢失这两种情况:客户端可以在一段时间没有收到响应后重新发起请求。

本文描述了两种接口:

  • query(objId, opts) 会返回 objId 对应的对象的衍生数据;opts 指定了需要返回对象中的哪部分数据。objId 所对应对象的值不会被修改。
  • update(objId, newVal, opts) 的返回值取决于 opts,一般来说,返回值 V 会基于 objId 对应的对象的当前值和(或)新值 newVal 根据某些预编程的非确定性计算求得;V 会成为 objId 对应的对象的新值。

查询操作是幂等的,但是更新操作不一定幂等。当客户端重新发起某个非确定性的更新操作时,必须确保上一次的请求并没有被执行。例如,客户端在重新发起更新操作前可以先执行一个查询操作,来确认该对象的值是否已经被更新。

如果某个请求没有响应,那么并不能区分是因为客户端的请求在到达存储服务前丢失还是客户端的请求被存储服务所忽略。这样当存储服务经历短暂的异常而丢弃了客户端的请求时,客户端可以简单的重新发起请求,而无需对这一异常场景单独处理。当然出于客户端性能的考虑,会尽可能降低系统异常的频率和持续时间。

在链式复制(chain replication)模式下,系统异常的时间远小于移除一个异常的节点或者增加一个新节点的时间。所以,遇到系统异常,恢复和其他配置变更时,对客户端请求的影响能降低到最小。而其他大多数的副本管理协议(replica-management protocols)要么会阻塞某些操作,要么在异常后或者配置变更期间牺牲一致性保证。

通过客户端视角下对象的状态以及查询和更新操作下客户端状态的转换,本文定义了所描述的存储系统的功能。下图通过伪代码的方式描述了该存储系统的功能:

alt

上图通过两个变量定义了 objIDobjID 所对应对象的状态:HistobjIDHist_{objID} 表示 objIDobjID 所对应对象已执行的更新操作,PendingobjIDPending_{objID} 表示待处理的请求。

上图也同时列出了可能的状态转换。T1 声明了新到达的请求会被添加到 PendingobjIDPending_{objID}T2 声明了 PendingobjIDPending_{objID} 中的请求被系统忽略时会从 PendingobjIDPending_{objID} 中移除,不过这种情况不会频繁发生。T3 展示了高层次的请求处理过程:首先请求 r 会从 PendingobjIDPending_{objID} 中移除;然后查询操作会生成相应的响应,而更新操作在生成响应之外还会将请求 r 添加到 HistobjIDHist_{objID} 中。

链式复制协议

本文描述的服务器假定具有 fail-stop 特性:

  • 每台服务器发生异常时会停机,而不是继续执行错误的状态转换
  • 服务器的异常能够被系统检测

对于有 t 个副本的对象来说,可以容忍 t - 1 个副本异常而不影响可用性。所以存储对象的可用性就取决于所有持有该对象副本的服务器都发生了异常的概率。因此,本文假定最多有 t - 1 个服务器会同时发生异常。

alt

如上图所示,在链式复制模式下,各节点以一条链的形式来复制对象。链中的第一个节点被称为头节点,最后一个节点被称为尾节点,系统以如下的方式处理请求:

  • 生成响应:每个请求的响应都只由尾节点生成并发送给客户端。
  • 查询处理:每个查询请求都只由尾节点处理,并根据 objIDobjID 查询尾节点本地的数据。
  • 更新处理:每个更新请求都交由头节点处理。首先头节点根据 objIDobjID 更新本地的数据,然后将更新请求以 FIFO 的顺序交由后继节点处理,以此类推,一直传递到尾节点处理。

在上述流程下,系统能保证强一致性,因为查询操作只由尾节点处理,而直到尾节点处理更新操作前,该更新结果都不会暴露给客户端,一旦尾节点更新完成,则后续的查询操作就能读到最新的请求。另外,各节点间 FIFO 的请求传递顺序也保证了某个对象更新的全局顺序性。

因为查询操作只涉及单个节点,所以查询操作是一个轻量级的操作。对于更新操作来说,前 t - 1 个节点的更新操作和最后一个节点生成响应没有直接关联,属于冗余操作,不过也正是这种冗余操作提高了系统的容错性。

如果更新操作不是单纯的直接写入而是需要涉及一系列计算,则该计算只会在头节点计算一次,后续节点的更新可以直接复用头节点计算的结果然后直接写入。这也表明系统可以接受非确定性的更新请求,因为非确定性计算只会在头节点计算一次。

协议细节

客户端不会直接操作 HistobjIDHist_{objID}PendingobjIDPending_{objID},所以可以以适合的方式来实现它们。当使用链式复制来实现 Fugure 1 中的规范时:

  • 使用 HistobjIDTHist_{objID}^T 来表示 HistobjIDHist_{objID},表示尾节点所存储的 HistobjIDHist_{objID}
  • PendingobjIDPending_{objID} 表示链中任一节点收到但还未被尾节点处理的客户端请求集合。

根据规范描述的如何实现 HistobjIDHist_{objID}PendingobjIDPending_{objID}(假设此时不会发生异常),可以发现唯一能影响 HistobjIDHist_{objID}PendingobjIDPending_{objID} 的系统状态转换为:

  1. 链中的某个节点收到了来自客户端的请求(影响 PendingobjIDPending_{objID}
  2. 尾节点处理客户端请求(影响 HistobjIDHist_{objID}),这里应该是处理更新请求

由于这里假设此时系统不会发生异常,所以上述两种情况已经能够覆盖 Fiture 1 中的状态转换。然后具体来看这两种情况:

  • 链收到来自客户端的请求:客户端将请求发送给头节点(更新)或者尾节点(查询)。在请求未被尾节点处理前,请求 r 会被先添加到 PendingobjIDPending_{objID} 中,并符合 T1 的转换。
  • 尾节点处理请求:处理请求时会先从 PendingobjIDPending_{objID} 中移除请求 r,也就是 T3 的第一步。尾节点处理完后会将请求 r 添加到 HistobjIDHist_{objID} 中,而在本文的定义下为 HistobjIDTHist_{objID}^T

处理节点异常

如果发现链中某个节点异常(根据 fail-stop 的假设,所有该种类型的异常都能被监测到),则链需要更新配置来剔除异常的节点。针对此,需要引入一个主节点来处理:

  • 监测节点异常
  • 通知链中的每个节点在删除异常节点后的新链中的前继和后继节点
  • 通知客户端新链中的头节点和尾节点

在本文接下来的内容中,本文假设主节点是个永远不会发生异常的单进程。这虽然简化了描述但显然不是一个现实的假设;在作者的原型实现中,主节点有多个副本,并使用 Paxos 协议来协调各节点,所以对外来说整个系统就有了一个不会发生异常的主节点。

主节点能监测三种类型的异常:

  1. 头节点异常
  2. 尾节点异常
  3. 中间节点异常

这三种异常的处理方式取决于更新操作如何在链中传递。

记链的头节点为 H,则下一个节点为 H + 1,以此类推。再记尾节点为 T,定义下述关系表示节点 iHistobjIDiHist_{objID}^i 是节点 jHistobjIDjHist_{objID}^j 的前缀:

HistobjIDiHistobjIDjHist_{objID}^i \preceq Hist_{objID}^j

因为更新操作以 FIFO 的顺序从一个节点传给下一个节点,所以每个节点收到的更新序列是前一个节点收到的更新序列的前缀。所以有:

  • Update Propagation Invariant:对于满足 iji \leq j 的节点 ij(即 i 在链中是 j 的前继节点),有 HistobjIDjHistobjIDiHist_{objID}^j \preceq Hist_{objID}^i
头节点异常

头节点异常时,系统会从链中移除头节点,并将头节点的下一个节点作为新的头节点。因为系统最多容忍 t - 1 个节点异常,所以必然存在一个新的头节点。

由于删除头节点属于系统转换,所以需要证明这等同于一个空操作或者满足 Figure 1 中的 T1T2 和(或)T3。修改链中的节点可能会改变 PendingobjIDPending_{objID} 的值,因为 PendingobjIDPending_{objID} 表示链中任一节点收到但未被尾节点处理的请求,所以从链中删除头节点 H 会一并删除 PendingobjIDPending_{objID} 中被 H 接收但还没有传递给下一个节点的请求。而从 PendingobjIDPending_{objID} 中删除请求符合 Figure 1 中的 T2,所以从链中删除头节点 H 符合 Figure 1 中的规范。

尾节点异常

尾节点异常时,系统会从链中移除尾节点 TT,并将尾节点的前一个节点 TT^- 作为新的尾节点。和前面描述的一样,因为系统最多容忍 t - 1 个节点异常,所以必然存在一个新的尾节点。

删除尾节点会同时影响 PendingobjIDPending_{objID}HistobjIDHist_{objID},不过也能满足 T3:因为 HistobjIDTHistobjIDTHist_{objID}^T \preceq Hist_{objID}^{T^-}(根据 Update Propagation Invariant 可得,因为 T<TT^- \lt T),对于新的尾节点来说,它未处理的请求相比于旧的尾节点少,所以 PendingobjIDPending_{objID} 序列的大小会减少。另外,根据 T3 的要求,已完成的请求需要追加到 HistobjIDHist_{objID} 中,在更新了尾节点后,某些未被 TT 完成的请求可能已被 TT^- 完成,所以此时以 HistobjIDTHist_{objID}^{T^-} 来表示 HistobjIDHist_{objID}

中间节点异常

中间节点 SS 异常时,系统会从链中移除节点 SS。主节点会首先通知 SS 的后继节点 S+S^+SS 的前继节点 SS^-,告诉它们新的前继和后继节点。不过这有可能会违反 Update Propagation Invariant,因为 SS 收到的某些请求可能还没有转发给 S+S^+(这些请求必然在任一 SS 节点前面的节点 iHistobjIDiHist_{objID}^i 中)。最适合将这些请求发送给 S+S^+ 的就是 SS^-,不过需要些额外的协作。

UU 表示一个请求集合,<U<_U 表示该集合中所有请求的顺序。如果下述条件满足,则认为请求序列 Mr\overline{\vphantom{M}r}(U,<U)(U, <_U) 一致:

  1. Mr\overline{\vphantom{M}r} 中的所有请求都在 UU
  2. Mr\overline{\vphantom{M}r} 中的所有请求以符合 <U<_U 的顺序升序排序

最后,对于和 (U,<U)(U, <_U) 一致的请求序列 Mr\overline{\vphantom{M}r}Mr\overline{\vphantom{M}r^{'}},记 MrMr\overline{\vphantom{M}r} \oplus \overline{\vphantom{M}r^{'}} 表示以出现在 Mr\overline{\vphantom{M}r} 中或者 Mr\overline{\vphantom{M}r^{'}} 中的请求组成的请求序列,所以 MrMr\overline{\vphantom{M}r} \oplus \overline{\vphantom{M}r^{'}} 也和 (U,<U)(U, <_U) 一致(所以 MrMr\overline{\vphantom{M}r} \oplus \overline{\vphantom{M}r^{'}} 中的请求以符合 <U<_U 的顺序升序排序)。

当节点 SS^- 的后继节点指向节点 S+S^+ 时,首先将 HistobjIDSHist_{objID}^{S^-} 中存在但是可能没有发送给节点 S+S^+ 的请求通过 FIFO 通道发送给节点 S+S^+;只有当这些请求都发送给了节点 S+S^+ 之后,节点 SS^- 才能将新来的请求发送给节点 S+S^+。这样就保证了 Update Propagation Invariant

每个节点 i 维护了一个已转发给后继节点但可能还没有被尾节点处理的请求列表,记 SentiSent_i。对 SentiSent_i 的增删非常简单:每当节点 i 将某个请求 r 转发给后继节点时,就将 r 加入 SentiSent_i;当尾节点处理完某个请求 r 时,它就会给前继节点发送一个 ack(r) 请求,表示请求 r 已处理完毕。当前继节点收到 ack(r) 请求时,就将 rSentiSent_i 中移除,并同样给它的前继节点发送一个 ack(r) 请求。

如果尾节点收到了一个请求,那么这个请求必然被所有的前继节点收到,因此有:

  • Inprocess Request Invariant:如果 iji \le j,则 HistobjIDi=HistobjIDjSentiHist_{objID}^i = Hist_{objID}^j \oplus Sent_i

所以,当主节点通知节点 SS^- 新的后继节点是 S+S^+ 时,首先节点 SS^-SentSSent_{S^-} 中的请求发送给节点 S+S^+,而已经存在于 HistobjIDS+Hist_{objID}^{S^+} 中的请求则无需发送,这就保证了 Update Propagation Invariant

alt

上图描述了中间节点发生异常时的流程。主节点发送消息1告诉节点 S+S^+ 新的前继节点,节点 S+S^+ 收到消息后发送消息2告知主节点已确认收到配置变更消息,同时也告知了主节点最后收到的更新消息序列号 sn;然后主节点发送消息3给节点 SS^- 新的后继节点以及节点 S+S^+ 最后收到的更新消息序列号 sn,节点 SS^- 就能计算出需要将哪些更新请求发送给节点 S+S^+;最后消息4就是节点 SS^- 发送给节点 S+S^+ 的更新请求。

扩展链

系统会将异常的节点从链中移除。不过链越短则能容忍的节点异常也就越少,最终由于节点数过少从而影响了对象存储的可用性。解决方法就是当链的长度减少到一定程度时,向链中增加新的节点。鉴于节点异常的概率不是很高,以及往链中添加一个节点不需要太长时间,链的长度基本能维持在期望的 t 个节点的水平(因此 t - 1 个节点都发生了异常才会造成可用性问题)。

理论上可以往链的任意位置插入一个新节点。不过,往链的结尾插入一个新的节点 T+T^+ 是最简单的。对于一个新的尾节点 T+T^+,它的 SentT+Sent_{T^+} 永远是个空列表,所以初始化 SentT+Sent_{T^+} 很简单。剩下的就是要初始化 HistobjIDT+Hist_{objID}^{T^+} 来满足 Update Propagation Invariant

可以让当前的尾节点 TT 发送自己的 HistobjIDTHist_{objID}^T 给节点 T+T^+ 来完成 HistobjIDT+Hist_{objID}^{T^+} 的初始化。这个过程(如果数据太大可能会需要一段时间)可以和节点 TT 处理来自客户端的查询请求和来自前继节点的更新请求并行执行,只要每一个更新请求都追加到列表 SentTSent_T 中。因为整个过程中满足 HistobjIDT+HistobjIDTHist_{objID}^{T^+} \preceq Hist_{objID}^T,所以也满足 Update Propagation Invariant。因此,只要满足 HistobjIDT=HistobjIDT+SentTHist_{objID}^T = Hist_{objID}^{T^+} \oplus Sent_T,则也满足 Inprocess Request Invariant,节点 T+T^+ 就可以成为新的尾节点:

  • 节点 TT 被通知不再是尾节点。节点 TT 就可以丢弃来自客户端的查询请求,不过更合适的策略是将这些查询请求转发给新的尾节点 T+T^+
  • 节点 TTSentTSent_T 中的请求按序发送给节点 T+T^+
  • 主节点被通知新的尾节点是节点 T+T^+
  • 所有客户端被通知新的查询请求需要发送给节点 T+T^+

主从复制协议

链式复制也是主从复制协议的一种,而主从复制协议本身也是复制状态机的一种。在主从复制协议下,系统会指定一个节点为主节点,并且:

  • 主节点强制按序执行客户端请求(因此保证了强一致性)
  • 主节点按序将客户端请求或结果更新发送给从节点
  • 主节点会等待所有非异常从节点的请求确认
  • 主节点收到从节点的请求确认后,再响应客户端

如果主节点发生异常,某个从节点会被提升为主节点。

在链式复制模式下,保证请求的顺序性的主节点的角色由两个节点承担。头节点负责处理更新请求,尾节点负责处理查询请求。这种分工一方面拆分了任务,另一方面降低了处理查询请求的延迟和负载,因为只会有一个节点处理查询请求(对单个对象来说),而且这个查询请求不会依赖链中的其他操作。而在主从复制模式下,主节点必须先收到从节点之前更新请求的确认,才能响应客户端的查询请求。

不管是链式复制还是主从复制,更新请求都需要发送给所有的节点,否则副本间可能会出现数据不一致。链式复制以串行的方式分发更新请求,相比于主从复制的并行更新有着更高的延迟。在并行更新下,整个过程的时间就取决于最慢的从节点更新完成的时间;在串行更新下,整个过程的时间等同于所有节点更新完成的时间之和。

当系统发生异常时,其中一个关注的点是客户端感知到的系统异常会持续多久,在这期间系统检测到了异常并需要重新调整集群配置;另一个关注点是由于节点异常所带来的延迟增长。

当某个节点发生异常到这个异常被监测到的时间是主要耗时的地方,不过这个时间对于链式复制和主从复制来说都是一样的。所以,剩下的就是要比较两种方式下异常恢复所需要的时间;相比于 CPU 计算延迟,消息延迟在异常恢复时间中被认为是占据主导地位。

对于链式复制,有三种异常需要考虑:头节点异常,中间节点异常,尾节点异常:

  • 头节点异常:客户端的查询请求不受影响。更新请求暂不可用,整体恢复时间受限于两个消息的处理,一个是主节点广播通知新的头节点和它的后继节点;二是主节点广播通知客户端新的头节点。
  • 中间节点异常:客户端的查询请求不受影响。更新请求可能会延迟不过不会丢失,异常节点后的节点还能正常处理已收到的更新请求,而异常节点前的更新请求可能会延迟。整个过程的延迟取决于 Figure 3 中的四个消息的处理时间。
  • 尾节点异常:客户端的查询和更新请求都不可用。整个过程的延迟取决于两个消息的处理,一个是主节点通知新的尾节点,另一个是主节点通知所有客户端新的尾节点。

在主从复制模式下,需要考虑两种异常:主节点异常和从节点异常。查询请求和更新请求需要处理的情况都一样:

  • 主节点异常:整个过程的延迟取决于5个消息的处理。系统监测到主节点发生异常,然后广播通知所有的从节点,要求各从节点返回已处理的更新请求数量并告知从节点暂停处理请求。每个从节点发送响应给系统。系统收到所有的响应后,选举一个新的主节点,并将主节点的信息广播给所有的从节点。只有处理了最多数量的更新请求的从节点才有可能被选为主节点,之后新的主节点需要给其他从节点补发缺失的更新请求。最后,系统再通知所有客户端新的主节点信息。
  • 从节点异常:客户端的查询请求不受影响,只要当前没有进行中的更新请求。如果有进行中的更新请求,则整个过程的延迟取决于一个消息的处理,系统需要通知主节点某个从节点异常,主节点就知道无须等待这个异常的节点对更新请求的确认。

所以异常情况下,链式复制的最差情况(尾节点异常)不会比主从复制的最差情况(主节点异常)还差;而链式复制的最好情况(中间节点异常)则好于主从复制的最好情况(从节点异常)。如果系统异常时的持续时间是设计存储服务的首要设计目标,那么需要结合请求的类型(查询请求多还是更新请求多)以及不同系统异常发生的概率来决定选择链式复制还是主从复制。

参考

介绍

大型分布式系统需要各式各样的协同。配置就是其中一种最基础的形式,在其最简单的形式中,配置只是一系列供系统使用的参数,而对于更复杂的系统来说,配置还可以动态更新。群组成员关系和选主同样在分布式系统中很常见:通常各进程需要知道哪些进程还存活,以及哪些进程在负责统一管理。另外,分布式锁作为一种强大的协调原语能够对临界资源提供互斥访问保护。

一种实现协同的方式是为每一个不同的协同需求开发一个服务。例如,Amazon Simple Queue Service 专注于消息队列。同时也存在专门为了选主和配置所开发的服务。针对较强的原语开发的服务能够用于实现较弱一级的原语。例如,Chubby 是一个强同步性保证的锁服务。则可以借助锁来实现选主,群组成员关系等服务。

相较于在服务端实现特定的协同原语,ZooKeeper 的作者选择暴露某些 API 来让应用开发者自行实现需要的原语。这种设计选择需要实现一个协同内核(coordination kernel)使得新原语的开发不需要修改核心服务端代码。这种方式能够适配应用程序对不同协同形式的需求,而不是让开发者受限于某几个固定的原语。

在设计 ZooKeeperAPI 时,设计者移除了阻塞原语,例如锁。一个协同服务的阻塞原语会导致某些问题,缓慢或出错的客户端会拖慢快速的客户端的性能。如果服务处理请求时需要依赖响应以及负责客户端的异常检测,那么服务的实现会变得更为复杂。因此,ZooKeeper 实现了一套 API 能够操作以类似文件系统的方式组织的无等待(wait-free)对象。实际上,ZooKeeperAPI 类似于其他任何的文件系统,以及和去除了加锁(lock),打开(open),关闭(close)这些方法的 Chubby 类似。实现了无等待对象的 ZooKeeper 显著有别于其他基于阻塞原语(例如锁)的系统。

虽然无等待这一特性对于性能和容错很重要,但是对于协同来说来不够。ZooKeeper 还需要对各操作提供其他保证。对客户端 FIFO 的操作保证和线性化写入的保证确保了服务的高效实现,同时也能够满足应用程序实现自定义协同原语的需求。实际上,利用 ZooKeeperAPI 可以实现任意节点数量的共识算法。

ZooKeeper 服务通过服务器间的复制来实现高可用和性能。它的高性能使得大量客户端进程能通过协同内核来管理方方面面的协同需求。通过一种简单的管道架构来实现 ZooKeeper 使得服务在承受几百或上千的请求的同时依然保持着低延迟。这种管道方式天然的支持对同一个客户端的请求以 FIFO 的方式执行。对客户端请求的 FIFO 顺序执行的保证使得客户端能异步的提交请求。异步提交也使得客户端同一时间有多个操作。这个特性很有用,例如当某个客户端成为主节点后,它需要操作元数据然后更新。如果缺少了多操作同时进行的特性,那么这个主节点初始化的时间可能达到秒级的数量级而不是亚秒级。

为了满足写入的线性化保证,ZooKeeper 实现了一个基于主节点的原子广播协议,即 Zab。典型的 ZooKeeper 应用属于读密集型应用,所以需要保证读操作的扩展性。ZooKeeper 的读操作由当前服务器完成,不涉及和其他服务器的交互,也不会使用 Zab 来保证读取的顺序性。

在客户端缓存数据是提高读性能的重要手段。例如,客户端可以缓存当前主节点的信息而不是每次请求 ZooKeeperZooKeeper 同时提供了监听机制来协助客户端缓存数据而无需直接管理客户端的缓存。借助这个机制,客户端可以对某个数据进行更新监听,从而在数据更新时收到通知。而 Chubby 会直接管理客户端的缓存,它会阻塞某个数据的更新直到所有缓存了该数据的客户端都清除了缓存。在这个设计下,如果某个客户端运行缓慢或者出错,则会拖慢数据的更新。Chubby 使用租约来避免某个客户端永久的阻塞系统。不过,租约只是确保了运行缓慢或者出错的客户端对性能的影响的上限,而 ZooKeeper 的监听机制则是完全的避免了这个问题。

本文主要介绍了 ZooKeeper 的设计和实现。借助 ZooKeeper,我们可以实现应用程序所需要的所有协同原语,即使只有写入是线性化保证的。为了验证这个设计,本文介绍了如何使用 ZooKeeper 来实现某些协同原语。

本文的关键点如下:

  • 协同内核(Coordination kernel):本文提出了一种供分布式系统使用的无等待、宽松一致性保证的协同服务。特别的,本文描述了一种协同内核的设计和实现,并且已经在很多重要的应用程序中使用来实现各种各样的协同服务。
  • 协同示例(Coordination recipes):本文描述了如何使用 ZooKeeper 来实现高层次的协同原语,包括在分布式应用中经常用到的阻塞和强一致性的原语。
  • 使用协同的经验(Experience with Coordination):本文分享了使用 ZooKeeper 的方式以及评估了其性能。

ZooKeeper 服务

客户端通过 ZooKeeper 提供的客户端类库来向 ZooKeeper 提交请求。除了向客户端暴露 ZooKeeper 提供的 API 外,客户端类库还负责维护客户端和 ZooKeeper 服务器间的连接。

本节会首先从高层次来介绍 ZooKeeper 服务,然后再讨论客户端和 ZooKeeper 交互的 API

术语:本文使用客户端(client)来表示使用 ZooKeeper 服务的一个用户;使用服务端(server)来表示 ZooKeeper 的服务提供者;使用 znode 来表示 ZooKeeper 的一个内存数据节点,这些数据节点以层次化的命名空间的形式所组织,即 data tree。同时,本文使用更新(update)和写入(write)来表示任何修改 data tree 状态的操作。客户端和 ZooKeeper 通过建立 session 进行连接,并且通过 session handle 发送请求。

服务概览

ZooKeeper 将数据抽象成数据节点(znodes)后供客户端访问,所有数据节点以层次化的命名空间进行组织。znodes 是客户端可通过 ZooKeeperAPI 操作的数据对象。层次化的命名空间通常被用于文件系统。因为用户已经习惯了这种抽象,所以 ZooKeeper 很自然的以这种方式来管理数据,另外这也能更好的管理应用程序的元数据。ZooKeeper 使用和标准 UNIX 文件系统命名一样的方式来表示一个 znode。例如,A/B/C 表示 znode C 的路径,并且 C 的父节点是 BB 的父节点是 A。每个 znode 都会保存数据,而且除了临时节点之外的所有节点都可以有子节点。

客户端可以创建两种类型的 znode

  • 常规(Regular):客户端可以显式的创建和删除常规节点。
  • 临时(Ephemeral):客户端创建临时节点后,可以显式的删除,或者当客户端和 ZooKeepersession 结束后(客户端主动断开连接或者由于异常失去连接)由系统自动删除。

另外,客户端在创建一个 znode 时可以设置一个顺序标记。设置了顺序标记所创建的节点会在节点名称后追加一个单调递增的序号。如果 n 是一个新的 znodepn 的父节点,那么 n 的序号一定不会比在 n 之前所创建的 p 的子节点的序号小。

ZooKeeper 实现了监听器使得客户端能及时的收到数据修改的通知而无需轮询。当客户端发起一个读操作并设置监听时,这个读操作会和普通的读操作一样正常返回,不过当数据更新时,系统能通知客户端。监听器在单次 session 内只会被触发一次,一旦监听器被触发或者 session 关闭,该监听器就会被注销。监听器被触发表示监听的数据发生了修改,但是不会告知被修改后的值。例如,如果一个客户端在 "/foo" 被修改了两次之前执行了 getData("/foo", true),那么客户端会收到一次通知表示 "/foo" 指向的数据被修改了。一些 session 级别的事件,例如连接丢失,也能通过监听回调通知给客户端,那么客户端就会知道监听通知可能会延迟。

数据模型

ZooKeeper 的数据模型基本上等同于简化版 API 的文件系统,只能一次性读取或者写入全部数据;或者等同于是一个以层次结构组织键的键值表。层次结构的命名空间能够为不同的应用程序分配子命名空间,同时也方便为不同的子命名空间分配访问权限。同时 ZooKeeper 在客户端这层提供了文件夹的概念,能够用于构建高层次的原语。

和文件系统中的文件不同,znodes 的设计目的并不是为了通用数据存储。相反,znodes 是作为客户端应用程序的抽象,典型场景是用于保存协同目的的元数据。在下图中有两个子树,其中一个用于应用程序1(/app1),另一个用于应用程序2(/app2)。应用程序1对应的子树实现了一个简单的群组成员关系协议:每个客户端 p_i/app1 下会创建一个 znode p_i,只要客户端还存活,对应的节点就会存在,那么,根据 /app1 下的节点数量就能知道当前应用程序有多少个存活的进程能提供服务。

alt

虽然 znodes 的设计目的不是为了通用数据存储,不过 ZooKeeper 也允许客户端在 znode 中保存数据,例如分布式计算需要用到的元数据或者配置信息。例如,在一个基于选主的应用中,一个刚启动的应用程序节点需要知道当前的主节点是谁。为了实现这个目的,可以让当前主节点将主节点的信息写入到某个约定的 znode 路径中。此外,znode 本身也提供了时间戳和版本号这样的元数据,使得客户端能够监控 znodes 的数据变化,从而根据 znode 的数据版本进行数据更新。

会话

一个客户端连接到 ZooKeeper 时会初始化一个 session。每一个 session 伴随着一个超时时间。如果在一个超时时间之内 ZooKeeper 没有收到来自客户端的任何请求,那么 ZooKeeper 就会认为这个客户端发生了异常。客户端可以主动通过关闭 session handle 来结束一个 session 或者 ZooKeeper 监测到客户端发生异常而自动关闭 session。在一个 session 内,客户端所观察到的系统状态变化和其提交的操作一一顺序对应。在一个 session 内,如果当前客户端连接的 ZooKeeper 节点发生异常,ZooKeeper 客户端类库能无缝的将其连接到一台新的 ZooKeeper 节点上,从而在各节点间完成持久化。

客户端 API

ZooKeeper 提供了以下的核心 API

  • create(path, data, flags):创建一个路径为 pathznode,并将数据 data[] 保存其中,然后返回新建的 znode 的名称。flags 用于指定 znode 的类型:常规节点,临时节点,以及设置顺序标记。
  • delete(path, version):如果指定路径 path 下的 znode 的版本号和 version 匹配,则删除该节点。
  • exists(path, watch):如果指定路径 path 下的 znode 存在则返回 true,否则返回 false。如果 watchtrue,则当 znode 的数据发生变化时,客户端会收到通知。
  • getData(path, watch):获取指定路径 path 下的 znode 的数据和元数据,例如版本信息。watch 的功能和 exists() 中的 watch 的功能一致,只不过如果当前节点不存在,则 watch 不会生效。
  • setData(path, data, version):如果指定路径 path 下的 znode 的版本号和 version 匹配,则将数据 data[] 写入到该节点。
  • getChildren(path, watch):返回指定路径 path 下的 znode 的子节点的名称。
  • sync(path):阻塞等待直到在操作开始时所有进行中的更新操作都同步到了当前客户端所连接的服务端。path 参数当前未使用。

ZooKeeper 通过 API 为每个方法提供了同步和异步两个版本。当应用程序希望执行单一 ZooKeeper 操作而且没有其他并发任务要执行时,可以选择调用同步方法。而如果应用程序希望同时执行多个 ZooKeeper 操作以及有其他任务需要并发执行时,可以选择调用异步方法。ZooKeeper 客户端类库保证了异步方法的回调顺序和提交请求的顺序一致。

ZooKeeper 不直接通过 handles 来访问 znodes。每个客户端请求会带上所要操作的 znode 的全路径。这不仅简化了 API(没有 open() 或者 close() 方法),同时服务端也不需要维护额外的状态。

客户端对 ZooKeeper 每个更新操作都会带上一个期望的版本号,这就能实现按条件更新。如果当前 znode 的版本号和期望的版本号不一致,那么此次更新就会失败并返回版本不匹配错误。如果传入的版本号是-1,则表示不进行版本号校验。

ZooKeeper 的保证

ZooKeeper 有两个基本的顺序保证:

  • 线性化写入(Linearizable writes):所有对 ZooKeeper 的状态修改都会按序串行化执行。
  • 先来先执行的客户端顺序(FIFO client order):来自同一个客户端的所有请求会按照请求发送的顺序执行。

ZooKeeper 提出的线性化和 Herlihy 提出的线性化有所不同,ZooKeeper 的作者称之为 A-linearizabilityasynchronous linearizability)。在 Herlihy 的线性化定义下,一个客户端只能有一个进行中的操作(一个客户端对应一个线程)。而在 ZooKeeper 中,一个客户端允许有多个进行中的操作,那么从设计上可以选择对多个进行中的任务不保证执行顺序,或者保证 FIFO 顺序。ZooKeeper 选择了后者。如果一系列操作的结果适用于 linearizable 的对象,那么也同时适用于 A-linerizalbe 的对象,因为 A-linearizability 本身就满足线性化。因为只有更新操作需要满足 A-linerizalbe,所以 ZooKeeper 的读操作可以直接通过本地副本执行。进一步使得添加新的服务器时能实现对服务的线性扩展。

下面将通过一个场景示例来说明上述两个保证是如何交互的。某个系统需要选举一个主节点来分配任务给其他工作节点执行。每当新选举了一个主节点,它需要更新大量的配置参数并且当更新完成时通知其他的工作节点。这就带来了两个重要的需求:

  • 当主节点在更新配置参数时,其他工作节点不能使用还未更新完成的配置参数。
  • 如果主节点在配置参数更新完成前发生异常,其他工作节点也不能使用未更新完成的配置参数。

类似 Chubby 提供的分布式锁能满足第一个需求,但是不足以满足第二个需求,因为当其他工作节点获取锁读取配置参数时,它并不能知道配置参数是否已更新完成。在 ZooKeeper 中,主节点可以在某个约定的路径创建一个 ready 节点,只有在 ready 节点存在的情况下,其他工作节点才可以认为配置参数已更新完成。在更新配置参数前,主节点会先删除 ready 节点,然后更新配置参数,最后再创建 ready 节点。所有这些操作都可以以管道的方式进行并异步提交请求,从而使得配置参数能快速更新。虽然一次更新操作的耗时是2毫秒的数量级,但是如果主节点需要阻塞的依次更新5000个配置参数的话则一共需要10秒才能完成;通过异步提交更新,所有的请求能在一秒内完成。因为 ZooKeeper 的顺序性保证,如果某个工作节点发现 ready 节点存在,那么就说明配置参数也必然更新完成了,因为 ready 节点的创建晚于配置参数的更新。如果主节点在配置参数更新完成前发生异常,那么也就不会创建 ready 节点,其他工作节点就知道配置参数更新未完成。

不过上述方案还存在一个问题:如果某个工作节点此时看见 ready 节点存在,但是同时主节点删除了 ready 节点然后开始更新配置参数,那么工作节点就会读取到正在更新的配置参数。这个问题通过监听通知的顺序性保证来解决:如果客户端对某个节点 A 开启了监听,此时系统先对节点 A 进行了修改,然后对另一个节点 B 进行了修改,此时客户端发起了对节点 B 的读请求,那么 ZooKeeper 会保证客户端先收到节点 A 修改的异步通知。所以,如果客户端在判断 ready 节点是否存在时开启了监听,那么它就会在读取到修改中的配置参数前先收到 ready 节点修改的通知,从而可以中断配置参数的读取。

如果客户端之间还有除了 ZooKeeper 之外的通信方式也会引发另一个问题。例如,两个客户端 AB 通过 ZooKeeper 共享配置,然后通过其他某种方式通信。如果 A 修改了 ZooKeeper 中的配置然后告诉 B,那么 B 收到通知后读取 ZooKeeper 就期望能获取到修改后的配置。不过如果 B 连接的 ZooKeeper 副本落后于主节点,那么 B 可能无法读取到最新的配置。而采用写入 ready 节点再读取的方式能保证 B 读取到最新的配置。ZooKeeper 提供了 sync 方法来更高效的解决这个问题:如果 sync 请求之后有一个读请求,则 ZooKeeper 会暂缓这个读请求。sync 会同步在这之前进行中的写请求,而无需等待当前所有的待写入操作完成。这个原语类似于 ISIS 中的 flush 原语。

ZooKeeper 同时也有存活性(liveness)和持久性(durability)的保证:只要 ZooKeeper 集群中过半数的机器存活,那么访问 ZooKeeper 服务就没有问题;如果 ZooKeeper 成功响应了某个修改请求,只要过半数的机器在异常后最终能恢复,那么不管经历了多少次系统异常这个更新都不会丢失。

原语示例

本节描述了如何利用 ZooKeeperAPI 来构建更强大的原语。对于 ZooKeeper 来说,它并不知晓这些原语的存在,因为这些原语是由客户端通过 API 自行实现的。一些通用的原语例如群组成员关系和配置管理都是无等待原语。对于其他原语如 rendezvous,客户端则需要等待某个事件发生。虽然 ZooKeeper 是无等待服务,客户端也同样可以实现阻塞的原语。ZooKeeper 的顺序保证可以高效的审视系统的状态,而监听机制则实现了高效的等待。

配置管理(Configuration Management)

ZooKeeper 可以用于分布式系统中实现动态配置管理。在最简单的形式中,配置信息保存在一个 znode 中,例如 z_c。应用启动时会读取 z_c 的数据并设置监听状态。如果 z_c 的数据更新了,那么应用就会收到通知,然后就可以读取最新的配置,并继续设置监听状态。

在这个例子以及其他大多数使用监听器的例子中,监听器确保了应用能获取到最新的数据。例如,如果某个监听 z_c 的应用收到了 z_c 的修改通知,而在这个应用读取 z_c 之前,z_c 又被修改了3次,那么这个应用不会再收到通知。这并不会影响应用的行为,因为 ZooKeeper 的变更通知不会返回更新后的数据,应用需要再次读取才能获得节点最新的数据,只通知一次已经使得应用知道当前节点的数据已经过期,没有必要重复通知。

Rendezvous

有时候在分布式系统中并不能清晰的预知系统的最终配置是什么。例如,某个客户端可能会希望启动一个主节点和几个工作节点,不过由于节点的启动是由某个调度器执行,客户端并不能事先知道某些需要的信息,例如工作节点需要连接的主节点的地址和端口号。这个问题可以由客户端通过 ZooKeeper 创建一个 rendezvous 节点 z_r 来解决。客户端将 z_r 的全路径作为启动参数传给主节点和工作节点。当主节点启动后,它就将自己的地址和端口号写入到 z_r 中。当工作节点启动后,它就能从 z_r 中读取主节点的地址和端口号,并设置节点的监听状态。如果工作节点启动时主节点还未写入数据到 z_r,那么工作节点就会等待数据写入的通知。如果 z_r 是临时节点,那么创建 z_r 节点的客户端下线后,主节点和工作节点就能收到节点删除通知,并在完成资源清理后退出。

群组成员关系(Group Membership)

客户端可以利用临时节点的特性来实现群组成员关系管理。这里利用了可以通过监听临时节点来观测创建该节点的 session 状态的特性。首先创建一个节点 z_g 来表示群组。当群组中的某个进程启动时,它会在 z_g 下创建一个临时的子节点。如果每个进程都有唯一的命名或标识,那么这个命名或标识就可以作为 ZooKeeper 节点的名称;否则就可以在创建节点时设置 SEQUENTIAL 标记让 ZooKeeper 自动在节点名称后追加一个单调递增的数字,以保证名称的唯一性。各进程可以将进程相关的信息放到临时节点中,例如当前进程的地址和端口号。

当进程在节点 z_g 下创建完临时进程后就可以正常启动。它不需要做其他任何事。如果这个进程发生异常或者主动结束,那么它所创建的临时节点也会自动被删除。

各进程可以简单的通过查询 z_g 的所有子节点来获取当前群组成员的信息。如果某个进程想要监控群组成员的变化,那么它可以设置监听标记(通过 getChildren(path, watch) 方法设置 watch),然后在收到通知时更新群组信息。

简单锁(Simple Locks)

虽然 ZooKeeper 不是一个锁服务,但也可以用来实现锁。使用 ZooKeeper 的应用通常使用同步原语来适配其需求。本节通过使用 ZooKeeper 实现锁来展示可以通过 ZooKeeper 来实现各种各样的通用同步原语。

最简单的锁实现借助于 lock files。使用一个 znode 来表示一把锁。为了获取锁,客户端会尝试以 EPHEMERAL 标记创建一个临时节点。如果创建成功,那么这个客户端就获得了锁。否则,客户端就会去读取这个 znode 并设置监听状态,从而当这个临时节点被删除时能收到通知。当持有锁的客户端发生异常或者主动删除该节点时,则代表释放了锁。其他监听的客户端就会收到通知并尝试重新创建临时节点来获取锁。

虽然这种方式能实现锁,不过也存在几个问题。首先,它会造成羊群效应(herd effect)。如果有大量的客户端在等待释放锁,那么当锁被释放时,这些客户端都会被通知然后都会尝试获取锁,而实际上只会有一个客户端能获得锁。第二,这种方式只实现了互斥锁。下面两种原语展示了如何解决这两个问题。

没有羊群效应的简单锁(Simple Locks without Herd Effect)

首先定义节点 l 来实现锁。然后,将所有希望获取锁的客户端按照请求顺序排序,之后这些客户端就能按照请求的顺序获取锁。客户端希望获取锁时需要执行下面的操作:

1
2
3
4
5
6
7
8
9
10
Lock
1 n = create(l + "/lock-", EPHEMERAL|SEQUENTIAL)
2 C = getChildren(l, false)
3 if n is lowest znode in C, exit
4 p = znode in C ordered just before n
5 if exists(p, true) wait for watch event
6 goto 2

Unlock
1 delete(n)

第一行 SEQUENTIAL 的标记用来将所有希望获取锁的客户端进行排序。每个客户端首先在节点 l 下创建一个临时顺序的子节点,然后获取 l 的所有子节点。之后在第三行判断自己创建的节点是否在所有子节点中有着最小的序号,如果是,则表示当前客户端获得了锁。如果不是,说明有其他序号更小的子节点存在,当前客户端需要排在这之后获取锁。然后客户端会尝试判断排在当前序号前的子节点是否存在,如果存在则设置监听状态等待前一个节点删除的通知,如果不存在,则继续回到第二行执行。每个客户端只监听排在自己前面的子节点避免了羊群效应,因为任何一个子节点删除的通知只会发给其中的一个客户端。每当客户端收到前面节点删除的通知时,需要再次获取 l 的所有子节点来判断自己是否是最小子节点(因为排在前面的子节点并不一定持有锁,可能是更前面的子节点持有锁。这里能否直接复用第一次请求 getChildren 的信息?实现起来会较麻烦些,因为需要挨个判断排在前面的子节点是否还存在,不如直接拉取一份最新的子节点信息)。

释放锁就是简单的删除对应的临时节点 n。而通过 EPHEMERAL 创建节点能保证进程异常时自动释放锁或者放弃对锁的获取请求。

这种锁实现有以下几个优势:

  1. 一个节点的删除只会唤醒一个客户端,因为每个节点都只会被一个客户端监听,所以也不会有羊群效应。
  2. 锁的获取和释放不依赖轮询或超时。
  3. 使用这种方式创建锁使得可以通过查看 ZooKeeper 中的数据来监测锁竞争的数量,以及调试锁相关的问题。

读写锁(Read/Write Locks)

在前面锁的基础上稍加修改就能实现一个读写锁。释放锁的操作和前面的相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Write Lock
1 n = create(l + "/write-", EPHEMERAL|SEQUENTIAL)
2 C = getChildren(l, false)
3 if n is lowest znode in C, exit
4 p = znode in C ordered just before n
5 if exists(p, true) wait for watch event
6 goto 2

Read Lock
1 n = create(l + "/read-", EPHEMERAL|SEQUENTIAL)
2 C = getChildren(l, false)
3 if no write znodes lower than n in C, exit
4 p = write znode in C ordered just before n
5 if exists(p, true) wait for watch event
6 goto 3

写锁是互斥锁,这里的实现和前面的锁的实现一模一样,只是改变了创建节点的名称。而由于读锁之间没有互斥,所以获取读锁时只需要检查有没有序号更小的写锁即可。另外,读锁实现时最后的 goto 直接跳到了第三行而没有到第二行,这个在 ZooKeeper FAQ 中也提到可能是个笔误。这里看起来会有羊群效应,即存在大量的客户端会监听某个写锁,当写锁被删除时这些客户端都会收到通知,不过这本身就是预期的行为,因为读锁之间没有互斥,写锁释放后就应该唤醒所有等待中的读锁。

双屏障(Double Barrier)

双屏障用于客户端在某个计算开始和结束时进行同步,当指定数量的进程加入到屏障中后,就可以开始各自的计算任务,每个进程在整个计算任务结束后就可以离开屏障。MapReduce 任务就是一个典型示例,reduce 任务的开始需要所有 map 任务的完成,当所有 map 任务完成后,各进程就可以进入屏障开始 reduce 任务,而整个 MapRecuce 任务的完成依赖所有 reduce 任务的完成,当所有 reduce 任务完成后,各进程就可以离开屏障。客户端可以使用一个 znode 来表示屏障,记作 b。每个进程在 b 下创建一个子节点来表示进入屏障,通过删除子节点来表示离开屏障。如果 b 下的子节点的数量超过了指定值,那么就允许开始执行计算任务。当 b 下的子节点都被删除后,进程就可以离开屏障。这里同样使用监听机制来高效的等待进入屏障和离开屏障的事件发生。一旦 b 下的子节点数量满足了阈值,创建最后一个子节点的进程会同时创建一个 ready 子节点,那么通过监听 ready 子节点是否存在就可以判断是否可以开始计算。另一方面,论文中提到通过监听某个特定的子节点来判断是否可以离开屏障,这里略显模糊,这个特定的子节点是谁创建的?创建了这个子节点的进程什么时候可以删除这个子节点?ZooKeeper FAQ 中提出了另一种方案,每个进程各自监听 b 下的子节点,并且在任务完成后删除所创建的节点,如果各进程发现 b 下没有子节点了,就说明可以离开屏障,整个计算任务已结束。

ZooKeeper 的实现

ZooKeeper 通过将数据复制到每台服务器上来实现服务的高可用。ZooKeeper 处理的服务器异常针对的是服务器宕机,且这些异常的服务器可能之后会恢复。下图展示了 ZooKeeper 服务的主要组件。当 ZooKeeper 服务收到一个请求时,会对这个请求进行预处理(request processor),如果这个请求需要各服务器协同完成(例如写请求),则会通过一致性协议处理(一种 atomic broadcast 的实现),最终 ZooKeeper 会将修改提交到各服务器副本中。而对于读请求,服务端则直接从本地数据库中读取数据然后返回给客户端。

alt

上图中的复制数据库是包含了整个数据树(data tree)的内存数据库。每个 znode 默认最多保存 1MB 数据,不过这个值可以根据需要通过配置修改。从可恢复性考虑,ZooKeeper 会高效的将更新写入到磁盘,并且将更新写入到内存数据库前会先强制将数据刷新到磁盘中。类似于 ChubbyZooKeeper 也会将提交的操作写入到重放日志中,并且会周期性的对内存数据库生成快照。

每个 ZooKeeper 服务端都能对客户端提供服务。客户端只会连接一个服务端然后提交请求。在之前提到过,读请求会直接返回当前服务端本地的数据。而修改系统状态的请求、写请求则会交由一致性协议处理。

作为一致性协议的一部分,客户端的写请求会转发给单台服务器,称之为主节点(leader)。其他的 ZooKeeper 服务器被称之为从节点(followers),从节点会收到来自主节点的状态更新请求,并就状态更新达成一致。

请求处理器(Request Processor)

由于 ZooKeeper 的消息层是原子的,它保证各副本的状态不会和主节点产生分歧,虽然在任一时间点有可能某些副本会比其他副本多提交一些事务。和客户端发送的请求不同,ZooKeeper 中的事务是幂等的。当主节点收到一个写请求时,它会先计算出系统提交了这个写请求后的系统状态,然后将其转化为一个能达到该系统状态的事务。这里之所以要先计算出将来的状态是因为当前可能存在未提交的事务。例如,当前客户端正在进行一个 setData 的条件更新,请求中的版本号和被修改的 znode 的某个未来的版本号所匹配,ZooKeeper 会生成一个 setDataTXN 事务,这个事务包含了更新后的数据,更新后的版本号,以及更新的时间戳。而如果发生了异常,例如 setData 期望的版本号不匹配或者要更新的 znode 不存在,则会生成一个 errorTXN 错误。

原子广播(Atomic Broadcast)

所有更新 ZooKeeper 状态的请求都会被转发给主节点。主节点会执行写请求然后将写请求通过 Zab 协议广播给所有的从节点,Zab 是一种原子的广播协议。当主节点就更新达成一致后,会返回结果给客户端。Zab 默认使用的是简单的大多数同意协议,所以只有过半数的节点存活时 ZabZooKeeper 才能正常工作(例如,由 2f + 1 个节点组成的 ZooKeeper 系统可以最多容忍 f 台节点异常)。

为了提高系统的吞吐,ZooKeeper 会尽量保持请求处理管道满载运行。请求管道的不同部分可能有着几千个请求。因为系统状态变更依赖于之前的状态变更,所以 Zab 比常规的原子广播协议提供了更强的顺序保证。具体来说,Zab 保证了主节点广播的状态变更被分发执行的顺序和主节点发出广播的顺序一致,同时 Zab 会先将之前主节点的修改先发送给新的主节点,等到这些修改都执行完成后,新的主节点才能广播自己的修改。

另外还有些实现细节有利于高性能。ZooKeeper 使用 TCP 协议来发送消息,所以消息的有序性天然得到了保证,这同时也简化了实现。ZooKeeper 使用 Zab 协议选择的主节点作为集群的主节点,这就使得创建事务的节点同时也能发起事务。ZooKeeper 使用日志来记录事务的发起,这同时也作为内存数据库的预写日志,从而避免了将消息写入到磁盘两次。

在正常情况下 Zab 协议能按序发送所有消息,且每条消息只发送一次,不过由于 Zab 不会持久化已发送的消息的 id,所以在宕机恢复时可能会重复发送消息。因为 ZooKeeper 的事务是原子的,所以只要消息依然能保证有序发送,重复发送就没有问题。实际上,ZooKeeper 会要求 Zab 再次发送至少上次快照开始后的所有已发送的消息。

复制数据库(Replicated Database)

每个副本在内存中都有一份 ZooKeeper 状态的拷贝。当 ZooKeeper 宕机重启后,它需要能恢复到宕机前的状态。如果重新发送所有已发送的消息来恢复状态则需要较长时间,尤其是当服务器已经运行了一段时间之后,所以 ZooKeeper 周期性的对系统状态建立快照,并且只重新发送快照之后的所有消息。ZooKeeper 的快照被称之为 fuzzy snapshots 因为执行快照时不会加锁;ZooKeeper 会对数据树进行深度优先搜索,并且能原子的读取每个 znode 的数据和元数据,然后将其写入到磁盘中。这种方式生成的快照有可能包含部分在生成快照期间执行的事务的结果,最终生成的快照可能不会和任一时间点的 ZooKeeper 状态一致。不过由于状态更新是原子的,ZooKeeper 可以在后续恢复阶段重新按序执行已提交的事务。

例如,当 ZooKeeper 开始执行快照时有两个节点 /foo/goo,对应的节点值分别为 f1g1,节点值的版本号都是1。此时,对系统状态的修改以 <transactionType, path, value, new-version> 的形式到达:

1
2
3
<SetDataTXN, /foo, f2, 2>
<SetDataTXN, /goo, g2, 2>
<SetDataTXN, /foo, f3, 3>

当系统执行了这些更新后,节点 /foo 的值变为 f3,对应版本号为3,而节点 /goo 的值变为 g2,对应版本号为2。不过,执行 fuzzy snapshot 后的快照中的节点 /foo 的值可能是 f3,而节点 /goo 的值可能是 g1,对应版本号分别为3和1,这并不是一个有效的 ZooKeeper 系统状态。如果服务器宕机后恢复,系统会先读取快照然后重新发送状态更新消息,由于消息执行的顺序性,最终系统的状态和宕机前的状态保存一致。

客户端-服务端交互(Client-Server Interactions)

ZooKeeper 处理了一个写请求时,它会给所有监听了该节点的客户端发送数据更新通知,并同时删除该节点的监听(因为监听只会触发一次)。服务端会按顺序处理写请求,而且同时不会并发的处理其他写请求或者读请求。这就保证了严格的监听通知顺序。不过服务端的监听通知是由各服务器自行负责,只有和当前服务器连接的客户端才会收到通知,其他客户端对同一节点的监听由其他服务器负责。

读请求由当前客户端所连接的服务端直接读取内存中的数据返回。每个读请求处理时会标记上一个 zxid,这个 zxid 对应当前服务端所知道的最新的事务。这个 zxid 定义了读写操作之间的相对顺序。通过直接读取内存中的数据返回的方式来处理读请求,ZooKeeper 能保证非常好的读性能,因为这不涉及任何磁盘 IO 或者其他一致性协议。这个设计是满足读密集型应用对性能要求的关键点。

直接从本地内存读取数据的一个缺点是不保证一定能读取到最新更新的数据,即可能返回过期的数据,即使当前节点的更新已经被 ZooKeeper 所提交,因为只要过半数的节点已完成数据更新就可以认为本次数据已提交,而当前节点可能还没有执行更新。对于必须保证读操作能读取到最新的数据的应用,ZooKeeper 提供了 sync 接口。sync 原语能异步执行,并且会由主节点将所有待写入的更新应用到当前副本中。如果希望读操作能读取到最新的数据,客户端需要在执行读操作前调用 sync 方法。ZooKeeper 对客户端操作的 FIFO 执行顺序保证以及 sync 写操作的全局顺序保证使得读操作在执行读时 sync 发起之前的所有写操作都已经应用到了当前服务器中。在 ZooKeeper 的实现中,执行 sync 操作时不需要原子广播协议,因为使用了基于主节点的算法,只需要将 sync 请求放在主节点和当前节点的请求队列的末尾即可。这种方式能正确工作的前提是当前的主节点依然是主节点。如果当前主节点还有进行中的事务并提交,那么从节点就可以认为当前主节点依然是主节点。如果主节点的请求队列为空,那么主节点就会先提交一个空的事务然后再发起 sync 请求。这样当主节点处于低负载运行时,不需要生成额外的广播请求。在 ZooKeeper 的实现中,主节点会有一段过期时间,所以主节点自己就能知道什么时候不再是主节点,从而不再发起空事务。

ZooKeeper 的服务器会以 FIFO 的顺序来处理客户端请求。响应结果中会附带上 zxid。即使客户端和服务端之间没有请求,在常规的心跳返回中也会附带上当前服务端所知道的最新的 zxid。如果客户端连接上了一台新的服务器,那么这个服务器会保证自己所知道的 zxid 不会比客户端的 zxid 旧。如果客户端发送的 zxid 更新,那么服务端在将自己本地的数据更新到最新前不会和客户端再建立连接。而 ZooKeeper 能保证客户端能连接上一台数据版本满足 zxid 的服务端,因为客户端连接到的服务器必然是过半数有着最新系统状态的服务器之一。这个行为对保证持久性来说至关重要。

ZooKeeper 使用超时来检测客户端的 session 异常。如果在 session timeout 期间没有一台服务器收到来自客户端的请求,那么主节点就会认为发生了异常。如果客户端发送请求的频率足够高,那么就不需要发送其他消息来告诉主节点没有异常。否则,在非活跃期间客户端会发送心跳来维持连接。如果客户端和某台服务器无法发送请求或者心跳,那么客户端会和另外一台服务器建立连接。为了避免客户端的 session 过期,ZooKeeper 客户端类库会在 session 空闲了 s/3 毫秒后发送心跳,并且如果在 2s/3 毫秒内没有收到响应则会连接上另外一台服务器,这里的 s 指的是 session 的过期时间,以毫秒为单位。

参考

准备工作

日志

Debugging by Pretty Printing 中介绍了如何高效的打印日志,这有助于在实验时进行问题排查。

首先在 Go 侧需要封装一个日志打印函数 PrettyDebugraft 目录下已经有了 Debug 变量,所以这里重命名为 PrettyDebug),在 raft 目录下新建一个 Go 文件,复制以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package raft

import (
"fmt"
"log"
"os"
"strconv"
"time"
)

// Retrieve the verbosity level from an environment variable
func getVerbosity() int {
v := os.Getenv("VERBOSE")
level := 0
if v != "" {
var err error
level, err = strconv.Atoi(v)
if err != nil {
log.Fatalf("Invalid verbosity %v", v)
}
}
return level
}

type logTopic string

const (
dClient logTopic = "CLNT"
dCommit logTopic = "CMIT"
dDrop logTopic = "DROP"
dError logTopic = "ERRO"
dInfo logTopic = "INFO"
dLeader logTopic = "LEAD"
dCandidate logTopic = "CAND"
dLog logTopic = "LOG1"
dLog2 logTopic = "LOG2"
dPersist logTopic = "PERS"
dSnap logTopic = "SNAP"
dTerm logTopic = "TERM"
dTest logTopic = "TEST"
dTimer logTopic = "TIMR"
dTrace logTopic = "TRCE"
dVote logTopic = "VOTE"
dWarn logTopic = "WARN"
)

var debugStart time.Time
var debugVerbosity int

func init() {
debugVerbosity = getVerbosity()
debugStart = time.Now()

log.SetFlags(log.Flags() &^ (log.Ldate | log.Ltime))
}

func PrettyDebug(topic logTopic, format string, a ...interface{}) {
if debugVerbosity >= 1 {
t := time.Since(debugStart).Microseconds()
t /= 100
prefix := fmt.Sprintf("%06d %v ", t, string(topic))
format = prefix + format
log.Printf(format, a...)
}
}

PrettyDebug 会通过环境变量 VERBOSE 来决定是否打印日志,该方法接受三个参数,第一个是日志主题用于对日志分组,后两个参数则是传递给 log.Printf 进行格式化打印,使用方法如下:

1
PrettyDebug(dTimer, "S%d, apply log, log index=%v, log term=%v, log command=%v", rf.me, entry.Index, entry.Term, entry.Command)

日志信息中的 S%d 是关键,它表示当前节点的编号,如 S0S1,按照这个模式打印日志,在后续日志处理时能将日志按照节点分组。

然后,就可以通过 VERBOSE=1 go test -run TestFigure82C 来进行测试(这里的 TestFigure82C 可以换成其他的测试用例):

alt

不过所有日志都混到了一起,不好区分,作者因此提供了一个 Python 脚本 dslogs 来美化日志。这个脚本用到了 typerrich 两个库,可以通过 pip 全局安装。接着再执行测试 VERBOSE=1 go test -run TestFigure82C | pipenv run python dslogs.py(这里使用了 pipenv 来安装依赖和运行脚本,不使用 pipenv 的可以按照作者的方式执行),美化后的日志根据主题着色后有了更强的区分度:

alt

更进一步,还可以将日志按照节点分组展示 VERBOSE=1 go test -run TestFigure82C | pipenv run python dslogs.py -c 3

alt

在上图中,每一列表示一个节点的日志,而且自上而下随时间排序。

批量测试

做实验时有时候测试用例成功了,有时候失败了,每次手动测试不方便抓取日志,Debugging by Pretty Printing 的作者提供了另一个脚本 dstest 来进行批量测试,并且当测试失败时自动保存日志到文件中,从而可以使用上面提到的脚本 dslogs 来处理日志,dstest 这个脚本也依赖 typerrich 这两个库。

然后通过 pipenv run python dstest.py 2A -n 10 -v 1 进行批量测试,这里 2A 可以换成其他的测试用例,-n 10 表示测试多少次,默认是10,-v 1 表示设置环境变量 VERBOSE,这样就能告诉 Go 打印日志:

alt

alt

脚本貌似有个小问题,当设置 -v x 参数时,会多一个名为 x 的测试任务,不过并不影响使用。

如果某次测试执行失败,则会保存相应的日志:

alt

实现

2A

第一个实验是选主,关键有两点:随机化的 election timeout 和什么时候重置 election timeout

当候选节点发出 RequestVote 请求后,应该在哪里判断是否获得了足够的选票?一种是在遍历完所有从节点发出 RequestVote 请求后,不过由于 RPC 的异步性,需要某种异步通知机制来通知当前的 goroutine。可以使用 sync.WaitGroup,事先计算好需要多少张选票才能成为主节点,发送 RPC 请求前调用 WaitGroup.Add(1),每当获得一张选票后就调用 WaitGroup.Done(),当获得了足够的选票后当前 goroutine 就能被唤醒,不过由于当前节点不一定能成为主节点,所以存在无法被唤醒的可能。虽然可以把 WaitGroup 设置成所有 RPC 都响应后再唤醒,不过整个响应时间就受限于最慢的 RPC 请求,等待时间可能会超过一个 election timeout 周期。使用这种方式的一个很大的问题就是无法及时响应其他候选节点成为主节点的情况,因为当前候选节点还阻塞在 WaitGroup.Wait()

所以可以将是否获得了足够的选票的判断放在每个 RequestVote 的响应中。先初始化需要的选票数量,每次获得选票后使用原子方法 atomic.AddInt32 对票数减1,当返回票数小于等于0时,说明当前候选节点成为了主节点。

2B

第二个实验需要实现日志复制。日志是 Raft 的核心部分,首先定义 LogEntry,包含三个字段,索引、任期、指令:

1
2
3
4
5
type LogEntry struct {
Index int
Term int
Command interface{}
}

之所以这里需要 Index 是因为需要对日志压缩,所以不能使用 rf.log 的数组下标作为日志项的索引。

复制日志时,可以选择在调用 Start 方法时就发送 AppendEntries 请求,并且在响应中判断从节点的日志是否匹配来更新 prevLogIndex,然后继续发送 AppendEntries 请求。不过,这会造成两个问题。

第一个问题是冗余的 RPC 请求,假设客户端连续调用了10次 Start,那么根据当前的 prevLogIndex 计算,主节点所发送的 AppendEntries 请求中分别包含1条日志,2条日志,…,10条日志。然而这10次 AppendEntries 请求完全可以由第10条请求替代,而如果 prevLogIndex 不匹配,主从节点间来回协调的过程又会带来更多的 RPC 交互,最终有可能导致测试用例 TestCount2B 的失败。

第二个问题是测试用例会模拟出特别不稳定的网络,如果在 AppendEntries 的响应中接着递归异步调用 AppendEntries,由于 goroutine 都在等待网络可能会造成同时存在的 goroutine 数量过多,导致测试失败。

所以,可以选择不在 Start 中发送带日志的 AppendEntries 请求,而是在常规心跳中根据 nextIndex 计算是否要发送日志。

2C

第三个实验是持久化,虽然从代码编写角度来说是所有实验中最简单和直白的,但是测试用例并不会比其他实验简单。特别是 TestFigure8Unreliable2C,容易在指定时间内无法提交某条日志,一方面是可以批量发送日志而不是逐条发送,另一方面是及时识别过期的 RPC 请求并丢弃,例如如果响应中的任期小于当前任期则可以直接忽略该响应,因为从节点收到请求时会更新任期(如果从节点的任期比主节点的小),并将更新后的任期放到响应中,所以在当前任期下主节点收到的响应中的任期必然等于当前任期,如果收到了小于当前任期的响应,必然是过期的响应。

2D

由于执行快照后会对日志压缩,所以 LogEntry.Indexrf.log 的数组索引不再一一对应,有两点需要改动,一是使用 len(rf.log) 表示日志长度的地方需要改为 rf.log[len(rf.log)-1].Index;二是使用 rf.log[i] 来引用 LogEntry 的地方需要将 i 减去某个偏移量,这个偏移量可以使用 lastIncludedIndex,例如,从节点想要判断 args.PrevLogIndex 所指向的日志的任期是否和主节点相同,需要改为 rf.log[args.PrevLogIndex-rf.lastIncludedIndex].Term 访问,因此 rf.lastIncludedIndex 也需要持久化。

另外还遇到两个死锁问题。第一个死锁发生在应用已提交的日志,日志的应用会由一个单独的 goroutine 执行,它会遍历所有需要应用的日志,然后发送到 applyCh,并且在整个期间持有锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (rf *Raft) applyLog(applyCh chan ApplyMsg) {
for rf.killed() == false {
rf.mu.Lock()

if rf.lastApplied < rf.commitIndex {
for i := rf.lastApplied + 1; i <= rf.commitIndex; i++ {
logEntry := rf.log[i]
applyMsg := ApplyMsg{
CommandValid: true,
Command: logEntry.Command,
CommandIndex: logEntry.Index,
}

applyCh <- applyMsg
}

rf.lastApplied = rf.commitIndex
}

rf.mu.Unlock()

time.Sleep(time.Millisecond * 10)
}
}

这种处理方式在之前的实验中没有问题,不过在 2D 中,客户端从 applyCh 中取出数据后,有一定概率会调用 Snapshot 方法,而实现 Snapshot 方法时会继续获取锁,从而造成死锁:

1
2
3
4
5
6
7
8
9
10
11
if (m.CommandIndex+1)%SnapShotInterval == 0 {
w := new(bytes.Buffer)
e := labgob.NewEncoder(w)
e.Encode(m.CommandIndex)
var xlog []interface{}
for j := 0; j <= m.CommandIndex; j++ {
xlog = append(xlog, cfg.logs[i][j])
}
e.Encode(xlog)
rf.Snapshot(m.CommandIndex, w.Bytes())
}

这个问题也在 Raft Locking Advice 中提到,不建议在等待某个事件时持有锁。

第二个死锁发生在 InstallSnapshot,从节点收到快照后也会通过 applyCh 将快照发送给客户端,这里将 applyCh 作为 Raft 的一个字段使用,不过由于忘记赋值造成 InstallSnapshot 往一个空 channel 中发数据,造成始终阻塞,并导致死锁。

其他工具

go-deadlock

如果怀疑有死锁,可以使用 go-deadlock 检测,只需要将 Raft 中的 sync.Mutex 替换成 deadlock.Mutex 即可,如果某个 goroutine 在较长的一段时间后依然无法获取锁,那么就有可能发生了死锁,go-deadlock 会输出持有锁的 goroutine 和希望获取锁的 goroutine,而且也会输出持有锁的 goroutine 阻塞在哪个代码上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
POTENTIAL DEADLOCK:
Previous place where the lock was grabbed
goroutine 240 lock 0xc820160440
inmem.go:799 bttest.(*table).gc { t.mu.RLock() } <<<<<
inmem_test.go:125 bttest.TestConcurrentMutationsReadModifyAndGC.func5 { tbl.gc() }

Have been trying to lock it again for more than 40ms
goroutine 68 lock 0xc820160440
inmem.go:785 bttest.(*table).mutableRow { t.mu.Lock() } <<<<<
inmem.go:428 bttest.(*server).MutateRow { r := tbl.mutableRow(string(req.RowKey)) }
inmem_test.go:111 bttest.TestConcurrentMutationsReadModifyAndGC.func3 { s.MutateRow(ctx, req) }


Here is what goroutine 240 doing now
goroutine 240 [select]:
github.com/sasha-s/go-deadlock.lock(0xc82028ca10, 0x5189e0, 0xc82013a9b0)
/Users/sasha/go/src/github.com/sasha-s/go-deadlock/deadlock.go:163 +0x1640
github.com/sasha-s/go-deadlock.(*Mutex).Lock(0xc82013a9b0)
/Users/sasha/go/src/github.com/sasha-s/go-deadlock/deadlock.go:54 +0x86
google.golang.org/cloud/bigtable/bttest.(*table).gc(0xc820160440)
/Users/sasha/go/src/google.golang.org/cloud/bigtable/bttest/inmem.go:814 +0x28d
google.golang.org/cloud/bigtable/bttest.TestConcurrentMutationsReadModifyAndGC.func5(0xc82015c760, 0xc820160440) /Users/sasha/go/src/google.golang.org/cloud/bigtable/bttest/inmem_test.go:125 +0x48
created by google.golang.org/cloud/bigtable/bttest.TestConcurrentMutationsReadModifyAndGC
/Users/sasha/go/src/google.golang.org/cloud/bigtable/bttest/inmem_test.go:126 +0xb6f

pprof

6.824 Lab 2: Raft 的每个实验都给出了参考的执行时间,如果发现某个实验的执行时间相差太大,可以使用 pprof 分析。这里以 CPU 耗时分析为例,首先在测试时增加 -cpuprofile cpu.prof 参数,其中 cpu.prof 是输出文件名:

1
go test -run TestInitialElection2A -cpuprofile cpu.prof

然后安装 pprof 并执行 pprof -top cpu.prof 分析:

alt

参考

Raft Locking Advice 提供了些关于如何在 Lab 2 中使用锁的建议。

规则1

只要有多个 goroutine 访问同一份数据,并且至少有一个 goroutine 会修改数据,那么就需要对数据加锁保护。建议在测试时开启 Go 的竞争检测(添加 -race 标记)来识别这类问题。

规则2

如果有多个数据需要作为一个整体被修改,为了避免其他的 goroutine 看到部分数据更新而造成不正确的行为,此时也需要加锁。例如:

1
2
3
4
rf.mu.Lock()
rf.currentTerm += 1
rf.state = Candidate
rf.mu.Unlock()

上面的代码需要同时更新 rf.currentTermrf.state,如果不加锁其他 goroutine 有可能看到更新后的任期,但是节点状态还未更新。同时,其他任何地方用到 rf.currentTerm 或者 rf.state 的地方也必须先持有锁,一是保证可见性,二是避免在多处同时修改 rf.currentTerm 或者 rf.state

规则3

如果需要对某个数据做一系列读操作(或者读写混合),那么为了避免其他 goroutine 在中途修改数据,就需要对这一系列操作加锁。例如:

1
2
3
4
5
rf.mu.Lock()
if args.Term > rf.currentTerm {
rf.currentTerm = args.Term
}
rf.mu.Unlock()

上面的代码是典型的 如果满足某个条件,那么就执行 xxx 场景。如果不加锁,可能其他的 goroutinerf.currentTerm 更新后,当前 goroutine 会将 rf.currentTerm 重置为 args.Term,在 Raft 中有可能造成任期倒退。

在真实的 Raft 代码中加锁的粒度可能会更大,例如可能在整个 RPC handler 处理期间都持有锁。

规则4

不建议在等待某个事件时持有锁,例如从 channel 中读取数据,向 channel 发送数据,计时器等待,调用 time.Sleep(),或者发送一个 RPC 请求并等待响应结果。因为有可能造成死锁,文中举了两个节点互发 RPC 请求并希望获取对方持有的锁的例子,这是个典型的死锁场景。

又或者某个 goroutine 先持有锁,但是使用 time.Sleep 来等待某个条件发生,其他的 goroutine 由于无法获取锁从而使得等待的条件永远无法成立,这个时候应该用 sync.Cond

1
2
3
4
5
6
7
mu.Lock()

while (!someCondition) {
time.Sleep(time.Millisecond * 1000)
}

mu.Unlock()

规则5

当释放锁然后重新获取锁之后,某些释放锁之前成立的条件可能此时已经不成立。例如下面的候选节点获取选票的实现是不正确的:

1
2
3
4
5
6
7
8
9
10
11
12
13
rf.mu.Lock()
rf.currentTerm += 1
rf.state = Candidate
for <each peer> {
go func() {
rf.mu.Lock()
args.Term = rf.currentTerm
rf.mu.Unlock()
Call("Raft.RequestVote", &args, ...)
// handle the reply...
} ()
}
rf.mu.Unlock()

在每个 goroutine 中,重新获取锁后拿到的任期可能已经不是当初的任期。这里需要将 goroutine 中的 rf.currentTerm 提取到循环之外作为一个变量,然后在 goroutine 中访问这个变量。另外,在 Call 执行完成后,也需要再次获取锁并检查 rf.currentTerm 或其他变量是否还满足条件,例如需要检查下当前的任期是否还是最初的任期,如果不是那说明又开启了一轮选主或者已经有其他节点成为了主节点。

参考

0%