【分布式系统与 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。什么是 RPCRPC 是Remote Procedure Call的缩写中文通常称为远程过程调用。这里的Procedure可以理解为过程、函数或者方法。因此RPC 所要解决的核心问题就是如何让一个进程调用另一个进程甚至另一台机器上的函数或者服务接口。RPC 的本质并不是让调用方真的能够直接访问远端进程中的函数而是将一次函数调用转换成一次请求和响应形式的网络通信。例如在单体应用中我们可能直接进行如下调用UserResponse responseuserService.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 框架提供EventLoopTcpServerTcpConnection输入输出缓冲区连接管理数据到达回调多线程 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远程调用过程示意图结语那么这就是本篇文章的全部内容我会持续更新希望你能够多多关注如果本文有帮助到你的话还请三连加关注你的支持就是我创作的最大动力