分布式系统是什么?
分布式系统是由多台独立的计算节点通过网络协同组成的系统,多个节点对外表现为一个整体,共同完成一个业务目标。这些节点可以是不同物理机、虚拟机、容器,也可以位于不同地理位置。
分布式系统特点:
-
多节点协作:系统中的多个服务进程分布在不同机器上。
-
网络通信:节点间通过网络(通常通过 RPC)通信。
-
透明性:用户感知不到后端有多少节点。
-
容错能力:节点故障不会影响整体系统的可用性。
为什么需要分布式系统?
单体系统虽然开发简单,但面临严重的扩展瓶颈和可靠性问题。分布式系统的出现就是为了解决单机架构无法满足大规模系统对性能、容量和可用性的需求。
-
性能瓶颈(计算能力有限)
单台服务器的 CPU、内存、磁盘 IO 等资源是有限的,随着业务增长无法承载更多请求。 -
存储瓶颈(数据量庞大)
数据量超过单机磁盘容量,例如搜索引擎、社交平台的海量数据,必须分布存储。 -
高可用性需求
单机宕机会导致整个系统不可用,分布式系统可通过冗余和故障转移实现高可用。 -
可扩展性要求
分布式架构可以水平扩展(增加节点),比垂直扩展(升级硬件)更灵活和经济。
RPC是什么?
RPC(Remote Procedure Call,远程过程调用)是一种计算机通信协议,它允许程序像调用本地函数一样调用位于另一台机器(或不同进程)上的函数或服务。其核心目的是隐藏底层网络通信的复杂性,让开发者专注于业务逻辑。
类比:我们要查询银行账户余额
本地调用:直接看自己的钱包
RPC调用: 打电话给银行客服(远程服务)。你(客户端)只需说出需求(调用方法名和参数),客服(服务端)处理请求并告诉你结果(返回值)。你无需了解电话信号如何传输、客服在哪个分行工作等底层细节 —— RPC 框架处理了这一切。
为什么需要RPC?
RPC是为了解决进程间调用复杂、跨机器通信困难的问题。
在单机程序中,我们调用一个函数简单直接。但是在分布式系统中,服务 A 需要调用服务 B,而服务 B 很可能部署在另一台机器上。这时,就必须依赖底层网络通信,涉及到数据的序列化与反序列化、连接的建立与维护、请求与响应的映射、超时与重试的处理等大量繁杂的工作。如果没有一个统一的机制,这些操作往往需要开发者手动实现,不仅代码冗长,开发效率低,而且容易出错。RPC 框架的出现,就是为了屏蔽这些底层细节,提供一种透明、简洁的调用方式,使得远程服务调用像本地函数调用一样自然,从而大大提升分布式系统的可维护性与开发效率。
RPC调用流程?
①Client Stub:是客户端调用远程服务时的代理组件,它屏蔽了底层通信细节。我们调用 Stub 中的方法,看起来像本地调用,实际是经过序列化、网络传输到远程服务端。Stub 负责请求封装、发送、等待响应并返回结果。
示例:
假设我们定义了一个服务:
class IUserService {
public:
virtual User getUser(int id) = 0;
};
Client Stub 实现类:
class UserServiceStub : public IUserService {
public:
User getUser(int id) override {
// 1. 序列化成 JSON 或 Protobuf
std::string req = encode("getUser", id);
// 2. 发给远程 Server
std::string resp = sendOverNetwork(req);
// 3. 解码结果
return decode(resp);
}
};
我们在本地调用
UserServiceClient client;
User user = client.getUser(123);
看着像是个本地调用,但实际上:
-
client.getUser(123)
会进入 Client Stub 代理类 -
它把
"getUser"
方法名 + 参数123
打包成请求 -
用 socket 发给服务器
-
等待响应,解码,返回一个 User 对象
②Server Stub:服务端接收请求、调用实际服务、并返回结果的桥梁组件
Server Stub 是部署在服务端的一段逻辑,它的职责是:
-
接收网络数据:从客户端接收到请求的字节流(如 JSON、Protobuf 等)。
-
解码请求:将网络数据反序列化为结构化的调用信息,例如方法名和参数。
-
调用实际服务实现:根据方法名,调用服务端真正的实现(Service Implementation)。
-
封装响应:将返回结果打包、序列化后,通过网络发回客户端。
Server Stub 把网络协议细节隐藏掉了,让服务端代码看起来就像本地方法被调用,实际是由远程客户端通过网络触发的。
示例:
假设我们定义了一个服务接口
class IUserService {
public:
virtual User getUser(int id) = 0;
};
服务端真正的业务逻辑实现类:
class UserServiceImpl : public IUserService {
public:
User getUser(int id) override {
// 实际的业务逻辑,比如从数据库查用户
return User{id, "Tom"};
}
};
Server Stub 实现:
class UserServiceServerStub {
public:
......
// 假设收到的是 JSON 字符串
std::string handleRequest(const std::string& req) {
// 1. 解码请求
std::string method;
int id;
decode(req, method, id); // 解析出方法名和参数
// 2. 调用真实服务实现
UserServiceImpl impl;
User result = impl.getUser(id);
// 3. 封装并序列化响应
return encode(result);
}
......
};
当服务端网络模块收到请求数据:
std::string req = receiveFromClient();
UserServiceServerStub stub;
std::string resp = stub.handleRequest(req);
sendToClient(resp);
从服务端开发角度来看,业务层只是像调用普通函数一样处理请求,而网络通信、序列化、协议处理等都被 Server Stub 封装好了。
一次RPC调用流程
①封装请求(Client)
客户端调用某个服务(如getUser(id)),框架将调用信息封装成一个结构体:方法名+参数值+参数类型等
示例:
{
"method": "getUser",
"args": [123]
}
②编码并发送(Client Stub → Network)
把上述封装好的请求结构转成网络能传输的字节流,此过程也称为序列化
常用的编码格式包括:JSON、Protobuf、Thrift等
客户端stub模块通过TCP/HTTP将请求发送到目标Server地址,底层使用socket或异步I/O等通信机制。
③接收并解码(Network→ Server Stub )
服务端接收网络传输过来的字节流,此时服务端尚未理解这是什么请求,只是原始的二进制数据
Server Stub将客户端发送过来的字节流进行反序列化,还原为结构体,拿到方法名、参数值、类型等调用信息
④反射调用本地方法(Server)
根据解码出的方法名,在服务端本地的服务对象中找到对应方法(如通过方法映射表)
方法映射表:就是一个类似map<string, function>的结构,负责把方法名映射到实际可调用的函数对象
示例:
std::unordered_map<std::string, std::function<std::string(const std::string&)>> method_table;
method_table["getUser"] = [](const std::string& arg) {
return UserService::getInstance()->getUser(arg);
};
// 来自 RPC 请求的参数
std::string method_name = "getUser";
std::string param = "123";
auto it = method_table.find(method_name);
if(it != method_table.end()) {
std::string result = it->second(param); // 动态调用
std::cout << "结果是:" << result << std::endl;
}
⑤封装响应(Server)
方法执行后,得到返回值,比如一个User对象
服务端将返回结果封装成统一结构体
{
"status": 200,
"result": { "id":123, "name":"Tom" }
}
⑥编码并发送(Server Stub→Network)
把封装后的响应结构体序列化为字节流
Server Stub通过socket把编码好的响应数据发回客户端
⑦接收并解码(Network→ Client Stub )
Client Stub接收到响应字节流,并对其进行解码(反序列化),还原为结构体数据,并取出status和result
⑧返回调用结果(Client)
此次RPC调用的最终结果返回给业务代码,对于用户来说,就像调用了一个本地函数
User user = getUser(123);
RPC框架中的服务治理模块是什么,一般都包含什么?
在RPC框架中,服务治理模块是指为保障服务之间稳定通信与动态扩展能力而设计的一系列辅助机制。
他不是RPC通信本身和核心功能,但在微服务架构中,已成为现代RPC框架的必要组成部分。
服务治理模块通常包含以下内容:
模块名称 | 作用 |
---|---|
服务注册(Service Register) | 服务启动时将自己的地址信息注册到注册中心 |
服务发现(Service Discovery) | 客户端从注册中心获取可用服务列表 |
负载均衡(Load Balancing) | 在多个服务实例中选择最优的一个进行调用 |
健康检查(Health Check) | 定期检测服务存活状态,剔除异常节点 |
容错机制(Fault Tolerance) | 保证服务调用失败时系统仍可正常响应或快速恢复 |
RPC框架为什么需要服务治理模型?
在微服务架构中,一个系统通常由大量服务实例组成,他们可能部署在多个物理机或容器中,节点数量、状态随时都可能变化,网络调用复杂多变。
传统的点对点静态调用方式在这种场景下难以应对,因此RPC框架需要引入服务治理模块,解决以下问题:
问题 | 服务治理模块提供的解决方案 |
---|---|
服务地址变动,无法写死 IP | 引入注册中心 + 服务发现,实现动态感知 |
多个副本怎么选最优? | 引入负载均衡策略(如轮询、一致性 Hash) |
服务挂了,客户端仍访问? | 健康检查剔除故障节点,或熔断快速失败 |
服务治理模块的核心组件?
服务注册(Service Register)
作用:在分布式系统中,服务端IP、端口是动态变化的。服务注册的目的是让系统知道“谁上线了、在哪”。
服务注册流程:服务提供者在启动后,将自己的服务信息(IP、端口、服务名、元数据等)主动上报到注册中心,以便客户端后续调用时可以查找
+-------------+ 注册 +-------------------+
| Service A | ----------> | 注册中心(ZK) |
+-------------+ +-------------------+
注册中心一般通过Zookeeper实现
/services/user_service/192.168.1.101:8000 -> ephemeral 节点
void registerToZk(const std::string& serviceName, const std::string& ip, int port) {
std::string path = "/services/" + serviceName + "/" + ip + ":" + std::to_string(port);
zkClient.createEphemeralNode(path, "online"); // 创建临时节点,服务宕机会自动消失
}
服务发现(Service Discovery)
作用:客户端要调用某个服务,需要知道他在哪里(IP、端口)。服务发现就是从注册中心动态获取服务实例地址列表,供客户端使用。
std::vector<std::string> discoverService(const std::string& serviceName) {
std::string path = "/services/" + serviceName;
return zkClient.getChildren(path); // 返回所有子节点,即服务实例地址
}
在RPC的服务发现流程中,如果我们只进行了
zkClient.getChildren("/services/user_service");
得到的只是此刻的服务列表,但在真实系统中,服务节点可能随时上线或宕机,比如某个服务宕机了,IP:Port不可用了,新服务实例上线了,提供了新的地址,如果客户端没有监听机制,就只能进行定时轮询,会造成响应不够及时、性能差等问题,所以在服务发现中我们一般会引入监听机制即Watch机制。
Watch示例:
zkClient.watchChildren(path, [](const std::vector<std::string>& nodes){
updateLocalCache(serviceName, nodes);
});
path: "/services/user_service"
那这个路径下会有多个子节点(每个子节点表示一个服务实例):
/services/user_service/192.168.1.10:8000
/services/user_service/192.168.1.11:8000
上述示例代码对path节点的子节点变化设置一个监听器(watch),一旦子节点有任何变动(增删改),立即触发回调callback,回调函数中一般会更新服务发现的本地缓存。
上述过程类似于订阅了/service/user_service节点的“变化事件”。
负载均衡(Load Balancing)
作用:
在分布式系统中,某个服务往往会部署多个副本(实例)以提高并发处理能力与容错性。例如,有一个 UserService
同时运行在3台机器上:
192.168.1.101:8080
192.168.1.102:8080
192.168.1.103:8080
当客户端需要调用该服务时,就要决定调用哪个实例,这就是负载均衡的职责,选择一个合适的服务实例进行调用,以达到:
-
请求分摊:防止某一节点压力过大;
-
高可用性:某个节点宕机也能切换到其他实例;
-
高性能:优先选择延迟低或权重高的节点。
负载均衡常见策略:
策略 | 说明 |
---|---|
Round Robin | 顺序轮询 |
Random | 随机选择 |
Least Connection | 调用当前连接最少的节点 |
Consistent Hash | 一致性哈希,适用于状态缓存服务 |
Weight Round Robin | 带权轮询,优先调用性能强节点 |
示例:轮询
class RoundRobinBalancer {
public:
RoundRobinBalancer(const std::vector<std::string>& my_nodes) : nodes(my_nodes), index(0) {}
std::string select() {
if (nodes.empty()) return "";
std::string addr = nodes[index];
index = (index + 1) % nodes.size();
return addr;
}
private:
std::vector<std::string> nodes;
size_t index;
};
容错机制
在分布式系统中,RPC 调用失败是常态,可能由于网络抖动、服务宕机、请求超时、响应异常、节点负载过高等问题导致。如果没有良好的容错机制,一次调用失败就可能引发连锁反应,影响整体服务的可用性。因此容错机制的核心目标是即使部分服务出现故障,系统整体仍然可以运行,能够提供稳定响应。
容错机制常见策略:
机制 | 说明 |
---|---|
超时控制 | 控制最长调用等待时间(如 3s) |
自动重试 | 调用失败后重新尝试 N 次 |
熔断器 | 多次失败时短路,等待恢复 |
服务降级 | 返回缓存、默认值、空数据 |
限流保护 | 拒绝超额请求,保护核心服务 |
异步兜底 | 调用失败时异步记录或报警 |
示例:超时+重试+降级
bool callWithRetry(std::function<bool()> rpcCall, int retry = 3, int timeoutMs = 1000) {
for (int i = 0; i < retry; ++i) {
auto future = std::async(std::launch::async, rpcCall);
if (future.wait_for(std::chrono::milliseconds(timeoutMs)) == std::future_status::ready) {
if (future.get()) return true; // 成功
}
std::cerr << "RPC call timeout/retry" << std::endl;
}
// 降级逻辑
std::cerr << "All retries failed. Using fallback result." << std::endl;
return false;
}