RPC原理拆解与BRPC实战:从协议序列化到高并发调优
那次线上事故至今印象很深。某个服务的P99延迟从30毫秒突然飙到800毫秒第一反应是查慢SQL、查GC日志、查CPU监控一圈下来全都没问题。最后把调用链拉出来才定位到是下游一个RPC接口的超时重试配置不合理多个请求叠加触发连锁等待。也就是从那时候起我意识到很多后端对RPC的理解停留在会调接口的层面对背后的协议封装、序列化、线程调度、超时语义这些底层机制完全没概念。这篇文章就是围绕RPC原理到底怎么回事和BRPC这个框架该怎么落地来写的。前半部分拆解一次远程调用的完整链路讲清楚协议、序列化、服务发现这些核心环节分别解决什么问题后半部分直接用BRPC从零搭一个可运行的服务然后讲参数调优、压测方法和线上排错思路。适合刚接触微服务、想搞懂RPC本质的后端同学也适合已经用过一些RPC框架但没系统梳理过底层机制的人。我会尽量把每个为什么都说透不搞玄学。1. 从一次线上故障说起RPC不是黑魔法是工程问题很多人在单机环境里写代码写习惯了会觉得调用一个方法就是一行代码的事。但在分布式环境里一次跨服务的调用要经过网络传输、数据编码、地址查找、异常重试等一系列环节任何一个环节出问题表现到业务层就是接口变慢了或者调用失败了。那次事故的核心原因说白了就是两个很容易被忽略的小问题叠加一是下游服务的处理线程被慢请求占满表现为偶发的长尾延迟二是上游RPC客户端的超时时间设得过大重试又在整体超时窗口内反复触发导致大量请求堆积在等待中。这种问题在单机调用里根本不存在因为单机方法调用要么成功要么抛异常不会出现不确定到底执行了没有的中间状态。而远程调用不一样网络可能丢包、服务可能重启、消息可能延迟调用方必须面对一个残酷的事实一个RPC请求发出去了你并不知道它最终是成功了、失败了还是只是慢一点。1.1 那次延迟飙升事故的排查链路先回顾一下完整的排查过程。最开始怀疑数据库因为业务接口里有两个SQL查询DBA反馈慢日志里没有新出现的慢查询行数估算也没问题。接着怀疑缓存检查命中率和超时配置正常。然后怀疑机器看了CPU、内存、IO、网络包量也都平稳。最后是把全链路追踪打开逐个节点看耗时占比才发现时间几乎全花在调用下游RPC服务的那一段上。再看下游服务的监控面板发现它的平均响应时间其实只有5毫秒但P99却高达300毫秒。这说明下游整体不慢只是有小比例的请求被拖住了。而客户端这边的超时配了3000毫秒、最大重试2次等于说一个请求最多可以等9秒于是上游线程全被这些慢请求占住雪球越滚越大。这个案例给我的教训是RPC调用的超时、重试、线程池大小不是随便填几个数字就行的它们之间有严格的联动关系。理解了原理才能在设计阶段就避免这种坑。1.2 单机调用的想当然在分布式环境里为什么失效单机方法调用默认的可靠假设是调用栈在同一个进程里、同一个内存空间方法要么执行完返回结果要么抛异常结束。整个过程耗时可能在微秒到毫秒级别不存在网络波动也不存在对端是谁的问题。远程调用把这些假设全部打破了。首先对端在另一台机器上你得先把方法名、参数打包成一个消息通过网络发送过去其次这个网络是共享的、不可控的可能延迟、可能断开最后对端进程可能已经挂了或者正在重启你得有一套机制去发现它、绕过它、在它恢复后继续连上它。所以RPC框架的本质就是把这堆复杂且容易出错的分布式通信细节封装起来对外提供一个像调用本地方法一样调用远程服务的接口。这句话每个RPC文档都会写但只有当你真正去排查过一次线上故障才会明白这个抽象背后埋了多少工程权衡。2. 一次远程调用的完整旅程协议、序列化、网络传输如何协同理解RPC最好的方式是跟着一个请求走完它的一生。假设客户端A要调用服务端B的GetUserInfo(user_id)方法这个过程大致可以拆成七个步骤。客户端业务代码调用的是本地代理对象这个代理在RPC框架里叫stub。stub把方法名和参数打包成网络消息这一步叫编码或序列化。接着通过底层网络模块把消息发到服务端服务端收到后先按协议拆包再反序列化成具体参数然后定位到对应的业务方法对象执行。执行完后结果再按同样的路径返回给客户端。客户端拿到响应数据反序列化后交回业务代码。这个过程你发现没有它和打电话极其相似。你以为在跟对方说话其实声音先被话筒编码成电信号经过交换机和线路传输再被对方听筒解码还原成声音。RPC里的stub就是话筒和听筒交换机就是服务发现和负载均衡模块线路就是网络传输层。2.1 协议设计为什么RPC必须解决粘包和拆包网络传输层提供的是字节流它不关心你的消息边界在哪。就好比你在一条传送带上不停放盒子接收方必须知道每个盒子从哪里开始、到哪里结束。这个问题在TCP编程里叫粘包/拆包问题。RPC协议一般会在每个消息前面加一个固定长度的头部头部里记录协议版本号、消息类型、请求ID、消息体长度等信息接收方先读头部再根据长度字段读取完整的消息体。这是几乎所有RPC协议的基本设计不管是二进制私有协议还是HTTP/2这种成熟协议核心思路都是用头部的元信息描述消息体。请求ID这个字段尤其重要。因为连接是复用的同一个TCP连接上会同时跑很多个请求的响应客户端必须能区分这个响应对应哪个请求。请求ID就是解决这个问题的发请求时记录ID收到响应时按ID匹配和快递单号一个道理。2.2 为什么主流RPC都在用二进制序列化而不是JSON刚接触RPC的人经常会问我直接用HTTPJSON不也能实现远程调用吗为什么还要搞Protobuf、Thrift这些序列化方案JSON确实可读性好调试方便但它有两个硬伤。第一是体积大同样的数据用JSON表示字段名、引号、逗号都要占字节第二是解析慢运行时要把字符串解析成对象再按key查找字段。在高并发场景下这两个问题会被放大到很明显的程度。Protobuf这类二进制序列化的思路完全不同。它把字段名映射成数字编号每个字段只用一小段空间表示编号和长度不需要传字段名字符串解析的时候直接按二进制布局定位字段不需要字符串比较。我用过一个控制台服务从JSON切到Protobuf后单次请求平均耗时降了40%左右payload体积也小了将近一半。另外Protobuf自带IDL定义文件这个文件既是接口文档又是代码生成器输入服务端和客户端都能从中生成代码天然保证了两边的数据结构一致。这种契约先行的开发方式多人协作时特别省心。2.3 服务发现、负载均衡与容错调用方怎么找到服务方一个RPC请求要发出去首先得知道目标服务在哪台机器上、端口是什么。最简单的方式是配置文件里写死IP和端口这在服务规模小、实例不流动时够用。但只要服务实例一变多、要扩容缩容写死的地址列表就没办法维护了。通用的做法是引入注册中心。服务端启动时把自己的地址注册上去同时上报心跳客户端启动时从注册中心拉取可用节点列表并订阅变更。这样服务端挂了注册中心会摘掉它的地址新节点上线了客户端也能及时拿到。负载均衡则是在客户端本地完成的常见策略有随机、轮询、加权、一致性哈希BRPC里在Channel的负载均衡策略里配置即可。我自己项目里通常会在Channel初始化时指定命名服务地址然后启动时打印一下拉到的节点列表确认连接建立情况。这一步很多人忽略等线上发现连的节点不对才回头查浪费时间。3. 选型对比与BRPC的核心设计凭什么它能扛住高并发市面上RPC框架不少gRPC、Dubbo、Thrift、Finagle、BRPC各成一派。选型不是看谁名气大而是看你的场景在哪里。如果你追求跨语言、生态好gRPC是很稳的选择基于HTTP/2和Protobuf浏览器、移动端都能直接调如果你前后端都在某个技术栈里Thrift和Protobuf的代码生成能带来很好的类型安全如果你的服务发现、网关、治理都需要完整体系Dubbo在Java系里产业链更完整。BRPC的定位和它们不完全一样它更偏向高性能基础设施这个角色。它在连接管理和线程调度上有自己的独特设计。这里我先不展开源码而是从工程使用者的视角说说它跟自己封装HTTP调用有多大的差别。3.1 自己封装HTTP调用和用RPC框架的差别我见过不少团队早期图省事直接在业务代码里用HTTP客户端请求服务把URL拼接在代码里参数用JSON序列化异常处理靠try-catch。这套方案在服务少、调用量小的时候确实能跑但一旦服务多了问题就开始冒头。没有统一协议每个团队定义的URL风格、错误码、超时语义都不一样没有内置负载均衡只能靠DNS或者前置代理分发实例变更时要手动改配置没有重试和链路追踪出问题了只能一台台机器登录上去翻日志。RPC框架把这些公共问题统一收编了。比如BRPC一个Channel对象就是一个对某服务的连接抽象它会帮你管理连接池、健康检查、失败重试。你只需要关心业务参数和返回结果通信细节跟你的代码基本解耦。3.2 bthread调度与连接复用BRPC高性能的两个支柱BRPC性能好的原因拆开来看主要是两个层面。第一是bthread。这是一个M:N线程调度模型把大量用户态协程映射到少量系统线程上执行。传统线程切换要陷入内核态成本高bthread的切换完全在用户态完成配合栈的复用和缓存单机支持几十万并发协程是很正常的事情。你的同步RPC代码写起来还是同步的但底层会被拆成异步调度等待响应时不会白白占着一个系统线程。第二是连接管理。BRPC默认支持单连接模式也就是一个客户端进程对同一个服务端进程只维护一个TCP连接所有请求在这个连接上并发复用。这带来了两个好处一是连接数大幅减少不会出现几千个连接挤占文件描述符的情况二是连接复用率高规避了频繁建连和断开的握手开销。代价是这个连接上的所有请求要共用一个带宽窗口所以BRPC为单连接模式设计了专门的流量控制机制。这套设计非常依赖epoll和多线程协作但只要用好了效果非常可观。我压测的时候遇到过单进程客户端用single连接模式就能打满一个小型服务集群的CPU。3.3 BRPC在工程化上的开箱即用能力除了性能BRPC让我觉得顺手的是它自带的那套排查工具。服务启动后访问/status可以看到服务在线信息/vars暴露了海量运行指标包括QPS、延迟分位、错误数、连接数/connections能直接看到当前所有活跃连接。这些能力不用额外搭建监控系统对定位线上问题帮助极大。我记得有一次排查内存增长问题就是靠/vars里的一个连接指标发现连接数异常膨胀顺藤摸瓜才找到是客户端连接池参数配错了。如果没有这些内置服务光靠日志去推可能得折腾一整天。4. BRPC实战从proto定义到第一台服务的完整过程讲了这么多原理现在动手搭一个真实可运行的服务。目标很简单客户端调用一个Echo方法传一个字符串服务端原样返回。这个例子虽然小但完整包含了RPC框架的所有核心元素IDL定义、代码生成、服务端注册、客户端调用。整个流程分四步走环境准备、定义接口、实现服务端、接入客户端。4.1 环境准备依赖与编译BRPC的依赖包括Protobuf、gflags、leveldb以及gtest编译测试时需要。在Linux环境下建议先装好这些基础库再拉取BRPC源码编译。# 安装基础依赖以Debian/Ubuntu为例 apt-get install -y git g cmake make libprotobuf-dev protobuf-compiler libgflags-dev libleveldb-dev # 拉取BRPC源码并编译 git clone BRPC官方仓库地址 cd brpc mkdir build cd build cmake -DCMAKE_BUILD_TYPERelease -DWITH_DEBUG_SYMBOLSOFF .. make -j8编译时间取决于机器配置通常几分钟到十几分钟。如果只是为了跑通核心功能可以把-DWITH_DEBUG_SYMBOLSOFF加上减少符号文件写入的IO开销。注意BRPC的版本迭代比较活跃建议直接使用当前最新的稳定分支避免老版本和现代编译器不兼容的问题。4.2 定义接口Protobuf IDL决定了通信契约服务端和客户端必须先商量好说什么语言、说哪些话。这就是proto文件的作用。我在项目根目录下建一个echo.protosyntax proto3; package demo; message EchoRequest { string message 1; } message EchoResponse { string message 1; } service EchoService { rpc Echo(EchoRequest) returns (EchoResponse); }这里注意几个细节。package demo决定了生成代码的命名空间message是请求和响应的数据结构service定义RPC方法。字段后面的数字1、2是字段编号一旦发布出去就不要改动否则会破坏已有客户端的兼容性。生成代码的命令protoc --cpp_out. echo.proto执行后会得到echo.pb.h和echo.pb.cc这两个文件包含了消息类和service骨架类的定义。4.3 服务端实现核心方法与启动参数服务端的核心任务是实现proto里定义的接口然后把实现挂到BRPC Server上去。#include brpc/server.h #include butil/logging.h #include echo.pb.h class EchoServiceImpl : public demo::EchoService { public: void Echo(google::protobuf::RpcController* cntl_base, const demo::EchoRequest* request, demo::EchoResponse* response, google::protobuf::Closure* done) override { // ClosureGuard 保证 done 被调用即使后面抛异常 brpc::ClosureGuard done_guard(done); response-set_message(request-message()); } }; int main(int argc, char* argv[]) { brpc::Server server; EchoServiceImpl service_impl; if (server.AddService(service_impl, brpc::SERVER_DOESNT_OWN_SERVICE) ! 0) { LOG(FATAL) Fail to add service; return -1; } brpc::ServerOptions options; options.idle_timeout_sec -1; if (server.Start(8888, options) ! 0) { LOG(FATAL) Fail to start EchoServer; return -1; } server.RunUntilAskedToQuit(); return 0; }几点说明SERVER_DOESNT_OWN_SERVICE表示service对象由外部管理Server关闭时不负责delete它这样可以避免对象生命周期管理混乱idle_timeout_sec -1表示连接空闲不超时压测时不会出现大量连接被主动断开的情况Start的8888是监听端口生产环境要注意端口规划。4.4 客户端接入Channel初始化与同步调用客户端的核心是Channel。它负责建立连接、维护连接池、执行负载均衡和重试。#include brpc/channel.h #include brpc/controller.h #include butil/logging.h #include echo.pb.h int main(int argc, char* argv[]) { brpc::Channel channel; brpc::ChannelOptions options; // 协议用baidu_std这是BRPC默认的二进制协议 options.protocol brpc::PROTOCOL_BAIDU_STD; // 单请求整体超时500ms options.timeout_ms 500; // 最大重试次数 options.max_retry 1; if (channel.Init(127.0.0.1:8888, options) ! 0) { LOG(FATAL) Fail to init channel; return -1; } demo::EchoService_Stub stub(channel); brpc::Controller cntl; demo::EchoRequest request; demo::EchoResponse response; request.set_message(hello brpc); stub.Echo(cntl, request, response, nullptr); if (cntl.Failed()) { LOG(ERROR) Call failed: cntl.ErrorText(); return -1; } LOG(INFO) Got response: response.message(); return 0; }这段代码看起来和本地调用很像但它背后做了很多事。channel.Init可以传入一个IP也可以传入命名服务地址cntl是BRPC的Controller负责携带本次调用的元信息比如超时、错误信息、附加数据stub.Echo的最后一个参数传nullptr表示同步调用如果想异步就传一个Closure回调。编译时把echo.pb.cc和客户端代码一起编译然后链接BRPCg -stdc17 echo.pb.cc client.cpp -o client -lbrpc -lprotobuf -lgflags -lleveldb -lssl -lcrypto -lpthread -ldl跑起来之后客户端会打印出Got response: hello brpc这就说明整个RPC链路已经通了。5. 参数调优与压测分析同样的代码性能为什么差三倍很多人在BRPC上跑出来的性能数据差异很大除了硬件因素更多是参数配置的问题。同样的Echo服务参数没调对可能只有几万QPS调好了能飙到十几万甚至更高。我梳理三个影响最大的配置项这三个都是线上经验最密集的地方。5.1 超时、重试与连接类型三个最容易踩坑的参数先说超时。BRPC里的timeout_ms是整个调用从发出到返回的最大时间不是单次网络往返的时间。这一点特别重要因为它直接决定了重试行为。如果第一次请求用了300ms才失败剩余时间只剩200ms那重试机会就很少了甚至等于没有重试。所以配置超时时必须把重试时间预算算进去。重试次数max_retry也不是越大越好。重试确实能提升短时故障下的成功率但如果下游已经过载重试只会加重雪崩。我一般建议核心服务max_retry0或1配合客户端快速失败和降级策略而不是靠无限重试去硬扛。连接类型connection_type有多个选项single、pooled、short。short每次请求新建连接适合低频短连接场景但高并发下会浪费大量CPU在建连上pooled用连接池适合连接数可控的中型规模single是单连接多路复用连接数最少、资源最省也是BRPC在高并发场景下最常用的配置。我压测时发现从pooled切到single客户端机器负载能降不少因为TCP握手和端口占用都大幅减少了。5.2 压测方法与指标解读从P99看真实服务水平压测RPC服务不要只盯平均耗时和QPS。平均耗时会被大量快请求拉低掩盖长尾问题。正确姿势是看分位值特别是P99和P999。我自己习惯用一个简单的压测工具循环发请求同时记录每次调用的耗时结束后计算分位数。也可以用BRPC自带的压测工具或者wrk这类通用压测工具。指标解读上P9920ms的意思是99%的请求在20ms内完成剩下的1%可能慢很多。如果P99明显大于P50说明服务存在周期性抖动或者线程竞争需要深入排查。压测时还要留意成功率。很多压测工具只看QPS和延迟不统计错误码导致结果虚高。我的做法是压测程序里同时统计非0返回码的比例任何非零错误都视为失败这样压出来的数据才真实可信。5.3 内置监控与排查工具/status、/vars怎么用服务跑起来后BRPC自带一个HTTP端口提供多种内置服务。我最常用的三个是/status、/vars和/connections。/status展示服务的基本状态包括监听的端口、注册的service列表、是否在正常运行。运维新加节点时我用它确认服务是否注册成功。/vars是按名字暴露出的一组运行时指标和监控系统的metric结构类似。里面有大量以rpc_开头的指标比如rpc_server_elapsed_ns_percent系列可以看延迟分布rpc_server_error_code系列可以看错误码计数。线上排查时我会按变量名过滤把异常指标和调用量变化对应起来。/connections直接列出当前所有TCP连接的对端地址和连接状态。有一次我从这里一眼看出某个客户端地址的连接数异常多随即联系对应团队调整了连接池配置。6. 几个真实踩过的坑与排查思路最后分享几个我在实际项目里踩过的坑。这些都不是什么高级技巧但每一个都切切实实坑过团队写出来算是帮大家省点时间。6.1 线程阻塞导致整体延迟抬升的真凶有一次服务改造把同步调用换成异步以后P99反而更差了。排查发现是业务代码里混用了同步阻塞调用某个第三方库的API内部会阻塞线程等待锁导致bthread worker被长期占住。BRPC的baidu_std协议在连接上是顺序请求并发处理的模型worker被占满后新请求就只能排队表现为P99被不断拉高。这个问题的排查思路是先看/vars里worker队列深度或者线程状态确认瓶颈不在CPU而在阻塞然后把可能阻塞的调用逐个替换成非阻塞版本或者给这些调用单独设置短超时避免无限等待。现在我对所有线程池、协程模型都有同一个原则不要在共享worker里放任何可能长时间blocking的调用。6.2 超时重试设置不当引起的雪崩开头提到的线上事故本质就是这个。客户端配了3000ms超时加2次重试看起来是为了更可靠实际上当对端出现短暂故障时所有请求都会卡满整个超时窗口上游线程池迅速耗尽整个服务跟着雪崩。现在我的做法是分层控制给RPC调用设置一个偏紧的超时比如P99的5到10倍重试次数严格控制且只在明确知道对端没收到请求的情况下重试配合快速失败的开关让系统在异常时优先保护自己而不是硬扛。这其实和熔断的思路是一致的宁可局部失败不要整体拖垮。6.3 客户端连接打满后出现大量快速失败还有一次是连接池参数没调好。客户端用pooled连接模式但连接池上限设得很小高流量一上来连接全部被占用新请求拿不到连接直接快速失败。现象上服务端CPU没满客户端却大量报错看起来像网络问题实际上就是连接管理器不够用。排查时从/connections看到连接数涨到上限就不再增长报错集中在连接获取失败马上就定位到问题。后来把连接类型切到single同时提高连接池上限再配合重试策略问题就解决了。那次之后我养成了一个习惯每次新上线涉及RPC的服务先做一轮小规模压测把超时、重试、连接类型、线程数这几个核心参数都验证一遍确认没有资源瓶颈再放量。RPC这东西原理搞清楚了参数调对了其实是很好用的工具怕的就是不理解内部机制按直觉配置出了问题都不知道该往哪个方向排查。希望这篇文章能帮你省掉当初我踩的那些坑。