引言:对 RPC 框架设计的简单思考,记录下来。
注册中心
- Provider 和 Consumer 通过注册中心来解耦,Provider 向注册中心注册服务,Consumer 向注册中心订阅服务。
- Provider 下线时需要及时通知 Consumer。
- 注册中心有两种类型,一种是遵循 CP 原则,牺牲可用性保证强一致性,比如说 Zookeeper;另一种是遵循 AP 原则,牺牲强一致性保证可用性,比如说 Eureka。建议 Provider 和 Consumer 不要强依赖注册中心,应该在注册中心不可用的情况下仍然可以正常服务,所以推荐使用遵循 AP 原则的注册中心。
动态代理
RPC 的初衷是“像调用本地方法一样调用远程服务”,为了实现这一初衷,需要屏蔽调用细节。 屏蔽调用细节通过动态代理来实现。 动态代理实现列举:
- JDK 动态代理:要求代理类实现了接口,生成的代理类也是接口的实现类。通过反射调用代理类,所以性能会低一点。
- CGLib 动态代理:通过字节码技术生成代理类的子类,并重写其方法,比较灵活。
通讯协议
- 采用 HTTP 协议。
- 采用 TCP 协议 + 自定义协议内容,需要解决拆包和粘包问题。下面是一个自定义协议内容例子:
+---------------------------------------------------------------+
| 魔数 2byte | 协议版本号 1byte | 序列化算法 1byte | 报文类型 1byte |
+---------------------------------------------------------------+
| 状态 1byte | 消息 ID 8byte | 数据长度 4byte |
+---------------------------------------------------------------+
| 数据内容 (长度不定) |
+---------------------------------------------------------------+
序列化方式
通信内容的请求体和响应体序列化后传输。序列化的几种方式:
- JAVA 自带的序列化:效率低,不支持跨语言。
- JSON:可读性高,支持跨语言。
- Hessian:支持跨语言,序列化后的字节数适中。
- Protobuf:支持跨语言,性能优于 Hession,但是需要生成对应的 .proto 文件,增加了复杂度。
调用方式
- 同步调用
- 异步调用 + 注册监听器
网络框架选型
毫无疑问选择 Netty。
- 线程模型选择主从 Reactor 多线程模型,利用 I/O 多路复用和事件处理机制,可以非常高效的处理 I/O 事件。
- 零拷贝技术,除了操作系统级别的零拷贝技术外,Netty 提供了更多面向用户态的零拷贝技术,例如 Netty 在 I/O 读写时直接使用 DirectBuffer,从而避免了数据在堆内存和堆外内存之间的拷贝。
负载均衡机制
从注册中心获取到多个 Provider,通过负载均衡机制选择其中一个进行调用。 负载均衡算法举例:
- 随机
- 轮询
- 随机+权重
- 轮询+权重
- 最小连接数
- 一致性哈希
高可用
心跳检测
注册中心定时发送心跳到服务端,如果多次发送失败说明服务端不可用。
重试机制
- 被调用的服务接口的业务逻辑需要保证幂等才可以考虑使用重试机制。
- 在负载均衡选择服务节点时,应该剔除上次重试失败的节点,进一步提高重试的成功率。
- 重试机制虽然可以提升服务可用性,但是重试可能会导致服务提供方流量倍增,极端情况下甚至造成雪崩。
- RPC 框架的重试机制一般会采取指数退避的策略,两次重试之间指数级增加间隔时间,例如 1s、2s、4s、8s,以此类推,同时必须限制最大延迟时间。
- Consumer 端的超时时间要大于 Provider 端的超时时间。
线程隔离
将不同的服务调用方根据重要等级划分到不同等级的业务线程池中,通过分组的方式对服务调用方的流量进行隔离,从而避免其中一个调用方出现异常状态导致其他所有调用方都不可用,提高服务整体性能和可用率,一定要保障核心业务不受影响。
容错策略
当 Consumer 调用 Provider 发生异常时,应该采取什么样的容错策略:
- Failover,失效转移策略。发生异常时调用其他 Provider 节点,要求是幂等操作。
- Failfast,快速失败策略。发生异常时立即报错,适合非幂等操作。
- Failsafe,失效安全策略。发生异常时忽略,适合重要性低操作。
- Failback,失效自动恢复策略。发生异常时放到队列中定时重试,适合实时性要求不高场景。
优雅下线
- Provider 的实现原理如下。
- 首先,在停止时先标记为不接收新请求,新请求过来时直接报错,让调用方重试其他机器。
- 然后,检测线程池中的线程是否正在运行,如果正在运行,则等待所有线程执行完成。如果超时,则强制关闭,并写下错误日志。
- Consumer 的实现原理如下。
- 首先,在停止时不再发起新的调用请求,所有新的调用直接在本地即时报错。
- 然后,检测是否还有请求没有返回,等待响应返回。如果超时,则强制关闭,并写下错误日志。
熔断限流
- 熔断:由于下游服务压力太大或其他原因导致频繁调用异常时,可以使用熔断保护下游以及快速反馈给调用方。
- 限流:保护自身服务,避免高并发时压力太大导致服务不可用。
链路跟踪
调用链路太长可能会导致排查问题困难,可以使用一些链路跟踪工具例如 SkyWalking 跟踪调用情况。