ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

BRPC百万级QPS架构解析:bthread协程调度与IOBuf零拷贝

BRPC百万级QPS架构解析:bthread协程调度与IOBuf零拷贝 在接触BRPC之前我一直有个困惑像Thrift、gRPC这些已经很成熟的RPC框架为什么百度还要花大力气自研一套直到自己上手做了几个高并发服务被线程上下文切换、内存拷贝、连接串行这些隐形成本反复摩擦之后才真正理解BRPC的价值所在。这篇东西不是官方文档的复述我想从一个使用者的角度把BRPC能做到百万级QPS的底层逻辑拆开来讲为什么它的bthread调度能扛住高并发为什么同一条连接可以并行收发请求为什么说IOBuf的零拷贝是性能的隐形推手。如果你是做网络编程、对RPC框架性能有追求或者正在为自己的服务做技术选型这篇文章应该能给你一些启发。1. 百度为什么要自己写一个RPC框架1.1 通用RPC框架在超大规模服务化场景下的乏力很多人以为RPC框架就是把socket封装一下加上序列化、服务发现、负载均衡就完事了。这种认知在小规模场景下没啥问题但放到百度内部那种成千上万个服务节点互相调用的环境里问题就暴露出来了。最典型的问题是线程模型。传统的RPC框架大多采用“一个请求一个线程”或者“线程池阻塞IO”的模式。假设每台机器要支撑上万并发请求线程数就得开到几百上千。线程一多操作系统调度开销、缓存争抢、锁竞争都会急剧上升。更麻烦的是很多业务逻辑里会有相互依赖的RPC调用一个上游请求可能要异步等待多个下游响应如果用同步线程模型线程会被白白挂起等待CPU利用率低得可怜。另一个问题是连接管理。长连接虽然避免了频繁建连的开销但常规实现里一条连接在某个时刻只能处理一个请求后面的请求必须排队等前面的响应回来也就是所谓的队头阻塞。高QPS场景下连接数量和服务规模同步膨胀文件描述符、内存缓冲、内核协议栈的压力都会成为瓶颈。1.2 BRPC的功能全景和技术画像BRPC全称是Baidu RPC一个基于C的高性能工业级RPC框架。它的核心设计目标总结起来就一句话让业务代码用最朴素同步的方式写但底层拿到接近异步的性能。从功能上看BRPC并不是只提供远程调用能力。它内置了命名服务、负载均衡、熔断降级、全链路跟踪、内置监控面板这些服务治理能力还支持多协议接入。也就是说同一个server端口既能服务BRPC自己的baidu_std协议也能解析HTTP请求甚至能兼容gRPC和Thrift的协议头。这种多协议能力极大降低了服务迁移的成本老服务用HTTP暴露接口新框架用BRPC两者可以和平共处。技术画像上BRPC的关键词可以列这么几个协程调度器bthreadM:N模型用户态线程IO线程与业务线程分离避免读写阻塞业务逻辑同一条TCP连接上的多请求并发突破队头阻塞基于引用计数的零拷贝缓冲区IOBuf内置多种负载均衡算法和熔断策略这些设计不是孤立的它们彼此咬合共同构成了一套为高并发而生的体系。接下来的几个章节我会逐一拆解这些东西到底是怎么运作的。2. bthread协程调度百万级QPS的关键引擎2.1 为什么“线程池锁”这条路走不远先看一个具体的压力场景。假设服务端收到一个请求平均处理耗时是100微秒其中一半时间在等下游RPC返回一半在做本地计算。如果要撑到10万QPS意味着同一时刻有大约5到10个请求在并发处理——听起来不多对吧但如果请求处理耗时变成10毫秒其中8毫秒在等下游那么同样支撑10万QPS就需要同时有800到1000个请求在途。如果按传统线程池的思路你得开800到1000个线程。每个线程有独立的栈空间、内核调度实体线程切换涉及用户态到内核态的切换加上缓存失效实际开销远高于“切换一次几微秒”的理想值。更别提多个线程同时访问共享数据时要加的锁锁竞争一上来性能曲线会断崖式下降。还有一种方案是异步回调。用一个事件循环加状态机来管理每个请求的整个生命周期线程少开销低但代价是业务代码被拆得七零八落写起来极其痛苦。百度的服务端业务逻辑往往很复杂层层嵌套的下游调用如果用回调来写基本没法维护。所以BRPC选择了第三条路让业务代码继续保持同步风格但底层用用户态协程来承载并发。2.2 bthread如何在一个pthread上调度一万个“线程”bthread是BRPC的协程调度层全称是Baidu Thread遵循M:N模型M个bthread协程被调度到N个pthread上运行N通常取CPU核数或机器负载配置。每个pthread都是一个调度循环它从任务队列里取出一个bthread的上下文切换进去执行。bthread执行过程中如果遇到阻塞操作——比如等待某个IO事件、等待一个锁、或者显式调用bthread_sleep——就会主动让出CPU把自己挂到等待队列调度器立刻切换去执行下一个就绪的bthread。这一切切换动作全部发生在用户态不涉及系统调用所以单次切换的成本可以控制在几十纳秒这个量级。更关键的是work stealing机制。当某个pthread上的任务队列空了它会去其他pthread的队列尾部“偷”任务这样能避免某些核忙死、某些核闲死的负载不均问题。BRPC在创建bthread时还会考虑cache locality尽量让相关的bthread在同一核上运行减少缓存迁移。正是这个设计让一台几十核的机器可以同时承载十万甚至百万级别的并发bthread。每个bthread的栈空间可以按需分配初始栈大概几十KB但成千上万个bthread同时挂起时它们基本不占CPU只占少量内存。业务线程池需要1000个线程才能扛住的并发bthread用几十个pthread就能扛下来CPU几乎全部花在处理业务逻辑上而不是花在线程调度和上下文切换上。2.3 同步写法、异步性能使用层感受从使用者的角度来说bthread带来的最直观体验是写代码的成本大幅降低了。服务端收到一个请求框架自动为这个请求分配一个bthread业务函数就像处理单线程请求一样顺序执行。如果你在服务端代码里要调用另一个服务直接同步调用就行不会阻塞整个进程的吞吐量——因为阻塞的只是一个bthread调度器会立刻调度其他bthread继续跑。我自己第一次跑这个模型时有点不敢置信。以前写异步代码要维护状态机、回调函数还得小心处理并发安全。换成BRPC之后业务逻辑的代码量差不多砍掉一半性能却比之前用epoll手写状态机还要好。因为手工状态机虽然后端是异步的但少了bthread调度层带来的“代码即流程”的灵活性一旦遇到条件分支嵌套状态机的状态爆炸会让代码完全不可维护。这里要提醒一点bthread的阻塞是指bthread层的协作式调度。如果在业务代码里直接调用sleep、pthread_mutex_lock、或者执行一个很重的CPU计算依然会阻塞整个pthread上的所有bthread。BRPC提供了bthread_mutex、bthread_sleep等替代接口就是为了让这些阻塞操作挂起的只是单个bthread而不是底层pthread。因此在BRPC服务端里尽量不要用原生系统级的阻塞调用这是性能优化的基本素养。3. 读写与处理分离IO线程模型到底优化了什么3.1 每个Socket都有一个专属的调度上下文很多RPC框架在处理网络IO时要么用独立的IO线程统一poll所有连接要么直接用业务线程去阻塞读。前者的瓶颈是全局事件循环在高并发下会产生严重的锁竞争后者的问题更直接——业务线程被读写卡住CPU利用率上不去。BRPC的IO模型把这两者做了一个巧妙的折中。框架为每个活跃的Socket维护独立的调度上下文由专门的bthread去处理这家连接上的读事件。也就是说读写事件不是集中在一个全局循环里串行处理的而是分散到多个bthread上并行处理天然利用了多核能力。当读到一个完整的请求包后IO层不会自己去执行业务逻辑而是把这个请求封装成一个task投递给业务bthread调度器。业务算完结果后返回的响应数据也通过bthread事件通知IO调度上下文继续发起写操作。读写与业务处理分属两套调度体系互不阻塞这是BRPC吞吐量的基础保障之一。这种模型还带来了一个额外好处如果某条连接特别“吵”比如客户端疯狂发送大量小包影响的只是这条连接对应的bthread其他连接依然能正常读写不会出现一损俱损。3.2 一条TCP连接上的并发请求与响应传统RPC框架在使用长连接时通常一个连接同一时间只能有一个在途请求。发出请求后必须等响应回来才能发下一个。如果下游服务处理很慢、或者网络延迟偏高这条连接就一直闲着QPS上不去。为了压满吞吐客户端只能被迫建立大量连接这会带来文件描述符消耗、内存开销、以及服务端的连接数管理压力。BRPC突破了这点。它允许同一条TCP连接上同时存在多个在途请求每个消息头里携带一个请求的唯一标识。响应返回时不需要按照发送顺序先回来的先配对对应的bthread被唤醒。这种设计本质上就是连接级别的多路复用效果和HTTP/2的多路复用非常像但不需要升级协议到HTTP/2。这个特性对性能的影响极其显著。比如一个下游服务的平均延迟是5毫秒传统连接模型下一条连接每秒最多处理200个请求BRPC的多路复用模型下一条连接可以同时并发几十上百个请求单连接的处理能力轻松提升一到两个数量级。连接数少了内核协议栈的压力也小了整个集群的连接规模可以被压缩好几个量级。3.3 IOBuf引用计数驱动的零拷贝缓冲区网络编程里内存拷贝是一个特别容易被低估的杀手。假如一个请求从网卡到应用层要经过内核缓冲区拷贝到用户缓冲区、从用户缓冲区再拷贝进包头结构、序列化结果再拷贝出来粘连到TCP发送缓冲……一次请求链路下来可能产生五六次大块内存拷贝。100微秒的处理时间里内存拷贝和锁竞争往往是CPU时间的真正去处。BRPC的IOBuf把数据组织成多个内存SegmentSegment之间通过链表串联整个缓冲区对象采用引用计数管理。切分、合并、拼接数据块时大部分操作不需要真正移动内存数据只需要调整Segment的引用关系。比如你拿到一个网络包想剥掉前面的头部、再拼接一段尾部数据IOBuf只需要修改几个引用计数和指针偏移根本不用把数据复制来复制去。更妙的是IOBuf可以直接配合writev系统调用把一段逻辑上连续的数据一次性写入内核。它天然支持非连续内存空间的聚合发送省掉了“先拷贝到连续缓冲区再发送”这一步。我自己在做网关类中间件的时候经常需要把多个消息片段拼装成一个大包转发出去IOBuf这种设计省掉了大量无效拷贝对CPU消耗的改善肉眼可见。耗时占比是一个很容易被忽略的问题。有人单纯比较RPC框架的CPU耗时却没注意到在内存分配频繁的场景下tcmalloc/jemalloc这类优化过的分配器会显著影响性能。BRPC对这类工程细节处理得很细致这也是它能在大规模集群里稳定运行的原因之一。4. 连接管理、协议适配和服务治理性能之外的另一半4.1 连接池、混合连接与keepalive细节在高并发场景下连接管理策略决定了框架的扩展性上限。BRPC的客户端会为每个目标节点维护一个连接池连接池里的连接按需建立并长期复用。建连是相对昂贵的操作涉及TCP三次握手和协议握手如果每来一批流量都新建连接延迟和CPU开销都会飙升。连接复用的意义不只是省握手时间更是让TCP窗口、拥塞控制状态得以保留避免每次都要重新进入慢启动状态。BRPC默认的混合连接模式很有特色。客户端与每个server之间可能同时存在多条连接但请求不是固定绑定在某一条连接上的而是通过一定的负载策略分布到这些连接上。这种设计既能利用多路并发又能避免单条连接成为故障点。实际调优中连接数并不是越多越好我会在后面章节展开讲。keepalive逻辑也做了精细处理。空闲连接不会立刻被关闭而是保留一段时间等待复用。服务端连接数量会随着流量波峰波谷自动调整配合SO_REUSEADDR等内核层面的参数保证了海量短连接场景下的连接稳定性。4.2 负载均衡、熔断与自适应超时BRPC的服务治理能力可以归纳为三个关键词感知、决策、干预。感知是基础BRPC通过命名服务定期拉取后端节点列表支持文件、DNS、Consul等多类型。决策是负载均衡策略的选择BRPC内置了round robin、random、weighted round robin以及一个很有趣的算法——power of two choices。这种算法在大规模集群中效果非常好它的思路是每次随机选择两个节点然后从中挑负载更小的那个去发送请求。相比纯随机它能把流量分布得更加均匀从而明显改善长尾延迟。干预就是熔断与重试。某个节点连续报错或超时时BRPC会通过错误率计算自动把这个节点标记为熔断状态并摘除流量等节点恢复健康后再重新加进来。超时控制方面BRPC提供了多层超时参数连接超时、整体超时、还有我认为整个框架里最欣赏的特性——备份请求。所谓的备份请求就是客户端发出请求后如果超过一定时间还没有拿到响应框架自动向另一个节点重新发一次请求两个请求谁先返回就用谁后到的直接丢弃。这个机制对降低长尾延迟特别有效。在线服务的P99延迟常常被极少数慢请求拖垮用备份请求可以用很小的流量代价大幅改善P99表现。我见过不少团队用一台后端性能抖动拖垮了整个调用链的延迟指标而备份请求恰好就是根治这个问题的答案。4.3 从baidu_std到HTTP/Thrift协议层设计的维度BRPC在协议层做的另一个重要设计是“多协议同时接入”。服务端启动时会注册多个协议解析器收到数据后先读取开头几个字节通过magic number判断这是哪种协议再分发到对应的解析逻辑。这意味着一个服务端口既可以服务BRPC客户端也可以接受HTTP请求。很多团队在做服务迁移时直接把原来的HTTP接口保留新增的流量用BRPC协议接入一步到位。甚至可以用HTTP加上protobuf写RESTful风格的接口在不额外部署网关的情况下一套服务内搞定。协议层这些能力在实际定位问题时很有价值。遇到复杂的线上故障你可以用一个普通的HTTP请求去curl一下服务的性能面板就能看到QPS、延迟、连接数等实时指标完全不用借助额外工具。这种“一个端口承载运营流量和监控流量”的思路在今天微服务拆分的浪潮下多少显得有些另类但用起来确实相当顺手。5. 调优方向与实操经验如何让BRPC吃满机器5.1 硬件、编译选项和服务端配置的现实约束百万级QPS不是光靠框架就能做到的业务逻辑的复杂度、报文大小、机器性能这些前提条件缺一不可。先说硬件高并发网络服务通常对CPU主频和多核并行能力很敏感。机器核数越多bthread能同时运行的pthread越多吞吐量自然越高。内存方面带宽和延迟会影响序列化与IOBuf操作的速度。万兆网卡几乎是高性能RPC的标配但要注意的是在包大小较大的情况下网卡吞吐同样会成为瓶颈。编译选项也很重要。BRPC和protobuf都是C写的官方推荐打开O2优化开启NDEBUG。如果保留debug符号关闭优化性能差距会有好几倍。这点经常有人忽略拿着默认编译选项去做压测得出“BRPC也没多块”的结论其实冤枉了框架。服务端参数里最核心的是bthread_concurrency。这个参数控制调度器使用的pthread数量默认值是机器的核数。在部分场景下适当调高可以让度CPU占用更满但超过一定程度后反而会因为缓存竞争和上下文切换导致性能下降。我的实践体会是先让它等于核数跑一轮压测再以两倍为上限往上试探找到QPS曲线的拐点。网络线程和业务线程的比例也需要根据请求的IO密集程度动态调整。5.2 客户端参数与服务端参数的配合调优时一个容易被忽视的问题是客户端参数与服务端参数的匹配。例如客户端的超时时间设置要合理太短会导致大量请求被误判超时触发不必要的重试把服务端打爆太长又会造成调用方延迟感知过慢。连接超时、重试次数、备份请求的延迟阈值这三个参数需要联动调整。在实际项目中我倾向于这样设定连接超时100ms到200ms整体超时等于P99延迟乘以5左右备份请求延迟阈值放在P50到P99之间。这些不是标准答案只是让框架的容错机制在你的特定业务模型下尽量少误伤。重试只适合幂等接口如果下游接口不是幂等的重试一次就可能多一笔扣款或一次重复发送这种风险要在业务层面充分评估后再启用重试。服务端的连接数限制也要配套调整。框架可以设置单IP的最大连接数在高并发场景下合理限制连接数能防止某个客户端异常活跃时挤占其他客户端的资源。我见过线上一个测试脚本没写好疯狂建连把服务端的文件描述符耗尽导致正常业务流量全部建连失败再加上一层reject限流措施就能有效避免这类问题。5.3 定位瓶颈的三种观测手段压测出性能不达标时第一步不是翻代码而是定位瓶颈到底在哪儿。我会按下面的顺序排查看CPU使用率分布。如果CPU跑满但QPS上不去大概率是业务逻辑里有热点代码、序列化开销过大或锁竞争。看延迟分布曲线。如果P50很低但P99很高通常是存在节点间负载不均或长尾请求配合备份请求和负载均衡策略调整。看客户端和服务端之间的连接数。连接数异常偏低时可能意味着多路复用生效不足请求在排队等待连接数过高则要检查连接池配置和内核参数。BRPC的监控面板可以提供qps、延迟分位数、连接数、错误数、CPU和内存使用这些实时数据排查问题时我一般先从这里入手。还有一个非常实用的手段开启内置的rpcz功能对某个慢请求做采样能看到它在服务端各个阶段的耗时分布——网络收包、排队等待、业务处理、序列化、发送响应分别花多少瓶颈一目了然。我自己的经验是BRPC这类高性能框架的调优最难的不是把参数调到最优而是理解每个参数背后的原理。只要理解了bthread的协作式调度、理解了IOBuf的零拷贝机制、理解了连接多路复用的价值线上出问题时你就能快速判断该调什么、不该调什么。框架的参数只是表象运行时模型才是解题的根本。最后再分享一个小技巧多路复用连接在开启后客户端的并发能力会有飞跃式提升但服务端同样需要对应地提高Socket接收缓冲区大小否则大流量冲击下可能会出现接收窗口频繁打满反而让TCP层成为瓶颈。调整缓冲区大小时要结合平均包大小和延迟带宽积来估算不要凭感觉设一个很大的数——过大的缓冲区也有风险内存占用会被放大。做个有心人把这些细节和线上监控数据对应起来你会在调优的路上走得更顺。
返回列表