diff --git a/notes/pics/rpc-call.png b/notes/pics/rpc-call.png new file mode 100644 index 00000000..b87503f2 Binary files /dev/null and b/notes/pics/rpc-call.png differ diff --git a/notes/分布式.md b/notes/分布式.md index 7b3373fc..1d802082 100644 --- a/notes/分布式.md +++ b/notes/分布式.md @@ -28,6 +28,11 @@ * [单个 Candidate 的竞选](#单个-candidate-的竞选) * [多个 Candidate 竞选](#多个-candidate-竞选) * [日志复制](#日志复制) + * [七、RPC](#七rpc) + * [调用过程](#调用过程) + * [服务发现](#服务发现) + * [RPC 与 HTTP](#rpc-与-http) + * [超时与重试](#超时与重试) * [参考](#参考) @@ -416,8 +421,107 @@ Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了 发生网络分区时,少数派一侧的旧 Leader 得不到大多数节点的确认,无法提交日志,多数派一侧会选出新的 Leader。分区恢复后,旧 Leader 发现更大的任期号就会退回 Follower,它未提交的日志被覆盖,因此不会出现脑裂。 +## 七、RPC + +远程过程调用(Remote Procedure Call, RPC)指的是像调用本地方法一样调用另一台机器上的服务。例如订单服务调用库存服务扣减库存: + +```java +boolean ok = inventoryService.deduct(10086, 2); +``` + +### 调用过程 + +调用方写的是普通的方法调用,RPC 框架在背后完成了网络通信: + +

+ +1. inventoryService 是框架用动态代理生成的对象,调用它的方法时,代理拿到接口名、方法名和参数; +2. 把这些信息序列化成字节,加上请求 ID 等首部组成请求帧,从注册中心获取的地址列表中按负载均衡选出一个实例,通过长连接发送; +3. 服务方解码请求,找到对应的实现类,通过反射调用 deduct(),再把返回值按同样的方式发回; +4. 一个连接上可能同时有多个请求在等待响应,调用方根据响应中的请求 ID 把结果交给对应的调用。 + +去掉工程上的细节,核心只有两部分:调用方用动态代理把方法调用转成网络请求,服务方用反射把网络请求转成方法调用。 + +```java +// 调用方:生成接口的代理对象,调用方法时把方法名和参数发给服务方 +@SuppressWarnings("unchecked") +static T refer(Class api, String host, int port) { + return (T) Proxy.newProxyInstance(api.getClassLoader(), new Class[]{api}, (proxy, method, args) -> { + try (Socket socket = new Socket(host, port)) { + ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream()); + out.writeUTF(method.getName()); + out.writeObject(method.getParameterTypes()); + out.writeObject(args); + out.flush(); + return new ObjectInputStream(socket.getInputStream()).readObject(); + } + }); +} + +// 服务方:读出方法名和参数,通过反射调用本地的实现 +static void export(Object impl, int port) throws Exception { + try (ServerSocket server = new ServerSocket(port)) { + while (true) { + try (Socket socket = server.accept()) { + ObjectInputStream in = new ObjectInputStream(socket.getInputStream()); + Method method = impl.getClass().getMethod(in.readUTF(), (Class[]) in.readObject()); + Object result = method.invoke(impl, (Object[]) in.readObject()); + ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream()); + out.writeObject(result); + out.flush(); + } + } + } +} +``` + +```java +// 服务方 +export(new InventoryServiceImpl(), 20880); + +// 调用方 +InventoryService inventoryService = refer(InventoryService.class, "10.0.0.8", 20880); +inventoryService.deduct(10086, 2); // 实际执行的是服务方的 InventoryServiceImpl.deduct() +``` + +这段代码每次调用都新建连接,使用 Java 自带的序列化,也没有超时处理。Dubbo、gRPC 等框架在此基础上实现了连接复用、高效的序列化、服务发现、负载均衡、超时重试和熔断,设计一个 RPC 框架时也是从这几个模块考虑。 + +### 服务发现 + +服务的实例会扩容、缩容或者宕机,地址不能写死在调用方。注册中心(例如 Nacos、ZooKeeper)保存每个服务当前可用的实例: + +- 服务方启动时把自己的地址注册到注册中心,之后定期发送心跳,长时间没有心跳的实例会被删除; +- 调用方订阅需要的服务,把地址列表缓存在本地,实例变化时由注册中心推送通知; +- 每次调用都从本地缓存的列表中选择实例,注册中心不转发请求。 + +因此注册中心全部宕机时,调用方仍然可以按缓存的地址继续调用,只是新上线的实例无法被发现。某个实例宕机后、被注册中心删除之前,发往它的请求会失败,需要调用方重试其它实例。 + +### RPC 与 HTTP + +HTTP 是一种协议,RPC 是一种调用方式,两者不是同一层面的概念:RPC 框架可以在 TCP 上使用自定义的协议,例如 Dubbo 协议;也可以基于 HTTP,例如 gRPC 基于 HTTP/2,Spring Cloud 的 OpenFeign 用动态代理把接口调用转换成 HTTP 请求。 + +面试中通常问的是:为什么对外接口常用 HTTP + JSON,内部服务之间却常用 RPC 框架。这是因为内部调用量大、对延迟敏感,RPC 框架在这些方面做得更好: + +- 序列化更紧凑:例如参数 {"skuId":10086,"count":2},JSON 需要 25 字节;Protobuf 不传字段名,只传字段编号和值,编码为 08 E6 4E 10 02,共 5 字节,其中 08 表示字段 1,E6 4E 是 10086 的变长编码,10 02 表示字段 2 的值为 2; +- 连接复用:在少量长连接上同时发送多个请求,用请求 ID 区分响应,省去频繁建立连接的开销; +- 服务治理:内置服务发现、负载均衡、超时重试和熔断; +- 接口约定:用 Java 接口或者 IDL 文件定义服务,自动生成调用方的代码,参数类型在编译期就能检查。 + +HTTP + JSON 的优势在于通用:浏览器和各种语言都能直接调用,报文可读,便于调试,适合对外开放的接口。 + +### 超时与重试 + +- 每次调用都要设置超时时间,否则下游变慢时,调用方的线程会一直等待,最终被耗尽; +- 调用方超时不代表服务方没有执行:请求可能在网络中延迟,或者在服务方的线程池中排队、遇到 GC 停顿,这些时间都计入了调用方的耗时,却不在服务方记录的处理时间里,于是调用方超时了,服务方却执行成功了; +- 因此只有幂等的接口才能自动重试,例如查询;扣库存这类写操作要带上请求 ID,由服务方去重,否则重试会扣减两次; +- 调用链较长时,下游的超时时间应该比上游短。gRPC 会把截止时间(deadline)随请求传给服务方,超时后调用方收到 DEADLINE_EXCEEDED,服务方也可以检查调用是否已经超时,从而停止处理。 + ## 参考 +- [Apache Dubbo: Architecture](https://dubbo.apache.org/docs/v2.7/user/preface/architecture/) +- [Protocol Buffers: Encoding](https://protobuf.dev/programming-guides/encoding/) +- [gRPC: Core concepts](https://grpc.io/docs/what-is-grpc/core-concepts/) +- [gRPC on HTTP/2](https://grpc.io/blog/grpc-on-http2/) - 倪超. 从 Paxos 到 ZooKeeper : 分布式一致性原理与实践 [M]. 电子工业出版社, 2015. - [Designing Data-Intensive Applications 中文版](https://github.com/vonng/ddia) - [Distributed locks with Redis](https://redis.io/topics/distlock) diff --git a/tools/diagram/src/rpc-call.svg b/tools/diagram/src/rpc-call.svg new file mode 100644 index 00000000..7bd85d9b --- /dev/null +++ b/tools/diagram/src/rpc-call.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + 一次 RPC 调用 + + + 注册中心 + 库存服务:10.0.0.8、10.0.0.9 + + ① 订阅地址,缓存在本地 + + 启动时注册,定期发送心跳 + + 订单服务(调用方) + + inventoryService.deduct(10086, 2) + + + 代理 + 取得方法名和参数 + + + 序列化、选择实例 + 组成带请求 ID 的请求帧 + + + + 库存服务(服务方) + + + 解码请求 + 得到方法名和参数 + + + 反射调用 + InventoryServiceImpl.deduct() + + 返回 true + + + + ② 请求帧 + 请求 ID 7 + 方法 + 参数 + + ③ 响应帧 + 请求 ID 7 + 结果 + 注册中心只负责注册和发现,不转发请求;调用方按请求 ID 把响应交给对应的等待线程 +