GitHub user lizining1231 added a comment to the discussion: [OSPP 2026] Triple 协议性能分析与优化
这里是一份针对Triple-unary的性能瓶颈分析报告,里面有一定的方案设计,如果没问题,之后的推进形式是issue和PR # 性能瓶颈分析报告(一)Triple-unary 热路径 io.Pipe 专项 ## 目录 一、全文摘要 二、数据分析与瓶颈假设 三、代码瓶颈定位 四、多工具交叉验证瓶颈 五、方案设计与演进 六、方案验证与实施 七、预估收益 八、参考链接 ## 一、摘要 本文针对 Triple unary 热路径的固定开销(每请求新建 `io.Pipe` + 额外 goroutine)做了一次完整分析:从基准数据定位到该瓶颈,用 pprof / trace 多工具交叉验证,再设计并实现 unary 快路径(去 pipe、去每请求 goroutine、池化单拷贝,并声明 Content-Length 与 `fastPathBody`)。方案原型 A/B 实测多指标全正 快路径方案拟以 `unaryFastPathCall` 形态落地,并配套独立开关 `unary-fast-path`(默认关闭,改配置即可回滚)。预估生产集成后收益收敛为:QPS **+8%\~+10%**、P99 **-5%\~-15%**、延迟 **-10%\~-25%**、B/op **-15%\~-35%**、allocs **持平\~微增**。 ## 二、数据分析与瓶颈假设 这里从基准测试数据报告入手,挑选一些特定场景的数据总结规律,这些规律均指向固定开销,然后定位到固定开销的瓶颈。 ### 2.1.1 小报文性能差距最大: **QPS 对比** | 并发数 | dubbo-go | grpc | dubbo-java | | --- | ----------- | -------- | ---------- | | 50 | **4,425.4** | 40,561.9 | 10,625.8 | | 100 | **4,537.6** | 47,064.7 | 10,667.0 | **P99 对比** | 并发数 | dubbo-go | grpc | dubbo-java | | --- | --------- | ---- | ---------- | | 50 | **19.56** | 3.58 | 6.74 | | 100 | **33.29** | 5.54 | 12.23 | 在 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 差距** | 报文 | dubbo-go QPS | grpc QPS | grpc/dubbo-go | | ----- | ------------ | -------- | ------------- | | 128B | 4,425.4 | 40,561.9 | **9.2x** | | 1KiB | 3,597.7 | 33,508.7 | **9.3x** | | 16KiB | 1,980.3 | 11,882.1 | **6.0x** | | 1MiB | 122.9 | 284.5 | **2.3x** | **50 并发 · P99 差距** | 报文 | dubbo-go P99 (ms) | grpc P99 (ms) | dg/grpc | | ----- | ----------------- | ------------- | --------- | | 128B | 19.56 | 3.58 | **5.5x** | | 1KiB | 21.51 | 4.09 | **5.3x** | | 16KiB | 43.24 | 10.07 | **4.3x** | | 1MiB | 1,074.68 | 261.09 | **4.1x** | **100 并发 · QPS 差距** | 报文 | dubbo-go QPS | grpc QPS | grpc/dubbo-go | | ----- | ------------ | -------- | ------------- | | 128B | 4,537.6 | 47,064.7 | **10.4x** | | 1KiB | 3,670.8 | 42,400.3 | **11.6x** | | 16KiB | 1,829.6 | 12,852.9 | **7.0x** | | 1MiB | 119.4 | 274.0 | **2.3x** | **100 并发 · P99 差距** | 报文 | dubbo-go P99 (ms) | grpc P99 (ms) | dg/grpc | | ----- | ----------------- | ------------- | --------- | | 128B | 33.29 | 5.54 | **6.0x** | | 1KiB | 42.96 | 6.80 | **6.3x** | | 16KiB | 93.16 | 16.28 | **5.7x** | | 1MiB | 2,400.09 | 511.09 | **4.7x** | 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 客户端发送路径**(`duplexHTTPCall`,即基于 connect-go 的 unary 发送实现)存在固定开销,包括:**每请求创建 io.Pipe、每请求启动 goroutine、多次系统调用、goroutine 调度(futex)唤醒**。此次瓶颈定位范围仅限 unary 客户端发送路径,**不含** dubbo-go 上层框架与服务端路径开销,也不涉及与 gRPC 共有的 HTTP/2 连接层。 ### 2.1.3 内存分配差距不足以解释吞吐差距 | 指标(128B × 50并发) | dubbo-go | grpc | 比值 | | --------------- | -------- | -------- | -------- | | QPS | 4,425.4 | 40,561.9 | **9.2x** | | B/op | 23,307 | 5,899 | **3.9x** | | allocs/op | 151.5 | 79.4 | **1.9x** | 若吞吐与单请求分配量成反比(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 类似路径不执行** | 候选 | unary 是否触发 | gRPC-Go 是否存在 | | --------------------------------------- | ---------- | ------------------------ | | **`io.Pipe()`** **新建无缓冲管道** | **每次** | **否** | | **`go makeRequest()`** **额外 goroutine** | **每次** | **否(loopyWriter 常驻连接级)** | | `cloneURL` + `http.Request` 构造 | 每次 | 是(无管道) | | Header 双重拷贝 | 每次 | 单次 | | `bytes.NewBuffer(raw)` 包装 | 每次 | 是 | 我们可以发现,其中 **`io.Pipe`** **与每请求 goroutine** 是 dubbo-go 独有、每次 unary 必发生、且 gRPC-Go 完全缺失的固定开销,锁定这两项后,接下来看一下这些固定请求的代码。 ## 三、代码瓶颈定位 ### 3.1 每请求新建无缓冲管道 `io.Pipe()`:同步交接 + 4KB 中间缓冲 + 双 channel 同步 [duplex\_http\_call.go L59-L103](https://github.com/lizining1231/dubbo-go/blob/76631118/protocol/triple/triple_protocol/duplex_http_call.go#L59-L103)(`newDuplexHTTPCall`:cloneURL → io.Pipe → 请求体挂管道读端) ```go func newDuplexHTTPCall( ctx context.Context, httpClient HTTPClient, url *url.URL, spec Spec, header http.Header, ) *duplexHTTPCall { url = cloneURL(url) // 每次拷贝 URL url.Path = spec.Procedure pipeReader, pipeWriter := io.Pipe() // 每次调用创建无缓冲同步管道 request := (&http.Request{ Method: http.MethodPost, URL: url, Header: header, Body: pipeReader, // 请求体为管道读端 Host: url.Host, }).WithContext(ctx) return &duplexHTTPCall{ ... } } ``` ### 3.2 每请求 goroutine [duplex\_http\_call.go L265-L307](https://github.com/lizining1231/dubbo-go/blob/76631118/protocol/triple/triple_protocol/duplex_http_call.go#L265-L307)(`makeRequest`:`httpClient.Do` 发送路径) ```go func (d *duplexHTTPCall) ensureRequestMade() { d.sendRequestOnce.Do(func() { go d.makeRequest() // 每次调用启动独立 goroutine }) } func (d *duplexHTTPCall) makeRequest() { defer close(d.responseReady) response, err := d.httpClient.Do(d.request) // x/net/http2 另为每个请求启动 ... // goroutine 关闭请求体 } ``` ### 3.3 写路径的同步交接 ([`duplex_http_call.go` L108-L126](https://github.com/lizining1231/dubbo-go/blob/76631118/protocol/triple/triple_protocol/duplex_http_call.go#L108-L126)): ```go // duplex_http_call.go:写入管道写端,阻塞至 transport 读取(同步交接) func (d *duplexHTTPCall) Write(data []byte) (int, error) { d.ensureRequestMade() // 触发 goroutine bytesWritten, err := d.requestBodyWriter.Write(data) // 阻塞交接 ... } ``` > 注:Triple unary 请求体**无 5 字节 envelope 帧化**,`tripleUnaryMarshaler.write` > 将消息体单次写入 > `writer.Write(data)`([`protocol_triple.go`](https://github.com/lizining1231/dubbo-go/blob/76631118/protocol/triple/triple_protocol/protocol_triple.go#L526-L527) > > [L526-L527](https://github.com/lizining1231/dubbo-go/blob/76631118/protocol/triple/triple_protocol/protocol_triple.go#L526-L527));`envelopeWriter`(5 > 字节 prefix + body 分离两次写)仅 gRPC > 协议使用([`protocol_grpc.go`](https://github.com/lizining1231/dubbo-go/blob/76631118/protocol/triple/triple_protocol/protocol_grpc.go#L206) > > [L206](https://github.com/lizining1231/dubbo-go/blob/76631118/protocol/triple/triple_protocol/protocol_grpc.go#L206) > / > [L298](https://github.com/lizining1231/dubbo-go/blob/76631118/protocol/triple/triple_protocol/protocol_grpc.go#L298)),不属于 > Triple unary 写路径。 ### 3.4 与gRPC此处实现差异 | 对比项 | 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分布 dubbo-go <img width="2000" height="1200" alt="image" src="https://github.com/user-attachments/assets/8e05424f-3969-4f01-ad2c-600973bc35d8" /> gRPC-go <img width="2000" height="1200" alt="image" src="https://github.com/user-attachments/assets/b0d59278-2694-4b02-9270-1cc8d69d1f5b" /> 解读一下,**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热点 dubbo-go <img width="2000" height="1200" alt="image" src="https://github.com/user-attachments/assets/bc3514f3-d9e6-4707-91bd-3c0ee5521439" /> dubbo-go的syscall6展开 <img width="2000" height="1200" alt="image" src="https://github.com/user-attachments/assets/c36a58b6-c69f-4796-bda0-7e21307fcf8f" /> grpc-go <img width="2000" height="1200" alt="image" src="https://github.com/user-attachments/assets/4243260a-78d2-49cc-afcf-de4c5f2005dd" /> 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 <img width="2000" height="1400" alt="image" src="https://github.com/user-attachments/assets/07116b02-acb4-4863-8ae0-4ab95af283f2" /> gRPC-go <img width="2000" height="1400" alt="image" src="https://github.com/user-attachments/assets/f1d92299-0527-4c43-8e05-f37a30781688" /> 从阻塞的角度来看,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 回滚与防御性设计 方案涉及请求行为模型改动(风险中高),提前应对风险的防御性设计如下: 1. **风险兜底**: - ① **一键回滚**:TripleConfig `unary-fast-path` 配置开关控制(`protocol_config.triple.unary-fast-path`),默认关闭; 已开启的业务若升级后出事故,回滚操作仅三步:配置文件把 `unary-fast-path` 改为 `false`(或删除该配置)→ 重启 → 回到升级前的 duplex 基线,行为与升级前完全一致(wire 兼容 + 共享 marshaler,差异面最小),零代码变更(设计为配置入口 + `WithUnaryFastPath` option 双通道)。 - ② **行为差异面最小**:快路径复用协议层现有 marshaler/unmarshaler 与 bufferPool,与社区路径共享编解码、压缩与 header 语义; - ③ **上线前全量回归**:全量回归 + 互通测试 + 四象限 A/B 对照(duplex/fastpath × gzip/无 gzip)。 2. **请求体 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` 复现验证。 3. **错误处理链对齐**:沿用 `duplexHTTPCall` 完整错误包装链(context 取消、h2c/gRPC misuse 提示、RST\_STREAM 映射、`CodeUnavailable` 兜底),错误码与提示行为与社区路径一致。 4. **并发安全**:错误状态互斥锁保护,`BlockUntilResponseReady` 就绪前不放行读取。 5. **请求体重放(GetBody,重试兜底)**:为请求设置 GetBody 与加锁 Rewind(Seek 0 回绕),传输层连接失败时可按 HTTP 重试语义重放请求体(unary 幂等语义下安全)实现时配套重放路径测试。 6. **规划内的回归测试**:(约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 设计依据 1. **原型实测**:方案在 4 档报文全部正收益;备选方案二收益在方案一实现后会被吸收大半,不单独实施,只做兜底补充。 2. **独立开关(即时回退)**:TripleConfig `unary-fast-path` 配置开关(默认 false)控制,默认关闭,线上异常改配置重启即可回退社区路径,零代码变更。 3. **范围隔离与互通**:仅 unary 走快路径,streaming 保留 `duplexHTTPCall`(上游 #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 语义验证,不阻塞本期交付。 4. **上游论据(connect-go 官方)**:triple\_protocol 包源自 connect-go(duplex\_http\_call\_test.go 版权头与 `fixed upstream in connect-go v1.19.2` 注释可证)。connect-go 在 issue #609 中明确承认:所有 RPC 共用 `duplexHTTPCall` 对 unary 是 overkill,其中的同步机制、每请求额外 goroutine 与 `io.Pipe` 对 unary 是 **pure overhead**,并通过 PR #611与#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 污染。 5. **gzip 对齐(对齐生产 wire 形状)**:生产客户端默认发 `Accept-Encoding: gzip`,服务端默认 gzip 压缩响应。原型早版未对齐,2MiB 档慢 2.4x;对齐后反超 -38%。快路径走生产同一客户端栈,天然带该 header。 6. **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 改动范围 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 包装。 2. **`NewConn`** **分支**:`spec.StreamType == StreamTypeUnary && c.UnaryFastPath` 走 `newUnaryFastPathCall(ctx, httpClient, url, spec, header, bufferPool)`,否则走 `newDuplexHTTPCall`。**范围隔离**:streaming(client/server/bidi)与开关关闭时路径与社区完全一致,零改动。 3. **调用对象接口化**:`tripleUnaryClientConn.call` 从具体类型改为 `unaryClientCall` 接口,marshaler(`writer: call`)/ unmarshaler(`reader: call`)/ validateResponse 注入 / bufferPool 全部复用,社区与快路径共用同一套编解码、压缩与 header 语义(行为差异面最小)。 4. **请求发送语义变化**:duplex 的 `Write` 写 io.Pipe → 每请求 goroutine 后台 `Do` 改为 fastpath 的 `Write` 累积 pooled buffer(零网络触碰)→ `CloseWrite` 同步 `Do`,请求体经 `unaryRequestBody` 直接引用 pooled buffer,无管道交接。 5. **body 归还机制变化**:duplex 请求体随 pipe 读端流入 http2、无池化归还;fastpath 归还委托 `unaryRequestBody.Close`(x/net/http2 对 `reqBody.Close()` 恰好调用一次:正常读完或 abort 时),内部互斥锁保证与后台 bodyWriter 的读不竞态,彻底规避跨请求 buffer 污染。 6. **一键回滚开关**:`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`,开关关闭时 = 默认路径), 每请求两个执行上下文** ```mermaid 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`** **开关启用时), 单执行上下文同步直发** ```mermaid 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 信号等待 ```go // 构造:完整 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(完整实现): ```go 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 生产中影响因素(预估收敛要素) 方案原型收益(协议层发送路径净收益)是优化基点,生产集成后受以下要素影响,预估时需计入: 1. **端到端摊薄**(最主要):协议内 A/B 为同栈对比(6.4),收益即协议层发送路径净收益;端到端叠加框架栈/网络/服务端调用链耗时,延迟下降比例被摊薄,QPS 提升收敛至低两位数百分比量级。 2. **协议栈差异**(收敛):原型 A/B 走 h2c(HTTP/2 cleartext)测试服务端,生产**通常**走 HTTP/2(Triple 主要部署形态,快路径本身不区分 HTTP 版本);HTTP/2 连接层为 dubbo-go 与 gRPC 共有部分,不在本次优化范围。 3. **gzip 压缩开关**(指标口径):用户开启压缩时,压缩路径自身临时分配会掩盖 B/op 账面收益;延迟与吞吐收益不受影响。 4. **Content-Length 依赖(O3)**(条件收益):快路径声明 Content-Length 后,依赖服务端按该长度预分配 buffer 才吃到收益;该字段为 HTTP 标准语义,dubbo-go/gRPC/Java 服务端均兼容,风险低。 5. **业务负载与并发饱和**(收敛):真实调用带业务(0.5ms\~几ms/请求),io.Pipe 固定开销占比被业务耗时摊薄;纯网络转发场景(业务≈0)才接近原型档位;流控/带宽天花板属预期。 6. **服务端调用链与带宽**(收敛):生产服务端带完整调用链(序列化/业务 handler/下游依赖),响应时间以服务端为主,客户端发送路径优化被整体耗时稀释;大报文(1MiB)受服务端带宽天花板限制。 7. **GC 与长跑**(双向):生产长跑下 bufferPool 复用使 B/op 收益更稳定;GC 压力与内存池行为可能放大或缩小分配收益,取中性。 8. **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](https://cn.dubbo.apache.org/en/overview/mannual/java-sdk/reference-manual/performance/rpc-benchmarking/#12-data-analysis) - connect-go 上游 issue #609(unary 专用流程设计讨论):<https://github.com/connectrpc/connect-go/issues/609> - connect-go 上游 PR #611(Special case unary HTTP calls):<https://github.com/connectrpc/connect-go/pull/611> - connect-go 上游 PR #649(Special case unary HTTP calls):<https://github.com/connectrpc/connect-go/pull/649> *** ## 许可 Apache License 2.0 GitHub link: https://github.com/apache/dubbo-go/discussions/3673#discussioncomment-18111020 ---- This is an automatically sent email for [email protected]. To unsubscribe, please send an email to: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
