介绍

Google API Design GuideGoogle 设计 Cloud APIs 和其他 Google APIs 的设计指南。

该指南面向的不仅仅是 REST APIs,同时也适用于 RPC APIs,其中 RPC APIs 主要面向的是 gRPC APIs

面向资源的设计

传统的 RPC 接口设计面向的是操作,各个接口之间是孤立的,没有明确的关联;不同系统的接口也有着不同的设计风格,存在着一定的学习和使用成本。

而面向资源的设计则将系统抽象为一系列资源,开发者则通过有限的几个标准方法来操作资源,从而实现对系统的修改。对于 RESTful 接口来说,有限的几个标准方法对应的就是 HTTP 请求方法中的 POSTGETPUTPATCHDELETE。另一方面,由于遵循了统一的设计,当开发者调用不同系统的接口时,能够自然的假定各个系统都支持相同的标准方法,从而降低了开发者学习的成本。

不管是面向资源的设计还是其他的设计标准,统一的标准胜过百花齐放,开发者应当将更多的精力放在自身系统的业务实现上,而不是耗费时间学习和调试其他系统的接口。

该指南建议按照如下的步骤设计面向资源的接口:

  • 确定接口所提供的资源类型
  • 确定资源间的关系
  • 根据资源类型和资源间的关系确定资源命名模式
  • 确定资源体系
  • 为每个资源设计最小限度的操作方法

在面向资源的设计体系下,各接口一般会按照资源的层级结构进行组织,层级结构中的每个节点可能是单一的资源,也可能是一个资源集合:

  • 一个资源集合包含了一系列相同类型的资源,例如,一个用户拥有一个联系人资源集合
  • 一个资源包含了若干的状态,同时也包含了0个或者多个子资源。每个子资源可以是一个单一资源或者是资源集合

以创建邮件接口为例,传统的接口设计可能是如下的方式:

1
2
3
4
5
6
7
8
9
POST /createMail

{
"userId": 123,
"title:" "Title",
"from": "from@example.com"
"to": "to@example.com",
"body": "Body"
}

而面向资源的接口设计则可能为:

1
2
3
4
5
6
7
8
POST /users/{userId}/mails

{
"title:" "Title",
"from": "from@example.com"
"to": "to@example.com",
"body": "Body"
}

可以看到,面向资源的接口设计体现了资源间的层级关系。一般而言,对于 RESTful 接口来说,请求 URL 中只会包含资源的名称(名词),而不会包含对资源的操作(动词),HTTP 的请求方法就对应了资源的标准操作方法。而该指南讨论的是通用的面向资源的设计,其对应的资源标准操作方法为:ListGetCreateUpdateDelete

资源名称

在面向资源的设计下,资源是一个命名实体,每个资源都有一个唯一的名称作为其标识符。一个资源的名称由三部分组成:

  1. 资源的 ID
  2. 所有父资源的 ID
  3. API 服务名,如 gmail.googleapis.com

资源集合被视为一种特殊的资源,它包含了一组相同类型的子资源,例如一个目录可以被视为一个资源集合,它包含了一组文件资源。同时,资源集合也有相应的 ID

资源名称由资源 ID 和资源集合 ID 组成,其定义也体现了资源的层级结构关系,各层级之间使用 / 进行分隔。例如,对于某个对象存储服务中的对象来说,其资源名称可能为 //storage.googleapis.com/buckets/bucket-123/objects/object-123,其中最顶层为服务名,即 //storage.googleapis.com,然后是一个资源集合,即 buckets,对象存储服务一般以 bucket 为维度来管理对象;接下来为了要定位到某个对象,需要先定位到具体的 bucketbucket-123 就是某个 bucket 的资源 ID,而每个 bucket 下包含了多个对象,进而产生了一个资源集合 objects,最后的 object-123 就是实际对象的资源 ID

一般来说,一个资源在实现上很可能对应一张数据库的表,所以可以用表的主键来作为资源的 ID。而由于使用了 / 来分隔资源的层级,因此只有最底层的资源才允许资源 ID 中包含 /,从而避免层级歧义。

如果资源 ID 中包含了 /,则必须在 API 文档中明确声明。

资源集合更多的是一种层级上的逻辑概念,所以其 ID 命名需要有意义,以及符合以下的规范:

  1. 必须是以小驼峰形式命名的英文单词复数,如果对应单词没有复数,则应当使用单词的单数形式
  2. 必须使用简洁明了的英文单词
  3. 避免使用过于宽泛的英文单词,例如,rowValues 优于 values。同时应当避免无条件的使用如下的英文单词:
    • elements
    • entries
    • instances
    • items
    • objects
    • resources
    • types
    • values

对于 Google 的服务来说,资源集合 ID 还会经常出现在自动生成的客户端类库代码中,所以它们的命名也必须是合法的 C/C++ 标识符。

完整的资源名称是协议无关的,虽然它看起来像 RESTful 服务的 HTTP 接口请求路径,但本质上这是两个东西。实际的资源请求还需要附带版本号,协议等信息,例如对于资源名称 //calendar.googleapis.com/users/john smith/events/123 来说,实际的 RESTful 请求路径可能是 https://calendar.googleapis.com/v3/users/john%20smith/events/123,和原本的资源名称相比有三点不同:

  1. 指明了具体的协议,HTTPS
  2. 指明了版本号,v3
  3. 对资源名称进行了 URL 转义

GoogleAPI 服务要求资源名称必须是字符串,除非有后向兼容的问题,资源名称在跨模块间传递时必须确保没有任何数据丢失。对于资源定义来说,第一个字段应该命名为 name,类型为字符串,用于表示资源的名称,以下是 gRPC 服务的定义示例:

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
// 图书馆服务
service LibraryService {
// 获取一本书
rpc GetBook(GetBookRequest) returns (Book) {
option (google.api.http) = {
get: "/v1/{name=shelves/*/books/*}"
};
};

// 创建一本书
rpc CreateBook(CreateBookRequest) returns (Book) {
option (google.api.http) = {
post: "/v1/{parent=shelves/*}/books"
body: "book"
};
};
}

// 资源定义
message Book {
// 资源名称,必须以 "shelves/*/books/*" 的形式。
// 例如,"shelves/shelf1/books/book2"。
string name = 1;

// ... 其他属性
}

// 获取书籍请求
message GetBookRequest {
// 资源名称,例如 "shelves/shelf1/books/book2"。
string name = 1;
}

// 创建书籍请求
message CreateBookRequest {
// 父资源的名称,例如 "shelves/shelf1"。
string parent = 1;
// 需要创建的资源实体,客户端不允许设置 `Book.name` 属性。
Book book = 2;
}

为什么这里资源的名称字段要定义为 name 而不是 id?首先从命名上来说 name 本身要比 id 更适合作为 名称 一词的命名。其次,name 也是一个较为宽泛的词语,例如文件资源的 name 代表的是文件的名称还是完整的路径?通过将 name 作为标准字段,使得开发人员必须要选择更适合的命名,例如 display_nametitle 或者 full_name

为什么不直接使用资源 ID 来定位资源?一个系统中往往有多个资源,单纯的资源 ID 不具有辨识度以及缺少上下文信息。例如,如果使用数据库表的自增主键作为资源 ID,则无法简单的通过数字来定位资源。如果想要通过资源 ID 来定位资源,则势必要扩展资源 ID 的定义,例如使用元组来表示资源 ID,如 (bucket, object) 用于定位某个对象存储服务的对象。不过,这也带来了几个问题:

  1. 对开发人员不友好,需要额外理解和记忆(例如不同资源 ID 的元组元素个数不同,每个元组元素代表的含义是什么)
  2. 解析元组比解析字符串更为困难
  3. 对基础设施组件不友好,例如日志和访问控制系统无法直接理解元组
  4. 限制了 API 设计的灵活性,如提供可复用的 API 接口

标准方法

标准方法的作用在于为大多数的服务场景提供统一、易用的接口,超过 70% 的 Google APIs 都是标准方法。Google APIs 设计了5种标准方法:

  1. List
  2. Get
  3. Create
  4. Update
  5. Delete

下表是标准方法和 HTTP 请求方法的映射:

标准方法 HTTP 请求方法映射 HTTP 请求体 HTTP 响应体
List Get <资源集合 URL> 资源集合
Get GET <资源 URL> 资源
Create POST <资源集合 URL> 资源 资源
Update PUT 或者 PATCH <资源 URL> 资源 资源
Delete DELETE <资源 URL>

HTTP 响应体返回的资源可能不会包含资源的全部字段,例如客户端请求时可以指定只返回需要的字段。
如果 Delete 操作不是立即删除资源,例如只是更新资源的某个字段标记删除或者是创建一个 长时间运行任务 来删除资源,则 HTTP 响应体应该包含修改后的资源或者任务信息。

List

List 方法用于返回一系列同类的资源,同时该接口支持额外的参数从而允许只返回匹配的资源。它适合用于获取有限大小、未缓存的单一资源集合,对于更复杂的场景则可以考虑使用自定义方法中的 Search 接口。

如果想要批量获取资源,例如入参一组资源 ID 来返回每个资源 ID 所对应的资源,则应该考虑实现 BatchGet 的自定义方法,而不是在 List 方法上扩展。

List 方法和 HTTP 请求的映射关系如下:

  • List 方法必须对应 HTTPGET 请求
  • List 方法的 RPC 请求消息体的 name 字段(也就是资源集合名称)应该和 HTTP 的请求路径匹配,如果相匹配,则 HTTP 请求路径的最后一个段必须是字面量(即资源集合 ID
  • List 方法的 RPC 请求消息体的其他字段应该和 HTTP 请求路径的查询参数相匹配
  • 对应的 HTTP 请求无请求体;List 方法的 API 定义中不允许声明 body 语句
  • HTTP 响应体应该包含一组资源及可选的元数据信息

接口定义示例:

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
// 获取书架上的书
rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {
// List 方法映射为 HTTP GET 请求
option (google.api.http) = {
// parent 对应父资源的名称,如 shelves/shelf1
get: "/v1/{parent=shelves/*}/books"
};
}

// 获取书籍集合请求
message ListBooksRequest {
// 父资源的名称,如shelves/shelf1
string parent = 1;

// 返回资源的最大个数
int32 page_size = 2;

// 返回第几页的资源集合,其值为前一个 List 请求返回的 next_page_token 字段
string page_token = 3;
}

// 获取书籍集合响应
message ListBooksResponse {
// 返回的书籍资源集合,该字段名应该和方法名中的 Books 相匹配。其数量上限由 ListBooksRequest 中的 page_size 决定
repeated Book books = 1;

// 下一页资源集合的页码信息,用于获取下一页的资源集合;没有下一页时为空
string next_page_token = 2;
}

Get

Get 方法接收一个资源名称及其他参数来返回某个指定的资源。

Get 方法和 HTTP 请求的映射关系如下:

  • Get 方法必须对应 HTTPGET 请求
  • Get 方法的 RPC 请求消息体的 name 字段(也就是资源名称)应该和 HTTP 的请求路径匹配
  • Get 方法的 RPC 请求消息体的其他字段应该和 HTTP 请求路径的查询参数相匹配
  • 对应的 HTTP 请求无请求体;Get 方法的 API 定义中不允许声明 body 语句
  • Get 方法返回的资源实体应该和 HTTP 的整个响应体相匹配

接口定义示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 获取一本书
rpc GetBook(GetBookRequest) returns (Book) {
// Get 方法映射为 HTTP GET 请求,资源名称映射到请求路径,无请求体
option (google.api.http) = {
// 所请求的资源名称,如 shelves/shelf1/books/book2
get: "/v1/{name=shelves/*/books/*}"
};
}

// 获取单个书籍请求
message GetBookRequest {
// 所请求的资源名称,如 shelves/shelf1/books/book2
string name = 1;
}

Create

Create 方法接收一个父资源名称,一个资源实体,以及其他的参数来在指定的父资源下创建一个新的资源,并返回创建的资源。

如果某个 API 服务支持创建资源,则应当为每一类的资源创建对应的 Create 方法。

Create 方法和 HTTP 请求的映射关系如下:

  • Create 方法必须对应 HTTPPOST 请求
  • Create 方法的 RPC 请求消息体应当包含一个 parent 字段用于表示所创建的资源的父资源的名称
  • Create 方法的 RPC 请求消息体中表示资源实体的各字段应当和 HTTP 请求体中的字段相对应。如果 Create 方法定义中标注了 google.api.http,则必须声明 body: "<resource_field>" 语句
  • Create 方法的 RPC 请求消息体可能包含一个 <resource>_id 字段来允许调用方指定所创建的资源的 id。这个字段可能会包含在资源字段实体内
  • Create 方法的其余参数应当和 URL 的查询参数相匹配
  • Create 方法返回的资源实体应该和 HTTP 的整个响应体相匹配

如果 Create 方法允许由调用方指定所创建的资源的名称,并且该资源已经存在,则该请求应当作失败处理并返回错误码 ALREADY_EXISTS;或者由服务端重新生成一个资源名称,并返回创建的资源,同时接口文档应当清晰的标注最终创建的资源的名称有可能和调用方传入的资源名称不同。

Create 方法的 RPC 请求消息体中必须包含资源实体,这样当资源实体的定义发生变更时,就无需同时变更请求消息体的定义。如果资源实体中的某些字段无法由客户端设置,则必须将其标注为 Output only 字段。

接口定义示例:

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
// 在书架上创建一本书
rpc CreateBook(CreateBookRequest) returns (Book) {
// Create 方法对应 HTTP 的 POST 请求,URL 请求路径为资源集合名称
// HTTP 请求体中包含需要创建的资源
option (google.api.http) = {
// parent 对应父资源的名称,如 shelves/1
post: "/v1/{parent=shelves/*}/books"
body: "book"
};
}

// 创建书籍请求
message CreateBookRequest {
// 父资源名称
string parent = 1;

// 资源 id
string book_id = 3;

// 需要创建的资源
// 字段名称需要和 RPC 方法中的名词对应,即 book -> Book
Book book = 2;
}

// 创建一个书架
rpc CreateShelf(CreateShelfRequest) returns (Shelf) {
option (google.api.http) = {
post: "/v1/shelves"
body: "shelf"
};
}

// 创建书架请求
message CreateShelfRequest {
Shelf shelf = 1;
}

Update

Update 方法接收一个资源实体,以及其他的参数来更新指定的资源及其属性,并返回更新后的资源。

资源的可变属性应当能够通过 Update 方法修改,除非该属性包含资源的名称或父资源的名称。资源重命名或者将资源移动到另一个父资源下都不允许在 Update 方法中实现,而应当由自定义方法来实现。

Update 方法和 HTTP 请求的映射关系如下:

  • 标准的 Update 方法应该能够支持更新资源的部分属性,并通过 update_mask 字段来指明需要更新的属性,对应的 HTTP 请求方法为 PATCH。资源实体中标注为 Output only 的属性应该在资源更新时忽略
  • 如果要求 Update 方法实现更为高级的局部更新语义则应当将其作为自定义方法来实现,例如追加新值到资源的某个列表类型的属性上
  • 如果 Update 方法不支持局部属性更新,则对应的 HTTP 请求方法必须是 PUT。不过不建议 Update 方法仅支持全局更新,因为后续如果为资源添加新的属性则可能会有后向兼容问题
  • Update 方法的 RPC 请求消息体中表示资源名称的字段值必须和 URL 中的请求路径相匹配。这个资源名称字段可能包含在资源实体内
  • Update 方法的 RPC 请求消息体中表示资源实体的各字段必须和 HTTP 请求体中的字段相对应
  • Update 方法的其余参数必须和 URL 的查询参数相匹配
  • Update 方法的返回结果必须是更新后的资源实体

既然 URL 中已经有了资源名称,为什么请求体里面还要再传一遍资源名称?关于这一点不同的服务有不同的实现,例如 DigitalOcean 的更新接口就不要求请求体中再传一遍 idUpdate an App

Update 方法的返回结果必须是更新后的资源实体看起来是多此一举,但是某些资源的属性必须由服务端来更新,例如资源的更新时间,或者对于 Git 服务来说文件更新后的版本号等等,这些属性更新后也需要返回给客户端。

如果后端服务允许客户端指定资源名称则 Update 方法允许客户端调用时发送一个不存在的资源名称,然后服务端会自动创建一个新的资源。否则,Update 方法应当作失败处理并返回 NOT_FOUND 的错误码(如果这是唯一的错误的话)。

即使 Update 方法本身支持新建资源也应当提供额外的 Create 方法,否则服务的使用者可能会感到迷惑。

接口定义示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 更新一本书
rpc UpdateBook(UpdateBookRequest) returns (Book) {
// Update 方法对应 HTTP 的 PATCH 请求,资源名称映射到请求路径
// 资源实体包含在 HTTP 请求体中
option (google.api.http) = {
// 请求路径包含了需要更新的资源名称
patch: "/v1/{book.name=shelves/*/books/*}"
body: "book"
};
}

// 更新书籍请求
message UpdateBookRequest {
// 需要更新的书籍
Book book = 1;

// 指定需要更新的书籍属性,具体定义见 https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#fieldmask
FieldMask update_mask = 2;
}

Delete

Delete 方法接受一个资源名称和其他参数来删除或者计划删除某个指定的资源。Delete 方法返回的消息体类型应当为 google.protobuf.Empty

服务调用方不应该依赖 Delete 方法返回的任何信息,因为 Delete 方法被重复调用时不一定每次都返回相同的信息。

Delete 方法和 HTTP 请求的映射关系如下:

  • Delete 方法必须对应 HTTPDELETE 请求
  • Delete 方法的 RPC 请求消息体中表示资源名称的字段值应当和 URL 中的请求路径相匹配
  • Delete 方法的其余参数应当和 URL 的查询参数相匹配
  • 对应的 HTTP 请求无请求体;Delete 方法的 API 定义中不允许声明 body 语句
  • 如果 Delete 方法在实现时是立即删除资源则该方法返回的消息体为空
  • 如果 Delete 方法在实现时是创建一个 长时间运行任务 来删除资源,则该方法返回的消息体应当为对应的任务信息
  • 如果 Delete 方法在实现时仅将资源标记为删除而不是物理删除,则该方法应当返回更新后的资源

Delete 方法应当是幂等的,但并不要求每次都返回相同的信息。对同一个资源的多个 Delete 请求应当使得该资源(最终)被删除,不过只有第一个成功删除资源的请求应当返回相应的成功信息,其余的请求应当返回 google.rpc.Code.NOT_FOUND 错误码。

接口定义示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 删除一本书
rpc DeleteBook(DeleteBookRequest) returns (google.protobuf.Empty) {
// Delete 方法对应 HTTP 的 DELETE 请求,资源名称映射到请求路径,无 HTTP 请求体
option (google.api.http) = {
// 请求路径包含了需要删除的资源名称,例如 shelves/shelf1/books/book2
delete: "/v1/{name=shelves/*/books/*}"
};
}

// 删除书籍请求
message DeleteBookRequest {
// 需要被删除的资源名称,如 shelves/shelf1/books/book2
string name = 1;
}

自定义方法

标准方法提供了对资源的基础操作功能,它们的职责都较为单一,基本上对应了基础的 CRUD 操作。不过,并不是所有对资源的操作都能或者适合抽象为 CRUD 操作,这也是对于 RESTful 风格的服务经常争论的地方。因此,自定义方法就应运而生。

不过,对于 API 的设计者而言应当尽可能的首选使用标准方法,因为标准方法有着更为统一的语义,对开发者而言更为简单易懂。

自定义方法可以应用于资源,资源集合或者服务。它可能会接收任意类型的输入和返回任意类型的输出,并且支持流式的请求和响应。

HTTP 请求映射

对于自定义方法来说,应当使用如下的 HTTP 请求映射:

1
https://service.name/v1/some/resource/name:customVerb

这里之所以选择 : 而不是 /namecustomVerb 分开是为了支持 name 中包含 / 的情况,例如将文件路径作为资源名称时则获取资源的请求可能为 GET /files/a/long/file/name,则撤销对该文件的删除操作所对应的自定义方法可能为 POST /files/a/long/file/name:undelete,如果 undelete 前用 / 分割则无法识别完整的资源名称。

自定义方法和 HTTP 请求的映射关系应当遵循如下规则:

  • 自定义方法应当使用 HTTP 请求的 POST 方法,除非该自定义方法是作为标准方法中的 List 或者 Get 方法的扩展,此时可以使用 HTTP 请求的 GET 方法
  • 自定义方法不应该使用 HTTP 请求的 PATCH 方法,但是可以使用其他 HTTP 请求方法
  • 对于使用 HTTP 请求的 GET 方法的自定义方法来说必须保证接口的幂等性
  • 自定义方法的 RPC 请求消息体中表示资源或者资源集合名称的字段值应当和 URL 中的请求路径相匹配
  • HTTP 请求的路径必须以 :customVerb 的形式结尾
  • 如果自定义方法对应的 HTTP 请求方法允许 HTTP 请求体(如 POSTPUTPATCH 或者自定义的 HTTP 方法),则该自定义方法的 HTTP 配置中必须声明 body: "*" 语句,并且 RPC 消息体中的剩余字段应当和 HTTP 请求体中的字段相匹配
  • 如果自定义方法对应的 HTTP 请求方法不接受 HTTP 请求体(如 GETDELETE),则该自定义方法的 HTTP 配置中不允许声明 body 语句,并且 RPC 消息体中的剩余字段应当和 URL 的查询参数相匹配

接口定义示例:

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
// 服务级别的自定义方法
rpc Watch(WatchRequest) returns (WatchResponse) {
// 对应 HTTP 的 POST 请求,所有请求参数都来自于 HTTP 的请求体
option (google.api.http) = {
post: "/v1:watch"
body: "*"
};
}

// 资源集合级别的自定义方法
rpc ClearEvents(ClearEventsRequest) returns (ClearEventsResponse) {
option (google.api.http) = {
post: "/v3/events:clear"
body: "*"
};
}

// 资源级别的自定义方法
rpc CancelEvent(CancelEventRequest) returns (CancelEventResponse) {
option (google.api.http) = {
post: "/v3/{name=events/*}:cancel"
body: "*"
};
}

// 一个批量获取资源的自定义方法
rpc BatchGetEvents(BatchGetEventsRequest) returns (BatchGetEventsResponse) {
// 对应 HTTP 的 GET 请求
option (google.api.http) = {
get: "/v3/events:batchGet"
};
}

使用场景

以下是一些自定义方法比标准方法更适合的场景:

  • 重启一台虚拟机:其中一种反直觉的设计是在重启资源集合下创建一个重启资源,这属于生搬硬套;或者为虚拟机增加一个状态字段,重启操作就等价于资源的局部更新操作,即将虚拟机的状态由 RUNNING 改为 RESTARTING,虽然看似合理但是增加了使用人员的心智负担,例如除了这两种状态之外还有其他什么状态?另一方面也增加了接口实现的复杂度,标准方法的 Update 接口需要额外针对状态字段进行逻辑处理,违背了单一职责原则
  • 批处理:对于性能敏感的场景而言,提供批处理的自定义方法比一系列独立的标准方法可能有着更好的性能

对于 RESTful 服务的争论中最常提到的例子就是如何使用 RESTful 接口表示注册/登陆?注册/登陆并不适合作为标准方法来实现,使用自定义方法会更好。一般而言,标准方法的实现应当尽量简单直白,一旦需要对标准方法扩展处理额外的逻辑,就需要考虑是否使用自定义方法更合适。

错误处理

错误处理是 RESTful 又一个争论的点,即业务错误可能有很多,HTTP 的状态码根本不够,以及业务的状态码不应该和协议层的状态码相混淆。

错误模型

Google API 将错误统一封装为 google.rpc.Status

1
2
3
4
5
6
7
8
9
10
11
12
13
package google.rpc;

// 适用于不同编程环境的统一错误模型,包括 REST 接口和 RPC 接口
message Status {
// 错误码,具体错误码的定义见 google.rpc.Code
int32 code = 1;

// 错误信息,对错误原因的描述以及可能的修复方式
string message = 2;

// 错误的详细信息,开发人员可以通过详细信息找到一些有用的信息
repeated google.protobuf.Any details = 3;
}

由于大部分的 Google APIs 都是面向资源的设计,错误处理同样遵循了这样的设计,即使用一系列的标准错误来应对大多数的资源错误场景。例如使用标准的 google.rpc.Code.NOT_FOUND 错误来统一表示某个资源不存在,而不是为每一个资源定义一个 SOME_RESOURCE_NOT_FOUND 错误。

错误码

Google APIs 必须使用 google.rpc.Code 中定义的错误码,不允许独自额外定义错误码。

错误信息

错误信息应当能够帮助用户简单快速的理解和解决 API 错误。一般而言,描述错误信息可以遵循如下的规则:

  • 不要假设用户是你所开发的服务的专家,他们可能是客户端开发者,运维人员,IT 人员以及应用的终端用户
  • 不要假设用户知晓你所开发的服务任何的实现细节,或者熟悉错误的上下文
  • 尽可能的使得错误信息有助于技术用户(不一定是服务的开发人员)响应错误并修正
  • 保持错误信息简洁。如果可能的话在错误信息中提供一个帮助链接,以便于用户能够提问,反馈或者查找一些有用的信息

错误信息可能会随时变动,应用开发人员不应该强依赖错误信息。

错误详情

Google APIs 定义了一些列标准的 错误详情,这些错误详情覆盖了大部分的错误场景,例如配额分配失败以及无效的参数等等。和错误码一样,开发人员应当尽可能的优先使用标准的错误详情。

只有当错误详情有助于应用代码处理错误的情况下才应该考虑引入新的错误详情。如果当前错误只能由人工处理,则应依据错误信息由开发人员处理,而不是引入额外的错误详情。

以下是一些错误详情类型的例子:

  • ErrorInfo:提供稳定又可扩展的结构化错误信息
  • RetryInfo:告诉客户端什么时候可以对一个失败的请求进行重试,可能随错误码 Code.UNAVAILABLE 或者 Code.ABORTED 返回
  • QuotaFailure:描述为什么配额分配失败了,可能随错误码 Code.RESOURCE_EXHAUSTED 返回
  • BadRequest:客户端请求参数非法,可能随错误码 Code.INVALID_ARGUMENT 返回

错误映射

Google APIs 会被不同的编程环境访问,每个环境有自己的错误处理方式,所以需要将前面描述的错误模型对各个编程环境进行适配和映射。

HTTP 映射

对于 HTTP 接口来说,出于后向兼容性的考虑,Google APIs 定义了如下的错误模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 适用于 JSON HTTP 接口的错误模型
message Error {
// 废弃字段,仅用于 v1 格式的错误
message ErrorProto {}
// 和 `google.rpc.Status 有着相同的语义,出于和 Google API Client
// Libraries 后向兼容的考虑多了额外的 status 和 errors 字段
message Status {
// 错误码,同时也是 HTTP 状态码,对应 google.rpc.Status.code
int32 code = 1;
// 错误信息,对应 google.rpc.Status.message
string message = 2;
// 废弃字段,仅用于 v1 格式的错误
repeated ErrorProto errors = 3;
// 错误码的枚举值,对应 google.rpc.Status.code
google.rpc.Code status = 4;
// 错误详情,对应 google.rpc.Status.details
repeated google.protobuf.Any details = 5;
}
// 实际的错误消息体,之所以要额外包一层也是出于和 Google API Client
// Libraries 后向兼容的考虑,同时对于开发人员来说错误信息的可读性更高
Status error = 1;
}

具体示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"error": {
"code": 400,
"message": "API key not valid. Please pass a valid API key.",
"status": "INVALID_ARGUMENT",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "API_KEY_INVALID",
"domain": "googleapis.com",
"metadata": {
"service": "translate.googleapis.com"
}
}
]
}
}

gRPC 映射

不同的 RPC 协议有着不同的错误处理模式,对于 gRPC 来说,上述的错误模型在各语言自动生成的代码中已经天然支持。

客户端类库映射

不同的编程语言对于错误处理有着不同的准则,客户端类库会尽量去适配这些准则,例如 google-cloud-go 遇到错误时会返回和 google.rpc.Status 实现了同样接口的错误,而 google-cloud-java 则会直接抛出错误。

错误处理

下表列出了 google.rpc.Code 定义的所有错误码:

HTTP gRPC
200 OK
400 INVALID_ARGUMENT
400 FAILED_PRECONDITION
400 OUT_OF_RANGE
401 UNAUTHENTICATED
403 PERMISSION_DENIED
404 NOT_FOUND
409 ABORTED
409 ALREADY_EXISTS
429 RESOURCE_EXHAUSTED
499 CANCELLED
500 DATA_LOSS
500 UNKNOWN
500 INTERNAL
501 NOT_IMPLEMENTED
502 N/A
503 UNAVAILABLE
504 DEADLINE_EXCEEDED

Google APIs 可能会并发的检查 API 请求是否满足条件,返回某个错误码不代表其他条件都符合要求,应用代码不应该依赖条件检查的顺序。

可以看到,即使是相同的 HTTP 状态码其代表的含义也是不同的,此时就需要 status 字段来进一步区分,类似于将所有错误划分几个大类,然后在每个大类中再细分小类,相比于单纯用 HTTP 状态码来表示不同的错误来说更加灵活,扩展性也更好。

与之相对的非 RESTful 做法则是 HTTP 状态码永远返回200,在返回的消息体中定义错误码和错误信息,例如:

1
2
3
4
5
{
"data": "some data",
"code": 123,
"message": "something is wrong"
}

重试错误

对于 503 UNAVAILABLE 错误客户端可以采用 exponential backoff 的方式进行重试,最短重试等待时间应该是1秒,以及默认重试次数应当是1次,除非文档有特别说明。

对于 429 RESOURCE_EXHAUSTED 错误客户端可以等待更长的时间进行重试,不过最短的等待时间应当是30秒。这种重试仅对于长时间运行任务有效。

对于其他的错误,重试可能就不太适合。

错误传播

如果当前服务依赖于其他服务,则不应该直接将其他服务的错误返回给客户端。在进行错误转换时建议:

  • 隐藏实现的细节及机密的信息
  • 调整负责该错误的一方,例如当前服务从其他服务收到 INVALID_ARGUMENT 错误时则返回 INTERNAL 错误给客户端

错误重现

如果通过日志分析和监控无法解决错误,则应该尝试通过简单、可重复的测试来重现错误。

设计模式

空响应

标准方法中的 Delete 方法应当返回 google.protobuf.Empty,除非 Delete 执行的是软删除,此时应当返回更新后的资源实体。

对于自定义方法来说,应当返回各自的 XxxResponse 消息体,因为即使该方法现在没有数据返回随着时间的推移有很大的可能会增加额外的字段。

范围表示

表示范围的字段应当使用左闭右开的区间,并以 [start_xxx, end_xxx) 的形式命名,例如 [start_key, end_key) 或者 [start_time, end_time)API 应当避免其他形式的范围表示,如 (index, count) 或者 [first, last]

资源标签

在面向资源的 API 设计下,资源的模式由 API 决定。为了让客户端能够给资源添加自定义的元数据(例如标记某台虚拟机为数据库服务器),资源定义中应当添加一个 map<string, string> labels 字段,例如:

1
2
3
4
message Book {
string name = 1;
map<string, string> labels = 2;
}

长时间运行操作

如果某个 API 方法需要很长时间才能完成,则该方法应该设计成返回一个长时间运行操作资源给客户端,客户端可以通过这个资源来跟踪方法的执行进展及获取执行结果。Operation 定义了标准的接口来处理长时间运行操作,各 API 不允许自行定义额外的长时间运行操作接口以避免不一致。

长时间运行操作资源必须以响应消息体的方式返回给客户端,并且该操作的任何直接结果都应该反应到其他 API 中。例如,如果有一个长时间运行操作用于创建资源,即使该资源未创建完成,LISTGET 标准方法也应该返回该资源,只是该资源会被标记为暂未就绪。当长时间操作完成时,Operation.response 字段应当包含该操作的执行结果。

长时间运行操作可以通过 Operation.metadata 字段来反馈其运行进展。API 在实现时应当为 Operation.metadata 定义消息类型,即使在一开始的实现中不会填充 metadata 字段。

List 方法分页

支持 List 方法的资源集合应当支持分页功能,即使该方法返回的结果集很小。

因为如果一开始 List 方法不支持分页,后续增加分页功能就会使得和原有 API 的行为不一致。客户端在不知道 List 方法使用分页的情况下依然会认为该方法返回的是完整的资源集合,而实际上有可能只是返回了第一页的资源。

为了兼容旧的逻辑,只能将分页信息设为非必要字段。

为了支持 List 方法的分页功能,API 应当:

  • List 方法的请求消息中定义 string 类型的 page_token 字段。客户端通过该字段来获取指定某页的资源
  • List 方法的请求消息中定义 int32 类型的 page_size 字段。客户端通过该字段来指定每页返回的最大数据量。对于服务端来说,可能会为客户端传入的 page_size 大小设置一个上限。如果 page_size 的值为0,则由服务端来决定需要返回多少数据
  • List 方法的响应消息中定义 string 类型的 next_page_token 字段。客户端通过该字段来获取下一页的资源,如果 next_page_token 的值为 "",则表示没有下一页的资源

接口定义示例:

1
2
3
4
5
6
7
8
9
10
11
12
rpc ListBooks(ListBooksRequest) returns (ListBooksResponse);

message ListBooksRequest {
string parent = 1;
int32 page_size = 2;
string page_token = 3;
}

message ListBooksResponse {
repeated Book books = 1;
string next_page_token = 2;
}

所有分页的实现可能会在响应消息中增加一个 int32 类型的 total_size 字段来表示资源的总个数。

查询子资源集合

有时候客户端可能会希望有一个 API 能够在多个子资源集合间查询资源。例如,某个图书馆 API 有一个书架的资源集合,每个书架包含一个书籍资源集合,客户端可能会希望在多个书架之间搜索某本书。这种情况建议对子资源使用标准的 List 方法,并使用通配符 - 来表示父资源集合,例如:

1
GET https://library.googleapis.com/v1/shelves/-/books?filter=xxx

从子资源集合获取唯一资源

有时候某个子资源集合下的资源在全局父资源下有着唯一的标识符。常规的做法是需要先知道该资源的父资源的名称然后才能获取该资源,这种情况建议对该资源使用标准的 Get 方法,并使用通配符 - 来表示父资源集合。例如,如果某本书在所有的书架上有着唯一的标识符,那么可以使用如下的请求来获取该本书籍:

1
GET https://library.googleapis.com/v1/shelves/-/books/{id}

另外,该接口返回的资源名称必须是完整的,其父资源名称必须返回实际的值,而不是 -,例如上述的请求应该返回资源名称 shelves/shelf713/books/book8141,而不是 shelves/-/books/book8141

排序顺序

如果某个 API 允许客户端指定资源的排序顺序,则请求消息体中应该包含一个 order_by 字段:

1
string order_by = ...;

order_by 应当遵循 SQL 语法:即使用逗号分割多个字段,例如 "foo,bar"。默认的排序规则是升序,如果需要降序排序,则应当在字段名后增加 " desc" 后缀,例如 "foo desc,bar"

同时,字段间额外的空格是无关紧要的,例如 "foo,bar desc"" foo , bar desc " 是等价的。

请求验证

如果某个 API 有副作用并且需要在执行前验证请求是否有效,则请求消息体中应该包含一个 validate_only 字段:

1
bool validate_only = ...;

如果设置为 true,则该 API 收到请求时仅进行验证而不会实际执行。

如果验证通过,则必须返回 google.rpc.Code.OK 给客户端,并且对于任何有着相同参数的请求都不应该返回 google.rpc.Code.INVALID_ARGUMENT 错误。不过该 API 依然有可能返回其他错误例如 google.rpc.Code.ALREADY_EXISTS

请求去重

对于网络 API 来说首选幂等的 API,因为当发生网络异常时可以安全的重试。不过某些 API 无法轻易的实现幂等,例如创建一个资源,但是又需要避免重复的请求。这种情况下请求消息体应该包含一个唯一的 ID,例如 UUID 使得服务端能够通过这个唯一的 ID 进行去重:

1
2
3
// 服务端根据此唯一的 ID 进行去重
// 该字段应当命名为 request_id
string request_id = ...;

当服务端监测到重复的请求时,服务端应当返回之前成功的相同请求的结果给客户端,因为客户端大概率没有收到之前的返回结果。

枚举默认值

每一个枚举的定义都必须以0作为起始值,当枚举值未明确时应当使用该0值,同时 API 文档必须标注如何处理0值。

枚举0值应当命名为 ENUM_TYPE_UNSPECIFIED。如果 API 有着通用的默认行为,则枚举值未定义时应当使用0值,否则0值应当被拒绝并返回 INVALID_ARGUMENT 错误。

枚举值示例:

1
2
3
4
5
6
7
8
9
10
11
12
enum Isolation {
// 未定义
ISOLATION_UNSPECIFIED = 0;
// 从快照读取数据,如果当前所有的读写操作与并发执行中的事务无法做到逻辑串行执行则发生冲突
SERIALIZABLE = 1;
// 从快照读取数据,如果当前有并发执行中的事务写入到相同的行则发生冲突
SNAPSHOT = 2;
...
}

// 当隔离级别未定义时,服务端可能会采用 SNAPSHOT 或者更优的隔离级别
Isolation level = 1;

在某些情况下可能使用某个惯用名表示枚举0值,例如 google.rpc.Code.OK 是错误码不存在时的默认值,它在语义上等价于 UNSPECIFIED

语法规则

在某些场景下,需要为特定的数据格式定义简单的语法,例如允许接受的文本输入。为了在各 API 间提供一致的开发体验,API 设计者必须使用如下的 Extended Backus-Naur Form (EBNF) 的变种来定义语法:

1
2
3
4
5
6
7
Production  = name "=" [ Expression ] ";" ;
Expression = Alternative { "|" Alternative } ;
Alternative = Term { Term } ;
Term = name | TOKEN | Group | Option | Repetition ;
Group = "(" Expression ")" ;
Option = "[" Expression "]" ;
Repetition = "{" Expression "}" ;

整数类型

设计 API 时应当避免使用无符号整型例如 uint32fixed32,因为某些重要的编程语言或者系统不能很好的支持无符号整型,例如 JavaJavaScriptOpenAPI,并且它们很大可能会造成整型溢出错误。另一个问题是不同的 API 可能将同一个值各自解析为不同的无符号整型或者带符号整型。

在某些场景下类型为带符号整型的字段值如果是负数则没有意义,例如大小,超时时间等等;API 设计者可能会用-1(并且只有-1)来表示特殊的含义,例如文件结束符(EOF),无限的超时时间,无限的配额或者未知的年龄等等。这种用法必须明确的在接口文档中标注以避免迷惑。同时 API 设计者也应当标注当整型数值为0时的系统行为,如果它不是非常直白明了的话。

局部响应

在某些情况下,客户端可能只希望获取资源的部分属性。Google API 通过 FieldMask 来支持这一场景。

对于任意 Google APIREST 接口,客户端都可以传入额外的 $fields 参数来表明需要获取哪些字段:

1
2
GET https://library.googleapis.com/v1/shelves?$fields=shelves.name
GET https://library.googleapis.com/v1/shelves/123?$fields=name

资源视图

为了减少网络传输,可以允许客户端指定需要返回资源的某个视图而不是完整的资源数据,这需要在请求消息体中增加一个额外的参数,该参数要求:

  • 应该是 enum 类型
  • 必须命名为 view

枚举类型的每一个值都定义了应当返回资源的哪部分数据。具体返回哪部分数据由实现决定并且应当在文档中标注。

接口定义示例:

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
package google.example.library.v1;

service Library {
rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {
option (google.api.http) = {
get: "/v1/{name=shelves/*}/books"
}
};
}

enum BookView {
// 未定义,等同于 BASIC
BOOK_VIEW_UNSPECIFIED = 0;

// 默认视图,仅返回作者,标题,ISBN 和书籍 ID
BASIC = 1;

// 完整视图,返回书籍的全部数据,包括书籍的内容
FULL = 2;
}

message ListBooksRequest {
string name = 1;

// 指定需要返回书籍的哪个视图
BookView view = 2;
}

客户端就可以通过如下的方式调用:

1
GET https://library.googleapis.com/v1/shelves/shelf1/books?view=BASIC

ETags

ETag 用于客户端进行条件请求,例如客户端获取了某个资源后将其缓存,下次再请求相同的资源时附带上之前服务端返回的 ETag,如果服务端判断 ETag 没有发生变化则无需返回完整的资源实体。为了支持 ETag,应当在资源定义时添加 etag 字段,同时其语义应当同 ETag 的常见用法保持一致。

ETag 支持强校验和弱校验,弱校验时 ETag 的值需要添加前缀 W/。强校验模式下,如果两个资源有着相同的 ETag 则说明这两个资源的每个字节都是相同的,而且有着相同的额外字段(例如 Content-Type)。这表示强校验模式下获取的多个资源局部响应数据可以组合成为一个完整的资源数据。

相反的,弱校验模式下两个资源有着相同的 ETag 并不能说明两个资源的每一个字节都相同,因此不适合用于缓存字节范围的请求响应。

强弱 ETag 示例:

1
2
3
4
// 强 ETag,包括引号
"1a2f3e4d5b6c7c"
// 弱 ETag,包括前缀和引号
W/"1a2b3c4d5ef"

需要注意的是,引号也是 ETag 的一部分,所以如果 ETagJSON 中表示需要对引号进行转义:

1
2
3
4
// 强
{ "etag": "\"1a2f3e4d5b6c7c\"", "name": "...", ... }
// 弱
{ "etag": "W/\"1a2b3c4d5ef\"", "name": "...", ... }

输出字段

一个资源的某些字段可能不允许客户端设置而只能由服务端返回,这些字段应当需要额外标注。

需要注意的是如果仅作为输出的字段在请求消息体中设置了,或者包含在了 google.protobuf.FieldMask 中,服务端也必须接受该请求而不是返回错误,只不过服务端在处理时需要忽略这些输出字段。之所以要这么做是因为客户端经常会复用某个接口返回的资源,将其作为另一个接口的输入,例如客户端可能会先请求获取一个 Book 资源,将其修改后再调用 UPDATE 方法。如果服务端对输出字段进行校验,则要求客户端进行额外的处理来删除这些输出字段。

接口示例如下:

1
2
3
4
5
6
import "google/api/field_behavior.proto";

message Book {
string name = 1;
Timestamp create_time = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
}

单例资源

当只有一个资源存在于某个父资源下(或服务,如果没有父资源的话)时,则可以使用单例资源。

标准方法的 CreateDelete 方法对单例资源无效,单例资源一般随着父资源的创建而创建,
随着父资源的删除而删除。单例资源必须通过标准方法的 GetUpdate 方法来访问,以及其他适合的自定义方法。

例如,每一个 User 资源可以有一个单例的 Settings 资源:

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
rpc GetSettings(GetSettingsRequest) returns (Settings) {
option (google.api.http) = {
get: "/v1/{name=users/*/settings}"
};
}

rpc UpdateSettings(UpdateSettingsRequest) returns (Settings) {
option (google.api.http) = {
patch: "/v1/{settings.name=users/*/settings}"
body: "settings"
};
}

[...]

message Settings {
string name = 1;
// 省略其他字段
}

message GetSettingsRequest {
string name = 1;
}

message UpdateSettingsRequest {
Settings settings = 1;
// 支持局部更新
FieldMask update_mask = 2;
}

流式半关闭

对于任何的双向或者客户端流式 API,服务端应该依赖由 RPC 系统提供、客户端发起的半关闭来完成客户端流。没有必要额外的定义一个完成的消息。

任何在客户端发起半关闭前想要发送的消息都必须定义为请求消息体的一部分。

Domain-scoped 名称

domain-scoped 名称指的是添加了域名前缀的实体名称,用于避免不同服务的命名冲突。Google APIsKubernetes APIs 大量使用了 domain-scoped 名称:

  • ProtobufAny 类型:type.googleapis.com/google.protobuf.Any
  • Stackdriver 的指标类型:compute.googleapis.com/instance/cpu/utilization
  • 标签的键:cloud.googleapis.com/location
  • KubernetesAPI 版本号:networking.k8s.io/v1
  • x-kubernetes-group-version-kindOpenAPI 扩展中的 kind 字段

布尔值 vs. 枚举 vs. 字符串

设计 API 时有时候会遇到需要能够启用或者禁用某个功能的场景,从实现上说可以增加一个 boolenum 或者 string 类型的字段来控制,具体选择哪种类型可以遵循如下规则:

  • 如果确定只有两种状态且不希望在未来扩展时使用 bool,例如 enable_tracing 或者 enable_pretty_print
  • 如果希望设计更为灵活但是又不希望改动太频繁时使用 enum,一个评估的准则是一旦 enum 的值确定了,那么一年内只会改动一次或者更低频,例如 enum TlsVersion 或者 enum HttpVersion
  • string 有着最大的灵活性,适用于可能会频繁修改的场景,其对应的值必须清晰的在文档中标注,例如:

数据保留

对于某些服务而言,用户数据非常重要,如果用户数据不小心被软件 bug 或者人为错误删除,在缺少数据保留策略和撤销删除功能的情况下,可能对业务造成灾难性的影响。

一般而言,建议为 API 服务设置如下的数据保留策略:

  • 对于用户的元数据,用户设置等其他重要的数据,设置30天的数据保留期。例如监控指标,项目的元数据和服务定义
  • 对于大容量的用户数据,应该设置7天的数据保留期。例如对象存储和数据库表
  • 对于临时的状态数据或者昂贵的存储数据,如果可行的话应该设置1天的数据保留期。例如 memcacheRedis 内存中的数据

在数据保留期内,可以执行撤销删除的操作从而不会造成数据丢失。

大型传输载荷

网络 API 依赖分层的网络架构来传输数据,大多数的网络协议层对输入和输出的数据量设置了上限,一般而言,32 MB 是大多数系统中常用的大小上限。

如果某个 API 涉及的传输载荷超过 10 MB,则需要选择合适的策略以确保易用性和未来的扩展的需求。对于 Google APIs 来说,建议使用流式传输或者媒体上传/下载的方式来处理大型载荷,在流式传输下,服务端能够以增量同步的方式处理大量数据,例如 Cloud Spanner API。在媒体传输下,大量的数据流先流入到大型的存储系统中,例如 Google Cloud Storage,然后服务端可以异步的从存储系统中读取数据并处理,例如 Google Drive API

可选的基本类型字段

Protocol Buffers v3 支持 optional 基本类型字段,在语义上等同于众多编程语言中的 nullable 类型,它可以用于区分空值和未设置的值。

在实践中开发人员难以正确的处理可选字段,大多数的 JSON HTTP 客户端类库,包括 Google API Client Libraries,无法正确区分 proto3int32google.protobuf.Int32Value 以及 optional int32。如果存在一个方案更清晰而且也不需要可选的基本类型字段,则优先选择该方案。如果不使用可选的基本类型字段会造成复杂度上升或者含义不清晰,则选择可选的基本类型字段。但是不允许可选字段搭配包装类型使用。一般而言,从简洁和一致性考虑,API 设计者应当尽量选择基本类型字段,例如 int32

版本控制

Google APIs 借助版本控制来解决后向兼容问题。

所有的 Google API 接口都必须包含一个主版本号,这个主版本号会附加在 protobuf 包的最后,以及包含在 REST APIsURI 的第一个部分中。如果 API 要引入一个与当前版本不兼容的变更,例如删除或者重命名某个字段,则必须增加主版本号,从而避免引用了当前版本的用户代码受到影响。

所有 API 的新主版本不允许依赖同 API 的前一个主版本。一个 API 本身可能会依赖其他 API,这要求调用方知晓被依赖的 API 的版本稳定性风险。在这种情况下,一个稳定版本的 API 必须只依赖同样是稳定版本的其他 API

同一个 API 的不同版本在同一个客户端应用内必须能在一段合理的过渡时期内同时生效。这个过渡时期保障了客户端应用升级到新的 API 版本的平滑过渡。同样的,老版本的 API 也必须在废弃并最终停用之前留有足够的过渡时间。

对于会发布 alpha 或者 beta 版本的 API 来说,必须将 alpha 或者 beta 附加在主版本号之后,并且使用如下其一的策略:

  • 基于渠道的版本控制(推荐)
  • 基于发布的版本控制
  • 基于可见性的版本控制

基于渠道的版本控制

stability channel 是在某个稳定性级别下长期进行更新的版本。每个主版本号下的每个稳定性级别最多只有一个版本。因此,在这个策略下,每个主版本最多只有三个可用的版本:alphabeta,以及 stable

alphabeta 版本必须将稳定性级别附加到主版本号后,而 stable 则不需要也不允许。例如,v1 可用作为 stable 版本的版本号,但是 v1betav1alpha 不是。类似的,v1beta 或者 v1alpha 可用作为对应的 betaalpha 版本,但是 v1 不行。每个版本下会对新功能进行就地更新而不会修改版本号。

beta 版本的功能必须是 stable 版本的功能的超集,同时 alpha 版本的功能必须是 beta 版本的功能的超集。

对于任何版本的 API 来说,其中的元素(字段,消息体,RPC 方法等)都有可能被标记为废弃:

1
2
3
4
5
6
// Represents a scroll. Books are preferred over scrolls.
message Scroll {
option deprecated = true;

// ...
}

废弃的 API 功能不允许从 alpha 版本继续保留到 beta 版本,也不允许从 beta 版本保留到 stable 版本。也就是说某个功能不能在任何版本中预先废弃。

beta 版本的功能可以在废弃后经过合理的时间后删除,建议是180天。对于只存在于 alpha 版本的功能,不一定会标记为废弃,并且删除时也不会通知。

基于发布的版本控制

在该策略下,alpha 或者 beta 版本的功能在合并到 stable 版本之前只会在有限的时间内可用。因此,一个 API 在每个稳定性级别下可能有任意数量的版本发布。

基于渠道的版本控制和基于发布的版本控制都会就地更新 stable 版本。

alphabeta 版本发布时需要在 alpha 或者 beta 之后附加一个递增版本号,例如 v1beta1 或者 v1alpha5API 应当在文档中记录这些版本的时间顺序。

每个 alpha 或者 beta 版本都有可能就地进行后向兼容的更新。对于 beta 版本来说,如果发布了后向不兼容的版本则应当修改 beta 后的版本号,然后发布新的版本。例如,如果当前版本是 v1beta1,则新版本为 v1beta2

alphabeta 版本中的功能合并到 stable 版本之后就可以终止 alpha 或者 beta 版本。alpha 版本可能会在任一时刻终止,但是 beta 版本在终止前应当给用户留有足够的过渡期,建议是180天。

基于可见性的版本控制

API 可见性Google API 基础架构提供的一项高级功能。它允许 API 发布者将一个 API 对外暴露出多个不同的外部 API 视图,每个视图关联一个 API 可见性的标签,例如:

1
2
3
4
5
6
7
8
9
import "google/api/visibility.proto";

message Resource {
string name = 1;

// 预览功能,勿在生产环境使用
string display_name = 2
[(google.api.field_visibility).restriction = "PREVIEW"];
}

可见性标签是一个区分大小写的字符串,可以绑定到任意 API 元素上。一般来说,可见性标签应当始终使用全大写字母。所有的 API 元素默认绑定 PUBLIC 的可见性标签,除非显式的声明了可见性。

每个可见性标签本质上是一个允许访问的列表,API 生产者需要授权给 API 消费者合适的可见性标签才能使用 API。换句话说,可见性标签类似于 APIACLAccess Control List)。

每个 API 元素可以绑定多个可见性标签,各可见性标签之间用逗号分割(例如 PREVIEW,TRUSTED_TESTER)。多个可见性标签之间是逻辑或的关系,API 消费者只要授权了其中一个可见性标签就可以使用 API

一个 API 请求只能使用一个可见性标签,默认使用的是授权给当前 API 消费者的可见性标签。客户端可以显式的指定需要用哪个可见性标签:

1
2
3
4
GET /v1/projects/my-project/topics HTTP/1.1
Host: pubsub.googleapis.com
Authorization: Bearer y29....
X-Goog-Visibilities: PREVIEW

API 生产者可以借助可见性标签来实现版本控制,例如 INTERNALPREVIEWAPI 的新功能从 INTERNAL 可见性标签开始,然后升级到 PREVIEW 可见性标签。当功能稳定可用后,就删除所有的可见性标签,即等同于默认的可见性标签 PUBLIC

总体来说,API 的可见性比 API 版本号更容易实现增量的功能迭代,不过这要求比较成熟的 API 基础架构的支持。Google Cloud APIs 经常使用 API 可见性用于预览功能。

兼容性

这里的兼容性讨论的是对于 API 使用者的影响,API 生产者应当自身知晓为了实现兼容性需要哪方面的工作。

总的来说,API 的小版本更新或者补丁更新不应该对客户端造成兼容性问题。可能的不兼容问题包括:

  • 源代码兼容性:针对1.0版本编写的代码升级到1.1版本后编译失败
  • 二进制文件兼容性:针对1.0版本编译生成的二进制文件链接到1.1版本后运行失败
  • 通信兼容性:针对1.0版本编写的应用程序无法和运行1.1版本的服务端通信
  • 语义兼容性:针对1.0版本编写的应用程序升级到1.1版本后能够运行,但是存在不可预知的结果

从另一个角度来说,只要主版本号一致,运行着旧版本的客户端程序就能够和运行着新版本的服务端结合使用,并且客户端程序也能轻易的升级小版本。

后向兼容的修改

向 API 服务添加新的接口

从协议角度来看,添加新的接口始终是安全的。需要注意的是有可能新添加的接口名称已经被客户端代码占用了。如果当前新添加的接口与当前存在的接口完全不同,则基本不用担心;但是如果新添加的接口是当前某个存在的接口的简化版本,则有可能和客户端自定义实现的接口冲突。

向 API 接口添加新的方法

除非新添加的方法和客户端自动生成的代码中的某个方法冲突,否则这也是安全的。

例如当前已经存在了一个 GetFoo 方法,C# 的代码生成器会同时生成一个 GetFooAsync 的方法,如果此时再添加一个 GetFooAsync 方法,则会造成冲突。

向已有的方法添加 HTTP 绑定

假设绑定 HTTP 没有任何歧义,那么让服务端响应之前拒绝的 URL 就是安全的。这个操作可能在将某个已有的操作映射到某个新资源时发生。

向请求消息体添加新的字段

只要服务端在新版本的代码中处理未传入的新字段的逻辑和老版本代码中的逻辑一致,那么添加新的字段就是安全的。

一个最可能出错的场景是添加分页相关的字段:如果 v1.0 版本的代码中没有分页功能,那么也不能将分页功能添加到 v1.1 版本中,除非 page_size 的默认值是无限大(但这通常不是个好的设计)。否则的话依赖了 v1.0 版本的客户端期望一次请求获取所有的数据,但实际上可能只获取了第一页的数据。

向响应消息体添加新的字段

对于非资源类的响应消息体来说(例如 ListBooksResponse)添加一个字段都不会造成后向兼容性问题,只要新添加的字段不会影响其他字段的行为即可。消息体中之前暴露的字段应当继续以相同的语义保留,即使可能存在冗余。

例如,1.0版本的响应消息体中有一个字段是 contained_duplicates 表示返回的结果存在重复值并已经进行了去重。在1.1版本中新增了 duplicate_count 字段表示重复的数据数量,虽然原有的 contained_duplicates 已经冗余了但是该字段也必须保留。

向枚举添加新值

如果枚举是在请求消息体中使用,那么向枚举添加新值是安全的。因为客户端并不关心它们用不到的值。

对于在资源消息体或者响应消息体中的枚举,默认的假设是客户端需要能够处理未知的枚举值。不过,API 生产者应当知晓客户端如何正确的处理新的枚举值不是一件简单的事,因此必须在文档中标注如果客户端遇到未知的枚举值时期望的行为是什么。

添加新的输出字段

如果一个字段只可能会由服务端设置并仅作为输出使用,那么添加这个字段也是安全的。服务端可能会校验客户端发送的消息体中的字段,但是如果新添加的输出字段在请求消息体中不存在,服务端不允许抛出异常。

后向不兼容的修改

移除或者重命名服务,字段,方法或者枚举值

一般来说,移除或者重命名某个客户端代码可能引用的内容都是一次后向不兼容的修改,必须升级主版本号才能更新。对于某些编程语言来说如果引用了旧的名称则会造成编译问题(例如 C#Java)或者对于另一些编程语言来说造成运行时异常或者数据丢失。

更改 HTTP 映射

这里的更改指的是删除然后添加。例如,假设你想将某个已经存在的方法的 HTTP 映射改为 PATCH,而目前暴露的 HTTP 方法是 PUT,你可以添加一个新的 HTTP 映射,但是不能删除原有的 HTTP 映射。

更改字段类型

即使更改后的字段类型和当前的传输格式兼容,也可能造成客户端生成的代码不兼容,因此也必须升级主版本号才能更新。对于静态编译型编程语言来说,这很容易引入编译问题。

更改资源的命名格式

不允许修改资源的名称,这也意味着资源集合的名称也不允许修改。

如果客户端能够在 v2.0 版本中访问 v1.0 版本中创建的资源(或者反过来),那么该资源在两个版本中就应当使用相同的资源名称。

另外,有效的资源名称集也不能修改,因为:

  • 如果集合变小,那么之前成功的请求就有可能失败
  • 如果集合变大,那么客户端基于之前关于资源名称的假设可能失效。因为客户端很可能会根据资源名称所允许的字符和长度将其存储在其他地方,以及构建自己的资源名称验证规则

更改现有请求的可见行为

客户端经常会依赖 API 的行为和语义,即使这些行为没有明确的表示支持或者在文档中说明。因此,在大多数情况下更改 API 的行为会造成后向不兼容问题。如果你的 API 的行为不是非常的隐秘,你都应该假设用户已经识别出 API 的行为并依赖它。

因此,加密分页功能中的页码信息就很有必要(即使该数据没有什么意义),从而防止用户自行创建页码信息,然后当页码行为更改时遭遇后向不兼容问题。

更改 HTTP 定义中的 URL 格式

除了资源名称之外,还有两个关于 URL 格式的修改:

  • 自定义方法的名称:虽然自定义方法名称不是资源名称的一部分,但依然是 REST 请求路径的一部分,虽然修改 HTTP 的自定义方法名称不会破坏 gRPC 客户端,但依然要假设存在 REST 客户端的用户
  • 资源参数名称:例如从 v1/shelves/{shelf}/books/{book} 修改为 v1/shelves/{shelf_id}/books/{book_id} 不会影响资源名称,但是会影响客户端自动生成的代码

向资源消息体添加读/写字段

客户端经常会执行先读,然后修改,最后写入的一整套操作,大多数情况下如果某个字段客户端用不到就不会给它赋值。虽然服务端可以采取缺失值的字段就不执行写入的措施,但不适用于基本类型的字段(包括 stringbytes),因为基本类型默认值的存在造成无法区分出是客户端主动设置 int32 类型的字段值为0还是没有设置值从而使用默认值0。

总结

虽然 Google 的这篇 API 设计主要是面向资源的设计,但同时也针对其不足提出了改进的方案。不管是 RESTful 还是非 RESTful 的接口设计,都只是一种规范,有各自适合的场景没有孰优孰劣,统一的规范胜过生搬硬套。

参考

AWS EC2 的监控页面默认没有显示内存使用率,需要搭配 CloudWatch 配置使用。

由于需要在 EC2 上安装 CloudWatch agent 来上报监控数据到 CloudWatch,所以需要先为 EC2 配置 IAM 角色来授予需要的权限。创建 IAM 角色时,在第一步的 Trusted entity type 选择 AWS serviceUse case 选择 EC2;在第二步的 Permissions policies 添加 CloudWatchAgentServerPolicy 即可。更多细节可参考 Create IAM roles and users for use with CloudWatch agent

接着,在 Download and configure the CloudWatch agent using the command line 中根据实际 EC2 的操作系统下载和安装 CloudWatch agent,这里以 ARM64Ubuntu 系统为例:

1
2
wget https://s3.amazonaws.com/amazoncloudwatch-agent/ubuntu/arm64/latest/amazon-cloudwatch-agent.deb
sudo dpkg -i -E ./amazon-cloudwatch-agent.deb

然后,为 CloudWatch agent 创建一个配置文件,例如 cloudwatch.json,写入如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"metrics":{
"metrics_collected":{
"mem":{
"measurement":[
"mem_used_percent"
],
"metrics_collection_interval":60
}
},
"append_dimensions": {
"InstanceId": "${aws:InstanceId}"
}
}
}

这表示每隔60秒收集一次内存使用率,接着启动 CloudWatch agent

1
sudo amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:$HOME/cloudwatch.json -s

可以通过 amazon-cloudwatch-agent-ctl -a status 来查看 CloudWatch agent 的状态:

1
2
3
4
5
6
7
8
9
{
"status": "running",
"starttime": "2022-10-09T13:23:11+00:00",
"configstatus": "configured",
"cwoc_status": "stopped",
"cwoc_starttime": "",
"cwoc_configstatus": "not configured",
"version": "1.247355.0b252062"
}

此时 CloudWatch agent 的状态为运行中。

如果一切正常,那么在 AWS 控制台中 CloudWatchAll metrics 下会多出一项 CWAgent(如果原来没有添加过的话):

alt

点击进入后选择相应的 EC2,点击 Add to graph

alt

在当前页面上方就会显示对应的内存使用率的监控:

alt

之后也可以创建一个 Dashboard,将这个监控加入到自定义的 Dashboard 中。

如果在 AWS 控制台没有看到 CWAgent 项目,那么可以查看 EC2CloudWatch agent 的日志是否有异常,日志保存在 /opt/aws/amazon-cloudwatch-agent/logs/amazon-cloudwatch-agent.log。例如,如果忘记为 EC2 配置 IAM 角色,同时 EC2 上又没有其他的权限访问信息,CloudWatch agent 就无法上报监控数据,会提示如下类似的异常:

1
2
3
4
5
2022-10-09T13:27:36Z E! WriteToCloudWatch failure, err:  NoCredentialProviders: no valid providers in chain
caused by: EnvAccessKeyNotFound: failed to find credentials in the environment.
SharedCredsLoad: failed to load profile, .
EC2RoleRequestError: no EC2 instance role found
caused by: EC2MetadataError: failed to make EC2Metadata request

最后,如果想要添加更多的监控指标,可以参考 Metrics collected by the CloudWatch agent 添加相应的指标。

参考:

挂载磁盘

在创建 AWSEC2 实例时如果添加了额外的磁盘则需要手动挂载到系统中。

首先运行 lsblk 来查看可用的块设备:

1
2
3
4
5
6
7
8
9
10
NAME         MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
loop0 7:0 0 22.2M 1 loop /snap/amazon-ssm-agent/5657
loop1 7:1 0 49M 1 loop /snap/core18/2406
loop2 7:2 0 57.8M 1 loop /snap/core20/1498
loop3 7:3 0 38.7M 1 loop /snap/snapd/15909
loop4 7:4 0 71.8M 1 loop /snap/lxd/22927
nvme1n1 259:0 0 8G 0 disk
nvme0n1 259:1 0 8G 0 disk
├─nvme0n1p1 259:2 0 7.9G 0 part /
└─nvme0n1p15 259:3 0 99M 0 part /boot/efi

其中的 nvme1n1 是本次新添加的磁盘,目前还未挂载到系统中,而 nvme0n1 则是根设备并且有两个分区。

lsblk 的输出结果会移除设备路径中的 /dev/ 前缀,所以设备 nvme1n1 的完整路径为 /dev/nvme1n1

然后,我们需要在 nvme1n1 之上创建文件系统才能使用,执行 sudo file -s /dev/nvme1n1 显示 nvme1n1 还没有文件系统:

1
/dev/nvme1n1: data

而如果我们查看 sudo file -s /dev/nvme0n1 则会显示:

1
/dev/nvme0n1: DOS/MBR boot sector; partition 1 : ID=0xee, start-CHS (0x0,0,2), end-CHS (0x3ff,255,63), startsector 1, 16777215 sectors, extended partition table (last)

执行 sudo mkfs -t xfs /dev/nvme1n1 来为 nvme1n1 创建文件系统,其中 xfs 表示文件系统的类型:

1
2
3
4
5
6
7
8
9
10
meta-data=/dev/nvme1n1           isize=512    agcount=8, agsize=262144 blks
= sectsz=512 attr=2, projid32bit=1
= crc=1 finobt=1, sparse=1, rmapbt=0
= reflink=1 bigtime=0 inobtcount=0
data = bsize=4096 blocks=2097152, imaxpct=25
= sunit=1 swidth=1 blks
naming =version 2 bsize=4096 ascii-ci=0, ftype=1
log =internal log bsize=4096 blocks=2560, version=2
= sectsz=512 sunit=1 blks, lazy-count=1
realtime =none extsz=4096 blocks=0, rtextents=0

接着,我们就可以创建一个文件夹用来挂载磁盘,例如 sudo mkdir /data。最后将 /dev/nvme1n1 挂载到 /data 上:

1
sudo mount /dev/nvme1n1 /data

此时如果查看 df -h 就会包含 /dev/nvme1n1

1
2
3
4
5
6
7
8
Filesystem       Size  Used Avail Use% Mounted on
/dev/root 7.6G 1.6G 6.1G 21% /
tmpfs 926M 0 926M 0% /dev/shm
tmpfs 371M 1000K 370M 1% /run
tmpfs 5.0M 0 5.0M 0% /run/lock
/dev/nvme0n1p15 98M 5.1M 93M 6% /boot/efi
tmpfs 186M 4.0K 186M 1% /run/user/1000
/dev/nvme1n1 8.0G 90M 8.0G 2% /data

系统启动自动挂载磁盘

当前的磁盘挂载信息会在系统启动后丢失,如果希望系统启动后自动挂载磁盘则需要向 /etc/fstab 中添加一条记录。

安全起见先备份下 /etc/fstab

1
sudo cp /etc/fstab /etc/fstab.orig

然后运行 sudo blkid 来查看设备 /dev/nvme1n1UUID

1
2
3
4
5
6
7
8
/dev/nvme0n1p1: LABEL="cloudimg-rootfs" UUID="15ea47e1-ef7d-4928-9dbe-ffaf0e743653" BLOCK_SIZE="4096" TYPE="ext4" PARTUUID="1957f80e-a338-441c-a0e0-ed1575eefda3"
/dev/nvme0n1p15: LABEL_FATBOOT="UEFI" LABEL="UEFI" UUID="68E7-1A63" BLOCK_SIZE="512" TYPE="vfat" PARTUUID="1eeb08ab-0afd-4477-bd53-4389a42db8f6"
/dev/loop1: TYPE="squashfs"
/dev/loop4: TYPE="squashfs"
/dev/loop2: TYPE="squashfs"
/dev/loop0: TYPE="squashfs"
/dev/nvme1n1: UUID="aa81c000-325c-40b7-ba4c-598ec2c824e0" BLOCK_SIZE="512" TYPE="xfs"
/dev/loop3: TYPE="squashfs"

最后向 /etc/fstab 添加一条记录:

1
UUID=aa81c000-325c-40b7-ba4c-598ec2c824e0  /data  xfs  defaults,nofail  0  2

可以通过先取消挂载 /datasudo umount /data 然后再执行 sudo mount -a 来验证自动挂载是否生效。

参考

介绍

EKSAmazon Elastic Kubernetes Service)是 AWS 提供的 Kubernetes 服务,它能大大减轻创建和维护 Kubernetes 集群的负担。

创建 EKS 集群

有两种方式来创建 EKS 集群,一种是使用本地的 eksctl 程序;另一种是通过 AWS 的管理后台(AWS Management Console),这里选择通过 AWS 的管理后台来创建 EKS 集群。

创建 Cluster service role

创建 EKS 集群时需要绑定一个 IAM 角色,因为 Kubernetescontrol plane 需要管理集群内的资源,所以需要有相应的操作权限。

首先进入 IAM 控制台,选择左侧 Access management 下的 Roles,点击 Create role。在 Trusted entity type 下选择 AWS service,然后在 Use cases for other AWS services 下选择 EKS,接着选择 EKS - Cluster 并点击 Next。在 Add permissions 这步直接点击 Next。在最后一步设置所创建的角色的名字,如 eksClusterRole,最后点击 Create role 创建角色。

创建集群

我们通过 AWS 管理后台中的 Amazon Elastic Kubernetes Service 界面来创建集群,第一步的 Configure cluster 主要设置集群的名称,如 my-cluster,以及绑定在之前步骤中所创建的 Cluster service role。第二步的 Specify networking 这里基本都保持默认,只是将 Cluster endpoint access 设置为 Public and private。第三步的 Configure logging 可以暂时不开启日志监控。最后在第四步的 Review and create 点击 Create 创建集群。

创建 Node group

当集群的状态变为 Active 后就表示集群创建成功,不过此时集群中还没有任何 Node,所以系统级别的 Pod 还无法正常工作,比如在集群详情的 Resources 下查看某个 corednsPod 会显示 FailedScheduling,因为 no nodes available to schedule pods

我们需要创建 Node group 来为系统添加可用的 Node

创建 Node IAM role

在创建 Node group 前,需要创建一个 Node IAM role。因为集群中的 Node 内部会运行着一个叫做 kubelet 的程序,它负责和集群的 control plane 进行通信,例如将当前 Node 注册到集群中,而某些操作需要调用 AWS 的接口,所以和 Cluster service role 类似,也需要绑定相应的权限。

这里同样也是通过 IAM 控制台 来创建角色,在 Trusted entity type 下选择 AWS service,在 Use case 下选择 EC2,然后点击 Next。在第二步的 Add permissions 需要添加 AmazonEKSWorkerNodePolicyAmazonEC2ContainerRegistryReadOnlyAmazonEKS_CNI_Policy 三个权限,虽然文档中说不建议将 AmazonEKS_CNI_Policy 权限添加到 Node IAM role 上,不过这里作为示例教程将三个权限都绑定在了 Node IAM role 上。最后也是点击 Create role 创建角色。

创建 Node group

在集群详情的 Compute 下点击 Add node group 来创建 Node group,在第一步 Configure node group 中设置 node group 的名称及绑定在之前步骤中所创建的 Node IAM role。在第二步 Set compute and scaling configuration 里配置节点的类型和数量等信息,作为教程都采用默认配置。第三步 Specify networking 同样采用默认配置。最后在第四步的 Review and create 点击 Create 完成创建。

最后当所创建的 Node group 的状态变为 Active 以及该 Node group 下的 Node 的状态变为 Ready 时说明节点创建成功。此时再查看集群详情下 ResourcescorednsPod 已成功分配了 Node 运行。

连接 EKS 集群

日常需要通过 kubectl 管理集群,所以需要先在本地配置访问 EKS 集群的权限。kubectl 本质上是和 Kubernetes API server 打交道,而创建集群时 Cluster endpoint access 部分选择的是 Public and private,所以在这个场景下能够从公网管理 EKS 集群。

首先需要安装 AWS CLIkubectl。然后在本地通过 aws configure 来设置 AWS Access Key IDAWS Secret Access Key。根据 Enabling IAM user and role access to your cluster 的描述,创建集群的账户会自动授予集群的 system:masters 权限,本文是通过 AWS 的管理后台创建集群,当前登录的账户为 root,所以 aws configure 需要设置为 rootAWS Access Key IDAWS Secret Access Key

When you create an Amazon EKS cluster, the AWS Identity and Access Management (IAM) entity user or role, such as a federated user that creates the cluster, is automatically granted system:masters permissions in the cluster’s role-based access control (RBAC) configuration in the Amazon EKS control plane.

一般公司生产环境中的 AWS 是不会直接使用 root 账户登录的,而是创建 IAM 用户,由于这里是个人的 AWS 账户所以直接使用了 root,反之就需要使用 IAM 用户的 AWS Access Key IDAWS Secret Access Key。设置完成之后可以通过 aws sts get-caller-identity 来验证当前用户是否设置正确:

1
2
3
4
5
{
"UserId": "123",
"Account": "123",
"Arn": "arn:aws:iam::123:user"
}

然后运行 aws eks update-kubeconfig --region us-west-2 --name my-cluster 来更新本地的 kubeconfig,其中 us-west-2 需要修改为实际的 AWS Regionmy-cluster 需要修改为实际的集群名称。最后就可以通过 kubectl get all 来验证能否访问集群,如果没有问题就会输出如下类似内容:

1
2
NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
service/kubernetes ClusterIP 10.100.0.1 <none> 443/TCP 175m

设置其他用户的集群访问权限

创建集群的账户可能权限较高,所以需要单独给某些账户开通集群的访问权限。可以通过 kubectl describe -n kube-system configmap/aws-auth 查看当前的权限分配情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Name:         aws-auth
Namespace: kube-system
Labels: <none>
Annotations: <none>

Data
====
mapRoles:
----
- groups:
- system:bootstrappers
- system:nodes
rolearn: arn:aws:iam::123:role/AmazonEKSNodeRole
username: system:node:{{EC2PrivateDNSName}}


BinaryData
====

Events: <none>

假设我们需要授予某个 IAM 用户 eks system:masters 的角色,首先运行 kubectl edit -n kube-system configmap/aws-auth

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Please edit the object below. Lines beginning with a '#' will be ignored,
# and an empty file will abort the edit. If an error occurs while saving this file will be
# reopened with the relevant failures.
#
apiVersion: v1
data:
mapRoles: |
- groups:
- system:bootstrappers
- system:nodes
rolearn: arn:aws:iam::123:role/AmazonEKSNodeRole
username: system:node:{{EC2PrivateDNSName}}
mapUsers: |
- groups:
- system:masters
userarn: arn:aws:iam::123:user/eks
username: eks
kind: ConfigMap
metadata:
creationTimestamp: "2022-09-11T06:33:38Z"
name: aws-auth
namespace: kube-system
resourceVersion: "33231"
uid: 6b186686-548c-4c99-9f65-0381da1366a4

这里在 data 下新增了 mapUsers,授予用户 eks system:masters 的角色:

1
2
3
4
5
mapUsers: |
- groups:
- system:masters
userarn: arn:aws:iam::123:user/eks
username: eks

保存后可以通过 kubectl describe configmap -n kube-system aws-auth 验证改动是否生效。然后下载 aws-auth-cm.yaml

1
curl -o aws-auth-cm.yaml https://s3.us-west-2.amazonaws.com/amazon-eks/cloudformation/2020-10-29/aws-auth-cm.yaml

将其中的 <ARN of instance role (not instance profile)> 替换为之前创建的 Node IAM role,然后执行 kubectl apply -f aws-auth-cm.yaml 应用修改,执行 kubectl get nodes --watch 观察是否所有的节点的状态都变为了 Ready

接着删除本地的 ~/.kube/config 来验证权限是否生效。重新运行 aws configure 来设置某个 IAM 用户的信息,因为我们要重新执行 aws eks update-kubeconfig --region us-west-2 --name my-cluster 来生成新的 ~/.kube/config,这里要求当前 IAM 用户拥有 DescribeCluster 的权限,这个权限是 AWS 层面的资源访问权限,而不是 EKS 集群的权限,添加权限后可能需要等待几分钟才会生效。当重新生成了 ~/.kube/config 文件之后,就可以继续通过 kubectl get all 验证访问权限是否生效。

参考

Apple M1 Max 处理器上按照 Hello Minikube 进行 minikube 的入门教程,不过最后通过本地链接访问的时候出现了 connection reset。按照 这里 的描述需要将镜像由 echoserver:1.4 换成适用于 Apple M1 Maxechoserver-arm:1.8,即:

1
kubectl create deployment hello-arm --image=registry.k8s.io/echoserver-arm:1.8

不过帖子中也有人提到换了镜像之后依然无效,所以也不一定对所有人有用。

参考:

介绍

Buddy Memory Allocation 是内存分配算法的一种,它假定内存的大小为2N2^NN 为整数),并且总是以2的幂次方为单位分配或者释放内存。

算法

假设某个线程需要申请 m 字节内存,Buddy Memory Allocation 会先在当前所有的空闲空间中找到最小的空间满足2km2^k \geq m,如果2k2^k的一半依然大于等于 m,说明当前分配的空间过大,则继续将2k2^k对半分(分裂后的这两块内存区域就成为了互为兄弟关系(buddies)),不断重复上述操作,直到找到最小的 ppkp \leq k)满足2p1<m2p2^{p - 1} < m \leq 2^p

下图描述了从16字节中分配3字节的过程(假设系统总共只有16字节内存):

  1. 初始状态整个内存只有16字节,是可分配的最小空间;不过由于16字节的一半大于3字节,所以将16字节拆分为两个8字节
  2. 同理一个8字节的一半依然大于3字节,继续将其中一个8字节拆分为两个4字节
  3. 4字节的一半比3字节小,所以4字节就是可分配的最小内存空间

alt

当某个线程需要释放2k2^k的内存时,Buddy Memory Allocation 会尝试将这个内存空间及其相邻的兄弟空间一起合并得到一个2k+12^{k + 1}大小的空间,然后一直重复此操作,直到某块内存空间无法和其兄弟空间合并,无法合并的情况有三种:

  1. 当前分配的内存空间大小为整个内存空间的大小,所以也就没有兄弟空间
  2. 兄弟空间已全部分配
  3. 兄弟空间已局部分配

下图描述了从16字节中释放3字节的过程(假设系统总共只有16字节内存):

  1. 当前系统分配了一个2字节的空间和一个4字节的空间
  2. 此时需要回收被占用的2字节,由于它的兄弟空间没有被占用,所以两个2字节的空间合并为一个4字节的空间
  3. 合并后的4字节的空间的兄弟空间同样没有被占用,两个4字节的空间继续合并为1个8字节的空间
  4. 合并后的8字节的空间的兄弟空间存在部分占用,无法继续合并

alt

实现

内存

首先定义一个 Memory 类来表示内存,其内部使用一个 byte 数组来存储数据,数组的索引就是内存地址:

1
2
3
4
5
6
7
8
9
10
11
public class Memory {
private final byte[] memory;

public Memory(int size) {
if (size <= 0) {
throw new IllegalArgumentException("size should be greater than zero");
}

this.memory = new byte[size];
}
}

同时,Memory 类还支持 boolint32 类型的数据读写,从实现的简化考虑,bool 值的读写以一个 byte 为单位;而 int32 的读写以4个 byte 为单位:

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
// 在给定的地址设置布尔值,占据一字节
public void setBool(int address, boolean value) {
checkAddress(address);

this.memory[address] = value ? (byte) 1 : (byte) 0;
}

// 根据给定的地址读取布尔值,读取一字节
public boolean getBool(int address) {
checkAddress(address);

return this.memory[address] == (byte) 1;
}

// 在给定的地址设置 int32,占据4字节
public void setInt32(int address, int value) {
checkAddress(address);

byte[] bytes = ByteBuffer.allocate(Constant.INT32_SIZE).putInt(value).array();
setByteArray(address, bytes);
}

// 根据给定的地址读取 int32,读取4字节
public int getInt32(int address) {
checkAddress(address);

if (address + Constant.INT32_SIZE > this.memory.length) {
throw new IllegalArgumentException("address overflow");
}

byte[] bytes = new byte[Constant.INT32_SIZE];

System.arraycopy(this.memory, address, bytes, 0, Constant.INT32_SIZE);

return ByteBuffer.wrap(bytes).getInt();
}

Block

定义 Block 表示系统所分配的内存块,其中 address 表示该 Block 的起始内存地址,同时 Block 借助 Memory 对内存实现读写操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Block {
private final int address;
private final Memory memory;

public Block(int address, Memory memory) {
if (address < 0 || address >= memory.getSize()) {
throw new IllegalArgumentException("invalid address");
}

Objects.requireNonNull(memory, "memory should not be null");

this.address = address;
this.memory = memory;
}
}

下图展示了一个 Block 在内存中的布局:

alt

一个 Block 除了包含用户数据外还需要保存元数据,所以每个 Block 占据的内存会大于用户实际申请的内存;元数据中的第一个字节表示当前内存块是否被使用;第2到5字节表示 sizeClass,用来计算当前内存块所占据的内存的大小,即2sizeClass2^{sizeClass};第6到9字节表示前一个空闲内存块的地址;第10到13字节表示后一个空闲内存块的地址;从第14字节开始就是用户数据。当然,这只是一种很粗犷的布局方式,实际应用中的布局必然比这个精炼。

这里需要前一个/后一个空闲内存块的地址是因为将相同大小的内存块通过双向链表的方式串联在一起,从而能快速找到以及删除某个指定大小的内存块。因为 Buddy Memory Allocation 始终以2k2^k大小分配内存,假设系统的最大内存为2N2^N,则可以建立 N 个双向链表,每个双向链表表示当前大小下可用的内存块,如下图所示:

alt

Block 通过 Memory 类提供的 boolint32 数据的读写功能来实现对元数据的读写:

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
// 将 block 标记为已使用
public void setUsed() {
this.memory.setBool(this.address, true);
}

// 当前 block 是否已使用
public boolean isUsed() {
return this.memory.getBool(this.address);
}

// 释放当前 block
public void setFree() {
this.memory.setBool(this.address, false);
}

// 设置 sizeClass
public void setSizeClass(int sizeClass) {
this.memory.setInt32(this.address + Constant.OFFSET_SIZE_CLASS, sizeClass);
}

// 获取当前 block 的 sizeClass
public int getSizeClass() {
return this.memory.getInt32(this.address + Constant.OFFSET_SIZE_CLASS);
}

// 设置前一个空闲的 block
public void setPrev(Block block) {
this.memory.setInt32(this.address + Constant.OFFSET_PREV, block.getAddress());
}

// 获取前一个空闲的 block
public Block getPrev() {
int address = this.memory.getInt32(this.address + Constant.OFFSET_PREV);

return address == -1 ? null : new Block(address, this.memory);
}

// 设置后一个空闲的 block
public void setNext(Block block) {
this.memory.setInt32(this.address + Constant.OFFSET_NEXT, block.address);
}

// 获取后一个空闲的 block
public Block getNext() {
int address = this.memory.getInt32(this.address + Constant.OFFSET_NEXT);

return address == -1 ? null : new Block(address, this.memory);
}

BlockList

BlockList 表示一个双向链表,用于存储某个 sizeClass 下的所有空闲内存块,为了实现方便,内部使用了一个哨兵头节点来作为双向链表的头节点,新节点的插入采用头插法的方式:

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
66
67
package buddy;

import java.util.Objects;

public class BlockList {
private final Block head;
private final int sizeClass;

public BlockList(int address, Memory memory, int sizeClass) {
if (address < 0 || address >= memory.getSize()) {
throw new IllegalArgumentException("invalid address");
}

Objects.requireNonNull(memory, "memory cannot be null");

if (sizeClass <= 0) {
throw new IllegalArgumentException("invalid sizeClass");
}

this.head = new Block(address, memory);
this.head.setSizeClass(sizeClass);
this.sizeClass = sizeClass;
}

// 清空列表,将头节点指向自身
public void clear() {
this.head.setNext(this.head);
this.head.setPrev(this.head);
}

// 列表是否为空
public boolean isEmpty() {
return this.head.getNext().equals(this.head);
}

// 获取头节点的后一个节点
public Block getFirst() {
if (this.isEmpty()) {
throw new RuntimeException("list must not be empty");
}

return this.head.getNext();
}

// 头插法插入一个 block
public void insertFront(Block block) {
this.head.insertAfter(block);
}

// 当前列表是否有空闲的内存块,以及该内存块是否能容纳 size 大小的数据(减去元数据占用的内存大小后)
public boolean hasAvailableBlock(int size) {
return !this.isEmpty() && Block.getActualSize(this.sizeClass) >= size;
}

// 所有空闲内存块的数量,不包含哨兵头节点
public int length() {
int length = 0;
Block block = this.head.getNext();

while (!block.equals(this.head)) {
length += 1;
block = block.getNext();
}

return length;
}
}

由于需要通过哨兵头节点访问下一个可用的内存块,所以每个哨兵头节点就需要知道下一个 Block 的内存起始地址,因此同样需要将哨兵头节点的信息保存在内存中,对于内存大小为2N2^N的系统来说,一共需要保存 N 个哨兵头节点的信息,这里将内存分为两部分,前一部分保存所有的哨兵头节点,后一部分保存所有的 Block

alt

因此第一个 Block 的内存起始位置也就等于所有哨兵节点的大小之和。

内存管理

初始化

定义 Allocator 负责内存的分配和回收,本质上是对 Block 的管理,即 Block 的分裂和合并:

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
public class Allocator {
private final Memory memory;
private final BlockList[] blockLists;

private static final int MIN_SIZE_CLASS = 4;
private static final int MAX_SIZE_CLASS = 16;

public Allocator() {
int allHeadSentinelSize = this.getMemoryOffset();
int maxMemorySize = (1 << MAX_SIZE_CLASS) + allHeadSentinelSize;
this.memory = new Memory(maxMemorySize);
this.blockLists = new BlockList[MAX_SIZE_CLASS];

// 初始化空闲列表
for (int i = 0; i < MAX_SIZE_CLASS; i++) {
int sizeClass = i + 1;
int headSentinelAddress = Constant.HEAD_SENTINEL_SIZE * i;
this.blockLists[i] = new BlockList(headSentinelAddress, this.memory, sizeClass);
this.blockLists[i].clear();
}

// The single full block
Block block = new Block(allHeadSentinelSize, this.memory);
block.setSizeClass(MAX_SIZE_CLASS);
block.setFree();
this.blockLists[MAX_SIZE_CLASS - 1].insertFront(block);
}
}

在这个例子中,我们假设系统最大能支持的内存大小为2162^{16}个字节,由于哨兵节点也需要占用一部分内存,所以在构造函数中初始化 Memory 的大小为所有哨兵节点占用的内存大小加上 2162^{16} 个字节。同时,系统可分配的 Block 的大小分别为212^1222^2,…,2152^{15}2162^{16},对应需要初始化16个双向链表,这里简单的使用数组来保存这16个双向链表,并初始化对应哨兵头节点的内存起始地址。同时,整个系统在初始状态只有一个 Block,大小为2162^{16}

内存分配

如前面所述,内存分配的第一步是找到满足用户内存需求的最小的 Block,然后如果 Block 过大则继续将 Block 进行分裂:

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
66
67
68
69
70
public int alloc(int size) {
Block block = null;

for (int i = 0; i < MAX_SIZE_CLASS; i++) {
BlockList blockList = this.blockLists[i];

// 找到满足用户内存需求的最小的 Block
if (!blockList.hasAvailableBlock(size)) {
continue;
}

block = blockList.getFirst();

// 尝试将 block 分裂
block = this.split(block, size);

// 将 block 标记为已使用
block.setUsed();

break;
}

if (block == null) {
throw new RuntimeException("memory is full");
}

// 这里没有返回 block 的起始地址,因为 block 的起始地址指向的是元数据,实际需要返回用户数据的起始地址
return block.getUserAddress();
}

// 将 Block 分裂(如果能分裂的话),返回分裂后的左兄弟
private Block split(Block block, int size) {
int sizeClass = block.getSizeClass();

// 只要 block 的一半(减去元数据占据的空间后)仍能容纳 size,则持续将 block 分裂
// 由于 block 本身需要存储元数据,每个 block 至少需要 2^MIN_SIZE_CLASS 字节
while (sizeClass > MIN_SIZE_CLASS && Block.getActualSize(sizeClass - 1) >= size) {
int newSizeClass = sizeClass - 1;

// 将 block 分裂为两个,取第一个继续分裂
Block[] buddies = this.splitToBuddies(block, newSizeClass);
block = buddies[0];
sizeClass = newSizeClass;
}

// 将 block 从空闲链表中删除
block.removeFromList();

return block;
}

private Block[] splitToBuddies(Block block, int sizeClass) {
block.removeFromList();
Block[] buddies = new Block[2];

for (int i = 0; i < 2; i++) {
// 更新分裂后的 block 的起始地址和 sizeClass,并标记为可用
int address = block.getAddress() + (1 << sizeClass) * i;
buddies[i] = new Block(address, this.memory);
buddies[i].setFree();
buddies[i].setSizeClass(sizeClass);
}

// 这里从后往前遍历 buddies 插入到双链表中是因为最后返回给用户的是第一个 buddy
for (int i = 1; i >= 0; i--) {
this.blockLists[sizeClass - 1].insertFront(buddies[i]);
}

return buddies;
}

内存回收

应用程序要求释放内存时,提交的是用户数据的起始地址,需要先将其转为 Block 的起始地址(减去 Block 元数据的占用空间大小即可),然后尝试将 Block 和其兄弟合并,并将合并后的 Block 加入到空闲列表中:

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
public void free(int userAddress) {
// 根据用户数据地址得到 Block 的起始地址
Block block = Block.fromUserAddress(userAddress, this.memory);
block.setFree();

// 尝试将 block 和其兄弟合并
this.merge(block);
}

public void merge(Block block) {
int sizeClass = block.getSizeClass();

// 最多只能合并到 MAX_SIZE_CLASS - 1
while (sizeClass < MAX_SIZE_CLASS) {
// 得到兄弟 block
Block buddy = this.getBuddy(block, sizeClass);

// 兄弟 block 正在被使用或者已分裂为更小的 block,则不能合并
if (buddy.isUsed() || buddy.getSizeClass() != sizeClass) {
break;
}

// 将兄弟 block 从空闲链表中删除
buddy.removeFromList();

// 如果兄弟 block 的起始地址比 block 的起始地址小,说明当前的 block 是右兄弟,由于合并后需要得到整个 block 的起始地址,因此将 block 指向 buddy
if (block.getAddress() > buddy.getAddress()) {
block = buddy;
}

sizeClass += 1;
}

// 设置合并后的 sizeClass
block.setSizeClass(sizeClass);
this.blockLists[sizeClass - 1].insertFront(block);
}

这里有个关键的问题在于如何根据 block 的地址知道其兄弟 block 的地址?因为一个 block 会被分为左兄弟和右兄弟两个内存块,如果当前 block 是左兄弟,则右兄弟的地址为 block.getAddress() + 1 << sizeClass,如果当前 block 是右兄弟,则左兄弟的地址为 block.getAddress() - 1 << sizeClass。然而由于缺失位置信息我们并不能知道一个 block 是左兄弟还是右兄弟。

原作者在这里巧妙的在不引入额外的元数据的情况下解决了这个问题。首先,对于某个 sizeClassk 的内存块来说,它的起始地址一定是C2kC2^k,其中 C 为整数。这里使用数学归纳法来证明,假设系统内存最多支持2N2^N个字节,则初始状态下整个系统只有一个内存块,k 就等于 N,该内存块的起始地址为0,满足C2kC2^k,取 C = 0 即可。假设某个 sizeClassk 的内存块的起始地址满足C2kC2^k,则需要进一步证明分裂后的两个内存块的起始地址为C2k1C'2^{k - 1}。而分裂后的内存块的起始地址分别为C2kC2^kC2k+2k1C2^k + 2^{k - 1},又C2k=(2C)2k1C2^k = (2C)2^{k - 1}C2k+2k1=(2C+1)2k1C2^k + 2^{k - 1} = (2C+ 1)2^{k - 1},证明完毕。同时,由这些公式可以发现,对于左兄弟内存块来说,C 是偶数,而对于右兄弟内存块来说 C 是奇数。更进一步来说,左右兄弟内存块的地址差异仅在于从低位往高位数的第 k + 1 位不同。

因此,根据某个内存块的地址推算出兄弟内存块的地址只需要将当前内存块的地址从低位往高位数第 k + 1 位反转即可。这种涉及反转比特位的操作就可以使用异或运算,我们可以将内存块的地址和 1 << sizeClass(也就是2k2^k)进行异或运算,得到的地址就是对应兄弟内存块的地址。

另外,由于哨兵头节点的存在,Memory 内部的数组大小不是严格的2k2^k,在计算兄弟内存块的地址时,可以先将当前内存块的地址减去哨兵头节点的大小之和,计算出兄弟内存块的地址之后,再加回偏移量:

1
2
3
4
5
6
7
private Block getBuddy(Block block, int sizeClass) {
int virtualAddress = block.getAddress() - this.getMemoryOffset();
int buddyVirtualAddress = virtualAddress ^ (1 << sizeClass);
int buddyAddress = buddyVirtualAddress + this.getMemoryOffset();

return new Block(buddyAddress, this.memory);
}

总结

以上仅作为 Buddy Memory Allocation 算法的示例,不具有实际应用意义,例如完全没有考虑线程安全。完整的代码可参考原作者的 代码Java 版本的 buddy-memory-allocation

参考

本文内容主要来源于 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

参考

0%