Qihang Yang

I love creating stuff, generally.

I've been building Drifting for a while. It writes with you and helps you organize your thoughts into stories.

I started off as a frontend enthusiast because I like good products, and the process of making them.

I also dig lower-level stuff. A lot of the ideas are truly ingenious, elegant, and timeless.

Besides coding, I'm a hobbyist writer. I also enjoy comedy and country music.

You can find me on GitHub, LinkedIn, or .

学完 6.5840,我开始怎样理解分布式系统

我一直想学习 6.824,听说,要做后端,这是必修的。

年底,我发现它改名了。6.5840。

在学这个课,或者读 DDIA 之前,我对分布式系统的理解大概是:一台机器不够,就多开几台;它们各自做一部分工作,再通过 RPC 交换结果。

学完之后,我才懂得,机器变多只是表象。分布式系统最麻烦的地方,是你不能轻易判断一件事到底有没有发生。

一个函数在本地执行,要么返回,要么报错。一次远程调用超时,却可能有很多种解释:请求没有送到,服务器还没来得及处理,服务器已经处理但回复丢了,或者回复只是堵在网络里。与此同时,不同机器可能活在不同的任期,保存着不同长度的日志,对“现在谁是 Leader”也有不同答案。

所以这门课真正教我的,不是几个 RPC 应该怎么写,而是在信息残缺、消息乱序、机器随时会消失的情况下,怎样维护一段仍然可信的历史。

失败并不是一个清晰的事件

单机程序里,一个模块崩了,整个进程通常都知道。分布式系统却经常只有一部分出了问题:一个 Worker 失联了,但其他 Worker 还在运行;一个副本被网络隔开了,但它自己并不知道;客户端收不到回复,也无法判断服务器是不是已经执行了请求。

这种状态叫部分失败。它最让人难受的地方,是“失败”和“慢”在外部看起来几乎一样。

系统唯一能做的,通常只是设一个超时,然后采取行动。MapReduce 的 Coordinator 等不到 Worker,就把任务重新交给其他人;KV 客户端等不到 Put 的回复,就再次尝试。但超时只是本地计时器到期,不是远端失败的证明。旧 Worker 可能稍后回来,第一次 Put 也可能早已成功。

因此,每一次重试都必须先回答一个问题:如果原来的操作其实已经完成,再做一遍会发生什么?

MapReduce 需要让任务可以被安全地重复执行;KV Server 使用版本号和条件写入,避免同一次修改生效两遍。更一般地说,系统需要给操作一个稳定的身份,或者把它设计成幂等的,让重复执行不会产生额外影响。

我以前会把重试理解成 try-catch 里面的最后几行代码。但现在,我更愿意把它看成协议本身的一部分。

只要通信可能失败,正常流程就必须包含重复、晚到和不确定的结果。

一致性不是“最后数据一样”

分布式系统很容易做到“过一段时间以后,各个副本差不多一样”。

难的是,在系统仍然不断接受读写时,对外给出一套可以理解的行为。

6.5840 的 KV 服务要求线性一致性。它可以用一句很朴素的话解释:从客户端看来,所有操作像是在某一台机器上按某个顺序逐个发生。

而不是,非常常见的,所谓的,最终一致性。

如果一次写入已经返回成功,之后开始的读取就必须看到它。两个并发写入谁先谁后都可以,但最后的结果必须能由某一种单线程顺序解释。

系统内部可以有副本、重试、选主和日志,客户端却不应该看到这些机制撕开的裂缝。

这个定义的重要之处,在于它划清了系统绝对不能产生哪些历史。一个失去联系的旧 Leader 不能继续确认写入,一个落后的副本不能随便回答最新读取,迁移中的 shard 也不能同时由新旧两个 group 对外服务。否则客户端看到的结果,就无法再排列成一条自洽的时间线。

Lab 里用 KV 实现分布式锁,是一个很好的检验。锁的代码本身不长,但只要条件写入或重试语义有一点含糊,就可能出现两个客户端都认为自己拿到了锁。

到了这里,一致性不再是论文里的名词,而是一项直接决定上层程序能不能正确运行的承诺。

Raft 复制的不是数据,而是历史

刚开始写 Raft 时,我把它理解成一种同步机制:Leader 上的数据变了,就把变化发给 Follower。

后来才明白,Raft 真正复制的是一串有顺序的操作。只要各个副本从相同的初始状态出发,按照相同顺序执行相同命令,它们就会得到相同的状态。这就是复制状态机。

于是问题,从“怎样同步几份 map”,变成了“怎样让一段历史不断延续,并被多数人认可”。

Raft 用任期区分一代又一代 Leader,用选举决定谁有资格提出新的日志,用多数派确认哪些条目已经成为共同历史。

多数派的意义不只是票数超过一半,而是任意两个多数派的必然相交。新的 Leader 想获得多数支持,就不可能完全绕过已经参与提交的那些节点。

这里最容易忽略的是时间。RPC 发出去时,节点可能还是 Candidate;回复回来时,它已经进入了更高任期。一次日志复制的旧回复,也可能比后发请求更晚到达。如果只看回复内容,不重新核对任期、角色和日志位置,过去的消息就会篡改现在的状态。

所以 Raft 训练的其实是一种处理异步结果的习惯:任何消息都必须放回它产生时的上下文里判断。一个结果即使曾经正确,也可能已经过期。

不过,共识并没有直接解决整个服务的问题。Raft 可以确定某条命令排在日志的什么位置,却不知道客户端有没有收到回复,也不知道业务操作重复执行会有什么后果。KV Server 仍然需要处理请求身份、等待结果和重试。共识提供了一条可靠的操作顺序,上层服务还要负责赋予这条顺序正确的业务含义。

恢复不是重新启动,而是继续过去

一个内存里的 Raft 可以在网络抖动中工作,却经不起进程重启。节点如果忘记自己投过谁、处于哪个任期、保存过哪些日志,就可能对已经确定的历史作出矛盾决定。

因此,持久化不是系统最后附加的存档功能。某些状态必须在对外产生影响之前写入稳定存储。机器重新启动后,也不是从头开始,而是带着过去的承诺继续参与协议。

日志又不能永远增长。系统运行得越久,重启时要重放的内容越多,落后节点追赶也越慢。快照解决了这个问题:状态机把一段已经执行完的日志压缩成当前状态,Raft 则只保留后续仍然有用的日志。

快照不是普通备份。它必须和被压缩的日志前缀严格对应,并且能够替代那段历史参与恢复。加入快照以后,日志的逻辑下标也不再等于内存数组下标;落后太远的 Follower 不能继续一条条接收旧日志,而要直接安装快照,再从新的位置向后追赶。

几乎所有的数据库相关的实现,都绕不开快照。

我自己的 Drifting 项目中,我使用了类似的机制。Yjs 负责多端同步,冲突协调,增量会在多个设备中流转,但,新设备,总会从“拿快照”开始。

扩展的难点,是转移所有权

一组 Raft 副本可以容忍故障,但所有请求仍然要经过同一条日志,吞吐量终究有上限。分片把不同 key 交给不同的 shard group,让多组复制状态机并行工作。

静态分片很简单。真正困难的是系统运行期间的重新配置:新的 group 会加入,旧的 group 会离开,热点数据也需要迁移。此时要移动的不只是一些键值对,而是“谁有资格回答这些 key”的所有权。

迁移过程中,如果新旧两个 group 同时提供服务,同一个 key 就会出现两份不断分叉的历史;如果旧 group 已经停止,新 group 却还没准备好,数据又会长时间不可用。客户端还可能缓存着旧配置,把请求继续发往已经不再负责这个 shard 的服务器。

解决这类问题,需要给配置编号,把迁移拆成可以识别和重复执行的阶段,并把未完成的意图持久化。这样 controller 即使中途崩溃,新的 controller 也能判断迁移进行到了哪里,而不是从一个模糊的中间状态重新猜测。多个 controller 并发工作时,它们同样需要用版本和所有权约束,避免彼此覆盖。

做到这一部分,我才发现控制面本身也是一个分布式系统。负责“管理其他节点”的 controller 并不会天然更可靠,它一样会崩溃、断网、重复执行,也必须接受相同的故障规则。

Redis 以高并发写入闻名,它的 data plane 是异步复制,但它的 high availability / cluster control plane,比如 sentinel,却使用了 epoch、quorum和leader-election机制。MYSQL InnoDB Cluster 的 Group Replication,也采用了类似的共识机制来做到 fault tolerance。

最后留下的几个问题

回头看,MapReduce、KV、Raft、复制状态机和分片 KV 只是课程用来承载这些概念的形状。真正反复出现的,是这些问题:

  • 没收到回复时,“外面”可能已经发生了什么?
  • 一个操作被重试以后,系统怎样认出它还是同一个操作?
  • 多个副本怎样对外表现出一段单一、连续的历史?
  • 机器重启以后,哪些承诺必须仍然成立?
  • 状态或所有权转移到一半时,别人怎样接着完成?

现在,再拿到一个分布式协议,我最先关注的已经不是怎么并发、吞吐量多少,而是它如何表达“不知道”,如何拒绝过期消息,以及如何从任意中间状态继续前进。

这大概就是 6.5840 对我最大的改变。

分布式系统并不是把单机程序复制几份,而是在没有全局视角、无法可靠判断失败的环境里,仍然为外部维护一份可信的现实。

MIT 的课程协作政策不允许公开实验代码。但相比代码的实现细节,上面这些贯穿整门课的概念,才让我受益最多。