拓冰建站拓冰建站
首页 / 资讯中心 / 正文

BRPC如何支撑百万级QPS:从socket到bthread的架构深度解析

聊到 C 网络编程稍微有点实践经验的人基本绕不开一个名字百度开源的 BRPC。这确实不是又一个普通的 RPC 框架很多团队拿它扛在线检索、推荐、流量调度这类对性能和延迟极度敏感的模块线上单机跑到几十万甚至百万级 QPS 的场景真实存在。我这几年代码里跟着 BRPC 打交道的时间不算短从最初照着文档写 Hello World到后来排查连接数异常、内存猛涨、EAGAIN 刷屏踩过的坑基本能凑一篇文章。今天这篇不想写成文档翻译想把 BRPC 为什么能扛住百万级 QPS 这句话拆开从 socket、IO 模型、线程调度讲到协议接入、负载均衡、监控调优一条线讲清楚。这篇东西适合两类人看。第一种是刚接触网络编程想搞明白 RPC 框架到底解决什么问题、底层长什么样的同学第二种是自己服务已经上了 BRPC但遇到性能瓶颈不知道怎么调或者被各种诡异问题折磨到怀疑人生的朋友。不管你是打算直接用 BRPC还是想参考它的设计自己写一个小网络框架下面这些内容都有能落地的东西。1. 先搞清楚 BRPC 是什么为什么值得研究1.1 RPC 框架在分布式系统里是什么角色先从最基础的问题说起为什么需要 RPC 框架单机程序里要调用一个函数写好函数名、传参、拿返回值就行。但在分布式系统里服务拆到不同机器上A 机器要调用 B 机器的函数中间隔着 TCP 连接、序列化、反序列化、超时、重试、负载均衡这一大堆破事。如果你每次都用原生 socket 去拼报文、解析响应那代码里一半以上都是通信逻辑业务代码反而没剩多少。RPC 框架的角色就是把“网络调用”伪装成“本地调用”。你用 BRPC 定义一个 EchoService客户端只要构造一个请求对象调一下 stub 的方法就能等回一个响应对象中间怎么连 socket、怎么序列化、怎么路由到对端机器全是框架的事。这样一来团队协作的边界也清爽了服务端暴露的是接口定义客户端只管拿着接口定义调用不关心服务部署在哪台机器、后面挂了几个实例。BRPC 在这件事上比很多框架走得更彻底。它不只是做序列化和传包而是把连接管理、IO 调度、服务治理、监控告警全打包在一起。你启动一个 BRPC Server它自动给你一套 HTTP 接口看状态你写一个 BRPC Client它默认就在做负载均衡、超时重试、健康检查。这也是为什么很多团队宁可学一个相对复杂的 C 框架也不用语言自带的简单 RPC 方案。1.2 BRPC 和别的 RPC 框架比差异在哪市面上 RPC 框架非常多Java 系有 Dubbo、Spring CloudC 系还有 grpc、thrift、Yet Another RPC Framework。BRPC 在设计上有一个很明显的倾向它在“性能”和“控制力”上压到了极致同时又不是牺牲易用性换来的。第一它用 C 写的但这不等于让你天天跟裸指针较劲。BRPC 对外暴露的 Channel、Server、Controller 这些对象封装程度高接口风格接近工业级。你不需要关心 epoll 怎么用但一旦性能出问题你又可以打开源码看到每个调度环节。第二它的 bthread 模型是一大特色。Java 里做高并发一般用线程池C 传统做法是每连接一线程或者纯异步回调。BRPC 选择用用户态协程把“同步写逻辑”和“高并发能力”调和起来这件事后面单独讲。第三多协议支持非常实用。Baidu 自己的 baidu_std 协议、HTTP、gRPC、Redis 协议BRPC 都能接。这意味着你在一个进程里可以同时用 RPC 服务内部模块用 HTTP 暴露接口给外部系统甚至可以伪装成一个 Redis 实例给老客户端用。这种东西在实际运维里省掉的中间层不是一点点。所以 BRPC 从来不是一个“用起来很简单”的玩具框架它更适合那种已经意识到网络细节决定上限、愿意花一点成本换性能和可观测性的团队。接下来进入正题看它到底怎么把百万 QPS 变成可能的。2. 百万级 QPS 的底层支撑从 socket 到 bthread2.1 socket 编程的老问题阻塞、线程和连接网络编程绕不开 socket。最直觉的写法是服务端 listen 之后客户端连进来就 fork 一个线程去阻塞地 read。这种一个连接一个线程的模式在连接数几百个的时候还能凑合连接数上万就崩了线程切换和内存开销能把 CPU 吃干净。解决思路大体分两路。一路是“事件驱动”用一个线程监听所有 socket 的读写事件哪个 fd 可读就处理哪个另一路是“协程化”把每个请求的等待状态放到轻量级执行单元里让线程不必阻塞。BRPC 不是二选一它把两路都用了然后在一堆细节上继续抠性能。老 C 程序员应该都有印象读 socket 的时候经常遇到 EAGAIN 和 EWOULDBLOCK这两个错误就是在告诉你“现在没数据你可以干别的了。”传统阻塞 IO 里线程会睡在 read 上事件驱动模型里你先得把 socket 设成非阻塞然后把 fd 交给 epoll 去盯。BRPC 的核心 IO 处理就是沿着这条路走但比普通 Reactor 多做了很多工程化封装比如批量读取、对端关闭的边界情况、连接的优雅回收。2.2 epoll 多 Reactor每个线程管一摊事BRPC 在 Linux 上的 IO 引擎核心是 epoll。它不是开一个全局的 epoll 去处理所有 fd那样做并发稍微一高就要在事件分发上抢锁。BRPC 的做法更接近多 Reactor 模型启动多个 IO 线程每个线程一个 epoll 实例然后某种策略把连接分配给不同线程。这里有一个细节非常关键一个连接被分配到某个 IO 线程之后这个连接上所有的读写和状态变更基本都在这个线程内完成。也就是说同一个连接不会同时被多个线程操作天然避免了连接级锁竞争。这个设计思路朴素但极其有效——既然锁冲突是性能杀手那就从结构上减少需要加锁的场景。epoll 本身有水平触发和边沿触发两种模式。水平触发简单只要缓冲区有数据就一直通知你边沿触发只在状态变化时通知一次效率高但要求你必须一次把数据尽量读完否则可能丢事件。BRPC 对高性能路径使用边沿触发配合非阻塞 IO读逻辑里的循环要非常小心这也是很多人在自己写网络库时最容易翻车的地方忘记读到了 EAGAIN 才收手或者没处理半包。另外现代网卡都有多队列多 IO 线程能从底层利用多核。BRPC 在绑核、CPU 亲和性上也有考虑不过这些属于“最后 5%”的性能优化。对多数系统来说Reactor 模型配合非阻塞 IO已经比传统每连接一线程拉开一个数量级了。2.3 bthread用同步的写法拿异步的性能讲完 IO 线程另一个绕不开的话题就是 bthread。BRPC 的整个请求处理模型是建立在这套协程调度上的。你可以把 bthread 理解成用户态线程但比系统线程轻得多。系统线程创建销毁需要进内核栈空间动辄几 MB而 bthread 的栈是用户态分配的可以做到 KB 级创建和切换成本低好几个数量级。BRPC 会启动若干个 worker 线程上层业务提交一个“你要处理这个请求”的任务worker 就从就绪队列里拿出来执行。这里最妙的地方在于bthread 在网络等待时能做自动切换。传统同步 RPC 代码里你调用了 RPC 之后线程就会阻塞住如果一个 worker 线程只处理一个请求并发请求多了就得开很多线程。BRPC 里 bthread 在等待对端响应时调度器会让出 CPU 给其他 bthread等响应回来了再切回来继续执行。对你来说代码写起来是“一行一行往下走”的同步式逻辑但底层实际是“大家都在轮流跑谁等数据就让位”的异步调度。这就是 BRPC 能保持高并发的同时不逼你写回调地狱的关键。C 业务代码里如果你想做异步逻辑传统方案是通过回调函数把状态传来传去代码结构立刻扭曲。bthread 相当于给了你一套“看起来像线程用起来像协程”的执行单元你只需要按业务逻辑顺序写性能就交给框架。2.4 锁、拷贝和调度细节里藏着的性能百万级 QPS 不是靠某一个黑科技而是靠数不清的小优化堆出来的。锁竞争是第一个大头。BRPC 内部大量使用无锁或细粒度锁设计。刚才提到连接绑定到固定线程避免了连接级锁对于需要跨线程修改的统计指标则用原子变量或缓存近似值来替代全局互斥锁。如果你在单机上压测 BRPC 并开内置监控能看到 mutex 等待相关指标很多情况下数值都很低这不是偶然是刻意设计的结果。数据拷贝是另一个容易被忽略的点。网络传输里如果你在每一层都来一次 memcpy数据量一大 CPU 就被吃光了。BRPC 的 IO 路径上尽量用 Iovec 批量发送把不同缓冲区组合成一次写调用减少系统调用次数和内存拷贝。高性能网络编程里“系统调用越少越好”是这个行当的铁律readv/writev 这类批量接口比一次读一个 int 再循环发送高效得多。调度器的细节也值得一提。BRPC 的 bthread 调度不是简单把所有任务丢到全局队列它会考虑本地队列、工作窃取和任务优先级。一个 worker 优先从自己的队列拿任务避免频繁访问全局队列导致竞争空闲时再从别的 worker 那里偷任务把多核利用率拉满。很多人在自己的网络库遇到“某个核打满其他核闲着”的问题本质上就是任务分发策略没做好。从这一整套机制可以看出百万级 QPS 的根基是两层底下一层是优秀的多 Reactor IO 框架上面一层是轻量级协程调度中间再用细粒度锁和减少拷贝把漏洞堵住。再往上就是工程能力的问题了。3. 不只是快协议接入与服务治理3.1 多协议接入一个框架吃掉所有流量性能再强如果只支持自己的私有协议很多场景还是用不上。BRPC 的协议层设计得非常灵活它内置了 baidu_std、HTTP/HTTPS、h2、gRPC、Redis 等多种协议解析能力。baidu_std 是 BRPC 自带的二进制协议设计紧凑、解析快适合内部模块间的高频调用。但对外系统不一定愿意接你的私有协议这时候你把同一个 Server 再开启 HTTP 端口外部系统直接用 HTTP 就能调用你 C 服务的能力不用做任何协议转换。更妙的是BRPC 可以不写一行 RPC 业务代码直接作为 Redis 协议的服务端或客户端存在你把它放进一个 Redis 生态里做定制化缓存逻辑老客户端完全无感。这种多协议能力带来的实际收益是明显的你可以用一套 C 服务同时应对内部 RPC、前端 HTTP、周边 gRPC 生态的调用不需要为每种协议各起一个进程也不需要在外面挂一层协议转换网关。省下的机器成本和运维复杂度是实打实的。协议自适应是另一个贴心设计。也就是说同一个端口上来的连接BRPC 会通过首包特征自动判断这是哪种协议然后分发给对应的解析器。这意味着乙方不需要在 Client 里强制指定 protocol很多时候你拿一个现成 HTTP 客户端连上去就能调给联调省了很多事。3.2 负载均衡、超时重试、熔断限流一个框架如果只有通信能力那它最多算传输层封装称不上服务治理。BRPC 把分布式系统里常见的流量治理能力内置到了 Channel 和 Server 里。客户端负载均衡是内置的重头戏。Channel 可以指向一个服务名或一组地址BRPC 提供 round-robin、随机、一致性哈希、加权、自适应等多种策略。比如你有 10 个实例按权重分配流量或者根据 key 做一致性哈希让同一类请求固定打到同一台机器都是配置层面的事不需要在业务代码里自己维护路由表。超时和重试也做得比较克制。Channel 有 connect_timeout_ms 和 timeout_ms超过时间直接判定这次调用失败避免业务一直等。失败之后还可以配置重试次数。这里有个坑我后面细说重试不能乱开尤其对写操作如果接口不是幂等的重试可能造成重复数据。服务端的保护也很重要。BRPC 有 max_concurrency 并发限制超过阈值的请求可以直接被拒绝或排队防止一个慢接口把整个进程拖死。对于连续失败的节点BRPC 会在一定窗口内暂时熔断不再把新流量打过去让它喘息一下。这种保护机制在生产环境里非常重要否则一个下游实例抖动会把整个调用链条都拖崩。3.3 内置监控bvar 和 /vars 的妙用我见过很多自研框架性能和功能都还行唯一的问题是排障太难出了问题只能打日志猜。BRPC 在这方面做得超出预期。它内部有一套叫 bvar 的统计体系把 QPS、延迟分布、连接数、FD 数、锁竞争、队列深度这些指标都实时记录下来。Server 启动后直接在浏览器里打开 /vars 页面就能看到一大屏监控数据/status 页面能看到服务和连接的健康状态。这套东西部署即用不需要额外接监控系统对自己定位问题、对团队做容量评估都特别方便。有个细节很实用QPS 指标不是简单“总数/时间”BRPC 会展示分位延迟比如 p99、p999。平均延迟低不代表服务稳定一个系统如果有 1% 的请求慢得离谱整体体验也会被拖垮。看 p99 和 p999 才能真正抓到长尾问题。另外 BRPC 还提供了内置的 RPC 调试页面可以在浏览器里直接发一个测试请求到本地服务查看返回结果。这个功能在联调和测试环境里堪称神器不用另外写 curl 或测试客户端大大降低排查成本。4. 从零上手 BRPC编译、代码与调优4.1 编译安装与依赖准备先把环境跑起来再说。BRPC 在 Linux 和 macOS 上都能编译生产环境基本是 Linux。编译之前需要准备几个基础依赖protobuf、leveldb、gflags、glog、openssl部分特性还可能需要 zlib 和 ssl 相关开发包。用 apt 或 yum 装齐这些之后再拉 BRPC 源码编译整体的流程比大多数 C 项目要顺。官方仓库里提供了 config_brpc.sh 之类的配置脚本也可以直接用 CMake。我个人的习惯是先读一遍 BUILDING 文档再动手因为 BRPC 对编译器版本有要求太老或者太新的 GCC 都可能出现诡异的编译错误。编译参数里建议开启 Release 模式开 Debug 的话性能会差不少而且很多性能问题在 Debug 下根本复现不出来。编译过程中如果报找不到符号多半是编译排序或者依赖版本不匹配的问题。比如 protobuf 版本和 BRPC 要求的版本不一致链接阶段会出现一堆 undefined reference。这种问题不要硬调直接看文档里推荐的版本把依赖对齐比在 Makefile 里折腾快得多。4.2 写一个带协议的 Server 和 Client编译装好之后写一个最小可用的示例是理解 BRPC 的最快路径。一般先用 protobuf 定义服务接口然后生成代码再写 Server 和 Client。proto 文件大概是这样的syntax proto2; package example; message EchoRequest { optional string message 1; } message EchoResponse { optional string message 1; } service EchoService { rpc Echo(EchoRequest) returns (EchoResponse); }服务端代码很简单核心就是实现 EchoService 定义的接口然后把它注册到 brpc::Server 上#include brpc/server.h #include echo.pb.h class EchoServiceImpl : public example::EchoService { public: void Echo(google::protobuf::RpcController* cntl_base, const example::EchoRequest* request, example::EchoResponse* response, google::protobuf::Closure* done) override { brpc::ClosureGuard guard(done); response-set_message(request-message()); } }; int main() { brpc::Server server; EchoServiceImpl service; brpc::ServerOptions options; options.idle_timeout_s -1; if (server.AddService(service, brpc::SERVER_DOESNT_OWN_SERVICE) ! 0) { return -1; } if (server.Start(8000, options) ! 0) { return -1; } server.RunUntilAskedToQuit(); return 0; }客户端也相对直白#include brpc/channel.h #include echo.pb.h int main() { brpc::Channel channel; brpc::ChannelOptions options; options.protocol brpc::PROTOCOL_BAIDU_STD; options.timeout_ms 500; options.connect_timeout_ms 200; if (channel.Init(127.0.0.1:8000, options) ! 0) { return -1; } example::EchoService_Stub stub(channel); brpc::Controller cntl; example::EchoRequest request; example::EchoResponse response; request.set_message(hello brpc); stub.Echo(cntl, request, response, nullptr); if (cntl.Failed()) { // 在这里处理失败 return -1; } return 0; }有一点必须注意服务端方法的第四个参数 done 是框架用来标记请求完成的你在实现里要么自己调用 done-Run()要么用 ClosureGuard 保证在函数退出时自动触发。如果忘记处理 done请求会永远悬在那里客户端等到超时服务端连接可能也会被异常回收。这种问题不崩溃、不报错但线上请求会隔段时间抖动一下非常隐蔽。4.3 关键参数与性能调优代码跑通之后可以把注意力放到参数调优上。BRPC 的 ServerOptions、ChannelOptions 里有一批高频参数值得认真对待。线程数方面BRPC 默认会根据 CPU 核数创建 worker 线程这个默认值在大多数场景下是合理的。很多人一上来就把 num_threads 调到几百结果锁竞争加剧、上下文切换变多QPS 反而下降。我的经验是先从默认值开始压测逐步往上加到 2 倍、4 倍观察 QPS 和 p99 延迟的拐点而不是拍脑袋定一个很大的数。超时参数设置是个平衡术。timeout_ms 设太短慢请求容易被误杀设太长下游故障时请求堆积在本地拖垮自身线程池。connect_timeout_ms 建议比 timeout_ms 更短因为连接失败可能是暂时的重试比超时更有效。重试次数建议不超过 2 次配合幂等设计来用宁可让调用失败也不要重复执行写操作。并发保护参数 max_concurrency 也很关键。如果你的服务里有一个下游调用特别慢不设上限的话大量请求会堆积在 bthread 队列中内存涨得飞快最终进程 OOM。设置合理的 max_concurrency 相当于给服务加了一个保险丝超出的请求快速失败反而比慢慢排队更健康。5. 实战踩坑与排查思路5.1 典型事故一QPS 低得像单线程之前接过一个线上问题服务配置不差几十个核但压测 QPS 只有几万CPU 利用率还不到一半。打开 /vars 看 bthread 数量和队列深度发现任务全堆在某个 worker 的队列上其他 worker 都闲着。这就是任务分发不均匀的典型症状。排查下来原因出在调用链上有一处同步逻辑用了全局锁某个线程一旦持锁时间过长其他线程抢锁失败后陷入等待调度器无法把任务分发出去。BRPC 手里的锁竞争指标是排障利器直接看 mutex 相关计数器哪个锁的等待时间在上涨一目了然。后续把持锁临界区改小、用更细粒度的锁替代全局锁QPS 才恢复正常。这类问题给一个很大的教训性能瓶颈往往不在 BRPC 框架本体而在你的业务代码里。框架只是把底子给你搭好如果你在业务里制造了串行约束再牛的网络层也救不回来。5.2 典型事故二内存和 FD 异常另一个常见事故是连接数猛涨进程 FD 数超过 ulimit 上限新连接进不来老连接也出现大量异常。排查时先打开 /connections 页面看看每个连接的空闲时间和对端地址。有次发现一批连接来自同一个客户端 IP但每个连接的使用时间都很短原来是对端代码每次请求都新建 Channel用完不销毁把连接池放弃了。BRPC 的 Channel 设计上是可以长期复用的你要尽量使用单例或连接池。频繁创建 Channel 不只是浪费资源还会造成大量 TIME_WAIT 连接最终把端口和 FD 打满。修复方式很简单把 Channel 提前 init 好所有请求复用同一个 channel同时避免在短生命周期对象里保存 channel 副本。内存增长类的坑也比较常见。如果你在 Server 方法里每次 new 一块内存并塞到 done 回调里但回调结束后忘了释放内存就会一点一点涨上去。处理请求的正确姿势是尽量用栈上对象或统一的 RAII 对象需要异步持有的资源一定要在 done 回调里明确释放。5.3 排查工具和方法论BRPC 排障的正确顺序我建议是先看 /status 和 /vars再看日志最后才考虑上 gdb 和 perf。/status 页面上有连接状态、服务状态、版本号、运行时长这些基础信息CPU 被打满时先确认是不是有异常流量在打你/vars 页面的价值在于看趋势如果你把 bvar 接入监控系统能回溯事故前后的指标变化找到真正的触发点。日志方面BRPC 的 C 客户端在 cntl.Failed() 时会带着 ErrorCode 和错误信息绝大多数网络问题在这里能直接看清比如 ECONNREFUSED、ETIMEDOUT、EINVAL。不要只打“调用失败”这样的笼统日志一定把 cntl.ErrorText() 打出来包括对端地址和协议信息。到了需要上 gdb 或 perf 的地步问题一般已经超出框架范畴比如业务死锁、内存踩踏、无限递归。这种情况下先复现现场再抓 core dump 或火焰图。BRPC 本身是有名字的线程和 bthread 模型上手分析有一定门槛但只要前面几步排查到位这一步通常不会被触发。6. 一点个人体会最后说点掏心窝的话。我在实际项目里越来越觉得QPS 数字本身不是终极追求能用一套框架把“性能底线”和“工程化能力”同时兜住才是最难的事。BRPC 厉害的地方是它没有把想法停留在纸面上而是用 bthread、细粒度锁、多协议这些具体设计把纸上谈兵的“百万级 QPS”变成了可复现的工程结果。如果你只是写一点小工具、小服务BRPC 可能会显得“重”它的学习曲线和编译成本摆在那里。但一旦你的系统真的到了“稍不注意就雪崩”的规模你就会明白框架帮你把网络层的脏活累活都接下来让你有余力专注业务逻辑这才是它最大的价值。如果你也打算在自己的项目里用 BRPC我的建议是别急着调参数先把监控、超时、重试、并发保护这些“防守型”配置打扎实。经过几次线上事故之后你会发现保证可用性往往比冲高 QPS 更值得投入精力。这也是我这几年被现实教育出来的最大体会。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门