1.1 Introduction
这8堂关于分布式系统的课构成了《Concurrent and Distributed Systems》的后半部分。前半部分的重点是在同一台计算机上运行的多个进程或线程之间的并发,而后半部分则进一步研究了由多个通信计算机组成的系统。
在一台计算机上的并发也被称为 shared-memory concurrency 共享内存并发,因为在同一进程中运行的多个线程可以访问同一地址空间。因此,数据可以很容易地从一个线程传递到另一个线程:在一个线程中有效的变量或指针,在另一个线程中也有效。
当我们转移到分布式系统时,情况就发生了变化。在一个分布式系统中,我们仍然有并发性,因为不同的计算机可以并行地执行程序。然而,我们通常没有共享内存,因为分布式系统中的每台计算机都运行自己的操作系统,有自己的地址空间,使用自己的内存。不同的计算机只能通过网络相互发送信息来进行通信。
在一些超级计算机和研究系统中存在有限形式的分布式共享内存,还有像remote direct memory access (RDMA) 远程直接内存访问 这样的技术,允许计算机通过网络访问对方的内存。另外,数据库在某种意义上也可以被视为共享内存,但与字节寻址的内存相比,它的数据模型不同。然而,广义上讲,大多数实用的分布式系统都是基于消息传递的。
分布式系统中的每一台计算机都被称为 node 节点。在这里,"计算机 "的解释相当宽泛:节点可能是台式计算机、数据中心的服务器、移动设备、与互联网连接的汽车、工业控制系统、传感器,或许多其他类型的设备。在本课程中,我们不对它们进行区分:一个节点可以是任何类型的通信计算设备。
创建分布式系统的原因有很多。有些应用本身就是分布式的:如果你想从你的手机向你朋友的手机发送一条信息,这个操作不可避免地需要这些手机通过某种网络进行通信。
一些分布式系统所做的事情原则上是一台计算机可以做到的,但它们做得更可靠。一台计算机可能会出现故障,可能需要不时地重新启动,但如果你使用的是多个节点,那么一个节点可以在另一个节点重新启动时继续为用户服务。因此,分布式系统有可能比单台计算机更可靠。
分布的另一个原因是为了获得更好的性能:如果一项服务的用户遍布世界各地,而他们都必须访问一个节点,那么无论是英国的用户还是新西兰的用户都会发现它很慢(或者两者都是)。通过在世界各地放置节点,我们可以通过将每个用户路由到附近的节点来解决速度慢的问题。
最后,一些大规模的数据处理或计算任务根本无法在一台计算机上完成,或者会慢得无法忍受。例如,欧洲核子研究中心(CERN)的大型强子对撞机是由一个全球性的计算基础设施支持的,它有100万个CPU核心用于数据分析,还有1 exabyte(10^18字节)的存储量!
然而,分布式系统也有缺点,因为事情可能出错,系统需要处理这种故障。网络可能出现故障,导致节点无法通信。
另一件可能出错的事情是,一个节点可能会崩溃,或运行速度比平时慢得多,或以其他方式行为不当(也许是由于软件错误或硬件故障)。如果我们想让一个节点在另一个节点崩溃时接管,我们需要检测到崩溃的发生;正如我们将看到的,即使是这样也不是很简单的。
网络故障和节点故障可能在任何时候发生,没有预先警告。在一台计算机中,如果一个组件出现故障(例如,一个RAM模块出现故障),我们通常不会期望计算机继续工作。软件不需要以明确处理有问题的RAM为前提来编写。然而,在一个分布式系统中,我们经常希望容忍系统的某些组件出现故障,而其余部分继续工作。例如,如果一个节点崩溃了(部分故障),其余的节点可能仍然能够继续提供服务。
如果一个系统的一个组件停止工作,我们称之为fault 故障,许多分布式系统努力提供 fault torlence容错性——也就是说,尽管有故障,系统作为一个整体继续运作。处理故障是使分布式计算与单台计算机编程相比有根本性的不同,而且往往更难。一些分布式系统工程师认为,如果你能在单台计算机上解决一个问题,那个问题一定是个很容易的问题。
1.2 computer networking
在研究分布式系统时,我们通常用高级抽象来描述。在本课程中,我们只是假设有某种方式让一个节点向另一个节点发送消息。我们并不特别关心该信息物理层面上是如何编码的(通过某种网络协议),因为发送和接收信息的基本原则是一致的。传输线路Wire 实际上可能是无线电波、激光、U盘,甚至是一辆装满硬盘在高速上飞驰的小货车。
如果你想发送一个非常大的消息(几十TB),在网上发送这些数据会很慢。但是,把这些数据写到一堆硬盘上,装到货车上,然后把它们开到目的地会更快。但从分布式系统的角度来看,传递信息的方法并不重要:我们只看到一个抽象的通信通道,它有一定的latency延迟(从发送信息到接收信息的延迟)和bandwidth带宽(每单位时间可传输的数据量)。
计算机网络关注于使信息能够到达目的地的网络协议。分布式系统建立在这一设施的基础上,侧重于应如何协调几个节点来实现一些共享任务。分布式算法的设计是关于决定发送什么消息,以及在收到消息时如何处理这些消息。
举个分布式系统的例子,你每天都在使用网络。
在网络中,主要有两种类型的节点:服务器server 托管网站,而客户端client(网络浏览器)显示网页。当你加载一个网页时,浏览器向相应的服务器发送一个HTTP请求信息。在收到该请求后,网络服务器会向请求的客户端发送一个包含页面内容的响应信息。这些信息通常是不可见的,但我们可以通过Charles(https://www.charlesproxy.com/)等工具来捕捉和可视化网络流量。
在一个URL中,//和后面的/之间的部分是客户端要发送请求的服务器的主机名(例如:www.cst.cam.ac.uk),其余部分(例如:/teaching/2122/ConcDisSys)是客户端在其请求信息中要求的路径。除了路径,请求还包含一些额外的信息,如HTTP方法(如GET加载一个页面,或POST提交一个表单),客户端软件的版本(user-agent),以及客户端理解的文件格式列表(accept header)。响应信息包含被请求的文件,以及其文件格式的指标(内容类型);在网页的情况下,这可能是一个HTML文档,一个图像,一个视频,一个PDF文档,或任何其他类型的文件。
由于请求和响应可能大于我们在单个网络包中的容量,因此HTTP协议运行在TCP之上,它将大块数据分解成小的网络包流,并在接收方将它们重新组合起来。HTTP还允许在一个TCP连接中发送多个请求和多个响应。然而,当我们从分布式系统的角度来看这个协议时,这个细节并不重要:我们把请求当作一个消息,把响应当作另一个消息,而不考虑传输它们所涉及的物理网络包的数量。
1.3 Remote Procedure Calls (RPC)
分布式系统的另一个例子是用信用卡/借记卡在网上买东西。当你在某个网上商店输入你的卡号时,该商店将通过互联网向专门处理银行卡支付的服务机构发送一个支付请求。支付服务反过来与Visa或MasterCard等银行网络进行沟通,后者与你的发卡行进行沟通,以便接受付款。
对于正在实现网上商店的程序员来说,处理付款的代码可能看起来像这样。
调用processPayment()看起来就像调用其他函数一样,但事实上,商店向支付服务发送请求,等待响应,然后返回它收到的响应。processPayment()的实际实现并不存在于商店的代码中:它是支付服务的一部分,但其实是运行在属于不同公司的另一个节点上的另一个程序。
这种类型的交互,即一个节点上的代码尝试调用另一个节点上的函数,被称为Remote Procedure Call(RPC)远程过程调用。在Java中,它被称为远程方法调用Remote Method Invocation(RMI)。实现RPC的软件被称为RPC框架或中间件。
当一个应用程序希望调用另一个节点上的一个函数时,RPC框架提供了一个stub来代替它。stub具有与真实函数相同的类型签名type signature,但它没有执行真实的函数,而是将函数参数编码在一个消息中,并将该消息发送到远程节点,要求调用该函数。编码函数参数的过程被称为marshalling。在下面的例子中,JSON编码被用于marshalling,但在实践中也会使用其他各种格式。
从RPC客户端到RPC服务器的消息发送可以通过HTTP进行(一般称为web服务),但也可以使用各种不同的网络协议。在服务器端,RPC框架对消息进行unmarshals 解码,并用提供的参数调用所需的函数。当函数返回时,返回值被打包,作为消息送回客户端,由客户端解包,然后由stub返回值。因此,对于stub的调用者来说,看起来就像该函数在本地执行一样。
RPC的难点在于,很多地方都可能出错,因为网络和节点可能会出现故障。如果客户端发送了一个RPC请求,但没有收到响应,它就不知道服务器是否收到并处理了这个请求。如果有一段时间没有收到回复,它可以重新发送请求,但这可能会导致请求被执行一次以上(例如对信用卡收费两次)。即使我们重试,也不能保证重试的消息能够通过。长期等待显然不行,所以在实践中,客户端一般会在超时后放弃。
几十年来,人们已经开发了RPC的许多变体,目的是为了使分布式系统的编程更加容易。这包括面向对象的中间件,如20世纪90年代的CORBA。然而,底层的分布式系统的困境一直没有变。
如今,最常见的RPC形式是使用通过HTTP发送的JSON数据。这类基于HTTP的API的一套常用的设计原则被称为REST,遵守这些原则的API被称为RESTful API。这些原则包括
- 通信是无状态的(每个请求独立于其他请求)
- 资源(可以检查和操作的对象)由URL表示
- 资源的状态可以通过向URL发起HTTP请求(如POST或PUT)来更改
REST的普及是由于在浏览器中运行的JavaScript代码可以很容易地进行这种类型的HTTP请求。在现在的网站中,使用JavaScript向服务器发出HTTP请求而不重新加载整个页面是非常常见的。这种技术被称为Ajax。
上面的代码获取参数args,使用JSON.stringify()将其打包成JSON,然后使用HTTP POST请求将其发送到URL https://example.com/payments。会有三种可能的结果:
- 要么服务器返回一个表示成功的状态码(在这种情况下,我们使用response.json()解开响应)
- 要么服务器返回一个表示错误的状态码
- 要么请求失败,因为没有从服务器收到响应(很可能是由于网络中断)
代码再根据情况调用success()或failure()函数。
尽管RESTful API和基于HTTP的RPC起源于web(客户端是在浏览器中运行的JavaScript代码),但它们现在也常用于其他类型的客户端(如移动应用app),或者server-to-server通信。
这种server-to-server的RPC在大型企业中特别常见,这些企业的软件系统过于庞大和复杂,无法在一台机器上以单一进程运行。为了管理这种复杂性,系统被分解成多个服务,这些服务由不同的团队开发和管理,甚至可能用不同的编程语言实现。RPC框架促进了这些服务之间的通信。
当使用不同的编程语言时,RPC框架需要转换数据类型,以便调用者的参数能够被被调用的代码所理解,同样,函数的返回值也是如此。一个经典的解决方案是使用Interface Definition Language(IDL)接口定义语言,为通过RPC提供的函数提供独立于语言的类型签名。从IDL中,软件工程师可以为使用不同编程语言的service和client自动生成marshalling/unmarshalling代码和RPC stub。比如下面这个ProtocolBuffers的例子。