UCSC-CSE138-分布式系统笔记-全-

UCSC CSE138 分布式系统笔记(全)

001:P01-L1 - 课程介绍与分布式系统初探

在本节课中,我们将学习本课程的基本安排,并初步探讨分布式系统的核心概念、挑战及其存在的意义。

课程安排与期望

首先,我们来了解本课程的后勤安排和期望。

课程沟通平台

我们将使用 Zulip 作为主要的课程沟通平台,替代 Zoom 聊天功能。Zulip 的聊天记录是持久的,方便大家在课程结束后回顾和提问。

课程网站与公开性

课程网站是公开的,包含课程概述、时间表等信息。除了作业是私密的,大部分课程内容,包括讲座和评分标准,都将公开分享。

课程团队

我是 Lindsay Cooper 教授,研究方向是编程语言与分布式系统的结合。我们还有两位优秀的助教(AB 和 Patrick)以及四位辅导员(Aria, Matthew, Argin, Vne),他们将负责讨论课、答疑和 Zulip 上的问题解答。

课程资源与通知

Zulip 的 “announcements” 频道将发布重要的课程通知,建议你开启该频道的通知。其他频道可根据个人需求设置。

特殊需求

如果你有残疾或需要特殊安排,请与我联系。无论是通过残疾资源中心(DRC)的正式渠道,还是直接与我沟通,我都非常乐意帮助你。

分布式系统:是什么与为什么

上一节我们介绍了课程的基本安排,本节中我们来看看分布式系统的核心定义及其存在的价值。

什么是分布式系统?

分布式系统的一个经典(略带玩笑)定义是:一个分布式系统是指,我无法完成工作,因为某个我从未听说过的计算机发生了故障。这个定义由分布式系统先驱 Leslie Lamport 提出,它揭示了分布式系统的核心挑战:部分故障

一个更正式的定义来自 Martin Kleppman:

  • 一个分布式系统运行在多个节点上。
  • 这些节点通过网络连接。
  • 其关键特征是部分故障

节点可以是一台计算机,或运行在计算机上的一个软件实例,它们通过网络通信以完成某项任务。部分故障意味着系统中的某些部分可能损坏,而其他部分运行正常,并且软件通常无法确切知道哪里出了问题。

分布式系统的挑战:不确定性

让我们通过一个最简单的两节点系统(M1 和 M2)来理解分布式系统的挑战。假设 M1 向 M2 发送请求“x 的值是多少?”,并期望得到响应“x=5”。

在这个简单的交互中,许多环节都可能出错:

以下是请求-响应过程中可能出现的故障类型:

  1. 请求丢失:M1 发出的请求在网络中丢失,从未到达 M2。
  2. 请求延迟:请求到达 M2 的过程异常缓慢。
  3. 节点故障:M2 在收到请求后、处理请求前崩溃。
  4. 处理延迟:M2 处理请求的过程异常缓慢。
  5. 响应丢失:M2 发出的响应在网络中丢失。
  6. 响应延迟:响应返回 M1 的过程异常缓慢。
  7. 拜占庭故障:消息在传输中被篡改,或 M2 恶意行为(如撒谎)。

最关键的问题是:如果 M1 发送请求后没有收到响应,它无法确定具体是哪种故障导致了这个问题。这种不确定性是分布式系统的根本特征之一。

为了应对无响应,现实系统通常采用超时重试机制。但这并非完美方案:

  • 如果超时设置过短,可能在响应到达前就错误地判定为失败。
  • 如果超时设置过长,会降低系统吞吐量。
  • 更重要的是,对于会产生“副作用”的请求(例如“将 x 加 1”),即使请求实际已生效但确认消息丢失,客户端也无法得知操作是否成功。

为何需要分布式系统?

既然分布式系统如此复杂且充满挑战,我们为什么还需要它呢?

以下是构建分布式系统的主要动机:

  • 性能/并行性:通过多台计算机并行处理,可以更快地完成任务。
  • 数据规模:当数据量超过单台机器的存储或处理能力时,必须使用多台机器。
  • 可靠性/容错性:通过在多台机器上存储数据的多个副本,即使部分机器故障,数据也不会丢失,服务仍可继续。
  • 地理分布/低延迟:将数据或服务部署在全球多个地点,可以让用户从地理上最近的节点获取数据,减少延迟。

课程结构与项目概述

了解了分布式系统的“为什么”之后,我们来看看本课程将如何帮助你掌握它。

评分构成

你的最终成绩将由以下几部分组成:

  1. 调查与课堂测验 (10%):包括课程开始/中期调查以及每堂课中的测验(仅要求参与,不评判内容对错)。
  2. 两次考试 (44%):期中考试 (20%) 和期末考试 (24%)。
  3. 课程项目 (46%):这是本课程的核心,包含 4 个作业。

课程项目:构建分布式键值存储

你将与 2-3 名队友组成小组,共同实现一个分布式、分片、复制的键值存储系统

以下是四个作业的简要介绍:

  • 作业 1:Docker 与 Web 服务:个人完成。学习使用 Docker,并实现一个提供指定 API 的简单 Web 服务。你可以使用任何编程语言和框架。
  • 作业 2:单机键值存储:实现一个非分布式的简单键值存储。
  • 作业 3:复制:为键值存储添加复制功能,以提高其容错性。
  • 作业 4:分片:为键值存储添加分片功能,将数据分布到多个节点上,以提高其可扩展性。

通过这个项目,你将亲身体验设计并实现一个非平凡的分布式系统。


本节课中我们一起学习了课程的基本安排,探讨了分布式系统的定义、其固有的不确定性与挑战,以及我们忍受这些复杂性所要追求的目标——性能、规模、可靠性和地理分布。同时,我们也了解了本课程的核心实践部分:通过小组项目构建一个真实的分布式键值存储系统。下节课我们将深入更多分布式系统的核心概念。

002:时间、时钟与因果关系 🕰️

在本节课中,我们将要学习分布式系统中关于时间、时钟和事件顺序的核心概念。我们将探讨物理时钟的局限性,并引入逻辑时钟和“happens-before”关系来理解系统中的因果关系。

时间与时钟

在软件开发中,我们使用时钟主要有两个目的:

  1. 标记特定的时间点。例如,本课程在太平洋时间下午3:20开始。
  2. 测量时间间隔或持续时间。例如,本课程时长为95分钟。

计算机通常有两种物理时钟:

  • 墙上时钟:告诉你当前的时间(例如,通过NTP同步)。它可能向前或向后跳变(例如闰秒),因此不适合精确测量时间间隔。
  • 单调时钟:只向前移动,适合测量时间间隔(例如,实现超时机制)。但其数值在不同机器间没有可比性,因此不适合标记时间点。

在分布式系统中,使用物理时钟(无论是墙上时钟还是单调时钟)来标记事件发生的绝对时间点并不可靠,因为不同机器的时钟无法完美同步。

逻辑时钟与“Happens-Before”关系

由于物理时钟的局限性,在分布式系统理论中,我们转而使用逻辑时钟。逻辑时钟不测量具体时间,只关心事件的先后顺序

核心问题是:如何定义事件A发生在事件B之前?

我们引入 A → B (读作“A happens before B”)关系。以下是其定义,满足以下任一条件即可认为 A → B

  1. 同一进程内:如果事件A和B发生在同一个进程上,并且A在B之前执行。
  2. 消息发送与接收:如果事件A是发送一条消息,事件B是接收同一条消息。
  3. 传递性:如果存在某个事件C,使得 A → CC → B,那么 A → B

这个关系帮助我们理解系统中的因果关系:如果 A → B,那么A可能导致了B,但B绝不可能导致A。

Lamport图(时空图)

为了直观地展示分布式系统中进程、事件和消息,我们使用Lamport图(也称时空图)。

  • 进程:用一条垂直线表示。
  • 事件:用进程线上的一个点表示。事件可以是内部计算、发送消息或接收消息。
  • 消息:用从发送事件指向接收事件的箭头表示。

在Lamport图中,时间默认向下流动。根据“happens-before”的定义,我们可以分析图中任意两个事件的关系。

如果两个事件既不满足 A → B,也不满足 B → A,则称它们是并发的。

网络模型

消息传递的延迟特性直接影响系统设计。我们主要讨论两种网络模型:

  1. 同步网络模型:存在一个已知的时间上限N,任何消息的传递延迟都不会超过N。
    • 公式∃N, ∀消息, 传递时间 ≤ N
  2. 异步网络模型:消息传递延迟没有上限,可能任意长。
    • 公式¬∃N, ∀消息, 传递时间 ≤ N (即不存在这样的N)

虽然同步模型让问题更简单,但异步模型更符合互联网等现实网络。为了使协议更健壮,我们通常基于更严苛的异步模型进行设计。

状态与事件

在Lamport图的视角下:

  • 事件是图中基本的点(发送、接收、内部事件)。
  • 状态(如变量x=5)是某一时刻进程所知信息的快照。

关键点在于:当前状态完全由该进程上所有已发生的事件序列决定。给定完整的事件日志,我们可以重现任何历史状态。然而,仅凭当前状态,通常无法反推出导致该状态的具体事件序列。


本节课中我们一起学习了分布式系统中时间概念的演变,从有缺陷的物理时钟转向逻辑时钟。我们掌握了定义事件顺序的“happens-before”关系及其可视化工具Lamport图,并了解了异步网络模型这一重要的系统假设基础。这些概念是理解后续分布式算法和协议的基石。

003:偏序、全序、Lamport时钟、向量时钟

在本节课中,我们将要学习分布式系统中事件排序的核心概念。我们将从回顾“先于发生”关系开始,然后深入探讨偏序与全序的区别。接着,我们将介绍两种逻辑时钟:Lamport时钟和向量时钟,它们用于捕获分布式系统中的因果关系,而无需依赖物理时间。

回顾“先于发生”关系

上一节我们介绍了分布式系统中事件排序的基础。本节中,我们来看看如何形式化地描述这种排序关系。

“先于发生”关系定义了分布式执行中事件的因果顺序。其定义基于以下三种情况:

以下是“先于发生”关系的三种情况:

  1. 如果事件A和事件B在同一个进程中,并且A在B之前发生,则 A → B。
  2. 如果事件A是消息发送,事件B是对应消息的接收,则 A → B。
  3. 该关系具有传递性:如果 A → C 且 C → B,则 A → B。

通过这个定义,我们可以判断任意两个事件是否具有因果先后关系。如果两个事件既不满足 A → B,也不满足 B → A,则称这两个事件是并发的。

偏序与全序

理解了“先于发生”关系后,我们可以用数学语言来精确描述它。本节中,我们来看看偏序和全序这两个关键概念。

偏序 由一个集合 S 和一个二元关系 ≤ 组成,该关系满足以下三个性质:

  • 自反性:对于所有 a ∈ S,有 a ≤ a。
  • 反对称性:对于所有 a, b ∈ S,如果 a ≤ b 且 b ≤ a,则 a = b。
  • 传递性:对于所有 a, b, c ∈ S,如果 a ≤ b 且 b ≤ c,则 a ≤ c。

在偏序中,并非集合中的每对元素都是可比较的。一个经典的例子是集合的包含关系。例如,集合 {a} 和 {b} 在包含关系下不可比较,因为 {a} 不是 {b} 的子集,{b} 也不是 {a} 的子集。

全序 是一种特殊的偏序,其中集合中的任意两个元素都是可比较的。自然数集及其通常的“小于等于”关系就是一个全序的例子。

现在,让我们将“先于发生”关系与偏序定义进行对比。它满足反对称性和传递性,但不满足自反性(一个事件不会“先于”自身发生)。因此,“先于发生”关系是一个非自反偏序。在分布式执行的上下文中,不可比较的事件正是那些并发的事件。

Lamport 逻辑时钟

既然我们有了描述事件顺序的关系,下一步就是设计一种机制来实际计算它。本节中,我们来看看Leslie Lamport提出的第一种逻辑时钟。

Lamport时钟为每个事件分配一个整数值,旨在捕获因果顺序。其核心是时钟条件:如果事件 A 先于事件 B 发生(A → B),那么 A 的Lamport时钟值必须小于 B 的Lamport时钟值。我们称Lamport时钟与因果关系一致

以下是Lamport时钟算法步骤:

  1. 每个进程维护一个计数器,初始值为0。
  2. 进程每发生一个事件(内部计算、发送消息、接收消息),就将自己的计数器加1,并将该值作为事件的时钟值。
  3. 发送消息时,进程将当前的计数器值随消息一起发送。
  4. 接收消息时,进程将自己的计数器值更新为 max(本地计数器值, 接收到的消息中的时钟值) + 1

通过这个算法,我们可以为执行中的所有事件分配Lamport时间戳。重要的是,它保证了如果 A → B,则 LC(A) < LC(B)

然而,反过来并不成立。即,LC(A) < LC(B) 并不能推出 A → B。这意味着Lamport时钟不能完全表征因果关系。尽管如此,它仍然非常有用:如果 LC(A) 不小于 LC(B),那么我们可以断定 A 不可能先于 B 发生。这是一种以较低开销(每个消息只附带一个整数)来排除某些因果可能性的有效方法。

向量时钟

Lamport时钟提供了部分信息,但我们能否做得更好?本节中,我们来看看一种更强力的逻辑时钟——向量时钟,它能完全捕获因果关系。

向量时钟的目标是同时满足以下两点:

  1. 与因果关系一致:如果 A → B,则 VC(A) < VC(B)
  2. 表征因果关系:如果 VC(A) < VC(B),则 A → B。

这里的小于号“<”是作用于向量的比较。向量时钟是一个数组,每个进程对应一个分量。

以下是向量时钟算法步骤:

  1. 系统有 N 个进程。每个进程维护一个长度为 N 的向量,所有分量初始为0。第 i 个分量记录进程 i 已知的事件数。
  2. 进程每发生一个事件,就将自己对应分量(向量中代表自己的位置)的值加1。
  3. 发送消息时,进程将当前的整个向量随消息一起发送。
  4. 接收消息时,进程将本地向量的每个分量更新为 max(本地向量分量, 接收向量中对应分量)。然后,再将代表自己的分量加1(因为接收本身也是一个事件)。

这里的“max”操作是逐分量进行的。向量之间的比较 V < W 定义为:对于所有分量 i,有 V[i] ≤ W[i],并且至少存在一个分量 j,使得 V[j] < W[j]

向量时钟比Lamport时钟携带了更多信息。例如,向量时钟 [5, 0, 0] 表示进程0知晓5个事件,而进程1和2尚未知晓任何事件(从进程0的视角看)。这种额外的信息使得向量时钟能够精确地推断出事件之间的“先于发生”关系。我们将在下一节课通过具体例子来详细演示向量时钟的运作和比较。

总结

本节课中我们一起学习了分布式系统事件排序的核心知识。我们首先回顾了“先于发生”关系,并将其定义为一种非自反偏序。然后,我们探讨了偏序与全序的区别。接着,我们介绍了Lamport时钟,它是一种与因果关系一致但无法完全表征因果关系的轻量级逻辑时钟。最后,我们引入了更强大的向量时钟,它通过维护一个向量来完全捕获事件间的因果关系,为后续实现分布式快照、因果一致性等高级概念奠定了基础。

004:向量时钟、FIFO、因果与全序交付 📚

在本节课中,我们将要学习向量时钟的完整概念,并探讨分布式系统中几种重要的消息交付保证:FIFO交付、因果交付和全序交付。我们将通过具体的例子和算法来理解这些概念,并了解它们在实际系统中的应用和实现方式。

向量时钟回顾与深入 🔄

上一节我们介绍了Lamport时钟,它能够捕捉因果关系的一个方向:如果事件A发生在事件B之前,那么A的Lamport时钟值小于B的。然而,反之则不成立。这意味着仅凭Lamport时钟,我们无法精确判断两个事件是否真正存在因果关系,还是仅仅是并发事件。

为了克服这个限制,我们引入了向量时钟。向量时钟提供了因果关系的双向判定:事件A发生在事件B之前,当且仅当A的向量时钟小于B的向量时钟(在向量比较的意义上)。这是一个非常强大的特性。

向量时钟算法示例

让我们通过一个具体的例子来回顾向量时钟的分配算法。假设系统中有三个进程:Alice、Bob和Carol。每个进程维护一个向量,初始化为 [0, 0, 0],分别对应Alice、Bob和Carol的本地事件计数。

以下是算法的核心步骤:

  1. 初始化:每个进程的向量时钟初始化为全零。
  2. 本地事件:每当进程发生一个事件(发送、接收或内部事件),它将自己对应的向量分量加1。
  3. 发送消息:发送消息时,进程将当前的向量时钟附加在消息上一起发送。
  4. 接收消息:当进程收到一个消息时,它首先将自己对应的分量加1(因为接收本身是一个事件),然后将其本地向量与消息携带的向量进行逐分量比较,取最大值,更新为自己的新向量时钟。

通过这个算法,我们可以为系统中的所有事件分配一个唯一的向量时钟。比较两个事件的向量时钟,我们可以精确判断它们的因果关系:

  • 如果向量V的所有分量都小于或等于向量W的对应分量,并且至少有一个分量严格小于,则V对应的事件发生在W对应的事件之前。
  • 如果两个向量无法比较(即一个在某些分量上大,另一个在另一些分量上大),则对应的事件是并发的。

向量时钟是分布式系统中用于推理因果关系的核心工具。

协议、执行与交付保证 ⚙️

在深入讨论具体的交付保证之前,我们需要理解“协议”和“执行”的概念。一个协议是一组规则,规定了进程间如何通信,特别是基于已接收的消息可以发送哪些消息。一个执行(或一次运行)则是遵循协议规则所产生的一系列事件和消息交换的具体实例,可以用时空图来表示。

交付(Delivery)是区别于接收(Receive)的概念。接收是消息到达进程的网络事件,而交付是进程决定处理该消息的应用层事件。协议可以控制何时交付已接收的消息,这为实现各种交付保证提供了可能。

FIFO(先进先出)交付 🚶‍♂️🚶‍♂️

第一种重要的交付保证是FIFO(First-In-First-Out)交付。

定义:如果一个进程先发送消息M1,后发送消息M2,那么任何交付了M1和M2的进程,都必须先交付M1,后交付M2。

违反FIFO交付的示例

Alice (发送M1) ----> Bob
Alice (发送M2) ----> Bob

如果Bob先交付M2,后交付M1,就违反了FIFO保证。

实现方式
实现FIFO交付的常见方法是使用序列号

  1. 发送方为发送给同一接收方的每条消息附加一个单调递增的序列号。
  2. 接收方维护一个预期序列号。当收到消息时,如果其序列号等于预期序列号,则立即交付并递增预期值;否则,将其缓冲,直到收到预期序列号的消息。

另一种方法是使用确认(Acknowledgements):发送方只有在收到上一条消息的确认后,才发送下一条消息。这种方法无需序列号,但会显著降低吞吐量,因为引入了往返延迟。

需要注意的是,TCP协议已经在其传输层提供了FIFO(有序)交付保证,因此在使用TCP的系统中,应用开发者通常无需自己实现此功能。

因果交付 ⛓️

FIFO交付保证的是来自同一个发送者的消息顺序。因果交付则是一个更强、更全局的保证。

定义:如果消息M1的发送发生在消息M2的发送之前(即 send(M1) -> send(M2)),那么M1的交付也必须发生在M2的交付之前(即 deliver(M1) -> deliver(M2))。

关键点:因果交付关注的是所有消息之间的因果顺序,而不仅仅是来自同一进程的消息。

违反因果交付的经典示例(“Bob有味道”问题)

  1. Alice 发送消息 “Bob有味道” 给 Bob 和 Carol。
  2. Bob 收到后,发送消息 “你才臭” 给 Alice 和 Carol。
  3. 由于网络延迟,Carol 可能先收到 Bob 的回复 “你才臭”,然后才收到 Alice 的原始指控 “Bob有味道”。
    在这种情况下,Carol 看到的消息顺序与因果关系不符(回复先于原消息被交付),这违反了因果交付,但并未违反FIFO交付(因为两条消息来自不同的发送者)。

实现提示:因果交付通常需要借助向量时钟来实现。进程在交付消息前,需要检查该消息的因果历史(通过向量时钟表示)中的所有消息是否都已交付。我们将在后续课程中详细讨论其实现。

全序交付 🗂️

最强的交付保证之一是全序交付。

定义:如果某个进程先交付消息M1,后交付消息M2,那么所有交付了M1和M2的进程,都必须以相同的顺序(先M1,后M2)进行交付。

违反全序交付的示例
假设有一个主从复制的数据库,两个客户端同时向两个副本发送更新。

  • 客户端1发送 SET x=1 给副本A和B。
  • 客户端2发送 SET x=2 给副本A和B。
    如果副本A先交付 x=1,后交付 x=2,最终 x=2;而副本B先交付 x=2,后交付 x=1,最终 x=1。两个副本状态不一致,这就是全序交付被违反的后果。

全序交付对于维持分布式系统(如分布式数据库、状态机复制)的一致性至关重要。实现全序交付是一个复杂的问题,通常需要共识算法(如Paxos、Raft)的支持,这将是本课程后续的重点内容。

总结 📝

本节课我们一起深入学习了向量时钟,并通过它理解了分布式系统中的三种核心消息交付保证:

  1. FIFO交付:保证来自同一发送者的消息按发送顺序交付。可通过序列号或确认机制实现,且通常由TCP等网络协议提供。
  2. 因果交付:保证所有因果相关的消息按因果顺序交付。它比FIFO更强,需要向量时钟等机制来实现。
  3. 全序交付:保证所有进程对消息的交付顺序达成全局一致。这是最强的一致性保证之一,是实现强一致性系统的关键,通常需要复杂的分布式共识算法。

理解这些交付保证及其层次关系,是设计和分析可靠、一致分布式系统的基础。在接下来的课程中,我们将探索如何具体实现因果交付和全序交付。

005:L5 - FIFO、因果、全序交付回顾与因果广播实现 🧩

在本节课中,我们将要学习分布式系统中的消息交付保证。我们将回顾三种重要的交付属性:FIFO交付、因果交付和全序交付。然后,我们将深入探讨如何实现一种特定的因果交付算法——因果广播。最后,我们会简要介绍因果关系的另一个重要应用:用于获取分布式系统全局一致状态的Chandy-Lamport快照算法。

交付属性回顾 📝

上一节我们介绍了分布式系统中的基本通信模型。本节中,我们来回顾一下三种核心的消息交付保证属性。理解这些属性对于设计可靠的分布式系统至关重要。

以下是三种交付属性的定义及其违规示例图:

  • FIFO交付(先进先出交付):如果进程P1向进程P2发送了消息M1和M2,且M1的发送先于M2,那么P2必须按M1、M2的顺序交付它们。
    • 违规示例:P2先交付M2,后交付M1。
  • 因果交付:如果消息M1的发送“先发生于”消息M2的发送,那么任何进程在交付M2之前,必须先交付M1。
    • 违规示例(经典Alice-Bob-Carol场景):Carol先收到了Bob对Alice消息的回复,后才收到Alice的原始消息。
  • 全序交付:如果某个进程按顺序M1、M2交付了一组消息,那么所有交付了这组消息的进程都必须以相同的顺序交付它们。
    • 违规示例:P2按M1、M2顺序交付,而P3按M2、M1顺序交付。

这些属性描述了执行(execution)的特征。一个执行可能满足其中一种或多种属性。它们之间的关系并非简单的层级嵌套。

所有可能的执行构成一个大集合。其中,满足FIFO交付的执行是它的一个子集。而满足因果交付的执行,又是FIFO交付执行的一个子集。这意味着,如果一个执行满足因果交付,它必然也满足FIFO交付。

全序交付与这两者的关系则不同。满足全序交付的执行与满足FIFO/因果交付的执行集合存在交集,但并不被它们包含。例如,可以存在一个执行,其中所有进程都以相同的错误顺序(如M2, M1)交付消息,这满足了全序交付(顺序一致),但违反了FIFO交付。

注意:“发送”是发送者的主动行为,“接收”是接收者的被动行为,而“交付”是接收者决定将已接收的消息提交给应用层处理的行为。在Lamport图中,我们通常用勾号(✓)表示消息被交付。

实现因果广播 🚀

上一节我们回顾了交付属性的概念。本节中,我们来看看如何实现一种能保证因果交付的算法——因果广播。

首先,我们需要明确几个通信模式:

  • 单播:一个发送者,一个接收者。
  • 多播:一个发送者,多个接收者。
  • 广播:一个发送者,系统中的所有进程(通常包括发送者自身)都是接收者。

因果交付是我们希望系统具备的属性。因果广播则是一种算法,它能在所有消息都是广播消息的设定下,保证因果交付。我们经典的Alice-Bob-Carol问题中的消息就是广播消息,因此因果广播算法足以解决该问题。

实现因果广播的核心工具是向量时钟。我们将使用一个稍作修改的向量时钟算法:

  1. 每个进程维护一个向量时钟 VC,初始值为全零。
  2. 当进程 P_i 发送一个广播消息时:
    • 递增自身对应的时钟分量:VC[i] = VC[i] + 1
    • 将更新后的 VC 作为元数据附加在消息中一起发送。
  3. 当进程 P_j 交付一个来自 P_i、附带向量时钟 VC_m 的消息时:
    • 更新本地向量时钟为逐分量最大值:VC = max(VC, VC_m)(注意:这里不将“接收”本身计为一个事件)。

这个算法本身并不强制因果顺序。关键在于,进程的“邮件收发员”不能一收到消息就立即交付,而需要根据一个可交付条件来判断。

可交付条件

进程 P 在收到来自进程 P_i(其ID在向量时钟中对应索引 i)、附带向量时钟 VC_m 的消息 M 时,仅当满足以下条件才可立即交付:

  1. VC_m[i] = VC[i] + 1 (消息中的发送者时钟分量恰好比接收者本地对应分量大1)
  2. 对于所有 k != i,有 VC_m[k] <= VC[k] (消息中其他所有分量都不大于接收者本地对应分量)

条件1 确保了这是接收者期待的下一个来自 P_i 的消息。
条件2 确保了发送者在发送此消息时,所知晓的其他进程的消息,接收者都已经知晓(即已交付)。

如果不满足条件,消息必须被放入一个延迟队列中暂存,等待后续检查。

算法运行示例

让我们用Alice(P1)-Bob(P2)-Carol(P3)的例子来演示,初始向量时钟均为 (0,0,0)

  1. Alice发送“丢失钥匙”消息,递增自身时钟为 (1,0,0) 并随消息发出。
  2. Bob收到并交付该消息,更新本地时钟为 (1,0,0)。然后发送“太好了!”回复,递增自身时钟为 (1,1,0) 并发出。
  3. Carol同时收到两个消息(假设Bob的消息先到)。
    • 检查Bob的消息 VC_m = (1,1,0)
      • 条件1: VC_m[2] (1) == VC[2] (0) + 1 ✔️
      • 条件2: VC_m[1] (1) <= VC[1] (0) ❌ 失败!
      • 结论:消息被放入延迟队列。
    • 检查Alice的消息 VC_m = (1,0,0)
      • 条件1: VC_m[1] (1) == VC[1] (0) + 1 ✔️
      • 条件2: VC_m[2] (0) <= VC[2] (0) ✔️, VC_m[3] (0) <= VC[3] (0) ✔️
      • 结论:消息可以交付。Carol交付该消息,并更新本地时钟为 (1,0,0)
  4. Carol交付Alice的消息后,重新检查延迟队列中Bob的消息 (1,1,0)
    • 此时Carol的 VC = (1,0,0)
    • 条件1: VC_m[2] (1) == VC[2] (0) + 1 ✔️
    • 条件2: VC_m[1] (1) <= VC[1] (1) ✔️, VC_m[3] (0) <= VC[3] (0) ✔️
    • 结论:消息现在可以交付了。

通过这个机制,Carol总是先交付Alice的消息,再交付Bob的回复,从而保证了因果交付。

关于活跃性:算法需要确保延迟队列中的消息最终都能被交付,这涉及到系统通信的可靠性等更深层的“活跃性”保证,我们将在后续课程中讨论。

因果关系的应用:Chandy-Lamport快照算法简介 📸

上一节我们详细介绍了如何利用因果关系实现因果广播。本节中,我们来看看因果关系的另一个巧妙应用——获取分布式系统的一致性全局快照。

在分布式系统中,每个进程都有本地状态。但我们常常需要了解整个系统的全局状态,用于调试、检查点、死锁检测等。由于缺乏全局精确同步的时钟,我们不能简单地让所有进程在“同一时刻”记录自己的状态。

我们期望的快照应满足一致性:如果事件B被包含在快照中,且事件A先发生于B(A -> B),那么事件A也必须被包含在快照中。这避免了快照中出现“无因之果”的混乱局面。

Chandy-Lamport算法是解决这个问题的经典算法。在介绍算法前,需要定义信道的概念:信道 C_{ij} 是从进程 P_iP_j 的一个单向、先进先出(FIFO)的消息传输通道。正在信道中传输的消息被认为是信道状态的一部分。

算法通过一个特殊的标记消息在系统中传播来协调快照的拍摄。其核心思想是:当进程第一次收到标记消息时,它记录自己的本地状态,并为其所有出站信道开始记录此后收到的消息(作为信道状态),然后将标记消息转发给所有邻居。通过标记消息的传播顺序和信道状态的记录,最终收集到的所有进程状态和信道中的消息集合,共同构成一个一致的全局快照

由于时间关系,我们将在下一次课程中深入探讨该算法的详细步骤、正确性证明以及具体示例。

总结 🎯

本节课中我们一起学习了分布式系统中消息交付的核心概念。我们回顾了FIFO、因果和全序三种交付属性的定义及其关系。我们深入探讨了如何利用修改后的向量时钟算法来实现因果广播,从而在广播通信的场景下保证因果交付。最后,我们介绍了因果关系在Chandy-Lamport全局快照算法中的重要应用,该算法能够在不依赖全局时钟的情况下,获取分布式系统的一致性状态视图,为系统监控和调试提供了基础工具。理解这些基于因果关系的算法,是构建可靠、可理解的分布式系统的关键一步。

006:分布式快照算法

在本节课中,我们将要学习分布式系统中的快照算法,特别是经典的 Chandy-Lamport 快照算法。我们将了解该算法的工作原理、其核心假设、所生成快照的性质,并探讨分布式算法中“中心化”与“去中心化”设计的区别。

概述:什么是分布式快照?

在分布式系统中,一个“快照”记录了系统在某个时刻的全局状态。由于没有全局时钟,我们无法让所有进程在同一物理时刻“暂停”并记录状态。因此,我们需要一种算法,让各个进程独立记录自己的状态,并确保最终组合起来的全局状态在逻辑上是一致的。

一个一致的快照需要满足以下性质:如果事件 B 在快照中,并且事件 A 发生在事件 B 之前(记作 A → B),那么事件 A 也必须在快照中。这是我们衡量快照是否有意义的关键标准。

Chandy-Lamport 快照算法详解

上一节我们介绍了快照一致性的概念,本节中我们来看看如何实际获取一个一致的快照。Chandy-Lamport 算法是第一个分布式快照算法,于1985年提出。它不依赖于向量时钟,其核心思想是通过发送特殊的标记消息来协调各个进程的状态记录。

算法核心步骤

算法可以由一个或多个进程发起,这些进程称为发起者。以下是算法的具体步骤:

1. 发起者进程的操作:

  • 记录自身的本地状态。
  • 向所有出站信道发送一条标记消息。
  • 开始记录所有入站信道上接收到的应用消息。

2. 当进程 Pi 在信道 Cki 上首次收到标记消息时:

  • 记录自身的本地状态。
  • 将信道 Cki 的状态标记为“空”(因为标记消息之前没有其他应用消息在传输)。
  • 向所有自身的出站信道发送标记消息。
  • 开始记录所有其他入站信道上接收到的应用消息(即除了 Cki 之外的信道)。

3. 当进程 Pi 在信道 Cki 上收到非首次标记消息时:

  • 停止记录信道 Cki 上的应用消息。此时,该信道上已记录的消息序列就构成了信道的快照状态。

算法示例与关键点

让我们通过一个简单的两进程例子来理解算法流程。假设进程 P1 是发起者。

  1. P1 发起快照:在事件 A 之后,P1 记录自身状态(包含事件 A),然后向 P2 发送标记消息(通过信道 C12),并开始记录从 P2 发来的消息(信道 C21)。
  2. P2 收到标记消息:当标记消息到达 P2 时(假设在事件 B 之后),这是 P2 首次见到标记消息。因此,P2 记录自身状态(包含事件 A 和 B),将信道 C12 标记为空,然后向 P1 回发一个标记消息(通过信道 C21),并开始记录其他入站信道(本例中没有其他信道)。
  3. P1 收到返回的标记消息:P1 收到来自 P2 的标记消息。由于 P1 已经见过标记消息(它自己发送的),因此进入“非首次”处理逻辑:停止记录信道 C21。如果在此期间有应用消息到达(例如事件 D),它会被记录为信道 C21 的状态。

最终,我们得到了进程 P1 和 P2 的状态,以及信道 C21 的状态(可能包含消息)。这个组合起来的全局状态就是一个一致的快照。

算法的关键假设

Chandy-Lamport 算法能够正确工作,依赖于以下几个重要的系统模型假设:

  • FIFO 信道:消息在信道中必须按发送顺序到达。这是防止快照不一致的关键。例如,它确保了标记消息一定会在其后发送的应用消息之前到达。
  • 可靠通信:假设消息不会丢失、损坏或重复。
  • 进程无故障:假设在算法执行期间,进程不会崩溃。

这些假设是该算法的局限性,但也是其能够在不暂停应用消息的情况下工作的前提。

算法性质与优势

上一节我们了解了算法步骤和假设,本节中我们来看看该算法具有哪些优良性质。

1. 快照的一致性

算法保证最终采集到的全局快照满足一致性定义。这是快照有意义的基本要求。

2. 可终止性

在满足上述假设(无消息丢失、无进程崩溃)的前提下,算法保证能够终止。每个进程最终都会收到所有必要的标记消息,从而完成自身状态和信道状态的记录。

3. 支持多发起者(去中心化)

这是 Chandy-Lamport 算法一个非常强大的特性。多个进程可以同时独立地发起快照,而算法依然能正常工作并产生一致的快照。这意味着进程之间不需要复杂的协调或选举来确定唯一的发起者。

去中心化算法(如 Chandy-Lamport)与中心化算法形成对比。中心化算法要求必须由唯一的一个进程来发起和控制流程,这通常需要额外的协调机制(如锁或领导者选举),增加了复杂性和潜在的性能瓶颈。Chandy-Lamport 算法的去中心化设计使其更健壮、更简单。

快照的用途

我们花费如此多精力学习快照算法,那么快照究竟有什么用呢?以下是几个主要应用场景:

  • 检查点:用于系统容错。定期保存系统快照,当系统发生故障时,可以从最近的一个一致快照恢复,而不是从头开始。
  • 死锁检测:死锁是一种“稳定属性”(即一旦为真,则始终保持为真)。通过分析一个一致的快照,可以判断系统在快照时刻是否处于死锁状态,并且由于稳定性,可以推断当前系统很可能仍处于死锁。
  • 稳定属性检测:更一般地,快照可用于检测任何稳定属性,例如“某个资源已永久不可用”或“某个计算任务已确定完成”。

总结

本节课中我们一起学习了分布式系统中的快照概念与 Chandy-Lamport 快照算法。

我们首先明确了一致快照的定义。然后,深入剖析了 Chandy-Lamport 算法的工作流程,它通过标记消息在进程间传递,协调各进程记录自身状态和信道状态。我们了解到该算法依赖于 FIFO 信道可靠通信进程无故障的假设。

该算法的优势在于能产生一致快照、保证终止,并且是去中心化的,允许多个发起者同时工作而无需额外协调。最后,我们探讨了快照在系统检查点稳定属性检测(如死锁检测)中的重要应用。

掌握 Chandy-Lamport 算法不仅是为了理解一种具体的快照技术,更是为了体会分布式系统中去中心化、协同工作的设计思想,这是构建健壮、可扩展分布式系统的基石。

007:安全性与活性、故障模型、两将军问题

在本节课中,我们将首先回顾并深入探讨Chandy-Lamport快照算法的一些遗留问题。随后,我们将正式介绍分布式系统中的两个核心概念:安全性活性。接着,我们会学习如何对系统故障进行分类,并理解故障模型的概念。最后,我们将通过经典的两将军问题来探讨在存在通信故障的系统中达成共识的固有困难。

回顾Chandy-Lamport快照算法

上一讲我们介绍了Chandy-Lamport全局快照算法。本节中,我们来解答一些关于该算法的常见疑问,以加深理解。

关于通信通道的假设

在Chandy-Lamport算法中,我们假设每个进程都与其他所有进程之间存在一个通道。这意味着任何进程都可以直接向其他进程发送消息,并且这些通道是先进先出的。

然而,在实际网络中,物理连接可能并非完全连通。算法要求的是底层的通信图是强连通的。只要网络是强连通的,即使没有直接的物理连接,进程也可以通过中间节点转发消息来“模拟”出与所有其他进程的直接通道。因此,算法依然可以工作。

快照的构成:进程状态与通道状态

Chandy-Lamport快照捕获了两部分信息:

  1. 进程状态:每个进程在收到标记消息瞬间的本地状态。
  2. 通道状态:每个进程在开始记录后,在其入向通道上收到的、在快照点之前发送的所有消息。

为什么需要记录通道状态(即“传输中”的消息)?考虑一个系统,其中只有一个令牌,用于控制对共享资源的访问。假设进程P1将令牌通过消息M发送给进程P2。如果在快照时刻,消息M仍在传输中(即已被P1发出但尚未被P2接收),那么:

  • 根据P1的快照状态,它已不持有令牌。
  • 根据P2的快照状态,它尚未收到令牌。

如果不记录通道状态,快照将显示“令牌消失了”,这违反了系统应始终保持“有且仅有一个令牌”的不变性。记录通道状态中的消息M,使我们能够推断出令牌的归属,从而保持快照的一致性。

安全性与活性

现在,让我们转向两个用于描述系统属性的核心概念:安全性与活性。

安全性属性

安全性属性可以通俗地理解为 “坏事永远不会发生” 。它规定了系统不允许出现的行为。

我们之前讨论过的许多消息传递保证都属于安全性属性:

  • FIFO交付:确保来自同一发送者的消息按发送顺序被接收。它排除了后发消息先到的“坏事”。
    • send(P1, m1); send(P1, m2) 必须导致 deliver(P2, m1) 先于 deliver(P2, m2)
  • 因果交付:确保具有因果关系的消息按因果顺序被接收。它排除了结果先于原因被传递的“坏事”。
  • 全序交付:确保所有进程以相同的顺序传递所有消息。

安全性属性的一个关键特征是:我们可以在一个有限的执行片段中指出该属性被违反的具体位置。

活性属性

活性属性则相反,它保证 “好事最终会发生” 。它规定了系统最终必须达成的行为。

一个典型的活性属性是 “最终交付”“可靠交付” 。它保证,在满足一定条件(例如,发送者和接收者均未崩溃,且并非所有消息都丢失)下,一条被发送的消息最终会被接收方传递。

活性属性的一个关键特征是:无法在一个有限的执行片段中判断该属性被违反。因为即使好事尚未发生,也不意味着它将来不会发生。

安全性与活性的关系

单独的安全性属性或活性属性都是不充分的。

  • 一个只满足FIFO交付(安全性)但从不实际传递任何消息(缺乏活性)的系统是毫无用处的。
  • 一个保证消息最终会传递(活性)但传递顺序完全随机(不满足任何顺序安全性)的系统同样可能无法正常工作。

因此,设计一个实用的系统通常需要同时指定和满足相应的安全性属性活性属性

故障分类与故障模型

在设计容错系统时,我们需要明确系统可能遭遇哪些类型的故障,这引出了故障模型的概念。

故障类型

以下是几种经典的故障分类:

  1. 崩溃故障:进程停止运行,不再发送或接收任何消息。
  2. 遗漏故障:单个消息在发送或接收过程中丢失。
  3. 定时故障:进程的响应过早或过晚(在异步网络模型中通常不考虑此类故障)。
  4. 拜占庭故障:进程行为任意,可能发送错误信息或恶意信息。

故障模型

故障模型定义了系统设计时假设可能发生的故障类型集合。它决定了算法需要容忍哪些故障。

这些故障类型之间存在包含关系:

  • 崩溃故障是遗漏故障的特例(所有涉及该进程的消息都丢失)。
  • 遗漏故障又是拜占庭故障的特例(恶意进程可以故意表现为丢失消息)。

因此,故障模型也呈嵌套关系:

  • 崩溃故障模型:只需容忍进程崩溃。
  • 遗漏故障模型:需容忍消息丢失(自然也包含了崩溃故障)。
  • 拜占庭故障模型:需容忍任意故障(包含了前两者)。

在本课程中,我们将主要关注遗漏故障模型,这是一个现实且具有足够挑战性的模型。

两将军问题

两将军问题是一个经典的思想实验,深刻揭示了在不可靠通信信道下达成协同的根本性困难

问题描述

两位将军(Alice和Bob)各自率领军队驻扎在两座山头上,山谷中驻扎着敌军。任何一方单独进攻都会失败,只有同时进攻才能取胜。他们唯一的通信方式是派信使穿过山谷,但信使可能被敌军俘获(消息丢失)。

不可能性分析

假设Alice想提议在黎明进攻:

  1. Alice发送消息“黎明进攻?”给Bob。
  2. Bob收到后,必须回复“同意”。但此时Alice不知道Bob是否收到她的提议,所以她不能贸然进攻。
  3. Alice收到Bob的“同意”后,可以进攻了吗?不行,因为Bob不知道他的“同意”是否成功送达Alice。如果没送达,Bob知道Alice不会进攻,所以他也不能进攻。
  4. 于是Alice需要发送“确认收到同意”……
  5. 这个“确认的确认”链条可以无限延伸下去。

结论:在遗漏故障模型下,无法通过有限次消息交换,让任何一位将军百分之百确信对方会同时发起进攻。

实际应对方案

虽然理论上不可能完美解决,但实践中可以采用一些折中方案:

  1. 概率性确信:Alice持续重复发送进攻提议。当Bob收到一条并回复后,如果Alice停止发送新消息,Bob在长时间收不到新消息后,可以以很高的概率确信Alice已收到回复并准备进攻。通过增加通信次数,可以无限接近(但无法达到)100%的确定性。
  2. 共同知识:如果Alice和Bob在分离之前就当面商定好了进攻时间,那么这个时间就成为双方的“共同知识”。共同知识意味着“每个人都知道P,每个人都知道每个人都知道P,……以至无限”。在分布式系统中,仅通过可能丢失的消息无法在事后建立共同知识。

两将军问题及其变体是理解许多分布式共识算法(如TCP握手、原子提交协议等)背后逻辑的基础。

总结

本节课中我们一起学习了:

  1. 澄清了Chandy-Lamport快照算法中关于通道和快照构成的细节。
  2. 正式定义了安全性属性(坏事不发生)和活性属性(好事终发生),并理解了它们相辅相成的关系。
  3. 学习了故障的四种主要类型:崩溃、遗漏、定时和拜占庭故障,并理解了故障模型的嵌套概念。
  4. 通过两将军问题,探讨了在不可靠通信条件下实现确定性的协同动作所面临的根本性限制,以及实际中的概率性解决方案。

这些概念为我们后续深入学习容错算法和共识协议奠定了重要的理论基础。

008:容错形式、可靠交付与可靠广播 🎯

在本节课中,我们将要学习分布式系统中的容错形式、可靠交付的实现方法、幂等性概念以及可靠广播协议。我们将从回顾故障分类开始,逐步深入探讨如何在异步网络模型中实现可靠的消息传递。

故障分类回顾 🔍

上一节我们介绍了故障的分类方法,本节中我们来看看这些分类的更多细节。故障可以分为以下几类:

  • 崩溃故障:进程停止运行。
  • 遗漏故障:进程未能发送或接收消息。崩溃故障是遗漏故障的一个子集。
  • 拜占庭故障:进程可能以任意方式出错,包括发送错误或恶意消息。遗漏故障是拜占庭故障的一个子集。

需要指出的是,这个分类并非绝对。例如,某些拜占庭故障可以被“降级”处理。

  • 可检测的拜占庭故障:如果消息被篡改,但我们可以通过校验和或错误纠正码等技术检测到,那么我们可以简单地丢弃这条消息,将其视为遗漏故障。这比无法检测的篡改要容易处理得多。

因此,在设计系统时,明确声明系统能够容忍哪一类故障(即选择故障模型)至关重要。在本课程中,我们将主要关注遗漏故障模型,因为它比崩溃模型更贴近现实。

容错的形式 🛡️

仅仅说一个系统“容忍某种故障”是不够的,我们还需要明确在故障发生时,系统的行为会发生何种变化。这涉及到系统的安全性活性属性。

  • 安全性:坏事永远不会发生。
  • 活性:好事最终会发生。

一个正确的程序需要同时满足其安全性和活性属性。在发生故障时,程序的行为可能有以下几种形式:

  • 屏蔽容错:在故障发生时,程序继续保持安全性和活性。这是最理想的情况。
  • 故障安全容错:在故障发生时,程序保持安全性,但可能丧失活性。例如,一个消息队列为了保证消息顺序(安全性),在出现网络分区时可能停止投递消息(丧失活性)。
  • 非屏蔽容错:在故障发生时,程序保持活性,但可能丧失安全性。例如,系统继续投递消息,但允许消息暂时失序。
  • 完全失效:在故障发生时,程序既丧失安全性,也丧失活性。

在实际系统中,故障安全容错是最常见的选择,即优先保证安全性。

实现可靠交付 ⚙️

上一节我们定义了可靠交付,本节中我们来看看如何实现它。可靠交付(在遗漏模型中)的定义是:如果进程P1向进程P2发送消息M,且P1和P2均未崩溃,并且并非所有消息都丢失,那么P2最终将投递M。

以下是实现可靠交付的一个基本思路:

  1. 发送方将消息放入发送缓冲区。
  2. 设置一个超时计时器。
  3. 每当超时触发,就重新发送缓冲区中的消息。
  4. 当发送方收到接收方对该消息的确认后,才将消息从缓冲区中删除。

这个方法确保了消息至少被传递一次,因此也被称为至少一次交付。但它带来了一个新问题:接收方可能收到重复的消息。

幂等性概念 🔄

重复的消息是否构成问题,取决于消息本身的性质。

  • 幂等操作:执行多次的效果与执行一次相同。例如,set x = 3 这个操作就是幂等的,无论执行多少次,x的最终值都是3。
  • 非幂等操作:执行多次的效果与执行一次不同。例如,increment x 这个操作就不是幂等的,执行两次会使x增加2。

在分布式系统中,幂等性至关重要。因为通过重传来实现可靠交付(至少一次交付)是常见做法,所以设计幂等的消息或操作可以简化对重复消息的处理。

那么,我们能实现恰好一次交付吗?严格来说,在异步分布式系统中,这是不可能的。当系统声称提供“恰好一次”语义时,通常意味着以下两种情况之一:

  1. 消息本身就是幂等的,因此重复投递没有影响。
  2. 系统在接收端进行了去重处理,尝试过滤掉重复的消息。

实现可靠广播 📢

广播是指一个进程发送消息,所有进程都接收该消息。可靠广播的定义是:如果一个正确的进程投递了广播消息M,那么所有正确的进程最终都会投递M。这里的“正确进程”含义取决于故障模型(例如,在崩溃模型中指未崩溃的进程)。

假设我们已有基本的单播(点对点)通信原语,如何在其上构建可靠广播呢?一个简单的想法是发送者向每个进程单独发送单播消息。但这无法处理发送者在发送过程中崩溃的情况。

以下是实现可靠广播(在崩溃模型中)的一个经典算法:

  1. 当进程收到一个广播消息M时(无论是从原始发送者还是其他进程),它首先将M转发给所有其他进程(除了它收到消息的那个来源进程)。
  2. 完成转发后,它才将消息M投递给自己的上层应用。
  3. 每个进程需要记录已经投递过的消息ID,对于已投递的消息,不再重复投递或转发。

这个算法的核心思想是:通过让接收者帮助转发,来确保即使原始发送者崩溃,消息也能传播到所有正确的进程。这个过程自然也会产生重复消息,因此需要上述的去重机制。

核心思想总结 💡

从可靠交付和可靠广播的实现中,我们可以提炼出一个分布式系统容错的核心模式:容错通常需要通过制造副本来实现

  • 为了容忍消息丢失(可靠交付),我们通过重传制造消息的多个副本。
  • 为了容忍进程崩溃导致广播中断(可靠广播),我们通过接收者转发来制造消息的多个副本。
  • 为了容忍进程崩溃导致数据丢失,我们需要将数据复制到多个进程上,即数据复制。这是复制技术最重要的目的,我们将在接下来的课程中详细讨论。

本节课总结 📚

本节课中我们一起学习了:

  1. 故障分类的细节与可检测拜占庭故障的概念。
  2. 容错的不同形式:屏蔽容错、故障安全容错等,并理解在故障中优先保持安全性是常见选择。
  3. 如何通过“重传直到确认”的机制实现可靠交付(至少一次交付)。
  4. 幂等性概念及其对处理重复消息的重要性,并理解了“恰好一次交付”在实践中的真实含义。
  5. 如何通过“接收者转发”的算法在单播原语上实现可靠广播。
  6. 认识到“制造副本”是实现容错(应对消息丢失、进程崩溃、数据丢失)的一个根本且强大的技术。

010:确定性、一致性模型与共识简介 🧩

在本节课中,我们将学习分布式系统中的几个核心概念:确定性、一致性模型,并初步了解共识问题。我们将探讨为什么在分布式环境中保证一致性是困难的,以及不同的一致性模型如何提供不同级别的保证。


考试回顾 📝

上一节我们讨论了因果广播和快照算法。本节中,我们来看看考试中关于这些主题的两个典型问题。

因果广播问题

以下是考试中关于因果广播的一个场景:

P1 广播消息 M1 给 P2 和 P3。
P2 发送点对点消息 M2 给 P3。
P2 发送点对点消息 M3 给 P3。

问题在于,如果尝试使用课堂上讨论的因果广播算法来确保因果传递,会遇到什么困难?

核心难点:因果广播算法要求所有消息都是广播消息。但在该场景中,M2 和 M3 是点对点消息,并非广播。因此,算法无法正常工作。

具体来说:

  1. M1 是广播消息,其向量时钟为 [1,0,0],可以被 P2 和 P3 正常传递。
  2. M2 是点对点消息,其向量时钟为 [2,0,0],可以被 P2 传递。
  3. M3 也是点对点消息,在 P2 传递 M1 和 M2 后发送,其向量时钟为 [2,1,0]
  4. 当 M3 到达 P3 时,P3 的本地向量时钟为 [1,0,0](仅收到 M1)。M3 的向量时钟 [2,1,0] 在 P2 的位置上比 P3 的时钟大 1,但在 P1 的位置上(值为2)大于 P3 的时钟(值为1),不满足传递条件。
  5. 因此,M3 会在 P3 被阻塞。更关键的是,P3 在等待永远不会到达的广播消息 M2(因为 M2 是点对点消息),导致永久阻塞

结论:因果广播算法依赖于所有消息都是广播的假设。当存在点对点消息时,算法无法保证因果传递,可能导致进程无限等待。

Chandy-Lamport 快照问题

以下是考试中关于一致快照的一个场景:

P1 发起快照,并发送标记消息。
P3 收到标记后,记录自身状态并转发标记。
P2 从 P3 收到应用消息 F,然后从 P1 收到标记消息。

问题在于,这个快照有什么问题?为什么在实际的 Chandy-Lamport 算法中,这种情况不会发生?

问题分析

  1. 快照中包含了事件 D(在 P2 上),但未包含因果上先于 D 的事件 F(从 P3 发送到 P2)。这违反了一致快照的定义:如果某个事件在快照中,那么所有因果上先于它的事件也必须在快照中。
  2. 因此,该快照是不一致的。

为何不会发生
Chandy-Lamport 算法假设信道是 FIFO(先进先出) 的。在该场景中:

  • P3 在记录快照后,会立即向 P2 发送标记消息。
  • 根据 FIFO 顺序,从 P3 发往 P2 的标记消息,必须先于后续的应用消息(例如 F)被 P2 接收。
  • 因此,P2 会在收到消息 F 之前收到来自 P3 的标记消息,从而在 F 到达之前记录快照。这样,F 就不会被包含在快照中,从而保证了快照的一致性。

结论:FIFO 信道假设是 Chandy-Lamport 算法能够捕获一致全局快照的关键。


全序传递与确定性 🔄

上一节我们回顾了因果顺序。本节中,我们来区分两个重要但不同的概念:全序传递和确定性。

考虑一个简单的键值存储,有两个客户端和两个副本:

  • 客户端 C1 写入 x=3
  • 客户端 C2 写入 x=4

如果两个副本以不同顺序接收这些写操作,就会违反全序传递:副本1认为 x=4,副本2认为 x=3

现在,考虑只有一个“副本” R 的情况:

  • 两个客户端的写操作 x=3x=4 可能以任意顺序到达 R。
  • 在单次运行中,R 会为接收到的消息建立一个全序(例如先 x=3x=4,最终状态为 x=4)。
  • 在另一次运行中,顺序可能相反(先 x=4x=3,最终状态为 x=3)。

关键区别

  • 全序传递:在单次系统运行中,所有进程传递消息的顺序一致。单副本 R 在单次运行中自然满足全序
  • 确定性:要求跨多次系统运行,给定相同的输入,系统产生相同的输出或最终状态。单副本 R 无法保证确定性,因为消息到达顺序可能不同。

公式化表达

  • 全序传递:对于任何两个消息 m1m2,如果某个进程传递了 m1 然后 m2,那么所有传递这两个消息的进程都必须以相同的顺序传递它们。
  • 确定性:对于相同的初始状态和输入序列,系统的所有可能执行都会达到相同的最终状态。

过渡:理解了一致性的一个目标(全序)及其与确定性的区别后,我们接下来看看分布式存储系统中几种常见的一致性模型,它们定义了客户端可以观察到哪些行为。


一致性模型 📊

一致性模型规定了分布式系统向客户端提供的保证。不同的模型在“正确性”和“性能”之间做出不同权衡。以下是几种常见模型,其强度依次递增。

读写一致性 (Read-Your-Writes Consistency, RYW)

定义:进程自己完成的写操作,其后的读操作必须能观察到该写操作的结果。

违反示例

客户端 C 向副本 R1 写入 x=5 并收到确认。
随后,C 向副本 R2 读取 x,但 R2 尚未收到更新,返回旧值(如 x=4)。

客户端读取到了自己未看到的写操作,违反了 RYW。

先进先出一致性 (FIFO Consistency / PRAM)

定义:单个进程发出的写操作,必须被所有其他进程以该进程发出它们的相同顺序观察到。

违反示例

客户端 C1 向副本 R1 发出:存款 $50 (W1),然后取款 $40 (W2)。
R1 处理顺序为 W1 -> W2,账户余额正确($10)。
R1 将更新传播给 R2,但顺序颠倒为 W2 -> W1。
此时若客户端 C2 从 R2 读取余额,会看到账户曾透支(-$40),违反了 C1 的操作顺序。

因果一致性 (Causal Consistency)

定义:在因果上相关的写操作(即通过 happened-before 关系关联),必须被所有进程以相同的因果顺序观察到。无关的写操作可以以不同顺序被观察。

违反示例

客户端 C1 存款 $100 到 R1 (W1)。
客户端 C2 从 R1 读取到余额 $100 (R1)。
客户端 C2 基于此向 R2 发起取款 $50 (W2),但被拒绝(资金不足)。

问题在于,W2 因果依赖于 W1(因为 C2 的读 R1 看到了 W1 的结果)。但 R2 在处理 W2 时,可能尚未看到 W1,因此无法理解 W2 的上下文,导致拒绝。这违反了因果顺序。

强一致性 (Strong Consistency / Linearizability)

定义任何写操作完成后,所有后续的读操作(无论来自哪个客户端,访问哪个副本)都必须返回该写操作的值或更晚的值。其非正式定义是:客户端无法察觉数据被复制了,感觉像是在与一个单一、最新的数据副本交互。

关系总结
这些一致性模型构成了一个强度递增的层次结构:
所有可能执行满足RYW的执行满足FIFO的执行满足因果的执行满足强一致的执行

过渡:既然强一致性提供了最强的保证,它是如何实现的呢?我们之前讨论过主备复制和链式复制这两种强一致协议。但它们都面临一个共同的核心挑战。


强一致复制与故障处理 ⚙️

上一节我们介绍了一致性模型。本节中,我们来看看实现强一致性的具体协议及其故障处理。

主备复制和链式复制都需要一个协调者 进程。协调者的职责包括:

  1. 维护所有副本的配置信息(例如,链式复制中谁是头节点、尾节点)。
  2. 检测副本故障。
  3. 在故障发生时,计算新的配置并通知所有客户端和其他副本。

这引出了一个关键假设:故障-停止模型。在此模型中:

  1. 进程可能发生崩溃故障。
  2. 其他进程可以检测到这种崩溃。

公式化表达
故障-停止模型 = 崩溃故障 + 故障可检测性。

故障检测的实践:通常通过心跳机制实现。进程定期发送“存活”消息。若协调者在超时时间内未收到心跳,则判定该进程故障。在异步网络(无延迟上限)中,完美的故障检测是不可能的,可能产生误判(进程活着却被判定死亡)。

链式复制中的故障处理

  • 头节点故障:协调者将后继节点提升为新的头节点,并通知客户端。
  • 尾节点故障:协调者将前驱节点设为新的尾节点,并通知客户端。
  • 中间节点故障:协调者将其前驱和后继节点直接连接,客户端无需感知。

核心挑战如果协调者自身故障了怎么办?
单点协调者是整个系统的脆弱点。一个自然的想法是使用多个协调者。但这立刻带来了新的问题:如何保证这些协调者之间的状态一致?这恰恰将我们引向了分布式系统中最核心、最困难的问题之一——共识

过渡:协调者的高可用需求,揭示了强一致性乃至许多分布式协调任务都依赖于一个更底层的问题的解决。接下来,我们将初步探讨这个基石问题。


共识问题简介 🤝

当我们需要多个进程就某件事达成一致时,就遇到了共识问题。它是许多分布式问题的抽象核心。

需要共识的典型场景

  1. 全序广播:所有进程以相同顺序传递消息。
  2. 组成员管理:所有进程就当前存活的成员列表达成一致。
  3. 领导者选举:所有进程就谁是领导者达成一致。
  4. 分布式互斥:所有进程就谁可以访问临界资源达成一致。
  5. 分布式事务提交:所有参与进程就提交或中止事务达成一致。

共识问题的抽象定义
假设有 N 个进程,每个进程都有一个初始输入值(例如 0 或 1)。共识协议的目标是,让所有未崩溃的进程最终就一个共同的输出值达成一致,并且该输出值必须是某个进程的初始输入值。

代码描述共识接口

# 每个进程调用 propose(value) 提出自己的值
def propose(self, initial_value):
    # 共识协议运行在这里...
    # 最终返回一个达成一致的值
    return agreed_value
# 协议结束后,所有存活进程的 agreed_value 都相同。

为什么共识困难
因为系统存在:

  1. 进程故障:进程可能在任何时刻崩溃。
  2. 异步性:消息延迟无上限,无法可靠区分“进程慢”和“进程已挂”。

FLP不可能定理(将在后续详述):在一个异步分布式系统中,即使只有一个进程可能发生崩溃故障,也不存在一个总是能在有限时间内达成共识的确定性协议。这是分布式系统理论中最著名的结论之一。

过渡:尽管有 FLP 定理,实践中我们仍需寻求解决方案。下一次课,我们将深入探讨一个经典的共识算法——Paxos,看看它是如何在实际中解决这一难题的。


总结 🎯

本节课中我们一起学习了:

  1. 全序传递与确定性:全序保证单次运行内的顺序一致;确定性要求跨次运行结果相同,是更强的属性。
  2. 一致性模型:包括读写一致性、先进先出一致性、因果一致性和强一致性。它们构成了强度递增的层次,在数据正确性和系统性能/可用性之间进行权衡。
  3. 强一致复制协议的故障处理:主备复制和链式复制依赖协调者,而协调者的高可用问题引出了共识需求。
  4. 共识问题简介:共识是多进程就某个值达成一致的基础问题,是解决许多分布式协调任务的关键。它在异步和可能故障的环境中非常困难,并受 FLP 不可能定理约束。

理解这些概念是构建和评估分布式系统的基石。下一讲,我们将开始深入探索共识算法的具体实现。

011:共识算法进阶、FLP不可能定理与Paxos协议

在本节课中,我们将深入学习分布式系统中的共识问题。我们将首先正式定义共识,然后探讨著名的FLP不可能定理,最后详细讲解经典的Paxos共识算法。我们将从基础概念开始,逐步深入到算法的核心机制。

共识问题定义

上一节我们提到了共识问题,本节我们将对其进行正式定义。共识可以被看作一个“黑盒”,多个进程向其中输入值,目标是让所有进程最终就一个输出值达成一致。

例如,三个进程可能分别输入0或1。共识的目标是让所有进程最终输出相同的值,要么全是0,要么全是1。

你可能会想,这似乎不难,比如直接选择多数派的值即可。但关键在于,没有外部观察者来判定多数派。这些进程必须通过彼此间的通信,自行协商并最终达成一致。

我们将要学习的Paxos算法,就是解决此问题最著名的算法之一。但在深入Paxos之前,我们需要明确共识算法试图满足的属性。

以下是共识算法试图满足的三个核心属性:

  • 终止性:每个正确的进程最终都会决定一个值。
  • 一致性:所有正确的进程决定的值是相同的。
  • 有效性:被一致决定的值必须是某个进程所提议的值。

有效性规则排除了总是输出固定值(如总是输出1)这种取巧方案。这三个属性共同定义了理想的共识行为。

然而,在异步网络模型(即网络延迟无上限)且允许进程崩溃故障的环境中,没有任何共识算法能同时满足这三个属性。这就是著名的FLP不可能定理,由 Fischer、Lynch 和 Patterson 在1985年证明。

既然无法同时满足,就必须做出妥协。Paxos 算法选择妥协的是终止性。这意味着 Paxos 能保证一致性和有效性,但无法保证算法一定会在有限时间内结束。

Paxos 算法基础

Paxos 是由 Leslie Lamport 提出的共识算法家族。我们将讨论其最基础的版本。在 Paxos 中,进程可以扮演三种角色:

  • 提议者:负责提议值。
  • 接受者:负责参与对提议值的“选择”。
  • 学习者:负责学习被一致决定的值。

一个物理进程可以扮演多个甚至全部角色。在算法运行前,需要明确两个前提:

  1. Paxos 节点必须能够持久化存储数据。
  2. 所有节点必须知道“接受者”的总数,从而能判断何为“多数派”。

接下来,我们通过一个简单场景来了解 Paxos 的基本流程。假设有1个提议者(P1)、3个接受者(A1, A2, A3)和2个学习者。多数派为2。

阶段一:准备

提议者 P1 希望提议一个值。它首先选择一个全局唯一且比它自己之前用过的编号都大的提案编号 N(例如5)。然后,它向至少一个多数派的接受者发送 Prepare(N) 消息。

当接受者收到 Prepare(N) 消息时,它进行检查:

  • 如果它之前已经承诺(Promise)会忽略编号小于或等于 N 的请求,则忽略此消息。
  • 否则,它现在承诺会忽略所有编号小于 N 的请求。同时,它回复给提议者一条 Promise(N) 消息。此外,如果该接受者之前已经接受(Accept)过某个提案,它需要在其 Promise 回复中附上这个已接受提案的编号和值

当提议者 P1 收到来自多数派接受者Promise(N) 回复时,阶段一结束。此时,由于多数派已承诺忽略编号小于 N 的请求,可以确保不会有编号小于 N 的提案再被多数派接受。

阶段二:接受

在阶段二,提议者 P1 在收到多数派的 Promise 回复后,需要向至少一个多数派的接受者发送 Accept(N, Value) 消息,请求它们接受该提案。

其中,提案编号 N 就是阶段一使用的编号。而值 Value 的选取规则如下:

  • 如果提议者在收到的 Promise 回复中,看到了附带的已被接受的提案信息,则它必须选择其中提案编号最大的那个提案所对应的值,作为本次要提议的 Value。
  • 如果没有附带任何已被接受的提案信息,则提议者可以自由选择它想提议的值。

当接受者收到 Accept(N, Value) 消息时,它进行检查:

  • 如果它之前已经承诺(Promise)会忽略编号小于或等于 N 的请求,则忽略此消息。
  • 否则,它接受这个提案。它回复一条 Accepted(N, Value) 消息给提议者,并同时广播该消息给所有学习者

多数派接受者都发送了 Accepted(N, Value) 消息时,共识实际上已经达成,该 Value 被选定。这是“不可回头”的时刻。
随后,当任意提议者或学习者收到来自多数派接受者的、针对同一提案编号 N 和值 Value 的 Accepted 消息时,它便知晓共识已经达成。

处理多提议者竞争

上述流程是“简单模式”。现在,我们引入第二个提议者 P2,看看 Paxos 如何通过之前提到的“附带信息”机制保证安全性。

假设 P1 已成功运行阶段一,使用提案编号5获得了多数派承诺。随后,P2 尝试使用提案编号4发起 Prepare 请求,但会被已承诺5的接受者忽略,导致 P2 无法获得多数派回复而超时。

P2 随后可以选择一个更高的提案编号(例如6)重新发起 Prepare。当接受者收到 Prepare(6) 时,由于6大于之前的承诺编号5,它们会回复 Promise(6)关键点来了:对于已经接受了 P1 提案(Accept(5, foo))的接受者,它们会在 Promise(6) 回复中附带信息 (5, foo),表明自己已接受过编号为5、值为 foo 的提案。

当 P2 收到多数派的 Promise(6) 回复,并看到附带信息 (5, foo) 时,根据阶段二的规则,它必须选择值 foo 作为它接下来 Accept 请求中的 Value。因此,P2 会发送 Accept(6, foo)

这样,即使出现新的、编号更高的提案,它也会被“强制”去提议已经被多数派接受过的值,从而保证了一致性,即不会出现两个不同的值被最终选定。

总结与下节预告

本节课我们一起学习了共识问题的正式定义及其三个核心属性(终止性、一致性、有效性),并了解了在异步崩溃模型中 FLP 不可能定理带来的限制。接着,我们深入探讨了 Paxos 算法的基础流程,包括提议者、接受者、学习者三种角色,以及“准备-接受”两个阶段。我们还分析了多提议者场景下,Paxos 如何通过 Promise 消息附带历史信息来确保安全性(一致性)。

Paxos 通过妥协终止性来保证一致性和有效性。那么,什么情况会导致 Paxos 无法终止呢?例如,如果两个或多个提议者持续地、交替地提出更高编号的 Prepare 请求,就可能导致活锁,每个提议者都在不断尝试但永远无法完成阶段二。我们将在下一节课中探讨这个问题,并介绍如何通过“领导者选举”等优化策略来促进 Paxos 在实际系统中的终止。

012:Paxos与共识总结,主被动复制

在本节课中,我们将继续讨论Paxos共识算法,并对其进行总结。我们将探讨“提案者竞争”问题,介绍Multi-Paxos优化,并简要讨论Paxos的容错性及其他共识协议。最后,我们将介绍主被动复制的概念及其应用。


继续讨论Paxos:提案者竞争

上一节我们介绍了Paxos算法的基本流程。本节中,我们来看看当存在多个提案者时可能出现的问题。

考虑一个场景:有三个接受者(A1, A2, A3)和两个提案者(P1, P2)。

  • 提案者P1首先发送编号为5的Prepare请求给A1和A2,并收到了Promise回复。
  • 同时,提案者P2发送编号为6的Prepare请求给A2和A3,A2和A3也回复了Promise。
  • 随后,P1进入第二阶段,发送编号为5的Accept请求给接受者们。A1会接受,但A2因为已经承诺了编号6,会忽略编号更低的请求。因此,P1无法获得多数接受者的接受,最终超时。
  • 同样,P2发送编号为6的Accept请求时,A2可能已经承诺了P1发出的更高编号(例如7)的Prepare请求,导致P2的请求也被忽略。

这种情况被称为“提案者竞争”。两个提案者都成功完成了第一阶段,但在第二阶段却因为对方的干扰而无法达成共识,导致算法可能永远无法终止。

一个自然的想法是:为什么不让系统只有一个提案者呢?这样不就可以避免竞争了吗?原因在于:

  1. 容错性:唯一的提案者如果崩溃,整个系统将无法提出新值。
  2. 领导者选举本身是共识问题:要选举出唯一的提案者(领导者),这本身就需要一个共识算法来解决,而共识算法(如Paxos)本身就可能无法终止。因此,将问题简化为“只有一个提案者”并没有从根本上解决非终止性问题。

Paxos选择了保证安全性(一致性和有效性),而牺牲了活性(终止性)。这是异步分布式系统中共识算法的固有局限(FLP不可能定理)。


Multi-Paxos:优化序列值共识

我们之前讨论的Paxos用于就单个值达成共识。但在许多实际场景中(如全序广播),我们需要就一个值序列达成共识。如果对序列中的每个值都运行一次完整的Paxos(两阶段),消息开销会非常大。

以下是Multi-Paxos的优化思路:

  1. 对于第一个值,仍然需要运行完整的Paxos(第一阶段Prepare + 第二阶段Accept)。
  2. 一旦某个提案者成功完成了一次完整的两阶段,并得到了多数接受者的支持,它就可以在后续的共识中跳过第一阶段,直接为后续的值重复执行第二阶段(Accept)。
  3. 只要这个提案者没有崩溃,且没有其他提案者用更高的编号“打断”它,它就可以持续高效地确定序列中的后续值。
  4. 如果出现了新的提案者并成功完成了更高编号的Prepare阶段,那么原提案者的“领导地位”将被取代,系统会回退到完整的Paxos流程。

这种优化被称为Multi-Paxos。它本质上是一种“领导权”的隐含形式,旨在大多数时间内只有一个活跃的提案者,从而减少消息延迟。批处理(将多个值打包在一个Accept请求中)是另一种常见的优化手段。


Paxos的容错性

Paxos被设计为能够容忍进程崩溃故障。其容错能力取决于接受者集群的规模。

  • 接受者容错:Paxos要求提案者必须与多数派的接受者进行通信。对于一个由N个接受者组成的集群,它可以容忍 F < N/2 个接受者同时崩溃。通常表述为需要 2F + 1 个节点来容忍F个故障。
    • 例如:3个节点可容忍1个故障,5个节点可容忍2个故障。
  • 提案者容错:只需要有一个提案者存活即可提出新值。因此,要容忍F个提案者故障,需要至少 F + 1 个提案者。
  • 消息丢失(遗漏故障):Paxos对偶尔的消息丢失具有鲁棒性。超时重传机制可以处理此类问题。在最坏情况下,消息丢失可能导致活锁(非终止),但这并不违反Paxos的安全保证(一致性)。在容错分类中,Paxos在遗漏故障下属于安全但不活跃的协议。

其他共识协议简介

除了Paxos,还有其他重要的共识协议,它们通常针对序列值共识(即状态机复制)进行了优化,并显式集成了领导者选举机制。

以下是几个著名的协议:

  1. Raft:由Diego Ongaro和John Ousterhout于2014年提出。其核心设计目标是易于理解和实现。Raft明确地将共识过程分解为领导者选举、日志复制和安全性等模块,逻辑清晰,已成为学习和实践的热门选择。
  2. Viewstamped Replication (VSR):由Brian Oki和Barbara Liskov于1988年提出。它是许多现代共识协议(包括Raft)的思想先驱,同样采用了视图(View)和领导者的概念。
  3. Zab (ZooKeeper Atomic Broadcast):是Apache ZooKeeper协调服务使用的共识协议,专注于实现原子广播(全序广播)。ZooKeeper常用于集群的元数据管理与协调。

这些协议与Paxos(特别是Multi-Paxos)在本质上解决的是相同的问题,但它们在具体设计、术语和工程实现上各有特点。2014年的论文《There Is More Consensus in Egalitarian Parliaments》对Paxos、VSR和Zab进行了比较分析。


主动复制 vs. 被动复制

这是实现复制服务(如我们作业中的键值存储)的两种策略,与“主从备份”或“链式复制”等具体架构是正交的。

假设一个客户端向复制组发起一个写操作(例如 deposit(50)):

主动复制(状态机复制)

  • 流程:主副本接收请求后,将操作本身(如 deposit(50))广播给所有副本。每个副本独立执行该操作,更新自身状态,然后回复确认。主副本收到多数确认后,再回复客户端。
  • 优点:当状态更新很大时(例如更新一个巨大的数据集),广播操作比广播整个新状态更节省带宽。
  • 缺点:如果操作执行成本很高,或者操作结果依赖于本地不确定状态(如本地随机数),则所有副本执行可能产生不一致结果。

被动复制(主从更新)

  • 流程:主副本接收请求后,自己执行该操作,计算出新的状态。然后它将完整的新状态(或状态差异)广播给其他副本。其他副本直接安装新状态,然后回复确认。主副本收到多数确认后,再回复客户端。
  • 优点:操作只执行一次,适用于计算成本高或具有非确定性的操作。
  • 缺点:当状态很大时,传输整个状态的网络开销很大。

选择依据

  • 使用主动复制,当操作确定且状态大。
  • 使用被动复制,当操作昂贵或非确定。

状态机复制 是指一组副本以相同的顺序执行相同的操作序列,从而经过相同的状态序列。无论是通过共识协议(如Raft、Multi-Paxos)确定操作顺序,还是通过主从架构广播操作,只要保证所有副本执行顺序一致,都属于状态机复制的范畴。


关于作业3的补充说明

在作业3中,我们需要实现因果一致性。规范中定义了两个条件,其中第一个条件实质上是 “读己之写” 一致性。

“读己之写”违规示例

  1. 客户端C向副本R1发送写操作 set(“x”, 3),并收到成功确认。
  2. 随后,客户端C向副本R2发送读操作 get(“x”)
  3. 如果R2返回一个旧值(例如2),则违反了“读己之写”。

如何保证
客户端必须在请求中携带并传递因果元数据

  1. 当R1处理完 set(“x”, 3) 后,在回复给客户端的确认中,需要包含反映此次写入的因果元数据。
  2. 客户端发起 get(“x”) 请求时,必须将之前收到的因果元数据一并发送给R2。
  3. R2看到这个因果元数据后,便知道自己可能尚未看到 x=3 这个写入。它不能立即返回本地旧值,而必须等待从R1或其他副本同步到足够新的数据(满足因果依赖)后,才能返回正确的值 3

因此,实现因果一致性的关键在于:服务器通过回复传递因果元数据,客户端通过后续请求传递因果元数据,从而在整个系统中跟踪和尊重操作的因果顺序。


总结

本节课中我们一起学习了:

  1. Paxos中的提案者竞争问题:多个提案者可能导致活锁,解释了为何Paxos无法保证终止性。
  2. Multi-Paxos优化:为了高效达成序列值共识,可以在首次完整运行后,由领导者仅执行第二阶段。
  3. Paxos的容错能力:可容忍少数派接受者崩溃,对消息丢失具有鲁棒性。
  4. 其他共识协议:了解了Raft、VSR、Zab等协议,它们通常为序列共识和领导者选举而设计。
  5. 主被动复制:区分了广播操作(主动)和广播状态(被动)两种复制策略及其适用场景。
  6. 作业3要点:重温了因果一致性与“读己之写”的关系,强调了因果元数据在客户端与服务器间传递的重要性。

013:最终一致性、可用性与冲突解决

在本节课中,我们将学习分布式系统中的最终一致性概念,探讨它与强一致性的区别,并了解在网络分区发生时,系统如何在一致性与可用性之间做出权衡。我们还将讨论应用特定的冲突解决机制,并介绍著名的CAP理论。

课程概述与安排

在深入今天的主题之前,我们先简要说明一下本季度剩余时间的课程安排。

以下是近期需要关注的事项:

  • 编程作业三:已发布数周,将于明天之后的一周截止。请确保你的小组已取得实质性进展。
  • 阅读作业:请在下周二之前阅读亚马逊Dynamo论文。这是本课程的首次阅读作业,论文链接已在课程网站上更新。建议预留数小时来阅读这篇学术论文,无需理解每个细节,但需把握其核心思想。
  • 课程中期调查:已重新开放,填写可获得少量课程学分,请尚未完成的同学尽快完成。
  • 客座演讲25日(周二),我们将有幸邀请到来自Twitch和亚马逊网络服务的前员工Cyrus Hall,为我们讲解实用的分布式系统设计。

现在,让我们进入今天的核心内容。

从强一致性到最终一致性

上一节我们介绍了如何通过主备复制或链式复制来实现副本间的强一致性。强一致性最终依赖于共识算法。然而,共识算法代价高昂,需要大量消息传递,甚至可能无法终止。

考虑一个典型的全序异常示例:两个客户端并发地向不同副本写入,导致副本状态最终不一致。即使使用向量时钟,我们也能判断这两个写入事件是并发的,无法确定顺序。这表明,问题不在于违反因果一致性,而在于违反了全序交付。

因此,我们常常希望找到一种方法,使得更新的到达顺序不再重要,从而避免使用共识算法。在许多场景下,更新的顺序确实无关紧要。

最终一致性与强收敛

如果两个客户端更新的是不同的键(例如 XY),那么无论更新以何种顺序到达副本,副本的最终状态(即键值对的集合)都是相同的。但这并不是强一致性,因为在此期间,客户端可能读取到不一致的状态。

我们称这种属性为最终一致性。其非正式定义是:如果客户端停止提交更新,那么所有副本最终将达成一致。这是一个活性属性,因为它无法在有限执行中被违反,只能尚未被满足。

最终一致性与我们之前讨论的安全性属性(如读己之写、FIFO、因果、强一致性)不属于同一范畴。然而,我们可以用一个安全性属性来区分“最终一致”和“最终不一致”的情况,即强收敛

强收敛的定义是:所有已交付相同集合更新的副本,必须具有等价的状态。这里的关键词是“集合”,而非“顺序”。强收敛是一个安全性属性

强最终一致性是最终一致性(活性)和强收敛(安全)的结合。它是一个混合了安全性和活性的属性。

应用特定的冲突解决

当多个客户端并发更新同一个键时,我们如何实现强收敛?副本可以保存所有并发更新的值。例如,两个副本可能分别保存了 X: [3, 4] 这样的集合。这实现了强收敛,但将冲突解决的负担转移给了客户端。

客户端读取时,会获得多个值,需要自行决定如何解决冲突。解决方式取决于具体的应用逻辑。

以亚马逊购物车为例:假设你的笔记本电脑添加了一本书,手机同时添加了一个搅拌机。两个更新并发到达不同的副本。当某个服务(客户端)查询购物车内容时,可能会得到两个购物车集合:{书}{搅拌机}。此时,合理的冲突解决方式是取这两个集合的并集,得到一个包含 {书, 搅拌机} 的购物车。亚马逊Dynamo论文中实际讨论了这种合并策略。

网络分区、可用性与CAP理论

网络分区是指网络中的部分节点无法与其他节点通信。我们可以用遗漏故障模型来描述它:跨越分区的消息被视为丢失。这与数据分片(有意将数据分布到不同机器)不同,网络分区是一种需要应对的故障。

可用性通常被定义为“每个请求都能收到响应”,这是一个活性属性。在实践中,我们还要求响应在合理的时间内完成。

考虑一个主备复制系统,当主节点与备份节点之间发生网络分区时,主节点面临两难选择:

  1. 等待分区恢复后再响应客户端:这保证了强一致性,但牺牲了可用性(客户端可能长时间等待)。
  2. 立即响应客户端,稍后异步复制:这保证了可用性,但牺牲了一致性(数据可能丢失或备份不一致)。

更一般地,假设有两个副本 R1R2 之间发生分区,但一个客户端能同时访问两者。客户端向 R1 写入后,立即从 R2 读取。R2 要么返回旧值(违反一致性),要么等待与 R1 通信(违反可用性)。这是一个根本性的权衡。

这个著名的权衡就是 CAP理论(一致性、可用性、分区容错性)。其核心是:在存在网络分区的情况下,无法同时实现完美的一致性和完美的可用性,必须有所取舍。CAP常被误解为“三选二”,但实际上,分区容错性是必须接受的现实,我们只能在一致性和可用性之间优先保障其中一项。不同的系统会根据其需求调整这个优先级。

测试与混沌工程

在完成作业三时,测试分布式系统是一大挑战。例如,要测试“读己之写”属性,需要模拟消息延迟的场景。在本地测试中,消息传递通常很快,难以触发边界情况。

混沌工程是一种通过故意向系统中注入故障(如延迟、丢弃消息、使机器崩溃)来测试其容错性的方法。目的是暴露系统中潜在的、在正常条件下难以发现的缺陷。Netflix是推广这一实践的知名公司。

由于容错系统会刻意掩盖故障,使得其中的缺陷更难被发现。混沌工程通过主动制造“混乱”,帮助我们验证系统在真实故障下的行为是否符合预期。对于想深入此领域的同学,可以了解Peter Alvaro关于溯源驱动故障注入的研究,该工作旨在更智能地选择故障注入点,以更快地发现缺陷。

课程总结

本节课我们一起学习了分布式系统中的几个核心概念:

  • 最终一致性:一个活性属性,确保在更新停止后,副本最终会一致。
  • 强收敛:一个安全性属性,要求交付相同更新集合的副本状态等价。
  • 强最终一致性:最终一致性与强收敛的结合。
  • 应用特定冲突解决:当并发更新发生时,由应用逻辑决定如何合并数据,例如购物车的并集操作。
  • 网络分区与可用性:分区是不可避免的故障,需要在一致性和可用性之间做出权衡。
  • CAP理论:阐述了在网络分区存在时,一致性与可用性不可兼得的根本性权衡。
  • 测试挑战与混沌工程:介绍了通过故意注入故障来测试和提升分布式系统鲁棒性的方法。

这些概念将为你阅读和理解亚马逊Dynamo论文奠定重要的基础。Dynamo系统正是优先考虑可用性和最终一致性,并采用向量时钟和应用级冲突解决机制的典型代表。我们将在后续课程中继续探讨论文中的其他细节。

014:Dynamo - Merkle树、法定一致性、尾部延迟

在本节课中,我们将深入探讨亚马逊Dynamo论文中的核心概念。我们将回顾一些已学过的思想,并介绍几个新的重要主题,包括反熵、Merkle树、流言协议、法定一致性和尾部延迟。

旧概念回顾

上一讲我们讨论了可用性、网络分区和最终一致性等概念。本节中,我们来看看这些概念在Dynamo系统设计中的具体体现。

可用性与网络分区

可用性意味着每个请求都会收到响应。在Dynamo的设计中,高可用性是首要目标,即使在网络分区的情况下,系统也优先响应客户端请求,而非等待所有副本达成一致。

网络分区是指网络中的一部分机器暂时无法与另一部分机器通信。Dynamo的设计明确考虑了对网络分区的容忍。

最终一致性

最终一致性是一种活性属性,它指出如果更新停止到达,那么所有副本最终将达成一致。Dynamo为了获得高可用性和分区容忍性,选择放松对强一致性的要求,采用了最终一致性模型。

需要注意的是,Dynamo并未提供强最终一致性(一种包含安全性属性的模型)。最终一致性这一术语实际上由Doug Terry等人在90年代中期的Bayou系统中提出。

应用层冲突解决

当副本状态不一致时,Dynamo允许应用提供自定义的冲突解决逻辑。例如,购物车服务可以采用集合并集作为解决策略。

以下是Dynamo处理冲突的两种方式:

  • 应用特定机制:例如,购物车合并。
  • 最后写入获胜(LWW):如果未提供应用特定机制,则默认采用此策略。

值得注意的是,论文中提到购物车实现存在一个缺陷:已删除的商品可能会重新出现在购物车中。这揭示了在采用合并等冲突解决策略的复制系统中,删除操作比添加操作更为复杂。

新概念探讨

在回顾了旧概念后,本节我们将聚焦于Dynamo论文中引入的几个新机制。

反熵与流言协议

在最终一致性系统中,副本可能处于不一致状态。Dynamo需要机制来发现并修复这些不一致。它使用两种相似的机制来处理不同类型的状态冲突。

反熵用于解决应用状态(即存储的键值对数据)的冲突。由于应用状态可能非常庞大,直接传输全部数据进行比对成本高昂。

流言协议用于解决视图状态(即集群中各节点对“哪些节点存活”的认知)的冲突。视图状态通常很小,只是一个节点列表。

虽然“反熵”和“流言”在广义上是同义词,但在Dynamo论文的语境中,它们分别指代上述两种不同的同步过程。

Merkle树(哈希树)

为了高效地比对庞大的应用状态并最小化网络传输开销,Dynamo采用了Merkle树。

Merkle树是一种数据结构,其中每个叶子节点是数据块(如键值对)的哈希值,每个非叶子节点是其子节点哈希值的哈希。最终,一个根哈希值代表了整个数据集的状态摘要。

当两个副本需要同步时,它们首先比较Merkle树的根哈希。如果根哈希相同,则说明数据一致。如果不同,则沿着树向下比较子节点的哈希,从而快速定位到具体差异的数据块,只需传输少量哈希值而非全部数据。

根哈希 = Hash( Hash(数据块A) + Hash(数据块B) )

这种方法极大地减少了在反熵过程中需要传输的数据量。

法定一致性

在像主备复制这样的策略中,客户端只与特定的主节点通信。而在Dynamo中,客户端可以联系任何或所有副本,没有节点扮演特殊角色。这引入了法定数量系统的概念。

在法定数量系统中,可以配置三个参数:

  • N:副本总数。
  • W:写入法定数。一次写入操作必须收到至少W个副本的确认才算成功。
  • R:读取法定数。一次读取操作必须收到至少R个副本的响应才算完成。

Dynamo论文建议的常见配置是设置 W + R > N(例如,N=3, W=2, R=2)。这保证了每个读取操作所联系的副本集合,一定会与每次写入操作所联系的副本集合存在交集。因此,读取总能获取到至少一份最新的写入数据,避免了陈旧的读取,尽管客户端可能仍需处理来自不同副本的冲突值。

选择合适的W和R值是一种权衡:较低的W使写入更快但耐久性风险更高;较低的R使读取更快但可能读到旧数据。

尾部延迟

延迟是指从发起请求到收到响应所需的时间。在评估系统性能时,平均延迟可能具有误导性。

考虑一个系统,大多数请求在1毫秒内完成,但极少数的请求需要100毫秒。其平均延迟可能仍然很低(例如3毫秒),但这掩盖了那些极慢请求对用户体验的严重影响。

尾部延迟关注的是延迟分布的高分位点,例如第99.9百分位延迟(每1000个请求中第二慢的那个)。Dynamo论文特别强调了对尾部延迟的优化,因为改善长尾请求的响应时间,对于提供稳定、可预测的用户体验至关重要,即使平均延迟变化不大。

总结

本节课我们一起学习了亚马逊Dynamo论文中的关键设计思想。我们回顾了可用性、网络分区和最终一致性如何塑造其架构,并深入探讨了用于状态同步的反熵机制与高效的Merkle树、用于管理成员信息的流言协议、提供灵活一致性权衡的法定数量系统,以及衡量系统响应稳定性的重要指标——尾部延迟。这些概念共同构成了一个为高可用性而设计、容忍网络分区、并最终保持一致的分布式存储系统的核心。

015:分片与一致性哈希 🧩

在本节课中,我们将要学习分布式系统中的两个核心概念:分片(Sharding)与一致性哈希(Consistent Hashing)。我们将探讨为何需要分片,以及一致性哈希如何优雅地解决数据分片中的关键挑战。


概述:为何需要分片?

在之前的课程中,我们深入讨论了数据复制(Replication)。复制意味着在多个节点上存储相同数据的副本。在目前讨论过的系统中,每个副本都存储全部数据,这被称为完全复制

然而,完全复制存在一些问题:

  • 存储成本高:每个节点都需要存储完整的数据集。
  • 一致性维护开销大:任何节点的数据更新都需要同步到所有其他副本。
  • 单点性能瓶颈:如果采用主备复制,所有写请求都必须由主节点处理,限制了系统的吞吐量。

为了解决这些问题,我们引入了分片(也称为数据分区)。分片是指将数据集分割成多个不相交的子集,并将这些子集分布到不同的节点上。这样,每个节点只负责存储整个数据集的一部分。


分片与复制的关系

分片和复制是两个正交的概念,可以组合使用。

  • 仅分片:数据被分割并存储在不同节点上,但没有副本。这提高了存储容量和吞吐量,但失去了容错能力
  • 分片与复制结合:这是生产系统的常见模式。每个数据分片(Shard)都有多个副本(Replica),既实现了水平扩展(容量与吞吐量),又保证了高可用性。

本节课,我们将暂时搁置复制问题,专注于如何将数据分割并分配到不同分片这一核心机制。


如何分配数据到分片?

假设我们有一个简单的键值存储,如何决定哪个键值对存储在哪个节点上?一个好的分片策略应满足:

  1. 负载均衡:数据应尽可能均匀地分布在各个节点上,避免“热点”。
  2. 易于定位:给定一个键,客户端或系统应能快速确定其存储位置。

让我们评估几种策略:

策略一:随机分配

  • 优点:如果分配足够随机,数据分布会很均匀。
  • 缺点:客户端无法预测键的存储位置,每次查找都需要查询所有节点,效率极低。

策略二:按主键范围分区

例如,按字母顺序将键分配到不同节点(A-H到节点1,I-R到节点2,等等)。

  • 优点:易于定位,知道键的范围就知道对应的节点。
  • 缺点:数据分布可能极不均匀。如果大部分键都以“S”开头,那么负责S-Z的节点就会过载。

策略三:哈希取模分区

这是对策略二的改进。我们使用一个哈希函数(如MD5)将键映射到一个固定范围的数字,然后对这个数字取模(hash(key) mod N,其中N是节点数),结果决定存储节点。

  • 优点
    • 哈希函数能将非均匀分布的键转换为均匀分布的哈希值,从而实现良好的负载均衡
    • 易于定位:客户端可以自行计算哈希和取模,直接定位目标节点。
  • 缺点(致命缺陷)
    • 当节点数量N发生变化时(例如增加或减少节点),绝大多数键的映射结果都会改变(因为mod N变了)。这会导致大规模的数据迁移,系统在扩容或缩容时开销巨大。

我们期望在节点数变化时,只进行最小必要的数据移动(理想情况下,移动 K/N 个键,其中K是总键数)。哈希取模法无法做到这一点。


一致性哈希:优雅的解决方案

一致性哈希正是为了解决上述“节点数变化导致大量数据迁移”的问题而设计的。它得名于其“一致性”属性:当哈希表大小(节点数)改变时,平均只需要重新映射 K/N 个键。

核心思想:哈希环

想象一个圆环,其范围对应哈希函数的输出空间(例如,对于MD5是0到2^128-1)。我们将所有节点通过哈希其标识符(如IP地址)也映射到这个环上。

数据定位规则

  1. 计算键的哈希值,对应环上的一个点。
  2. 从该点出发,顺时针找到第一个节点。这个节点就是该键的“归属节点”。

示例

  • 节点哈希到环上的位置:Node1(8), Node2(20), Node3(32), Node4(47)。
  • 键“Apple”哈希值为14。从14顺时针找到的第一个节点是Node2(20)。因此,“Apple”存储在Node2。

节点增减时的数据移动

增加节点
假设在位置60加入新节点Node5。现在,原本属于Node1(位置8)的一部分数据(哈希值在47到60之间的键)将改由Node5负责。只有这一部分数据需要从Node1迁移到Node5,其他节点不受影响。

移除节点
假设Node2(20)崩溃。原本属于Node2的数据(哈希值在8到20之间的键)现在由其顺时针的下一个节点Node3(32)接管。同样,只有这部分数据的所有权发生变更

一致性哈希确保了节点数的变化只影响环上相邻节点之间的数据,实现了最小化的数据迁移。


虚拟节点:解决分布不均问题

理论上,一致性哈希依赖于节点在环上均匀分布。但实际上,节点数量较少时,哈希其标识符可能无法实现完美均匀分布,导致某些节点负载过重。

虚拟节点是解决此问题的技巧。一个物理节点不再对应环上的一个点,而是对应多个虚拟节点(每个虚拟节点有其唯一标识并哈希到环上)。数据定位规则不变,只是现在找到的是虚拟节点,再映射回其所属的物理节点。

虚拟节点的好处

  1. 改善负载均衡:大量虚拟节点更可能均匀分布在环上,使得数据分布更均匀。
  2. 支持异构硬件:可以为存储容量更大的物理节点分配更多的虚拟节点,使其承担更多数据,从而更精细地匹配硬件能力。
  3. 故障时负载分散:当一个物理节点故障时,其负责的多个虚拟节点区间会由环上多个不同的后继节点接管,避免了负载集中压垮单个邻居节点。

虚拟节点的注意事项
在设置副本(如Dynamo中的偏好列表)时,需要确保不将副本放在属于同一物理节点的不同虚拟节点上,否则会失去容错意义。


总结

本节课我们一起学习了分布式数据管理的两个关键技术:

  1. 分片:通过将数据集水平分割到多个节点,解决了单机存储容量和性能瓶颈的问题。它与复制技术结合,构建了可扩展且高可用的分布式存储系统的基础。
  2. 一致性哈希:一种智能的分片数据分配算法。它通过引入哈希环的概念,使得在节点数量发生变化时,能够实现最小化的数据迁移(仅需移动约 K/N 个数据项)。虚拟节点的引入进一步优化了负载均衡,并适应了异构的硬件环境。

一致性哈希是许多现代分布式系统(如Amazon Dynamo、Cassandra、Riak等)的核心组件,它优雅地平衡了数据分布的均匀性、定位效率以及系统弹性扩缩容的能力。

016:P16 - 来自Cyrus Hall的客座讲座 - 异构分布式系统

概述

在本节课中,我们将学习异构分布式系统。我们将首先定义什么是异构分布式系统,然后探讨这类系统是如何随着时间演进而发展的,最后介绍构建和管理这类系统的一些核心原则与经验法则。


第一部分:什么是异构分布式系统?

上一节我们介绍了课程背景,本节中我们来看看异构分布式系统的定义。

经典分布式系统

一个分布式系统是多个进程为了某个共同目标而协同工作。它们之所以是“分布式”的,是因为这些进程不共享内存空间,可能运行在不同的机器上。这类系统通常依赖于某种时间和顺序概念(例如向量时钟),并做出接受决策(例如,是否可以接受这条消息?这个决策是否达成共识?)。

当服务语义聚焦且处于受控环境中时,这类系统通常运行良好。然而,它们难以扩展到广域网。

更极端的分布式系统

这导致了更极端的分布式系统,例如对等网络和传感器网络。它们仍然是多个进程为共同目标工作(例如,管理互联网路由表的边界网关协议)。这些系统通常是最终一致的,并且决策通常以贪婪算法的方式做出,旨在在系统停止变化时达成最终的全局一致性。

一阶系统

我们将上述讨论的系统称为一阶系统。它们具有单一目的,没有共享内存空间,并且所有进程运行相同的算法。这里的“相同算法”指的是作为算法定义一部分的不同角色(例如,Paxos中的领导者)。

以下是一阶系统的例子:

  • 共识与复制:Paxos、Raft、Google文件系统、S3、DynamoDB。
  • 网络:BGP、RIP等路由协议,以及Tor等覆盖网络。
  • 可靠性协议:TCP(可被视为一种分布式系统)。
  • 对等网络:分布式哈希表、Gossip协议、BitTorrent。

异构分布式系统

一个异构分布式系统由多个一阶系统(或非分布式组件)组合而成。其行为不仅由各个一阶组件的行为决定,也由它们组合的方式决定。组件组合主要由人工完成,而组件间的交互往往会导致复杂的故障模式。处理异构分布式系统的核心挑战之一就是应对这些复杂的故障模式。

一个简单的网络规模示例

考虑一个典型的初创公司网站的第一天:一个HTTP API(可能运行多个进程)连接到一个数据存储(如Postgres)。HTTP API本身(多个进程运行相同代码)就是一个分布式系统,数据存储可能是另一个。它们共同构成了一个简单的异构系统。

然而,即使在这个简单系统中,复杂性也已存在:

  • API可能承担多种功能(渲染首页、管理页面等)。
  • 数据存储的一致性并不保证客户端体验的一致性(API、客户端缓存可能干扰)。
  • 数据存储容量有限,不同的API调用以不同方式使用该容量。
  • 客户端是不可控的代码,可能提供错误输入。

第二部分:这类系统如何发展?

上一节我们定义了异构分布式系统,本节中我们来看看它们是如何在实践中演进的。

单体架构的兴起

回到我们简单的初创公司网站:一个HTTP API(单体)连接到一个数据存储。这就是所谓的单体架构,即所有API调用都塞进一个单一系统,通常连接到一个单一数据存储。

但如果我们审视这个HTTP API内部,它实际上包含多个具有不同属性的组件:

  • 前端API:在线、低延迟要求。
  • 离线API(如每日聚合任务):可接受高延迟、可能消耗大量内存、对后端负载重。
  • 管理API:通常是上述两者的混合。

第一次演进:服务拆分

我们可以递归地应用之前的思路,将HTTP API拆分成多个更能反映不同使用模式的服务:前端API、管理API和离线API,它们都通过负载均衡器与数据存储通信。

负载均衡器的作用是:

  • 为客户端提供一个统一的访问入口。
  • 进行故障检测(例如,通过ping机制),将故障实例移出服务池。

然而,这个设计存在明显问题:

  • 后端问题:数据存储是所有API的单点故障。可变的请求模式(如离线任务锁定表)和突发的流量高峰(如病毒式事件)可能拖垮整个系统。资源管理是分布式的,各API需要了解彼此的请求模式以避免相互影响,这又引入了强耦合。
  • 前端问题:负载均衡器本身是单点故障,同样面临负载问题。此外,缓存(负载均衡器可能缓存)会带来隐私和安全问题,因为用户数据可能被缓存在各处。

核心关注点是可用性(系统响应时间的百分比)和弹性(系统在故障中保持可用的能力)。弹性取决于故障隔离、冗余与易扩展性以及负载管理。

需要注意的是,高可用性指标(如HTTP 200响应)并不保证返回数据的正确性。有时,完全失败(硬故障)比返回错误数据的部分失败(软故障)更好。

第二次演进:更进一步的解耦

一个更进一步的迭代设计是:分离API、分离负载均衡器、复制数据存储,并让离线API使用副本。这看起来更好,体现了递归服务模式:随着重要性的增长和新功能的添加,我们会自然地将单个功能和产品拆分出来。

需要区分系统服务

  • 系统:实现单一技术功能的分布式系统(一组进程)。
  • 服务:实现产品功能的系统(分布式或非分布式)集合。例如,聊天服务由实现代理、API、数据库等的系统组成。

现实的演进

然而,实际的第二次迭代很少像理论设计那样整洁。在业务高速增长期,工程师每天可能都在处理运营和停机事件,但往往没有时间进行根本性修复,因为产品变更和新功能的需求迫在眉睫。这导致:

  • 大多数服务仍采用自己独特的弹性和故障策略。
  • 工程师流动会产生知识缺口。
  • 新接手的维护者可能不理解原有的机制。

一个更现实的第二次迭代可能看起来像这样:

  • 离线API曾拖垮前端,因此被移出负载均衡器,脚本直接调用它。
  • 数据存储变慢,有人引入了回写缓存(如Memcached或Redis)。
  • 为了进一步缓解,在API内部又添加了内存缓存。

这引入了新的问题:

  • 缓存一致性:内存缓存、回写缓存和数据存储之间的数据可能不一致。
  • 不同前端API进程的内存缓存互不同步。
  • 离线脚本绕过负载均衡器,使得负载均衡器无法对其流量进行调控,故障检测的责任也转移到了脚本本身。

Twitch的案例

当Cyrus在2012年加入Twitch时,视频系统基本上是一个在单一数据中心运行的、包含5个核心系统的单体架构(起源于2009年)。到2020年,它已演变为包含超过120个服务的微服务架构,每个服务又由多个系统(如负载均衡器、API、数据存储)组成,总计约300个系统,运行在多个区域。原有的单体直到2022年4月才最终下线,存活了12年。

采用云环境(如AWS)有助于标准化,因为云服务内置了协同工作的组件和特定的设计理念。


第三部分:理念与经验法则

上一节我们看到了系统演进的复杂性,本节中我们来看看如何应对这种复杂性。核心理念是:接受失败、拥抱错误、尽可能保持无状态、并进行隔离。

以下是构建和管理异构分布式系统的一些高级原则:

  1. 单一职责:每个系统或服务应只关注一个核心问题。这有助于降低复杂性、依赖关系和故障影响范围。
  2. 松耦合:系统间应尽可能松散耦合。这与单一职责原则紧密相关。
  3. 易于维护:这不仅指代码可维护性,还包括系统语义易于测试。能够验证系统行为是否与之前一致至关重要。
  4. 独立且可重复部署
    • 独立:可以仅部署对目标系统的更改,而无需同时部署其依赖项。这降低了部署复杂性和故障风险。
    • 可重复:部署相同代码应产生完全相同的结果。不一致的部署结果会难以判断系统状态。
  5. 清晰的文档:记录系统和服务的行为与语义,以便在人员更替时知识得以保留。

关于松耦合和重试的深入探讨

让我们聚焦于松耦合,并讨论一个重要的经验法则:重试是危险的,尽量避免。

在异构系统的上下文中,当请求失败时,我们通常不清楚失败的具体原因(是负载均衡器问题、网络问题还是后端过载?)。盲目重试可能会:

  • 给已经过载的组件增加更多负载,可能使其完全崩溃。
  • 如果操作是非幂等的(例如,Increment 命令),可能导致数据不一致。

示例:Memcached的Increment操作

  1. 初始值 x = 0
  2. 请求 Increment x by 1
  3. 操作成功(x 变为 1),但返回时出现错误。
  4. 客户端重试 Increment x by 1
  5. 操作再次成功(x 变为 2),但可能再次返回错误。
  6. 结果:x 的实际值(2)与预期值(1)不一致。

经验法则:

  • 尽量使请求无状态/幂等:对于上述例子,应使用 Get 获取当前值,在本地计算新值,然后用 Set 存储。这虽然成本更高,但保证了语义正确性。
  • 如需事务语义,使用成熟的服务:不要尝试在自己的服务中构建事务。
  • 尽早传播错误:收到错误后,不要立即重试,而是将错误返回给原始请求者。让他们决定是否以及如何重试。这有助于减轻系统压力。
  • 结合主动负载控制:在进程实例中维护一个出站请求队列。如果队列已满,说明依赖的服务可能正忙,此时应直接拒绝新请求并返回错误,而不是尝试处理并增加后端压力。
  • 如果必须重试,使用退避策略:但要注意,重试可能导致请求状态在系统中快速累积,有内存耗尽的风险。

补充:速率限制

每个服务都有其可持续的峰值负载。基于此,可以设置一个速率限制器(通常在负载均衡器层面),只允许一定比例(如95%-98%)的峰值负载进入生产系统。这可以在不涉及业务逻辑的情况下,平滑流量高峰,防止服务因过载而性能非线性下降直至崩溃。速率限制与主动负载控制相辅相成,分别保护自身服务和依赖服务的健康。


总结

本节课中我们一起学习了异构分布式系统。我们了解到,复杂的业务流程和生命周期自然会导致复杂的异构系统。阻碍业务发展并非正确的权衡。相反,我们需要运用隔离、松耦合、单一职责、谨慎处理重试、实施速率限制等工具和技术,以在故障发生时(故障必然会发生)降低风险和影响。工程不仅是编写代码的技术过程,也是一个需要与产品、业务方持续沟通和平衡的社会过程。

017:MapReduce

概述

在本节课中,我们将要学习MapReduce,这是一个用于大规模数据处理的经典离线(批处理)系统框架。我们将了解其核心概念、工作原理,并通过具体例子来理解它如何将复杂的分布式计算问题简化为编写两个简单的函数。

在线系统 vs. 离线系统

到目前为止,我们讨论的系统大多属于在线系统(或服务)。这类系统等待客户端请求,并快速响应。例如,键值存储、Web服务器、数据库和缓存。它们的核心目标是低延迟高可用性

然而,并非所有系统都遵循这种模式。MapReduce属于另一类系统:离线系统(或批处理系统)。这类系统接收大量输入数据,进行处理,然后产生输出数据。这个过程可能需要几分钟、几小时甚至几天。它们的核心目标是高吞吐量,即尽可能快地处理大量数据。

值得注意的是,还存在介于两者之间的混合系统,称为流式系统。它们能处理持续到达的数据流,并希望快速响应数据变化。本节课我们主要聚焦于经典的离线系统MapReduce。

原始数据与衍生数据

为了理解为什么需要MapReduce,我们需要先了解数据处理中的两种数据表示:原始数据衍生数据

  • 原始数据是权威的、初始的数据形式。新数据到来时,首先进入原始数据存储。
  • 衍生数据是通过对现有数据(原始数据或其他衍生数据)进行处理后得到的结果。

MapReduce本质上是一个用于计算衍生数据的工具。

一个例子:从正排索引到倒排索引

让我们通过一个具体例子来理解这个概念。假设我们正在构建一个搜索引擎。

  1. 正排索引:网络爬虫收集网页后,可能会生成一个数据结构,列出每个文档及其包含的单词。这被称为正排索引

    文档1 -> [“the”, “quick”, “brown”, “fox”, ...]
    文档2 -> [“the”, “dog”, ...]
    ...
    

    这种格式便于快速插入新数据,但对于“查找包含单词‘dog’的所有文档”这类查询效率很低。

  2. 倒排索引:为了高效支持按单词查询,我们需要构建倒排索引。它是正排索引的“反转”。

    “the” -> [文档1, 文档2, ...]
    “dog” -> [文档2, ...]
    ...
    

    倒排索引和正排索引包含的信息完全相同,只是组织形式更适合特定类型的查询。倒排索引就是一种衍生数据

算法思路与挑战

将正排索引转换为倒排索引的算法在概念上并不复杂:

  1. 映射阶段:遍历所有文档,对于每个文档中的每个单词,生成一个(单词, 文档ID)的键值对。

    # 伪代码示例:Map函数
    def map(document_id, words):
        for word in words:
            emit(word, document_id)
    

    这个阶段是高度并行的,因为每个文档的处理独立于其他文档。

  2. 规约阶段:收集所有具有相同单词的键值对,然后将这些文档ID合并成一个列表。

    # 伪代码示例:Reduce函数
    def reduce(word, list_of_document_ids):
        emit(word, sorted(list_of_document_ids))
    

挑战在于规模。当数据量巨大,无法存放在单台机器上时,这个简单的算法就变成了一个分布式系统问题。我们需要考虑故障容错、数据分区、任务调度等复杂问题。MapReduce的核心理念就是:提供一个框架,让程序员只需编写上述简单的mapreduce函数,而由框架自动处理所有底层的分布式系统复杂性

MapReduce 工作原理详解

上一节我们介绍了MapReduce的核心思想,本节中我们来看看一个完整的MapReduce作业是如何执行的。我们将继续使用构建倒排索引的例子。

一个MapReduce作业通常涉及三类节点:一个主节点(Master)多个Map工作节点(Mapper)多个Reduce工作节点(Reducer)

以下是执行步骤:

1. Map阶段

  • 输入数据(正排索引)被分割成多个分片,存储在一个分布式文件系统(如GFS或HDFS)中。
  • 主节点将每个数据分片分配给一个空闲的Map工作节点。
  • 每个Map工作节点读取分配给它的数据分片,对其中的每个文档执行用户定义的Map函数
  • Map函数输出一系列中间键值对(例如(“the”, 文档1))。这些中间结果被临时写入该Map工作节点的本地磁盘,而不是分布式文件系统。这是一个设计上的权衡:写入本地更快,但若该节点故障,数据会丢失,需要重新计算。

2. Shuffle阶段(洗牌)

  • 这是连接Map阶段和Reduce阶段的桥梁,主要工作是数据移动
  • 框架需要确保所有具有相同键(例如单词“the”)的中间键值对最终被送到同一个Reduce工作节点进行处理。
  • 这是通过对键进行哈希运算,然后对Reduce工作节点数量取模来实现的:hash(key) mod R(R是Reducer数量)。这样就能保证相同键的数据去往同一个Reducer。
  • 每个Reduce工作节点需要从所有Map工作节点的本地磁盘上拉取(读取)属于自己的那部分中间数据。这个过程会产生大量的网络通信。

3. Reduce阶段

  • 每个Reduce工作节点从网络上拉取到属于自己的中间数据后,会根据键进行排序和分组,使得相同键的所有值被组织在一起。
  • 然后,该节点对每一组键值对执行用户定义的Reduce函数。在我们的例子中,Reduce函数就是将同一个单词对应的所有文档ID合并成一个列表。
  • Reduce函数的输出(最终的倒排索引)被写入分布式文件系统(如GFS),通常会被复制多份以实现容错。

4. 主节点的角色

  • 主节点负责整个作业的协调:调度任务(将Map和Reduce任务分配给工作节点)、监控工作节点的状态(通过定期心跳)、处理节点故障(如果某个工作节点故障,将其任务重新分配给其他节点)。

另一个例子:词频统计

让我们看一个更简单的例子——词频统计,以巩固理解。目标是统计大量文档中每个单词出现的次数。

  • Map函数:输入文档内容,输出(单词, 1)这样的键值对,表示该单词出现了一次。
    def map(document_id, document_text):
        for word in document_text.split():
            emit(word, 1)
    
  • Combine函数(可选优化):这是一个在Map节点本地执行的“迷你Reduce”操作。它可以在数据发送到网络前,先在本地对相同单词的计数进行部分汇总(例如,将(“the”, 1)(“the”, 1)合并为(“the”, 2)),从而减少网络传输量。
  • Reduce函数:接收同一个单词的所有部分计数值(例如[1, 2, 1, ...]),将它们相加,得到该单词的总出现次数。
    def reduce(word, list_of_counts):
        total_count = sum(list_of_counts)
        emit(word, total_count)
    

关键设计与权衡

  • 故障处理:MapReduce选择将中间数据存储在Map工作节点的本地磁盘,而非可靠的分布式文件系统。这意味着如果某个Map节点故障,其产生的中间数据会丢失,任务需要重新执行。这是一个倾向于用重新计算来换取更快写入速度的权衡。
  • 静态配置:与Dynamo等需要动态增删节点的在线系统不同,一个MapReduce作业的Worker数量(特别是Reducer数量)通常在作业开始时确定。这是因为作业的输入数据集是已知且固定的,不需要在运行中弹性伸缩。因此,使用简单的hash(key) mod R进行数据分区是可行的。
  • 编程模型限制:MapReduce模型非常适用于像倒排索引、词频统计、排序、分布式Grep这类“先映射后规约”模式的问题。但对于更复杂的、需要多轮迭代或复杂依赖关系的计算,表达起来可能比较困难,这也是后续更高级的批处理和流式处理框架试图解决的问题。

总结

本节课我们一起学习了Google的MapReduce框架。我们首先区分了在线系统和离线系统,理解了MapReduce作为批处理系统的定位。然后,我们通过“从正排索引构建倒排索引”和“词频统计”两个例子,深入剖析了MapReduce的Map、Shuffle、Reduce三个阶段的核心工作流程。我们了解到,MapReduce的强大之处在于它将分布式计算的复杂性(如数据分区、任务调度、故障容错)封装在框架内部,程序员只需关注描述计算逻辑本身的mapreduce两个函数。尽管MapReduce已有多年历史,且被更先进的系统所补充,但其核心思想仍然是理解大规模分布式数据处理的基石。

018:P18-L17 - MapReduce总结与副本冲突解决的数学原理 🧮

在本节课中,我们将总结MapReduce框架,并深入探讨副本冲突解决背后的数学原理。课程最后,我们将了解如何利用这些原理来设计无需共识协议即可解决冲突的分布式系统。


课程剩余安排 📅

上一节我们介绍了课程的整体进度,本节中我们来看看最后一周的具体安排。

以下是本学期剩余的计划:

  • 今天是第17讲,我们将讨论MapReduce,并开始讲解副本冲突解决的数学原理。
  • 如果今天能完成所有内容,那么周四的课程将全部用于解答期末考试相关问题,并进行一场“问我任何事”的问答环节。
  • 周五,第四次编程作业截止。
  • 下周四是期末考试。

关于期末考试,你至少有3小时答题时间。题目数量不会比期中考试多太多,总分为120分,相对而言你有更多时间来完成。

关于第四次编程作业的说明 📝

第四次编程作业的一部分是进行同伴评估。

以下是关于同伴评估的详细信息:

  • 请查看并填写同伴评估表,以评估你的队友在项目中的贡献。
  • 评估表链接可在作业描述和Zulip的公告中找到。
  • 填写评估表不会花费太长时间,其中包含一些多项选择题。
  • 请注意,我们要求你评估的是队友在这个特定项目中的贡献,而不是评估他们本人。
  • 评估内容包括:他们的行为是否合作且乐于助人、是否完成了分内工作、是否积极参与团队活动、是否倾听并支持其他成员、以及工作是否准确及时。
  • 为了激励你完成评估,我们将在你为每一位队友提交评估后,才会公布第四次作业的成绩。

关于课程反馈 📢

在讨论评估的同时,请务必填写课程评价。

关于课程评价,我想说的是:

  • 可操作的反馈很有价值。我非常希望听到关于这门课程哪些方面有效、哪些方面无效的反馈。
  • 如果你有改进建议,我们很乐意听取。请提供具体的、可操作的反馈。
  • 例如,与其简单地说“我不喜欢这些作业”,不如具体说明你不喜欢什么,以及你希望如何改进。
  • 请将反馈集中在我们可以控制的事情上,例如课程内容、作业设计等,而不是我们无法控制的事情,如上课时间。

MapReduce 阶段回顾 🔄

现在,让我们进入正题,总结并回顾上次课关于MapReduce的讨论。

首先,回顾一下MapReduce作业的三个阶段:Map阶段Shuffle阶段Reduce阶段

Map阶段

Map阶段是程序员提供的Map函数在所有数据上运行的阶段。

Map函数的类型是什么?即,它的输入和输出类型是什么?概念上,Map函数接收一个输入键值对,并输出一组中间键值对。

例如,在上次课创建倒排索引的例子中,输入可能是 (文档ID, "文档内容"),输出则是一组形如 (单词, 文档ID) 的中间键值对。

如果我们要进行词频统计,Map函数会输出每个单词及其计数(通常为1),例如 (单词, 1)

Shuffle阶段

Map阶段之后是Shuffle阶段。

Shuffle阶段发生了什么?数据根据键进行哈希分区,并被发送到相应的Reduce工作节点进行处理。

具体来说,决定中间键值对发送到哪个Reduce工作节点的分区函数通常是:hash(key) mod R,其中 R 是Reduce工作节点的数量。

需要重申的是,在MapReduce的设定中,Reduce工作节点的数量 R 在作业期间通常是固定的,因此这种基于哈希的分区方式是可行的。

Reduce阶段

Shuffle阶段之后,数据到达了Reduce工作节点,Reduce阶段开始。

Reduce函数的类型是什么?Reduce工作节点拥有自己分区内的数据。所有具有相同键的中间键值对都会到达同一个Reduce工作节点。

Reduce函数接收一个特定的键,以及与该键关联的所有值的集合,然后产生一组输出值。

输出取决于具体的Reduce计算。例如:

  • 在倒排索引的例子中,输入可能是 (单词, [文档ID列表]),Reduce函数可能只需将这个列表写入GFS。
  • 在词频统计的例子中,输入可能是 (单词, [1, 1, 1, ...]),Reduce函数需要将这些值相加,得到总计数。

Combiner函数与结合律 ⚙️

让我们更深入地讨论一个优化点:Combiner函数。

假设一个Map工作节点需要统计单词“dog”的出现次数。如果使用论文中的词频统计Map函数,对于句子“My dog is the best dog and the fastest dog”,它会输出三个 ("dog", 1) 中间键值对。

在Shuffle阶段,这三个键值对都会被发送到负责键“dog”的Reduce工作节点,然后相加得到3。

这种方式的问题在于,发送这三个元组需要消耗网络带宽。我们可以在Map工作节点本地先将它们相加,然后只发送 ("dog", 3)。这样,Reduce工作节点再将来自不同Map工作节点的本地计数相加即可。

这种优化之所以可行,是因为我们进行的操作(加法)是结合律的。结合律意味着 (A + B) + C = A + (B + C)

如果Reduce操作是结合律的,我们就可以在Map工作节点上提前进行部分计算(Combiner),从而减少Shuffle阶段需要传输的数据量。

如果Reduce操作不是结合律的(例如求平均值),那么这种提前计算的方式可能会导致错误的结果,因为 average(average(A, B), C) 不一定等于 average(A, B, C)

为什么Google不再主要使用MapReduce? 🤔

有人提出了一个好问题:为什么Google不再主要使用MapReduce?

MapReduce并没有完全消失,而是被泛化了。你可以查阅2010年的一篇名为《FlumeJava: Easy, Efficient Data-Parallel Pipelines》的论文,其中介绍了Flume系统。

为什么需要Flume?因为MapReduce框架的结构化很强(Map-Shuffle-Reduce-输出到存储)。有些计算很难塞进这个结构,你最终可能需要串联多个MapReduce作业。

在MapReduce作业之间,数据需要写回GFS,然后被下一个作业读入。如果这些作业只是整个计算流水线的中间阶段,那么反复的中间存储和读取会非常慢。

Flume泛化了MapReduce,它允许更灵活地调度这些计算链,避免了每次中间结果都必须落盘的性能开销。因此,MapReduce更多是进化而非被淘汰。

共识的成本与强收敛性 💸

接下来,我们将话题转向副本冲突解决的数学原理。首先,我们需要讨论一下共识的成本。

在分布式系统中,我们经常讨论副本一致性问题。假设有两个副本,都可以接收更新。

如果我们需要强一致性,可以使用共识协议(如Paxos)来决定每个事件的顺序。但这需要大量的消息传递,成本很高。

共识协议只有在事件顺序至关重要时才值得使用。然而,很多时候,我们需要的安全属性不是强一致性,而是强收敛性

强收敛性意味着:如果副本收到了相同的更新集合(无论顺序),那么它们的状态最终会一致。

对于许多应用(如购物车),强一致性是过度的,强收敛性就足够了。这可以避免共识带来的高昂成本。

偏序集与最小上界 📐

为了形式化地讨论强收敛性,我们需要引入一些数学概念:偏序集和最小上界。

偏序集 由一个集合 S 和一个定义在 S 上的二元关系 组成,该关系满足:

  • 自反性:对于所有 a ∈ S,有 a ≤ a
  • 反对称性:如果 a ≤ bb ≤ a,则 a = b
  • 传递性:如果 a ≤ bb ≤ c,则 a ≤ c

偏序集意味着并非所有元素都是可比较的。一个经典例子是某个集合的所有子集构成的集合,其上的偏序关系是“子集包含关系”。

给定偏序集中的两个元素 ab,它们的上界 u 是满足 a ≤ ub ≤ u 的元素。
在所有这些上界中,如果存在一个元素 l,使得对于 ab 的任意上界 v,都有 l ≤ v,那么 l 称为 ab最小上界

如果一个偏序集中,任意两个元素都存在最小上界,那么这个结构被称为并半格

应用到副本冲突解决 🔗

现在,我们将这些数学概念应用到分布式系统中。

假设副本的状态是某个并半格中的元素。当两个副本收到冲突的更新时,它们不需要运行共识协议来决定顺序,而是可以简单地计算它们当前状态的最小上界,并将结果作为合并后的新状态。

例如,在购物车场景中,状态可以是商品集合(即集合的幂集,这是一个并半格)。添加商品对应于向集合中添加元素。两个副本状态的合并就是取它们集合的并集,这正是集合包含偏序下的最小上界操作。

这种无需共识的冲突解决方法,正是无冲突复制数据类型(Conflict-free Replicated Data Types, CRDTs)的核心思想之一。上面描述的是基于状态的CRDTs

CRDTs的挑战:删除操作 🗑️

然而,CRDTs并非万能。当我们引入删除操作时,情况会变得复杂。

如果简单地将状态视为集合,合并操作取并集,那么一个在某个副本上被删除的项目,可能因为另一个副本尚未收到删除操作,而在合并后重新出现(这就是Dynamo论文中“已删除商品重现”的问题)。

处理删除的常见技巧是使用墓碑集合。每个副本不仅跟踪已添加元素的集合,还跟踪已删除元素的集合。合并时,对两个集合分别取并集,然后从添加集中减去删除集。

但这带来了新问题:状态会不断增长(墓碑需要被永久保存吗?)。此外,如果添加一个元素,删除它,然后再添加它,简单的墓碑机制会认为它已被删除(因为删除优先)。要支持这种场景,可能需要引入计数器等更复杂的机制。

最终,为了垃圾回收墓碑,可能还是需要共识协议来让所有副本就某个清理点达成一致。

总结 📚

本节课中我们一起学习了以下内容:

  1. MapReduce总结:回顾了Map、Shuffle、Reduce三个阶段,以及Combiner函数如何利用结合律来优化性能。
  2. 共识的成本:认识到强一致性(通常需要共识)成本高昂,而许多应用只需要强收敛性。
  3. 冲突解决的数学原理:引入了偏序集、最小上界和并半格的概念。我们了解到,如果副本状态构成一个并半格,就可以通过计算最小上界来合并状态,从而无需共识即可解决冲突。
  4. CRDTs简介:介绍了基于状态的CRDTs如何利用这一原理,以及处理删除操作时面临的挑战(墓碑集合、状态增长)。

分布式系统是复杂的,但理解这些基础原理能帮助我们设计出更高效、更合理的系统。

019:问答环节

在本节课中,我们将回顾课程内容,并回答学生提出的各种问题,涵盖课程材料、技术工具、职业发展以及个人兴趣等多个方面。

概述

这是本课程的第10周,也是最后一次讲座。本节课没有新的教学内容,主要进行课程公告和问答环节。我们将回答学生关于课程内容、分布式系统测试、编程语言选择、职业建议以及个人经历等方面的问题。

课程公告与资源介绍

首先,我想向大家介绍我们加州大学圣克鲁兹分校的研究小组——语言、系统与数据实验室(LSD Lab)。这个实验室汇集了对系统、分布式系统、操作系统、数据库系统和编程语言等不同研究主题感兴趣的研究人员和学生。我的研究背景是编程语言,后来转向分布式系统领域。目前,我的研究兴趣是利用编程语言技术来形式化验证分布式系统的行为。

相关课程与博客

在我来到UCSC的头几年,我有幸教授了一些与我的研究相关的研究生课程:

  • 2018年秋季:我教授了一门名为“分布式编程的语言与抽象”的课程。课程网页仍然在线,上面列出了该主题领域的一系列有趣论文。
  • 我的学生和我共同撰写了一个博客,其中包含了一系列由我们编写和编辑的博文。这些博文涵盖了许多我们在本季度课程中讨论的主题。

特别主题课程

  • 2019年秋季:我教授了另一门研究生专题课程,主题是“SMT求解与分布式系统”。SMT求解器是一种强大的工具,如果使用得当,可以帮助我们推理分布式系统这类具有巨大状态空间的问题。这门课程涉及SMT求解及其应用,不仅限于分布式系统,还包括测试生成、程序合成等领域。

学生问答环节

现在,我将回答大家提出的问题。问题范围很广,从课程 logistics 到个人兴趣都有。

关于课程安排与工具

问题:辅导课下周还会继续吗?
我不确定。课程工作人员没有义务在期末考试周继续提供辅导。部分人员可能会选择继续,但建议你提前与他们确认。

问题:你最喜欢的IDE或文本编辑器是什么?
我使用Emacs。这主要是出于习惯,因为我在本科时它很流行。Emacs非常可定制,几乎可以做任何你想做的事情。

问题:你最喜欢的编程语言是什么?对于本课程的团队项目,你推荐什么语言?
对于团队项目,最重要的因素是什么能帮助团队更好地协作,这取决于团队成员的熟悉程度。Peter Norvig的建议是“学习你朋友正在使用的语言”,这很有道理,因为拥有一个可以交流和获得帮助的社区非常重要。
就个人而言,我很喜欢Rust。目前为了研究,我在使用Haskell,主要是因为现有工具适合我要做的SMT增强类型系统方面的工作。我也非常推荐Racket,它是一个功能强大、社区友好的Scheme语言变体。
对于本课程的项目,Python是最受欢迎的选择,它有很好的框架。Go也被称为“系统编程的Python”,构建Web服务非常直接。Java也有很好的API,不应被完全排除。

关于个人背景与兴趣

问题:能谈谈你的音乐学位吗?
我本科时双修了音乐和计算机科学。我是一名歌手,在非疫情期间,我会与硅谷交响乐团合唱团一起演出。唱歌和教学在某种程度上都是表演。

问题:你做过结合音乐和计算机科学的事情吗?
我学习过一些电子音乐,其中涉及的工具本身就像不完整的编程语言。虽然我没有深入研究,但我认为音乐记谱法的研究很有趣,不同文化和时代的记谱系统有不同的特性,这与编程语言设计有相似之处。

关于分布式系统测试

问题:有哪些用于测试分布式系统的工具或框架,特别是模拟复杂网络条件(如分区、高延迟)的?
测试分布式系统确实非常困难。我们提供的测试脚本远不足以发现所有错误。理想情况下,你需要能够系统性地测试所有消息交错、机器故障组合的工具。

  • 混沌工程:在生产环境中注入故障进行测试,这能让你在真实运行环境中观察系统行为。
  • 工具推荐:可以看看开源框架Jepsen,它用于向分布式系统注入一系列故障(如网络分区)以发现错误。还有像基于溯源的故障注入这类研究,它试图通过选择最可能暴露错误的故障类型来缩小搜索空间。
  • 可观测性工具:确保系统正常工作的第一步是了解系统正在发生什么。目前业界在分布式系统可观测性方面有很多很酷的工具和实践,这是一个新兴领域。

问题:你为什么选择使用纸笔和文档相机教学?
最初是出于 necessity(去年突然转为线上教学,我没有平板电脑)。我习惯了在教室黑板上书写,文档相机能最接近那种体验。后来我发现它很好用,学生反应也不错。现在我不只用于教学,和学生会面讨论时也会用它来画图,这类似于线下使用白板。

关于数据库与职业发展

问题:你对关系型数据库和NoSQL数据库有什么看法?
这完全取决于你的使用场景和问题约束。

  • 关系型数据库(如PostgreSQL)具有丰富的数据模型和完整性约束,但在大规模分布式场景下,维护这些约束会带来复杂性。
  • NoSQL数据库(如Dynamo的键值模型)数据模型扁平简单,使得处理分布式一致性和容错更容易,因为键值对之间是独立的。
    第一步始终是问自己:你真的需要一个分布式系统吗? 对于很多场景,单机关系数据库可能就足够了。只有当你处理像亚马逊那样规模的数据时,才需要开始认真考虑可用性等问题,此时扁平数据模型会变得非常有帮助。有时人们会尝试在NoSQL系统上重建关系模型,这又会带来新的设计考量。

问题:有哪些值得关注的使用分布式系统的大公司?
大公司(如Google、Microsoft、Netflix、Amazon)处理的问题规模是独一无二的,这会催生像MapReduce这样的新工具。在这些地方工作能接触到别人没有的问题规模。
但很多很酷的工作也发生在不那么大的公司。例如,Fastly就在使用Rust做很酷的分布式系统工作。支付行业虽然数据总量可能不大,但每笔交易都极其重要,这带来了不同的权衡和基础设施设计考量。游戏行业也以极具创意的方式解决硬核的分布式系统问题,只是它们有自己的文化和词汇。

问题:你最喜欢的分布式系统研究论文是哪篇?
课程中选取的MapReduce和Dynamo论文是文献中的标准,它们展示了分布式系统工作的不同方面,虽然有些年头了,但仍是经典。
我个人更喜欢80年代和90年代的论文。那时的研究预见到了我们今天在容错、网络分区等方面的关切,在当时更多是理论兴趣,但现在与日常软件工程实践深刻相关。这些老论文中有很多深刻的见解。

其他问题

问题:课程中哪个主题最难讲/最难学?
这取决于个人。有些讲座内容非常深刻和微妙。例如,第一次讲Paxos时我非常紧张,需要投入大量精力准备例子。经过几次授课后,我感觉好多了。
我发现,要真正做好作业,必须理解讲课材料。这让我感到欣慰,因为这意味着这些理论材料不仅具有知识上的趣味性,而且非常实用。过去一年,因为要验证因果广播的实现,我对它的理解也加深了。

问题:能讲讲章鱼表情符号的故事吗?
这个梗源于Recurse Center社区,他们早期就使用了Zulip。有人发现章鱼表情符号可以“托起”其他表情,非常可爱,于是在社区内流行起来。因为RC是Zulip的早期用户,Zulip将章鱼表情放在了默认表情选择区的前列。后来,这个用法传播到了其他使用Zulip的社区。
当我们开始在UCSC举办西海岸版的Bang Bang Con会议时,我们决定使用章鱼作为标志,既因为它是RC的内部梗,也因为章鱼很符合圣克鲁兹和西海岸的精神(圣克鲁兹有章鱼公共艺术,UCSC著名的分布式存储系统Ceph名字也来源于头足类动物)。于是就有了章鱼托起感叹号的标志。

问题:对于毕业后想保持联系的同学,你有什么建议?
请保持联系! 教授们很高兴听到学生们如何将所学知识应用于实际工作中。这对于我教的这类课程尤其让我开心。
此外,如果几年后你决定重返校园读研究生,你需要来自了解你、能评价你研究潜力的教授的推荐信。如果你毕业后一直保持联系,教授就能为你写出更有力的推荐信。即使你不再踏入校园,保持联系、分享近况也是一件很棒的事。

总结与告别

在本节课中,我们一起回顾了课程的相关资源,并解答了大家关于技术、职业和个人的各类问题。感谢所有同学在这一年困难情况下的坚持与付出,特别感谢我们出色的课程工作人员(Patrick, Aria, Abha等)的卓越工作。
对于即将毕业的同学,祝你们前程似锦。请记住保持联系,随时告诉我你们的近况。祝大家期末考试顺利,圆满完成最后一个作业!
再见!

posted @ 2026-03-29 09:28  布客飞龙II  阅读(2)  评论(0)    收藏  举报