[OSPP 2026] Triple 协议性能分析与优化 #3673
Replies: 5 comments 4 replies
|
这里是一份初步的性能基准测试报告: Dubbo-Go grpc协议 基准测试报告目录
一、测试环境
二、测试配置
三、测试数据1. 128 bytes 报文QPS(每秒请求数)
P99 延迟 (ms)
2. 1024 bytes 报文QPS(每秒请求数)
P99 延迟 (ms)
3. 16384 bytes 报文QPS(每秒请求数)
P99 延迟 (ms)
4. 1048576 bytes 报文QPS(每秒请求数)
P99 延迟 (ms)
四、服务端内存分配(B/op、allocs/op)测试数据1. 128 bytes 报文B/op(每请求分配字节)
allocs/op(每请求分配次数)
2. 1024 bytes 报文B/op(每请求分配字节)
allocs/op(每请求分配次数)
3. 16384 bytes 报文B/op(每请求分配字节)
allocs/op(每请求分配次数)
4. 1048576 bytes 报文B/op(每请求分配字节)
allocs/op(每请求分配次数)
五、特定场景测试数据摘要1. 128B × 100 并发场景三端表现
2. 1MiB × 50/100 并发场景三端表现1Mib × 50 并发
1Mib × 100 并发
六、性能优化Dubbo-Go 客户端优化参照社区已有benchmark,为获得最佳性能,Dubbo-Go 客户端使用了以下优化配置:
Dubbo-Go 服务端优化参照社区已有benchmark,服务端配置了以下优化参数:
七、许可证Apache License 2.0 |
|
非瓶颈分析定位,先对这份报告做出一些初步解释: 1. 无法复现社区现有数据趋势:测试显示在本机环境下dubbo-go的性能表现略弱于dubbo-java,与社区测试数据结论相违背。目前排除wsl2拖慢golang的可能性,现有数据报告显示部分场景dubbo-go与gRPC部分场景QPS比值接近1:5,在本机被放大为1:10,问题需进一步换环境/切版本分支等 尝试推进排查 2. dubbo-java无allocs/op数据:Java 标准 API 只能统计分配字节数,没有精确的分配次数接口 3. 对benchmark工具进行了一些修复适配:
4. 与社区一致的指标与趋势:测试与社区现有测试数据绝对值与指标比例均不符合,但是: ①测试出的饱和拐点与社区一致50→100 并发下社区与本地都是 dubbo-go 已饱和/微降 dubbo-go 128B 社区测 -2.5%、本地测 +2.5%;16KiB 社区本地测都降), grpc 仍线性增长 (社区测 +10.7%、本地测 +16%) ②衰减曲线大部分形状与趋势一致如50并发下QPS 相对衰减(这里归一化 128B=1.00):
6. 本次推进带来的新事项:
7. 这份测试报告在参照社区测试报告(benchmark/README.md)的基础上扩展了哪些内容:①增加指标allocs/op 与 B/op②增加轮次、环境信息8. 一些本机与社区测试都可以得出的优化方向结论:①小包相较于大包是 Triple 短板都出现小包下与 grpc 差距大于大包下与grpc差距 (社区测得128B 低 5.4x vs 1MiB 低 4.1x ,本机测得128B 低 9.2x vs 1MiB 低 2.3x) ②大包路径需优化从绝对数值来看1 MiB 大包 都出现QPS断崖下跌,P99 崩塌(QPS 跌 57 倍、P99 795ms QPS 跌 38 倍、P99 2,400ms) |
|
这里是一份针对Triple-unary的性能瓶颈分析报告,里面有一定的方案设计,如果没问题,之后的推进形式是issue和PR 性能瓶颈分析报告(一)Triple-unary 热路径 io.Pipe 专项目录一、全文摘要 二、数据分析与瓶颈假设 三、代码瓶颈定位 四、多工具交叉验证瓶颈 五、方案设计与演进 六、方案验证与实施 七、预估收益 八、参考链接 一、摘要本文针对 Triple unary 热路径的固定开销(每请求新建 快路径方案拟以 二、数据分析与瓶颈假设这里从基准测试数据报告入手,挑选一些特定场景的数据总结规律,这些规律均指向固定开销,然后定位到固定开销的瓶颈。 2.1.1 小报文性能差距最大:QPS 对比
P99 对比
在 50 并发×128B 小包场景下,gRPC 的 QPS 约为 dubbo-go 的 9.2 倍(dubbo-go 明显落后),而 dubbo-go 的 P99 延迟约为 gRPC 的 5.5 倍;在 100 并发×128B 小包场景下,gRPC 的 QPS 约为 dubbo-go 的 10.4 倍,dubbo-go 的 P99 延迟约为 gRPC 的 6.0 倍。对比 50 并发可见:并发越高、小报文差距越大(QPS 差距 9.2x → 10.4x,P99 差距 5.5x → 6.0x),高并发 × 小报文是差距最大的场景,也是本报告的主要场景。 2.1.2 性能差距随报文增大而降低50 并发 · QPS 差距
50 并发 · P99 差距
100 并发 · QPS 差距
100 并发 · P99 差距
dubbo-go 与 gRPC 的性能差距(QPS 与 P99)随报文增大呈现缩小趋势:QPS 差距 128B 时约 9~10 倍、1MiB 时约 2.3 倍;P99 差距 128B 时约 5.5~6 倍、1MiB 时约 4~4.7 倍。但并非严格单调递减:1KiB(QPS 9.3x / P99 5.3x)与 128B 几乎持平,16KB 以上才出现明显收窄。这非常符合每请求的固定开销被摊薄的模型,因为报文越小,固定开销在单请求耗时中占比越高,性能差距也就越大;当报文越大时,传输带宽替代固定开销成为主要耗时占比,差距自然变小。同时对比 50/100 两档并发可见:并发越高、小报文差距越大(128B QPS 差距 9.2x → 10.4x、P99 差距 5.5x → 6.0x),进一步印证高并发 × 小报文是固定开销最显著的主战场。 也可以参考这个简化模型来解释: 所以此次优化不仅仅是定位小报文高并发场景,而是小报文瓶颈背后所体现的固定开销: dubbo-go 的 triple_protocol 层 unary 客户端发送路径( 2.1.3 内存分配差距不足以解释吞吐差距
若吞吐与单请求分配量成反比(QPS ∝ 1/分配量),则 B/op 差距 3.9x 最多把吞吐差距解释到 3.9x,占实际差距 9.2x 的约 42%(3.9 ÷ 9.2 ≈ 0.42),即分配因素最多解释约 4 成差距,其余约 6 成与分配量无关,只能由每请求固定开销(io.Pipe、goroutine、调度)解释。 2.2.1 瓶颈定位假设先来定义一下什么叫dubbo-go triple unary存在的固定开销, ①每请求固定发生、②与报文无关、③gRPC-Go 类似路径不执行
我们可以发现,其中 三、代码瓶颈定位3.1 每请求新建无缓冲管道
|
| 对比项 | dubbo-go(Triple) | gRPC-Go |
|---|---|---|
| unary 请求体承载 | io.Pipe(无缓冲同步管道) | 直接写入 HTTP/2 stream |
| 每请求额外 goroutine | 1 个(makeRequest)+ http2 关闭请求体 goroutine | 0 个(loopyWriter 每连接常驻 1 个) |
| 写路径 | 消息体单次写入(无帧化),经 io.Pipe 交接 | 5 字节 hdr 与 data 打包进 controlBuf,loopyWriter 合并写出 |
| 同步机制 | 管道内部双 channel 同步(chanrecv 等待) | 无 |
分析:duplexHTTPCall 的全双工设计(管道 + goroutine + responseReady channel)服务于 streaming 语义。unary 场景下请求体在请求发起前已完整就绪,无需流式写入与并发读写,该机制属于设计冗余。gRPC-Go 的 unary 直接写入 stream,不存在管道与每 RPC goroutine,这是小报文固定开销差距的主要来源。
四、多工具交叉验证瓶颈
4.1 pprof采集goroutine分布
解读一下,gRPC 压测稳态下共 114 个 goroutine:99 个是压测 worker 的请求 goroutine,阻塞在 waitOnHeader(等待响应头到达);其余约 15 个是运行时基础设施
dubbo-go 这边,共 312 个 goroutine,分布为:
99 个阻塞在 duplexHTTPCall.makeRequest,每请求启动的发送 goroutine,等待 HTTP 响应
90 个阻塞在 io.(*pipe).write,请求体写入无缓冲管道,等待 transport 读取交接
98 个阻塞在 http2 clientStream.writeRequest,连接层写帧,等待流控窗口 / 等待管道数据
8 个阻塞在 BlockUntilResponseReady,等待响应就绪信号
其余约 17 个为运行时基础设施
对比可见:dubbo-go 的 goroutine 主要分布在 io.Pipe 交接 与 每请求 goroutine(makeRequest) 两条机制上(99 + 90 = 189 / 312 ≈ 61%);gRPC 没有这两条机制(其请求发送在 worker goroutine 内同步完成,连接写由常驻 loopyWriter 处理)。这正是 unary 快路径方案想要去除的开销。
4.2 pprof 采集CPU热点
grpc-go

dubbo-go 侧 CPU 采样中 syscall 占比 26.57%、futex 占比 12.16%,总和达到近40%,分别为 gRPC 侧(10.86% / 7.98%)的 2.4 倍与 1.5 倍;gRPC 侧 CPU 则相对集中于内存分配,mallocgc 占比 18.18%(dubbo-go 为 9.39%)
syscall 与 futex 正是 duplexHTTPCall 的 io.Pipe 交接的直接运行成本,该机制包含三处开销:
① io.Pipe 将请求体分段读、写,放大单请求系统调用次数,write() 占 syscall 的 88%,写请求头与写请求体两条链路均在 doRequest 上,单请求消耗 2 次系统调用,优化点从“每请求多次小写”改为“最少的必要写入”;
② responseReady 就绪信号引入跨 goroutine 唤醒与 futex 等待,请求发出后,客户端 goroutine 阻塞在 BlockUntilResponseReady,等待 http2 连接层读取到响应头后经该就绪信号唤醒;此类跨 goroutine 的等待与唤醒(连同管道内部的 channel 同步)由内核 futex 支撑,对应火焰图中 futex 占比 12.16%;
③ makeRequest 独立 goroutine 叠加调度与上下文切换,每个请求额外启动一个发送 goroutine,专职从 io.Pipe 读取请求体并写入 http2 层;每请求多一次 goroutine 调度与上下文切换,goroutine 验证中 99 个 makeRequest 阻塞栈即为该机制的直接观测。
unary 快路径整体绕过这三处,该部分开销随之消除,向 gRPC 水平收敛。此结论与 goroutine 对比验证(312 vs 114)互相印证,两项数据同源于 duplexHTTPCall 的 io.Pipe 交接。
4.3 go tool trace 采集调度延迟
dubbo-go

gRPC-go

从阻塞的角度来看,dubbo-go 侧调度延迟 Top 显示,排名靠前的依次是互斥锁 Unlock(23.89%)、响应处理 endStream(22.14%)、processHeaders(20.14%)、条件变量 Signal(15.86%)、channel 接收 chanrecv1(13.01%)。对照三件套逐一解释:chanrecv1(13.01%)是 channel 接收等待,io.Pipe 交接(写端等读端取走数据)与 BlockUntilResponseReady(<-responseReady,duplex_http_call.go L261-262)均属此类;Signal(15.86%)为条件变量唤醒,不在 io.Pipe 交接路径上,responseReady 经 close() 触发的是 channel 接收而非条件变量唤醒(duplex_http_call.go L274),现代 io.Pipe 内部亦为 channel 同步、无 sync.Cond;Unlock(23.89%)为响应读取路径(transportResponseBody.Read)上的锁竞争。
gRPC 侧调度延迟集中为 operateHeaders(90.67%,等待响应头到达)这一个合理等待点,无管道相关阻塞。
也可以与前两个小节数据印证:4.1 显示 io.Pipe 交接产生约 61% 的阻塞 goroutine,本节调度延迟栈表明这些 goroutine 的阻塞真实发生;4.2 显示 syscall 占 CPU 26.57%,本节中 dubbo-go 的 syscall 阻塞总时长 8.14s、gRPC 为 3.43s。CPU 占比与阻塞时长同时偏高,说明该开销在执行与阻塞两个维度都存在。
三小节的三项验证分别从机制存在、CPU 占比、调度阻塞三个角度出发,共同指向 io.Pipe 交接带来的性能瓶颈。
交叉验证的总结
| 采集内容 | 测试结果 | 结论 |
|---|---|---|
| goroutine 分布(io.Pipe 两端 / makeRequest 栈) | dubbo-go 共 312:makeRequest 99 / io.Pipe 写 90 / writeRequest 98 / BlockUntilResponseReady 8;gRPC 共 114(waitOnHeader 99) | 管道栈仅 dubbo-go 存在,总数比约 2.7x |
| syscall / futex / mallocgc 占比 | dubbo-go:syscall 26.57% / futex 12.16% / mallocgc 9.39%;gRPC:syscall 10.86% / futex 7.98% / mallocgc 18.18% | syscall 与 futex 均显著高于 gRPC |
| 调度延迟栈、syscall 阻塞时长 | dubbo-go 调度栈含 chanrecv1(io.Pipe 交接 / responseReady 等待)等 channel 等待;syscall 阻塞 8.14s vs gRPC 3.43s | io.Pipe 交接进入调度栈,阻塞时长约 2.4x |
五、方案设计与演进
5.1 问题定位
源码定位将固定开销锁定在 duplexHTTPCall 每次 unary 调用创建的 io.Pipe(duplex_http_call.go L76)与 go makeRequest() goroutine(L267)dubbo-go 独有、gRPC 完全缺失的两项开销。
5.2 设计目标
在保证 streaming(client/server/bidi)行为完全不变的前提下,为 unary 建立一条无 io.Pipe、无每请求 goroutine 的发送路径。
5.3 四个设计优化点
- O1 同步直发:unary 请求体一次就绪,不走流式交接。
MarshalAppend直接写入池化 buffer 作为 HTTP body,同步httpClient.Do。消除 pipe 分配、goroutine、channel 同步(futex)与 pipe 交接 memmove。 - O2 池化流式响应:响应用池化 buffer 流式读取(对齐生产
tripleUnaryUnmarshaler的ReadFrom+ bufferPool 模式),避免io.ReadAll全量分配;gzip 响应按Content-Encoding解压。 - O3 Content-Length 参数:请求是否声明
Content-Length作为 A/B 参数。实测启用 CL 全面更优(服务端按声明长度预分配http2dataBuffer,避免动态扩容),决策为启用。 - O4 fastPathBody 零分配 body 包装:零额外分配 io.ReadCloser 替代
io.NopCloser+bytes.Reader双重包装,消除 body 包装层的对象分配,从而使得 B/op/allocs 降低。
5.4 回滚与防御性设计
方案涉及请求行为模型改动(风险中高),提前应对风险的防御性设计如下:
-
风险兜底:
-
① 一键回滚:TripleConfig
unary-fast-path配置开关控制(protocol_config.triple.unary-fast-path),默认关闭;已开启的业务若升级后出事故,回滚操作仅三步:配置文件把
unary-fast-path改为false(或删除该配置)→ 重启 → 回到升级前的 duplex 基线,行为与升级前完全一致(wire 兼容 + 共享 marshaler,差异面最小),零代码变更(设计为配置入口 +WithUnaryFastPathoption 双通道)。 -
② 行为差异面最小:快路径复用协议层现有 marshaler/unmarshaler 与 bufferPool,与社区路径共享编解码、压缩与 header 语义;
-
③ 上线前全量回归:全量回归 + 互通测试 + 四象限 A/B 对照(duplex/fastpath × gzip/无 gzip)。
-
-
请求体 buffer 延迟回收(防竞态):
CloseWrite发起请求后如果不立即回收 body buffer,x/net/http2 的 bodyWriter 后台 goroutine 可能晚于Do返回(服务端提前响应时),从而触发http2: response body closed竞态;设计应当统一在
CloseRead(响应处理完)后回收,且回收前需确认后台 bodyWriter 已退出:服务端提前响应(非 2xx / 404 / 鉴权失败)时其仍在读未发完的请求体 buffer,对照 x/net/http2 v0.56.0 源码,abortRequestBodyWrite触发的异步Close()对 no-op 无效,可以采用委托 transport 对 Close 的恰好一次回调 + 互斥锁;并且上线前go test -race复现验证。 -
错误处理链对齐:沿用
duplexHTTPCall完整错误包装链(context 取消、h2c/gRPC misuse 提示、RST_STREAM 映射、CodeUnavailable兜底),错误码与提示行为与社区路径一致。 -
并发安全:错误状态互斥锁保护,
BlockUntilResponseReady就绪前不放行读取。 -
请求体重放(GetBody,重试兜底):为请求设置 GetBody 与加锁 Rewind(Seek 0 回绕),传输层连接失败时可按 HTTP 重试语义重放请求体(unary 幂等语义下安全)实现时配套重放路径测试。
-
规划内的回归测试:(约22个)
- 配置接线层:配置 round-trip 不丢字段(yaml/json/property 三通道读回 + Clone);URL attribute 类型断言错误不 panic;gRPC 协议收到开关字段无副作用;healthClient 固化走 duplex(不受开关影响);
- 分支路由层:
StreamType零值(=Unary 0b00)命中快路径、ClientStream/ServerStream 仍走 duplex,断言防回归; - 核心实现层:
unaryRequestBody(归还竞态核心):Read/Close 并发(-race复现服务端提前响应 abort 场景)、Close 幂等(防 buffer 双归还污染池)、Close 后 Read 返回 EOF;makeRequest:CloseWrite 幂等(sendOnce 保证 Do 恰好一次)、空 body(NoBody + Content-Length=0 跳过写)往返、错误链与 duplex 一致(h2c 提示/RST/Unavailable 兜底)、validateResponse 失败后响应体由 CloseRead 关闭;Write:多段累积 + Content-Length 精确、SetError/ctx 取消后停止累积;Read/CloseRead:CloseRead 后 Read 返回包装错误、CloseRead 幂等、错误响应路径不 panic;
- 端到端:fastpath vs duplex 同请求响应一致性(
-race)、并发 unary 调用 buffer 池化并发安全(-race)、Content-Length wire 断言(中间件层拦截:unary 请求 CL≥0、streaming 请求 CL≤0)、GetBody 重放路径(服务端回Connection: close触发 GOAWAY,断言 transport 调用 GetBody 重放请求体)。
5.5 备选方案
- 方案一(unary 快路径):结构改造,消除 pipe + goroutine。收益面覆盖全部 unary 场景。
- 方案二(写路径合并):局部微调,将 envelope 的两次写合并为一次。注意
envelopeWriter仅 gRPC 协议路径使用,不属于 Triple unary 写路径,与方案一优化的是两条独立路径,收益边际,仅作兜底。 - 组合:方案一为主,方案二兜底/补充
5.6 设计依据
- 原型实测:方案在 4 档报文全部正收益;备选方案二收益在方案一实现后会被吸收大半,不单独实施,只做兜底补充。
- 独立开关(即时回退):TripleConfig
unary-fast-path配置开关(默认 false)控制,默认关闭,线上异常改配置重启即可回退社区路径,零代码变更。 - 范围隔离与互通:仅 unary 走快路径,streaming 保留
duplexHTTPCall(上游 fix coredump #611 同样只做 unary 特化),互通/流式回归不受影响;快路径仅替换 Triple unary 的请求发送路径,路由仅按 StreamType 分支,不区分底层 HTTP 版本,HTTP/1.1 与 HTTP/2 下的 Triple unary 均命中快路径;gRPC 协议(独立实现)与 streaming(StreamType 非 unary)走原路径;wire 字节级与 duplex 一致(POST + Content-Length + Content-Type 等 header 语义不变);跨语言互通仅依赖标准 HTTP 语义,无自定义字段。server-streaming(请求体同样一次性写完)纳入快路径列为后续评估项,涉及 trailer 语义验证,不阻塞本期交付。 - 上游论据(connect-go 官方):triple_protocol 包源自 connect-go(duplex_http_call_test.go 版权头与
fixed upstream in connect-go v1.19.2注释可证)。connect-go 在 issue Mod: update haozhuo logo url #609 中明确承认:所有 RPC 共用duplexHTTPCall对 unary 是 overkill,其中的同步机制、每请求额外 goroutine 与io.Pipe对 unary 是 pure overhead,并通过 PR #611与data race #649 落地 unary 专用发送流程(sendUnary:同步直发 + ContentLength + payloadCloser),与本方案 O1 方向一致。上游实现面向 connect-go 通用协议,不会覆盖 Triple 协议细节,本方案实现时需做以下本地化适配:-
架构差异与权衡适配:上游改造 duplexHTTPCall 本体。它在 Send 方法内部按请求类型分支,只有流式请求才创建 io.Pipe,一个类承载所有 RPC 类型。好处是代码更优雅、发送入口单一、抽象层级高;
代价是改动波及所有 RPC 共用的类,流式请求同样受影响,且 unary 特化与流式逻辑耦合在同一个类里,后续难以单独演进,没有灰度和开关。
本方案采用接口抽象加分支路由:duplexHTTPCall 原封不动,NewConn 按 RPC 类型把 unary 路由到新建的 unaryFastPathCall。流式语义不受影响,快路径可以独立演进,后续补 GetBody 重试、覆盖 server-streaming 都不需要触碰 duplex;开关关闭时走原类,快路径零开销,与一键回滚策略适配。
代价是多一个类、多一层接口分发,两个实现的行为需要持续对齐,这部分由 5.4 第 6 点的 Parity 测试兜底。
-
复用现有组件:O1 直接接入 triple_protocol 现有 marshaler/unmarshaler 与 bufferPool(O2 对齐生产
tripleUnaryUnmarshaler的ReadFrom模式),而非照搬上游payloadCloser的独立分配路径; -
协议行为继承:Content-Encoding / Accept-Encoding / Trailer 语义完全不变,gzip 默认行为与生产 connect 客户端一致;
-
Content-Length(O3):上游用
ContentLength做流式关闭优化,本方案将其扩展为 A/B 参数,实测启用全面更优,决策为启用。 -
请求体归还机制本地化:上游
sendUnary用 payloadCloser 包装上层传入的 payload,不归还任何池(所有权在上层),Release 清引用后 Read 返回 EOF 自然终止;本方案请求体来自协议层 bufferPool(上限 8MiB / 初始 512B,对齐生产 buffer_pool.go),归还必须回到池中,因此实现unaryRequestBody加锁 Read/Close,归还委托 transport 对 Close 的恰好一次回调(正常读完或 abort 时),与后台 bodyWriter 读互斥,杜绝跨请求 buffer 污染。
-
- gzip 对齐(对齐生产 wire 形状):生产客户端默认发
Accept-Encoding: gzip,服务端默认 gzip 压缩响应。原型早版未对齐,2MiB 档慢 2.4x;对齐后反超 -38%。快路径走生产同一客户端栈,天然带该 header。 - pool 对齐生产配置:buffer 上限对齐生产
buffer_pool.go(8MiB,初始 512B),避免大 buffer 被池丢弃导致每次重新分配。
5.7 方案原型演进实测
被测是构造的简化原型,指标是单请求耗时 ns/op;协议内 go test -benchmem,httptest h2c 服务;基线是同轮 duplex 社区版
| 版本 | 128B | 1024B | 16384B | 2MiB | 关键变更 |
|---|---|---|---|---|---|
| v1 | - | - | - | +约100% ↓↓ | 裸原型,无池化 |
| v2 | -32% ↑ | -25% ↑ | -13% ↑ | +107% ↓↓ | 引入池化(上限 1MiB,2MiB allocs 1,147) |
| v3 | -24% ↑ | -29% ↑ | +12% ↓ | +139% ↓↓ | pool 对齐生产 8MiB(2MiB allocs 降至 1,020) |
| v4 | -37% ↑ | -43% ↑ | -52% ↑ | -38% ↑ | gzip 对齐(生产默认 Accept-Encoding) |
注:① 表中数值 = 单请求耗时相对同轮 duplex 基线的变化率(负 = 更快、正 = 更慢),每轮跑出的基线值不同,跨行数值不具可比性,仅看各版本内的档位趋势;
六、方案验证与实施
6.1 生产落地改动点
正式方案当前设计以接口抽象 + 分支路由的方式落地:tripleUnaryClientConn 持有的具体调用从 *duplexHTTPCall 抽象为 unaryClientCall 接口,NewConn 按 RPC 类型路由,unary 走新实现的 unaryFastPathCall(同步直发),streaming 与开关关闭时保留原 duplexHTTPCall。协议层 marshaler/unmarshaler、bufferPool、错误链与 header 语义全部复用,改造对协议行为零侵入。
6.1.1 改动范围
-
新增
unary_fastpath.go:unaryClientCall接口(tripleUnaryClientConn依赖的 duplex 方法子集:Write/Read/Header/CloseWrite/CloseRead/BlockUntilResponseReady/SetValidateResponse)+unaryFastPathCall实现 +unaryRequestBody(加锁的池化归还 body)。四个优化点全部放在这里:O1 消除 io.Pipe + 每请求 goroutine(同步直发)、O2 复用现有 marshaler/unmarshaler 与 bufferPool、O3 显式 Content-Length、O4 零分配 body 包装。
-
NewConn分支:spec.StreamType == StreamTypeUnary && c.UnaryFastPath走newUnaryFastPathCall(ctx, httpClient, url, spec, header, bufferPool),否则走newDuplexHTTPCall。范围隔离:streaming(client/server/bidi)与开关关闭时路径与社区完全一致,零改动。 -
调用对象接口化:
tripleUnaryClientConn.call从具体类型改为unaryClientCall接口,marshaler(writer: call)/ unmarshaler(reader: call)/ validateResponse 注入 / bufferPool 全部复用,社区与快路径共用同一套编解码、压缩与 header 语义(行为差异面最小)。 -
请求发送语义变化:duplex 的
Write写 io.Pipe → 每请求 goroutine 后台Do改为 fastpath 的Write累积 pooled buffer(零网络触碰)→CloseWrite同步Do,请求体经unaryRequestBody直接引用 pooled buffer,无管道交接。 -
body 归还机制变化:duplex 请求体随 pipe 读端流入 http2、无池化归还;fastpath 归还委托
unaryRequestBody.Close(x/net/http2 对reqBody.Close()恰好调用一次:正常读完或 abort 时),内部互斥锁保证与后台 bodyWriter 的读不竞态,彻底规避跨请求 buffer 污染。 -
一键回滚开关:
WithUnaryFastPath()option(option.go,默认关闭)→clientConfig.UnaryFastPath(client.go)→protocolClientParams.UnaryFastPath(protocol.go)→ NewConn 分支;生产入口TripleConfig.unary-fast-path配置(global/triple_config.go,默认 false)经newClientManager注入 option。
6.1.2 流程图
改造前(社区 duplexHTTPCall,开关关闭时 = 默认路径), 每请求两个执行上下文
flowchart TB
subgraph caller["caller goroutine"]
direction TB
CC["tripleUnaryClientConn"] --> DC["duplexHTTPCall"]
DC -->|"Write ① ensureRequestMade() 启动后台 goroutine"| GO["go makeRequest()"]
DC -->|"Write ② pipeWriter.Write(data)(阻塞)"| PW["pipeWriter"]
DC -->|"Receive: BlockUntilResponseReady() 阻塞等待"| READY["responseReady"]
end
subgraph bg["makeRequest goroutine(每请求一个)"]
direction TB
MR["makeRequest: httpClient.Do(request)"] -->|"req.Body = pipeReader 读端"| PR["pipeReader"]
PR --> T["http2 transport"]
end
caller ~~~ bg
GO -. "sendRequestOnce.Do 派发" .-> MR
PW -. "io.Pipe 无缓冲交接(写端阻塞至读端消费)" .-> PR
T -. "Do 返回 → close(responseReady)" .-> READY
T -. "响应体" .-> DC
每请求固定开销:① io.Pipe 交接 ② makeRequest goroutine ③ responseReady channel 跨 goroutine 等待
改造后(本方案 unaryFastPathCall,unary-fast-path 开关启用时), 单执行上下文同步直发
flowchart TB
subgraph caller2["caller goroutine(全程同步,单执行上下文)"]
direction TB
CC2["tripleUnaryClientConn"] --> FP["unaryFastPathCall"]
FP -->|"Write: bufferPool.Get() → body.Write 累积"| BUF["pooled buffer"]
FP -->|"CloseWrite: sendOnce.Do(makeRequest) 同步执行"| MR2["makeRequest"]
MR2 -->|"Content-Length 声明(O3)"| CL["Content-Length"]
MR2 -->|"Body = unaryRequestBody(锁保护归还)"| RB["unaryRequestBody"]
MR2 -->|"httpClient.Do(request)"| T2["http2 transport"]
T2 -. "响应(responseReady 已就绪,Receive 不阻塞)" .-> FP
RB -. "Close → bufferPool.Put" .-> BUF
end
classDef diff fill:#ffe0b2,stroke:#e65100,stroke-width:2px;
class BUF,MR2,CL,RB diff;
(突出色块 = 相对 duplex 的差异点:去 pipe 改池化缓冲、去后台 goroutine 改同步发送、新增 Content-Length 声明、新增锁保护归还)
该路径由 unary-fast-path 开关启用(默认关闭,开启方式:WithUnaryFastPath() option 或 protocol_config.triple.unary-fast-path: true),异常时改配置重启即可回退到上方社区路径。对比上方社区路径:消除 io.Pipe + makeRequest goroutine + responseReady 等待;新增 pooled buffer 累积 + 显式 Content-Length(O3)+ 锁保护归还。
6.1.3 组件职责与调用关系对照
| 组件 | 改造前(duplex) | 改造后(fastpath) | 变化 |
|---|---|---|---|
tripleUnaryClientConn |
持有 *duplexHTTPCall |
持有 unaryClientCall 接口 |
接口化解耦,改动收敛于 NewConn 一行分支 |
marshaler(tripleUnaryRequestMarshaler) |
writer = duplexHTTPCall |
writer = unaryFastPathCall |
复用,仅 writer 实现替换 |
unmarshaler(tripleUnaryUnmarshaler) |
reader = duplexHTTPCall |
reader = unaryFastPathCall |
复用 |
| 请求体承载 | io.Pipe(读端交 http2,写端阻塞) | pooled buffer 直接引用 | 消除管道交接 |
| 请求发起 | 每请求 goroutine 异步 Do |
CloseWrite 同步 Do |
消除 goroutine + channel 等待 |
| Content-Length | 未知(流式管道) | 显式声明(O3) | 新增,服务端预分配 |
| body 归还 | 无池化 | unaryRequestBody.Close → bufferPool.Put(锁保护) |
新增(#F2) |
| 错误链 | context / h2c / gRPC 提示 / RST / Unavailable | 同链逐行对齐 | 不变 |
| header / gzip / trailer 语义 | 原样 | 原样 | 不变 |
6.1.4 调用序列对比(同一 unary 请求)
- 改造前:
Send(写 pipe) →CloseWrite(EOF 通知) →〔后台 goroutine:读 pipe +Do+ close responseReady〕→Receive(BlockUntilResponseReady等) →CloseResponse - 改造后:
Send(累积 buffer) →CloseWrite(同步Do+ 直接读 buffer) →Receive(直接读 response,无等待) →CloseResponse
调用序列形态完全不变(协议层 Client 与 marshaler/unmarshaler 无需任何改动),仅每次 RPC 内部的执行上下文由 2 个收敛为 1 个。
以下为快路径核心实现(unary_fastpath.go):unary_fastpath_bench_test.go 为协议内 A/B 基准。
6.3 原型代码:unary 快路径(A/B 两版原型测试代码)
原型通过测试代码提炼社区原版与设计版 triple-unary 的关键逻辑进行 A/B 验证。两版原型共用同一服务端与报文档位(协议内 bench 走 httptest h2c 服务端、端到端走真实服务端 :20000),仅请求发送路径不同,形成 A/B 对照组。
当前原型(对照组):DubboGoClient(dubbo_client.go),完整 dubbo-go 框架栈,unary 经 duplexHTTPCall:io.Pipe 管道交接、每请求 makeRequest 后台 goroutine、responseReady 信号等待
// 构造:完整 dubbo-go 框架客户端(NewDubboGoClient)
cli, err := client.NewClient(
client.WithClientNoCheck(),
client.WithClientSerialization("protobuf"),
client.WithClientParam(constant.SerializationKey, "protobuf"),
client.WithClientParam("compression", "none"),
)
service, err := benchmark.NewTripleBenchmarkService(cli,
client.WithURL(fmt.Sprintf("tri://%s/%s", addr, benchmark.BenchmarkServiceName)),
client.WithSerialization("protobuf"),
client.WithParam(constant.MaxCallRecvMsgSize, "16MB"),
client.WithParam(constant.MaxCallSendMsgSize, "16MB"),
client.WithParam("compression", "none"),
)
// 每次调用:内部进入 duplexHTTPCall,io.Pipe 交接、makeRequest goroutine、responseReady 等待
func (c *DubboGoClient) unaryCall(ctx context.Context) error {
req := &benchmark.BenchmarkRequest{Payload: c.payload}
_, err := c.client.UnaryCall(ctx, req)
return err
}设计优化原型(实验组):ProtoFastPathClient(proto_fastpath_client.go),raw HTTP/2 直发,无 duplexHTTPCall、无 io.Pipe、无每请求 goroutine(完整实现):
func (c *ProtoFastPathClient) Call(ctx context.Context) error {
// O1 池化 marshal:MarshalAppend 直接写入池化 buffer
reqBuf := c.pool.Get().(*bytes.Buffer)
data, err := proto.MarshalOptions{}.MarshalAppend(reqBuf.Bytes()[:0],
&benchmark.BenchmarkRequest{Payload: c.payload})
if err != nil {
c.put(reqBuf)
return fmt.Errorf("marshal request: %w", err)
}
// O4 零分配 body:fastPathBody 直接引用 marshal 结果,不经 bytes.Reader + NopCloser
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, &fastPathBody{data: data})
if err != nil {
c.put(reqBuf)
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/proto")
req.ContentLength = int64(len(data)) // O3 显式 Content-Length
resp, err := c.httpClient.Do(req) // O1 同步直发:无 io.Pipe、无后台 goroutine
if err != nil {
c.put(reqBuf)
return fmt.Errorf("do request: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
c.put(reqBuf)
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
// O2 池化读响应:ReadFrom 入池化 buffer,对齐 tripleUnaryUnmarshaler
respBuf := c.pool.Get().(*bytes.Buffer)
body := io.Reader(resp.Body)
if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") {
gz, gzErr := gzip.NewReader(body)
if gzErr != nil {
resp.Body.Close()
c.put(reqBuf)
c.put(respBuf)
return fmt.Errorf("new gzip reader: %w", gzErr)
}
defer gz.Close()
body = gz
}
if _, err := respBuf.ReadFrom(body); err != nil {
resp.Body.Close()
c.put(reqBuf)
c.put(respBuf)
return fmt.Errorf("read response: %w", err)
}
resp.Body.Close()
c.put(reqBuf) // unary 语义:服务端读完请求体才响应,此回收点安全
var out benchmark.BenchmarkResponse
if err := proto.Unmarshal(respBuf.Bytes(), &out); err != nil {
c.put(respBuf)
return fmt.Errorf("unmarshal response: %w", err)
}
c.put(respBuf)
return nil
}验证点:
- 无 io.Pipe 分配,无 makeRequest goroutine;
MarshalAppend单次拷贝入池化 buffer,消除codec.Marshal+bytes.NewBuffer(raw)二次拷贝;- 响应按
Content-Encoding: gzip手动解压,协议语义与生产一致; ContentLength声明后,服务端按长度预分配http2dataBuffer,避免动态扩容。
6.4 方案原型 A/B 实测(协议内 bench,同栈同服务端)
数据口径:go test -benchmem(httptest h2c 服务端,BenchmarkUnaryPathAB),A/B 双方为同栈 tripleUnaryClientConn(共享 marshaler/unmarshaler/bufferPool),仅发送路径不同:原版 duplexHTTPCall vs 优化原型 unaryFastPathCall;count=3 取中位数,原始数据见 data/unary-fastpath-proto/ab_path_20260822.log。QPS/P99 依赖端到端固定并发压测口径,测试原型代码不产出该指标,故本表不列。
6.4.1 128B
| 指标 | duplex(原版) | fastpath(优化原型) | 变化 |
|---|---|---|---|
| allocs/op | 141 | 140 | ≈0% |
| B/op | 30.6KB | 27.6KB | -10% ↑ |
6.4.2 1024B
| 指标 | duplex(原版) | fastpath(优化原型) | 变化 |
|---|---|---|---|
| allocs/op | 139 | 139 | 持平 |
| B/op | 34.9KB | 30.5KB | -13% ↑ |
6.4.3 16384B
| 指标 | duplex(原版) | fastpath(优化原型) | 变化 |
|---|---|---|---|
| allocs/op | 147 | 145 | -1% |
| B/op | 165.8KB | 150.9KB | -9% ↑ |
6.4.4 2MiB
| 指标 | duplex(原版) | fastpath(优化原型) | 变化 |
|---|---|---|---|
| allocs/op | 372 | 324 | -13% ↑ |
| B/op | 13.5MB | 13.0MB | -4% ↑ |
- 同栈下内存指标改善温和:allocs 基本持平(协议层 duplex 与 fastpath 共享 bufferPool/marshaler),B/op 小中包 -10%~-13%、大包 -4% 上下。发送路径优化的主要收益在延迟/吞吐侧,内存侧不是本轮重点。
- 第 4 项优化
fastPathBody:零额外分配 io.ReadCloser 替代io.NopCloser+bytes.Reader双重包装,是使方案原型 B/op/allocs 由负转正的关键
七、预估收益
7.1 生产中影响因素(预估收敛要素)
方案原型收益(协议层发送路径净收益)是优化基点,生产集成后受以下要素影响,预估时需计入:
- 端到端摊薄(最主要):协议内 A/B 为同栈对比(6.4),收益即协议层发送路径净收益;端到端叠加框架栈/网络/服务端调用链耗时,延迟下降比例被摊薄,QPS 提升收敛至低两位数百分比量级。
- 协议栈差异(收敛):原型 A/B 走 h2c(HTTP/2 cleartext)测试服务端,生产通常走 HTTP/2(Triple 主要部署形态,快路径本身不区分 HTTP 版本);HTTP/2 连接层为 dubbo-go 与 gRPC 共有部分,不在本次优化范围。
- gzip 压缩开关(指标口径):用户开启压缩时,压缩路径自身临时分配会掩盖 B/op 账面收益;延迟与吞吐收益不受影响。
- Content-Length 依赖(O3)(条件收益):快路径声明 Content-Length 后,依赖服务端按该长度预分配 buffer 才吃到收益;该字段为 HTTP 标准语义,dubbo-go/gRPC/Java 服务端均兼容,风险低。
- 业务负载与并发饱和(收敛):真实调用带业务(0.5ms~几ms/请求),io.Pipe 固定开销占比被业务耗时摊薄;纯网络转发场景(业务≈0)才接近原型档位;流控/带宽天花板属预期。
- 服务端调用链与带宽(收敛):生产服务端带完整调用链(序列化/业务 handler/下游依赖),响应时间以服务端为主,客户端发送路径优化被整体耗时稀释;大报文(1MiB)受服务端带宽天花板限制。
- GC 与长跑(双向):生产长跑下 bufferPool 复用使 B/op 收益更稳定;GC 压力与内存池行为可能放大或缩小分配收益,取中性。
- P99 尾延迟(收敛):io.Pipe + 每请求 goroutine 的调度(futex)唤醒抖动是客户端尾延迟来源之一,但端到端 P99 叠加服务端尾延迟与网络抖动,改善幅度通常低于均值延迟。
7.2 生产预期区间(预测)
结合 7.1 要素综合测算:
- 小报文(128B/1KiB,主战场):基线 4,425 → 预期 4,779 ~ 4,868(+8% ~ +10%),P99 同步收敛
- 大报文(1MiB):基线 122.9 → 预期 133 ~ 135(+8% ~ +10%),受服务端带宽天花板限制
- 综合判断:QPS +8% ~ +10%,P99 同步改善
预估性能区间(预测,非实测):
| 指标 | 预估区间 | 预估依据 |
|---|---|---|
| QPS | +8% ~ +10% | 端到端叠加框架栈/网络/服务端耗时,收益摊薄后收敛至低两位数百分比 |
| P99 | -5% ~ -15% | 端到端 P99 受服务端尾延迟稀释,改善幅度通常低于均值延迟 |
| 延迟 | -10% ~ -25% | 协议层消除 io.Pipe + 每请求 goroutine 固定开销 |
| B/op | -15% ~ -35% | 请求体池化单次拷贝,小报文更明显 |
| allocs | 持平 ~ 微增 | O3(Content-Length)引入少量 header 分配,换取服务端预分配收益 |
八、参考链接
- 本机基准测试报告
- 社区基准测试报告(benchmark/README_CN.md):tools/benchmark/README_CN.md
- RPC 协议 Triple&Dubbo java 基准测试:RPC Protocol Triple&Dubbo Benchmark Testing | Apache Dubbo
- connect-go 上游 issue Mod: update haozhuo logo url #609(unary 专用流程设计讨论):Specialize the unary RPC flow internally connectrpc/connect-go#609
- connect-go 上游 PR fix coredump #611(Special case unary HTTP calls):Special case unary HTTP calls connectrpc/connect-go#611
- connect-go 上游 PR data race #649(Special case unary HTTP calls):Implement unary HTTP calls with retry support connectrpc/connect-go#649
许可
Apache License 2.0
|
😔抱歉的消息,根据8月26日优化落地后性能异常引发的排查,发现在8月19日的基准测试中犯了一个错误,那份基准测试报告实测为dubbo-go默认协议grpc协议,而非triple协议。补救措施如下:
这里是dubbo-go端 triple协议 duplex版本: Dubbo-Go Triple 基准测试报告(QPS、P99指标)基准测试报告测试环境
测试配置
128 bytes 报文QPS(每秒请求数)
P99 延迟 (ms)
1024 bytes 报文QPS(每秒请求数)
P99 延迟 (ms)
16384 bytes 报文QPS(每秒请求数)
P99 延迟 (ms)
1048576 bytes (1MiB) 报文QPS(每秒请求数)
P99 延迟 (ms)
性能优化Dubbo-Go 客户端配置(duplex 基线)
Dubbo-Go 服务端配置服务端配置了以下参数:
许可证Apache License 2.0 |
|
triple-unary fastpath已落地,多场景收益超出预期(预期~10%),但存在单个场景(1Mib 50并发)小幅度负收益,性能对比如下: Dubbo-Go Triple 协议 性能对比报告基准测试报告测试环境
测试配置
128 bytes 报文QPS(每秒请求数)
P99 延迟 (ms)
1024 bytes 报文QPS(每秒请求数)
P99 延迟 (ms)
16384 bytes 报文QPS(每秒请求数)
P99 延迟 (ms)
1048576 bytes (1MiB) 报文QPS(每秒请求数)
P99 延迟 (ms)
性能优化Dubbo-Go 客户端优化(Triple 协议路径)
Dubbo-Go 服务端优化服务端配置了以下优化参数:
benchstat显著性检验 count -10goos: linux
goarch: amd64
pkg: dubbo.apache.org/dubbo-go/v3/protocol/triple/triple_protocol
cpu: Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz
│ duplex10_b.txt │ fastpath10_b.txt │
│ sec/op │ sec/op vs base │
Unary/128B-12 163.6µ ± 21% 121.3µ ± 25% -25.85% (p=0.003 n=10)
Unary/1024B-12 157.4µ ± 22% 113.0µ ± 18% -28.25% (p=0.002 n=10)
Unary/16384B-12 202.0µ ± 16% 182.9µ ± 31% ~ (p=0.393 n=10)
Unary/2MiB-12 6.891m ± 12% 7.740m ± 26% ~ (p=0.796 n=10)
geomean 435.1µ 373.2µ -14.24%可得,
许可证Apache License 2.0 |





Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Dubbo-Go Triple 协议性能分析与优化
项目背景
Triple 是 Dubbo 3 的核心 RPC 协议,也是 Dubbo-Go 面向生产环境的主力协议。社区已提供 Dubbo-Go / Dubbo-Java / gRPC-Go 三端 benchmark 对比工具(PR #3502),本机实测确认 Dubbo-Go 与 gRPC-Go 在 unary 小包场景存在明显吞吐差距。项目以可复现的 benchmark 与 profiling 数据为依据,系统化定位 Triple unary 热路径中的固定开销,在保持 Triple 功能、gRPC 兼容及 Dubbo-Java 互通的前提下落地优化,产出可合并的社区 PR。
Step 1: 基线建立
Step 2: 瓶颈定位与优化设计
(从已定位的低风险固定开销开始验证,再根据正式 profiling 数据决定是否深入 Envelope、Buffer 或 Codec 热路径)
Step 3: 优化落地
Step 4: 验证与交付
All reactions