【分布式系统与 RPC 框架系列】从单机瓶颈到远程调用:一文理解分布式架构与 RPC 原理
🔥 本文专栏:分布式系统与 RPC 框架系列
🌸作者主页:努力努力再努力wz
💪今日博客励志语录:
暂时没有结果,并不说明你的坚持毫无意义;很多成长,本来就发生在结果出现之前。
思维导图
单机单进程部署的局限与垂直扩容瓶颈
在我们此前所编写的程序或者项目中,大部分采用的都是单机部署的方式。所谓单机部署,简单来说,就是将整个程序部署并运行在同一台机器上,共享这台机器所提供的 CPU、内存、磁盘以及网络等硬件资源。
以一个聊天服务器系统为例,其内部可能包含用户登录模块、消息模块、好友管理模块、群聊模块以及后台管理模块等多个功能模块。如果采用单机单进程的方式进行部署,那么这些模块都会被集成在同一个进程中,并共同使用当前节点的硬件资源。
这种部署方式在业务规模较小时并没有明显问题,但是随着用户数量和并发量不断提升,单机部署就会逐渐暴露出一些局限性。
单台机器的硬件资源存在上限
对于一个网络服务器来说,其需要在运行过程中与大量客户端建立连接。每建立一个连接,底层通常都会涉及 socket 文件描述符以及用户态中的连接对象。
用户态的连接对象中还可能保存连接上下文、输入缓冲区、输出缓冲区、用户身份标识以及连接状态等信息。因此,连接数量不断增加时,会持续消耗机器的内存资源,同时也会增加 CPU、网络带宽以及内核资源的压力。
虽然我们可以通过提高进程的文件描述符上限,使程序能够维护更多连接,但是这只是解除了配置层面的限制。每一个连接依然会真实地消耗内核内存和用户态内存,所以单台机器能够承载的连接数量终究是有限的。
同理,对于 CPU 来说,CPU 核数决定了程序能够真正并行执行的线程数量。程序虽然可以创建远多于 CPU 核数的线程,但是线程本身也存在成本。
创建线程需要分配线程栈以及保存线程控制信息,而线程数量过多以后,还会增加操作系统的调度压力和上下文切换开销。因此,在高并发网络服务器中,通常不会为每一个连接创建一个线程,而是通过 Reactor、I/O 多路复用以及线程池等方式,让少量线程管理大量连接。
此前我们实现的高性能网络库,本质上就是在提高单台机器的资源利用率,让一台服务器能够以更低的线程和内存成本处理更多连接。
但是无论单机性能优化得多好,都只是提高了单台机器的承载上限,并不能消除单台机器本身的硬件上限。
不同模块对于硬件资源的需求不同
单进程中虽然集成了多个业务模块,但是不同模块的访问频率和资源消耗情况并不相同。
仍然以聊天服务器为例。
对于消息模块来说,客户端发送消息时,通常会携带发送方身份、接收方身份以及消息主体等信息,然后将消息序列化,通过网络发送到服务端。
服务端接收到消息以后,需要对消息进行解析和反序列化,并根据当前连接中保存的用户身份完成必要的身份校验。接着,服务器需要查询接收方当前所在的连接或者服务节点,然后重新组织消息并进行序列化,最终将消息转发给接收方。
因此,消息模块会持续处理大量网络数据,同时还涉及消息解析、序列化、反序列化以及消息路由等业务逻辑,其通常属于负载较高的模块。
对于用户登录模块来说,客户端提交账号以后,服务器需要查询 MySQL 或 Redis,完成账号校验、密码验证以及登录状态的创建。登录成功以后,还需要将用户身份与当前连接进行绑定,并将该用户加入在线用户表或者活跃连接表中。
因此,登录模块同样会涉及数据库或缓存访问,也可能在大量用户同时登录时形成较高负载。
而后台管理模块通常面向管理员,主要提供踢出用户、禁言、封禁以及系统配置等功能。相较于普通用户持续产生的消息请求,管理员操作的频率通常比较低,因此其资源消耗一般远低于消息模块和登录模块。
由此可以看到,同一个进程中的各个模块虽然共享同一台机器的硬件资源,但是它们对于 CPU、内存、网络和数据库等资源的需求并不相同。
系统整体的性能上限,往往主要受到消息模块、登录模块等高负载模块的影响,而不是受到后台管理等低频模块的影响。
垂直扩容的局限性
当单台机器的性能无法继续满足业务需求时,一个最直接的解决方式就是垂直扩容。
所谓垂直扩容,就是提升当前机器的硬件配置,例如增加内存、更换更多核心的 CPU、提升磁盘性能或者升级网络带宽。
通过垂直扩容以后,部署在这台机器上的进程确实能够获得更多硬件资源,而消息模块和登录模块等高负载模块也能够获得更高的处理能力。
但是问题在于,这些模块都位于同一个进程中,无法被单独部署和单独扩容。
真正需要更多资源的可能只有消息模块和登录模块,但是我们却只能提升整个节点的硬件配置。这样一来,后台管理等低负载模块也会随着整个进程一起运行在更高配置的机器上。
这里所谓的资源浪费,并不是说后台管理模块一定会主动占满新增的 CPU 和内存,而是说我们为了少数高负载模块,必须整体购买和部署更高规格的机器,却无法将新增资源精确地配置给真正需要扩容的模块。
因此,单机单进程部署存在一个非常明显的问题:
不同模块的负载不同,但是只能以整个进程、整个节点为单位进行扩容,导致扩容粒度过粗,无法针对某个高负载模块进行精准扩容。
除此之外,垂直扩容本身也存在明显上限。
一台机器的 CPU、内存插槽、磁盘接口以及网络能力都是有限的,不可能无限提升。同时,越高规格的硬件,其成本通常越高,单位性能的提升成本也会越来越大。
更重要的是,即使将单台机器升级得非常强大,整个系统依然运行在同一个节点上。一旦该节点发生故障,整个系统仍然可能不可用。
因此,垂直扩容只能在一定阶段内缓解单机性能不足的问题,但不能从根本上解决系统规模持续增长所带来的扩展问题。
单体进程带来的部署耦合
除了硬件资源和扩容问题以外,将多个业务模块集成在同一个进程中,还会带来部署上的耦合。
在单体进程中,用户登录模块、消息模块、好友管理模块、群聊模块以及后台管理模块通常会被编译和链接成同一个可执行程序。
这意味着,即使我们只修改了其中某一个模块,哪怕只是修改后台管理模块中的一小部分代码,也通常需要重新编译整个程序,并重新部署整个服务。
对于聊天服务器这种长期维护大量连接的系统来说,重新部署整个进程还可能导致已有连接断开、客户端重新连接以及短时间内产生大量重连请求。
也就是说,某个局部模块的修改,最终会扩大为整个系统级别的重新发布。
因此,单体进程还存在另一个明显问题:
各个模块作为一个整体进行编译和部署,任意模块发生修改,都可能导致整个服务重新编译和重新部署。
单机单进程部署的核心局限
至此,可以将单机单进程部署所面临的问题归纳为以下几个方面。
首先,单台机器所提供的 CPU、内存、磁盘以及网络资源存在物理上限。即使通过 Reactor、线程池以及内存优化等方式提高单机资源利用率,也只能提高单台机器的承载能力,无法消除单机上限。
其次,不同业务模块对于硬件资源的需求并不相同。消息模块和登录模块可能属于高负载模块,而后台管理模块的访问频率相对较低。但是由于这些模块被集成在同一个进程中,所以无法针对某个模块单独分配资源或者单独扩容。
再次,垂直扩容只能整体提升当前节点的配置,扩容粒度较粗,而且存在硬件上限、成本较高以及单点故障等问题。
最后,多个模块被编译和部署为一个整体,任意模块发生修改,都可能导致整个程序重新编译和重新部署,模块之间存在明显的发布耦合。
因此,单机单进程部署的核心局限可以概括为:
- 单台机器的硬件资源存在上限;
- 不同模块的资源需求不同,但无法独立分配和扩容;
- 垂直扩容只能整体升级节点,扩容粒度过粗;
- 任意模块发生修改,都可能导致整个服务重新部署。
水平扩容:突破单机瓶颈后的新问题
根据上文,我们已经认识到了单节点部署单进程所存在的局限。其中,一个最直接的解决思路就是垂直扩容,也就是提升当前机器的 CPU、内存、磁盘以及网络等硬件配置。
垂直扩容确实可以在一定程度上提高单个节点的处理能力,但是它只能暂时缓解问题,并不能从根本上解决单节点资源有限的问题。毕竟一台机器的硬件性能存在上限,不可能无限提升。
因此,我们可以从垂直扩容继续过渡到另一个解决思路——水平扩容。
什么是水平扩容
所谓水平扩容,就是不再只提升原有节点的硬件配置,而是在原有节点的基础上继续增加新的服务器节点。
例如,原来整个聊天服务器系统只部署在一个节点上:
Node-1 └── ChatServer ├── 用户登录模块 ├── 消息模块 ├── 好友管理模块 ├── 群聊模块 └── 后台管理模块水平扩容以后,则会复制出多个相同的节点:
Node-1:运行完整的 ChatServer Node-2:运行完整的 ChatServer Node-3:运行完整的 ChatServer这里每一个节点中仍然运行着同一套完整程序,也就是说,每一个节点中的进程都同时包含用户登录、消息转发、好友管理、群聊以及后台管理等模块。
因此,水平扩容本质上是:
将原来的单体应用复制为多个相同的服务实例,并部署到不同节点上,让多个节点共同承担系统压力。
在多个节点之前,通常还会增加一个负载均衡器:
客户端 ↓ 负载均衡器 ↓ Node-1 / Node-2 / Node-3负载均衡器负责将不同客户端的连接或者请求分配给不同节点,使多个节点能够共同处理系统流量。
对于聊天服务器这种长连接场景来说,一个客户端的连接一旦被分配到某个节点,通常就会持续由该节点维护,而不是每发送一条消息就重新选择一个节点。
水平扩容解决了什么问题
相比垂直扩容,水平扩容不再依赖不断提升单台机器的性能,而是通过增加服务器数量来提升整个系统的处理能力。
例如,原来只有一个节点维护所有客户端连接,现在可以将这些连接分散到多个节点:
Node-1:维护一部分客户端连接 Node-2:维护一部分客户端连接 Node-3:维护一部分客户端连接这样一来,单个节点只需要承担部分连接和请求,整个系统所能够处理的并发量和请求量就会随节点数量增加而提升。
因此,水平扩容解决的核心问题是:
通过增加节点数量,让多个节点共同分担连接和请求,从而突破单台机器的硬件资源上限。
水平扩容的扩容粒度仍然较粗
虽然水平扩容提高了整个系统的处理能力,但是它并没有改变单个节点内部的程序结构。
每一个新增节点中,仍然运行着完整的单体应用:
新增节点 ├── 用户登录模块 ├── 消息模块 ├── 好友管理模块 ├── 群聊模块 └── 后台管理模块假设当前系统真正的性能瓶颈是消息模块。
对于消息模块来说,客户端会携带发送方身份、接收方身份以及消息正文等信息,将消息序列化后通过网络发送给服务器。服务器收到消息以后,需要进行解析和必要的身份校验,然后查询接收方所在的连接或者服务节点,最终再将消息序列化并进行转发。
因此,消息模块需要频繁处理网络 I/O、消息解析、序列化、反序列化以及消息路由等任务,通常属于负载较高的模块。
而后台管理模块主要面向管理员,提供踢人、禁言、封禁等功能,使用频率通常较低。
当前真正需要扩容的可能只是消息模块,但是在水平扩容时,我们无法只增加消息模块,而是必须复制整个聊天服务器程序。
也就是说:
消息模块 ← 真正需要扩容 用户登录模块 ← 被迫一起复制 好友管理模块 ← 被迫一起复制 后台管理模块 ← 被迫一起复制消息模块的总体处理能力确实随着节点数量增加而提高了,但是其他低负载模块也被重复部署到了每一个节点中。
因此,水平扩容虽然能够扩展系统整体能力,但是其扩容粒度仍然是整个单体应用,无法根据不同模块的实际负载进行精确扩容。
其核心问题可以概括为:
为了扩容某一个高负载模块,必须将所有模块作为一个整体进行复制和部署。
模块之间的部署耦合仍然存在
水平扩容只是增加了单体应用的实例数量,并没有改变各个模块被编译和部署为一个整体的问题。
假设后台管理模块发生了修改,哪怕只是修改了一小部分代码,也仍然需要重新编译整个聊天服务器程序。
然后,还需要将新的程序版本部署到所有节点:
Node-1:重新部署 Node-2:重新部署 Node-3:重新部署实际环境中可以通过滚动更新的方式,依次更新不同节点,避免整个系统同时停止服务。但是无论采用什么发布方式,核心问题仍然没有改变:
只修改了某一个模块,却需要重新编译整个程序,并更新所有运行该单体应用的节点。
因此,水平扩容解决了单机容量的问题,却没有解决模块之间的编译和部署耦合。
多节点会导致连接状态分散
除了扩容粒度和部署耦合以外,水平扩容还会带来一个新的问题,即连接状态会被分散到不同节点中。
在单节点环境中,服务器可以直接在当前进程的内存中维护一张在线连接表:
用户 ID → Connection 对象因为所有客户端连接都由同一个进程管理,所以消息模块可以直接通过用户 ID 找到对应的连接对象。
但是水平扩容以后,不同客户端会连接到不同节点:
Node-1: 用户 A → ConnectionA Node-2: 用户 B → ConnectionB此时,每一个节点只知道自己所维护的连接状态,并不知道其他节点中保存了哪些连接。
假设用户 A 连接在 Node-1,用户 B 连接在 Node-2。
当用户 A 向用户 B 发送消息时,消息首先会到达 Node-1。Node-1 查询自己的本地连接表,只能够找到用户 A 的连接,却无法找到用户 B 的连接,因为用户 B 的连接上下文由 Node-2 维护。
这里更准确的问题并不是所有节点一定会产生数据冲突,而是:
用户连接状态被分散在多个节点中,当前节点无法直接获得完整的全局连接信息。
通过 Redis 保存全局路由信息
为了解决当前节点无法确定接收方所在位置的问题,可以通过 Redis 维护一份全局的用户路由信息。
例如:
用户 A → Node-1 用户 B → Node-2这里 Redis 保存的并不是真正的连接对象,而是:
用户身份 ID → 用户所在的服务器节点因为真正的 socket 文件描述符、连接对象以及输入输出缓冲区都属于对应节点的进程内存,其他节点无法直接访问和使用。
所以每个节点仍然需要在自己的进程内维护本地连接表:
Node-1 本地连接表: 用户 A → ConnectionA Node-2 本地连接表: 用户 B → ConnectionB而 Redis 则负责记录用户当前所在的节点。
当用户 A 向用户 B 发送消息时,整体流程可以简化为:
用户 A 将消息发送给 Node-1 ↓ Node-1 查询自己的本地连接表 ↓ 本地找不到用户 B ↓ 查询 Redis ↓ 得到用户 B 位于 Node-2 ↓ Node-1 将消息发送给 Node-2 ↓ Node-2 查询自己的本地连接表 ↓ 找到用户 B 对应的 Connection ↓ Node-2 将消息转发给用户 B因此,Redis 在这里承担的是全局路由信息的维护工作,用来告诉当前节点:
- 接收方是否在线;
- 接收方当前位于哪个服务器节点。
Redis 不能代替节点之间的通信
需要注意的是,Redis 只能够帮助当前节点确定接收方所在的位置,但是它并不会自动将消息发送给目标节点。
Node-1 查询 Redis,知道用户 B 位于 Node-2 之后,Node-1 与 Node-2 之间仍然需要进行网络通信,将消息真正传递给 Node-2。
这里可以通过节点之间直接建立网络连接,也可以通过消息队列、发布订阅等方式完成跨节点消息传递。
因此,这几个部分的职责可以简单区分为:
Redis: 保存用户在线状态以及所在节点信息 节点之间的网络通信: 负责将消息从当前节点传递到目标节点 目标节点的本地连接表: 负责找到真正的客户端连接并发送消息也就是说,Redis 保存的是全局连接路由信息,而真正的连接对象仍然由各个节点分别管理。
水平扩容的核心局限
至此,可以将水平扩容的特点归纳为以下几个方面。
首先,水平扩容通过增加服务器节点,让多个节点共同承担连接和请求,突破了单台机器的硬件资源上限。
其次,每一个节点仍然运行着完整的单体应用,所以扩容的最小单位仍然是整个程序,无法只针对消息模块、登录模块等高负载模块进行独立扩容。
再次,各个模块仍然被编译和部署为一个整体,因此任意模块发生修改,都可能导致整个程序重新编译,并更新所有节点。
最后,多节点环境会使用户连接状态分散在不同节点中。当前节点只能直接访问自己维护的连接,需要借助 Redis 等中间件保存用户与节点之间的映射关系,并通过节点间通信完成跨节点消息转发。
因此,水平扩容虽然解决了单台机器资源不足的问题,但是仍然存在以下局限:
- 扩容粒度仍然是整个单体应用;
- 无法针对某一个高负载模块进行独立扩容;
- 模块之间的编译和部署耦合仍然存在;
- 多节点带来了状态分散以及跨节点通信问题。
从水平扩容到分布式架构:服务拆分与独立扩展
根据上文,我们已经认识了水平扩容。所谓水平扩容,就是在原有单节点的基础上,继续增加多个相同的服务器节点,并在每个节点上部署一份完整的单体应用。
通过这种方式,多个节点可以共同分担客户端连接和业务请求,从而突破单台机器的硬件资源上限。
但是,水平扩容仍然存在一个核心局限:
水平扩容的扩展粒度依然是整个单体应用,而不是其中某一个具体的业务模块。
例如,在聊天服务器系统中,真正负载较高的可能是消息模块和用户登录模块,而好友管理、群聊管理或者后台管理模块的负载可能相对较低。
但是在水平扩容时,我们无法只复制消息模块,而是必须将整个聊天服务器程序一起复制到新的节点上。
也就是说,每新增一个节点,都会同时部署:
用户登录模块 消息模块 好友管理模块 群聊模块 后台管理模块虽然消息模块的总体处理能力确实得到了提升,但是其他低负载模块也被迫一起复制和部署,因此扩容粒度仍然比较粗。
为了进一步解决这个问题,就需要将扩展粒度从整个单体应用继续缩小到具体的业务模块,这也就引出了分布式架构。
从业务模块到独立服务
在单体应用中,用户登录、消息转发、好友管理、群聊管理以及后台管理等功能,通常都只是同一个进程内部的不同模块。
它们虽然在代码结构上进行了划分,但是最终仍然会被编译、链接和部署为同一个可执行程序。
而在分布式架构中,会进一步将这些业务模块拆分为能够独立运行的服务进程,例如:
用户登录服务 消息服务 好友服务 群聊服务 后台管理服务这里的拆分并不只是将代码放到不同目录中,而是让各个服务拥有相对独立的运行环境,并能够单独部署、单独更新以及单独扩容。
因此,分布式架构真正改变的是:
原来必须作为一个整体部署的业务模块,被拆分成了多个能够独立运行和独立扩展的服务。
需要注意的是,分布式架构并不意味着一个服务一定只能部署在一台机器上,也不意味着一台机器上只能部署一个服务。
实际部署时,可以根据不同服务的负载情况灵活安排。
例如:
Node-1:后台管理服务 + 好友服务 Node-2:用户登录服务实例 1 Node-3:用户登录服务实例 2 Node-4:消息服务实例 1 Node-5:消息服务实例 2 Node-6:消息服务实例 3低负载服务可以部署较少实例,甚至多个低负载服务可以部署在同一个节点上;而高负载服务则可以部署多个实例,分散到不同节点中。
所以分布式架构并不是简单地让“一个模块占用一台机器”,而是让每个服务都具备独立部署和独立扩容的能力。
分布式架构可以实现更细粒度的扩容
继续以聊天服务器为例。
消息服务需要频繁接收、解析和转发客户端消息,同时还会涉及序列化、反序列化、消息路由以及大量网络 I/O,因此通常属于高负载服务。
用户登录服务需要完成身份验证、查询 MySQL 或 Redis、维护用户登录状态等工作,在大量用户同时登录时,也可能产生较高负载。
而后台管理服务主要面向管理员,提供踢人、禁言、封禁以及系统配置等功能,其访问频率通常相对较低。
在单体水平扩容中,如果消息模块压力较大,我们只能复制整个聊天服务器程序。
而在分布式架构中,可以根据各个服务的实际负载分别部署不同数量的实例:
消息服务:6 个实例 用户登录服务:3 个实例 好友服务:2 个实例 后台管理服务:1 个实例这样一来,真正影响系统性能上限的高负载服务可以部署更多节点,而低负载服务只需要部署少量实例即可。
因此,分布式架构相比单体水平扩容最大的优势之一,就是:
可以将扩容粒度从整个单体应用缩小到具体服务,只扩容真正存在性能压力的服务。
可以根据服务特点配置不同的硬件资源
不同服务的资源消耗特点并不相同。
有些服务主要消耗网络和磁盘 I/O,有些服务主要消耗 CPU,还有些服务可能主要消耗内存。
例如,消息服务需要管理大量连接并频繁转发消息,因此可能更加依赖:
- 网络带宽;
- 内存容量;
- 网络 I/O 性能。
而某些需要进行复杂计算、数据分析或者编解码的服务,则可能更加依赖:
- CPU 核数;
- CPU 单核性能。
后台管理服务访问频率较低,对硬件资源的要求通常也相对较低。
在单体应用中,这些模块共享同一个进程和同一台机器,难以根据各自特点进行单独配置。
而拆分成独立服务以后,就可以为不同服务选择更加合适的机器配置。
例如:
消息服务: 使用更高网络带宽和更大内存的节点 计算密集型服务: 使用更多 CPU 核心的节点 后台管理服务: 使用普通配置的节点这样可以减少资源配置与实际负载不匹配的问题,使不同节点的硬件资源得到更充分的利用。
服务可以独立更新和部署
分布式架构除了能够独立扩容以外,还可以缓解单体应用中的部署耦合问题。
在单体应用中,如果只修改了后台管理模块,通常仍然需要重新编译和部署整个聊天服务器程序。
而在分布式架构中,后台管理模块已经成为了一个独立服务。此时只需要重新编译并部署后台管理服务即可,不需要重新发布消息服务、用户登录服务以及好友服务。
因此,分布式拆分以后:
修改消息服务 ↓ 只重新部署消息服务 修改后台管理服务 ↓ 只重新部署后台管理服务这使系统的更新粒度更小,也降低了局部功能修改对整个系统的影响。
分布式架构会引入服务之间的网络通信
分布式架构并不是只有优点。
原来在单体应用中,各个模块都运行在同一个进程中,模块之间可以直接通过普通函数调用完成交互。
例如:
userService.login();friendService.getFriendList();messageService.sendMessage();这种调用发生在同一个进程内部,不需要经过网络。
但是将不同模块拆分为独立服务以后,它们可能运行在不同进程,甚至不同机器上。此时,一个服务无法再直接调用另一个服务内部的函数。
例如,用户登录服务需要查询用户信息,而用户信息相关逻辑位于用户服务中,那么登录服务就需要通过网络向用户服务发送请求:
用户登录服务 ↓ 网络请求 用户服务 ↓ 返回结果 用户登录服务也就是说,分布式架构将原来的进程内函数调用变成了跨进程、跨节点的网络调用。
网络调用相比本地函数调用更加复杂,因为它可能会面临:
- 网络延迟;
- 请求超时;
- 连接断开;
- 目标服务不可用;
- 请求丢失或者重复;
- 序列化与反序列化开销。
因此,分布式架构虽然实现了服务的独立部署和独立扩容,但是也引入了服务之间的网络通信问题。
服务之间会形成依赖关系
将模块拆分为独立服务,并不意味着各个服务之间完全没有联系。
整个系统的业务流程通常需要多个服务共同完成,因此服务之间仍然会存在调用和依赖关系。
例如:
客户端 ↓ 用户登录服务 ↓ 用户服务 ↓ 数据库用户登录服务可能依赖用户服务提供用户信息,而用户服务又依赖数据库存储的数据。
如果用户服务发生故障,那么用户登录服务可能无法完成登录流程。
因此,在分布式系统中,一个服务出现问题以后,故障可能沿着服务调用链继续向上传播:
A 服务调用 B 服务 B 服务调用 C 服务 C 服务发生故障 ↓ B 服务调用失败或者阻塞 ↓ A 服务也可能受到影响这种问题可以理解为分布式系统中的故障传播。
不过,这并不意味着任何一个服务发生故障,整个系统都会完全无法运行。
故障的影响范围取决于该服务的重要程度以及其他服务对它的依赖情况。
例如,后台管理服务发生故障以后,可能只会导致管理员暂时无法使用踢人、禁言等功能,而普通用户仍然可以正常登录和聊天。
如果好友服务发生故障,可能只会导致好友列表暂时无法查询,但是部分消息功能仍然能够继续运行。
如果用户登录服务或者消息服务整体不可用,那么就会严重影响系统的核心功能。
因此,更准确的说法是:
分布式架构中,各个服务在部署上相互独立,但是在业务上仍然可能存在依赖。某个服务发生故障以后,可能影响依赖它的其他服务,并沿着调用链形成故障传播。
分布式架构同样可以部署多个服务实例
水平扩容中的单体应用可以部署多个相同节点,从而在某个节点故障以后,由其他节点继续提供服务。
分布式架构中的每一种服务同样可以采用水平扩容的方式部署多个实例。
例如:
消息服务实例 1 消息服务实例 2 消息服务实例 3当其中一个消息服务实例发生故障时,其他实例仍然可以继续处理消息请求。
因此,分布式架构并不是放弃水平扩容,而是在完成业务服务拆分以后,再对每一种服务分别进行水平扩容。
也就是说:
单体水平扩容: 复制多个完整的单体应用 分布式水平扩容: 根据需要分别复制不同服务分布式架构真正的问题,不是单个服务实例故障以后一定会导致整个系统停止,而是系统中存在更多种服务和更复杂的调用链。
从水平扩容到分布式架构
现在可以将整个推导过程梳理为:
单节点单体应用 ↓ 单台机器硬件资源存在上限 ↓ 垂直扩容 ↓ 单台机器无法无限升级 ↓ 水平扩容 ↓ 复制多个完整的单体应用 ↓ 系统总体处理能力得到提升 ↓ 但是扩容粒度仍然是整个应用 ↓ 无法针对某一个高负载模块独立扩容 ↓ 将不同业务模块拆分为独立服务 ↓ 形成分布式架构因此,分布式架构的核心作用可以概括为:
将原来集中在同一个进程中的业务模块拆分为能够独立运行的服务,使不同服务可以根据自身负载和资源特点独立部署、独立更新以及独立扩容,从而实现更细粒度的资源分配和系统扩展。
但是与此同时,分布式架构也会带来新的复杂性:
本地函数调用 ↓ 变成跨进程、跨节点的网络调用 ↓ 需要处理网络延迟、调用失败和服务依赖 ↓ 还需要解决服务发现、负载均衡和故障传播等问题所以,分布式架构并不是一个只有优点的完美方案,而是通过增加系统复杂度,换取更强的扩展能力、更灵活的资源分配以及更小的部署粒度。
从分布式服务调用到自研 RPC 框架
根据上文,我们已经认识了分布式架构。对于分布式架构来说,其必然会面对一个非常关键的问题:原本位于同一个进程中的模块被拆分成了多个独立服务,并分别运行在不同进程甚至不同节点上,那么这些服务之间应该如何完成调用?
在单体应用中,各个模块运行在同一个进程内部,共享同一份进程地址空间。因此,一个模块需要使用另一个模块提供的功能时,只需要直接调用对应的函数或者接口即可。
从底层来看,本地函数调用会涉及参数传递、调用栈建立、保存返回地址以及跳转到目标函数代码执行等过程。由于调用方和被调用方位于同一个进程中,所以整个调用过程不需要经过网络。
但是在分布式架构中,原本位于同一个进程中的业务模块已经被拆分成了多个独立服务。这些服务可能运行在不同进程中,也可能被部署在不同机器上。
不同进程之间的地址空间相互隔离,一个服务无法直接访问另一个服务中的函数地址、对象或者内存数据。因此,此时不可能再像调用本地函数一样,直接跳转到目标函数执行。
既然无法直接进行本地函数调用,那么服务之间就只能通过网络完成远程调用。为了解决这一问题,便引入了 RPC。
什么是 RPC
RPC 是Remote Procedure Call的缩写,中文通常称为远程过程调用。
这里的Procedure可以理解为过程、函数或者方法。因此,RPC 所要解决的核心问题就是:
如何让一个进程调用另一个进程,甚至另一台机器上的函数或者服务接口。
RPC 的本质并不是让调用方真的能够直接访问远端进程中的函数,而是将一次函数调用转换成一次请求和响应形式的网络通信。
例如,在单体应用中,我们可能直接进行如下调用:
UserResponse response=userService.login(request);但是如果userService已经被拆分成了一个独立服务,并运行在另一个节点上,那么调用方就无法直接调用该对象中的函数。
此时,RPC 框架需要将这次调用转换成一条网络请求,其中通常会包含:
目标服务名称 目标方法名称 调用参数 其他必要的请求信息然后将这些信息发送给远端服务,由远端服务真正执行对应方法,并将执行结果返回。
因此,可以将 RPC 简单理解为:
将原本的本地函数调用,转换成跨进程、跨节点的网络请求。
RPC 的基本调用流程
一次完整的 RPC 调用通常会涉及调用方和服务提供方。
假设调用方需要调用远端的用户服务,并执行其中的登录接口,那么调用方首先需要明确:
服务名称:UserService 方法名称:Login 调用参数:用户名、密码等信息然后,RPC 框架会将服务名称、方法名称以及参数信息封装成一个 RPC 请求报文。
由于网络传输的本质是传输连续的字节流,所以请求对象不能直接通过网络发送,而是需要先完成序列化,将请求信息转换成适合网络传输的字节数据。
调用方的整体流程可以简化为:
调用远程接口 ↓ 确定目标服务和目标方法 ↓ 序列化调用参数 ↓ 组装 RPC 请求报文 ↓ 通过网络发送给服务提供方服务提供方收到请求以后,需要从网络字节流中解析出 RPC 请求报文,然后根据其中携带的信息确定调用的是哪个服务以及哪个方法。
接着,服务提供方会将参数数据反序列化,恢复成对应的参数对象,再调用本地真正的业务函数。
服务端的处理流程可以简化为:
接收 RPC 请求 ↓ 解析 RPC 请求报文 ↓ 确定目标服务 ↓ 确定目标方法 ↓ 反序列化调用参数 ↓ 调用真正的业务函数 ↓ 获得执行结果业务函数执行完成以后,服务提供方还需要将返回值进行序列化,组装成 RPC 响应报文,再通过网络返回给调用方。
业务函数返回结果 ↓ 序列化返回值 ↓ 组装 RPC 响应报文 ↓ 通过网络返回调用方调用方收到响应以后,再解析响应报文,并将返回数据反序列化成对应的结果对象,最终交给上层业务使用。
接收 RPC 响应 ↓ 解析响应报文 ↓ 反序列化返回值 ↓ 将结果交给上层业务所以,一次 RPC 调用的完整过程可以概括为:
调用方发起接口调用 ↓ 封装服务名称、方法名称和参数 ↓ 序列化并通过网络发送 ↓ 服务端解析请求 ↓ 调用真正的业务函数 ↓ 序列化执行结果 ↓ 通过网络返回调用方 ↓ 调用方反序列化得到结果为什么需要 RPC 框架
从上面的流程可以看到,远程调用相比普通本地函数调用,需要额外处理很多与网络通信相关的工作,例如:
- 设计 RPC 请求和响应报文;
- 描述目标服务和目标方法;
- 序列化调用参数;
- 创建或者复用网络连接;
- 发送请求数据;
- 接收响应数据;
- 解析响应报文;
- 反序列化返回结果;
- 处理网络错误和调用失败。
而这些过程通常并不依赖具体的上层业务。
无论调用的是聊天系统中的登录服务、消息服务,还是其他系统中的订单服务、支付服务,其底层调用过程基本都是相同的:
描述调用目标 ↓ 序列化调用参数 ↓ 通过网络发送 ↓ 远端解析并执行 ↓ 返回执行结果如果每一个业务服务都需要重复实现这套流程,就会产生大量重复代码,并且底层通信细节会严重侵入业务逻辑。
因此,可以将这一整套通用流程抽离出来,并封装成一个独立的 RPC 框架。
上层业务只需要告诉 RPC 框架:
需要调用哪个服务 需要调用哪个方法 需要传递什么参数至于底层如何组装报文、如何序列化、如何建立连接、如何发送请求以及如何接收响应,则统一交给 RPC 框架处理。
这就是实现 RPC 框架的主要意义。
让远程调用尽可能接近本地调用
RPC 框架通常会尽可能屏蔽底层的网络通信细节,让调用方在代码形式上感觉像是在调用一个普通的本地函数。
例如,调用方可能只需要编写类似下面的代码:
UserResponse response;userStub.Login(&controller,&request,&response,nullptr);从代码形式上看,这似乎只是调用了userStub对象中的一个普通成员函数。
但是在这个函数内部,RPC 框架实际上完成了:
获取目标服务和方法信息 ↓ 序列化请求参数 ↓ 组装 RPC 请求报文 ↓ 发送网络请求 ↓ 等待远端服务执行 ↓ 接收 RPC 响应 ↓ 反序列化返回结果因此,RPC 框架的目标之一就是:
让上层业务能够以接近本地函数调用的方式,完成跨进程、跨节点的远程服务调用。
不过需要注意的是,RPC 只是在使用形式上尽可能模拟本地调用,其底层本质仍然是网络通信。
所以一次 RPC 调用仍然可能面临:
- 网络延迟;
- 请求超时;
- 连接断开;
- 目标服务不可用;
- 请求发送成功但响应丢失;
- 同一个请求被重复执行;
- 序列化或者协议解析失败。
这些问题在普通的本地函数调用中通常并不存在。
因此,虽然 RPC 框架会尽可能屏蔽底层细节,但是上层业务仍然需要认识到:
远程调用和本地调用在可靠性、延迟以及失败模型上存在本质区别。
自研网络库在 RPC 项目中的作用
RPC 的底层需要完成跨节点的数据传输,因此必然需要一套网络通信能力。
服务提供方需要监听端口、接收客户端连接、接收 RPC 请求、解析请求并发送 RPC 响应。
其整体流程大致为:
监听端口 ↓ 接收网络连接 ↓ 接收 RPC 请求字节流 ↓ 解析 RPC 协议 ↓ 调用对应的业务方法 ↓ 发送 RPC 响应这些功能正好可以由此前实现的自研高性能 Reactor 网络库提供。
自研网络库可以为 RPC 框架提供:
EventLoop;TcpServer;TcpConnection;- 输入输出缓冲区;
- 连接管理;
- 数据到达回调;
- 多线程 Reactor 模型;
- 非阻塞网络 I/O。
这里需要注意的是,RPC 框架不一定直接建立在 HTTP 服务器之上,而是建立在 HTTP 服务器底层所使用的 TCP 网络库之上。
因为 HTTP Server 本身是基于网络库实现的一个具体应用层协议服务器,而 RPC 框架也需要设计自己的应用层通信协议。
因此,HTTP Server 和 RPC Server 可以理解为两个建立在同一套网络库之上的上层应用:
自研 Reactor 网络库 / \ HTTP Server RPC ServerHTTP Server 负责解析和处理 HTTP 协议,而 RPC Server 负责解析和处理自定义的 RPC 协议。
所以,在 RPC 项目中真正被复用的是此前实现的:
Reactor 网络库以及基于 TCP 的连接管理和数据收发能力。
Protobuf 在 RPC 项目中的作用
RPC 调用需要在网络上传输请求参数和返回结果,因此需要解决数据的序列化和反序列化问题。
在当前项目中,可以使用 Google 提供的 Protobuf 完成这一工作。
例如,可以通过.proto文件定义登录请求:
message LoginRequest { string username = 1; string password = 2; }同时定义登录响应:
message LoginResponse { int32 code = 1; string message = 2; }Protobuf 编译器会根据这些定义生成对应的 C++ 类,并提供序列化与反序列化接口。
调用方可以将请求对象转换成字节流:
request.SerializeToString(&args);服务提供方收到字节流以后,则可以将其恢复成原来的请求对象:
request.ParseFromString(args);同理,服务端的返回结果也可以通过 Protobuf 进行序列化,然后通过网络返回给调用方。
除了定义请求参数和响应结果以外,Protobuf 还可以定义 RPC 服务和方法:
service UserServiceRpc { rpc Login(LoginRequest) returns (LoginResponse); }根据服务定义生成的代码中,会包含与Service、Stub、MethodDescriptor等相关的结构。
这些结构能够描述:
- 服务名称;
- 服务包含的方法;
- 方法的请求类型;
- 方法的响应类型。
这些信息可以帮助 RPC 框架在服务端完成服务注册、方法查找以及请求分发,也可以帮助调用方生成对应的远程调用入口。
不过,Protobuf 本身主要负责的是:
- 数据结构定义;
- 序列化与反序列化;
- 服务和方法描述;
- 生成基础接口代码。
它并不会自动完成整个 RPC 调用过程。
例如,下面这些功能仍然需要由我们自己的 RPC 框架实现:
- 网络连接管理;
- RPC 请求报文设计;
- 请求发送和响应接收;
- 服务注册;
- 服务查找;
- 方法分发;
- 服务发现;
- 超时和错误处理。
因此,Protobuf 是 RPC 框架中的一个重要基础组件,但它本身并不等同于完整的 RPC 框架。
RPC 项目的整体组成
经过上面的分析,我们当前要实现的 RPC 项目,可以初步拆分为三个核心部分。
第一部分是自研 Reactor 网络库,其负责提供底层 TCP 网络通信能力,包括连接建立、数据收发、缓冲区管理以及事件循环等功能。
第二部分是自定义 RPC 通信协议,其负责规定一次 RPC 请求和响应应该包含哪些信息,以及如何在 TCP 字节流中进行解析。
第三部分是 Protobuf,其负责定义请求参数、响应结果、服务和方法,并完成数据的序列化和反序列化。
因此,当前 RPC 项目的整体技术组成可以概括为:
自研 Reactor 网络库 + 自定义 RPC 通信协议 + Protobuf 序列化与服务描述 = 自研 RPC 框架其中各部分的职责可以进一步理解为:
自研 Reactor 网络库: 负责数据如何通过网络进行传输 自定义 RPC 协议: 负责规定网络中传输的数据应该如何组织 Protobuf: 负责将参数对象和返回对象转换为字节流 并提供服务与方法的描述信息 RPC 框架: 负责将这些组件组合起来 完成远程服务调用的整个流程从分布式架构到 RPC
至此,可以将整个推导过程整理为:
单体应用 ↓ 各个模块运行在同一个进程中 ↓ 模块之间通过本地函数调用完成交互 ↓ 采用分布式架构 ↓ 模块被拆分成独立服务 ↓ 不同服务运行在不同进程或者不同节点 ↓ 无法再直接进行本地函数调用 ↓ 只能通过网络完成远程调用 ↓ 远程调用过程具有通用性 ↓ 将网络通信、协议解析、序列化和请求分发等流程统一封装 ↓ 形成 RPC 框架因此,RPC 框架的核心作用可以概括为:
RPC,即远程过程调用,其主要作用是将分布式系统中的跨进程、跨节点调用统一封装起来。调用方将服务名称、方法名称以及调用参数封装成 RPC 请求,通过序列化和网络传输发送给服务提供方;服务提供方解析请求并调用真正的业务方法,再将执行结果序列化后返回。RPC 框架通过封装这些通用流程,使上层业务能够以接近本地函数调用的形式完成远程服务调用。
这也明确了当前项目的目标:
基于此前实现的自研 Reactor 网络库,结合自定义 RPC 通信协议以及 Protobuf,完成一个能够支持服务注册、远程调用、请求分发和结果返回的自研 RPC 框架。
RPC远程调用过程示意图
结语
那么这就是本篇文章的全部内容,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!