第 48 章 分布式系统简介

分布式系统简介

分布式系统由大量协作的机器组成,共同提供服务(如 Google、Facebook)。构建分布式系统的核心挑战是故障,因为机器、磁盘、网络和软件都会发生故障。然而,故障也带来了机遇:通过组合多台机器,可以构建出极少失效的系统。

通信基础

现代网络的核心原则是通信本质上是不可靠的。数据包丢失或损坏的原因包括:

  • 传输过程中的位翻转(电气干扰)。
  • 网络链路、路由器或端主机损坏。
  • 核心原因:网络交换机、路由器或端主机中的缓冲区不足。当大量数据包同时到达路由器时,内存无法容纳,只能丢弃数据包。

不可靠的通信层

某些应用能够自行处理丢包,因此可以直接使用基础的不可靠消息层(端到端原则)。典型例子是 UDP/IP。进程通过套接字(sockets)API 创建通信端点,发送固定大小的数据报(datagram)。

UDP 不保证数据包到达目的地,也不会通知发送方丢包情况。但它包含校验和(Checksum)以检测数据包损坏。

以下是基于 UDP 构建的简单客户端与服务端代码:

// client code
int main(int argc, char *argv[]) {
    int sd = UDP_Open(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)
        int 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);
        if (rc > 0) {
            char reply[BUFFER_SIZE];
            sprintf(reply, "goodbye world");
            rc = UDP_Write(sd, &addr, reply, BUFFER_SIZE);
        }
    }
    return 0;
}
int UDP_Open(int port) {
    int sd;
    if ((sd = socket(AF_INET, SOCK_DGRAM, 0)) == -1)
        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 in_addr *in_addr;
    struct hostent *host_entry;
    if ((host_entry = gethostbyname(hostname)) == NULL)
        return -1;
    in_addr = (struct in_addr *) host_entry->h_addr;
    addr->sin_addr = *in_addr;
    return 0;
}
 
int UDP_Write(int sd, struct sockaddr_in *addr, char *buffer, int n) {
    int addr_len = sizeof(struct sockaddr_in);
    return sendto(sd, buffer, n, 0, (struct sockaddr *) addr, addr_len);
}
 
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);
}

可靠的通信层

为了在不可靠的网络上构建可靠通信,需要引入处理丢包的新机制。

  • 确认机制(Acknowledgment):接收方收到消息后向发送方返回简短的确认消息(ACK)。
  • 超时与重试(Timeout/Retry):发送方发送消息后启动定时器。如果超时未收到 ACK,则认为消息丢失并重发。
    • 超时时间设置至关重要。在多客户端场景下,服务器丢包可能意味着过载,此时客户端应采用指数退避(Exponential Back-off)策略增加超时时间,避免加剧拥塞。
  • 序列号(Sequence Counter):用于处理 ACK 丢失导致的重复消息问题。
    • 发送方和接收方维护一个计数器 。发送消息时附带 ,发送后递增。
    • 接收方期望收到 。如果收到 ,则确认并传递给应用,计数器递增。如果收到小于当前计数器的消息,说明是重复消息,仅返回 ACK 但不传递给应用。

最常用的可靠通信层是 TCP/IP,其内置了拥塞控制等复杂机制。

通信抽象

分布式共享内存(Distributed Shared Memory)曾是一种通信抽象,它将分布式计算抽象为多线程应用,进程共享虚拟地址空间。它依赖 OS 的虚拟内存系统,缺页时通过网络从其他机器获取页面。由于难以处理机器故障(部分地址空间丢失)且性能不可控,目前已很少使用。

远程过程调用

远程过程调用(RPC)是构建分布式系统的主流抽象。其目标是让执行远程机器上的代码像调用本地函数一样简单。RPC 系统通常包含存根生成器和运行时库两部分。

存根生成器

存根生成器自动打包函数参数和结果,避免手动编写繁琐代码。

客户端存根执行步骤:

  1. 创建消息缓冲区。
  2. 序列化(Marshaling):将函数标识符和参数打包到缓冲区。
  3. 发送消息至 RPC 服务器。
  4. 等待回复(通常是同步调用)。
  5. 反序列化(Unmarshaling):解包返回码和结果。
  6. 返回给调用者。

服务端存根执行步骤:

  1. 反序列化:提取函数标识符和参数。
  2. 调用实际的远程函数。
  3. 序列化:将结果打包到回复缓冲区。
  4. 发送回复给调用者。

为了提高效率,服务器通常使用线程池并发处理 RPC 请求,避免单个阻塞调用浪费服务器资源。

运行时库

运行时库负责处理性能和可靠性问题。

  • 服务定位:客户端需要知道服务器的主机名(或 IP 地址)和端口号。
  • 传输协议选择:基于可靠协议(如 TCP)构建 RPC 会导致性能低下,因为 TCP 的 ACK 和 RPC 的请求/响应机制会产生多余的确认消息。因此,许多 RPC 包基于不可靠协议(如 UDP)构建,由 RPC 层自身通过超时/重试和序列号来实现可靠性保证。
  • 长时间运行的调用:使用显式 ACK 通知客户端请求已收到,客户端定期轮询服务器状态以避免误判为超时。
  • 大参数传递:支持发送方分片(Fragmentation)和接收方重组(Reassembly)。
  • 字节序(Byte Ordering):不同机器的大小端不同。RPC 包(如 XDR)定义标准消息格式,必要时进行转换。
  • 异步调用:允许客户端发送请求后立即返回执行其他任务,稍后再获取结果,从而提升性能。