6.824 lab4: 基于 Raft 的强一致分片 KV 数据库

2 阅读16分钟

MIT 6.824 的 Lab 2,3,4 是一系列循序渐进的任务:Lab 2 是完成一个能保证日志一致性的 Raft 实现;Lab 3 在此基础上实现一个强一致的分布式 KV 数据库,增加了 Client-Server 交互的过程;Lab4 是将 3 的数据分片存储。 从2到3和从3到4,难度和工作量都有一个陡增。不愧是 MIT 的研究生课程,难度和工作量整体都比 15213 和 6.828 的 lab 要高出好几个 level。

整体架构

一个分片 KV 数据库,key 和 value 都是字符串。SDK 向应用层提供 Get, Put, Append 三种接口,保证强一致。一个集群由若干个 Group 组成,其中几个概念的解释如下

  • Shard:数据片,每条 KV 依据 hash(key) 映射到其所属的 shard。比如 key 以 "a" 或 "b" 开头的数据在 shard-1,"c" 或 "d" 开头在 shard-2... 等等

  • Replica Group:一个 Raft 集群实例,包含 Leader 和 follower,负责一部分数据的存储和响应。一个完整的 6.824 KV 数据库集群包含一个 Shard Master 和多个 Replica Group

  • Configuration:配置,即 Shard 到 Group 的分配(assignment)。应用层可通过 API 更改 Group(比如添加或移除 Group),此时 Shard Master 会生成一套新的 Config。

  • Shard Master:也是一个 Raft 实例集群,但与 Replica Group 不同,它保存的是 Config 历史记录,不保存 KV 数据

  • Cluster management SDK:提供这几种 API ——

    • Join:Group 上线(开始 serve shards)
    • Leave:Group 下线(停止 serve shards)
    • Move:指定将某个 shard 移动到某个 Group
    • Query:请求某个 config

    注意前三种 API 成功后都会生成一套新的 config,即 shards -> group 的分配,但具体分配方案并非在 API 参数中指定,而是由 Shard master 通过下文的“一致性哈希”算法生成的。Move 相当于手动干预分配方案,仅用于测试。

UML diagram.jpg

NOTE:

  1. Shard 是抽象概念,Group 是物理概念。一般而言 Shard 数量远多于 Group,因此每个 Group 都要存多个 Shard,不会有空闲的 Group(除非下线了)
  2. 由于 Lab2 中没有实现 Raft 集群成员变更,因此一个 Group 的机器是固定的,集群管理 API 只能做 Group 粒度的上线或下线,不能更改 Group 中的成员。

实在要变更成员也可以这样操作:假如说要给 G1 增加一台 server,先下线 G1,然后清除所有数据,加入新 server,再上线新的 G1。此时 Shard Master 视角下 G1 就都是完全空白的新 server。

  1. Group 在升级时(即切换至更新的 config),如果有新的 shard 被分配到它,就会向其它 Group 请求那个 shard 的数据。这是一个新的 RPC 类型(FetchShard RPC),不过这个 RPC 不涉及 Raft 核心逻辑。

可用性分析

  1. KV 服务整体可用依赖每个成员 Group 可用,因为一方面 shard 在所属 group 之外没有冗余,另一方面配置升级时 Group 之间要发送数据,如果需要的数据不来 Group 就卡着无法升级,因此一个 Group 的崩溃会拖垮所有 Group
  2. 单个 Group 可用依赖有超过半数的 Raft 节点可用

配置升级

应用层视角的 Config Update

举个例子,下面五行数组表示 5 套 config 版本,分别记为 config-1, config-2, ... config-5。shard 数始终为 10,表示为 shardID={0,1...9},GID 为 Group ID

1: [00000 00000]  // 该数组表示一套 config,array[2]=0 表示 shard-2 分配给 group-0
                  // 此时上线的 Group 只有 Group-0,因此所有 shards 都只能分配到它那儿
2: [11111 00000]  // Group-1 上线,生成 config-2,前五个 shards 被分配给了 group-1
3: [21111 22000]  // Group-2 上线,生成 config-3
4: [21111 22221]  // Group-0 下线
5: [11111 11111]  // Group-2 下线

下面这张图更直观,展示了各 shard 是如何随着 config 的推进在 group 之间转移的。一组平行的斜箭头表示一次 FetchShard RPC。Config-1 到 Config-2 之间的五条斜箭头表示 Group-2 在升级到 Config-2 的过程中要向 Group-1 请求 0-4 这五个 shards(在一次 RPC 中),其它 shards 仍继续留在 Group-1 上(虚线)。

要注意的是,随着时间推移,Group 会向更高的 config 升级,但同一时刻各 Group 并不一定处于同一个 config 状态下,可能有的 Group 升级稍落后。

UML diagram (1).jpg

Config 实现

处理配置更新(Reconfig)是 lab4 的难点。首先,Group 内部的所有 server 必须就 Reconfig 事件发生时机(即在 log 序列中的位置)达成一致——显而易见的办法就是 Reconfig 都由 leader 通过 log 来告知 follower,而非 follower 去主动请求 shardmaster。

其次,任意 Shard 在任意时刻最多只能有一个 group 负责响应。如果满足,比如 G1 和 G2 都可以响应 S1,两个 client 分别向 G1 和 G2 写同一个 key,就造成了数据不一致。

一个更常见的情况是:当 G1 切换到配置 k 时需要向 G2 请求数据片 S1,G2 响应之,但此时 G2 还没有来得及切换到配置 k,这时候新来一个写 S1 的请求到达 G2,一旦 G2 响应了它,就出现了数据不一致,因为此时出现了 G1 和 G2 两个可以响应 S1 的 group!

容易想到的办法是 G2 以收到 FetchShard RPC 为时间节点,G2 如果在这个 RPC 的响应中把 S1 移交给了其它 Group,那么之后就不再响应 S1 的请求了。但问题是 FetchShard RPC 只发给 raft leader,而 follower 并不知道 S1 已经被转移出去了,如果 leader 挂掉,follower take over 又会出现同样的问题。这个时候你会发现 lab4 比想象的复杂(tmd...)。因此我不得不为一次 reconfig 设计两条 log —— 第一条告诉 server 暂停接收请求,第二条表示 reconfig 完成,可以继续 serve 了。

在 Lab2 的基础上,KV server 实例增加了两条状态,以及三种日志类型:

type ShardKV struct {
   mu           sync.Mutex
   cond         *sync.Cond
   me           int
   rf           *raft.Raft
   data         map[string]string
   nextSeq      map[int64]int
   // ...
   workingConfig     *shardmaster.Config // New! 目前的 config
   configOK          bool                // New! 是否处于 working 状态
}

const (
   GET = 0
   PUT = 1
   APPEND = 2
   STOPOLD = 3  // New log type
   RESHARD = 4  // New log type
   NOOP = 5  // New log type
)

Follower Config Update

整个配置更新流程中,leader 负责从 shardmaster 处周期性轮询拉取配置,如果发现有更新,则将更新后的配置以及转移进来的 shards 以日志的形式同步给 followers。follower 不会直接与 shardmaster 和其它 group 交互,其配置升级是完全通过 apply log 实现的。

单个 KV server 升级的状态转移如下图所示。KV server 有 working 和 pending 两种状态,前者可以正常服务,后者不行。server 启动时处于初始状态 config-0-working,当应用 config-1 的 STOPOLD 日志后暂停服务,进入 config-1-pending 状态,直到应用 config-1-RESHARD 日志(其中包含从 config-0 进入到 config-1 新转移进来的所有 shards),进入 config-1-working 状态,开启服务。

注意必须符合 Num 和 log type 约束的日志才会触发状态转移,否则直接 ignore。

UML diagram (2).jpg

Leader Config Update

leader 处理的流程图如下。Server 启动时开启一个 goroutine。如果没有节点挂掉且网络良好(正常情况),走的流程如图中加粗箭头所示。

Flowchart.jpg

挨个说下每一步在做啥:

  1. 判断是否是 leader,如果不是的话就 continue

  2. 判断此时是不是此 leader 当选后第一次进入该循环,判断依据是此时 raft term 是否与上一次循环时相同,

  3. 如果上一步判断为 true,此时要提交一个 Noop log 并等待其 applied。这样做是让 leader 在当选后尽快“进入状态”。由于 raft 中 leader 当选后并不会自动提交 previous term's log(其原因由原论文的经典 figure 8 阐明),因此如果此时没有新的 log 到来(从而 push leader 开始干活),之前的 log 就既不成功也不失败,client request 就被一直挂着。 这种情况 lab3 里边也会出现,但后果仅是把 API 延迟拉长了,并不影响系统功能,而在 lab4 中这会造成系统陷入僵局——如果 server1 当选 leader 时候本身处于 pending 状态,此时该节点一方面会拒绝所有客户端请求,因此不会有新的 GET, PUT, APPEND 日志出现,另一方面也不清楚自己处于哪个版本的 config(直接读取本地 workingConfig 是不可行的,因为此时未提交日志里可能积攒了很多 STOPOLD 和 RESHARD log 了),因此也不适合贸然向 shardmaster 请求 config。因此使用 Noop 迫使 leader 将上一任期的 log 全部提交,从而让自己进入正确的状态。

  4. 请求下一个版本的 config。要注意这里不能直接请求 newest config!仍然以刚刚的例子,group-2 如果从 config-1 直接跳到 config-3,那么它没有感知到 config-2 的存在,错误地从 Group-0 请求 shard-1(本应从 group-1 请求),拉取到的 shard-1 就是过时的。

        1: [00000 00000]
        2: [11111 00000]
        3: [21111 22000]
        4: [21111 22221]
    

    其实 lab 文档里有提示说 Process re-configurations one at a time, in order,我错误地把 in order 理解成了“不要并发拉取 config”,而没有理解到“不要跳版本拉取”这层意思,又多 debug 了好一阵子,不过也算是加深了对系统的理解吧...

  5. Repair mode 的含义是目前 configOK=false 即处于 pending 状态,属于是 STOPOLD 已经应用了但 RESHARD 未应用,原因可能是上任 leader 没来得及应用后者就崩溃了。我之所以叫它 repair mode 是因为此时本地 KV data 和 workingConfig 不一致,需要维修(repair),repair 的方式是从 shardmaster 请求【working-1】的配置(而不是常规的【working+1】),之后只需要应用 RESHARD log,这套配置就算“维修”结束了。

  6. 向 Raft 层提交 STOPOLD 日志,等待它被应用。此步有可能失败,失败原因是 raft 发起了新一轮选举,此节点落选变成 follower,这种情况回到循环的起点从头再来就行咯

  7. 向其他 group 请求 shards,此步有可能失败,失败原因是依赖的 group 还没来得及进入这个 config 状态,即数据没准备好,这时同样是回到循环起点从头再来,但下次循环会进入 repair mode

  8. 向 Raft 层提交 STOPOLD 日志,此轮配置升级大功告成

一致性哈希

一致性哈希是将数据映射到存储节点上的哈希算法,常用于分布式存储领域,由于存储节点变化会引起哈希结果的变化,因此算法的主要目标是:1) 映射均匀;2) 重新映射时在节点之间迁移的数据尽可能少。在 lab4 中,Client SDK 提供了上线下线 group 的 API,shardmaster 要根据新加入/删除的 group 和现有的分配方案生成出一套新的方案(配置)。文档中要求的是 move as few shards as possible(你这要求可不低啊.jpg)

流行的一致性哈希算法有哈希环、ketama、maglev 等等,论映射平衡度、复杂度和槽位变化各有优劣。不过我还是拍脑袋亲自想了一个算法。如图所示:

UML diagram (3).jpg

请求流程

Client SDK

SDK 是把请求直接发到 server,因此要做 group 和 follower/leader 的发现。跟 lab3 没啥差别

Flowchart (1).jpg

Server Side

与 lab3 的差别仅仅是加入了【检查请求的 shard 是否是属于当前 group】这个环节(下面简称 Group Check)。读请求和写请求流程唯一的不同稍有不同:

Flowchart (2).jpg

Group check 看起来简单实际上很微妙——必须把它放在恰当的位置。在最开始的版本中,我把读写请求都按照上图最右边的流程处理,即 Group check 跟 leader check 完全一样,每次循环中检查 if current leader still owns this shard。其中紫色和红色分别对应两处错误

红色处的错误非常明显:如果在循环中执行到此处,待应用的 write log 紧随其后是一条 STOPOLD log,其携带的 config 把刚写入的 shard 从节点所在 group 拿走了。而在 sleep 过程中这两条 logs are both applied,这时红色的 group check will fail,但是数据的写入是在 config 切换之前,即此处本应返回 ok。客户端收到 fail 之后重新向另一个 leader 发写请求,造成重复写。

这个问题的解决,要求 group check 只能在 log 被应用的时候进行,如果 check 失败,将此信息通过全局变量返回给在轮询等待的 Handler(代码中搜 shardSuccess)。shardSuccess 并不需要作为状态写入到 snapshot 里边,读者可以思考下这里的原因。

紫色处的问题则稍隐晦一点:客户端请求到来的时候做一遍检查,如果它要写的 shard 不是我负责的,那么我就拒绝掉,看起来好像没毛病啊!以下事件序列会触发问题:

  1. Group-1 leader applied write log (idx=7, config-2, shard-0)
  2. 节点失去 leader 身份,新一轮选举
  3. Handler 发现该节点已不是 leader,返回 ErrWongLeader
  4. 选举结束
  5. 配置升级到 config-3,Group-1 将 shard-0 转移给 Group-2
  6. Client 向 Group-1 leader 重发写请求,被拒绝,返回 ErrWrongGroup
  7. Client 拉取最新配置,向 Group-2 发送写请求
  8. 但 Group-2 在第五步拉到的数据是已包含该写请求的,因此造成重复写

这个问题的本质是做幂等去重的 NextSeq 没有在 Group 之间同步。Lab3 中 Client SDK 重发请求仅仅是跨 server 重发,在 Raft server 之间 NextSeq 是根据日志自动同步的(是共识的一部分);而 lab4 中 client 会跨 Group 重发,就出现问题了。Group-2 leader 收到 Client 重发的请求时,它并不知道这个请求在 Group-1 上已经执行过了。

因此,对于写请求,需要把 Handler 开头的 Group check 去掉。其本质是 Handler 看到一个 WrongGroup 写请求的时候,它并不知道这个请求是确实就是搞错 Group 了,还是之前自己未处理完的写请求的重试,因此一律先接受,反正 Apply 的时候会统一做 Group Check。不过这样有个不太好的后果就是 log 会变得冗长,里面挤满了 WrongGroup log。

对于读请求仍然做 Early Group check,毕竟读请求不需要实现幂等。

几个 challenge exercise

目前的成果在 MIT 交作业是可以拿满分了,留的 challenge exercise 没做

Garbage collection

难度:⭐️⭐️⭐️

目前实现的 KV 数据库虽然分片了,但每个 Group 仍然保存着自己不负责的 shards —— 配置更新时只是从其它 Group 请求了新数据,但并没有移除旧数据,久而久之每个 Group 都会趋向于所有 shards 都存一份,存储空间浪费太大。

旧数据要删除,但删除的时机很微妙,显然不能在自己配置升级之后就擅自删除,因为其它需要这个 shard 的 group 有可能还没来得及向我请求;也不能在通过 FetchShard RPC 把数据送出去之后就删除,因为 RPC 有可能重试。对删除时机的形式化表述如下:

对于任意 Group G 与其所存储的 shard-p,如果以下表述对所有的 i 都成立:

如果在 config-i 到 config-i+1 升级的过程中 shard-p 有从 G 流向了另外的 Group(记为Gi),则 Gi 的配置已经升级到了至少 config-i+i

那么 shard-p 就可以从 G 中删掉。

其本质是通过 shard 的转移路径对 Groups 建立偏序关系。上述表述中,G 是否可以删除 p 是由config 的更新历史充分决定的,因此理论上各 server 可以独立决定何时删除 shards,而不需要依赖额外的 log 或 RPC。待实现。

Unaffected Shards

难度:⭐️⭐️⭐️

要求在配置更新的过程中仍能 serve 此次更新没有转移的 shards,即 pending 状态不能简单粗暴地把所有 shards 的请求都拒绝。理论上这同样可以通过分析 config 更新历史实现,不需要额外 log 或 RPC,目测不难。

Partial Config

难度:⭐️⭐️⭐️⭐️⭐️

要求在配置更新过程中,如果别的 Group 响应 FetchShard 有的快有的慢,那么响应慢的 shards 不应拖累响应快的 shards,即先返回回来的 shards 先 serve 上。这要求 leader 每收到一个 FetchShard RPC 返回就提交一条 log,通知所有 follower 可以 sever 这些 shards 了,对目前的架构改动要求较大。

一些个人体会

相比于照着论文实现就能过的 lab2-3,lab4 需要更多的“设计”,比如怎么实现一致性哈希、配置升级的流程、Group 之间的 RPC 等等。lab4 虽然不用再修改 Raft 核心逻辑,但仍要求对 Raft 的深入理解,其中最重要的是理解 raft log 的本质是节点的“状态转移”,由此理解每个结构体的每个成员变量的意义,哪些属于“状态”(data, NextSeq, workingConfig, configOK,哪些不属于“状态”(shardSuccess, sleepCnt);对于属于“状态”,一方面要保存进快照,另一方面要在 log 中实现闭环更新。我在原先的设计中为了省事,把很多“状态”的更新逻辑放在了 log 之外,绕了不少弯路。

在 Lab 中挣扎的历程也促进了我对分布式系统做深入思考。当在程序中增加一个变量时,以前是直接加行代码完事,现在脑中首先蹦出来一系列问题 —— “是否定义了节点的状态”“读写需不需要持锁”“崩溃后如何恢复原值”“各节点值能否收敛到一致”...等等(原本简单的工作变得复杂起来)。对于一个有冗余备份的系统,会从譬如 故障时数据能否保持一致、应用层对部署有无感知、切换是否需要人工干预的等角度去思考和评估。

每当找到并发 corner case 下的细微 bug 的时候,都不由自主惊呼测试用例实在太强大了,居然连这个 bug 都能找得出来。动手实践的收获比单纯看 paper 和听 lecture 更丰富且扎实,而设计出这个 lab 比单纯做出 lab 所要求的知识和工作量都要高得多。向开这门课的 Robert Morris 教授致敬。