网络编程与分布式系统

计算机网络

计算机网络诞生之初的目的是在几个大学之间共享数据。这种共享数据的需求在过去的几十年间随着计算机的普及日益旺盛,在今天互联网较为发达的背景下,几乎所有的应用都会利用互联网进行数据传输,或者直接通过互联网调用其它计算机上的进程,因此操作系统需要为实现这些功能提供支持。这一章中,我们将先向你介绍计算机网络的大致背景,然后再结合操作系统讲解分布式存储等与我们之前学到的内容息息相关的知识。
为了方便我们讨论计算机网络,我们需要先理解几个简单的概念。

主机(host) 是计算机网络的两个终端,它既包含了提供数据的 服务器(server) 、又包含了从服务器获得数据的 用户(client) 。所有将主机连接在一起的物理连接都能够构成 网络(network) ,它包括了光缆等多种类的物理连接以及作为“链接中转站”的 路由器(router) 和 交换机(switch) 。网络可以被分为 局域网(Local Area Network,LAN) 和 广域网(Wide Area Network,WAN) 两种。广域网实际上就是将多个局域网连接在一起的网络,互联网就是一个巨大的广域网。在计算机网络中,数据被拆分成 数据包(packet) ,每个数据包都包含了不同的协议为数据包提供的 标头(header) ,其中包含了数据包的来源、目的地等,可以被用于传送数据包和将数据包恢复为完整的数据。一个数据包在网络中由一个主机传送至另一个主机的过程被称为 路由(routing) ,路由的过程包含了从一个路由器传送至另一个路由器或由一个交换机传送至另一个交换机的中间过程,这种中间过程被称为 转发(forwarding) 。

  1. 为了理解计算机网络的设计,我们首先要明白计算机网络面临的基本问题。

网络面临的第一大问题就是如何在一个如互联网一般规模庞大的网络中将一些数据包成功传送到指定的主机。在一个规模较小的图中,我们可以用 Dijkstra 算法等简单的算法找到从一个定点到另一个顶点的最短路,但在互联网这样一个巨大的网络中,每个路由器不可能获得全局信息,因此我们需要一种即使在不能获得全部信息的前提下仍然能够生效的路由方法。不仅如此,由于各个国家和地区的网络由不同的互联网服务提供商(Internet Service Provider,ISP)代理,每个 ISP 都可以在其代理的范围内自主地选择传送数据包的路径,因此在国际范围内实现一个可靠的计算机网络就变得更加困难。

网络面临的第二大问题就是如何在路由过程中出现错误时仍能够将数据成功送达目的地。须知,在网络中,每一段物理连接、每一个路由器都可能出现故障;即使链接没有故障,数据包在传输的过程中也可能会出现数据错误的情况,而一个合理的设计应当能够使得数据包在存在故障的前提下仍然能够完整地抵达目的地、提供正确的数据。

除了上述的问题以外,网络还面临着 拥塞(congestion) 的问题。如果同一时间有很多数据包被传送到同一个路由器处,那么这个路由器的缓冲区很可能会被装满,这时如果再有数据包被传送到这里就会出现丢失的情况。为了避免这种情况,网络设计需要实现 拥塞控制(congestion control) 。我们会在讲解 TCP 时具体地讨论如何实现拥塞控制

在解决上面提出的问题的同时,我们还需要考虑到,由于我们的解决方案是针对整个网络的,它必须易于维护,且能够方便网络工程师向网络的设计中加入新的实现方式、实现不同种类的服务;这就要求我们设计的界面拥有较强的变通性。我们设计的解决方案为工程师提供的应该是所有服务都需要的基本功能和界面,工程师可以基于这些功能和界面来实现更为复杂的服务,供应用开发者使用

针对上述的问题,计算机网络的先驱提出的解决方案囊括了三条计算机网络的设计原则:分层(layering)、端到端(End-to- End,E2E)与命运共享(fate-sharing)

分层原则是由 模块化(modularity) 这一概念在网络中的应用产生的。
网络自下而上被划分为五层:物理层(physical layer),数据连接层(data link layer),网络层(network layer),传输层(transport layer)和应用层(application layer)。每一层的协议都认为它们在与同一层的协议交流;除底层的物理层外,每一层都基于自己的下一层实现自己的功能,无需估计其它层面,这大大简便了每一层的实现难度。不仅如此,这五个层级也可以被看作是五个抽象层;由于每个抽象层的界面不变,在每个抽象层中添加新的实现方式或修改现有的实现方式在不改变界面的情况下都不会影响剩余的抽象层,这使得维护网络的整体结构变得非常简便。在下一节中我们会更详细地讲解每一个层级负责实现的功能。

**端到端原则涉及到的就是我们前面提到的网络设计应当只提供基本功能、易于维护的特点。**它的基本思想是,如果一个功能需要被在主机中实现,那么就没有必要在网络中实现它,这样网络所保有的功能就只是最基本的功能。在后面的学习中,你会看到,在传输层中实现数据可靠性的检查就是端到端原则的应用。

命运共享原则涉及到的是在何处存储网络的状态;它的思想是,网络存储状态的方式应当只可能在其状态涉及到的实体(如:一段物理连接)失效时失去有关该状态的信息。这条原则主要与路由的过程有关;由于路由与操作系统的关联性不大,我们在这一课程中不会重点讲解这一原则,有兴趣的同学可以通过我们未来推出的网络课程了解这一原则。

网络的分层

上一节中我们已经讲到了网络的五层模型:网络自下而上被分为物理层、数据连接层、网络层、传输层、应用层。这一节中我们就来具体地看一看每一层在网络中扮演的角色。
在这里插入图片描述
上图表示的是网络分层结构,并在每一层中都给出了一些协议作为例子。我们可以看到,所有层级中只有网络层只包含了一个协议,也就是 IP 协议。之所以我们需要这样一个瓶颈,是因为如果没有一个能够统一所有服务的协议、我们就无法将所有网络连接在一起,这正是网络层的意义。

在五层模型中,你可以以网络层为界,将五层分为上下两半。上一半被用来实现应用所需的数据共享功能,而下一半则用来实现网络中数据的传输。

上一节中我们提到了网络设计三大基本原则之一的端到端原则,其基本思想是网络设计只需要支持最基本的功能,也就是路由和转发,因此网络层与其下面的数据连接层、物理层只负责实现路由和转发。IP 作为网络层唯一的协议对于大家是比较熟悉的,IP 地址能够被用来在全球范围内确定一个主机的位置;路由器就是利用 IP 地址选择合适的链接传送数据的。数据连接层与 IP 层不同,只能被用于一个不需要路由器就能够被连接的子网中,不同的链接之间由交换机连接,交换机根据每个网络设备唯一的 MAC 地址传送数据包。

鉴于网络层及其以下的层面只被用来实现路由和转发,它们不能解决链接错误、数据错误带来的可靠性的问题;并且在一台主机上有多个进程同时使用网络时,网络层也不能够做到将数据包派送给需要该数据的应用。因此传输层就被用来实现这两个功能。

需要注意的是,并不是所有的应用都需要百分之一百的可靠性——一个协议想要在一个不可靠的网络上实现可靠的数据传输就需要重复传输丢失或错误的数据包,这势必会使得应用获取数据的速度下降。UDP 就是一个不保证可靠性的传输层协议,它一般被用于实现视频聊天等时效性较重的应用 TCP 是一个可靠的数据传输协议,在后面的章节中我们会具体的讲到它们的实现方法。

基于传输层实现的是应用层。应用层的目的是根据不同的服务需求提供具体的服务,它包含了我们熟悉的 HTTP,SSH,FTP 等协议。这一层中,数据包的概念已经不再适用;应用将数据看作是一段连续的字节,可以被完整地发送和接收。为了支持这一抽象,传输层需要将数据包合为一段连续的数据,提供给应用层。本章中我们还会讲到这一层中的 RPC 协议;上一章的结尾我们提到了远程文件系统正是通过 RPC 协议实现的,我们在后面的课节里会重新回顾这个主题。

传输层详解

前几节中我们已经向你介绍了网络的分层和各个层级的功能;就像我们前面提到的那样,网络层、数据连接层和物理层负责的是网络中数据的传输,而传输层和应用层则负责向需要数据的应用提供服务;由于操作系统的作用之一也是为用户程序提供服务,在这门课程中,我们主要关注的是后者。这一节中我们就来看一看两个非常常见的传输层协议,UDP 与 TCP。

我们已经知道,网络架构中每个层级都基于它的下一个层级实现,由于网络层只有 IP 一个协议,UDP 和 TCP 都是基于 IP 实现的;你可以假设 IP 已经实现了找到数据包从一个 IP 地址传送到另一个 IP 地址需要的路径,但它不能保证数据包在传送过程中不被丢失或其内容不产生错误,它也无法将数据包直接传送给需要数据的应用,因此一个传输层协议的标头必须包含这些内容。在学习 UDP 和 TCP 的标头实际包含的内容以前,让我们先来预测一下它们的标头应该包含什么。

首先,为了识别数据包应该被呈递给哪个进程,我们需要一个识别进程的方法,这个方法就是 端口(port) 。端口可以有两种不同的含义:在我们探讨路由器和交换机如何通过 IP/MAC 地址找到合适的端口发送数据包时,端口代指的是物理端口;在操作系统中,我们所说的端口是虚拟端口,它的作用是区分不同的进程在网络上开启的链接。一个端口由一个 16 位的数字表示,因此我们总共可以有2^{16} = 65536 个端口。这些端口中, 0−1023 端口是所谓的 知名端口**(well-known ports)** ,它们由 ICANN(The Internet Corporation for Assigned Names and Numbers,互联网名称与数字地址分配机构) 分配给不同的协议;在利用这些协议建立连接时,必须使用这一端口。一些常见的例子包括端口 22 和端口 80,它们分别被预留为 SSH 和 HTTP 协议的端口。

在一个进程需要使用网络连接时,它会调用我们下一节中即将讲到的 socket,获得一个端口;这样所有属于这个连接的数据包标头中都会包含这个端口号,传输层协议就可以通过这个端口号来唯一确定哪些数据包属于这个连接了。因此,在 TCP 和 UDP 标头中,我们需要加入连接两端的主机使用的端口号。

除了端口号以外,我们还需要一种方法来帮助我们将数据包恢复成连续的数据,因为应用层不应该需要处理像数据包这样复杂的界面。为了能够将数据包恢复为数据,我们首先需要知道哪些数据包属于同一段数据,其次需要知道它们在这段数据里的位置、以便于我们将它们重新按顺序排列起来。(注意,由于网络是不可靠的,在传输过程中数据包的先后顺序不能被保证,所以我们不能直接按接受顺序将数据包串联在一起)

为了实现这个目的,我们可以有两种办法:**要么我们每次只发送一个数据包,这样就不存在排序问题;要么我么需要在每个数据包的标头中包含唯一识别这个数据包属于哪段数据的信息和它在这段数据中的位置信息。**在后一种方法中,我们还面临着另一个问题,那就是在一系列数据包中的一个未能到达目的地时,我们该如何保证这个数据包一定会被重新传输直到它到达目的地。在具体讲解 TCP 的章节,我们会看到 TCP 是如何处理这个问题的。

最后,如果一个应用需要可靠的数据传输,我们还需要在传输层检查数据是否出现错误。

校验和(checksum) 可以被用来检查数据在传输过程中是否产生了错误。校验和通过将数据包的数据输入一个函数,检查函数的输出值与数据包标头中包含的校验和是否相同;如果不同,则证明数据包在传输过程中出现了错误。一个好的校验函数可以做到即使输入值的变化量很小,输出值也将变得完全不同,且两个不同的输入值一般不会产生同一个输出值、一般也很难根据输出值找到两个不同的输入值,因此使用一个好的校验函数基本可以保证数据包的错误能够被检测到。

UDP 和 TCP

UDP 和 TCP 这两个最为常见的传输层协议是如何实现其功能的。

UDP

UDP 全称为 User Datagram Protocol,即用户数据报协议。UDP 协议的标头只包含了发送方和接收方的端口号、数据长度、以及非强制的校验和。由于 UDP 的标头中不包含唯一确定数据包所属的数据流信息和数据包在数据流中的位置,UDP 协议一次只能发送一个数据包。不仅如此,它只保证校验和不符的数据包会被丢弃,而不保证没有传送到的数据包会被重新传送,因此它不能保证可靠性。

与 TCP 相比,UDP 的优势在于,它不需要在系统中保存链接的状态,且建立连接速度快、标头所占空间小(只有 \ 8 8 字节),因此像视频聊天、游戏等时效性很强且能够处理数据丢失的应用就可能使用 UDP 协议进行传输。除此以外,用于通过主机名称获得 IP 地址的 DNS 和用于在加入网络时获得地址的 DHCP 使用的也都是 UDP,这是因为这些协议需要传输的内容本来就很短,如果花费很多时间在建立连接上,效率反而会降低。

TCP

TCP 不仅可以将数据拆分成多个数据包可靠地传输到目的地,而且还能控制网络拥塞。

前面我们已经提到,UDP 相对于 TCP 的优点在于它建立连接所花费的时间短、且无需在系统中储存连接的状态,这两点正是保证 TCP 的可靠性的关键因素。简单地说,TCP 在传输一段数据时会先建立一个连接,然后将数据拆分为数据包、逐个发送;在所有数据包都确认被收到以前,TCP 会一直保存这个连接的状态,然后在双方都传送完所有数据后 TCP 会发送结束连接的请求,在双方都确认结束连接后连接才会被关闭。

下图表示的是 TCP 数据包的结构,其中绿色背景的部分是 TCP 标头:
在这里插入图片描述
它的前两个字段,发送端口、接收端口,和后面的校验和是与 UDP 相同的。与 UDP 明显不同的是序列号与窗口大小这两个字段,我们接下来就重点来讲解着两个字段的作用。我们前面已经提到,TCP 可以将数据拆分后发送,并在另一端重新组装起来,这就要求我们有某种办法知道每个数据包属于哪个数据流、在数据流的什么位置。这就是序列号的作用。

在 TCP 建立连接时,第一个发出的数据包是 SYN 数据包,它被用来使得对方得知自己的起始序列号。注意,起始序列号是随机的!这是因为同一个 IP 地址下的同一个端口可能被重复利用,那么上一次连接留存下来的数据包可能还在网络中没有到达,这时候如果使用随机的起始序列号,那么两次连接的数据包就不会被混在一起。为了表明第一个数据包是 SYN 数据包,数据包中的标志字段会标明数据包的类型,这个类型可以包括 SYN,ACK,FIN 等六种类型,我们在后面会讲到其它类型的数据包。

接收到 SYN 数据包的主机会回复一个 SYNACK 数据包,其中既包含了自己的起始序列号,也包含了对方的起始序列号加 1,后者被存储在确认号字段里,表示对方可以开始向自己发送数据。

连接的发起者在收到 SYNACK 数据包后会回复一个 ACK 数据包,将对方的起始序列号加 1 存在确认号字段里,表示对方可以向自己发送数据,这样连接建立的过程才算完成。由于这个过程涉及到三个数据包(SYN + SYNACK + ACK)的交换,它被形象地称为 “三次握手”(Three-way Handshake)

在连接开始后,双方就开始给对方发送数据。发送数据时,TCP 使用的是基于窗口的发送方法。窗口指的是一个以数据包数量来衡量大小固定的区间。假设窗口大小为 w,TCP 在发送数据包时,一次只允许 w 个数据包处于传输过程中。如果接受方已经接受到某个数据包,那么它就会回复一个 ACK 数据包,表示已经接收到了这个数据包,但是这个 ACK 承认的并不是这个数据包本身,而是所有到目前为止接受到的数据包中由起始数据包开始序号连续的数据包的最后一个序号加 1。例如,如果接受方收到了 1,2, 4, 3 这个序列,那么在数据包 2 到达时,ACK 会回复 2+1=3,当数据包 4 到达时,ACK 仍然会回复 3,因为 4 与之前的数据包不连续。当 3 到达时,所有到达的数据包组成了一个连续的段,其最终的序号是 4,因此 ACK 会回复5。这种 ACK 被称为 累计确认(cumulative ACK) 。

需要注意的是,TCP 发送的数据包实际不是被编号的;它们的序列号等于起始序列号与这个数据包在数据中的偏移字节数之和。例如,如果每个数据包包含了1400 字节的内容,那么第二个数据包的序列号会是第一个数据包的序列号加1400。

ACK 在 TCP 中既保证了可靠传输、又帮助 TCP 控制窗口大小、避免网络拥塞。

**TCP 传输的过程中,系统会设定一个计时器,它在每次有新的数据包被承认时会被重置。**如果计时器超时,那么最新的 ACK 序列号代表的数据包就会被重新发送,超时时限会变为原来的 2 倍,窗口的大小也会随之减小为原来的一半。随着每个数据包被承认,窗口都会向前移动;在成功传输一个窗口的内容后,窗口大小会加 1,这样我们就可以在充分利用带宽的同时保证不使网络过载。

**数据传输完毕后,TCP 连接的双方还会经过一个“四次挥手”的过程来结束一端连接。**这一过程包含了 FIN,ACK,FIN,ACK 四个数据包。之所以在一方发送 FIN 后另一方不能直接回复 FINACK,是因为这一方的数据可能还没有发送完毕。注意,在最后一个 ACK 被收到后,ACK 的发送方会等待一段时间再清除自己有关这个连接的数据,你明白这是为什么吗?(Hint:如果最后一个 ACK 数据包丢失了会怎么样?)

socket

传输层之上就是应用层,但是在应用层和传输层之间,操作系统提供了一个中间的抽象层,那就是 套接字(socket) 。我们知道在网络的分层模型中每一层的协议都建立在它们只与同层的协议沟通的抽象上;socket 也是这样一个抽象,它就像一个建立在网络上的双向管道,管道两端的用户都可以直接向对方发送信息或接受对方的信息。一个套接字是由五个参数决定的:socket 两端的 IP 地址、端口号和这个 socket 使用的传输层协议。

在 UNIX 系统中,socket 的界面和文件的界面是一样的,在打开一个 socket 时我们获得的是一个文件描述符,因此我们可以针对这个文件描述符调用read()、write()这些函数从 socket 中读取或向 socket 中写入数据。

上面我们已经提到 socket 由五个参数决定,因此在建立一个 socket 进行交流时,我们也需要代入这些参数。需要注意的是,在服务器- 用户模型中,服务器、用户两端建立 socket 的步骤是有所不同的。我们前面已经提到过,服务器使用的端口应该是众所周知的,比如 HTTP 协议的服务器随时都应该在 80 号端口等待连接;与服务器不同,用户在使用 HTTP 与服务器交流时不需要、也不能使用 80 号端口。用户只能使用 1023 号以上的端口,这一端口一般由操作系统随机选择。

由于这两者不同,我们在建立 socket 时所需的过程也有所不同。

在服务器端,我们在建立一个 socket 后应该调用bind()将这个 socket 与一个端口连接起来,之后调用listen()在这个端口等待连接。这时我们可以调用accept()函数,等到新的连接到来时,将新建一个 socket 进行通信,并用原来的 socket 继续等待连接。accept()的默认模式是阻塞的,因此我们可以在一个循环里不断地调用这个函数,等待新的连接。

在用户端,我们在建立一个 socket 后,只需要将这个 socket 对应的文件描述符和包含了服务器 IP 地址和端口的struct sock_addr_in结构作为参数调用connect(),就可以将这个 socket 与指定的主机连接。

假如我们已经在host_name这一变量中存储了服务器的名字,并在portno中存储了我们需要使用的协议对应的端口号,那么下面的代码是一个用户需要的基本的代码:

int socketfd;
struct sock_addr_in server_addr;
struct hostent *server;

socketfd = socket(AF_INET, SOCK_STREAM, 0);
server = gethostbyname(host_name);
bzero((char *) &serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
bcopy((char *)server->h_addr,
      (char *)&serv_addr.sin_addr.s_addr,
      server->h_length);
serv_addr.sin_port = htons(portno);

int result = connect(socketfd,(struct sockaddr *) &serv_addr,sizeof(serv_addr));
if (result<0) {
  printf("Error!");
  return -1;
}
/* 从这里开始我们就可以用 read() 和 write() 读写 socket 中传输的内容 */ 

在建立 socket 时,我们使用了 AF_INET 和 SOCK_STREAM 这两个参数。AF_INET 表示这个 socket 使用的是 IPv4 协议;**SOCK_STREAM 表示这个 socket 能够提供一个可靠的双向字节流,**正是这个抽象允许我们像使用管道一样使用这个 socket。

我们通过gethostbyname()这个方法根据服务器的名字获得了它的 IP 地址(这一过程是通过 DNS 实现的,有兴趣的同学可以自己查找相关的资料),然后将这一地址和我们使用的端口号储存到server_addr中,再将这个结构作为输入值作为参数代入到connect()中,我们就可以与服务器连接了。

与用户相对应的,一个基本的服务器至少需要如下的代码:

int sockfd;
struct sock_addr_in server_addr;

sockfd = socket(AF_INET, SOCK_STREAM, 0);
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(portno);
int result = bind(sockfd, (struct sockaddr *) &serv_addr,
              sizeof(serv_addr));
if (result<0) {
  printf("Error!");
  return -1;
}
listen(sockfd, 5);
while (true) {
  new_sock = accept(sockfd, NULL, NULL);
  /* 此处我们可以建立一个新的线程来处理这个新的连接 */
}

在服务器端,我们输入bind()的参数包含的sin_addr.s_addr是INADDR_ANY,这是因为服务器应该接受来自所有地址的连接请求。

socket 对于其中传送的内容没有限制,因此它本身并不受一个应用层协议的限制;你可以用 socket 实现包括 HTTP 在内的很多协议。从下一节开始我们就将进入应用层,学习一个被用来实现分布式系统的重要的应用层协议,RPC。

RPC 与 分布式文件系统

上一章中我们讲到,VFS 这一抽象层允许我们将分布式文件系统嵌入到本地的文件系统中,这一过程实际是由 RPC 这个应用层协议实现的。这一节中,我们就来了解一下 RPC 协议。RPC 的全称是 远程程序调用(Remote Procedure Call) ,通过这一协议一台主机上的程序可以调用另一台主机上的程序提供的服务。例如,在文件系统中,我们可以利用 RPC 向另一台主机的文件系统发送read或write命令,读写另一台主机中存储的文件。

RPC 需要解决的问题有两个:一方面,由于 RPC 允许程序完全透明地调用远程的服务,程序可以将指向本机中某一位置的指针作为函数的参数代入,但是这个指针所指的位置在目标的主机上完全是不同的东西,因此我们需要找到这一指针所指的内容、将其作为远程服务的参数代入;另一方面,远程的主机可能基于不同的操作系统或物理结构实现,因此可能使用与本地主机不同的编码方式,在这种情况下,我们就需要一种“中间形式”来表达信息,使得远程主机可以识别这一内容。
为了实现上述的功能,RPC 提供了一种叫做 存根(stub) 的服务。为了实现 RPC 请求,发送者与接受者两端都必须拥有存根。发送端的存根负责将一个程序发出的 RPC 请求中所有参数变为与本机的地址空间无关的内容,并将整个请求翻译成与物理结构无关的 IDL(Interface Description Language),发送给接收端;接收端的存根会将 IDL 重新翻译成符合本机环境的语言,供接收端程序使用。接收端程序在运行完指定的命令后可以通过同样的过程将输出值发送给请求方。

RPC 和存根的概念;在实际应用中,它们在不同的语言和操作系统中有很多不同的实现方法,

RPC 实现的分布式文件系统是如何运行

分布式文件系统采用 VFS 的界面,因此可以被挂载到本地的系统中。每次读写操作会在用户端先经过 VFS 层再到达 NFS,而在服务器端则正相反——NFS 层会收到用户请求,将其通过 VFS 转化为一个普通的本地文件系统操作来执行。

与本地文件系统相比,分布式文件系统的一大劣势是,分布式文件系统的读写操作都要通过网络实现,速度远慢于直接读取磁盘上的信息。因此一个合理的分布式文件系统设计应当能够尽可能减少这一问题对文件系统速率造成的影响。解决这一问题的办法和解决本地磁盘与内存读取速度差异的办法是一样的,也就是在用户端设置一个专门用来存储分布式文件系统文件的高速缓冲存储器。每个用户端在请求打开一个文件时都会将文件存储到本地的高速缓存中。但这种做法也有一个弊端,那就是在 write-back 的高速缓存中,不同的用户端可能针对同一个文件作出修改而不知道其它用户也做出了修改,导致最终只有一个用户的修改能够出现在分布式文件系统服务器的磁盘上。不仅如此,如果服务器崩溃,那么存储在服务器内存中的数据都会丢失,如果丢失数据中包括了用户的状态这一状态就会丢失。为了解决第一个问题,我们可以使用一个 write-through 高速缓存,即每一次write操作都会被直接写入服务器的高速缓存,且通知所有打开了该文件的用户该文件已被修改;为了解决后一个问题,我们可以使用一个不存储状态的文件系统。这个文件系统就是 网络文件系统(Network File System,NFS)

NFS 中服务器是不在内存中保存状态的;所有文件操作都必须包含其所需要的所有信息,也就是说我们不能再使用seek()将指针移动到某一位置后再发送read()请求,而需要在read()请求中直接包含开始读取的位置。每个用户端都有自己的 write-through 高速缓存,用于存储最近读写的文件;每一个write()操作都会在服务器磁盘上的数据已经被修改后再返回,因此write()操作相对较慢。为了保持用户端高速缓存与服务器的数据同步,所有用户端都需周期性地检查服务器是否有数据更新,这一步骤也可能成为这一文件系统的瓶颈。不仅如此,由于服务器与用户端数据更新不同步,可能出现多个用户端修改同一文件后只有一个版本被保留下来的结果。

分布式系统的可靠性

两种分布式文件系统 NFS 和 AFS
。通过分析这两种文件系统我们可以看出,在分布式文件系统中可能存在很多可靠性的问题。服务器崩溃、磁盘崩溃等问题都可能造成文件系统的数据损失。磁盘崩溃这一问题可以通过储存多个不会同时出错的备份来解决,但服务器崩溃导致的状态丢失、操作中断的问题就比较复杂了。假如一个文件系统的服务器在执行用户请求的过程中出现崩溃,而用户无法得知这一事实,用户就会按照请求已成功执行来运行,这时系统中就会出现问题;更糟糕的是,如果系统在写入数据的过程中崩溃,系统中就可能出现被部分修改的数据,这一部分数据就无法再被正常使用。

为了避免这些问题,我们需要一种机制帮助我们记录全部已经完成的操作和正在进行的操作,以便于我们在重启后可以恢复到崩溃前的状态。为了了解这种机制,我们首先需要理解一个概念:事务(transaction)

事务指的是一系列具有 原子性(atomicity,又可译作不可分割性) 的对于文件的读写操作,它将文件系统从一个 一致的状态(consistent state) 带入另一个一致的状态。所谓一致的状态指的就是文件系统中所有文件的数据都是完整的(数据的完整性被称为 data integrity),上面提到的部分修改的情况就将文件系统带入了一个不一致的状态,因此不能被认为是一个事务。

一个事务与另一个事务必须被分开执行,因此同一个系统上不能同时有两个事务在进行。最后,事务的执行一旦 提交(commit) 就会保留在系统中,不被系统崩溃影响。

一个可靠的文件系统,应该将每一次单独的、对于文件系统的修改都看作是一个事务;文件系统中利用系统日志将每一个事务需要进行的操作 先 记录下来,然后 commit,将日志写入磁盘,commit 完成后再开始实际执行这个事务所需要的操作。这样的操作顺序能够保证,如果我们在 commit 以前出现了系统崩溃,那么这时操作还没有对磁盘进行修改,我们可以直接将日志中 commit 以前的部分丢弃;如果在 commit 以后操作完成前出现了系统崩溃,那么我们就可以重新进行这一操作,只要操作具有 幂等(idempotent) 的性质,即多次执行同一操作与只执行一次该操作的效果相同,那么重新进行这一操作就不会对正确性产生影响。为了保证操作具有幂等的性质,我们记录在日志中的操作不能使用文件指针,因为文件指针在一次操作后就会被移动,下一次重复操作时就会产生不同的效果;我们的操作应该直接针对文件中的 inode,这样在同一个 inode 上重复写入两次数据就不会产生不同的效果。

每次系统崩溃恢复后,系统可以读取日志,重新执行日志中所有已经 commit 的操作,然后清空日志(否则日志在系统中可能会占据很大的空间)。

在分布式系统中,我们同样可以使用这种方法,使一个分布式系统中的多台主机达成一致,只不过我们日志的范围不再是一台主机,而是所有主机的状态。两阶段提交协议(Two Phase Commit,2PC) 是一个被用于在分布式系统中实现原子性操作的协议。这一协议要求在一个分布式系统中存在一个协调者和多个工人,在系统将被修改时,协调者向所有系统中的工人发送 vote request,收到信息的工人会回复 vote commit 或 vote abort,并将自己投票的结果记录在自己的日志中。如果所有工人在指定时间内都回复 vote commit,那么协调者就会发送 global commit 信息,此时所有工人都会同时更新其数据,并将更新的操作写入本机的日志中;如果有至少一个工人不回复,或回复 abort,那么协调者就会向所有主机发送 global abort,数据就不会被更新,abort 的结果也会被写入每个工人中。在工人更新数据后,它们会向协调者发送确认信息,这时协调者才可以结束此次事务。
下图表示的是 2PC 中协调者和工人的状态变化关系:
在这里插入图片描述
在这里插入图片描述
在这一模型下,如果一个工人在投票前崩溃,那么等待 vote 信息的协调者就会直接发送 global abort,这样就不会发生一台机器上的数据未被更新的情况;如果一个工人在投票后崩溃,它可以在重新上线后查看自己的日志,假如它发送的是 abort,那么我们可以肯定协调者发送的是 global abort,我们就可以将 abort 写入自己的日志;如果它发送的是 commit,它就可以向协调者或其它工人发送信息,查看之前的投票结果,再根据结果更新自己的日志和数据。

如果协调者在发送 vote request 前就出现问题,那么等待 vote request 的工人可能在超时后回复 vote abort,协调者在重新上线后可以处理这一情况。如果协调者在工人投票后未及发送 global 信息就产生错误、崩溃,那么等待 global 信息的工人就必须阻塞(block)直至协调者重新上线、发送 global abort 信息。由于协调者可能需要很长时间才能重新上线,这样的模型可能导致所有工人都浪费很多时间,这是这一模型的缺点。

Kay-Value 存储

数据库中非常常用的存储方法, Key-Value 存储(Key-Value Storage) 。 这种存储方法又被称为 分布式哈希表(distributed hash table) ,其主要思想是将所有的 键(key) 按照一定方法分别存放在多台机器上,这些机器将共同构成一个分布式数据库。

Key-Value 存储需要实现的基本功能是,在被给予一个已经存在于数据库中的键时,它能够快速返回键对应的 值(value) ;在被给予一个 键值对(key-value pair) 的时候它能够迅速地将这组数据存储到合适的位置。除了这两个基本功能以外,Key-Value 存储面临着很多挑战。一个好的设计应该允许数据库在一台或多台机器出错崩溃时仍然正常运行,保持数据的一致性,且数据库的管理者应该很容易将一台新的机器加入到现有的系统中。

为了能在多台机器中存储键值对,我们势必需要用一个服务器存储键值对与不同机器之间的对应关系。在通过服务器向存储数据的机器存储数据或从存储数据的机器获得数据时,我们可以采用两种方法:递归法与迭代法

递归法指的是服务器会成为用户与存储数据的机器之间的中转站——用户不需要知道它在向哪一台机器存储数据,因为所有的数据都是通过服务器进行传输的。迭代法则与这一方法相反——在我们需要获得或存储数据时,我们将键发送给服务器,服务器会返回这个键对应的机器,这时用户就可以利用这个返回值自行与这个键所在的机器联系。

递归法与迭代法各有利弊——递归法一个明显的缺点是所有的操作都要由服务器完成,因此服务器会成为一个瓶颈,限制系统的可扩展性。迭代法虽然能够减轻服务器的负担,但它的速度较慢,因为用户与存储数据的机器的距离一般远大于服务器与这些机器的距离。不仅如此,在迭代法中,由于并不是所有的操作都经过服务器,我们很难保证数据的一致性。

在实现基本功能的基础上,我们还需要保证系统在一些机器发生错误的情况下仍然能够正确运行,因此我们需要在多台机器上存储同一组键值对。在存储多对键值对时,我们就需要解决数据在多个拷贝之间保持一致的问题。我们假设所有存储数据的机器只有一种错误模式,也就是丢失所有数据。因此我们不需要考虑数据被污染、产生错误数据的情况,而只需要保证在有机器丢失数据的情况下我们仍然能够获得数据。

为了实现这个目的,我们可以采取如下的方法:我们将数据备份在 c 个机器上,每次在存储一个值时,都向 c 个机器中存储这个值,等待每个机器回复确认,如果其中有一个机器在时间限制内仍没有回复,就选择一个新的机器存储这个值。在读取数值时,我们试图从这 c 个机器上读取数值,只要其中有一个回复我们就可以获取这个值。这种方法显然有一个问题,那就是每次存储数值都很慢。为了解决这个问题,我们可以做出如下的改进:每次写入数据时,我们都只等待 w 个机器回复确认,每次阅读时,我们都从r 个备份机器处读取数据,只要 w+r>c,我们就至少能从一个机器中读取到正确的数据。

超文本传输协议

基于超文本传输协议(HTTP)的 Web 服务器
超文本传输协议(HyperText Transfer Protocol, HTTP) 是一个运行在 TCP 协议之上、用来在服务器和客户之间传输信息的协议,并且可能是现在使用最为广泛的传输协议。HTTP 是一个请求-响应协议,即客户端向 HTTP 服务器发送请求,服务器按照 HTTP 的约定给出对应的响应消息。

浏览器通过和服务器机器上的 \ 80 80 端口建立 TCP 连接来进行数据传输。因为 HTTP 建立在 TCP 的基础之上,故而 HTTP 是一个应用层协议,正因如此,在请求和响应 HTTP 数据时无需考虑消息过长、可靠性等问题,这些都由 TCP 协议负责处理了。

请求格式

在发送 HTTP 请求时,需要在最一开始加上 标头(header) 。请求的标头 ASCII 码的形式给出,下面这段就是 Chrome 浏览器一个请求的标头:

GET / HTTP/1.1\r\n
Host: www.baidu.com\r\n
Connection: keep-alive\r\n
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36\r\n
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\r\n
Accept-Encoding: gzip, deflate, sdch, br\r\n
Accept-Language: zh-CN,zh;q=0.8\r\n
\r\n
GET

其中,第一行的 GET 表示请求的 方法(method) ,第一个/表示请求的内容是/,HTTP/1.1表示协议版本号。常见的方法有 GET、POST、PUT、DELETE、OPTIONS 等。方法名区分大小写,因此 get 不是一个合法的方法。接下来,我们着重学习两个最常用的方法:GET 和 POST。
GET 方法,顾名思义,是向服务器获取数据的一类请求。一个更具有参考性的 GET 请求第一行内容如下:

GET /jsk.php?user=suantou&action=login HTTP/1.1\r\n 
POST

除了 GET 方法,还有一类常用的请求方法——POST——是一类向服务器提交数据的请求。如下是一个 POST 请求完整数据的例子:

POST /jsk.php HTTP/1.1\r\n
Host: hello.world\r\n
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US; rv:1.9.1.5) Gecko/20091102 Firefox/3.5.5 (.NET CLR 3.5.30729)\r\n
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\n
Accept-Language: en-us,en;q=0.5\r\n
Accept-Encoding: gzip,deflate\r\n
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7\r\n
Keep-Alive: 300\r\n
Connection: keep-alive\r\n
Referer: http://hello.world/jsk.php\r\n
Content-Type: application/x-www-form-urlencoded\r\n
Content-Length: 25\r\n
\r\n
user=suantou&action=login

从中可以很明显地看出来 POST 请求和 GET 请求格式的区别。POST 请求不再将请求的具体内容写在标头第一行,而是写在了请求标头之后。注意,在 HTTP 的各种请求中,换行都是用\r\n来表示,并且用\r\n\r\n来分隔标头部分和请求正文(body)。

请求内容的长度通过Content-Length进行限定,在这个请求中,正文为 25 个字节。除了Content- Length,标头中还有很多参数信息,在下一页中会列出几个标头中常见的参数及其意义。

标头参数
  • User-Agent
    • 有关浏览器(或其他 HTTP 客户端)的信息
    • Accept
  • 浏览器可处理的页面类型
    • Accept-Language
    • 浏览器可处理的自然语言
    • Accept-Charset
    • 浏览器可接受的字符集
    • Host
    • 服务器的 DNS 名称
    • Referer
    • 发出请求前的 URL
    • Connection
    • 标识是否需要持久连接,keep-alive 表示请求是 HTTP1.1,默认进行持久连接
    • Keep-Alive
    • 显示此次 HTTP 连接的 keep-alive 时间,在此期间内,连接不会断开,以避免不断重新建立连接
    • Content-Type
    • 请求或响应的 MIME(Multipurpose Internet Mail Extensions,用于描述消息内容类型的标准)类型,比如 application/x-www-form-urlencoded 表示表单数据,text/html 表示 html 页面数据
    • Content-Length
    • 请求或响应的正文长度,单位是字节
响应格式

你可以在你的 Linux 系统或 macOS 系统下执行wget -S www.baidu.com命令,就能看到 HTTP 响应的标头信息:

HTTP/1.1 200 OK\r\n
Server: bfe/1.0.8.18\r\n
Date: Mon, 23 Jan 2017 12:48:56 GMT\r\n
Content-Type: text/html\r\n
Content-Length: 2381\r\n
Last-Modified: Mon, 25 Jul 2016 11:11:40 GMT\r\n
Connection: Keep-Alive\r\n
ETag: "5795f3ec-94d"\r\n
Cache-Control: private, no-cache, no-store, proxy-revalidate, no-transform\r\n
Pragma: no-cache\r\n
Set-Cookie: BDORZ=27315; max-age=86400; domain=.baidu.com; path=/\r\n
Accept-Ranges: bytes\r\n
\r\n

和请求的格式略有不同,在第一行的 HTTP/1.1 之后会有一个数字 200,这个数字就是 HTTP 状态码,200 表示“请求成功”。除了 200 之外,还有很多常见的状态码:

  • 301:请求的页面永久移动或跳转到了新的位置
  • 403:服务器拒绝请求
  • 404:服务器找不到请求的页面
  • 500:服务器内部错误
  • 501:服务器尚不具备完成请求的功能
    所有状态码都是三位数字,根据第一个数字将状态码分为了 5 类。以 1 开头的状态码很少使用,以 2 开头的状态码表示请求成功,以 3 开头的状态码表示重定向,以 4 开头的状态码表示客户端发生的错误,而以 5 开头的状态码表示服务器发生的错误。
浏览器打开页面过程

了解了浏览器(客户端)和服务器之间请求和响应的方式,那么,从我们在浏览器中输入一个 URL,到在浏览器中看到页面,一共经历了哪些过程呢?

  1. 在页面中输入 URL 
  2. 浏览器查找域名对应的 IP 地址 
  3. 浏览器向 Web 服务器发送 HTTP 请求
  4. 服务器处理请求,返回一个 HTTP 响应
  5. 浏览器获取 HTML 数据并显示
  6. 浏览器向服务器发送请求,获取嵌入 HTML 中的资源(JavaScript 脚本、CSS、图片、视频等)
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值