关键问题:部件会坏,系统不能坏

如何在部件会出故障的前提下构建能正常工作的系统?机器、磁盘、网络、软件都会时不时出故障,我们(很可能永远)造不出"完美"的部件。怎样用并不总是正确工作的部件,拼出一个看起来很少出故障的系统?

原文:HOW TO BUILD SYSTEMS THAT WORK WHEN COMPONENTS FAIL. How can we build a working system out of parts that don't work correctly all the time?

你打开的每个网页背后,往往是成千上万台机器在协作。分布式系统带来的新挑战里,本章关注最主要的一个:故障(failure)。有意思的是,故障既是挑战也是机会:单台机器坏了,并不意味着整个系统必须停摆——把许多机器组合起来,就能做出一个"看起来几乎不坏"的系统,尽管它的部件经常在坏。

这让人想起第 38 章的 RAID:用多块会坏的盘拼出一个可靠的盘。但分布式系统的问题更复杂:除了故障,还要考虑性能(尽量少发消息、低延迟高带宽)和安全(确认对方身份、防止第三方窃听或篡改)。本章先打地基:机器之间怎么通信,以及通信层如何对付故障。接下来的第 49 章(NFS)和第 50 章(AFS)就是建立在这些通信原语之上的分布式文件系统。

核心概念速览

丢包是常态

比特翻转、链路断开,最根本的是路由器/主机缓冲区满了只能丢包。通信天生不可靠。

UDP:不可靠通信层

发出去的数据报可能丢,发送方不会被通知;只有校验和(checksum)检测部分损坏。

确认(ack)

接收方回一条短消息"我收到了",发送方由此知道对方确实收到。

超时/重传(timeout/retry)

发送时保留副本并设定时器;定时器到期还没 ack,就再发一次。

序列号(sequence counter)

双方各维护一个计数器,接收方据此识别重复消息:仍然 ack,但不再交给应用。

分布式共享内存(DSM)

用虚拟内存把多台机器伪装成一个地址空间;故障和性能都难处理,几乎没人用。

远程过程调用(RPC)

让远程执行像调用本地函数:存根生成器负责封送(marshaling),运行时库负责可靠传输。

端到端论证(end-to-end)

某些功能(如可靠文件传输)只有最上层的应用"端点"才能真正保证。

通信基础:丢包是常态

现代网络的核心信条是:通信从根本上就是不可靠的。无论是广域的互联网,还是局域网里的 InfiniBand 这类高速网络,包都会经常丢失、损坏,或者因为别的原因到不了目的地。原因有好几类:

① 传输中比特翻转 发出:1011 0010 收到:1001 0010 电气干扰等让某些位变了 → 用校验和(checksum)检测, 检测到就当作丢弃 ② 部件坏了 主机 A 主机 B 网线被意外切断、 路由器损坏、远端主机宕机 包发出去了, 但没有人能收到 ③ 缓冲区满(最根本) 同时涌入的包 路由器内存(满) 放不下,只能丢 即使所有部件都完好 也会丢包(端主机同理)
图 48-A:丢包的三类原因。第三类说明:就算链路和机器都完美无缺,丢包也无法避免。

第三点最关键:一个包到达路由器后,必须先放进路由器的内存里才能处理。如果同一时刻涌进来太多包,内存装不下,路由器唯一能做的就是丢掉一部分。端主机也一样:你向同一台机器猛发大量消息,它的资源很快就会被压垮,于是又开始丢包。所以丢包在网络中是根本性的,问题只能是:我们怎么应对它?

发送前对消息的字节算一个校验和(checksum),和消息一起发出;接收方对收到的字节再算一次,两者一致就可以比较放心地认为数据没在路上被损坏。最简单的校验和是把字节加起来;更复杂的有 CRC、Fletcher 校验和等。评价校验和有两个维度:有效性(数据变了,校验和是否一定跟着变)和性能(算起来多贵)——两者经常互相矛盾。

不可靠通信层:UDP

最简单的应对办法是:不处理。有些应用自己知道怎么处理丢包,给它们一个基本的不可靠消息层就够了——这也是后面会讲的"端到端论证"的一个例子。今天几乎所有系统都自带的 UDP/IP 协议栈就是这样的不可靠层:进程用套接字(sockets)API 创建一个通信端点,其他机器(或同一台机器)上的进程向它发送 UDP 数据报(datagram)——一个有最大长度限制的消息。

下面是原书 Figure 48.1 的客户端/服务端,只保留了核心部分:

// client code
int main(int argc, char *argv[]) {
    int sd = UDP_Open(20000);                     // 在本机 20000 端口建一个端点
    struct sockaddr_in addrSnd, addrRcv;
    int rc = UDP_FillSockAddr(&addrSnd, "cs.wisc.edu", 10000);  // 目的地:主机名 + 端口
    char message[BUFFER_SIZE];
    sprintf(message, "hello world");
    rc = UDP_Write(sd, &addrSnd, message, BUFFER_SIZE);          // 发出去(可能丢!)
    if (rc > 0)
        rc = UDP_Read(sd, &addrRcv, message, BUFFER_SIZE);       // 等回复(可能永远等不到)
    return 0;
}

// server code
int main(int argc, char *argv[]) {
    int sd = UDP_Open(10000);
    assert(sd > -1);
    while (1) {
        struct sockaddr_in addr;
        char message[BUFFER_SIZE];
        int rc = UDP_Read(sd, &addr, message, BUFFER_SIZE);     // addr 里记下是谁发来的
        if (rc > 0) {
            char reply[BUFFER_SIZE];
            sprintf(reply, "goodbye world");
            rc = UDP_Write(sd, &addr, reply, BUFFER_SIZE);       // 按原地址回复
        }
    }
    return 0;
}

它们依赖的小库(Figure 48.2)只是对套接字系统调用的薄封装:

int UDP_Open(int port) {
    int sd;
    if ((sd = socket(AF_INET, SOCK_DGRAM, 0)) == -1)   // SOCK_DGRAM:数据报 = UDP
        return -1;
    struct sockaddr_in myaddr;
    bzero(&myaddr, sizeof(myaddr));
    myaddr.sin_family      = AF_INET;
    myaddr.sin_port        = htons(port);              // 端口转成网络字节序
    myaddr.sin_addr.s_addr = INADDR_ANY;
    if (bind(sd, (struct sockaddr *) &myaddr, sizeof(myaddr)) == -1) {
        close(sd);
        return -1;
    }
    return sd;
}
int UDP_FillSockAddr(struct sockaddr_in *addr, char *hostname, int port) {
    bzero(addr, sizeof(struct sockaddr_in));
    addr->sin_family = AF_INET;          // host byte order
    addr->sin_port   = htons(port);      // network byte order
    struct hostent *host_entry;
    if ((host_entry = gethostbyname(hostname)) == NULL)   // 命名:主机名 → IP
        return -1;
    addr->sin_addr = *(struct in_addr *) host_entry->h_addr;
    return 0;
}
int UDP_Write(int sd, struct sockaddr_in *addr, char *buffer, int n) {
    return sendto(sd, buffer, n, 0, (struct sockaddr *) addr, sizeof(struct sockaddr_in));
}
int UDP_Read(int sd, struct sockaddr_in *addr, char *buffer, int n) {
    int len = sizeof(struct sockaddr_in);
    return recvfrom(sd, buffer, n, 0, (struct sockaddr *) addr, (socklen_t *) &len);
}

UDP 是不可靠通信层的典型:包会丢,发送方也不会被告知。但它也不是什么都不管——UDP 包含一个校验和,用来检测某些形式的包损坏。然而很多应用只想"把数据发过去"而不操心丢包,所以我们需要更多:在不可靠的网络之上构建可靠的通信。

可靠通信层:确认、超时/重传、序列号

设想客户端通过不可靠的连接给服务器发消息。第一个问题:发送方怎么知道接收方真的收到了?办法叫确认(acknowledgment,简称 ack):接收方收到后回一条短消息。发送方收到 ack,就能放心对方收到了原始消息。

那没收到 ack 怎么办?再加一个机制:超时(timeout)。发送方发消息时设一个定时器;如果到期时还没收到 ack,就断定消息丢了,于是重试(retry)——再发一次同样的消息。为此发送方必须保留消息的副本。这套组合被称为超时/重传(timeout/retry)。

正常:消息 + ack(图 48.3) 发送方接收方 消息 ack 收到;回 ack 收到 ack 请求丢失(图 48.4) 发送方接收方 留副本、设定时器 定时器到期 → 重传 ack 收到 ack;删副本、关定时器 ack 丢失(图 48.5) 发送方接收方 收到① 超时 → 重传 又收到②! 收到 ack 接收方收到了两次同一条消息
图 48-B:原书 Figure 48.3 / 48.4 / 48.5。从发送方看,"请求丢了"和"ack 丢了"一模一样:都是等不到 ack。

可惜仅有超时/重传还不够。看右边的情况:丢的不是消息而是 ack。在发送方看来情形完全一样——没收到 ack,于是超时重传;可在接收方看来,同一条消息收到了两次!有时这无所谓,但一般不行:想象你在下载文件,结果文件中间有些包被重复写了进去。所以可靠消息层通常还要保证每条消息恰好被接收一次(exactly once)。

为了识别重复,发送方要给每条消息一个唯一标识,接收方要能判断是否见过它。看到重复消息时,接收方照样回 ack(否则发送方会一直重传),但关键是不把它交给应用。

一种办法是每条消息带全局唯一 ID,接收方记住见过的所有 ID——但这需要无限的内存。更省的办法是序列计数器(sequence counter):

发送方 counter = N 发消息时附上当前值 N 发完后 counter = N+1 (双方事先约定起始值, 比如都从 1 开始) 接收方 expected = N 收到 ID == expected: ack + 交给应用 + expected++ 收到 ID < expected: 重复!只 ack,不交给应用 消息 (ID = N) ack N 每端只需存一个整数, 而不是"见过的所有 ID"
图 48-C:序列计数器。ack N 丢失后发送方重发 N,而接收方的 expected 已经是 N+1,于是识别出重复。

超时太短,发送方会白白重发消息,浪费发送方 CPU 和网络资源;超时太长,发送方等太久才重发,感知到的性能下降。从单个客户端和服务器的角度看,"正确"的值是刚好足够长以检测丢包,但不再更长。

而在多个客户端访问一个服务器的场景中,服务器端的丢包可能意味着它已经过载。这时客户端可以自适应地重试:第一次超时后把超时值提高(比如翻倍)。这种指数退避(exponential back-off)最早来自 Aloha 网络并被早期以太网采用,可以避免大量重发进一步压垮资源。

最常用的可靠通信层是 TCP/IP(简称 TCP)。它比上面描述的复杂得多:有应对网络拥塞的机制、允许多个未完成的请求同时在途,以及上百个其他改进。下面的模拟器实现的是最简单的"停止-等待"版本:一次只有一条消息在途。

模拟器

可靠通信协议:泳道图

左边是发送方、右边是接收方,时间从上往下流。调丢包率、超时时间,开关序列号,单步观察:消息/ack 丢失 → 超时重传 → 接收方用序列号识别重复。关掉序列号,看重复消息被"执行两次";把超时设得比往返时间还短,看会发生什么。接收方的应用是"存款 +100",不是幂等操作。
原书示例:
数据消息 M(#k 表示第 k 次发送) ack 丢失的包 发送方定时器

通信抽象(一):分布式共享内存为什么不行

有了基本的消息层,下一个问题是:构建分布式系统时,应该给程序员什么样的通信抽象?

一派研究把操作系统的抽象扩展到分布式环境。例如分布式共享内存(distributed shared memory,DSM)让不同机器上的进程共享一个巨大的虚拟地址空间,分布式计算就变得像一个多线程程序,只不过线程跑在不同的机器上。它通常借助操作系统的虚拟内存实现:

一个"共享"的虚拟地址空间,页面散落在各台机器上 机器 A(运行线程) P0 P1 访问 P0:本地,很快 访问 P2:缺页(page fault) → 发消息去别的机器取页 机器 B P2 P3 缺页处理程序取回 P2, 装入 A 的页表,继续执行 机器 C(宕机!) P4 P5 P4、P5 突然"消失" 链表的 next 指针可能 正指向这里…… 问题 1 · 故障:数据结构散布在整个地址空间,一台机器坏了,地址空间就出现"窟窿"。 问题 2 · 性能:有的内存访问是纳秒级,有的要跨网络取页;程序员只好小心安排计算、尽量不通信。
图 48-D:DSM 借助虚拟内存把远程页面伪装成本地内存。

访问一页时有两种情况:最好的情况,页面就在本机,很快;否则就触发缺页,缺页处理程序向另一台机器发消息取回该页,装进请求进程的页表,然后继续执行。

这种方法今天没什么人用,原因有二。最大的问题是故障处理:某台机器坏了,它上面的页怎么办?如果分布式计算的数据结构散布在整个地址空间里,部分数据结构会突然不可用——想象一个链表,某个 next 指针指向了已经消失的那部分地址空间。其次是性能:写代码时我们通常假设访存很便宜,而在 DSM 中有些访问很便宜、有些却要缺页并从远程取回。程序员不得不非常小心地组织计算,让几乎不发生通信——这就违背了这种抽象的初衷。尽管相关研究很多,实际影响很小:今天没人用 DSM 构建可靠的分布式系统。

通信抽象(二):远程过程调用(RPC)

操作系统的抽象不适合构建分布式系统,而编程语言(PL)的抽象更合理。最主流的是远程过程调用(remote procedure call,RPC)。它的目标很简单:让在远程机器上执行代码像调用本地函数一样简单直接。客户端发起一次过程调用,一段时间后得到结果;服务器只需定义好要导出的例程,其余"魔法"都由 RPC 系统处理。RPC 系统一般有两部分:存根生成器(stub generator,也叫协议编译器 protocol compiler)和运行时库(run-time library)。

客户端机器 客户端代码:r = func2(7, 300); 客户端存根:封送参数 / 解封结果 RPC 运行时:超时/重传、ack、命名 服务器机器 真正的函数:int func2(a, b) 服务端存根:解封参数 / 封送结果 RPC 运行时:去重、线程池分派 请求 回复 两边的存根都由存根生成器根据接口定义自动生成
图 48-E:RPC 的组成。蓝色两层是存根生成器生成的代码,黄色两层是运行时库。

存根生成器

存根生成器的工作是把"把函数参数和结果打包成消息"这件痛苦的事自动化。好处有很多:从设计上避免手写这类代码时的低级错误;编译器还可能优化这些代码、提升性能。它的输入就是服务器想导出的一组调用,概念上可以简单到这样:

interface {
    int func1(int arg1);
    int func2(int arg1, int arg2);
};

生成器据此为客户端生成客户端存根(client stub),其中包含接口中的每个函数;客户端程序链接这个存根并调用它来发起 RPC。在客户端看来就是调用了 func1(x),而存根里的 func1() 内部会:

  1. 创建消息缓冲区——通常就是一段连续的字节数组。
  2. 把所需信息打包进缓冲区:要调用的函数的标识符,以及函数需要的全部参数(例如 func1 的一个整数)。把这些信息放进一个连续缓冲区的过程叫参数封送(marshaling)或消息序列化(serialization)。
  3. 把消息发给目标 RPC 服务器:通信细节由运行时库处理。
  4. 等待回复:函数调用通常是同步的,所以要等它完成。
  5. 解包返回码和其他结果:即解封(unmarshaling)或反序列化;复杂的函数可能返回列表等复杂结果。
  6. 返回给调用者。

服务器端也会生成代码:解封消息(取出函数标识符和参数)→ 调用真正的函数(终于!)→ 打包结果到回复缓冲区 → 发送回复。

存根编译器还要考虑几个重要问题:

运行时库

运行时库承担了 RPC 系统的大部分重活,大多数性能和可靠性问题都在这里处理。

命名(naming):客户端怎么找到远程服务?最简单的做法建立在现有命名系统之上,例如互联网协议提供的主机名和端口号:客户端必须知道运行 RPC 服务的机器的主机名或 IP 地址,以及服务使用的端口号(端口号用来区分一台机器上同时进行的多个通信活动)。协议栈负责把包从任意机器路由到这个地址。

传输协议选哪个:直觉上应该用可靠的 TCP——请求要可靠送达,回复也要可靠收到。然而在可靠层之上构建 RPC 会导致严重的性能浪费:

RPC 建在 TCP 上:4 条消息 客户端服务器 ① 请求 ② ack(额外) ③ 回复 ④ ack(额外) RPC 建在 UDP 上:2 条消息 客户端服务器 ① 请求 ② 回复 回复本身 就说明请求已收到 RPC 层自己做超时/重传 + 序列号去重
图 48-F:请求/响应协议建在可靠层之上,会多出两条"额外"的 ack。

可靠层靠 ack + 超时/重传工作:客户端发出 RPC 请求,服务器回一个 ack 让调用方知道请求已收到;服务器发回复时,客户端也要 ack。于是一个请求/响应协议就多发了两条"额外"消息。因此许多 RPC 包建在不可靠的 UDP 之上,这让 RPC 层更高效,但可靠性的责任就落到了 RPC 系统自己身上:它像前面描述的那样使用超时/重传和确认;再借助某种形式的序列号,通信层可以保证每个 RPC 恰好执行一次(exactly once,没有故障时),或者至多执行一次(at most once,出现故障时)。

其他问题:

整数 0x12345678 在内存中的 4 个字节(地址从低到高) 大端(XDR、网络字节序) 12 34 56 78 小端(x86 主机) 78 56 34 12 高位在前 低位在前 小端机器发送前要逐项转换成大端(htonl),收到后再转回来(ntohl)。
图 48-G:字节序。两种都是合法的存储方式,问题只在于不同机器之间怎样交流。
模拟器

一次 RPC 的完整旅程

选一个函数和参数,单步跟踪:客户端存根封送参数(看消息缓冲区里的每个字节)→ 运行时发送 → 网络 → 服务端存根解封 → 执行 → 结果原路返回。打开"回复丢失",看运行时如何用超时重传 + 请求编号(xid)去重,保证函数不被执行两次。

模拟器中服务器的实现(示例):int func1(int arg1) { return arg1 * arg1; }、int func2(int arg1, int arg2) { return arg1 + arg2; }。消息格式(示意,类似 Sun RPC):请求 = [xid][过程编号][参数…],回复 = [xid][状态][返回值],每项 4 字节、大端。

端到端论证

本章多次提到一个设计原则:端到端论证(end-to-end argument)。它认为,系统中最高的一层——通常是位于"端点"的应用——才是分层系统中某些功能唯一能真正实现的地方。Saltzer 等人在其经典论文中用"两台机器之间可靠地传输文件"作为例子:

机器 A 磁盘上的文件 发送方内存 可靠通信协议(ack、超时/重传、序列号) 保证:发出的每个字节都按序到达 机器 B 接收方内存 写入磁盘 发送前字节在内存中损坏 写盘时出错 网络层"可靠"≠ 文件传输可靠 端到端检查:传完后读回 B 盘上的文件算校验和,与 A 的比对
图 48-H:可靠的底层协议管不到它两端之外发生的错误。

如果只在底层加可靠性——比如用可靠的通信协议来实现可靠的文件传输——并不能构成真正可靠的文件传输:字节可能在通信开始之前就在发送方内存中损坏,或者接收方写盘时出了问题。字节虽然在网络上可靠送达,文件传输最终仍不可靠。要实现可靠的文件传输,必须加入端到端的检查,例如传输完成后读回接收方磁盘上的文件、计算校验和,与发送方文件的校验和比较。

推论是:在底层提供额外功能有时确实能提升性能、优化系统,所以不必一概排斥底层机制,而应结合它在整个系统中的最终用途,仔细权衡它的价值。TCP 的存在就是这种权衡的结果:它不能替代端到端检查,但让大部分应用少操心。

先预测再验证

在可靠通信模拟器中点"图 48.5 ack 丢失(无序列号)"。预测:接收方的余额最终是多少?发送方一共发送了几次数据消息?然后单步验证。
看答案
发送 2 次(第一次 ack 丢失,超时后重传),接收方把两次都当成新消息执行,余额是 200 而不是 100。统计栏里"重复执行"为 1。勾上"使用序列号"再跑一次:重传的消息 seq=1,而接收方 expected 已经是 2,于是被识别为重复,只回 ack、不执行,余额正确为 100。
点"超时太短 + 无序列号"(超时 3,往返时间 = 2×单程延迟 = 4)。预测:M2 最终有没有被接收方执行?为什么发送方却认为 M2 已经成功?
看答案
M2 在网络中丢了,接收方从未执行它;可发送方在 t=7 收到了一个 ack——那其实是接收方对 M1 重传副本的确认。因为没有序列号,发送方无法区分这个 ack 属于谁,于是"张冠李戴"地认为 M2 成功了。结果:M1 执行两次,M2 一次都没执行。勾上序列号后,迟到的 ACK 1 会被识别为过期而忽略,发送方继续等待并重传 M2。这说明序列号不仅接收方要用,发送方匹配 ack 时也要用。
使用序列号,单程延迟 2,把超时从 6 逐步调到 3、2、1(保持丢包率 0)。预测:发送次数如何变化?最终余额会错吗?
看答案
往返时间是 4。超时 ≥ 4 时每条消息只发 1 次;超时小于往返时间时,ack 还在路上定时器就到期了,发送方不必要地重传,发送次数明显增加(超时越短越多)。但因为有序列号,重复都被接收方丢弃,余额始终正确——超时太短浪费的是网络和 CPU,而不是正确性。这正是"小心设置超时值"提示所说的。
点"随机丢包"后,把消息丢包率调到 60%、ack 丢包率 0%,超时 4,种子 3,分别在勾选和不勾选"指数退避"时跑到最后一步。预测:哪种情况结束得更晚?哪种发送次数更少?
看答案
发送次数完全相同(都是 11 次):同一种子下,每个包丢不丢是一样的。但不退避时 t=44 就结束了,退避时要到 t=212——每次超时后定时器翻倍(4→8→16→…),连续丢包时等待越来越长。在单客户端场景下它看起来"更慢";它的价值在多客户端场景:如果丢包是服务器过载造成的,大家都退避就能减少重发,给服务器喘息的机会。把丢包率调到 80% 再试,还会看到退避版本在时间上限内都没能完成。
在 RPC 模拟器中勾选"第一次回复在网络中丢失",再取消"服务端按 xid 去重"。预测:服务器上的真实函数被执行几次?如果这个函数是"从账户转出 100 元",会怎样?
看答案
客户端运行时超时后用同一个 xid 重发请求。开启去重时,服务器发现这个 xid 已处理过,直接重发缓存的回复,函数只执行 1 次(至多一次语义)。关闭去重时函数被执行 2 次:对 func1/func2 这种纯计算无所谓,但"转账"这种非幂等操作就会转出 200 元。第 49 章 NFS 会用另一种思路解决这个问题:把操作设计成幂等的。

动手实验

① 原书的 UDP 客户端/服务端(Figure 48.1 + 48.2)。make run 会在后台启动 ./server 1(处理一条消息后退出),然后运行客户端:

cd labs/48-distributed && make && make run

示例输出(本机 WSL2 上的真实运行结果,你的机器上端口、时间等细节可能不同):

./server 1 & sleep 0.3; ./client localhost; wait
server: listening on UDP port 10000
client: sending "hello world" to localhost:10000
server: got "hello world" (1000 bytes) from 127.0.0.1:20000
server: replied (1000 bytes)
client: got reply "goodbye world" (1000 bytes) from 127.0.0.1:10000

注意服务端通过 recvfrom 得知客户端地址是 127.0.0.1:20000,并按这个地址回复。每条消息都是 BUFFER_SIZE = 1000 字节,因为代码直接发送整个缓冲区。

② 在 UDP 上实现可靠通信:reliable.c fork 出接收方进程,发送方在发送前按概率人为丢包,用 poll 睡眠等待 ack 或超时(不空转)。接收方每收到一条存款消息执行 balance += 100。

./reliable -p 0.3 -m 5 -s 1        # 带序列号
./reliable -p 0.3 -m 5 -s 1 -n     # 不带序列号

示例输出(真实运行;时间戳在你的机器上会不同,丢包序列由种子决定):

loss=0.30 timeout=100ms msgs=5 seed=1 seq=on
    0.48 ms  sender:   send msg #1 (seq=1, try 1)
    0.84 ms  receiver: got seq=1 -> deliver, balance=100
    0.94 ms  receiver: send ACK seq=1
    0.95 ms  sender:   got ACK seq=1 -> msg #1 done, delete copy, timer off
    0.97 ms  sender:   send msg #2 (seq=2, try 1)
    0.98 ms  receiver: got seq=2 -> deliver, balance=200
    0.98 ms  receiver: send ACK seq=2  ... LOST in network
  101.10 ms  sender:   TIMEOUT for msg #2 -> retry
  101.16 ms  sender:   send msg #2 (seq=2, try 2)
  101.19 ms  receiver: got seq=2 but expected=3 -> DUPLICATE, discard (still ack)
  101.19 ms  receiver: send ACK seq=2  ... LOST in network
  200.28 ms  sender:   TIMEOUT for msg #2 -> retry
  200.34 ms  sender:   send msg #2 (seq=2, try 3)
  200.36 ms  receiver: got seq=2 but expected=3 -> DUPLICATE, discard (still ack)
  200.39 ms  receiver: send ACK seq=2
  200.39 ms  sender:   got ACK seq=2 -> msg #2 done, delete copy, timer off
  200.40 ms  sender:   send msg #3 (seq=3, try 1)  ... LOST in network
  300.52 ms  sender:   TIMEOUT for msg #3 -> retry
  ...(省略 msg #3~#5)
---- sender summary: 5 messages, 9 transmissions (4 retries), seq numbers ON
---- receiver summary: delivered=5, duplicates discarded=2, acks sent=7
---- final balance = 500 (expected 500) OK

同样的种子加 -n(不用序列号)时,丢包位置完全一样,但结尾变成:

---- sender summary: 5 messages, 9 transmissions (4 retries), seq numbers OFF
---- receiver summary: delivered=7, duplicates discarded=0, acks sent=7
---- final balance = 700 (expected 500) <-- WRONG! duplicate executed

msg #2 的 ack 连丢两次,接收方把两次重传都当新消息执行,余额多了 200。可以试试不同的 -p、-t、-s,对照上面的模拟器理解每一行日志。详见 labs/48-distributed/README.md。

自测题

即使所有链路、路由器和主机都完好无损,为什么网络仍然会丢包?
原书指出更根本的丢包原因是缓冲不足:包必须先放进路由器内存才能处理,太多包同时到达时,唯一的选择就是丢包。端主机被大量消息淹没时也一样。
发送方超时后没有收到 ack,它能确定什么?
从发送方角度看,"请求丢失"和"回复丢失"完全一样(图 48.4 vs 48.5);超时设太短时 ack 甚至可能只是还没到。正因为无法区分,重传就可能造成重复,需要序列号去重。
接收方用序列计数器发现一条重复消息(ID < expected)后,正确的做法是?
必须回 ack:发送方重传正是因为没收到 ack,如果不回,它会一直重传。但不能交给应用,否则就违反了"恰好一次"语义。
为什么序列计数器比"为每条消息生成唯一 ID、接收方记住所有见过的 ID"更好?
原书:跟踪所有见过的 ID "需要无界内存,代价高得难以承受";序列计数器只需要双方各维护一个值,接收方把它当作下一条消息的期望 ID。
分布式共享内存(DSM)没有流行起来,原书给出的最大问题是什么?
原书说 "The largest problem for DSM is how it handles failure"。性能(有的访存要跨网络)是另一个问题。
为什么很多 RPC 包选择建在 UDP 而不是 TCP 之上?
原书:building a request/response protocol on top of a reliable communication layer, two "extra" messages are sent. 代价是 RPC 层要自己承担可靠性:超时/重传、ack、序列号。
关于封送(marshaling),下列说法正确的是?
封送/序列化是客户端存根(以及服务端存根打包结果时)做的事。指针的地址值在另一台机器上没有意义,需要借助 buffer_t 这样的已知类型或注解,把指针指向的数据本身序列化。
按照端到端论证,要实现可靠的文件传输,下列哪种做法才是必需的?
底层机制(TCP、链路校验、RAID)都管不到发送方内存损坏、写盘出错等端点上的问题;只有端到端检查能真正保证。不过底层机制仍可能在性能上有帮助。

常见误区与小结

本章小结