系列目录
- MySQL 索引与慢查询:B+ 树如何减少扫描
- 声明式事务之下:InnoDB 的 MVCC 与锁
- 接入层:Nginx 反向代理与 OpenResty 的边界
- Web 容器与 Netty:线程模型之下的 IO 模型
- Redis(上):缓存用法与单线程模型的限制
- Redis(下):超出缓存用途的用法:锁、队列与排行榜
- 数据库访问层:连接池与 MyBatis 的显式 SQL
- Kafka(上):吞吐的来源是顺序 IO
- Kafka(下):生产端、broker 与消费端的可靠性配置
- RPC 框架:像本地调用一样调远程的代价(本篇)
服务化改造中的一次网络抖动
2016 年公司推进服务化改造。一个原本在单进程里通过 Spring Bean 注入调用的接口,被改造成跨机房远程调用。接口签名保持不变,调用方代码几乎没改,只把 @Autowired 换成框架的服务引用注解,方法调用仍是 userService.getUser(id)。服务化框架将这种远程调用写成了本地方法调用的形式。
一次下游机房网络抖动暴露了问题。调用方接口没有配置超时,沿用了框架默认值;该默认值对部分调用路径是无限等待(事后从文档确认)。下游响应变慢后,调用线程阻塞在 socket read 上,线程池被占满,上游入口开始排队,接口 RT 飙升。下游在这段时间的 GC 和负载都正常,调用方在等待未返回的响应。
远程调用需要处理网络超时,本地调用不涉及网络超时。框架把网络封装成方法调用,也容易让超时配置缺失。2016 系列第 13 篇讨论过超时、重试、限流和降级(超时重试限流降级)。本篇说明 RPC 框架如何标准化服务发现、负载均衡、超时和重试,以及调用方需要处理的故障域。
RPC 调用路径中的失败点
一次 RPC 调用包含代理、序列化、网络传输和线程模型四层;每层都有不同的失败形态。
代理层。 调用方拿到的是框架通过 JDK 动态代理或字节码增强生成的 stub。方法调用被拦截后,stub 把方法名、参数类型和参数值组装成请求对象,交给序列化层。代理可能引用错误的服务版本,方法签名可能在注册中心找不到对应提供者,参数类型在跨语言场景下也可能找不到对应映射。本地调用的类型绑定发生在编译期,不经过这些步骤。
序列化层。 请求对象需要转换为字节流才能传输。序列化协议决定可表达的类型、向前兼容方式和跨语言能力。Java 原生序列化要求双方类版本一致,字段变化可能导致 InvalidClassException;Thrift 的 binary 协议按字段编号读写,旧消费端会跳过新增字段,删除字段也不会报错;Hessian、Protobuf 有各自的兼容策略。序列化问题包括不支持的类型,例如 Map<自定义类, 自定义类> 在某些协议下序列化后再反序列化会丢失 key;也包括循环引用和大对象序列化耗时过长,使单次调用延迟超过预期。
网络传输层。 多数 RPC 框架的网络层建立在 Netty 之上(见Web 容器与 Netty:线程模型之下的 IO 模型)。Netty 的 Reactor 模型用少量 IO 线程处理连接读写,业务逻辑交给后续线程池。网络层可能发生连接建立超时、发送后对端无响应、半开连接(调用方以为连接还在,对端已经停止)和网络分区下请求已发出但响应无法返回。本地调用不涉及这些网络状态。
线程模型层。 提供方收到请求后,IO 线程把请求投递到业务线程池执行。线程池满时,请求会排队,延迟随之上升,或按拒绝策略直接失败。本地方法调用在调用方的线程栈上执行,不占用独立的执行资源;远程调用需要竞争由所有调用方共享的提供方线程池。一个慢接口占住线程,会拖慢同一提供方上的其他接口。
各层失败的含义不同:代理层失败对应配置错误,序列化层失败对应数据损坏,网络层失败对应超时或连接异常,线程池层失败对应资源耗尽。框架会把它们统一包装成异常抛给调用方。调用方要分别处理时,需要根据异常码区分连接失败、超时、序列化失败和线程池拒绝。
RPC 框架提供的调用能力
RPC 框架除了代理和序列化,还通常提供服务发现、负载均衡、超时、重试和版本管理,使每个服务不用分别实现这些功能。
服务发现。 调用方从注册中心订阅服务地址列表,而不写死提供方 IP;地址变化时由注册中心推送更新。这里只说明调用侧:调用方在本地缓存地址列表,注册中心不可用时继续使用该缓存调用。已建立的连接不受注册中心抖动影响;新实例无法注册会影响扩容,不影响存量调用。注册中心负责地址分发,不参与调用本身。
负载均衡。 框架在每次调用前从地址列表中选择一个实例,策略包括随机、轮询、一致性哈希和最少活跃连接等。负载均衡负责流量分发;健康检查或熔断负责摘除故障实例。重试时是否更换实例也由负载均衡决定,调用方不能假定重试一定会换机器。
超时与重试的默认值。 框架默认超时值未必适合具体接口:有的默认无限等待,有的设置为几秒,有的按调用类型区分。沿用默认值会把超时决策交给框架的通用假设,接口的延迟特征则由业务决定。重试默认值也会放大流量:公司服务化框架默认重试一次,使触发重试的请求数最多变为原来的 2 倍。下游恢复期间叠加重试可能形成重试风暴,分层控制见 超时重试限流降级)。
公司服务化框架的使用约定是:每个接口必须显式配置超时;重试次数必须显式声明,且只对幂等接口开启;非幂等接口关闭重试。这些约定需要调用方配置,框架不会自动保证。
接口定义:Thrift 和 Dubbo
服务化框架的接口定义有两条主要路线,对应 Thrift 和 Dubbo 两种思路。
Thrift 的 IDL-first。 接口定义写在 .thrift 文件中,用 thrift 编译器生成多语言 stub。一个 .thrift 文件可以同时生成 Java、Python、C++、Go 的客户端和服务端骨架。业务代码需要操作生成的类,新增字段需要修改 IDL 并重新生成代码。Thrift 的协议与传输分层:TProtocol 定义二进制读写规则(binary 按字段编号匹配读写、compact 用压缩变长整数编码),TTransport 定义字节流的传输方式(socket 是裸 TCP、framed 是先发长度前缀再发数据、buffered 是带缓冲)。不同语言实现相同的 protocol 和 transport 语义即可互通。公司内部的 Pigeon 建立在 Thrift 之上,OCTO 提供服务发现与治理。使用方通过 Thrift IDL 加服务引用注解定义服务,监控和治理面板体现了差异;内部实现按公司惯例不披露。
Dubbo 的 Java 接口代理。 Dubbo 不用 IDL,接口就是一个 Java interface,提供方实现这个 interface 并注册,调用方拿到框架生成的代理对象。方法调用、参数和返回值都是 Java 类型,不需要代码生成步骤。Dubbo 的设计目标是 Java 服务之间的调用。Dubbo 包含 provider(提供方)、consumer(调用方)、registry(注册中心)和 monitor(监控)四种角色。其 SPI(Service Provider Interface)扩展机制允许按接口约定替换协议、序列化、负载均衡和注册中心接入的实现;扩展点增加也会增加配置复杂度。
Thrift 要求先生成代码,支持跨语言调用;Dubbo 直接使用 Java interface,适合 Java 服务之间的调用。公司有 Java、Python、Go 多语言服务并存,因此采用 Thrift IDL 路线;纯 Java 栈可以直接使用 Dubbo 的 Java interface。
一个 Dubbo 服务引用示例:
// consumer 侧:声明需要哪个服务,框架生成代理
@Reference(check = false, timeout = 500, retries = 1)
private UserService userService;
// check=false:启动时不强校验提供方是否已注册(避免启动顺序依赖)
// timeout=500:单次调用超时毫秒数,必须显式配
// retries=1:超时或异常时重试一次,仅对幂等接口开启check、timeout、retries 都需要显式配置。超时未显式配置且默认值适用于该调用路径时,会出现切入部分的无限等待问题。
字段编号复用导致的版本兼容问题
Thrift 的字段编号兼容规则需要在接口演进时持续遵守。
一个 User 结构体有字段编号 1-5,其中编号 3(旧的中间名)后来废弃删除了。下次需要加字段时,有人复用了编号 3 放入新字段(例如手机号)。已经部署的旧消费端仍在使用旧版本 IDL 生成的类,它的编号 3 还是中间名。新提供端发来的字节流中,编号 3 是手机号;旧消费端按自己的 IDL 将其读为中间名字段。类型碰巧兼容时(例如都是 string),运行时不会抛异常,业务数据会错乱,排查也更困难。
字段编号一旦分配就不再复用;删除字段时保留编号占用(标记为 deprecated 或留空);新增字段使用新编号。Thrift 的兼容性依赖字段编号的语义稳定。编译器不知道历史,不会报告复用了已删除的编号;这一规则依赖文档约定和 review,框架不会强制。
新增可选字段(optional)可以兼容:旧消费端会跳过不认识的字段编号,新消费端遇到旧提供端缺少该字段时使用默认值。该路径支持向前兼容。复用字段编号会破坏这种兼容性。
RPC 框架的调用边界与配置要求
- 框架处理调用管道,业务方定义业务语义。 在配置正确时,框架处理请求发送和响应接收。业务逻辑是否正确、下游是否成功、调用是否幂等,仍由调用方和提供方约定。
- 远程调用的失败语义与本地不同。 方法调用的写法像本地调用,但远程调用还会遇到网络超时、未返回的响应、序列化和远端线程池竞争。每个远程调用都必须明确超时时间、失败重试次数和重试是否幂等。未作出这些配置时,线程池和上游会承担相应的故障影响。
- 具体接口需要配置超时、重试和负载均衡。 超时、重试和负载均衡策略的默认值基于框架对通用场景的假设,具体接口的延迟特征和幂等性由业务方确定。沿用默认值会将这些决策保留给框架通用配置。
- 重试放大下游压力。 重试次数是乘法关系:N 层各重试一次,最坏放大 2^N 倍。重试只对幂等接口开启,且要分层控制,避免网关、接口、负载均衡层叠加。
- 字段编号是接口契约的一部分。 Thrift 的字段编号一旦分配不复用,删除字段保留编号占用,新增字段用新编号。这靠约定和 review,框架不强制。
- 注册中心故障对调用的影响。 已建立连接的调用不受注册中心影响,受影响的是新实例注册和地址推送。调用方本地缓存的地址列表可在注册中心不可用时继续用于调用。
熔断与限流中间件:把失败当作正常状态管理讨论按失败比例自动熔断,以及通过限流主动放弃请求。
参考资料
- Thrift 官方文档与 IDL 规范(0.11 版本,protocol/transport 分层、字段编号兼容规则)
- Dubbo 官方文档(2.6 版本,provider/consumer/registry/monitor 架构、SPI 扩展、@Reference 注解)
- 超时重试限流降级(2016 系列第 13 篇,超时重试纪律,本篇引用不复述)
- Web 容器与 Netty:线程模型之下的 IO 模型(RPC 框架网络层的 Reactor 模型)
- 熔断与限流中间件:把失败当作正常状态管理(失败处理机制化)
