MIT-6-824-2021-分布式系统笔记-全-
MIT 6.824 2021 分布式系统笔记(全)
MIT 6.824 分布式系统笔记:第1课 - 介绍 🚀
在本节课中,我们将要学习分布式系统的基本概念、其重要性以及面临的挑战。我们将探讨为什么需要构建分布式系统,并初步了解其核心设计目标。
什么是分布式系统?
分布式系统由多个通过网络连接的计算机组成。这些计算机协同工作,以完成一个共同的目标。它们呈现给用户的印象是一个单一、连贯的系统。
为什么需要分布式系统?
构建分布式系统主要出于三个原因。
- 性能/并行性:通过多台机器并行处理任务,可以显著提升计算能力与速度。
- 容错性:系统的一部分出现故障时,其他部分可以继续工作,从而提高了整体可靠性。
- 物理原因:不同组件或用户本身在地理上就是分布的,例如银行的各个分行。
- 安全性/隔离性:通过将不同部分运行在独立的机器上,可以实现更好的安全隔离。
面临的挑战
尽管分布式系统优势明显,但其设计也面临诸多困难。
- 并发执行:许多事情会同时发生,需要复杂的协调机制。
- 局部故障:系统中的部分组件可能独立于其他部分发生故障。
- 性能瓶颈:网络延迟和带宽限制可能成为系统性能的瓶颈。
上一节我们介绍了分布式系统的定义与挑战,本节中我们来看看实现分布式系统时追求的核心目标。
分布式系统的目标
设计良好的分布式系统通常致力于实现以下几个关键目标。
- 可扩展性:随着机器数量的增加,系统的性能应能线性提升。
- 高可用性:系统应能在部分故障时继续提供服务,通常通过复制(Replication) 技术实现。
- 强一致性:尽管存在复制和并发,系统应表现得像单一机器一样,这是最理想但最难实现的目标。
接下来,我们将通过一个核心概念来理解分布式系统中的数据一致性。
核心概念:可线性化(Linearizability)
可线性化(或称强一致性)是分布式系统中最强的一致性模型。它要求系统表现得好像所有操作都在某个单一副本上原子性地、按顺序执行,并且这个顺序与真实时间顺序一致。
简单来说,对于任何一系列读写操作,存在一个全局的、线性的顺序,这个顺序符合每个操作的真实发生时间,并且读操作总能读到最新写入的值。
我们可以通过以下伪代码描述一个可线性化的键值存储的写操作逻辑:
func Put(key, value) {
// 1. 为操作生成全局唯一且递增的时间戳(或序列号)
timestamp = generate_monotonic_timestamp()
// 2. 将 (key, value, timestamp) 同步复制到大多数副本
replicate_to_majority(key, value, timestamp)
// 3. 确认成功后,操作才被视为完成
return success
}
而读操作则需要读取已提交的最新数据:
func Get(key) {
// 1. 向所有副本查询该key的最新已提交数据
latest_data = query_all_replicas_for_latest(key)
// 2. 确保读取到的数据是已达成多数派共识的
ensure_data_is_committed(latest_data)
// 3. 返回该数据
return latest_data.value
}
这种模型对编程者非常友好,但为了实现它,通常需要付出性能代价,例如使用同步通信和共识算法。
本节课中我们一起学习了分布式系统的基础:它的定义、构建原因、主要挑战和设计目标。我们重点探讨了可线性化这一强一致性模型,它要求分布式系统像单机一样运作。理解这些基本概念是深入后续课程中关于复制、容错和一致性协议等内容的重要基石。在接下来的课程中,我们将看到为了在现实约束下平衡这些目标,系统设计者所做出的各种权衡。


课程 P1:分布式系统导论 🚀

在本节课中,我们将要学习分布式系统的基本概念、其发展历史、核心挑战以及课程的整体结构。我们还将通过一个具体的案例研究——MapReduce论文,来初步了解分布式系统的实际应用。
什么是分布式系统? 💻

分布式系统是由多台通过网络连接的计算机组成的集合。这些计算机只能通过发送和接收数据包进行交互,而不是像多处理器系统那样通过共享内存。它们协同工作,以提供某种服务。
从非正式的角度来说,分布式系统有四个关键词:多台计算机、网络连接、数据包交互和协同服务。通常,用户可能意识不到分布式系统的复杂性,例如在使用Zoom客户端时,其背后是庞大的数据中心在支持。
为什么需要分布式系统? 🤔

一般来说,构建分布式系统有四个主要原因:
- 连接物理分离的机器:允许位于不同地理位置的设备(如笔记本电脑、手机)连接到服务器,实现远程访问。
- 实现资源共享:连接起来的计算机可以共享数据、计算资源或基础设施,支持协作(如文件共享、屏幕共享)。
- 通过并行提升性能:将任务分配到多台机器上并行处理,可以显著提高处理能力和吞吐量,以支持大量并发用户(如Zoom会话)。
- 提高容错性与安全性:
- 容错性:部分机器故障不会导致整个服务停止,系统可以继续运行,实现高可用性。
- 安全性:通过将敏感服务(如密码管理)隔离在独立的机器上,可以减少攻击面,提升安全性。

分布式系统的历史背景 📜

分布式系统大致始于20世纪80年代初的局域网(LAN)时代,例如MIT的Athena集群和AFS文件系统。早期的互联网应用主要是DNS和电子邮件。
其重要性在90年代随着数据中心的兴起而急剧增长。商业互联网的开放催生了大型网站(如搜索引擎、电子商务),这些应用需要处理海量数据和海量用户,单台计算机无法胜任。MapReduce等论文正是这个时期的产物。
2000年代中后期,云计算的出现进一步加速了发展。用户将计算和数据迁移到云端,云提供商(如亚马逊、谷歌、微软)建设了庞大的基础设施,使得构建大规模分布式系统变得更加容易。如今,分布式系统已成为一个极其活跃的研究和开发领域。
分布式系统的核心挑战 ⚠️
分布式系统之所以复杂,主要源于两个核心挑战:

- 大量并发组件:现代数据中心可能并行运行数十万台计算机。管理如此多的并发进程,并确保它们正确协同工作,非常困难。
- 部分故障:系统中的某台机器可能发生故障,而其他部分仍在运行。系统必须能够处理这种“部分故障”情况。更复杂的是,可能出现网络分区(即“脑裂”),导致系统的两部分彼此隔离但各自运行,这使得协议设计异常复杂。
此外,实现理论上的性能优势(如通过增加机器数量线性提升吞吐量)在实践中也颇具挑战性,因为任务往往无法做到完全并行。
课程6.824概述与结构 📚
选择学习6.824课程有多个原因:它涉及有趣且强大的技术解决方案;在现实世界中应用广泛;是活跃的研究领域;并且提供了独特的、构建分布式系统的实践经验。
课程结构主要包括以下几个部分:

- 讲座:围绕重要论文(案例研究)展开,探讨核心思想。课前阅读论文并回答问题至关重要。
- 实验:共有四个编程实验,是课程的核心。
- 实验1:实现MapReduce库。
- 实验2:实现Raft共识算法,构建复制状态机基础。
- 实验3:基于实验2的库,构建一个容错的复制键值存储服务。
- 实验4:构建分片键值服务,通过分片实现性能扩展和负载均衡。
- 可选项目:学生可以组队进行自选项目,替代实验4。
- 考试:包含一次期中考试和一次期末考试。
实验采用进程模拟多台机器,并使用自定义的RPC库进行通信。测试用例公开且具有挑战性,建议尽早开始。
课程核心主题 🔑

本课程将反复围绕以下几个核心主题展开:
- 容错:
- 可用性:在故障发生时继续提供服务。关键技术是复制。
- 可恢复性:故障机器重启后能恢复状态并重新加入系统。关键技术是日志/事务。
- 一致性:在并发和故障存在的情况下,系统对外表现出的行为。理想情况是表现得像一台顺序执行的单机。
- 强一致性通常需要机器间通信,可能影响性能。
- 实践中常需要权衡,例如采用最终一致性以换取更好的性能。
- 性能:
- 吞吐量:通过增加机器数量来提高。
- 延迟(尤其是尾部延迟):由最慢的机器或组件决定,是分布式系统中的一个关键难题。
- 并发管理:在实现分布式系统时,如何正确、高效地管理并发操作,是一个持续出现的挑战。
这些目标(容错、一致性、性能)之间常常存在冲突,需要进行权衡。



案例研究:MapReduce 📄
MapReduce论文是分布式系统领域一篇极具影响力的论文。它源于Google处理海量网页数据(如构建倒排索引)的需求。目标是让普通工程师能轻松编写处理大数据集的分布式程序,而无需担心底层的容错、并行等复杂问题。


MapReduce 编程模型
MapReduce采用特定的计算模型,程序员只需编写两个函数式(无状态、确定性)的函数:
- Map函数:处理输入键值对,生成一组中间键值对。
// 示例:词频统计中的Map函数 func Map(filename string, contents string) []KeyValue { // ... 解析内容,对每个单词word return []KeyValue{{word, "1"}} } - Reduce函数:接收一个键及其对应的所有值列表,进行归并处理,产生最终输出。
// 示例:词频统计中的Reduce函数 func Reduce(key string, values []string) string { // ... 计算values中“1”的个数 return totalCount }

MapReduce框架负责在多台机器上调度这些函数的执行,处理数据分发、容错等所有分布式细节。
MapReduce 执行流程
- Map阶段:将输入文件分割,并由多个Worker并行执行Map任务,生成中间结果(存储在本地磁盘)。
- Shuffle阶段:框架根据中间键将数据重新分区和排序,确保同一键的所有值被发送到同一个Reduce任务。
- Reduce阶段:多个Worker并行执行Reduce任务,读取已排序的中间数据,调用Reduce函数,并将最终输出写入全局文件系统(如GFS)。
MapReduce 的容错机制

- Worker故障:Master(协调器)会周期性地Ping Worker。如果Worker无响应,Master会将其标记为失效,并将该Worker上已完成的或正在进行的Map/Reduce任务重新调度给其他Worker。
- 任务重复执行:由于网络延迟或Worker临时故障,同一个Map或Reduce任务可能会被多次执行。这之所以可行,是因为Map和Reduce函数被设计为确定性的,即相同的输入总是产生相同的输出。多次执行的结果是幂等的。
- 应对慢节点(Straggler):当作业接近完成时,Master会启动剩余任务的“备份任务”(Speculative Execution)。多个Worker同时处理同一任务,取最先完成的结果,以避免个别慢节点拖慢整体进度。
- Master故障:在论文描述的简单实现中,Master是单点,其故障会导致整个作业需要重启。更复杂的系统会采用复制等方式使Master具备容错性。

本节课中我们一起学习了分布式系统的基本定义、其发展历程、面临的核心挑战以及本课程(6.824)的结构和目标。我们还深入探讨了MapReduce这一经典案例,了解了它如何通过简单的编程模型和强大的框架,将分布式计算的复杂性隐藏起来,让开发者能够专注于业务逻辑。在接下来的课程和实验中,我们将继续探索分布式系统的其他关键技术和设计权衡。
🎓 课程10:Go语言嘉宾讲座 - Russ Cox
在本节课中,我们将跟随Go语言联合负责人Russ Cox,学习如何用Go设计和实现并发程序。我们将通过四种常见的并发模式,理解如何利用Go的并发特性编写清晰、高效的代码,并掌握一些实用的设计原则和技巧。
🔄 并发与并行
首先,区分并发性和并行性很重要。
并发性是关于如何编写程序。它指的是程序能够独立编排多个控制流(如进程、线程或goroutine),从而同时处理多件事情,而不会使程序结构变得混乱。
并行性是关于程序是如何执行的。它指的是允许多个计算同时运行,使程序能够同时做很多事情,而不是一次处理很多事情。
并发性很自然地适合并行执行,但今天的关注点是如何使用Go的并发支持来使你的程序结构更清晰,而不是单纯地让它们运行得更快。
💡 核心设计原则:状态即代码
在设计并发程序时,一个反复出现的决定是:将状态表示为代码还是数据。
- 数据状态:将状态存储在变量中。
- 代码状态:将状态隐含在程序的控制流(如程序计数器和调用栈)中。
将数据状态转换为代码状态,通常会使代码更清晰、更易读。
示例:解析字符串
假设我们需要从文件中读取并扫描一个C风格的引号字符串。一个基于状态机的实现可能将当前状态存储在一个变量中:
state := 0
for {
ch := readChar()
switch state {
case 0:
if ch != '"' { return false }
state = 1
case 1:
if ch == '"' { return true }
if ch == '\\' { state = 2 } else { state = 1 }
case 2:
state = 1
}
}
这个程序的状态完全存储在state变量中。我们可以通过将状态移动到控制流中来重写它,使其更清晰:
// 状态0: 寻找起始引号
if readChar() != '"' {
return false
}
// 状态1: 在字符串内循环
for {
ch := readChar()
if ch == '"' {
return true
}
if ch == '\\' {
// 状态2: 转义字符,跳过下一个字符
readChar()
}
// 循环回到状态1
}
提示1:将数据状态转换为代码状态,它会使你的代码更清楚。
🧵 使用Goroutine保存代码状态
有时,我们无法完全控制程序的控制流(例如,必须通过回调函数处理数据)。在这种情况下,我们可以创建额外的goroutine来保存代码状态。
示例:无法控制流的字符处理
假设我们有一个ProcessChar方法,必须一次处理一个字符并返回,无法在同一个函数调用中保持循环状态。
type Parser struct {
chars chan rune
status chan Status
}
func (p *Parser) Init() {
go p.readString()
}
func (p *Parser) ProcessChar(ch rune) Status {
p.chars <- ch
return <-p.status
}
func (p *Parser) readString() {
// 这里可以包含之前清晰的、基于循环的解析逻辑
// 它通过p.chars接收字符,并通过p.status发送状态
}
通过启动一个专门的goroutine来运行readString,我们将状态(解析进度)保存在了这个goroutine的栈和程序计数器中,从而实现了清晰的代码状态。
提示2:使用额外的goroutine来保存额外的代码状态。
注意:创建goroutine不是免费的,必须确保它们能正确退出,避免goroutine泄漏。
🔍 调试:检测Goroutine泄漏
Go提供了强大的工具来检测未退出的goroutine。
- 在Unix系统上,向程序发送
SIGQUIT信号(通常按Ctrl+\),程序会崩溃并打印所有goroutine的堆栈跟踪。 - 如果程序是HTTP服务器,并且导入了
net/http/pprof包,可以访问/debug/pprof/goroutine端点来获取所有运行中goroutine的堆栈信息,它会聚合相同的堆栈,使泄漏更容易被发现。
提示3:使用/debug/pprof/goroutine端点来调查goroutine泄漏。
📡 模式一:发布订阅服务器
发布订阅模式用于将程序中产生事件的部分与处理事件的部分解耦。
API设计
type PubSub struct {
// ...
}
func (s *PubSub) Subscribe(ch chan Event)
func (s *PubSub) Publish(e Event)
func (s *PubSub) Cancel(ch chan Event)
Subscribe:注册一个channel来接收事件。Publish:向所有注册的channel发送一个事件。Cancel:取消订阅,并关闭对应的channel(通知接收方)。
基础实现(带互斥锁)
type PubSub struct {
mu sync.Mutex
subs map[chan Event]bool
}
func (s *PubSub) Publish(e Event) {
s.mu.Lock()
defer s.mu.Unlock()
for ch := range s.subs {
ch <- e
}
}
问题:如果一个订阅者处理事件很慢,Publish中的channel发送操作会阻塞,拖慢所有其他订阅者。
解决方案:分离关注点
我们可以将核心逻辑移到一个专用的goroutine中,将“互斥锁保护的状态”转换为“goroutine内的局部状态”。
type PubSub struct {
publish chan Event
subscribe chan chan Event
cancel chan chan Event
}
func (s *PubSub) loop() {
subs := make(map[chan Event]bool)
for {
select {
case ch := <-s.subscribe:
subs[ch] = true
case ch := <-s.cancel:
delete(subs, ch)
close(ch)
case e := <-s.publish:
for ch := range subs {
ch <- e
}
}
}
}
// Publish, Subscribe, Cancel 方法现在只是向对应的channel发送请求。
提示4:使用goroutine来分离独立的关注点。
为了处理慢速订阅者,我们可以在核心循环和订阅者之间增加一个“助手goroutine”来进行缓冲、丢弃或合并事件,防止一个慢速订阅者影响整体系统。
👷 模式二:工作调度器
这个模式类似于MapReduce实验中的协调者,它负责任务的调度。
目标
给定一个服务器列表和一系列任务,将任务分配给可用的服务器执行。
使用Channel作为队列
func Schedule(servers []string, numTasks int, call func(string, int) bool) {
idle := make(chan string, len(servers))
for _, s := range servers {
idle <- s
}
for i := 0; i < numTasks; i++ {
go func(task int) { // 注意:捕获循环变量
s := <-idle
call(s, task)
idle <- s
}(i) // 将当前i值作为参数传入,避免数据竞争
}
// 等待所有任务完成(略)
}
关键点:
- 避免闭包捕获循环变量:在goroutine中使用函数参数或
task := task来创建局部副本。 - 控制并发度:通过从
idlechannel中获取服务器来控制同时运行的任务数量,避免创建过多goroutine。 - 等待完成:需要额外的机制(如
sync.WaitGroup或donechannel)来等待所有任务完成。
更高级的版本可以为每个服务器创建一个专用的goroutine,从任务队列中拉取工作。
📞 模式三:复制服务的客户端
当服务被复制到多台服务器以提高可靠性时,客户端需要能够尝试多台服务器,直到成功。
接口
type ReplicatedClient struct {
servers []string
mu sync.Mutex
preferred int // 上次成功使用的服务器索引
}
func (c *ReplicatedClient) Call(args Args) Reply
实现:尝试多个服务器并处理超时

func (c *ReplicatedClient) Call(args Args) Reply {
c.mu.Lock()
pref := c.preferred
c.mu.Unlock()
done := make(chan Reply, len(c.servers)) // 缓冲channel防止goroutine泄漏
for offset := 0; offset < len(c.servers); offset++ {
id := (pref + offset) % len(c.servers)
go func(serverID int) {
done <- c.sendCall(c.servers[serverID], args)
}(id)
select {
case reply := <-done:
c.mu.Lock()
c.preferred = id // 记住成功的服务器
c.mu.Unlock()
return reply
case <-time.After(timeout):
// 这个服务器超时,尝试下一个
continue
}
}
// 所有服务器都尝试过,等待第一个返回的(无论成功与否)
return <-done
}
注意:使用time.After创建的计时器需要及时停止(timer.Stop()),否则可能造成资源浪费。在上面的循环中,每次迭代都会创建新的计时器。
🔀 模式四:协议多路复用器(RPC核心)
这是RPC系统的核心,负责匹配请求和响应。
职责
- 发送请求消息。
- 接收响应消息。
- 通过唯一的标签(tag)将响应与等待的请求配对。
实现
type Mux struct {
send chan Message
recv chan Message
pending map[int]chan Reply
mu sync.Mutex
}
func (m *Mux) loop() {
// 发送循环
go func() {
for msg := range m.send {
m.service.Send(msg)
}
}()
// 接收循环
go func() {
for {
reply := m.service.Recv()
tag := reply.GetTag()
m.mu.Lock()
ch, ok := m.pending[tag]
if ok {
delete(m.pending, tag) // 取出后立即删除
}
m.mu.Unlock()
if ok {
ch <- reply
}
}
}()
}
func (m *Mux) Call(args Args) Reply {
tag := getUniqueTag()
done := make(chan Reply, 1)
m.mu.Lock()
m.pending[tag] = done
m.mu.Unlock()
m.send <- Message{Tag: tag, Args: args}
return <-done
}
关键点:pending映射由互斥锁保护。在接收循环中,一旦从映射中取出回复channel,就立即删除该条目,确保每个请求只被回复一次。
🎯 总结与核心提示
本节课我们一起学习了用Go设计和实现并发程序的四种常见模式:
- 发布订阅服务器:解耦事件生产者和消费者,使用goroutine分离关注点,处理慢速消费者。
- 工作调度器:使用channel作为工作队列,协调多个worker goroutine,注意控制并发度和避免数据竞争。
- 复制服务的客户端:实现容错调用,尝试多个服务端,处理超时,并记住上次成功的节点。
- 协议多路复用器:RPC核心,匹配请求与响应,管理并发访问的映射。
贯穿这些模式,我们强调了几个核心设计原则和提示:

- 状态即代码:尽可能将状态存储在控制流中,而非变量里,使逻辑更清晰。
- Goroutine作为状态容器:当无法在单一控制流中保持状态时,使用goroutine。
- 分离关注点:使用独立的goroutine处理不同的逻辑模块。
- Channel用于通信:使用channel在goroutine间传递数据和信号,注意方向性。
- 谨慎对待共享状态:使用互斥锁保护共享数据,并清楚锁所保护的不变量。
- 避免Goroutine泄漏:始终规划goroutine的退出路径,并利用工具进行检测。
- 竞态检测器是你的朋友:在测试中积极使用
go run -race来发现数据竞争问题。
Go的并发模型提供了一套强大的工具,其目标不仅是提升性能,更重要的是帮助你编写出结构清晰、易于推理的并发程序。希望这些模式和提示能对你在6.824课程及以后的并发编程中有所帮助。
课程 P11:第 11 讲 - 链式复制 🧬
在本节课中,我们将要学习链式复制(Chain Replication),这是一种用于构建复制状态机的主备复制方案。我们将探讨其工作原理、故障处理机制,并将其与之前学过的 Raft 等共识算法进行比较。
概述
链式复制是一种主备复制协议,它假设存在一个外部的配置服务(如使用 Raft 或 Paxos 实现)来管理服务器链的成员和角色。该协议将写操作定向到链的头部,并沿链向下传播,直到尾节点应用操作后向客户端确认。读操作则由尾节点直接处理。这种设计提供了线性一致性、简单的故障恢复以及良好的读性能扩展能力。
复制状态机的两种构建方法
在深入链式复制之前,我们先回顾构建复制状态机的两种常见方法。
上一节我们介绍了复制状态机的通用模型。本节中我们来看看两种具体的实现路径。
方法一:单一共识协议
所有客户端操作(包括读写)都通过一个共识协议(如 Raft、Paxos)来排序和执行。我们的实验 3 键值存储就采用了这种方法。服务状态完全由共识库管理。
方法二:配置服务 + 主备复制
这种方法将系统分为两层:
- 配置服务:一个独立的、状态较小的服务,使用共识协议(如 Raft)运行,负责管理元数据,例如决定哪组服务器构成一个复制组、谁是主节点(在链式复制中对应头尾节点)。
- 主备复制服务:负责实际的数据复制和客户请求处理,其协议(如链式复制、GFS 的主备协议)由配置服务协调。这种方法常用于数据量巨大的场景,因为主备同步可以更专业化,而不必受限于共识协议的状态转移机制。
链式复制属于第二种方法中的“主备复制方案”。
Zookeeper 锁的补充说明 🔒
在进入链式复制的核心内容前,我们先简要补充上节课关于 Zookeeper 锁的讨论,因为锁是实现协调原语的重要工具。
Zookeeper 提供了强大的原语,可以用来实现分布式锁。需要注意的是,Zookeeper 锁与编程语言中的互斥锁(如 Go 的 mutex)语义不同。
以下是 Zookeeper 锁的两个关键特性:
- Ephemeral(临时)节点:当创建锁的客户端会话失效时,Zookeeper 会自动删除该临时节点,从而自动释放锁。这避免了因客户端崩溃导致的死锁。
- Watch(监视)机制:客户端可以在一个 Znode 上设置监视,当该节点发生变化(如被删除)时,Zookeeper 会通知客户端。
基于这些原语,可以实现两种锁:
简单锁(可能引发羊群效应)
以下是其伪代码逻辑:
def acquire_lock():
while True:
if create("/lock-file", ephemeral=True): # 尝试创建临时锁文件
return # 创建成功,获得锁
else:
exists("/lock-file", watch=True) # 设置监视,等待锁文件被删除
wait_for_notification() # 收到通知后循环重试
def release_lock():
delete("/lock-file") # 删除锁文件,触发其他人的监视
缺点:当锁释放时,所有等待的客户端会同时被通知并蜂拥而至地重试创建锁,造成“羊群效应”,给服务器带来巨大压力。
改进的锁(使用顺序节点避免羊群效应)
以下是其伪代码逻辑:
def acquire_lock():
n = create("/lock-", ephemeral=True, sequential=True) # 创建顺序临时节点,如 lock-001
children = get_children("/") # 获取所有锁节点
if n is smallest(children): # 如果自己的节点编号最小
return # 获得锁
else:
p = get_prev_node(n) # 找到编号刚好在自己前面的节点
exists(p, watch=True) # 在该前驱节点上设置监视
wait_for_notification() # 前驱节点被删除(锁释放)时获得通知,然后检查自己是否变为最小
优点:所有客户端形成一个隐式的队列,每个客户端只监视其前一个节点。锁释放时,只有队列中的下一个客户端会尝试获取锁,彻底避免了羊群效应。
Zookeeper 锁的用途:它们常用于领导者选举或作为软锁。软锁允许在常见情况下任务只执行一次,但在持有者故障时允许任务被重新执行(例如,确保 MapReduce 任务至少执行一次)。
链式复制详解 ⛓️
现在,让我们聚焦于今天的主题——链式复制。我们假设已经存在一个配置服务(Master),它知道链中有哪些服务器,以及谁是头(Head)和尾(Tail)。


正常操作流程
考虑一个由三台服务器 S1(头)、S2、S3(尾)组成的链。
-
写请求(更新):
- 客户端向配置服务查询后,将写请求发送给头部服务器 S1。
- S1 在本地应用该更新(例如修改其键值状态),然后通过一个可靠、先进先出(FIFO)的通道(如 TCP 连接)将更新发送给 S2。
- S2 在本地应用更新后,同样将更新发送给 S3。
- S3 在本地应用更新后,向客户端发送确认响应。此时,该写操作被视为已提交(Committed)。
-
读请求(查询):
- 客户端将读请求直接发送给尾部服务器 S3。
- S3 使用本地状态立即响应客户端。
关键点:
- 提交点:当尾部服务器应用了更新时,该更新才被提交。因为后续的所有读操作都会访问尾部,所以它们一定能看到这个已提交的更新。
- 线性一致性:该协议保证了线性一致性。写操作在头部全序发起,并在尾部提交。任何在写操作确认之后发起的读操作,都会访问尾部,因此一定能读到该写操作的结果(或更晚的结果)。
思考:如果让头部服务器在收到写请求后立即响应客户端,而不是等待尾部确认,会破坏线性一致性吗?
答案:是的。因为此时写操作可能还未传播到尾节点,如果另一个客户端立即从尾部读取,将无法看到自己的写入,这违反了线性一致性。
故障恢复
链式复制的故障恢复方案相对简单,主要分为三种情况。配置服务负责检测故障并重新配置链。
-
头部服务器(S1)故障:
- 配置服务检测到 S1 故障,将 S2 提升为新的头部,链变为 [S2 -> S3]。
- 无需特殊恢复操作。任何仅在故障的 S1 中而未传播到 S2 的更新都将被丢弃,由于它们未被提交(尾部未确认),这是允许的。
-
中间服务器(S2)故障:
- 配置服务检测到 S2 故障,通知 S1 和 S3 形成新链 [S1 -> S3]。
- 需要状态同步:S1 必须将那些已发送给 S2 但 S3 尚未收到的更新(即 S1 有而 S3 没有的后缀更新)直接发送给 S3,以使 S3 的状态追赶上。
-
尾部服务器(S3)故障:
- 配置服务检测到 S3 故障,将 S2 提升为新的尾部,链变为 [S1 -> S2]。
- 无需特殊恢复操作。所有已提交的更新都已经存在于 S2,未提交的更新会继续从 S1 传播到新的尾部 S2。
与 Raft 等需要处理众多复杂故障场景的协议相比,链式复制的恢复逻辑更加清晰和有限。
添加新副本
为了维持可用性,需要向链中添加新的服务器。通常最方便的是添加到尾部。
- 新服务器 S_new 启动,并开始从当前尾部 S_tail 复制完整状态(这可能是个耗时过程)。
- 在复制期间,写更新仍通过旧链 [S1 -> ... -> S_tail] 进行。S_tail 需要记录在 S_new 复制开始后收到的所有更新。
- 当 S_new 完成基础状态复制后,S_tail 将积累的更新发送给 S_new。
- S_new 应用所有这些更新后,通知配置服务。配置服务将 S_new 置为新的尾部,并通知客户端更新访问路径。
链式复制的特性与扩展
与 Raft 的对比
| 特性 | 链式复制 | Raft |
|---|---|---|
| 读写负载分离 | 是。写请求到头部,读请求到尾部。 | 读写通常都通过领导者。 |
| 写操作通信开销 | 头部只需发送一次更新给下一个节点。 | 领导者需要将日志条目发送给所有追随者。 |
| 读操作开销 | 仅涉及尾部一个节点,可立即响应。 | 即使使用只读优化,领导者仍需联系多数派以确保自己仍是领导者。 |
| 故障恢复 | 场景简单,恢复逻辑清晰。 | 场景复杂(如领导者变更、日志不一致)。 |
| 故障期间可用性 | 任何节点故障都需要重新配置链,可能导致短暂中断。 | 只要多数派存活,写操作可继续,无需服务中断。 |
扩展读性能:多链分片
链式复制的一个强大扩展是使用多条链,并将数据对象(或“分片”)分布到不同的链上。
例如,有三台服务器 S1, S2, S3:
- 链1 (负责分片A): Head=S1 -> S2 -> Tail=S3
- 链2 (负责分片B): Head=S2 -> S3 -> Tail=S1
- 链3 (负责分片C): Head=S3 -> S1 -> Tail=S2
优势:
- 读性能线性扩展:每个服务器都是某个链的尾部。对不同分片的读请求可以被不同链的尾部并行处理,总体读吞吐量随链(服务器)数量增加而提升。
- 保持线性一致性:对于同一个分片(对象)的操作,仍然由同一条链处理,因此强一致性得以保持。同时,负载被均匀地分散到所有服务器上。
这种方法结合了可扩展的读性能和强一致性保证。
总结
本节课中我们一起学习了链式复制。我们首先回顾了构建复制状态机的两种方法,并指出了链式复制属于“配置服务+主备复制”的范畴。我们详细分析了链式复制的正常操作流程,其核心在于写请求沿链传播并由尾部提交,读请求由尾部直接处理,从而保证了线性一致性。

我们探讨了链式复制简洁而有效的故障恢复机制,包括头部、中间节点和尾部故障的处理方式。最后,我们对比了链式复制与 Raft 的优缺点,并介绍了通过多链分片来扩展读性能的巧妙设计。链式复制以其清晰的设计和良好的特性,在实际系统中有着广泛的应用。
课程 P12:Frangipani 缓存一致性协议 🧠
在本节课中,我们将学习一篇1997年的经典论文《Frangipani》。这是一个关于网络文件系统的研究,其核心目标是在一组用户之间共享文件。虽然 Frangipani 本身未被广泛使用,但它引入了三个在未来分布式系统中反复出现的重要思想:缓存一致性协议、分布式锁和分布式崩溃恢复。本节课将对这些概念进行平和的介绍,为后续学习更复杂的事务系统打下基础。
系统设计背景
上一节我们介绍了课程概述,本节中我们来看看 Frangipani 的设计背景和与传统网络文件系统的对比。
传统的网络文件系统(如 Athena AFS)设计通常采用集中式架构。在这种设计中,多个客户端通过网络连接到一组文件服务器。文件服务器负责实现所有复杂的文件系统操作(如 open、close、read、write),并以抗崩溃的方式将数据写入磁盘。客户端相对简单,主要职责是将应用程序的文件系统操作请求转发给文件服务器。
这种设计受欢迎的一个原因是安全性:文件服务器通常是可信的,而客户端可能不可信。
Frangipani 采用了一种截然不同的、更加去中心化的设计。在 Frangipani 中,并没有传统意义上的专用文件服务器。相反,客户端本身运行文件服务器代码。多个客户端共享一个大的虚拟磁盘(由名为 Petal 的系统实现)。Petal 内部使用多台机器和 Paxos 协议来复制磁盘块,确保操作顺序正确,但从外部看,它就像一个提供 read 和 write 块接口的普通磁盘。

这种设计的优势在于可扩展性:通过增加工作站(客户端)的数量,可以获得更多的 CPU 处理能力,因为繁重的计算(文件系统操作)直接在客户端机器上完成,无需经过集中的文件服务器。在传统设计中,文件服务器往往成为性能瓶颈。
设计目标与用例
了解了基本架构后,我们来看看驱动 Frangipani 设计的具体目标和用例。
Frangipani 的设计源于一个研究实验室的环境,目标用户是50到100名研究人员和工程师。他们的典型工作负载包括编译程序、编辑文档。这个用例有几个关键特点:
- 用户可信:所有机器和用户都是可信的,安全问题不是首要考虑。
- 工作负载私有性高:用户大部分时间都在处理自己的私有文件。
- 需要高性能:用户是重度计算机使用者,需要与文件系统进行高性能交互。
- 存在共享需求:用户间需要协作(如合写论文),或者同一用户会从多个工作站访问文件。
这些用例直接驱动了 Frangipani 的两个核心设计选择:
- 缓存:由于工作负载主要是私有的,将数据缓存在本地工作站可以避免大多数读/写操作都需要网络传输到 Petal,从而获得高性能。Frangipani 使用回写式缓存,数据先留在本地缓存,稍后才批量写回 Petal。
- 强一致性:当确实发生共享时,系统需要提供强一致性(或称连贯性)。例如,一个用户写入文件后,其他用户读取该文件时必须能看到最新的更改。
为了对比,可以考虑 Google 文件系统 GFS。GFS 是为 MapReduce 这类顺序读取大文件的应用设计的,它不进行客户端数据缓存,因此也不需要缓存一致性协议。而 Frangipani 旨在提供标准的 POSIX/Unix 兼容性,允许直接运行 vi、gcc 等传统应用。
核心挑战与解决方案
基于上述设计,Frangipani 需要解决三个主要的挑战:缓存一致性、原子性和崩溃恢复。下面我们逐一探讨。
挑战一:缓存一致性 🔄
当多个工作站缓存同一文件时,如何确保一个工作站的写入能被其他工作站看到?这就是缓存一致性问题。
Frangipani 的解决方案核心是一个分布式锁服务器。锁服务器维护一个表,记录每个文件(通过 inode 编号标识)当前被哪个工作站加锁。工作站本地也维护类似的表,记录自己持有的锁及其状态(“忙”或“闲”)。
缓存一致性协议的基本规则是:要缓存一个文件,必须先获取该文件的锁。


以下是协议中关键的四种消息:
- 请求锁:工作站向锁服务器申请某个文件的锁。
- 授予锁:锁服务器检查文件未被锁定后,将锁授予该工作站。
- 撤销锁:当另一个工作站请求已被持有的锁时,锁服务器向当前锁持有者发送撤销消息。
- 释放锁:当前锁持有者收到撤销消息后,将本地修改写回 Petal,然后通知锁服务器释放锁。
工作流程示例:
- 工作站1请求并获取文件
f的锁,然后读取/修改f,修改暂存于本地缓存。 - 工作站2后来也请求
f的锁。 - 锁服务器向工作站1发送撤销
f锁的消息。 - 工作站1收到撤销消息后,将其对
f的修改写回 Petal,然后释放锁。 - 锁服务器将
f的锁授予工作站2。 - 工作站2现在可以从 Petal 读取包含工作站1最新修改的
f文件。
通过这个协议,强一致性得以保证,因为任何工作站只有在释放锁(并写回修改)后,其他工作站才能获取锁并读取数据。


挑战二:原子性 ⚛️
文件系统操作(如创建文件)通常包含多个步骤(分配 inode、初始化 inode、更新目录等)。需要确保这些操作是原子的,即不会让其他工作站看到中间状态。
Frangipani 同样利用锁机制来实现原子性。在执行一个复合操作前,工作站需要获取所有相关资源(如目录 inode 和文件 inode)的锁。持有这些锁期间执行所有步骤,操作完成后才释放锁。这确保了在操作过程中,其他工作站无法访问这些中间状态。


为了防止死锁,Frangipani 要求所有工作站以全局固定的顺序(例如按 inode 编号排序)来获取锁。
挑战三:崩溃恢复 💥
工作站可能在执行复杂文件系统操作(尤其是写回 Petal 的过程中)时崩溃,导致数据不一致。
Frangipani 采用 预写式日志 技术来解决这个问题。每个工作站都有自己的日志区(位于 Petal 上)。在将修改写回真正的文件系统区域之前,工作站先将修改作为日志记录写入日志。日志记录包含序列号和描述元数据更改(如 inode、目录块更新)的“更新数组”。


关键的两阶段提交:
- 写日志:将包含所有元数据更改的日志记录原子性地写入 Petal 日志区。
- 施日志:日志写入成功后,再将日志记录中的更改应用到真实的文件系统数据结构上。
如果工作站在写日志前崩溃,则更改丢失,但这没问题,因为锁尚未释放,其他工作站看不到不一致状态。
如果工作站在写日志后、施日志前崩溃,则恢复守护进程(一个负责清理的服务)会检测到未应用的日志,并重放这些日志记录,完成文件系统状态的更新,从而保证元数据的一致性。


需要注意的是,用户文件数据本身不经过日志,而是直接写入 Petal。这主要是出于性能考虑,因为数据量可能很大。这意味着应用程序级别的原子性(如保证一个大文件的所有块同时写入)需要由应用自己通过其他方式(如先写临时文件再重命名)来保证。
多日志与版本号


由于每个工作站有自己的日志,一个潜在的问题是:工作站1的日志中有一个删除操作,工作站2的日志中有一个创建操作(针对同一文件),如果工作站1崩溃后其日志被重放,可能会覆盖工作站2的创建。

Frangipani 通过 版本号 机制避免这个问题。文件系统中的每个元数据块(如 inode)都有一个版本号。每次更新日志记录时,都会包含目标块的新版本号(旧版本号+1)。恢复守护进程在重放日志记录时,会检查日志记录中的版本号是否高于 Petal 中该块的当前版本号,只有更高时才会应用该更新。这样就确保了后来的操作不会被先前的日志重放所覆盖。



总结

本节课我们一起学习了 Frangipani 分布式文件系统设计。我们首先了解了其去中心化的架构,即客户端本身运行文件服务代码并共享一个虚拟磁盘。接着,我们探讨了由其特定用例(可信环境下的高性能私有工作负载与强一致性共享)所驱动的设计选择:客户端缓存和回写策略。
然后,我们深入分析了 Frangipani 为解决分布式环境下的三大核心挑战所采用的机制:
- 缓存一致性:通过一个基于锁服务器的协议来管理文件缓存权限,确保数据的强一致性视图。
- 原子性:利用细粒度锁并以固定顺序获取,来保证复合文件系统操作的原子性。
- 崩溃恢复:借助预写式日志(WAL)和版本号机制,确保在客户端崩溃后文件系统元数据能恢复到一致状态,并处理了多客户端日志可能存在的冲突。

Frangipani 优雅地将缓存一致性、分布式锁和崩溃恢复这三个思想结合在一起,为理解后续更复杂的分布式事务系统提供了重要的基础。
课程 P13:分布式事务 🧩
在本节课中,我们将学习分布式事务的核心概念。事务是数据库系统中用于保证一组操作原子性、一致性、隔离性和持久性的强大工具。在分布式环境中,我们需要跨多台机器协调这些操作,这带来了新的挑战。我们将重点探讨两个核心机制:两阶段锁和两阶段提交,它们是实现分布式事务隔离性和原子性的基石。
事务简介
事务允许程序员将一系列操作(例如,读取和写入数据)分组为一个逻辑单元。这个单元要么全部成功执行,要么完全不执行,即使在并发操作或系统故障的情况下也是如此。这通过 BEGIN、COMMIT 和 ABORT 三个关键操作来实现。
事务的语义通常由 ACID 属性概括:
- A - 原子性:事务中的所有操作要么全部完成,要么全部不发生。这与故障恢复相关。
- C - 一致性:事务必须保持数据库的内部一致性约束(如参照完整性)。本节课不重点讨论。
- I - 隔离性:并发执行的事务不会看到彼此的中间结果。一个事务看到的所有写入,要么是另一个事务的全部写入,要么完全没有。
- D - 持久性:一旦事务提交,其结果将永久保存在稳定存储中。
在分布式场景中,例如一个分片的键值存储,事务的目标是跨多台机器执行原子操作。考虑一个转账例子:客户端需要从服务器A的账户 x 转出1美元,并存入服务器B的账户 y。我们希望这两个 PUT 操作要么都发生,要么都不发生,并且其他客户端不能看到转账的中间状态(例如,x 已扣款但 y 未收款)。
隔离性与可串行化
上一节我们介绍了事务的基本概念和ACID属性。本节中,我们来看看隔离性的具体含义以及如何定义“正确”的并发执行。
隔离性的标准定义是 可串行化。这意味着,尽管多个事务可能并发执行,但最终结果必须等同于这些事务按某种串行顺序(一个接一个)执行的结果。
理解可串行化
假设账户 x 和 y 初始值均为10。我们有两个事务:
- T1(转账):
x = x - 1;y = y + 1 - T2(查询):读取并打印
x和y的值。
合法的串行结果只有两种:
- 先执行T1,后执行T2:输出
(9, 11),最终账户为(9, 11)。 - 先执行T2,后执行T1:输出
(10, 10),最终账户为(9, 11)。
任何导致其他结果(例如,T2读到 x=9, y=10)的并发执行都是非法的,必须被禁止。可串行化比线性一致性(Linearizability)略弱,因为它不要求事务的排序与其实时顺序完全一致,但它仍然是一个强大且便于编程的保证。
并发控制:悲观 vs. 乐观
为了实现可串行化,系统需要采用并发控制机制来禁止那些非法的执行交错。主要有两类方法:
悲观并发控制:在执行操作前先获取必要的“许可”(如锁),确保不会发生冲突。如果无法获取许可,则等待。
乐观并发控制:先直接执行操作,在提交时检查是否存在冲突。如果存在冲突,则中止并重试事务。
可以类比为:悲观方法是“先获得许可再行动”,乐观方法是“先行动,如果错了再道歉”。本节课我们聚焦于悲观方法,特别是两阶段锁。
两阶段锁
上一节我们了解了并发控制的两种哲学。本节中,我们深入探讨实现可串行化最常用的悲观协议之一:两阶段锁。
在两阶段锁中,每个数据项(如变量 x)都关联一个锁。协议遵循两条核心规则:
- 事务在读写任何数据项之前,必须先获得该数据项的锁。
- 事务在获得一个锁之后,在提交或中止之前,不能释放这个锁。

规则2中“持有锁直至事务结束”的阶段划分,正是“两阶段”的由来:第一阶段是增长阶段(不断获取锁),第二阶段是收缩阶段(在事务结束时释放所有锁)。
为何需要持有锁至事务结束?

考虑转账事务T1(x--, y++)和查询事务T2(read x, read y)。如果T1在写入 x 后立即释放 x 的锁,那么T2可能读到 x 的新值(9)和 y 的旧值(10),这是一个非法状态。持有锁至提交确保了其他事务无法看到任何中间状态。
死锁处理
两阶段锁可能导致死锁。例如,T1先锁 x 后请求锁 y,而T2先锁 y 后请求锁 x,双方互相等待。
以下是事务系统处理死锁的两种常见策略:
- 超时:如果一个事务持有锁时间过长,则假定其可能陷入死锁并中止它。
- 等待图:系统动态维护一个图,其中节点是事务。如果事务A等待事务B持有的锁,则添加一条边
A -> B。当图中出现环时,即检测到死锁,系统可以选择中止环中的一个事务。
被中止的事务会释放其持有的所有锁,从而让其他事务得以继续。应用程序通常可以选择重试已中止的事务。
两阶段提交
前面我们讨论了如何通过锁在单机或多核环境下实现隔离性。现在,我们转向分布式环境中的核心挑战:如何跨多台机器实现事务的原子提交。这就是两阶段提交协议要解决的问题。
在一个典型场景中,有一个协调者(Coordinator)和多个参与者(Participants,例如分片服务器A和B)。客户端向协调者发起一个跨分片事务(如从 x 转账到 y)。
协议流程(无故障情况)
- 执行阶段:协调者向所有参与者发送事务操作(如
PUT)。每个参与者锁定所需数据项,预写日志,但不提交。完成后,参与者回复“已准备”。 - 准备阶段:协调者向所有参与者发送
PREPARE消息,询问是否可提交。 - 投票阶段:每个参与者检查自身状态(如日志是否成功写入)。如果可以提交,则回复
YES并做出不可撤销的提交承诺;否则回复NO。 - 决定阶段:
- 如果协调者收到所有参与者的
YES,则决定提交。它将COMMIT决定写入持久日志,然后向所有参与者发送COMMIT消息。 - 如果收到任何一个
NO,则决定中止。它向所有参与者发送ABORT消息。
- 如果协调者收到所有参与者的
- 完成阶段:参与者收到
COMMIT后,将数据正式写入数据库并释放锁;收到ABORT后,丢弃预备数据并释放锁。然后回复协调者“完成”。
故障处理
两阶段提交的强大之处在于它能处理各种故障:
- 参与者在回复
YES后崩溃:恢复后,参与者必须查看日志。如果发现自己处于“已准备”状态,则必须继续等待协调者的最终决定(COMMIT或ABORT),而不能单方面中止。这期间它持有的锁不会释放。 - 协调者在发送
COMMIT前崩溃:如果协调者尚未做出决定(未写入日志),它可以重启后单方面决定中止。 - 协调者在做出
COMMIT决定后、发送消息前崩溃:恢复后,协调者从日志中读取到COMMIT决定,并重新向参与者发送COMMIT消息。参与者可能处于阻塞等待状态。
关键点:一旦参与者回复 YES,它就进入了一个“不确定”状态,必须等待协调者的最终指令。这是导致2PC可能阻塞的原因——如果协调者永久故障,某些参与者可能永远等待。实践中,常通过复制协调者(例如使用Raft共识算法)来使其高可用,从而避免此问题。
2PC 与 Raft 的区别
虽然2PC和Raft都涉及多节点协调,但它们解决不同问题:
- Raft:用于在多个副本间复制相同的状态,实现高可用。
- 2PC:用于在持有不同数据的多个节点间,就一个分布式操作的原子提交达成一致。
总结
本节课中我们一起学习了分布式事务的核心机制。
我们首先回顾了事务的ACID属性,并明确了在分布式环境下对隔离性和原子性的追求。对于隔离性,我们学习了可串行化的标准以及通过两阶段锁实现它的方法。2PL通过规则性地获取和持有锁,确保了并发事务的正确交错。
对于跨机器的原子提交,我们深入探讨了两阶段提交协议。2PC通过“准备-提交”两个阶段,协调所有参与者达成一致的决定,即使在部分节点故障时也能保证原子性。我们也分析了其潜在的阻塞问题及解决方案。

理解2PL和2PC是阅读现代分布式事务系统论文的基础。在接下来的课程中,我们将看到这些思想如何在真实的工业级系统中被应用、优化和扩展。
课程 P14:Spanner 分布式数据库系统 🗄️
在本节课中,我们将要学习 Google 的 Spanner 分布式数据库系统。Spanner 是一个支持跨广域网(WAN)事务的强大系统,它允许数据被分片并存储在全球不同数据中心的服务器上,同时为应用程序提供具有 ACID 语义的事务支持。我们将重点探讨其读写事务和只读事务的实现机制,特别是如何利用快照隔离和同步时钟来实现高性能的只读操作。
高层架构概览 🌐
上一节我们介绍了 Spanner 的基本目标,本节中我们来看看它的高层组织架构。

Spanner 系统包含多个数据中心。为了便于理解,我们可以假设有三个数据中心:A、B 和 C。数据被组织成多个分片,每个分片包含一部分数据库行或键值对。例如,一个分片可能包含键 a 到 m。
为了获得容错能力和高可用性,每个分片会在不同的数据中心之间进行复制。这些位于不同数据中心的副本共同组成一个 Paxos 组。这类似于实验 3 中的 Raft 组,但运行在全球范围的数据中心之间。
使用多个分片可以实现并行性。如果事务涉及不同的、互不相交的分片,它们就可以完全并行地执行。


Paxos 协议不仅提供了数据复制,还带来了额外的好处:它允许系统在仅获得多数副本响应的情况下继续运行。这意味着系统可以容忍单个数据中心故障或网络延迟,而不会对整体性能产生太大影响。
最终,Spanner 的客户端(通常是某个 Google 服务的后端服务器,如 Gmail 服务器)可以与地理位置最近的副本进行通信。特别地,只读事务可以由本地副本执行,无需与其他数据中心进行任何通信。
核心挑战与目标 🎯

在了解了高层架构后,我们来看看 Spanner 设计时需要应对的几个主要挑战。
以下是 Spanner 需要解决的三个核心挑战:
- 高效的只读事务:我们希望只读事务无需与其他服务器通信,但必须确保读取能看到最新的写入,并保持强一致性(如线性一致性)。
- 跨分片事务:Spanner 需要支持跨多个分片的事务(例如银行转账),并保证其 ACID 语义。
- 事务的串行化:所有事务(包括只读和读写)都必须是可串行化的,Spanner 实际上提供了更强的“外部一致性”保证。
对于读写事务,Spanner 使用了两阶段锁(2PL)和两阶段提交(2PC)协议。我们首先讨论读写事务,然后深入探讨只读事务如何实现高性能。
读写事务的实现 🔒
上一节我们提到了核心挑战,本节中我们来看看读写事务的具体实现。

读写事务通过两阶段锁(2PL)和两阶段提交(2PC)来实现。我们通过一个简化的银行转账例子来说明:从账户 X(位于分片 A)转账 1 美元到账户 Y(位于分片 B)。
- 客户端读取与加锁:
- 客户端(例如 Gmail 后端服务器)启动事务,读取账户
X和Y的当前值。 - 读取请求会发送到相应分片(Paxos 组)的领导者。
- 每个分片的领导者维护着一个锁表,它会为这个事务在
X和Y上记录并持有锁。锁表存储在领导者内存中,并未复制。如果领导者在事务过程中故障,事务将中止。
- 客户端(例如 Gmail 后端服务器)启动事务,读取账户




- 本地计算与提交:
- 客户端在本地执行计算(从
X减 1,向Y加 1)。 - 完成后,客户端将事务提交给一个事务协调者。协调者本身也是一个 Paxos 组,以确保高可用性。
- 客户端在本地执行计算(从



- 两阶段提交(2PC)流程:
- 准备阶段:协调者将更新发送给分片 A 和 B 的领导者。领导者们将修改写入预写日志(WAL),并将“准备就绪”状态通过 Paxos 复制到其组内,确保持久化。此时,锁被升级或保持。
- 提交阶段:当所有参与者都回复“准备就绪”后,协调者通过 Paxos 写入“提交”决定。然后通知各参与者。参与者收到后,正式提交修改,并释放锁。



关键点:与经典 2PC 的主要区别在于,事务协调者和每个参与者都是高可用的 Paxos 组。这避免了因单点故障导致的事务阻塞。



只读事务与快照隔离 📸



读写事务虽然强大,但涉及广域网通信,延迟较高。本节中我们来看看 Spanner 如何让只读事务变得非常高效。
只读事务只进行读取操作,不进行写入。Spanner 通过以下设计使其非常快:
- 从本地副本读取,无需跨数据中心通信。
- 不获取任何锁,因此不会阻塞读写事务。
- 无需参与两阶段提交。

根据论文中的性能数据,只读事务的延迟在 5-10 毫秒级别,比读写事务快约十倍。
当然,挑战在于如何在不加锁、不进行全局协调的情况下,依然保证正确性(可串行化和外部一致性)。Spanner 的解决方案是快照隔离。
快照隔离是一个标准的数据库概念。其核心思想是:
- 为每个事务分配一个唯一的、全局有序的时间戳。
- 对于读写事务,时间戳在提交开始时分配。
- 对于只读事务,时间戳在事务开始时分配。
- 数据存储是多版本的。每个键值对会保存其历史版本及对应的时间戳。
- 事务在其时间戳对应的“快照”上执行。读取一个键时,返回的是该时间戳之前提交的最新版本。
公式表示:对于一个在时间戳 T_read 发起的只读事务,读取键 K 时,返回的值 V 满足:
V = argmax_{V_i} (TS(V_i) <= T_read),其中 TS(V_i) 是值 V_i 被写入时的时间戳。
这种机制自然地提供了可串行化,因为所有事务都按时间戳顺序生效。
外部一致性与同步时钟 ⏰
快照隔离提供了可串行化,但 Spanner 还追求更强的外部一致性。本节中我们来看看这个目标以及实现它的关键——同步时钟。


外部一致性要求:如果事务 T2 在事务 T1 提交之后才开始,那么 T2 必须能看到 T1 的所有写入。这类似于线性一致性,但是是在事务级别。


为了按时间戳顺序执行事务以实现外部一致性,整个系统必须对“时间”有统一、准确的认识。这就要求不同服务器上的时钟必须高度同步。
Spanner 使用了一个称为 TrueTime 的全球时间同步服务。TrueTime 不直接返回一个精确的时间点,而是返回一个时间区间 [earliest, latest],并保证真实的当前时间一定落在这个区间内。这个区间的宽度 ε 就是时钟的误差范围,通常在几毫秒以内。

基于 TrueTime,Spanner 修改了事务协议中的两条规则:

- 开始规则:为事务分配时间戳时(只读事务在开始时,读写事务在提交开始时),选择从 TrueTime API 获取的时间区间的
latest值。这确保了事务的时间戳一定晚于真实的开始时间。 - 提交等待规则:对于读写事务,在协调者做出提交决定后(即选定了提交时间戳
T_commit),它必须等待,直到 TrueTime API 返回的时间区间的earliest值大于T_commit。这确保了事务的提交在真实时间中一定发生在T_commit之后。
通过这两条规则,Spanner 保证了事务时间戳的全局顺序与真实时间的先后关系一致,从而实现了外部一致性。虽然提交等待可能引入少量延迟,但由于时钟精度高(ε 小),这个延迟通常很小。
总结 📝
本节课中我们一起学习了 Google Spanner 分布式数据库系统的核心设计。
- 读写事务通过两阶段锁(2PL)和两阶段提交(2PC)实现,且所有参与者(分片)和协调者都是Paxos 组,从而获得了高可用性。
- 只读事务通过快照隔离和从本地副本读取来实现极低的延迟。它们不加锁,不参与 2PC。
- 为了保证更强的外部一致性(类似线性一致性),Spanner 依赖全局的TrueTime时钟服务。通过开始规则和提交等待规则,系统确保了事务时间戳的顺序与真实时间的因果关系一致。

Spanner 的强大之处在于,它为程序员提供了一个跨越全球数据中心、支持强一致性 ACID 事务的简单编程模型,同时通过精巧的设计,使常见的只读操作保持了极高的性能。
课程15:乐观并发控制 (FaRM) 🚀
在本节课中,我们将要学习 FaRM 系统。FaRM 是一篇 2015 年的研究论文,旨在探索如何构建一个高性能的事务处理系统。其核心目标是实现极高的吞吐量,例如在 TATP 基准测试中,使用 90 台机器达到了每秒 1.4 亿个事务。这与我们之前学习的 Spanner 系统(每秒处理 10 到 100 个事务)形成了鲜明对比。FaRM 专注于单个数据中心内的高性能内存数据库,并提供严格的可串行化保证。

为了实现高性能,FaRM 结合了多种关键技术:数据分片、非易失性 DRAM、内核旁路以及支持 RDMA 的网卡。这些技术共同消除了传统系统中的存储、CPU 和网络瓶颈。特别地,FaRM 采用了乐观并发控制 方案,这与我们之前常见的悲观锁方案不同,它允许读取操作无需获取锁,从而更好地利用 RDMA 的低延迟特性。
系统架构与设置 🏗️
上一节我们介绍了 FaRM 的高层目标和技术概览。本节中我们来看看 FaRM 的具体系统架构是如何组织的。


FaRM 系统由多台机器通过高速数据中心网络连接而成。数据被划分为多个 区域 ,每个区域大小为 2GB,并存储在机器的 DRAM 中。整个数据库的数据集必须能够容纳在所有机器的 DRAM 总和之内。

为了实现容错,每个区域会被复制到多台机器上,采用主备复制模式。一台机器可能同时是某些区域的主节点和其他区域的备节点。一个独立的 配置管理器 与 ZooKeeper 协同工作,负责维护区域到其主、备节点的映射关系。
为了处理整个数据中心的故障(如断电),每台机器都配备了不间断电源。在全局停电时,UPS 提供足够的时间让机器将内存中的数据(包括区域内容和事务日志)刷新到 SSD 中。待电力恢复后,系统可以从 SSD 重新加载状态。
以下是系统架构的关键组件列表:
- 区域:2GB 大小的数据分片,存储在内存中。
- 主备复制:每个区域有一个主节点和一个或多个备节点,用于容错。
- 配置管理器:跟踪区域到主备节点的映射。
- 非易失性 DRAM:配合 UPS 和 SSD,处理相关故障。
内存布局与 API 💾
了解了整体架构后,我们来看看数据在内存中是如何组织的,以及应用程序如何与 FaRM 交互。
在 FaRM 的内存中,每个区域本质上是一个大的字节数组。对象存储在其中,并通过一个唯一的对象标识符 OID 来寻址。OID 由区域编号和区域内的偏移量组成。每个对象都有一个 64 位的头部,其中最高位是锁位,低 63 位是版本号。版本号在乐观并发控制中起着至关重要的作用。
应用程序通过一个简单的事务 API 与 FaRM 交互。

以下是事务 API 的主要调用:
txbegin():开始一个新事务。read(oid):读取指定 OID 的对象。write(oid):准备写入(更新)指定 OID 的对象。实际的修改在应用程序本地内存中进行。txcommit():提交事务。可能成功,也可能因冲突而中止,此时应用程序需要重试事务。
一个事务可以读写多个位于不同区域的对象,因此 FaRM 需要一种跨区域的原子提交协议,这类似于两阶段提交。




性能关键技术:内核旁路与 RDMA ⚡

我们已经知道 FaRM 将数据全放在内存中以避免存储瓶颈。接下来,我们看看它如何通过内核旁路和 RDMA 技术来进一步降低 CPU 和网络开销。
内核旁路 允许用户态应用程序直接与网卡交互,绕过操作系统内核。FaRM 进程可以将其地址空间的一部分直接映射到网卡的发送和接收队列上。这样,应用程序可以直接向队列写入数据包或从中读取,无需进行昂贵的系统调用。同时,FaRM 使用轮询线程来检查接收队列,避免了网卡中断带来的开销。
RDMA 允许一台机器的网卡直接读取或写入另一台机器指定内存地址的内容,而无需远程服务器的 CPU 参与。FaRM 主要利用两种 RDMA 操作:
- 单边 RDMA 读:客户端网卡可以直接从服务器内存中读取数据(如对象及其版本号)。
- 写入 RDMA:客户端网卡可以直接将数据(如日志记录)写入服务器内存的特定位置(如日志区域),并等待网卡确认。


此外,FaRM 还利用写入 RDMA 来实现高效的 RPC:客户端将消息直接写入服务器的消息队列,服务器端的轮询线程发现后进行处理并回复。这些技术使得远程内存访问的延迟极低(约 5 微秒)。
乐观并发控制协议 🔄
上一节介绍的技术(尤其是 RDMA)带来一个核心挑战:如何在不要求服务器端过多参与的情况下实现事务。这直接推动了 FaRM 采用乐观并发控制方案。
在 OCC 中,事务的执行阶段不获取任何锁。事务读取对象时,通过单边 RDMA 获取对象数据和当前的版本号,并在本地进行修改。在提交时,才进行验证,检查所读对象是否被其他并发事务修改过。如果所有读取对象的版本号都未变,则提交;否则,中止并重试。

这种方案特别适合 FaRM,因为读取操作可以完全通过高效的单边 RDMA 完成,无需服务器端处理。而写入操作则需要在提交阶段通过一个类两阶段提交协议来原子化地完成。



事务提交协议详解 📝




现在,我们深入探讨 FaRM 事务提交协议的具体步骤。该协议分为五个阶段,确保了严格可串行化和容错性。
假设一个事务读取了对象 R(位于区域 3),并要写入对象 W1(区域 1)和 W2(区域 2)。提交协议步骤如下:


第 1 阶段:加锁
协调者(即运行事务的客户端)向所有待写入对象所在的主节点发送 写入 RDMA 请求,将一条锁记录追加到主节点的日志中。锁记录包含事务ID、对象OID、新值以及读取时的版本号。
主节点上的一个线程轮询日志,看到新记录后,尝试使用原子操作(test-and-set)获取该对象头部的锁位。
- 如果成功上锁,则通过 写入 RDMA 向协调者的消息队列发送“锁获成功”消息。
- 如果锁已被占用,则发送“锁获失败”消息,协调者将中止事务。
第 2 阶段:验证
协调者对所有只读但不修改的对象(如本例中的 R),发起单边 RDMA 读,获取其当前的 64 位头部信息(包含版本号和锁位)。
协调者检查:1) 锁位是否被置位(表示正被其他事务修改);2) 版本号是否与事务开始时读取的一致。
如果任一检查失败,协调者将中止事务(并通知相关主节点释放锁)。否则,进入下一阶段。此时事务通过了验证,达到了逻辑上的“提交点”。


第 3 阶段:提交至备份
协调者向所有相关备份节点发送 写入 RDMA,将提交备份记录(内容与锁记录相同)追加到备份节点的日志中,并等待网卡确认。这一步确保了即使主节点故障,数据也不会丢失。

第 4 阶段:提交至主节点
协调者向所有相关主节点发送 写入 RDMA,将提交记录(主要包含事务ID)追加到主节点的日志中,并等待网卡确认。
一旦任一主节点的提交记录确认到达,事务即被视为永久提交。协调者可以通知应用程序提交成功。




第 5 阶段:截断
这是一个后台清理阶段,用于截断已处理完毕的日志条目,释放空间。

正确性示例与总结 🎯

最后,我们通过一个简单例子来理解协议如何保证正确性,并对本节课内容进行总结。



考虑两个并发事务 T1 和 T2,都执行“读取 x,然后 x++,写入 x”的操作。假设 x 初始版本为 0。
- T1 和 T2 都通过单边 RDMA 读取到 x=0(版本0)。
- 在提交阶段,T1 和 T2 都进入锁阶段。假设 T1 先成功获取 x 的锁。
- T1 随后通过验证(版本未变),并成功完成提交,将 x 更新为 1,版本号递增为 1。
- T2 在锁阶段可能因锁已被占而直接失败,或者在验证阶段发现 x 的版本号已变为 1 而与读取时的 0 不符,从而中止。
- 中止的 T2 可以重试,此时它会读取到 x=1(版本1),并可能成功提交,将 x 更新为 2。




这个例子展示了 OCC 如何通过提交时的验证来解决写-写冲突,确保事务的串行化执行。

本节课总结
在本节课中,我们一起学习了 FaRM 系统,一个追求极致性能的内存事务处理系统。我们了解了其架构如何利用数据分片、非易失性 DRAM、内核旁路和 RDMA 网络来消除性能瓶颈。重点是,FaRM 为了充分利用 RDMA 的低延迟、无服务器端参与的特性,采用了乐观并发控制协议。我们详细剖析了该事务提交协议的五个阶段:加锁、验证、提交至备份、提交至主节点和截断。这个协议在保证严格可串行化和容错性的同时,使得只读事务和低冲突的写入事务能够获得极高的性能。FaRM 代表了在特定假设(数据全内存、低冲突 workload、数据中心内)下对事务性能边界的一次成功探索。
课程 P16:第15讲(续) - 乐观并发控制 (FaRM) 第二部分 🚀
在本节课中,我们将继续学习 FaRM 系统。我们将通过一个具体的例子深入探讨其如何保证严格的可串行化,并简要分析其容错机制的工作原理。最后,我们将总结 FaRM 的特点与局限性。
回顾:无故障时的事务流程
上一节我们介绍了 FaRM 事务在没有故障时的基本流程。本节中,我们来看看一个更复杂的并发场景。
在 FaRM 中,应用程序事务分为两个阶段:
- 执行阶段:从不同分片读取对象。
- 提交阶段:包含锁获取、读取验证、备份复制和最终提交等步骤。
关键点在于,对于只读对象,验证通过单边 RDMA 操作完成,无需服务器参与,速度极快。对于写入对象,则需要获取锁并在服务器端处理。

并发控制与严格可串行化 🔄
为了检验并发控制的正确性,我们来看一个经典的测试案例,它涉及两个事务 T1 和 T2。
以下是两个事务的逻辑:
- 事务 T1:如果
x == 0,则设置y = 1。 - 事务 T2:如果
y == 0,则设置x = 1。
根据可串行化要求,最终结果只能是 (x=1, y=0) 或 (x=0, y=1),而 (x=1, y=1) 是不允许的,因为它违反了任何串行执行顺序。
场景分析
假设 T1 和 T2 几乎同时开始执行:
- 两者都读取到
x=0和y=0。 - T1 需要写入
y,T2 需要写入x。 - 假设 T1 先进入提交阶段:
- 它成功获取了对象
y的锁。 - 它验证读取的对象
x,由于x未被修改,验证成功。 - T1 提交。
- 它成功获取了对象
- 接着,T2 进入提交阶段:
- 它尝试获取对象
x的锁(成功,因为x未被锁定)。 - 它尝试验证读取的对象
y。此时,由于 T1 已经获取了y的锁并可能更新了它,y的版本号或锁位已改变。 - 因此,T2 对
y的验证失败,事务 T2 将中止。
- 它尝试获取对象

通过这个机制,FaRM 防止了 (x=1, y=1) 这个非法结果的出现,保证了严格可串行化。
关于只读事务的说明
以下是关于验证阶段的一个关键点:
- 对于纯只读事务(不修改任何对象),协议仅通过单边 RDMA 读取和验证版本号即可完成,无需获取任何锁,这是 FaRM 高性能的重要原因之一。
- 验证阶段对于混合事务(同时包含读和写)至关重要,它确保了事务读取数据后,在提交前这些数据没有被其他事务修改。

容错机制简介 🛡️
讨论完并发控制,我们来看看 FaRM 如何应对故障。核心挑战是:在向应用程序报告“事务已提交”后,若发生崩溃,必须确保该事务的所有写入都不会丢失。

我们再次回顾提交阶段的几个关键步骤和持久化信息:
- 锁记录:存储在对象的 Primary 副本上,包含对象ID、版本号和新值。它不指明事务是否已提交。
- 提交备份记录:存储在对象的 Backup 副本上,是锁记录的副本。
- 提交主记录:事务协调者通知 Primary 副本事务已提交。

崩溃恢复场景



假设在最终提交后,系统发生了部分故障。FaRM 的复制设计(每个对象有一个 Primary 和一个 Backup)确保了数据的可恢复性。

考虑一个最坏情况:某个对象的 Backup 副本和另一个对象的 Primary 副本在提交确认后崩溃。恢复进程可以通过检查存活的副本上遗留的提交记录和锁记录,来重构并确定哪些事务已经提交,从而持久化其写入。
这种设计保证了,一旦协调者向应用程序返回成功,即使在后续发生故障,事务的结果也是持久化的。

总结:FaRM 的特点与局限 📊
本节课中我们一起学习了 FaRM 如何通过乐观并发控制处理复杂的事务冲突,以及其容错机制的基本原理。最后,我们来总结一下 FaRM 系统的核心特点与局限:
主要优势:
- 速度极快:利用 RDMA 和单边操作,尤其擅长处理只读和低冲突事务。
- 内存存储:所有数据常驻内存,访问延迟极低。
假设与局限:
- 低冲突工作负载:其乐观并发控制机制在事务冲突频繁时会导致大量中止,性能下降。
- 数据全内存:数据库容量受限于集群总内存大小,成本较高。
- 数据中心内复制:不支持跨地理区域的同步复制,无法应对整个数据中心级别的故障。
- 特殊硬件依赖:严重依赖 RDMA 网卡和不同断电源 (UPS) 等硬件来保证高性能和持久性。

FaRM 代表了在特定硬件和负载假设下,追求极致性能的分布式事务系统设计。它结束了我们关于分布式事务的系列讨论。从下一节课开始,我们将转向分布式系统中的其他主题。
课程 P17:Lecture 16 - 大数据 - Spark 🚀


在本节课中,我们将要学习 Spark,这是一个广泛用于大规模数据处理的分布式计算框架。我们将了解其核心概念、编程模型、执行方式以及容错机制,并通过具体示例来理解其优势。
概述
Spark 是 Hadoop MapReduce 的继任者,被设计用于处理大规模数据科学计算。它支持比 MapReduce 更广泛的应用,尤其擅长迭代计算。Spark 由 Databricks 公司商业化,同时也是一个非常流行的开源项目。
Spark 的核心:RDD 与编程模型
Spark 的核心抽象是弹性分布式数据集(RDD)。RDD 是一个不可变的、分区的数据集合,可以通过一系列确定性转换来创建。
RDD 的特性
RDD 具有两个关键特性:
- 惰性计算:转换操作(如
filter,map)不会立即执行,而是构建一个计算谱系图(lineage)。只有遇到行动操作(如count,collect)时,计算才会真正触发。 - 不可变性:RDD 是只读的。任何转换都会生成一个新的 RDD,而不会修改原始 RDD。
转换与行动
RDD 的 API 主要分为两类操作:
- 转换(Transformations):将一个 RDD 转换为另一个 RDD,例如
filter,map,join。这些操作是惰性的。 - 行动(Actions):触发实际计算并返回结果给驱动程序或写入存储系统,例如
count,collect,save。
以下是一个简单的代码示例,展示了如何创建和使用 RDD:
// 创建一个代表文本文件的 RDD(此时并未读取数据)
val lines = sc.textFile("hdfs://...")
// 定义一个转换,过滤出包含“ERROR”的行
val errors = lines.filter(_.startsWith("ERROR"))
// 将过滤后的 RDD 持久化到内存中,以便后续重用
errors.persist()
// 触发第一个计算:统计错误行数
errors.count()
// 触发第二个计算:收集所有错误行(由于已持久化,无需重新计算)
errors.collect()
上一节我们介绍了 RDD 的基本概念和惰性计算特性。本节中我们来看看 Spark 是如何执行这些计算的。
执行模型与依赖
当行动操作被调用时,Spark 调度器会根据 RDD 的谱系图创建一个执行计划。这个计划被组织成多个阶段(Stage)。
窄依赖与宽依赖
阶段划分的依据是 RDD 之间的依赖关系:
- 窄依赖:父 RDD 的每个分区最多被子 RDD 的一个分区使用。例如
map、filter操作。这类操作可以在单个节点上独立、并行地执行,无需跨节点通信。 - 宽依赖:父 RDD 的分区可能被子 RDD 的多个分区使用。例如
groupByKey、reduceByKey操作。这类操作通常需要Shuffle,即跨节点混洗数据,是计算中的关键屏障。
调度器会将连续的窄依赖操作流水线化(pipelining),合并到同一个阶段中执行,以提高效率。而宽依赖则会划分出新的阶段。
执行流程
- Driver 程序:用户编写的 Spark 程序作为 Driver 运行,它定义了 RDD 的转换和行动。
- 集群管理器:Driver 向集群管理器(如 YARN、Mesos 或 Spark Standalone)申请资源。
- 任务执行:集群管理器分配 Worker 节点。Driver 将根据阶段划分,将任务(Task)发送给各个 Worker。每个任务处理一个 RDD 分区。
- 流水线计算:在同一个阶段内,多个窄依赖转换会以流水线方式在同一个任务中连续执行。
我们了解了执行模型后,自然会关心在分布式环境下如何处理节点故障。接下来,我们将探讨 Spark 的容错机制。
容错机制
Spark 的容错基于 RDD 的不可变性和确定性转换这一特性。
基于谱系图的恢复
由于每个 RDD 都记录了它是如何从其他 RDD 转换而来的谱系图,当某个分区的数据丢失时(例如存储该分区的 Worker 节点故障),Spark 可以通过重新执行该分区谱系图中的所有转换来重建数据。这与 MapReduce 的重新执行失败任务思路类似。
检查点(Checkpointing)
对于包含宽依赖的漫长谱系图,从头开始重新计算可能代价很高。为此,Spark 提供了检查点功能。程序员可以将某个关键的中间 RDD 持久化到像 HDFS 这样的可靠存储中。
// 将 RDD 设置检查点(会物化到可靠存储)
rdd.checkpoint()
设置检查点后,如果发生故障,恢复过程可以从最近的检查点 RDD 开始重新计算,而不是从最初始的数据开始,从而节省大量时间。检查点操作会触发一个额外的作业来保存数据,因此需要权衡检查点的频率。
理解了基础原理和容错后,让我们通过一个实际案例来看看 Spark 如何优雅地处理复杂计算。
案例研究:PageRank 算法
PageRank 是一个迭代算法,用于计算网页的重要性。它完美展示了 Spark 在迭代计算和内存重用方面的优势。
以下是 Spark 实现 PageRank 的简化代码:
// 加载链接关系图 (url, neighbor-list)
val links = sc.parallelize(Array(("A", List("B", "C")), ("B", List("C")), ("C", List("A")))).persist()
// 初始化排名
var ranks = links.mapValues(v => 1.0)
for (i <- 1 to 10) {
// 将当前页面的排名贡献(rank/size)发送给它的所有出链页面
val contribs = links.join(ranks).flatMap {
case (url, (neighbors, rank)) =>
neighbors.map(neighbor => (neighbor, rank / neighbors.size))
}
// 将所有页面收到的贡献值求和,得到新的排名
ranks = contribs.reduceByKey(_ + _).mapValues(0.15 + 0.85 * _)
}
ranks.collect()
Spark 在此案例中的优势
- 内存中迭代:
linksRDD 通过persist()被缓存在内存中,在每次迭代中重复使用,避免了像 MapReduce 那样每轮迭代都要从磁盘读取的巨大开销。 - 数据局部性优化:如果
links和ranks都使用相同的键(如 URL)进行哈希分区,那么join操作可以转化为窄依赖。这意味着具有相同键的数据会位于同一台机器上,join可以在本地进行,无需昂贵的网络 Shuffle。 - 清晰的编程模型:算法逻辑通过一系列 RDD 转换清晰地表达出来,与单机思维类似,易于理解和维护。
总结

本节课中我们一起学习了 Spark 分布式计算框架。我们首先了解了其核心抽象 RDD 及其惰性计算和不可变性。接着,我们探讨了其执行模型,包括基于窄依赖和宽依赖的阶段划分与流水线执行。然后,我们分析了 Spark 基于谱系图重建和检查点机制的容错设计。最后,通过 PageRank 案例,我们看到了 Spark 如何高效支持迭代计算、利用内存缓存以及通过分区优化来提升性能。Spark 通过将数据保留在内存中并提供丰富的转换操作,实现了比传统 MapReduce 更强大的表达能力和更优的性能,使其成为大规模数据处理的重要工具。
课程 P18:第17讲 - 缓存一致性 - Facebook的Memcached 🚀
在本节课中,我们将学习Facebook在2013年发表的一篇关于Memcached的经验性论文。这篇论文并非介绍全新的系统构建思想,而是总结了如何利用现成组件(如MySQL、Memcached)构建一个能够支持每秒数十亿请求的大型系统的实践经验。我们将重点关注其设计背后的性能驱动因素,以及如何在性能与一致性之间取得平衡。


概述 📋

Facebook的Memcached系统设计主要受性能需求驱动,旨在支持极高的读取吞吐量。其一致性模型与我们之前讨论的提供线性一致性的系统不同,它追求一种更弱的一致性——最终一致性,这对于Facebook的许多应用(如新闻推送)来说是足够的。系统的核心挑战在于保护后端数据库免受过载,并处理由此引入的各种一致性问题。
网站架构的演变 🏗️

上一节我们介绍了论文的背景和目标,本节中我们来看看一个典型网站随着用户增长,其架构是如何演变的。
阶段一:单机架构

如果你刚开始建立一个网站,用户量很少,架构可以非常简单。



- 网络服务器:例如 Apache。
- 应用框架:例如 PHP、Python。
- 数据库:例如 MySQL,用于存储所有持久化状态。
客户端连接到网站,应用代码在服务器上运行并与数据库交互。所有状态都存储在数据库中,便于备份和容错。

阶段二:前端横向扩展

随着用户量增加,第一个瓶颈通常是应用服务器的计算能力。
解决方案是保持单台数据库服务器不变,但横向扩展无状态的前端服务器(Web服务器 + 应用代码)。所有前端都连接到同一台数据库。这个方案很简单,因为前端无状态,易于扩展,且所有写入都通过单一数据库,不存在一致性问题。




阶段三:数据库分片
当单台数据库无法承受读写压力时,下一步是对数据库进行分片。
将数据按键分布到多台数据库机器上。前端需要知道每个键位于哪个分片。这提供了数据库层面的并行处理能力,提高了吞吐量。但此步骤引入了跨分片事务的复杂性。

阶段四:引入缓存层
为了进一步扩展,特别是应对重读的工作负载(如Facebook的时间线浏览),可以引入缓存层。


以下是引入缓存后的基本架构组件:
- 前端层:大量无状态的Web服务器。
- 缓存层:由Memcached或Redis等开源缓存软件组成的集群。
- 存储层:分片的数据库。


基本工作流程如下:
- 读取时,前端首先尝试从缓存获取数据。
- 若缓存命中,则快速返回。
- 若缓存未命中,则从数据库读取数据,进行一些计算(如生成HTML),然后将结果设置到缓存中,以供后续请求使用。
- 写入时,前端直接更新数据库,并使缓存中对应的键失效。
这种设计被称为旁路缓存,因为缓存位于数据库“旁边”,由应用程序显式管理数据的存入和失效,而不是透明地代理所有请求。
引入缓存带来了两个主要挑战:
- 如何保持缓存与数据库的一致性。
- 如何确保数据库不会因缓存失效或故障而过载。


性能优化策略 ⚡

上一节我们了解了基础架构,本节中我们来看看Facebook为了追求极致性能所做的特定优化。

获得高性能主要有两种策略:分区(分片)和复制。



策略一:分区


将数据分布到多台缓存服务器上,可以增加总容量和并行性。这适用于存储大量不同的键。

核心概念:使用一致性哈希等算法将键映射到特定的缓存服务器。
# 简化的伪代码:确定键k应访问哪个缓存服务器
server_index = hash(k) % number_of_servers
target_server = cache_servers[server_index]
策略二:复制


将相同的数据复制到多台服务器上,有助于应对热键问题(即大量请求集中在少数键上)。复制还能减少每个前端需要连接的服务器数量,缓解网络拥塞。
Facebook在多个层面运用了复制:
- 跨区域复制:在西海岸和东海岸建立两个完整的数据中心副本。所有写入都发送到主区域(西海岸)的数据库,然后异步复制到备份区域(东海岸)。这为地理上分散的用户提供了低延迟的读取。
- 区域内集群复制:在单个数据中心内,将前端和缓存层划分为多个独立的集群。每个集群拥有自己的一组缓存服务器。用户被负载均衡到不同集群。
- 优点:热键在每个集群内都被复制,减轻单点压力;减少了网络连接数和“Incast拥塞”。
- 缺点:冷数据在多个集群中冗余存储,浪费容量。
- 解决方案:引入一个跨集群共享的区域池,用于存储不常访问的冷数据。



保护数据库:关键挑战与解决方案 🛡️
缓存系统的核心目标之一是保护后端数据库。以下是Facebook解决的一些关键挑战:
挑战一:惊群效应
当某个热门键失效后,大量并发的读取请求会同时缓存未命中,并涌向数据库,导致数据库过载。
解决方案:租约
- 当缓存未命中时,Memcached可以向客户端颁发一个短期的、针对该键的租约。
- 只有持有有效租约的客户端,才能将数据设置回缓存。
- 如果某个键被删除(失效),其对应的所有租约也将被作废。
- 其他未持有租约的客户端在获取时会收到一个“重试”提示,从而将请求分散开,避免了向数据库的请求风暴。
挑战二:缓存服务器故障
当一台缓存服务器故障时,原本指向它的所有请求会直接落向数据库,造成风险。

解决方案:Gutter池
- 维护一个额外的、小型的缓存池,称为Gutter池。
- 当客户端无法从主缓存服务器获取数据时(例如超时),它转而尝试从Gutter池获取。
- 第一个在Gutter池未命中的客户端会从数据库读取并填充Gutter池。
- 后续请求便可以从Gutter池获取数据,直到故障的主缓存服务器被替换或恢复。
- 注意:为了减轻Gutter池的压力和简化设计,写入失效消息不会发送到Gutter池。这意味着Gutter池中的数据可能是过时的,但它作为一个临时避风港,核心目的是保护数据库。
挑战三:新集群上线
启动一个新的、空的缓存集群时,所有请求都会未命中并访问数据库,可能导致数据库过载。

解决方案:从暖集群预热
新集群中的客户端在缓存未命中时,不是直接查询数据库,而是先向已有的、数据已预热的老集群(暖集群)请求数据,获取后再设置到自己的新集群缓存中。这平滑地填充了新集群的缓存,避免冲击数据库。

一致性模型与竞态条件处理 ⚖️
尽管系统追求的是最终一致性,但一些基本的正确性仍需保证,例如“读己之所写”。高性能的优化措施引入了一些微妙的竞态条件。

一致性目标
- 非目标:不提供线性一致性。
- 目标:提供最终一致性。写入按顺序应用到数据库,但读取可能滞后几秒钟,这对Facebook的应用是可接受的。
- 额外保证:客户端必须能读到自己的最新写入。

竞态条件一:陈旧设置




场景:
- 客户端C1读取键
k,缓存未命中,从数据库读到值v1,并获得租约L1。 - 在C1将
(k, v1)设置回缓存前,客户端C2更新了k为v2,并删除了缓存中的k(同时使租约L1失效)。 - 随后,C1尝试用已失效的租约
L1执行set(k, v1)。 - 如果成功,缓存中将永久保留陈旧的
v1。



解决方案:租约失效机制。
C2的删除操作会使所有该键的未完成租约失效。因此,C1的set操作会因为租约无效而被Memcached拒绝。C1需要重试读取,此时将从数据库获取到最新的v2。

竞态条件二:冷集群预热

场景(发生在新集群预热期间):
- 键
k在数据库中的值为v2,在暖集群缓存中为v1(已过时)。 - 客户端C1在冷集群中读取
k,未命中。 - C1从暖集群获取到陈旧的
v1。 - C1将
(k, v1)设置到冷集群的缓存中,导致陈旧值被固化。
解决方案:延迟设置。
在集群预热阶段,对刚刚因写入而失效的键,在短时间内(如2秒)禁止任何set操作。这给了数据库更新传播到所有副本的时间。
竞态条件三:跨区域读取自己的写入
场景:
- 位于备份区域的客户端C1向主区域数据库写入,更新键
k,并立即删除本地(备份区域)缓存中的k。 - 紧接着,C1在备份区域读取
k。此时,主区域的数据库更新可能尚未异步复制到备份区域的数据库。 - 因此,C1的读取请求在本地缓存未命中后,会查询备份区域的数据库,从而读不到自己刚刚的写入,违反了“读己之所写”的保证。


解决方案:远程标记。
当备份区域的客户端执行删除时,可以在本地缓存中为该键设置一个“远程标记”。当带有此标记的键被读取时,系统会引导客户端去主区域获取最新值,而不是查询本地的备份数据库。一旦主区域的更新复制完成,该标记即可被移除。
总结 🎯

本节课我们一起学习了Facebook Memcached系统的设计精髓。


- 核心目标:利用现成组件构建支持每秒数十亿请求的超高吞吐系统,性能是首要驱动力。
- 架构演变:从单机到引入缓存层,采用旁路缓存设计,由应用管理缓存内容。
- 性能策略:结合数据分区来提升容量与并行性,以及数据复制(跨区域、区域内集群)来处理热键和优化访问延迟。
- 关键挑战:核心在于保护数据库免受过载。我们探讨了通过租约解决惊群效应、通过Gutter池处理缓存故障、以及通过预热平滑上线新集群。
- 一致性:系统提供最终一致性,并尽力保证“读己之所写”。为了实现高性能,系统引入了一些竞态条件,我们分析了三种主要竞态(陈旧设置、冷集群预热、跨区域读己写)及其解决方案(租约失效、延迟设置、远程标记)。

这篇论文展示了一个现实世界大型系统如何在性能与一致性之间进行精妙的权衡,并通过一系列务实的优化和补丁机制,构建出一个极其成功的分布式缓存架构。
课程 P19:第 18 讲 - Fork 一致性与 SUNDR 🛡️
在本节课中,我们将要学习去中心化系统中的一个核心概念:如何在没有单一可信中心的情况下,构建一个能抵御恶意(拜占庭式)参与者的文件系统。我们将重点探讨 SUNDR 系统提出的 Fork 一致性 模型及其背后的关键技术——签名日志。
概述:去中心化与拜占庭威胁
去中心化系统意味着没有单一权威机构控制整个系统。这与我们之前讨论的、所有参与者都在单一可信机构控制下的系统(如 Raft)有本质不同。在去中心化环境中,参与者可能是拜占庭式的:他们有时遵守协议,有时则可能为了自身利益而欺骗、破坏系统。这使得系统设计更具挑战性,因为它位于分布式系统与安全领域的交汇点,需要借助密码学工具(如签名和哈希)来保障安全。
SUNDR 这篇论文虽然未直接投入实际应用,但它提出的 签名日志 思想极具影响力,后续出现在 Git、比特币等众多系统中。
背景与动机:为何需要 SUNDR?
SUNDR 的设定是一个网络文件系统,类似于我们之前学过的 Frangipani。不同之处在于,SUNDR 假设文件服务器本身可能是恶意的(拜占庭式)。这种威胁模型非常强大,涵盖了诸如利用软件漏洞获取控制权、物理入侵机器、贿赂管理员等多种现实攻击场景。
论文的一个核心动机是防止软件仓库被篡改。例如,2003年 Debian Linux 的开发服务器曾被入侵,攻击者植入了后门。发现后,开发者不得不花费数天时间从备份中甄别哪些文件被篡改,导致开发进程冻结。SUNDR 旨在解决此类问题,确保文件系统的完整性(即数据未被非法修改),而非保密性。
让我们通过一个具体例子来理解问题。假设三位开发者 A、B、C 合作开发一个银行应用 zoobar:
- A 负责修改
auth.py,以支持 MIT 证书认证。 - B 负责修改
bank.py,将其与真实支付系统TechCash对接。 - C 负责最终部署软件。
如果文件服务器被攻陷(成为拜占庭式),可能发生两种攻击:
- 结果1:服务器直接篡改
auth.py或bank.py的内容。 - 结果2(更微妙):服务器有选择性地只向 C 提供 B 修改的新版
bank.py,同时提供 A 未修改的旧版auth.py。这样,支付系统已上线,但用户认证却失效了,造成严重安全漏洞且难以察觉。
初始方案:简单的文件签名及其不足
一个简单的起点是让每个文件修改者对自己的文件进行数字签名。例如,A 修改 auth.py 后,用其私钥对文件内容生成签名。C 下载文件时,用 A 的公钥验证签名,从而确认文件确实来自 A 且未被篡改。
这个方案可以抵御直接的篡改攻击(结果1),但存在严重缺陷:
- 服务器可以发送一个完全不同的文件,并谎称它是
auth.py(可通过在签名中包含文件名修复)。 - 服务器可以发送旧版本的文件,因为签名只认证文件内容,不认证其“新鲜度”。
- 服务器可以有选择性地展示文件(例如,只展示新版
bank.py而隐藏新版auth.py),因为文件之间没有关联。 - 服务器可以声称某个文件不存在。

核心问题在于,简单的文件签名无法提供文件系统的一致性全局视图,客户端无法判断自己看到的是否是所有文件的最新、完整状态。
核心思想:签名操作日志 📝

SUNDR 论文提出了一个强大的核心思想:对文件系统的操作日志进行签名。这个日志不仅记录修改操作,也记录读取(获取)操作。
每个日志条目都包含当前操作(例如“A 修改了 auth.py”)以及之前所有日志条目的加密哈希值。当用户对条目签名时,签名覆盖的正是这个“当前操作+历史哈希”的组合。
用伪代码表示日志条目 i 的结构:
LogEntry[i] = {
operation: “Modify auth.py by A”,
prev_hash: Hash(LogEntry[i-1]), // 前一个条目的哈希
signature: Sign(operation + prev_hash, private_key_of_A)
}
这种设计带来了关键优势:服务器无法在不被察觉的情况下删除或重排日志中间的条目。因为后续条目的签名依赖于被删除条目的哈希值,任何篡改都会导致签名验证失败。
包含“获取”操作的重要性
上一节我们介绍了签名日志的概念,本节中我们来看看为什么读取(获取)操作也必须被记录在日志中。
假设日志中不记录获取操作。考虑以下攻击序列:
- C 请求获取
auth.py。恶意服务器可以返回一个不包含 A 和 B 修改操作的日志前缀,以及旧的auth.py文件。C 验证这个前缀签名有效,且包含自己之前的操作,因此接受并安装了旧版auth.py。 - 随后,C 请求获取
bank.py。这次服务器返回完整的日志(包含 A 和 B 的修改)。C 验证通过,构建文件系统视图,得到了新版bank.py。
从 C 的视角看,A 和 B 的修改似乎是与它的两次读取同时发生的。它无法察觉自己先读到了旧版 auth.py,后读到了新版 bank.py,从而落入了“结果2”的攻击陷阱。
如果将获取操作也签名并加入日志,情况则不同:
- C 获取
auth.py后,会在日志中追加一个“C 获取了 auth.py”的签名条目,并上传给服务器。 - 当 C 再次获取
bank.py时,服务器返回的日志必须包含 C 刚才的获取记录。如果服务器试图返回一个不包含 A 修改的日志前缀,那么这个前缀里自然也不会有 C 的获取记录,C 会因找不到自己的最后操作而拒绝该日志。
因此,记录获取操作是防止服务器向客户端呈现不一致历史视图的关键。

Fork 一致性:能力与极限

尽管签名日志非常强大,但它仍然无法阻止一种特定的攻击:服务器可以对世界进行“分叉”。
由于服务器是唯一存储和分发日志的实体,它可以这样做:
- 为客户端 A 维护并展示一条日志分支。
- 同时,为客户端 B 维护并展示另一条不同的日志分支。
- 只要这两个分支从某个点分叉后就不再合并,并且分别满足签名链的连续性,那么 A 和 B 各自看到的都是一个内部一致但彼此不同的文件系统视图。
这就是 Fork 一致性。它是 SUNDR 在恶意服务器模型下所能提供的最强一致性保证。服务器无法回滚单个客户端的视图(因为客户端会检查自己最后的操作是否在日志中),但它可以创造多个并行演进的分支。
那么,如何检测这种分叉呢?论文提出了两种方法:
- 带外通信:客户端之间直接通信,交换各自看到的最后一条日志条目哈希。如果发现不一致,则表明可能发生了分叉。
- 引入可信时间戳服务:一个受信任的第三方定期向日志中写入带时间戳的条目。由于所有客户端都期望看到连续的时间戳,服务器就很难在不被发现的情况下维持长时间的分叉。
SUNDR 的实际实现:版本向量与快照
上一节我们探讨了概念上的签名日志,本节中我们来看看 SUNDR 如何高效地实现这一思想。实际上,SUNDR 并不维护一个从头开始的完整物理日志,而是为每个用户维护一个文件系统状态的快照,并使用版本向量来协调不同用户的视图。
以下是其核心机制:
- i-handle(用户快照):每个用户都有一个
i-handle,它是其当前文件系统视图的加密哈希。这个哈希实际上是一个 Merkle 树的根哈希,该树涵盖了用户可见的所有文件和目录的元数据及内容哈希。任何文件修改都会导致i-handle更新。 - 版本向量:每个用户的快照都关联一个版本向量。这是一个计数器数组,记录了该用户所见到的、由其他每个用户执行的修改操作次数。版本向量本身也被用户签名。
其工作流程简化如下:
- A 修改
auth.py后,计算新的i-handle_A,并更新版本向量(例如,将 A 自己的计数器加1)。然后对新的i-handle和版本向量签名。 - B 修改
bank.py时,会先获取 A 的最新版本向量和i-handle。然后基于此计算自己的新i-handle_B,并更新版本向量(将 A 的计数器和 B 自己的计数器都更新到最新值),最后签名。 - 当 C 读取文件时,它会下载所有用户(A 和 B)最新的、已签名的版本向量和
i-handle。通过比较版本向量,C 可以确定哪个视图包含了所有已知的最新修改(例如 B 的视图),然后基于该视图的i-handle来读取文件。
这种设计同样能防止服务器隐藏修改:如果服务器想向 C 隐藏 A 的修改,它只能提供旧的、不包含 A 修改的版本向量和 i-handle。但这样一来,它也无法提供 B 基于 A 修改后产生的新 i-handle,因为 B 的签名版本向量证明了它看到了 A 的修改。因此,C 总能检测到视图的不一致。
总结
本节课我们一起学习了在去中心化系统中应对拜占庭式服务器的挑战。SUNDR 系统的核心贡献在于提出了 Fork 一致性 模型以及实现它的关键技术——签名日志。我们了解到:
- 简单的文件签名不足以防御恶意服务器有选择性地展示文件状态。
- 将所有操作(包括读取) 记录在一个前后哈希关联、并经过签名的日志中,可以防止服务器篡改或隐藏历史。
- 即使如此,服务器仍能创建不同的日志分支,导致 Fork 一致性。客户端需要通过带外通信或可信时间戳来检测分叉。
- SUNDR 通过版本向量和基于 Merkle 树的用户快照(
i-handle)来高效实现签名日志的语义,确保用户能获得一个包含所有已知修改的一致文件系统视图。

签名日志的思想影响深远,为比特币等区块链系统奠定了基础。在下节课中,我们将看到比特币如何利用类似的技术,并通过共识算法来解决 Fork 一致性问题。
课程2:线程与RPC 🧵📡
在本节课中,我们将学习分布式编程的两个核心概念:线程(在Go中称为goroutine)和远程过程调用(RPC)。我们将重点讨论Go语言中的实现,因为这是本课程实验所使用的语言。通过理解这些概念,你将能够更好地完成后续的实验项目。
概述:为什么使用Go?🤔
首先,我们来探讨一下为什么本课程选择Go语言。理论上,有许多编程语言可用于分布式编程,Go并非唯一选择。我们在6.824课程中选择Go有几个原因。
首先,Go对线程和RPC有出色的支持,这两点对于分布式编程至关重要。其次,Go拥有垃圾回收器。在进行共享内存式并发编程时,多个线程共享结构体或变量,垃圾回收器能自动管理内存,无需线程操心谁是最后一个引用者,这非常方便。Go还是类型安全的,语言本身简单易学。最后,Go是编译型语言,运行时开销较小,且编写Go程序是一种愉快的体验。
第一部分:线程(Goroutine)🏃♂️
上一节我们介绍了选择Go的原因,本节中我们来看看线程的基本概念。线程是执行线程的简称,在Go中称为goroutine。你可以将其视为一个顺序执行的程序,拥有自己的程序计数器、栈和寄存器。
当你运行go run时,Go会在你的操作系统上创建一个进程。在这个进程中,Go运行时系统最初只有一个执行线程,即主线程。但Go提供了创建新线程的原语。
线程的创建与操作
线程可以与其他线程共享内存,因为它们都在相同的进程地址空间中运行。这意味着一个线程写入内存位置后,另一个线程可以读取该位置,从而实现信息交换。
以下是关于线程操作的几个关键点:
- 创建线程:使用
go关键字。 - 线程退出:通常在线程函数返回时隐式发生。
- 线程阻塞与恢复:例如,当线程向一个没有读取者的通道(channel)写入数据时,它会被阻塞。Go运行时会停止该线程,稍后在适当时机恢复它。
为什么需要线程?🎯
使用线程编程有时会使程序员的生活更复杂,因为编写顺序代码通常比编写并行代码更容易。但在分布式系统中,线程至关重要,主要原因有三点:
- I/O并发性:当一个线程因网络I/O操作(如等待远程机器回复)而阻塞时,其他线程可以继续运行,从而允许程序同时发起多个请求。
- 多核并行性:现代计算机拥有多个核心。线程允许不同的goroutine在不同的核心上并行运行,从而提高吞吐量。
- 编程便利性:线程便于执行定期任务(如每隔一段时间执行某些操作)或后台活动。
线程的挑战与应对策略 ⚠️
尽管线程强大,但使用它们进行编程也面临挑战。
1. 竞态条件
当多个线程共享并修改同一变量时,可能会发生竞态条件。例如,两个线程同时执行n = n + 1,由于该操作并非原子性,可能导致最终结果错误(期望为2,实际为1)。
有两种主要方法解决竞态条件:
- 避免共享:使用通道(channel)进行通信,而不是直接共享内存。这是Go鼓励的风格。
- 使用锁:通过锁(如互斥锁
sync.Mutex)确保一系列指令以原子方式执行。
提示:Go内置了竞态检测器。建议在运行实验代码时使用-race标志,它有助于识别潜在的竞态条件。
2. 协调
线程间经常需要协调,例如一个线程必须等待另一个线程完成某项工作。Go提供了两种主要原语来处理协调:
- 通道:允许通信和协调同时进行。
- 条件变量:与锁配合使用,用于等待特定条件成立。
3. 死锁
当两个或多个线程相互等待对方释放资源时,就会发生死锁,导致程序无法继续执行。最简单的死锁例子是:一个线程向无人读取的通道写入数据,而程序中又没有其他活动线程。Go运行时会检测到这种简单死锁并报错。

Go的并发方案:通道 vs 锁/条件变量 🛠️
Go提供了两种主要的并发编程方案来应对上述挑战。
- 通道方案:适用于线程间需要通信但无需共享内存的场景。它通过传递数据来协调线程。
- 锁与条件变量方案:适用于线程间需要方便地共享内存的场景(例如,共享一个键值表)。
选择哪种方案通常取决于问题的便利性。本课程教程重点介绍了通道,对锁和条件变量涉及较少,因此我们将通过一个示例来补充说明条件变量的用法。
示例:使用锁和条件变量进行线程协调 📊
考虑一个场景:主线程t1需要从多个远程机器收集选票(例如在Raft协议中)。它创建多个goroutine(t2, t3...)来并行获取选票。t1需要等待,直到收集到足够多的选票(例如5票)或所有请求完成。
以下是使用互斥锁和条件变量的解决方案核心逻辑:
var mu sync.Mutex
var cond = sync.NewCond(&mu)
var count int
var finished int
// 主线程
mu.Lock()
for count < 5 && finished < 10 {
cond.Wait() // 等待条件满足,会原子性地释放mu并进入睡眠
}
mu.Unlock()
// 每个投票goroutine
go func() {
// ... 模拟远程调用获取投票 ...
mu.Lock()
defer mu.Unlock()
if vote {
count++
}
finished++
cond.Broadcast() // 通知等待的主线程
}()
解释:
- 主线程获取锁后检查条件。如果条件不满足,调用
cond.Wait(),这会使其睡眠并释放锁。 - 投票goroutine在更新共享变量(
count,finished)后,调用cond.Broadcast()唤醒所有等待者(此处是主线程)。 - 主线程被唤醒后会自动重新获得锁,再次检查条件,如此循环。
示例:使用通道进行线程协调 📨
同样的投票问题也可以用通道解决。在这种方案中,共享变量count和finished仅由主线程访问,避免了锁的使用。
ch := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
// ... 模拟远程调用获取投票 ...
ch <- vote // 将投票结果发送到通道
}()
}
for i := 0; i < 10; i++ {
v := <-ch // 从通道接收投票结果
if v {
count++
}
}
注意:此方案需小心处理goroutine生命周期。如果主线程在收集到足够票数后提前退出,可能会留下阻塞在通道写入操作上的goroutine,造成资源泄露。
第二部分:爬虫实践 🕷️
上一节我们通过投票示例了解了线程协调,本节我们来看一个更实际的并发编程例子:网络爬虫。爬虫的目标是从一个起始URL开始,获取网页内容,提取其中的新URL,并继续爬取,同时确保不重复访问同一URL。
其核心挑战在于:
- I/O并发:网络抓取很慢,需要并发进行以提高效率。
- 正确性与性能:确保每个URL只被抓取一次,并利用多核并行处理。
顺序爬虫解决方案
首先,我们有一个简单的顺序递归解决方案作为基线:
func Serial(url string, fetcher Fetcher, fetched map[string]bool) {
if fetched[url] {
return
}
fetched[url] = true
urls, err := fetcher.Fetch(url)
if err != nil {
return
}
for _, u := range urls {
Serial(u, fetcher, fetched)
}
}
并发爬虫解决方案:互斥锁版本
以下是使用互斥锁和sync.WaitGroup的并发解决方案要点:

type fetchState struct {
mu sync.Mutex
fetched map[string]bool
}
func (fs *fetchState) testAndSet(url string) bool {
fs.mu.Lock()
defer fs.mu.Unlock()
if fs.fetched[url] {
return true
}
fs.fetched[url] = true
return false
}

func ConcurrentMutex(url string, fetcher Fetcher, fs *fetchState) {
if fs.testAndSet(url) {
return
}
urls, err := fetcher.Fetch(url)
if err != nil {
return
}
var done sync.WaitGroup
for _, u := range urls {
done.Add(1)
go func(u string) {
defer done.Done()
ConcurrentMutex(u, fetcher, fs)
}(u) // 注意:将u作为参数传入,避免循环变量捕获问题
}
done.Wait()
}
关键点:
fetchState结构体包含一个互斥锁和一个记录已抓取URL的映射。testAndSet函数在锁的保护下检查并设置URL状态。sync.WaitGroup用于等待所有为子URL创建的goroutine完成。
并发爬虫解决方案:通道版本


通道版本的爬虫采用了“协调者-工作者”模式:

func worker(url string, ch chan []string, fetcher Fetcher) {
urls, err := fetcher.Fetch(url)
if err != nil {
ch <- []string{}
} else {
ch <- urls
}
}
func coordinator(ch chan []string, fetcher Fetcher) {
n := 1 // 跟踪活跃工作者数量
fetched := make(map[string]bool)
for urls := range ch { // 持续从通道读取URL列表
for _, u := range urls {
if !fetched[u] {
fetched[u] = true
n++
go worker(u, ch, fetcher)
}
}
n--
if n == 0 { // 所有工作者都已完成
break
}
}
}
func ConcurrentChannel(url string, fetcher Fetcher) {
ch := make(chan []string)
go func() {
ch <- []string{url}
}()
coordinator(ch, fetcher)
}
关键点:
- 协调者(
coordinator)维护已抓取URL的映射,并负责创建工作者goroutine。 - 工作者(
worker)执行抓取任务,并将找到的新URL列表发送回通道。 - 协调者通过计数
n来跟踪活跃工作者,当n为0时结束。 - 所有数据通过通道传递,避免了显式的锁。
第三部分:远程过程调用(RPC) 🌐
上一节我们探讨了单机内的并发,本节我们将视野扩展到多台机器,学习远程过程调用。RPC的目标是让跨网络的过程调用看起来和本地过程调用一样简单。
RPC工作原理
在RPC模型中,调用方(客户端)调用一个本地函数(stub),该函数负责:
- 将函数名和参数序列化成消息。
- 通过网络将消息发送到服务器。
- 等待响应。
- 将响应反序列化,并返回给客户端。

服务器端有一个对应的stub,它:
- 接收网络消息。
- 反序列化出函数名和参数。
- 调用服务器上真正的函数实现。
- 将函数返回值序列化,发送回客户端。
Go中的RPC示例:简单键值存储
以下是一个简单的键值存储服务器的RPC实现框架:
1. 定义参数和响应结构
type PutArgs struct {
Key string
Value string
}
type PutReply struct {
}
type GetArgs struct {
Key string
}
type GetReply struct {
Value string
}

2. 客户端调用
func (ck *Clerk) Get(key string) string {
args := GetArgs{key}
reply := GetReply{}
ok := ck.srv.Call("KV.Get", &args, &reply) // 调用远程方法
if !ok {
return ""
}
return reply.Value
}

3. 服务器端实现
type KV struct {
mu sync.Mutex
data map[string]string
}

func (kv *KV) Get(args *GetArgs, reply *GetReply) error {
kv.mu.Lock()
defer kv.mu.Unlock()
val, ok := kv.data[args.Key]
if !ok {
return errors.New("key not found")
}
reply.Value = val
return nil
}
// 设置服务器
func server() {
kv := &KV{data: make(map[string]string)}
rpcs := rpc.NewServer()
rpcs.Register(kv) // 注册可供调用的方法
// ... 网络监听与连接处理 ...
}
说明:
- 服务器结构
KV包含互斥锁,因为多个客户端RPC调用可能并发访问共享的data映射。 - 只有首字母大写的方法(如
Get,Put)会被RPC系统导出供客户端调用。 rpcs.Register会注册所有导出的方法。
RPC的故障语义 ⚡
RPC调用与本地调用一个关键区别在于对故障的处理。常见的RPC语义有:

- 至少一次:客户端在超时或失败后会重试,确保操作至少执行一次。但可能导致操作被执行多次。
- 最多一次:服务器通过去重机制,确保同一个请求最多执行一次。Go的RPC系统(基于TCP)通常提供最多一次语义。如果调用失败,会返回错误,由应用程序决定是否重试。
- 正好一次:这是最理想但最难实现的语义,需要结合持久化状态和复杂的恢复机制。你将在本课程的后续实验中构建一个提供“正好一次”语义的系统。
总结 🎉
本节课中我们一起学习了分布式编程的两大基石:线程和RPC。
- 我们了解了线程(goroutine) 在Go中如何创建和运行,以及它们为何对实现I/O并发和多核并行至关重要。我们探讨了使用线程时面临的竞态条件、协调和死锁等挑战,并学习了通过通道和锁/条件变量两种方案来应对这些挑战。
- 我们通过爬虫示例实践了并发编程,对比了使用互斥锁和通道两种不同风格的解决方案。
- 我们学习了远程过程调用(RPC) 的基本概念,它使得跨网络的函数调用像本地调用一样方便。我们了解了Go中如何实现一个简单的RPC服务,并讨论了至少一次、最多一次和正好一次这些重要的RPC故障语义。

掌握这些概念将为你完成本课程关于分布式系统的实验打下坚实的基础。
课程 P20:第19讲 - 点对点系统 - 比特币 🪙
在本节课中,我们将要学习比特币系统。比特币解决了一个分布式系统中的核心难题:在一个完全开放、参与者可以随意加入或离开、且可能存在恶意(拜占庭)节点的环境中,如何就交易发生的顺序达成共识。我们将重点关注其分布式系统层面的设计,特别是其共识协议。
概述
比特币系统旨在解决金融交易中的几个关键挑战:防止交易伪造、防止双重花费,并在一个去中心化的开放网络中达成共识。其核心思想是维护一个所有参与者都认可的公共交易账本(区块链),并通过“工作量证明”机制来决定谁有权向这个账本中添加新的交易记录。
核心挑战
在深入设计细节之前,我们首先需要理解比特币试图解决的几个核心问题。
1. 交易伪造
伪造交易是指恶意参与者凭空捏造交易记录,试图花费不属于自己的钱。这个问题可以通过密码学签名来解决,与SUNDR系统类似。我们假设基础的密码学是安全可靠的。
核心概念:每笔交易都需要由资金所有者的私钥进行签名。验证者使用对应的公钥来验证签名。
# 简化的交易验证逻辑
is_signature_valid = verify_signature(transaction, public_key_of_sender)
2. 双重花费
双重花费是指一个用户试图将同一笔钱花费两次。这是比特币论文重点解决的问题。解决方案依赖于一个所有参与者都认可的、有序的公共交易日志。通过检查日志,可以确认一笔钱是否已经被花费过。
3. 密钥盗窃
虽然这是一个严重的安全问题(例如,私钥存储的电脑被黑客入侵),但本课程主要关注分布式系统层面的共识机制,因此不会深入讨论此问题。
交易的结构
为了理解共识如何工作,我们首先需要了解交易在账本中是如何记录的。
每一笔交易本质上是一条记录,它包含以下核心信息(进行了大量简化):
- 接收者公钥:标识这笔钱的新所有者。
- 上一笔交易的哈希值:唯一标识这笔钱的来源(即它来自哪一笔之前的交易)。
- 发送者的签名:由资金的当前所有者(发送者)使用其私钥生成,以授权这笔转账。
核心概念:比特币并非一个具体的“币”,而是由交易链定义的。一枚“币”的本质是上一笔交易的哈希值。
交易 T7 结构示例:
- 接收者公钥: PubKey_Y
- 来源交易哈希: Hash(T6)
- 发送者签名: Sign(PrivateKey_X, 交易内容)
当用户Y想将钱花给用户Z时,会创建交易T8,其中包含PubKey_Z、Hash(T7)以及Y的签名。Z在交付商品(如一杯咖啡)前,可以验证T8中的哈希是否指向有效的T7,并使用T7中Y的公钥来验证T8的签名。
为何需要共识?—— 简单方案的缺陷
上一节我们介绍了交易的基本结构和验证方法。本节中我们来看看,如果仅仅有一个可被篡改的日志,会遇到什么问题,以及为什么需要复杂的共识机制。
方案一:可信中心服务器
假设存在一个所有客户端都信任的服务器S,由它来维护和排序所有交易日志。这很容易解决问题,但违背了去中心化的初衷,因为在开放的比特币网络中,无法让所有参与者都同意并信任某一个中心实体。
方案二:不可信服务器(SUNDR风格)
我们可以使用类似SUNDR的系统,让客户端维护日志,服务器只负责转发。但正如SUNDR所示,不可信的服务器可以通过“分叉”日志(展示给不同客户端不同的日志版本)来制造双重花费问题。
场景:
- 服务器向用户Z展示日志分支A: ... -> T7 -> T8 (Y付钱给Z)
- 服务器向用户Q展示日志分支B: ... -> T7 -> T8‘ (Y付钱给Q)
- Z和Q都检查自己的分支,发现T7尚未被花费,于是都接受了交易。
- 结果:Y成功双重花费。
方案三:去中心化节点网络(Raft风格)
用对等节点网络取代服务器,每个节点都维护日志副本。这类似于Raft等共识算法。然而,Raft依赖于一个封闭的、已知的节点集合来定义“多数派”。在比特币这种完全开放、节点可自由进出的系统中,我们无法确定谁是参与者,因此“多数派”的概念无法生效。
综上所述,我们需要一种能在开放、去中心化环境中达成共识的新机制。
比特币的解决方案:工作量证明与区块链
上一节我们分析了简单方案为何失效,本节中我们来看看比特币的核心创新:如何通过工作量证明(Proof-of-Work)和区块链来实现共识。
工作量证明的核心思想
规则很简单:只有完成大量计算工作的节点,才有权利将新的交易块追加到公共账本(区块链)上。这就像解决一个数学难题,需要付出真实的计算资源(时间和电力)。
核心概念:矿工(节点)必须找到一个随机数(Nonce),使得该区块数据的哈希值满足特定条件(例如,以足够多的零开头)。
条件:Hash(区块头 + Nonce) < 目标值
找到这样一个Nonce非常困难,需要大量尝试;但验证一个找到的Nonce是否正确却非常容易。这确保了“冒充”成功矿工极为困难。
从交易到区块
为了避免为每一笔交易都进行昂贵的工作量证明,交易被分组打包成“区块”。矿工的工作是为整个区块寻找有效的Nonce。
以下是区块内容的简化视图:
- 前一个区块的哈希:将区块串联成链的关键,确保了历史的不可篡改性。
- 本区块包含的交易列表
- Nonce:用于满足工作量证明条件的随机数
- 时间戳
共识形成过程
以下是网络运作的基本流程:
- 交易在网络中传播,被各个矿工节点接收并放入本地缓存池。
- 矿工节点将未确认的交易打包成一个候选区块。
- 矿工开始尝试不同的Nonce值,计算候选区块的哈希,直到找到满足难度条件的Nonce。
- 第一个找到有效Nonce的矿工,将新区块广播给整个网络。
- 其他节点收到新区块后,快速验证其工作量证明是否有效、其中的交易是否合法(如签名、无双重花费)。
- 如果验证通过,节点就将这个新区块追加到本地区块链的末尾,并开始基于这个新区块挖掘下一个区块。
网络同步与难度调整:协议设计平均每10分钟产生一个新区块。网络会根据过去一段时间出块的平均速度,动态调整哈希难度目标,以维持这个出块速率。时间戳和确定性的调整算法确保了所有节点对难度的看法一致。
处理分叉与双重花费攻击
区块链可能会出现临时分叉,例如两个矿工几乎同时找到了有效的区块。比特币通过一个简单的规则来解决这个问题:节点总是选择并延长“累计工作量最大”的链(通常也是最长的链)。
双重花费攻击场景
假设攻击者Y想对Z和Q进行双重花费:
- Y同时向网络的不同部分广播两笔冲突的交易:T8(付给Z)和T8‘(付给Q)。
- 诚实的矿工可能会将这两笔交易分别打包进两个不同的候选区块B1和B1‘,并各自进行挖矿。
- 最终,其中一个区块(比如包含T8的B1)会先被加入到主链中。
- 此时,包含T8‘的交易在另一条分支上。根据最长链规则,所有诚实节点都会切换到包含B1的链上,并抛弃B1‘分支。
- 交易T8‘因此被作废。
安全等待确认
对于收款方(如Z)来说,仅仅看到交易被打包进一个区块(1次确认)还不够安全,因为攻击者可能秘密挖掘一条更长的链来替换它。因此,通常需要等待该交易后面又增加了若干个区块(例如6个确认,约1小时)。这使得攻击者想要成功重组链需要拥有超过全网50%的计算力,在诚实节点占多数的情况下极难实现。
激励与实际问题


矿工为何参与?
矿工投入大量算力是有经济激励的:
- 区块奖励:成功挖出新区块的矿工,可以在区块中创建一笔“铸币交易”,将一定数量的新比特币奖励给自己(当前约6.25 BTC,每21万个区块减半)。
- 交易手续费:区块中所有交易附带的手续费也归矿工所有。
其他实际问题
- 矿池:个体矿工通过加入矿池共享算力和收益,以获得更稳定的收入。
- 能源消耗:工作量证明机制因消耗大量能源而受到批评。
- 协议升级:比特币协议的更改需要社区共识。不兼容的升级会导致“硬分叉”,从而产生新的加密货币分支(如比特币现金)。
- 性能:约10分钟的出块时间和有限的区块大小,限制了比特币网络的交易吞吐量(每秒数笔)。
总结

本节课我们一起学习了比特币系统。它通过区块链和工作量证明机制,在一个开放、去中心化且可能存在拜占庭节点的对等网络中,成功解决了就交易顺序达成共识的难题。核心在于,它利用计算成本来制约恶意行为,并通过最长链规则来收敛状态,从而实现了无需信任任何单一实体的安全电子现金系统。尽管存在能源消耗和性能扩展等挑战,比特币在分布式系统共识领域无疑是一个标志性的成功实践。
📚 课程 P21:第 20 讲 - Blockstack
在本节课中,我们将要学习一种构建去中心化应用程序的方法——Blockstack。我们将探讨其核心设计思想,特别是它如何利用区块链技术来解决去中心化系统中的命名挑战。与传统的中心化应用不同,去中心化应用旨在让用户控制自己的数据,而非由某个中心化网站掌控。
🎯 概述:中心化 vs. 去中心化应用
在深入Blockstack之前,我们先回顾一下构建网络应用的常见范式。典型的中心化网站(如Gmail、Twitter)运行自己的应用程序代码,并控制着存储所有用户数据的数据库。用户通过浏览器与网站交互,但数据的呈现、访问规则和所有权完全由网站控制。
上一节我们介绍了中心化应用的模式,本节中我们来看看另一种设计思路:去中心化应用。在这种模式下,应用程序代码在用户自己的设备上运行,而数据则存储在用户选择的存储提供商(如Google Drive、Amazon S3)处。用户控制着自己的数据,包括谁能访问以及使用哪个应用程序来处理数据。


🔑 去中心化应用的核心挑战与命名问题
构建去中心化应用面临一系列独特的技术挑战。其中最关键的一个是命名。命名系统在去中心化架构中扮演着至关重要的角色:
- 标识用户:需要将可读的名字映射到特定用户。
- 定位数据:需要将名字映射到用户数据存储的位置。
- 验证身份:需要将名字映射到公钥,以验证数据的真实性和完整性。
一个理想的命名系统需要同时满足三个属性:唯一性、人类可读性和去中心化。然而,传统系统往往只能满足其中两个:
- 电子邮件地址:唯一且人类可读,但非去中心化。
- 随机公钥:唯一且去中心化,但非人类可读。
- 个人通讯录中的名字:人类可读且去中心化,但非全局唯一。
Blockstack(借鉴自Namecoin)的创新之处在于,它利用区块链技术,首次实现了同时满足这三个属性的去中心化命名系统。
⛓️ Blockstack 的架构:利用区块链实现命名
Blockstack 的核心思想是利用现有的区块链(如比特币)作为不可篡改的日志,来记录全局唯一的名称绑定。其架构分为多层,旨在最小化对区块链的直接写入,以解决区块链吞吐量低、写入速度慢的问题。
以下是 Blockstack 架构的关键组成部分:

1. 区块链层(底层)
这是基础,例如比特币区块链。Blockstack 将特殊的命名交易记录在区块链的 OP_RETURN 字段中。每条记录包含一个名称(如 6.824)和该名称对应的区域文件(zonefile)的哈希值。区块链提供了去中心化的共识,确保了名称注册的先后顺序(先到先得)和不可篡改性。
2. 虚拟链层(Blockstack 节点)
Blockstack 节点读取区块链中的所有交易,但只筛选并解释那些与命名相关的交易。它们维护一个数据库,建立从名称到区域文件哈希值的映射。这个数据库构成了去中心化的命名系统状态。
3. 区域文件(Zonefile)
区域文件是一个小型的、由名称所有者控制的文件,其哈希值被记录在区块链上。它包含了更具体的映射信息,例如:
- 可变存储:包含数据存储的 URI 和用于验证签名的公钥。
{ “todo_app_uri”: “https://storage.example.com/u1/todo.json”, “public_key”: “0xabc123...” } - 不可变存储:除了 URI 和公钥,还包含文件内容本身的哈希值,用于确保特定版本的数据完整性。
由于区域文件很小,可以被广泛复制和缓存。应用程序通过其哈希值来检索和验证区域文件。
4. 存储层
这是实际存储用户应用数据(如待办事项列表、推文)的地方。数据可以存储在多个提供商(如 S3, Google Drive)以实现冗余。Blockstack 文件系统提供了一个统一 API 来访问这些不同的存储后端。
🛡️ 工作原理示例:协作待办事项列表
假设用户 U1 和 U2 想用一个去中心化的待办事项应用协作。
- 名称发现:U1 和 U2 需要先安全地获知对方的 Blockstack 名称(例如
alice.id和bob.id)。 - 解析名称:
- U1 的应用查询名称
bob.id。 - Blockstack 节点在数据库中查找
bob.id,返回其区域文件的哈希值。 - 应用通过路由层获取该哈希值对应的区域文件,并验证哈希值是否匹配。
- U1 的应用查询名称
- 定位与验证数据:
- 在
bob.id的区域文件中,找到待办事项应用对应的 URI 和 Bob 的公钥。 - 应用从该 URI 获取 Bob 的待办事项列表文件。
- 使用 Bob 的公钥验证文件上的数字签名,确保数据确实来自 Bob。
- 在
- 数据整合:U1 的应用将验证后的 Bob 的待办事项与自己本地的列表整合,呈现给 U1。
- 更新数据:当 Bob 更新自己的待办事项时,他只需用私钥签名新文件,并上传到其区域文件指定的 URI。U1 的应用可以定期拉取并验证更新。
通过这种方式,实现了无需中心服务器的协作。然而,与中心化数据库简单的 SQL 查询相比,这种去中心化整合机制更为复杂。


⚙️ 技术细节与挑战
防止抢注
为了应对“抢注攻击”(即攻击者看到你广播的注册交易后,试图抢先注册同一名称),Blockstack 采用了两阶段提交:
- 预购交易:将名称的哈希值(而非明文)提交到区块链。
- 注册交易:等待预购交易被确认后,再提交包含明文名称和区域文件哈希的交易。
数据新鲜度与访问控制
- 可变 vs 不可变:可变存储适合频繁更新的数据,但应用程序需要自行处理版本控制以确保获得最新数据。不可变存储通过包含数据哈希来保证特定版本,但每次更新都需要创建新的区块链记录(成本高、速度慢)。
- 访问控制:可以通过加密来实现。例如,用目标用户的公钥加密数据,或加密一个共享密钥,然后将该密钥用各用户的公钥分别加密(即“密钥盒”模式)。
可扩展性与依赖
- Blockstack 的设计最小化了对区块链的写入(仅名称注册和区域文件哈希更新),将大量数据操作转移到了链下,缓解了区块链的吞吐量瓶颈。
- 其架构不严格依赖特定区块链,可以从比特币切换到其他共识机制更高效的链。
💭 讨论与思考
Blockstack 展示了一种实现去中心化应用和命名系统的有趣路径,但它并非终极解决方案,也引发了许多开放性问题:
- 开发复杂性:与中心化应用相比,去中心化应用的逻辑更分散,集成不同用户的数据更困难。
- 用户体验:密钥管理、数据备份、寻找应用和用户名称对普通用户门槛较高。
- 性能与规模:虽然数据存储被划分,但大规模应用下的数据发现、同步和一致性仍是挑战。
- 商业模式:如何激励开发者开发高质量的去中心化应用,是一个尚未完全解决的问题。
- 竞争方案:与其他去中心化身份/数据方案(如 Keybase、Solid)相比,Blockstack 提供的全局唯一、人类可读名称是其特色,但这是否是必需属性,取决于具体应用场景。
📝 总结
本节课我们一起学习了 Blockstack,一个旨在构建去中心化应用程序的架构。我们重点探讨了:
- 核心目标:将数据控制权归还用户,实现应用与数据分离。
- 关键挑战:命名是去中心化系统的基石,需要同时满足唯一性、人类可读和去中心化。
- 解决方案:Blockstack 利用区块链作为不可篡改的日志来记录名称绑定,并通过分层架构(区块链 -> 虚拟链 -> 区域文件 -> 存储)来平衡去中心化信任与系统性能。
- 工作流程:通过名称解析、区域文件查找、数据获取与验证的步骤,实现了用户间的安全数据共享与协作。
- 现存问题:该设计在易用性、开发模式、可扩展性等方面仍面临挑战,是更广泛的去中心化应用探索中的一次实践。

Blockstack 更像是一个引发思考的案例,它展示了去中心化设计的可能性与复杂性,而非一个已获验证的完美答案。去中心化应用的未来形态,仍在探索之中。
课程 P22:第21讲 - 项目展示 🎤
在本节课中,我们将学习多个分布式系统项目的核心设计与实现。这些项目涵盖了电子投票、隐私数据分析、协同编辑、匿名广播、容错文件系统、形式化验证等多个领域,展示了分布式系统原理的实际应用。
分布式私有电子投票系统 🗳️
大家好,我是Felipe,我和Caralina一起工作。今天我们将介绍我们的分布式私有电子投票项目。
这个项目的动机很简单。考虑到当前的事件和选举必须在保密限制下进行,我们提出了一个问题:投票将如何运作?我们特别关注维护选民隐私,即让投票保密。

以下是投票系统的工作原理。你有一些选民,例如五个,然后是一个计票器。选民将他们的选票发送给计票器,可能会加密发送或采取某种安全措施。计票器解密选票,确保每个选民最多投票一次,并计算出获胜者。

这里的关键是,为了使计票器确定每个选民最多投一次票,每一张选票都必须以某种方式与选民身份关联,这很危险。
接下来,我们将解释我们的威胁模型。我们赋予攻击者两种能力。

第一种是制造崩溃停止故障,但不是拜占庭式故障。这意味着攻击者可以使服务器崩溃,但不能使其行为异常。这是一个合理的假设,但我们认为还有其他协议可以处理拜占庭式故障,我们在此不处理。
第二种能力是监视计票器。这是一个问题,因为正如我们所说,选票与选民身份相关联。因此,一个对服务器进行间谍活动的被动攻击者可以在某种程度上取消这些投票的匿名性。
这就是我们的用武之地。我们将展示我们的分布式投票设计,以处理第一个问题,即对手使服务器崩溃。我们将使用多个投票器。
我们的想法是,每个投票者将选票发送给所有的投票器。计票器将使用与之前相同的协议来计算获胜者。这样,即使有n-1个计票器崩溃,只要其中一个还在运行,我们就能够计算出获胜者。
然而,这对于第二种类型的被动攻击来说非常不安全。因为攻击者可以危害一台服务器,从而取消投票的匿名性。
为了解决这个问题,我们将介绍Shamir秘密共享。对于Shamir秘密共享,投票者选择一个投票(0或1)。我们不会详细解释Shamir的工作原理,它是一种加密协议,但我们将展示它允许我们做什么。

你通过Shamir传递投票,给它两个参数n和k,它将产生n个份额,允许你重新计算选票。这n个份额可以是完全随机的。最强大的是,即使有k-1个份额,你也不会获得任何关于原始投票的信息。但如果有k个或更多的份额,你可以使用Shamir重新计算那个选票。


现在,我们将展示Shamir投票方案。
首先,所有选民选择他们的选票,并将份额分享给不同的计票器。两个计票器都将接收份额。当它们获得所有选民的份额时,将这些份额相加,并将总和分享给计票器。
这里需要注意的是,这个总和看起来完全是随机的。因此,通过与其他两个计票器共享,它们无法了解任何关于收到的份额的信息。这保证了选民的隐私得到维护。
当计票器收到来自其他选民的总和(包括他们自己的份额)时,它们最终可以计算出获胜者。再次使用Shamir秘密共享,它会重新计算总和。如果我们得到的选票超过选民总数的一半,那么获胜者就是1;如果小于,则获胜者是0。最终,我们得到了获胜者。
我们方案的一些假设是:首先,选民和计票器都表现良好,并遵循协议,或者它们的份额是善意的。我们使用这个方案只处理故障停止故障。
现在,我们来处理一些情况。
首先,如果我们有一个不可靠的网络,我们在服务器中发送的所有RPC都将定期发送,直到我们收到它已被接收的确认。
其次,为了处理选民故障,我们需要持久化所有选民的信息,例如持久化他们计算的份额和他们的投票。因为如果我们重新计算份额,然后计票器有不同的份额,那么方案的正确性就会消失,它们将不能修正总和。因此,始终共享来自同一计算的份额而不修改份额非常重要。

最后,为了处理计票器故障,我们依赖于Shamir秘密共享方案。正如我们之前提到的,我们只需要k个服务器来计算获胜者。因此,系统可以容忍n-k个计票器崩溃。
现在是演示时间。我将停止共享屏幕,并共享另一个屏幕。这是我们的演示。我们这里有5名选民,3个投票器,k等于2,这意味着网络是不可靠的。
如果我们运行它,我们得到的获胜者是1。我们也可以让其中一台服务器崩溃,因为k等于2,我们仍然可以通过只有两台服务器运行来计算获胜者。现在如果我们运行它,我们得到的选举获胜者还是1。我们的展示到此结束。
非常感谢你的收听,我们将回答一些问题。
问: 你测试这个系统的范围有多广?比如,你有没有尝试过不同的其他配置?你们有性能数字吗?

答: 我们确实有不同大小的测试。我们创建了一整套测试套件,确保测试投票者故障、服务器故障,以及不同的大小。我们没有性能数据,我们没有测试过,但就失败而言,以及不同数量的投票者、投票器,我们有一套完整的测试套件。我们可以把链接放到GitHub上,或者发给你,你可以在测试中查看实现。
问: 你能说说6.824的想法吗?你在这个系统里应用的,而不是使用测试框架。
答: 这个想法来自密码学课上的披萨。从不同的角度来看它是很有趣的。现在我们关注安全问题,我需要考虑如果个别的服务器会发生什么,无论是处理投票者故障、处理投票器故障,还是网络问题。我们没有解决分区问题,因为它的工作原理类似于投票器。从不同的角度来看待这个问题是很有趣的。
隐私数据分析系统 📊
大家好,我是Kevin。今天我将介绍这个非常有创意的命名为Sys的系统。这是一种以保护隐私的方式收集汇总统计数据的系统。




上一个演示很好地引导了我的项目。在6.824的大部分时间里,我们已经讨论了如何在拜占庭或故障的情况下建立可靠的系统。服务器可能会崩溃,客户端通常都表现得很好。但从这次演讲来看,我希望你能学到一些新概念,关于我们如何在出现拜占庭式故障的情况下建立具有强大保障的系统,代表系统内的客户端和服务器。

我们要使用的主要工具是实现这些保证的加密原语,例如多方计算和零知识证明,以及广播之类的分布式计算原语。
为了简单起见,假设我们想建立一个计算和的系统。我们将拥有一个聚合服务器,存储键值存储。键将成为某些统计数据的索引,值将是和的元组。我们会有一堆客户端,每个客户端都会有一些身份(如它的客户端IP地址),它会有他们想要增加的统计数据的索引,而且它也将有它的私有输入。
最直接的做法是让所有客户端按原样将其输入发送到服务器,它们可以计算和。但显然这很糟糕,因为服务器将获知客户端的身份、发出的索引和私有输入。

我们可以做得更好一点,我们可以私下计算这些和。如果我们部署到非合谋服务器,那么每个客户端将秘密地将它们的输入共享到每个服务器。正如我们在上一次演示中所说的,每个服务器、每个签名本身都不会泄露有关客户端私有输入的信息。但服务器仍然可以将这些份额相加,并计算出一个键值存储的本地版本。然后稍后当服务器想要恢复实际和时,它们可以合并它们本地的键值存储,重建全局键值存储。

这样好了一点。至少如果这些服务器中有一个是诚实的,那么服务器仍然会获知客户的身份和索引,但现在不是获得每个客户端的输入,它们将获得所有客户端的和。这样好多了,但这仍然是个问题,即身份索引关系仍然可以泄露大量信息。
那么我们如何才能解决这个问题?我们可以让事情匿名。我们仍将调整设置,现在我们给每台服务器提供一个公钥用于加密,并且每个客户端都将对其每个份额进行加密。并且不是让客户端将它们的份额直接发送到服务器,现在,我们将在两者之间设置一层转发代理。
接下来会发生的是,客户端将通过广播将其加密的共享发送到这些代理,并且代理将每个共享发送到它们各自的服务器。然后聚合可以像解释的那样进行。
这里的隐私保障是什么?如果这些代理中至少有一个是诚实的,代理仍将获知客户端的身份,它会获得一些计时信息(基于客户端何时设置共享),但没别的了,因为共享被加密到服务器。如果这些服务器中至少有一个是诚实的,服务器也会获得一些计时信息(基于代理转发它的时间),它还会获得统计的指标和和。最重要的是,只要代理和服务器不是同时受到危害,这个设计将身份与被导出的索引解除链接,这正是我们想要的。
但这导致了另一个问题:现在客户端可以躲在隐私和匿名保证后面发送错误的输入。假设系统预期客户端输入为0和1,在这种隐私的后面,客户端可以通过系统发送10亿的签名,从而在无法察觉的情况下扭曲这个和。这很糟糕。



我们来解决这件事。我们想让这个系统更强大。我们要做的是让每个客户生成对它们分享的零知识证明,并将这些发送到服务器。服务器收集这些零知识证明,它们可以交互地检查客户端的输入实际上共享重建为一些格式良好的输入。因为这个证明是零知识的,它没有留下任何关于输入的内容,除了它是格式良好的。所以我们的隐私属性又一次保持不变。代理仍会获得客户端的身份和定时信息;服务器获知定时信息、索引和和。但现在我们已经保护系统不受恶意客户端的攻击,因为它只接受格式良好的输入。




这很棒。还有另一个问题:服务器可能会崩溃,我们可能会丢失数据。我们显然需要两台服务器都在线,以便重建全局键值存储。如果我们想让系统更可靠,我们可以做我们最了解的事情,即复制服务器。我们可以使用Raft风格的复制,或者主备份式复制。

现在的问题是:所有这些复制和加密机制,在这条消息路由上,我们还能实现很好的吞吐量吗?事实证明,我们可以在这里并行服务器步骤,提供证明验证,这很可能是系统的瓶颈。接下来会发生的是,代理要对分区进行哈希,它们对每台服务器的输入,然后这些服务器中的每一个都处于reduce步骤中,将合并它们的中间键值存储,以重建包含所有和的全局键值存储。

最后一个问题是:我是不是在到期日之前实现了这个?很遗憾,没有,但我确实走了很远的路。我将展示一个简单的演示,它的非复制、非并行版本。




我将快速切换到我的另一台笔记本电脑。在这两个右侧的终端上将安装服务器,所以我要运行它们(它是用Rust实现的)。现在我要把中间的这两个代理连接起来,然后在左边的终端,我只想模拟一千个诚实的客户端。

现在的情况是,所有的客户端都在生成它们的输入共享,以及生成零知识证明,并通过代理发送它们,而代理只是将它们转发到服务器。最后,在服务器端,它们会检查所有的证明,如果这些输入是格式良好的,它将把它添加到本地键值存储中。然后在一段时间之后,当服务器想要重建最终统计数据时,它们只需结合它们的键值存储来恢复和。
我的演讲到此结束,我很乐意回答任何问题。
问: 你到目前为止实现的那样,比如什么你会接受,什么容错?
答: 现在实现的可靠性不是很高,主要是因为服务器没有被复制。对于代理来说,因为客户广播代理,你所需要的就是其中一个代理启动。如果我们有两个代理,我们可以容忍一个代理失败,我们仍然会获得到服务器的消息。但如果任何一台服务器出现故障,那么你就不能根据这些数据进行重建了。
问: 这只是对于和吗?或者,你是否实现了对所有这些输入进行操作的任何通用函数?
答: 目前,我只实现了和。但使用这种相加的秘密共享方案,你可以计算任何你想要的线性函数。也许更复杂的问题是可能的,但我还没有探索过这些。至少在实践中,和可能会达到90%的水平。
问: 你的性能数据是什么样子的?
答: 我们想要衡量的主要事情是客户端计算和客户端带宽。对于客户端计算,这些共享和证明只需不到几毫秒,所以它非常轻,带宽只有几千字节。对于服务器端的吞吐量,我是在使用EC2,但我只给每台服务器分配了四个核心。在四个核心上,可能每秒有一千个查询。如果每台机器并行20台服务器,它们可能每秒可以实现近22000次查询。但这些都是在同一个数据中心运行的,所以实际数字可能会比这个数字低一点。
问: 你如何实现零知识证明代码?
答: 我可以把我实现的论文发送给你。这并不太复杂,基本上,它只是一堆有限域运算。只要按照论文,遵循步骤,从这一点上来说,这是非常简单的。
问: 有没有可能测试它?你怎么知道它起作用了还是没有起作用?
答: 我只展示了诚实的客户端模拟,但是你也可以生成与提交错误证据的客户的模拟,然后你可以看到它们被拒绝了。

分布式协同编辑器 BukaDocs ✍️
大家好,我是Shannen和Nik Johan。我们将谈论BukaDocs。BukaDocs是一个分布式协同编辑器,它类似于Google Docs,只是稍微好一点。
在分布式系统中实现一致性是非常困难的。一致性可能会在很多方面出错。一个非常简单的例子是,如果你在每个节点中收到RPC的顺序不同,你可能会得到一个不一致的状态。还有一种叫做CRDT的数据结构,我们在我们的系统中使用它来缓解这个问题。CRDT实现最终一致性,通过对文档进行每一次操作全局唯一的,不仅对每个节点唯一,而是全局唯一的。所以如果我在编辑器输入字母a,不同于Shannen或Nik在他们的编辑器输入字母a。
例如,这里将一个新的海龟添加到文档中,即使它们接收到来自其他两个节点的移除请求,它们永远不会移走金海龟,因为这个操作本身不同于删除。所以移除绿海龟和移除金海龟是不同的。这就是我们实现最终一致性的方式。
对于BukaDocs,我们选择了一种名为LSEQ的CRDT,表示具有可变长度密钥的元素序列。我们的目标是,假设我们想要一个表示字母表的序列,到目前为止,我们有字母a和c。一位编辑者可能会选择尝试在它们之间添加字母b,而另一编辑者可以选择尝试在c之后添加字母d。目标是最终的一致性,最终会到达状态a b c d。

LSEQ实现这一点的方式是通过使用开始和结束token,然后它给文档中的每个字符一个单独的token在START和END之间。所以我们可以在START和END之间插入h。如果我们想要i在h之后,我们可以把它插入到7,在4和8之间。现在,如果我们想在i和文档末尾之间插入一个感叹号,我们可以把它插入到键7,2,因此我们增加键到2,在相邻两个其他键之间创建键。通过这种方式,我们始终可以在任何两个其他键之间创建一个键,所以我们可以随时插入。

LSEQ很好地形成了,它以很少的协调努力达到了最终的一致性,它还进行了一些优化,导致键的长度增长很慢。然而,一些缺点是,为了支持删除这些元素,它依赖于因果交付和恰好一次交付。我们并不想实现这个,因为它是给予其他一些工作的。所以我们使用一种稍微简单一些的方法,也就是删除集。这是一个仅增长的集合,我们在其中添加元素。例如删除字母h和i,我们会把4 7添加到这个删除集中。然后,整个状态相当于只需要START和END token,感叹号在键7,2。

我们建立了Buka Docs服务,类似于我们实现kv Raft的方式。我们有多个服务器和多个客户端。客户端一次只与一台服务器通信,它们不断尝试操作,直到从服务器获得成功回复。客户端和服务器都维护文档中的字符的AVL树,以及我们自己删除的删除键集合。我们选择将字符存储在AVL树中是出于性能原因。
事件的链条大概是这样的:客户端将向服务器发送插入和删除,服务器将更新其自己的AVL树和删除集,并持久化保存对于所有其他服务器和客户端的更新,然后服务器会向客户端响应成功。
我们要演示一下。我们在这里为它构建了一个非常简单的用户界面。Johan和Nik现在也在从不同的客户访问这个。你可以看到他们的输入,你可以输入其他内容,我在这里输入,我想Johan是在输入hi,Nik在输入一些东西,我们还可以编辑彼此的文本。这就是Buka Docs。
我们很乐意回答任何问题。
问: 我很喜欢海龟的主题,你们想出了这个,我只是想知道海龟的主题是从哪里来的?
答: 海龟是Nik Johan和我通过网络讨论课程,海龟是我们课程的吉祥物。我们受到启发,构建Buka Docs,因为我们使用的Google Docs是一个问题文档,针对学生提问,它无法同时处理超过75个用户的输入,Buka Docs就是这样诞生的。
问: 为了确保我理解正确,这些数据结构非常有趣,它们存储在客户端或服务器上?如果它们被存储了,我想现在服务器没有复制,但这是你可以很容易做到的事情。但如果它们存储在客户端上,如果其中一个客户端出现故障会发生什么情况?
答: 数据结构存储在所有客户端和所有服务器上。我们假设客户端是完全值得信任的。如果客户端出现故障,它们没有发送到服务器的任何编辑会一直在它们那边,在它们重新上线之前,在这种情况下,它们可以发送,它将实现最终的一致性。
问: 为什么LSEQ胜过其他CRDT?
答: 我们选择LSEQ主要是因为这是我们最先发现的,我们想要开始。因为在实现可变长度键的逻辑之后,没有太多我们需要做的,以确保客户端和服务器保持一致。除此之外,它就像是消息传递,并确保每个人都收到了所有的信息。
问: 在这个示例中,你运行了多少台服务器,是只有一个还是多个?


答: 有三台服务器和三台客户端。每个客户端都连接到自己已知的服务器。
问: 你使用服务器用于扩展或容错,还是两者都有?
答: 两者都有。当我们运行性能指标时,通过将客户端请求的负载分布在多个服务器上,你可以处理稍微多一点的带宽请求。然而,这是一种权衡,因为最终一致性将需要更多的时间,因为服务器将必须发送RPC到每一台其他服务器。在我们的论文上,我们有一个完整的图表。
问: 它的规模有多大,随着你添加更多的服务器?
答: 它能够处理,如果我们将其分配给五个客户端和五个服务器上的3000个请求,它在三秒内实现了最终一致性,但这主要是因为它在计算机上,在本地主机上,RPC速度非常快。然而,这也意味着所有的计算机都在一台机器上运行。当我们把它放在真正的硬件上时,是两种方式。还有一件事是,如果你一次提出太多请求,最终一致性很难实现。例如,我们试图一次进行19000次编辑,通过100个客户端和5台服务器,它花了大约32秒才达到最终一致性。然而,如果我们只在100个客户端和5个服务器上进行3000次编辑,这只花了大约三秒钟。

问: 当你说你做了19000或其他的事,就是一次发送所有这些信息,或者它是随着时间的推移而扩散的?
答: 每一个单独发送,每个编辑并行发送,至少在测试时。
问: 其中一个激励因素像Google Docs这样的协作文本编辑器,不能支持大规模更新和并发更新,所以你认为他们为什么不采用你们所提议的方法?
答: 我认为Google Docs使用了一种类似的方法,称为操作转换。我不确定它是否也可以并行。他们可能不想这么做,因为他们不想提供那么多的服务器能力,这可能只是为了成本效益。我不认为上百个人编辑一个文档的情况出现得很频繁,至少在Google Docs的使用中,所以可能不想

课程 P3:Lecture 3 - GFS 🗂️

在本节课中,我们将要学习 Google 文件系统(GFS)的设计与核心思想。GFS 是一个为大规模数据处理(如 MapReduce)而设计的分布式文件系统,它通过复制实现容错,并在性能与一致性之间做出了独特的权衡。
存储系统的重要性

存储系统在分布式系统中占据核心地位,主要原因在于它是一个实现容错的关键组件。基本思想是,如果你能建立一个持久的存储系统,那么你就可以构建无状态的应用程序。应用程序本身不保存任何持久状态,所有状态都交由存储系统管理。这极大地简化了应用程序的设计,因为应用程序崩溃后可以快速重启,并从分布式存储系统中恢复状态。这种架构模式在当今的网站中非常普遍:一个存储后端保存状态,而应用服务器则处理计算逻辑。

然而,设计一个容错的存储系统并非易事。
设计分布式存储的挑战
设计的主要驱动力是追求高性能。为了获得高性能,数据必须在多台服务器间进行分片,因为单台服务器的磁盘和网络吞吐量有限。GFS 的目标是支持 MapReduce 这类应用,因此它需要处理数千台机器上的数据。

但是,使用多台服务器就意味着会面临故障。随着机器数量的增加,故障会变得司空见惯。为了获得容错能力,传统的方法是使用复制,即将数据复制到多个磁盘上。这样,当一台服务器故障时,其他副本仍能提供数据。
然而,复制带来了新的挑战:数据不一致。如果多个副本的数据不同步,就会导致不一致。为了避免不一致,如果我们想要强一致性,那么复制系统的行为就必须像未复制的单一系统一样。这通常需要引入分布式协议来协调副本,而这类协议可能涉及消息传递和磁盘读写,从而可能降低性能。

因此,我们看到了一个基本难题:我们想要高性能(需要多台服务器和复制),也想要容错(因为服务器众多),但这可能导致不一致。为了解决不一致,我们需要协议,而这又可能损害性能。这个在一致性、性能和容错之间的权衡,是设计分布式存储系统的核心挑战。
什么是一致性?
上一节我们介绍了分布式存储面临的一致性挑战,本节中我们来看看一致性具体指什么。从高层次看,理想的一致性意味着整个系统表现得像一台单一的、未复制的机器。

有两种主要风险使得实现这种行为变得困难:并发和故障。我们先从并发开始讨论。
即使在一台单机中,如果有多个客户端并发访问,也需要考虑一致性。例如,假设有两个客户端 C1 和 C2 同时向同一个键 x 写入值 1 和 2。随后,第三个客户端 C3 来读取 x,它可能读到 1 或 2,这都是合理的,因为写入是并发的。但如果 C3 读到了 1,之后第四个客户端 C4 再来读取 x,我们希望 C4 也读到 1,而不是 2。服务器可以通过锁等机制来强制这种顺序。

在分布式系统中,第二个风险是故障,通常与复制相关。

复制与不一致性
假设我们有两台服务器 S1 和 S2,都存储数据 x。我们采用一个非常简单的复制方案:客户端写入时,无需协调,直接写入两台服务器。客户端 C1 写入 x=1,同时 C2 写入 x=2。
那么,客户端 C3 读取 x 时,可能从 S1 读到 1,也可能从 S2 读到 2。更糟糕的是,如果 C3 读到了 1,而紧随其后的 C4 却读到了 2,这对于应用程序开发者来说将非常难以处理。这种不一致性是因为缺乏协调客户端读写的协议。

因此,我们需要某种分布式协议来协调,以确保获得期望的一致性。本课程后续会研究许多不同的协议,它们在容错和一致性方面有不同的权衡。今天的案例研究就是 GFS。
为什么研究 GFS? 🎯
GFS 是一个有趣的案例研究,因为它集中体现了上述所有核心问题:它旨在通过复制实现高性能和容错,同时又要应对保持一致的难题。

GFS 也是一个成功的系统,被 Google 实际使用,并启发了后续系统如 Colossus 和 Hadoop HDFS。有趣的是,在 GFS 论文发表的年代(约2000年),分布式系统的概念已为人知,但没有人构建出能在数千台机器规模上运行的系统。GFS 的设计有两个非标准之处:
- 它采用单一主节点(Master) 负责协调,而非当时学术界推崇的多主复制。
- 它容忍一定程度的不一致性,而非追求强一致性。
这种基于实际需求(尤其是 MapReduce 工作负载)的务实设计,使得 GFS 在巨大规模下能够运行,这令人印象深刻。

GFS 的设计目标与性能
GFS 可以看作是 MapReduce 的文件系统。其设计目标是支持多个 MapReduce 作业并获得高性能。从 MapReduce 论文中的性能图可以看出,Mapper 从 GFS 读取输入数据的速率可以超过 10,000 MB/秒。

考虑到当时单个磁盘的吞吐量大约为 30 MB/秒,要达到 10,000 MB/秒,需要数百个磁盘并行工作。GFS 正是通过将文件自动分片到大量磁盘上,允许多个客户端并行读取,从而实现这种高性能。
GFS 的关键属性包括:
- 大数据集:存储如全网爬取数据。
- 高性能:通过分片和并行实现。
- 全局共享:所有应用看到统一的文件系统视图,方便数据共享。
- 容错:自动处理常见的服务器故障。
GFS 架构总览 🏗️

上一节我们了解了 GFS 的目标,本节中我们来看看它的具体架构。GFS 不是普通的 Linux 文件系统,而是专为大型计算设计的。
以下是其核心组件和交互流程:
- 客户端:应用程序(如 MapReduce 任务)。
- 主节点(Master):单一节点,管理文件系统元数据(文件名到块的映射、块位置等)。
- 块服务器(Chunk Server):多个节点,实际存储 64 MB 的数据块(每个块作为一个 Linux 文件存储)。

读取流程:
- 客户端向 Master 发送请求(文件名 + 偏移量)。
- Master 回复对应的块句柄(Chunk Handle)、块服务器列表以及版本号。
- 客户端缓存这些信息,然后直接向最近的块服务器请求数据。
- 块服务器检查版本号,如果匹配则发送数据。
写入/追加流程(更复杂,涉及一致性):
- 客户端联系 Master 获取块的主副本(Primary)和次级副本(Secondary)位置及租约信息。
- 客户端将数据推送到所有副本(管道式传输以优化网络利用率)。
- 数据到达所有副本后,客户端通知 Primary 开始写入。
- Primary 为写入分配一个序列号(确定顺序),并指示所有 Secondary 写入。
- 所有副本成功写入后,Primary 回复客户端成功。若有副本失败,则回复客户端失败,客户端通常会重试。

这种设计使得数据读写可以绕过 Master,直接与块服务器进行,从而获得高吞吐量。Master 只处理元数据请求,负载得以减轻。

Master 节点的状态与容错

Master 是系统的控制中心,它维护以下状态:
- 命名空间:文件名到块句柄列表的映射。
- 块信息:每个块句柄对应的版本号、副本位置列表(包含 Primary 和 Secondary)、Primary 的租约到期时间。




为了容错,Master 将所有元数据变更记录到操作日志中,并在响应客户端前将日志写入稳定存储。这样,即使 Master 崩溃重启,也能通过重放日志恢复状态。为了加速恢复,Master 还会定期创建检查点。
关于状态持久化的问题:
- 文件名到块的映射必须持久化到稳定存储(通过日志),否则文件会丢失。
- 块到服务器的映射是易失的,Master 重启后可以通过询问块服务器来重建。
- 版本号必须持久化,用于区分新旧副本。




GFS 的一致性讨论 🔍
GFS 为了性能,在某些情况下牺牲了强一致性。它提供的一致性模型可以概括为:
- 确定性的写入:在 Primary 协调下,所有成功的写入在所有副本上顺序一致。
- 并发的写入:可能被破坏,但文件区域会被标记为“已定义”(包含写入的数据)或“未定义”(可能包含重复或垃圾数据)。
- 记录追加:GFS 保证数据至少被原子性地写入一次,但可能会因为重试而产生重复记录。应用程序需要能够处理这种情况(例如,通过记录ID去重)。
以下是一些可能导致读取到旧数据(不一致)的场景:
- 客户端缓存了过期的副本位置:客户端从 Master 获取了块服务器列表并缓存。之后,某个副本失效,Master 更新了版本号和副本组,但缓存的客户端仍向旧的副本读取,可能读到过时数据。
- 租约与网络分区:如果 Primary 与 Master 之间发生网络分区,Master 必须等待 Primary 的租约到期后才能指定新的 Primary,在此期间系统可能无法写入,但可以避免出现“脑裂”(两个 Primary)的严重不一致。
GFS 的设计是务实的,它针对 MapReduce 这类批处理工作负载进行了优化,容忍了某些不一致性以换取极高的吞吐量。对于需要更强一致性的应用,Google 后来开发了其他系统(如 Spanner)。

总结
本节课我们一起学习了 Google 文件系统(GFS)。我们首先探讨了分布式存储系统的重要性及其在容错中扮演的关键角色,并分析了设计这类系统时在性能、容错和一致性之间面临的固有挑战。

接着,我们深入研究了 GFS 的设计。GFS 通过单一 Master 管理元数据、分块存储、副本复制以及客户端直接与块服务器通信等机制,实现了为 MapReduce 等批处理任务量身定制的高性能。我们详细剖析了其读写流程、Master 的状态管理与容错机制。

最后,我们重点讨论了 GFS 的一致性模型。GFS 并非提供强一致性,它允许在故障恢复时产生重复记录,并且客户端可能读取到过时数据。这种设计是其追求极致吞吐量所做的权衡,也体现了分布式系统设计中根据应用需求进行折衷的务实思想。理解 GFS 有助于我们把握分布式存储的核心问题,并为学习后续更复杂的系统奠定基础。
课程 P4:主备复制入门 🛡️
在本节课中,我们将学习主备复制的基本概念,这是一种用于构建容错系统的关键技术。我们将探讨其核心思想、面临的挑战、两种主要实现方法,并通过 VMware 容错(VM-FT)这一具体案例来深入理解复制状态机方法。
概述:什么是主备复制?
主备复制是一种容错技术,旨在通过维护一个或多个备份副本来确保系统在主服务器发生故障时仍能继续运行。其核心目标是让客户端感觉像是在与一台高度可靠的单一服务器交互。
1. 处理何种故障?
首先,我们需要明确主备复制旨在处理何种类型的故障。
1.1 Fail-Stop 故障
我们主要关注 Fail-Stop 故障。这种故障的假设是:计算机要么正常工作,要么完全停止工作,不会产生错误的中间结果。
- 典型例子:电源线被拔掉、风扇故障导致过热关机、网络连接完全中断。
- 软件层面的处理:系统可以通过计算校验和等方式,在检测到内部错误时主动停止,从而将某些故障转化为 Fail-Stop 故障。
1.2 无法处理的故障
主备复制无法解决所有问题,以下情况通常不在其处理范围内:
- 逻辑错误:如果软件本身存在缺陷(如除以零),主备机可能都会执行相同的错误操作。
- 配置错误:错误的配置文件会导致主备系统都无法正常工作。
- 恶意攻击:试图破坏或欺骗协议的恶意行为不在基础主备复制的考虑范围内。
1.3 物理灾难的考量
主备复制能否应对物理灾难(如地震)取决于部署方式:
- 可以处理:如果主备服务器物理分离(如在不同大陆),备份可以接管。
- 无法处理:如果主备服务器位于同一数据中心且该中心整体失效,则系统无法恢复。
2. 主备复制的主要挑战
即使只关注 Fail-Stop 故障,构建容错系统也面临诸多挑战。
以下是构建主备系统时需要解决的核心问题:
- 故障检测与脑裂:在分布式系统中,难以区分机器故障和网络分区。必须设计机制(如仲裁)来确保不会出现两个主机同时运行的“脑裂”状态。
- 状态同步:必须确保备份服务器的状态与主机严格同步,包括所有状态变更及其顺序。这需要处理非确定性操作(如获取当前时间、外部输入),确保它们在主备机上产生完全相同的影响。
- 故障转移:当主机故障时,需要平滑地将服务切换到备份。这涉及确定主机是否真的失效、确保备份状态最新、并处理主机可能“正在发送响应”等中间状态。
3. 两种主要方法


有两种高级方法来实现主备复制。
3.1 状态转移
在这种方法中,主机在更新自身状态后,将变更后的完整或增量状态发送给备份。
- 流程:
客户端请求 -> 主机处理并更新状态 -> 主机将新状态发送给备份 -> 主机响应客户端。 - 优点:概念简单。
- 缺点:如果单个操作产生的状态变更很大(如写入GB级数据),传输开销会很高。
3.2 复制状态机
这是更常见的方法。主机不是发送状态,而是将导致状态变更的确定性操作发送给备份。
- 流程:
客户端请求(操作)-> 主机将操作发送给备份 -> 备份执行相同操作 -> 备份确认 -> 主机执行操作并响应客户端。 - 核心思想:主备机从相同的初始状态开始,按相同顺序执行相同的确定性操作,最终必然达到相同的状态。
- 优点:通常传输开销更小,只传输操作本身而非全部状态。
- 应用实例:GFS(发送追加/写入操作)、VM-FT(发送机器指令)、课程实验3和4。
为什么客户端不需要直接联系备份?
因为操作是确定性的。只要备份从相同状态开始并执行相同操作,就能得到与主机相同的结果,无需客户端介入。
4. 案例研究:VMware 容错
VMware FT 是复制状态机方法的一个经典实现,其独特之处在于在机器指令级别进行复制,从而对上层操作系统和应用程序完全透明。
4.1 核心架构与透明性
VM-FT 利用虚拟机监控器来实现透明复制。
- 组件:主备两台物理机,各自运行一个包含 VM-FT 功能的虚拟机监控器,其上运行相同的虚拟机(如运行 Linux 和应用程序)。
- 日志通道:连接主备虚拟机监控器,用于传输非确定性事件信息。
- 外部中断处理:所有外部事件(网络数据包、定时器中断)首先被主机虚拟机监控器捕获。监控器记录事件发生时的精确指令位置,并通过日志通道通知备份“在执行到第 N 条指令时,交付此中断”。这样确保了主备机在指令流的同一点处理外部输入。
- 非确定性指令处理:VM-FT 在启动虚拟机前,会修改其二进制代码,将所有非确定性指令(如
RDTSC读取时间戳)替换为会陷入虚拟机监控器的指令。当主机执行这些指令时,监控器模拟执行,将结果记录并发送给备份。备份在执行到相同指令时,直接使用主机发来的结果,从而保证一致性。
4.2 输出规则
这是保证一致性的关键规则。主机在向外部发送任何输出(如网络响应)之前,必须确保导致该输出的所有操作日志都已被备份接收并确认。
- 目的:防止主机在备份尚未知晓某个操作时就对外响应,随后主机故障,备份接管后状态不一致,客户端观察到异常。
- 影响:这会引入一定的延迟,是性能开销的主要来源之一。
4.3 故障检测与仲裁
当主备机之间的日志通道中断时,需要区分是网络分区还是对方机器故障。
- 仲裁服务器:VM-FT 引入一个共享的存储服务器,其上维护一个Test-and-Set 标志。
- 流程:
- 主备机无法通信时,都会尝试连接仲裁服务器,并原子性地将该标志从 0 设置为 1。
- 首先成功设置为 1 的一方(读到旧值 0)赢得仲裁,继续作为主机运行。
- 另一方(读到旧值 1)知道自己失败,将自行终止。
- 避免脑裂:此机制确保了即使发生网络分区,也最多只有一个服务器能继续以主机身份运行。
4.4 性能与局限性
- 性能:VM-FT 提供了良好的透明性,但性能有显著下降(论文中显示某些场景下降约30%),主要源于输出规则带来的等待延迟以及日志通道的通信开销。
- 主要局限性:论文中的方案不支持多核处理器。因为多核环境下的线程调度和锁竞争会引入非确定性,使得在指令级保持主备严格同步变得极其复杂。后续产品版本可能通过其他方式支持了多核。
总结
本节课我们一起学习了主备复制的基础知识。我们首先明确了它主要处理的 Fail-Stop 故障类型及其挑战,特别是状态同步和脑裂问题。接着,我们分析了状态转移和复制状态机这两种核心实现方法,并指出后者因其效率更高而更常见。

通过深入剖析 VMware FT 案例,我们看到了复制状态机方法如何在实际系统中实现。VM-FT 通过在虚拟机监控器层面、机器指令级别进行复制,实现了对上层软件的完全透明。其关键技术点包括:通过日志通道同步非确定性事件和外部输入、使用输出规则保证故障前后状态一致性、以及依赖仲裁服务器解决故障检测与脑裂问题。尽管存在性能开销和初期不支持多核等限制,VM-FT 清晰地展示了复制状态机原理的强大与实用价值,为后续学习更复杂的分布式容错协议奠定了基础。
课程5:容错 - Raft (1) 🚀
在本节课中,我们将要学习 Raft 复制协议。Raft 是构建容错分布式系统的核心组件之一,它通过复制状态机来确保系统在部分节点故障时仍能正确运行。我们将重点关注 Raft 协议的基础概念,包括领导者选举和日志复制,这些是实验 2A 和 2B 的核心内容。下一周,我们将继续探讨快照、日志压缩等更深入的主题。
概述:从单点故障到多数原则
在之前的课程中,我们研究过 GFS、MapReduce、VM-FT 等复制系统。这些系统虽然通过复制实现容错,但都存在一个关键的单点故障,例如 MapReduce 的协调器、GFS 的主服务器或 VM-FT 的测试与设置服务器。引入单点主要是为了避免“脑裂”问题。
然而,完全消除单点故障是可能的。Raft 这类协议的目标就是构建一个没有单点故障、能自动处理故障和网络分区的强容错系统。在深入 Raft 之前,让我们先理解为什么简单地复制单点服务(如测试与设置服务器)会导致脑裂问题。
假设我们复制测试与设置服务器,有两个副本 S1 和 S2。客户端 C1 调用 test-and-set,它成功更新了 S1,但由于网络分区,无法联系 S2。与此同时,客户端 C2 可能正在与 S2 通信并成功更新。这样,两个客户端都认为自己成功了,违反了 test-and-set 的互斥语义。问题的核心在于,C1 无法区分 S2 不可达是因为其故障还是网络分区。
解决方案是多数原则。我们不运行两个副本,而是运行三个副本 S1、S2、S3。规则是:客户端必须成功更新大多数服务器(即至少 2 台),才能认为操作成功。这样,任何两个“大多数”集合必然存在重叠,确保了操作的唯一性。在发生网络分区时,只有包含多数服务器的分区能够继续运行,从而避免了脑裂。
这个思想可以推广:为了容忍 f 个故障,我们需要 2f + 1 台服务器。多数是指所有服务器中的大多数,包括在线和离线的。
Raft 协议概览
在详细讨论 Raft 之前,我们先了解如何使用 Raft 构建一个复制状态机,这是我们的最终目标。
复制状态机架构
在实验 3 中,我们将使用 Raft 构建一个键值存储服务器。基本架构如下:
- 每个服务器实例包含两个部分:上层的键值服务(KV Server)和下层的 Raft 共识模块(Raft Library)。
- 客户端向领导者(Leader)发送请求(如 Put/Get)。
- 领导者的 KV Server 将操作传递给本地的 Raft 模块。
- Raft 模块将该操作作为一个日志条目追加到自己的日志中,然后通过 RPC 复制给其他服务器(Follower)的 Raft 模块。
- 一旦日志条目被复制到大多数服务器,该条目就被视为已提交。
- 已提交的日志条目会按顺序传递给本地的 KV Server 执行。
- 领导者执行操作后,将结果返回给客户端。
如果领导者故障,会触发新的领导者选举。客户端在超时后会向新领导者重试请求,这可能导致重复操作,需要在 KV Server 层进行重复检测。
日志的作用
你可能会问,既然 KV Server 已经有数据表,为什么还需要日志?原因如下:
- 确保顺序:日志为所有操作提供了一个全局一致的顺序。
- 持久化与恢复:日志存储在磁盘上,即使服务器崩溃重启,也能从中恢复状态。
- 试探性操作:在条目被提交之前,它处于“未提交”状态,日志为此提供了暂存空间。
- 重传:在网络丢包或跟随者落后时,领导者可以根据日志重传缺失的条目。
每个日志条目包含:
- 命令:需要状态机执行的操作。
- 任期号:创建该条目时领导者的任期。
- 索引:在日志中的位置。
索引和任期号的组合唯一标识了一个日志条目。
领导者选举 🗳️
领导者选举是 Raft 保持可用性的关键,也是实验 2A 的主题。
选举触发与流程
在稳定状态下,一个领导者会定期向所有跟随者发送心跳(一种不包含新日志条目的特殊追加条目 RPC),以维持其权威。
- 超时:每个跟随者都维护一个选举超时计时器。如果在计时器期间内没有收到领导者的心跳,跟随者就会认为领导者可能已经故障。
- 成为候选人:超时的跟随者增加自己的当前任期号,转换为候选人状态,并首先为自己投一票。
- 请求投票:候选人向集群中的所有其他服务器发送请求投票 RPC。
- 赢得选举:如果候选人获得了大多数服务器的投票,它就赢得选举,成为新的领导者。
- 开始领导:新领导者立即开始向跟随者发送心跳,以阻止新的选举。
关键机制与问题处理
- 任期号:每个任期最多只有一个领导者。如果候选人或跟随者发现通信对方的任期号比自己高,它会立即退位为跟随者。这防止了旧领导者在网络分区恢复后继续行使职责,从而避免脑裂。
- 随机化超时:为了避免多个跟随者同时超时、同时成为候选人并导致选票分裂(无人获得多数),Raft 让每个服务器在一个随机区间内选择其选举超时时间。这大大降低了选票分裂的概率,即使发生,也会因随机性在后续选举中快速解决。
- 投票限制:一个服务器在一个任期内最多投出一票(先到先得)。这确保了每个任期最多只有一个候选人能获得多数票。服务器必须将投票给谁的信息持久化存储,以防崩溃后忘记并重复投票。
- 日志最新性限制:在投票请求中,候选人会携带自己日志的最后索引和任期号。收到投票请求的服务器,只有当候选人的日志至少和自己一样新时,才会投出赞成票。这个规则确保了只有包含所有已提交日志条目的服务器才有可能成为领导者,这是保证数据安全性的关键。
日志复制与一致性 📝

上一节我们介绍了如何选举出领导者,本节我们来看看领导者如何通过日志复制来保证所有服务器状态的一致性。这是实验 2B 的核心。

正常操作流程
- 接收请求:领导者收到客户端请求。
- 追加本地日志:领导者将请求作为新条目追加到本地日志中。
- 并行复制:领导者通过追加条目 RPC 将新条目并行发送给所有跟随者。
- 等待确认:领导者等待大多数跟随者的成功回复。
- 提交与应用:一旦收到大多数成功回复,领导者就将该条目标记为已提交,并将其应用于本地状态机,然后回复客户端。
- 通知跟随者:在后续的心跳或追加条目 RPC 中,领导者会携带一个
commitIndex参数,通知跟随者哪些日志条目可以被提交并应用到它们的状态机。
日志不一致与恢复
由于网络延迟、服务器崩溃等原因,跟随者的日志可能与领导者不同。Raft 通过强制跟随者复制领导者的日志来解决不一致问题,这称为日志同步。
领导者为每个跟随者维护一个 nextIndex,表示将要发送给该跟随者的下一条日志索引。当领导者和跟随者的日志不一致时,追加条目 RPC 会失败。领导者随后会递减 nextIndex 并重试,直到找到两者一致的位置。然后,领导者会从这个位置开始,发送之后的所有日志条目,覆盖跟随者不一致的部分。
提交规则与安全性
一个至关重要的规则是:领导者只能提交当前任期内的日志条目。如果领导者试图提交之前任期的条目,可能会破坏图7中讨论的安全性。具体来说,即使一个日志条目已经存储在大多数服务器上,如果它来自旧任期,新领导者也可能用新的条目覆盖它。只有当前任期的条目被提交后,才能间接地提交之前任期的所有条目。这个规则是 Raft 安全性的基石。
总结
本节课我们一起学习了 Raft 复制协议的第一部分。我们从单点故障的问题出发,引入了多数原则作为解决脑裂和实现容错的基础。然后,我们概述了使用 Raft 构建复制状态机的整体架构。
我们深入探讨了 领导者选举 的流程,包括选举触发、任期号机制、随机化超时以避免分裂投票,以及投票的日志最新性限制。接着,我们学习了 日志复制 的核心过程,了解了领导者如何复制日志、如何提交条目,以及如何处理日志不一致的情况。最后,我们强调了领导者只能提交当前任期日志这一关键的安全性规则。

理解这些基础概念对于完成实验 2A 和 2B 至关重要。在下一节课中,我们将继续研究 Raft 的更多方面,包括持久化、快照和客户端交互。
课程 P6:实验 1 问答与 Go 编程技巧 🗣️💻


在本节课中,我们将回顾 MapReduce 实验 1 的解决方案,讨论常见的设计模式与错误,并学习一些通用的 Go 编程技巧,为后续实验做好准备。
概述


本节课内容主要包括:
- 演示一个可行的 MapReduce 实验 1 解决方案。
- 讨论替代的解决方案设计。
- 分析实验中常见的错误和 Bug。
- 提供一些通用的编程提示。
- 进行问答环节。


1. 实验解决方案演示 🧪




首先,我们逐步演示一个 MapReduce 实验 1 的解决方案。这个方案使用 RPC 进行 Worker 和 Coordinator 之间的通信,并利用条件变量进行任务调度。


第一步:定义 RPC API

首先,在 rpc.go 中定义任务类型和 RPC 接口。

// 任务类型
type TaskType int
const (
MapTask TaskType = iota
ReduceTask
DoneTask // 表示协调者已完成
)


// Worker 请求任务的 RPC 参数与回复
type GetTaskArgs struct {}
type GetTaskReply struct {
TaskType TaskType
// ... 其他任务所需的元数据,如文件、Map/Reduce 任务数量等
}
// Worker 通知任务完成的 RPC 参数
type FinishedTaskArgs struct {
TaskType TaskType
TaskId int
}
type FinishedTaskReply struct {}
核心概念:定义清晰的 RPC 接口是分布式系统通信的基础。
第二步:实现 RPC 处理程序
在 Coordinator 中实现 RPC 的处理程序。Coordinator 需要维护状态,并使用互斥锁保护并发访问。
type Coordinator struct {
mu sync.Mutex
// 状态信息:任务文件、任务状态、完成标志等
mapFiles []string
mapTasks []TaskStatus
reduceTasks []TaskStatus
nReduce int
done bool
}
func (c *Coordinator) GetTask(args *GetTaskArgs, reply *GetTaskReply) error {
c.mu.Lock()
defer c.mu.Unlock()
// 检查并分配 Map 或 Reduce 任务,若全部完成则返回 DoneTask
// ...
return nil
}
func (c *Coordinator) FinishedTask(args *FinishedTaskArgs, reply *FinishedTaskReply) error {
c.mu.Lock()
defer c.mu.Unlock()
// 根据 args.TaskType 和 args.TaskId 更新对应任务状态为完成
// ...
return nil
}
核心概念:使用 sync.Mutex 和 defer 语句可以安全、简洁地管理对共享状态的访问。
第三步:Worker 发送 RPC
Worker 在一个循环中向 Coordinator 请求任务,并根据任务类型执行相应操作。
func Worker() {
for {
args := GetTaskArgs{}
reply := GetTaskReply{}
callCoordinator("Coordinator.GetTask", &args, &reply)
switch reply.TaskType {
case MapTask:
performMap(reply.MapFile, reply.NReduce)
callCoordinator("Coordinator.FinishedTask", &FinishedTaskArgs{TaskType: MapTask, TaskId: reply.TaskId}, &FinishedTaskReply{})
case ReduceTask:
performReduce(reply.ReduceId, reply.NMap)
callCoordinator("Coordinator.FinishedTask", &FinishedTaskArgs{TaskType: ReduceTask, TaskId: reply.TaskId}, &FinishedTaskReply{})
case DoneTask:
return // 退出 Worker
}
}
}
核心概念:Worker 的核心逻辑是一个简单的“请求-执行-报告”循环。
第四步:实现文件管理
实现辅助函数来原子性地重命名中间文件,防止冲突。
func finalizeFile(tmpFile string, finalFile string) error {
return os.Rename(tmpFile, finalFile)
}
第五步:实现 Map 和 Reduce 函数
performMap 函数读取输入文件,应用用户定义的 map 函数,并将输出写入中间文件。performReduce 函数读取属于其分区(reduce ID)的所有中间文件,对键进行排序,然后应用 reduce 函数。
func performMap(filename string, nReduce int) {
// 1. 读取文件内容
// 2. 调用用户 map 函数,生成键值对列表
// 3. 根据键的哈希将键值对分区到 nReduce 个临时文件中
// 4. 原子重命名临时文件为最终中间文件
}
func performReduce(reduceId int, nMap int) {
// 1. 读取所有 Map 任务生成的、属于此 reduceId 的中间文件
// 2. 对所有键值对按键排序
// 3. 对每个键,收集其所有值,调用用户 reduce 函数
// 4. 将结果写入临时输出文件,然后原子重命名为最终输出文件
}
核心概念:Map 阶段进行“分而治之”,Reduce 阶段进行“汇总归约”。
第六步:实现 Coordinator 的任务调度
这是 Coordinator 最复杂的部分,负责给 Worker 分配任务,并处理超时与重试。一种实现方式是使用条件变量来等待可分配的任务出现。
func (c *Coordinator) scheduleTasks() {
c.mu.Lock()
// 首先分配所有 Map 任务
for !c.allMapTasksDone() {
if taskId := c.findAvailableMapTask(); taskId != -1 {
// 找到任务,分配给 Worker (通过 RPC 回复)
c.assignMapTask(taskId)
// 启动一个 goroutine 来监控此任务超时
go c.monitorTask(MapTask, taskId)
} else {
// 没有立即可用的 Map 任务,等待(条件变量)
c.cond.Wait()
}
}
// 所有 Map 任务完成后,开始分配 Reduce 任务
for !c.allReduceTasksDone() {
if taskId := c.findAvailableReduceTask(); taskId != -1 {
c.assignReduceTask(taskId)
go c.monitorTask(ReduceTask, taskId)
} else {
c.cond.Wait()
}
}
c.done = true
c.mu.Unlock()
}

// 监控任务超时的 goroutine
func (c *Coordinator) monitorTask(taskType TaskType, taskId int) {
time.Sleep(TaskTimeout)
c.mu.Lock()
defer c.mu.Unlock()
if !c.isTaskDone(taskType, taskId) {
// 任务超时未完成,重置状态以便重新分配
c.resetTask(taskType, taskId)
c.cond.Broadcast() // 唤醒调度循环
}
}

// 在 FinishedTask RPC 处理程序中,完成任务后也发出广播
func (c *Coordinator) FinishedTask(args *FinishedTaskArgs, reply *FinishedTaskReply) error {
c.mu.Lock()
defer c.mu.Unlock()
c.markTaskDone(args.TaskType, args.TaskId)
c.cond.Broadcast() // 唤醒调度循环,可能现在有任务可分配了
return nil
}
核心概念:条件变量 sync.Cond 适用于等待某个特定条件(如“有任务可分配”)变为真,比循环睡眠更高效。
上一节我们介绍了一个基于条件变量的 Coordinator 调度实现,本节中我们来看看其他可能的设计方案。
2. 替代解决方案设计 🔄
除了使用条件变量,还可以考虑其他同步和通信模式。
设计一:Worker 侧主动轮询
在这种设计中,如果 Coordinator 没有任务可分配,它会立即返回一个“无任务”的回复。Worker 在收到此回复后,等待一段时间再重新请求。
- 优点:Coordinator 逻辑简单,不会阻塞在 RPC 处理程序中。
- 缺点:产生更多网络 RPC 流量;Worker 的等待时间可能不够高效。
设计二:使用 Channel 进行任务队列
可以利用 Go 的 Channel 作为任务队列和生产-消费者模型。以下是一个概念性示例,展示了 Channel 的潜在用法:
func CoordinatorWithChannels(taskChan chan Task, workerChan chan int) {
// 将初始任务推入 taskChan
for i := 0; i < numTasks; i++ {
taskChan <- Task{Id: i}
}
// 监听 worker 加入
go func() {
for workerId := range workerChan {
go func(wId int) {
for task := range taskChan { // 从通道取任务
if callWorker(wId, task) {
// 任务成功,通知完成
doneChan <- task.Id
} else {
// 任务失败,重新放回队列
taskChan <- task
}
}
}(workerId)
}
}()
// 等待所有任务完成
for completed := 0; completed < numTasks; completed++ {
<-doneChan
}
close(taskChan) // 关闭通道,使 worker goroutine 退出
}
核心概念:Channel 非常适合用于 goroutine 之间的消息传递和队列管理,能简化某些场景下的同步逻辑。但在保护复杂共享状态时,互斥锁可能更直观。
3. 常见设计错误与 Bug 🐛
在实现过程中,需要注意以下几点:
以下是实验中一些常见的陷阱:
- Coordinator 过载:将本应由 Worker 执行的工作(如排序、读取大量文件内容)放在 Coordinator,使其成为瓶颈。MapReduce 的优势在于将计算分散到 Worker。
- RPC 设计冗余:设计过多或过于细粒度的 RPC 调用(例如,先询问是否有任务,再请求任务)。应精简 API。
- 同步错误:
- 在可能阻塞的操作(如网络 RPC、Channel 操作)期间持有锁,导致整个程序停滞。
- 误以为不同机器上的锁(或 Channel)可以跨进程同步。同步原语仅用于协调同一进程内的多个线程。
- “良性”数据竞争:即使你认为某个变量(如
isDone)的读写竞争是安全的,也必须使用同步机制(如锁或atomic操作)。未定义行为可能导致难以调试的问题。 - 超时处理不完善:未正确处理任务超时和重试,或重试逻辑导致任务被重复执行。
4. 通用编程提示与技巧 💡
以下技巧有助于提高未来实验的编码和调试效率:
- 条件调试输出:使用类似
DPrintf的函数,方便在调试时输出信息,提交时无需注释大量printf。func DPrintf(format string, a ...interface{}) (n int, err error) { if Debug { log.Printf(format, a...) } return } - 检查 Goroutine:在程序运行时,可以按
Ctrl-\(Unix)来查看所有运行中的 goroutine 及其堆栈,帮助诊断死锁或阻塞。 - 善用
defer:defer语句能确保函数返回前执行清理(如解锁)。注意多个defer的执行顺序是后进先出(LIFO)。 - 代码组织:将代码按功能分到不同文件(如
rpc.go,coordinator.go,worker.go)。将重复逻辑提取为函数(如 Raft 中检查任期并重置状态的逻辑)。 - 编辑器与环境:配置一个带有自动补全、代码导航功能的开发环境,可以大幅提升效率。
5. 问答环节精选 ❓
以下是对课程中部分问题的解答:
- Q:MapReduce 适用于更复杂的计算吗?
- A:是的。MapReduce 模型可用于矩阵乘法、机器学习等复杂计算。后继系统如 Spark、Google Dataflow 提供了更灵活的数据流图计算模型。
- Q:Coordinator 如何容错?
- A:原始论文使用简单的检查点机制。对于需要强一致性的场景,可以使用 Raft 复制状态机来实现一组高可用的 Coordinator。
- Q:为什么 Mapper 在本地写文件?
- A:在 MapReduce 论文发表的年代,网络带宽是瓶颈。本地写减少网络传输。输出阶段才写入分布式文件系统(如 GFS)。
- Q:如何选择超时时间?
- A:在 MapReduce 实验 1 中,10秒的任务超时是合理的。在 Raft 实验中,选举超时需要仔细选择(例如 150-300ms 范围),需考虑心跳间隔,并加入随机性以防止同时选举。
- Q:可以混合使用锁和 Channel 吗?
- A:当然可以。锁适合保护复杂的共享状态,Channel 适合 goroutine 间的通信和特定类型的同步(如等待事件)。Raft 实现中通常会同时使用两者。
- Q:如何干净地关闭进程?
- A:一种简单方法是 Coordinator 在完成后,不再响应 Worker 的 RPC。Worker 发现连接错误后自行退出。也可以定义明确的退出 RPC。
总结



本节课中我们一起学习了 MapReduce 实验 1 的一个完整解决方案,其核心在于通过 RPC 实现 Worker 与 Coordinator 的通信,并利用条件变量进行有效的任务调度与容错处理。我们还探讨了不同的设计选择,分析了常见错误,并掌握了一些实用的 Go 编程和调试技巧。理解这些概念将为后续更复杂的分布式系统实验(如 Raft)打下坚实的基础。
🧩 课程7:容错 - Raft (2)
在本节课中,我们将深入学习Raft共识算法的更多细节,包括日志分叉的处理、日志追赶机制、状态持久化、快照以及线性一致性等重要概念。这些内容对于理解分布式系统的容错机制至关重要。
📊 日志分叉与领导者选举
上一节我们介绍了Raft的基本工作原理和领导者选举。本节中我们来看看当系统出现日志分叉时,Raft协议如何通过特定的选举规则来保证一致性。
图6展示了一个典型的日志分叉场景。当领导者崩溃后,系统中的日志可能出现很大差异。新领导者的选举必须遵循特定规则,以确保集群能够收敛到正确的日志状态。
- 任何领导者都需要获得大多数节点的投票,以避免脑裂问题。
- 选举规则要求候选者的日志至少和投票者一样新。具体来说,候选者最后一个日志条目的任期号必须大于或等于投票者的最后一个日志条目的任期号。如果任期号相同,则日志更长的候选者获胜。
基于这些规则,在图6的场景中,节点A、C、D都有可能成为新的领导者,具体取决于哪些节点在线以及它们当前的任期号。
🔄 日志追赶机制
当新的领导者选举出来后,它需要让所有跟随者的日志与自己保持一致。这个过程称为日志追赶。
领导者为每个跟随者维护两个关键变量:
nextIndex: 对跟随者下一个日志条目的乐观估计(初始值为领导者最后日志索引+1)。matchIndex: 已知的已复制到跟随者的最高日志索引的悲观估计(初始值为0)。
追赶的基本流程(未优化版本)如下:
- 领导者发送心跳(包含
prevLogIndex和prevLogTerm)给跟随者。 - 如果跟随者发现
prevLogIndex处的日志任期不匹配,则回复拒绝。 - 领导者将对该跟随者的
nextIndex减1,然后重试,直到找到匹配点。 - 找到匹配点后,领导者从该点开始发送后续的所有日志条目。
- 跟随者接受并应用这些条目,回复成功。领导者更新
matchIndex。
未优化版本在跟随者落后很多时效率低下(需要逐条回退)。Raft论文描述了一种优化方法:
- 跟随者在拒绝响应中,额外返回冲突的任期号(
conflictTerm)以及该任期号在日志中第一次出现的索引(firstIndexForTerm)。 - 领导者利用这些信息,可以直接将
nextIndex回退到firstIndexForTerm,从而可能跳过一个完整的任期,加速追赶过程。
💾 状态持久化
为了在节点崩溃重启后能快速恢复并继续参与集群,Raft需要将一些关键状态持久化到稳定存储(如磁盘)。
以下是必须持久化的状态:
- 当前任期号 (
currentTerm): 用于检测过时的RPC请求和确保每个任期只投票一次。 - 投票记录 (
votedFor): 记录在当前任期投给了哪个候选者,防止重复投票。 - 日志条目 (
log[])): 必须持久化以确保已提交的条目不会丢失。如果只在内存中,崩溃可能导致已向客户端确认的操作丢失,破坏一致性。
每次这些状态发生变化时,都必须立即写入持久化存储,然后才能进行后续操作(如回复RPC)。


📸 快照与日志压缩
随着系统运行,日志会不断增长。重放全部日志来恢复服务状态或让新节点加入会非常耗时。Raft使用快照来解决这个问题。
快照机制的核心思想:
- 服务周期性地对其状态(如键值对)创建检查点,生成快照。
- 服务通知Raft库:“我已对直到索引
i的所有操作完成了快照”。 - Raft库可以安全地删除日志中索引
i及之前的所有条目(日志压缩)。 - 快照本身也需要持久化存储。
当跟随者大幅落后(其所需的日志条目已被领导者压缩)或新节点加入时,领导者会通过InstallSnapshot RPC将快照发送给它们。跟随者应用快照来快速重建服务状态,然后只需同步快照点之后的少量日志。
注意:快照RPC必须小心处理。如果跟随者收到的快照比它当前的状态还旧,它必须拒绝或只应用快照中覆盖旧日志的部分,而不能回滚已提交的状态。
⚖️ 线性一致性
线性一致性是衡量像Raft这样的复制状态机是否正确、行为是否像单台机器的严格标准。
线性一致性要求所有操作(读/写)的集合,存在一个全局排序,且满足以下条件:
- 全局顺序:所有操作可以排列成一个序列。
- 实时顺序:如果一个操作在另一个操作开始之前完成,那么在全局顺序中,前一个操作也必须排在另一个操作之前。
- 读最新:读操作必须返回最近一次写入的值(根据全局顺序)。
简单来说,线性一致性系统对外表现必须和一台单机处理所有客户端请求完全一样。这是分布式系统提供“强一致性”的常见定义。
在基于Raft的键值服务中,客户端通过clerk(客户端存根)与服务器交互。clerk负责维护当前领导者的信息、为请求生成唯一ID以进行重复检测,并在超时或被告知非领导者时重试其他服务器,共同协作以实现线性一致性语义。
🎯 总结
本节课我们一起深入探讨了Raft算法的几个高级主题:
- 我们分析了日志分叉场景,理解了严格的领导者选举规则如何保证安全。
- 我们学习了日志追赶机制,包括其基本流程和加速追赶的优化技巧。
- 我们明确了Raft中必须持久化的状态(任期、投票、日志)及其原因。
- 我们介绍了快照与日志压缩,这是管理长日志和高效恢复的关键。
- 最后,我们定义了线性一致性这一强一致性标准,它是对基于Raft构建的服务正确性的最终要求。

掌握这些概念,对于实现一个健壮的、容错的分布式系统至关重要。
课程 P8:实验 2A 与 2B 问答 🧑🏫
在本节课中,我们将针对实验 2A 和 2B 进行专门的问答。我们将探讨调试方法、代码结构、选举、心跳、日志提交等核心概念,并分享一个具体的实现方案。
调试方法 🐛

调试是实验中最常遇到的问题。以下是一个系统化的高级调试流程。

首先,运行第一个测试用例。即使失败,它也是一个起点。在编写代码时,系统地记录所有消息至关重要。可以使用 util.go 中的 DPrintf 函数。
测试失败后,需要深入研究测试用例。测试用例的名称通常暗示了它要测试的场景。接下来,需要思考并假设代码失败的原因。
为了验证假设,需要分析日志。通过查看发送到协议的消息跟踪,可以定位问题所在。有时可能需要重新运行测试以获取更详细的日志输出。
一旦假设被证实,就可以修改代码并重新运行测试。这个过程是迭代且系统化的。建议将假设和证据记录在文本文件中,以确保修复代码的理由充分。
避免非系统化的方法,例如随意更改代码以查看测试是否通过。这可能导致 Bug 被隐藏或转移,在后续测试中再次出现。


以下是调试的关键步骤:
- 运行测试用例。
- 如果失败,研究测试用例和日志以形成假设。
- 验证假设。
- 修复代码。
- 进入下一个测试。
关于日志记录的粒度,建议记录所有细节。然后可以使用编辑器或 grep 等工具过滤出感兴趣的部分。一些人会构建额外的调试基础设施来简化日志分析。
关于日志输出,通常将调试信息通过管道重定向到文件中,而不是标准输出。
代码结构概览 🏗️
上一节我们介绍了调试方法,本节中我们来看看一个可能的 Raft 实现结构。这不是唯一的结构,但提供了一个清晰的范例。
整体结构围绕一个 Raft 结构体展开,它包含了图 2 中的所有状态变量,并使用一个粗粒度的锁进行保护。
主要的线程/协程包括:
- 定时器线程:周期性检查选举超时。
- Apply 线程:唯一负责向
applyCh写入已提交日志条目的线程,它在一个条件变量上等待。 - RPC 处理线程:每个传入的 RPC 请求由 RPC 库启动一个线程处理。它们首先获取锁,读写
Raft状态,然后释放锁。 - Start 调用:客户端调用
Start时,会获取锁,将命令追加到日志,然后释放锁。 - RPC 发送线程:领导者会为每个追随者启动一个单独的线程来发送
AppendEntriesRPC。这些线程在发送 RPC 时不持有锁,但在处理回复时会获取锁。
所有线程通过 Raft 锁进行序列化,这简化了并发控制。需要特别注意避免死锁,尤其是在持有锁时进行通道操作或发起 RPC 调用。
选举实现详解 🗳️
了解了整体结构后,我们深入看看选举部分的具体实现。
Make 函数初始化 Raft 状态,启动 applier 和 ticker 协程。ticker 每 50 毫秒触发一次,检查是否超时。
选举超时时间设置为 1 秒加上一个 0 到 300 毫秒的随机偏移。当 ticker 发现选举超时且当前节点不是领导者时,会调用 startElectionL 开始选举。
以下是 startElectionL 的关键步骤:
- 增加
currentTerm。 - 将状态转为
Candidate。 - 投票给自己(设置
votedFor)。 - 持久化状态(实验 2C 要求)。
- 向所有其他节点并行发送
RequestVoteRPC。
发送 RPC 前,在持有锁的情况下准备好参数。每个 RPC 在一个新协程中发送,发送时不持有锁。当收到回复时,回复处理程序会获取锁,并更新计票。
如果候选人获得了超过半数的选票,并且在选举期间 term 未变,它就赢得选举,调用 becomeLeaderL 成为领导者,并立即发送一轮心跳。
在 RequestVote RPC 的处理程序中,追随者遵循图 2 的规则:
- 如果 RPC 的
term小于自己的currentTerm,则拒绝投票。 - 如果 RPC 的
term更大,则更新自己的term并转为追随者状态。 - 只有在(自己在本任期尚未投票 或 已经投给同一个候选人)且 候选人的日志至少和自己一样新时,才会授予投票。
判断日志新旧(upToDate)的规则是:比较最后一条日志的 (term, index)。任期号大的更新;任期号相同时,索引大的更新。
心跳与日志复制 ❤️
成为领导者后,需要发送心跳来维持权威并复制日志。心跳与日志复制的代码是同一套逻辑。
领导者通过 sendAppendsL 函数向所有追随者发送 AppendEntries RPC。对于每个追随者,领导者根据 nextIndex 猜测要从哪个日志索引开始发送条目。
AppendEntries 的参数构建如下:
args := AppendEntriesArgs{
Term: rf.currentTerm,
LeaderId: rf.me,
PrevLogIndex: nextIndex - 1,
PrevLogTerm: rf.log.entry(nextIndex - 1).Term,
Entries: rf.log.slice(nextIndex),
LeaderCommit: rf.commitIndex,
}
需要小心地将日志条目切片复制到参数中,避免共享内存。
与发送投票请求类似,每个 AppendEntries RPC 在一个新协程中发送。回复处理程序 processAppendReply 在获取锁后处理:
- 如果回复的
term更大,领导者退位为追随者。 - 如果 RPC 成功,则更新该追随者的
nextIndex和matchIndex。 - 如果 RPC 失败(通常因为日志不一致),则根据回复中的冲突信息快速回退
nextIndex,或者简单地将其减 1。
在处理完回复后,领导者会检查是否可以更新 commitIndex。规则是:寻找一个最大的索引 N,使得 N > commitIndex,并且大多数节点的 matchIndex[i] >= N,并且 log[N].term == currentTerm。然后设置 commitIndex = N。这个规则确保了图 8 描述的安全性(不提交旧任期的日志)。
当 commitIndex 更新时,会通过条件变量通知 applier 线程。
日志提交与应用 📤
applier 线程是唯一负责向 applyCh 写入的线程。它在条件变量上等待,当 commitIndex > lastApplied 时被唤醒。
被唤醒后,applier 获取锁,遍历 lastApplied + 1 到 commitIndex 之间的日志条目,为每个条目构造一个 ApplyMsg,然后在释放锁后将其发送到 applyCh。这样做是为了避免在通道操作上持有锁可能导致的死锁。
发送完毕后,更新 lastApplied。如果此时没有更多需要提交的条目,applier 线程再次在条件变量上等待。
Start 命令处理 🚀
客户端通过调用 Start(command) 来提议一个新命令。在 Start 函数中:
- 检查当前节点是否是领导者,如果不是则返回
false。 - 是领导者,则创建一个新的日志条目(包含命令和当前任期)。
- 将条目追加到本地日志。
- 持久化日志(实验 2C 要求)。
- 立即调用
sendAppendsL,将新条目广播给所有追随者。 - 返回
true以及新条目在日志中的索引。
总结 📝
本节课我们一起学习了实验 2A 和 2B 的实现要点。
我们首先探讨了系统化的调试方法,强调日志记录和假设验证的重要性。接着,我们分析了一个典型的 Raft 代码结构,它使用一个粗粒度锁来协调多个并发协程。
我们详细剖析了选举过程的实现,包括超时处理、投票请求的发送与处理,以及成为领导者后的状态转换。然后,我们深入研究了心跳与日志复制机制,涵盖了 AppendEntries RPC 的发送、冲突解决以及领导者提交索引的更新规则。
最后,我们了解了 applier 线程如何安全地将已提交的日志应用到状态机,以及 Start 命令的处理流程。

希望这些详细的解释和代码片段能帮助你更好地理解和完成实验。记住,这里展示的只是一种实现方式,你可以根据自己的理解进行设计和优化。
课程 P9:Zookeeper 详解 🐘

在本节课中,我们将学习分布式协调服务 Zookeeper。我们将探讨其设计背景、核心特性、性能优势以及它如何通过提供一种不同于线性一致性的保证来实现高性能。课程内容与实验3紧密相关,特别是关于线性一致性的讨论。

概述
Zookeeper 是一个来自 Apache 的开源项目,在实践中被广泛使用。它之所以有趣,主要有两个原因:高性能和作为通用协调服务。高性能体现在其异步客户端操作和允许从任何副本读取的设计上。作为协调服务,它旨在管理分布式应用中的配置信息,例如跟踪集群成员和主节点。



复制状态机基础

上一节我们介绍了 Zookeeper 的背景。本节中,我们来看看其底层架构的基础——复制状态机。

Zookeeper 服务器接收来自客户端的请求(例如创建 znode)。它使用一个独立的库(称为 ZAB,类似于 Raft 库)来分发这些操作。领导者将操作放入 ZAB,ZAB 与其他节点交互,创建一个在所有机器上保持一致的操作日志。然后,这个日志通过一个应用通道(apply channel)传递给服务,服务应用操作以响应客户端。
在实验3中,我们将在 Raft 之上实现一个键值存储服务,其操作是 put 和 get。Zookeeper 的结构略有不同,它由一棵 znode 树组成,但基本操作流程是相似的:底层的 ZAB 库对所有操作进行排序,并以相同的顺序应用到所有副本,从而确保每个副本的最终状态一致。

性能分析:简单方案 vs Zookeeper



上一节我们介绍了复制状态机模型。本节中,我们来看看性能考量,并对比简单方案与 Zookeeper 的差异。

首先,考虑在实验3的简单方案中,一个 put 操作的性能。假设一切运行正常,领导者需要将操作写入自己的稳定存储,并发送给至少一个跟随者以形成多数派。这至少涉及一次网络往返和两次稳定存储写入(领导者和一个跟随者)。假设网络往返为1毫秒,每次SSD顺序写入为2毫秒,那么一次 put 操作大约需要5毫秒,即每秒约200次操作。



现在,让我们看看 Zookeeper 的性能。论文中的图表显示,对于纯写操作,3台服务器的吞吐量约为每秒21,000次操作;对于纯读操作,吞吐量可达每秒60,000-70,000次操作,并且读吞吐量随服务器数量线性扩展。写性能则随着服务器增加而下降,因为领导者需要联系更多服务器。



Zookeeper 实现高性能的两个关键思想是:
- 异步与批处理:客户端可以异步提交多个操作,领导者可以将多个操作批量写入磁盘,减少磁盘I/O次数。
- 从任意副本读取:读操作可以在任何服务器上处理,无需经过领导者,这极大地提升了读吞吐量和可扩展性。

线性一致性 vs Zookeeper 的保证
上一节我们看到了从任意副本读取能带来性能提升。本节中,我们来看看这种“天真”的读取方案会带来什么问题,以及 Zookeeper 提供了何种一致性保证。



如果允许从任何跟随者读取,可能会出现两种问题:
- 读取过时数据:读取可能无法看到最新的写入。
- 时间倒流:客户端可能先读到新值,随后又读到旧值。
线性一致性要求系统表现得像一台单一机器,其正式定义包括:操作可以排列成一个整体顺序;该顺序需与真实时间匹配(如果一个操作在另一个开始前完成,则在前);读操作必须返回最后一次写入的值。上述两种问题都违反了线性一致性。
在实验3中,为了保证线性一致性,简单的解决方案是让所有读操作(get)也通过领导者进行。但这牺牲了读性能的可扩展性。

Zookeeper 没有提供线性一致性,它提供了一套不同的保证:
- 线性化写入:所有写入操作通过领导者并全局排序。
- FIFO 客户端顺序:单个客户端发出的操作按其发送顺序生效。
- 读取观察到某个一致前缀:读操作可以返回日志的某个前缀状态,可能不是最新的。
- 不读取过去:对同一客户端,后续读取不能观察到比之前读取更旧的状态。



Zookeeper 的实现机制
上一节我们了解了 Zookeeper 提供的一致性保证。本节中,我们来看看它如何实现这些保证。
Zookeeper 客户端与服务端建立会话(session)。当客户端执行写入时,领导者通过 ZAB 库将操作记录到日志中,并分配一个递增的 zxid(可视为日志索引)。提交后,领导者将 zxid 返回给客户端,客户端在其会话中记录该 zxid。

当客户端执行读取时,请求可以发送给任何跟随者,但会携带客户端已知的最后一次写入的 zxid。跟随者收到读请求后,会等待自己的日志至少应用到了该 zxid 所指示的位置,然后才回复读取结果。这确保了“不读取过去”的特性,但允许返回旧数据(即“读取观察到某个一致前缀”)。
Zookeeper 的 API 与编程模型
上一节我们探讨了 Zookeeper 的实现机制。本节中,我们来看看其精心设计的 API 如何支持协调服务。


Zookeeper 的数据模型是一棵 znode 树。Znode 有三种类型:
- 常规节点:持久化存储。
- 临时节点:与客户端会话绑定,会话结束则节点自动删除。
- 顺序节点:创建时名字会自动附加一个单调递增的序列号。
其核心 API 包括:
create(path, data, flags):创建节点。delete(path, version):删除节点(需指定版本号)。exists(path, watch):检查节点是否存在,并可设置监视(watch)。getData(path, watch):获取节点数据和版本号。setData(path, data, version):设置节点数据(需指定版本号,实现原子条件更新)。getChildren(path, watch):获取子节点列表。sync():强制同步,以实现线性化读取。

版本号是实现无锁式原子操作的关键。例如,实现一个原子递增计数器:

while True:
data, version = getData("/counter")
new_value = int(data) + 1
if setData("/counter", str(new_value), version):
break # 成功
# 版本号已变,重试循环
setData 操作只有在提供的版本号与当前节点版本号匹配时才会成功。这可以防止多个客户端基于过时状态进行更新,实现了类似 test-and-set 的原子操作。

监视(Watch) 机制允许客户端在节点发生变化时得到通知。Zookeeper 保证监视通知在触发它的变更操作之后、但在后续其他写入操作之前被传递。这有助于客户端在配置变更时做出正确响应,例如检测到主节点变更后,重新读取配置。



总结

本节课中,我们一起学习了分布式协调服务 Zookeeper。它是一个非常成功且广泛使用的系统,其核心在于通过提供比线性一致性更弱但精心设计的一致性保证(线性化写入、FIFO客户端顺序、一致前缀读取)来换取高性能和高可扩展性。尽管编程模型比单一机器模型更复杂,但其精心设计的 API(如条件更新和监视机制)使得在其上构建正确的协调服务(如领导者选举、配置管理)成为可能。这种在一致性、性能和可编程性之间的权衡,是分布式系统设计中一个经典而重要的模式。


浙公网安备 33010602011771号