Repository navigation
Discussion 3673
- 编号:#3673
- 分类:General
- 状态:打开 / 无已采纳答案
- 创建:2026-08-17T10:38:17Z
- 更新:2026-09-20T02:31:03Z
- 作者:lizining1231
社区原始记录;其中的建议、未决提案和版本描述需结合 版本与升级 核对。快照日期:2026-09-20。
阅读说明:原文引用作者本地磁盘文件的链接已转为文字路径,避免产生无效 Wiki 导航;原始内容完整保存在 sources/discussions.json。
OSPP 2026 · Apache Dubbo-Go
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。
- profiling 工具链(pprof CPU/内存火焰图、runtime/trace、benchstat 显著性检验)26-8-17 Done
- 三端基准测试(Dubbo-Go / gRPC-Go / Dubbo-Java,以及补充场景)26-08-17—26-08-18 Done https://github.com/apache/dubbo-go/discussions/3673#discussioncomment-18071388
- 兼容性回归基线(Triple 协议兼容、gRPC 兼容、Dubbo-Java 互通)26-8-18—26-8-19 Done https://github.com/apache/dubbo-go/pull/3677 https://github.com/apache/dubbo-go/pull/3689
(从已定位的低风险固定开销开始验证,再根据正式 profiling 数据决定是否深入 Envelope、Buffer 或 Codec 热路径)
- io.Pipe 交接与每请求 goroutine(调度与管道交接开销,已验证)26-8-19—26-8-21 Done https://github.com/apache/dubbo-go/discussions/3673#discussioncomment-18111020
- 分析codec层内存分配可优化点 26-9-2—26-9-3 Done https://github.com/apache/dubbo-go/discussions/3673#discussioncomment-18268020
- 消掉 wrapper 层的新分配 于2026-9-12开始 进行中 https://github.com/apache/dubbo-go/discussions/3673#discussioncomment-18483329
- envelope 写路径(prefix 与 body 分离写入、多次 flush)
- buffer 复用(Reset 不缩容:小消息占大缓冲、大消息反复扩容)
- header/metadata 处理(重复拷贝与规范化)
- codec 序列化与反序列化(非 protobuf 场景分配放大)
- http2.Transport 参数与连接池调优(可选探索)
Step 3: 优化落地
- 落地 io.Pipe 交接固定开销的消除路径 26-8-20—26-9-2 Done https://github.com/apache/dubbo-go/pull/3706
- 落地codec层内存分配优化(新加接口)于26-9-4—26-9-6 Done https://github.com/apache/dubbo-go/pull/3718
- 写路径对齐缓冲(合并 prefix 与 body 的大块写入)
- header/metadata 路径优化(合并拷贝、去除重复规范化)
- buffer 复用优化(按消息长度预扩容、分级缓冲池)
- codec 复用与压缩/解压输出预扩容
- 写路径对齐缓冲(合并 prefix 与 body 的大块写入)
- http2.Transport 参数与连接池调优(可选探索)
Step 4: 验证与交付
- 同环境 A/B 对比 + benchstat(Mann-Whitney U,α=0.05)量化收益
- 全量回归测试,确认兼容性红线
- 优化前后性能数据整理,输出对比结论
这份文档会随任务推进而细化,每个任务对应有的性能报告、瓶颈验证、具体优化方案等会随进度以markdown文档形式发在评论区,原始数据以链接形式放在文档里。
永久链接 · 2026-08-18T20:48:48Z
这里是一份初步的性能基准测试报告:
- 一、测试环境
- 二、测试配置
- 三、测试数据
- 四、服务端内存分配(B/op、allocs/op)测试数据
- 五、特定场景测试数据摘要
- 六、性能优化
- 七、许可证
- Go 版本: 1.25.6
- Java 版本: 17.0.19
- Maven: 3.9.16
- OS: Ubuntu 24.04.1 LTS (WSL2, Linux 6.6.87)
- CPU: Intel Core i7-10750H @ 2.60GHz, 6 核 12 线程
- 内存: 7.6 GiB
- 测试框架: Dubbo-Go / gRPC / Dubbo-Java
- 预热时间: 10s
- 测试时长: 每个用例 60s
- 轮次: 5 轮 × 24场景取中位数
| 参数 | 值 |
|---|---|
| 报文大小 | 128B / 1KiB / 16KiB / 1MiB |
| 序列化 | protobuf |
| 压缩 | none |
| 并发数 | 50 / 100 |
| 调用模式 | unary |
| 并发数 | 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 |
| 并发数 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| 50 | 19.56 | 3.58 | 6.74 |
| 100 | 33.29 | 5.54 | 12.23 |
| 并发数 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| 50 | 3,597.7 | 33,508.7 | 8,870.0 |
| 100 | 3,670.8 | 42,400.3 | 8,919.1 |
| 并发数 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| 50 | 21.51 | 4.09 | 7.79 |
| 100 | 42.96 | 6.80 | 14.07 |
| 并发数 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| 50 | 1,980.3 | 11,882.1 | 4,772.9 |
| 100 | 1,829.6 | 12,852.9 | 4,819.3 |
| 并发数 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| 50 | 43.24 | 10.07 | 21.03 |
| 100 | 93.16 | 16.28 | 32.91 |
| 并发数 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| 50 | 122.9 | 284.5 | 288.4 |
| 100 | 119.4 | 274.0 | 293.5 |
| 并发数 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| 50 | 1,074.68 | 261.09 | 279.11 |
| 100 | 2,400.09 | 511.09 | 697.37 |
| 并发数 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| 50 | 23,307 | 5,899 | 20,345 |
| 100 | 22,976 | 5,895 | 20,342 |
| 并发数 | dubbo-go | grpc |
|---|---|---|
| 50 | 151.5 | 79.4 |
| 100 | 151.5 | 79.3 |
| 并发数 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| 50 | 27,659 | 8,847 | 23,160 |
| 100 | 27,317 | 8,844 | 23,163 |
| 并发数 | dubbo-go | grpc |
|---|---|---|
| 50 | 153.1 | 79.6 |
| 100 | 153.1 | 79.4 |
| 并发数 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| 50 | 123,700 | 60,638 | 86,883 |
| 100 | 113,426 | 60,625 | 86,922 |
| 并发数 | dubbo-go | grpc |
|---|---|---|
| 50 | 161.2 | 81.7 |
| 100 | 164.5 | 81.4 |
| 并发数 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| 50 | 3,124,173 | 3,176,880 | 4,417,419 |
| 100 | 3,085,140 | 3,173,124 | 4,413,321 |
| 并发数 | dubbo-go | grpc |
|---|---|---|
| 50 | 407.4 | 119.7 |
| 100 | 406.9 | 104.9 |
| 指标 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| QPS | 4,537.6 | 47,064.7 | 10,667.0 |
| P99 (ms) | 33.29 | 5.54 | 12.23 |
| B/op | 22,976 | 5,895 | 20,342 |
| allocs/op | 151.5 | 79.3 | N/A |
1Mib × 50 并发
| 指标 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| QPS | 122.9 | 284.5 | 288.4 |
| P99 (ms) | 1,074.68 | 261.09 | 279.11 |
| B/op | 3,124,173 | 3,176,880 | 4,417,419 |
| allocs/op | 407.4 | 119.7 | N/A |
1Mib × 100 并发
| 指标 | dubbo-go | grpc | dubbo-java |
|---|---|---|---|
| QPS | 119.4 | 274.0 | 293.5 |
| P99 (ms) | 2,400.09 | 511.09 | 697.37 |
| B/op | 3,085,140 | 3,173,124 | 4,413,321 |
| allocs/op | 406.9 | 104.9 | N/A |
参照社区已有benchmark,为获得最佳性能,Dubbo-Go 客户端使用了以下优化配置:
| 配置项 | 说明 |
|---|---|
WithClientNoCheck() |
跳过服务检查,减少不必要的开销 |
MaxCallRecvMsgSize: 16MB |
最大接收消息大小,支持大报文测试 |
MaxCallSendMsgSize: 16MB |
最大发送消息大小,支持大报文测试 |
参照社区已有benchmark,服务端配置了以下优化参数:
| 配置项 | 说明 |
|---|---|
WithMaxServerRecvMsgSize("16MB") |
最大接收消息大小 |
WithMaxServerSendMsgSize("16MB") |
最大发送消息大小 |
Apache License 2.0
永久链接 · 2026-08-18T22:20:38Z
非瓶颈分析定位,先对这份报告做出一些初步解释:
测试显示在本机环境下dubbo-go的性能表现略弱于dubbo-java,与社区测试数据结论相违背。目前排除wsl2拖慢golang的可能性,现有数据报告显示部分场景dubbo-go与gRPC部分场景QPS比值接近1:5,在本机被放大为1:10,问题需进一步换环境/切版本分支等 尝试推进排查
Java 标准 API 只能统计分配字节数,没有精确的分配次数接口
| 行为 | 说明 |
|---|---|
dubbo.protocol.name=tri |
协议由 dubbo 改为 Triple(SPI 扩展名 tri) |
@EnableDubbo(scanBasePackages="benchmark") |
扫描包对齐 Go 端 benchmark.BenchmarkService
|
protobuf 生成类 (BenchmarkProto) |
消息契约由手写 POJO 改为 protobuf 生成类,序列化与 Go 端一致 |
dubbo-rpc-triple 依赖 |
引入 Triple 协议实现模块 |
测试与社区现有测试数据绝对值与指标比例均不符合,但是:
50→100 并发下社区与本地都是 dubbo-go 已饱和/微降 dubbo-go 128B 社区测 -2.5%、本地测 +2.5%;16KiB 社区本地测都降), grpc 仍线性增长 (社区测 +10.7%、本地测 +16%)
如50并发下QPS 相对衰减(这里归一化 128B=1.00):
| 框架 | 数据源 | 128B | 1KiB | 16KiB | 1MiB |
|---|---|---|---|---|---|
| dubbo-go | 社区 | 1.00 | 0.89 | 0.48 | 0.02 |
| dubbo-go | 本地 | 1.00 | 0.81 | 0.45 | 0.03 |
| grpc | 社区 | 1.00 | 0.87 | 0.41 | 0.01 |
| grpc | 本地 | 1.00 | 0.83 | 0.29 | 0.007 |
| dubbo-java | 社区 | 1.00 | 0.91 | 0.47 | 0.03 |
| dubbo-java | 本地 | 1.00 | 0.83 | 0.45 | 0.03 |
- 修复benchmark保证测试成功率 #3677
- benchmark修复与提交,使其存在测试java的能力 #3689
- 解读性能报告,初步确定瓶颈并进一步推进瓶颈分析
都出现小包下与 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)
永久链接 · 2026-08-19T01:48:04Z
和社区结果不一致这个事情在这个阶段不用纠结
永久链接 · 2026-08-19T05:07:19Z
和社区结果不一致这个事情在这个阶段不用纠结
好,那我接下来的方向调整为:
- 提交benchmark的issue与PR以支持java
- 定位分析一个明显的瓶颈并提出可能的解决方案
产出内容是相关pr和瓶颈分析报告。并对近期TODO同步调整,删除排查数据不一致问题的任务
永久链接 · 2026-08-21T22:27:02Z
这里是一份针对Triple-unary的性能瓶颈分析报告,里面有一定的方案设计,如果没问题,之后的推进形式是issue和PR
一、全文摘要
二、数据分析与瓶颈假设
三、代码瓶颈定位
四、多工具交叉验证瓶颈
五、方案设计与演进
六、方案验证与实施
七、预估收益
八、参考链接
本文针对 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%、allocs 持平~微增。
这里从基准测试数据报告入手,挑选一些特定场景的数据总结规律,这些规律均指向固定开销,然后定位到固定开销的瓶颈。
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),高并发 × 小报文是差距最大的场景,也是本报告的主要场景。
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 连接层。
| 指标(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、调度)解释。
先来定义一下什么叫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 完全缺失的固定开销,锁定这两项后,接下来看一下这些固定请求的代码。
duplex_http_call.go L59-L103(newDuplexHTTPCall:cloneURL → io.Pipe → 请求体挂管道读端)
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{ ... }
}duplex_http_call.go L265-L307(makeRequest:httpClient.Do 发送路径)
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 关闭请求体
}(duplex_http_call.go L108-L126):
// 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.goL526-L527);envelopeWriter(5 字节 prefix + body 分离两次写)仅 gRPC 协议使用(protocol_grpc.goL206 / L298),不属于 Triple unary 写路径。
| 对比项 | 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,这是小报文固定开销差距的主要来源。
dubbo-go

gRPC-go

解读一下,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 快路径方案想要去除的开销。
dubbo-go

dubbo-go的syscall6展开

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 交接。
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 |
源码定位将固定开销锁定在 duplexHTTPCall 每次 unary 调用创建的 io.Pipe(duplex_http_call.go L76)与 go makeRequest() goroutine(L267)dubbo-go 独有、gRPC 完全缺失的两项开销。
在保证 streaming(client/server/bidi)行为完全不变的前提下,为 unary 建立一条无 io.Pipe、无每请求 goroutine 的发送路径。
-
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 降低。
方案涉及请求行为模型改动(风险中高),提前应对风险的防御性设计如下:
-
风险兜底:
-
① 一键回滚: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 重放请求体)。
- 方案一(unary 快路径):结构改造,消除 pipe + goroutine。收益面覆盖全部 unary 场景。
-
方案二(写路径合并):局部微调,将 envelope 的两次写合并为一次。注意
envelopeWriter仅 gRPC 协议路径使用,不属于 Triple unary 写路径,与方案一优化的是两条独立路径,收益边际,仅作兜底。 - 组合:方案一为主,方案二兜底/补充
- 原型实测:方案在 4 档报文全部正收益;备选方案二收益在方案一实现后会被吸收大半,不单独实施,只做兜底补充。
-
独立开关(即时回退):TripleConfig
unary-fast-path配置开关(默认 false)控制,默认关闭,线上异常改配置重启即可回退社区路径,零代码变更。 -
范围隔离与互通:仅 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 语义验证,不阻塞本期交付。 -
上游论据(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 污染。
-
-
gzip 对齐(对齐生产 wire 形状):生产客户端默认发
Accept-Encoding: gzip,服务端默认 gzip 压缩响应。原型早版未对齐,2MiB 档慢 2.4x;对齐后反超 -38%。快路径走生产同一客户端栈,天然带该 header。 -
pool 对齐生产配置:buffer 上限对齐生产
buffer_pool.go(8MiB,初始 512B),避免大 buffer 被池丢弃导致每次重新分配。
被测是构造的简化原型,指标是单请求耗时 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 基线的变化率(负 = 更快、正 = 更慢),每轮跑出的基线值不同,跨行数值不具可比性,仅看各版本内的档位趋势;
正式方案当前设计以接口抽象 + 分支路由的方式落地:tripleUnaryClientConn 持有的具体调用从 *duplexHTTPCall 抽象为 unaryClientCall 接口,NewConn 按 RPC 类型路由,unary 走新实现的 unaryFastPathCall(同步直发),streaming 与开关关闭时保留原 duplexHTTPCall。协议层 marshaler/unmarshaler、bufferPool、错误链与 header 语义全部复用,改造对协议行为零侵入。
-
新增
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。
改造前(社区 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)+ 锁保护归还。
| 组件 | 改造前(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 语义 | 原样 | 原样 | 不变 |
-
改造前:
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 基准。
原型通过测试代码提炼社区原版与设计版 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,避免动态扩容。
数据口径: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 由负转正的关键
方案原型收益(协议层发送路径净收益)是优化基点,生产集成后受以下要素影响,预估时需计入:
- 端到端摊薄(最主要):协议内 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.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 #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
永久链接 · 2026-08-22T12:00:02Z
分析的不错 如果可以的话可以写个博客 让雨哥发在公众号里😃
永久链接 · 2026-08-22T13:27:29Z
好!
永久链接 · 2026-09-05T03:49:40Z
目前 Unary 快速通道的链路是:
消息 → MarshalAppend 写入池化缓冲区 A
→ unaryFastPathCall.Write
→ 复制到池化请求体 B
→ HTTP transport
复制明确发生在 unary_fastpath.go:148
可以增加内部的“接收完整请求体”接口,让发送端直接接管 A:
消息 → 池化缓冲区 A → 移交给 Request.Body → Body.Close 时归还
这样能够:
- 消除一次 O(L) 的完整 payload 复制。
- 减少发送准备阶段同时持有的缓冲区。
- 压缩场景直接移交压缩结果,省掉压缩数据到请求体的复制。
关键是明确“成功接管后谁负责释放”,覆盖取消、发送失败、尚未提交请求等路径。不能在 http.Client.Do 返回时直接归还,因为 transport 可能仍在异步读取请求体;应沿用现有 unaryRequestBody.Close 的释放机制。Go HTTP 契约
这里消除的是应用层的一次复制,HTTP/2、TLS 内部仍可能复制。
以及把 gRPC 的 5 字节头与 payload 一起构造
当前 envelope.go:147 分别写入 prefix 和 payload。
可以在池化缓冲区前预留 5 字节:
[ flags | payload length | MarshalAppend 的结果 ]
编码完成后回填
再整体移交给 Unary 请求体。压缩时,在压缩输出缓冲区预留头,填入实际压缩长度。
这有机会同时消除独立 prefix 的逃逸分配,并减少 Write 调用和请求体锁操作。应在编码前预留空间,否则事后移动整份 payload 会抵消收益。两次 Write 也不等于两次系统调用,收益需要实测。
永久链接 · 2026-08-25T20:58:03Z
😔抱歉的消息,根据8月26日优化落地后性能异常引发的排查,发现在8月19日的基准测试中犯了一个错误,那份基准测试报告实测为dubbo-go默认协议grpc协议,而非triple协议。补救措施如下:
- 取消勾选已完成的plan step 1.2(如上)
- 注明初始基准测试为grpc而非triple(如上)
- 先补测dubbo-go端的triple协议的QPS/P99指标性能基准,以供与io.pipe优化落地作对比(如下)
- 补测三端四指标性能基准测试,并勾选plan step 1.2
这里是dubbo-go端 triple协议 duplex版本:
- Go 版本: 1.25.6
- OS: Ubuntu 24.04.1 LTS (WSL2, Linux 6.6.87)
- CPU: Intel Core i7-10750H @ 2.60GHz, 6 核 12 线程
- 内存: 7.6 GiB
- 测试框架: Dubbo-Go(Triple 协议路径)
- 预热时间: 10s
- 测试时长: 每个用例 60s
- 轮次: 3 轮/场景,取中位数
| 参数 | 值 |
|---|---|
| 报文大小 | 128B / 1KiB / 16KiB / 1MiB |
| 序列化 | protobuf |
| 压缩 | none(消息级)/ 响应 gzip(HTTP 层默认) |
| 并发数 | 50 / 100 |
| 调用模式 | unary |
| 对照项 | duplex(io.Pipe 原路径,未优化基线) |
| 并发数 | duplex |
|---|---|
| 50 | 5,329.3 |
| 100 | 5,409.2 |
| 并发数 | duplex |
|---|---|
| 50 | 15.99 |
| 100 | 30.58 |
| 并发数 | duplex |
|---|---|
| 50 | 4,300.3 |
| 100 | 4,368.5 |
| 并发数 | duplex |
|---|---|
| 50 | 20.40 |
| 100 | 37.48 |
| 并发数 | duplex |
|---|---|
| 50 | 2,584.2 |
| 100 | 2,428.2 |
| 并发数 | duplex |
|---|---|
| 50 | 37.28 |
| 100 | 81.24 |
| 并发数 | duplex |
|---|---|
| 50 | 101.3 |
| 100 | 104.3 |
| 并发数 | duplex |
|---|---|
| 50 | 1,430.17 |
| 100 | 3,179.88 |
| 配置项 | 说明 |
|---|---|
WithClientNoCheck() |
跳过服务检查,减少不必要的开销 |
MaxCallRecvMsgSize: 16MB |
最大接收消息大小,支持大报文测试 |
MaxCallSendMsgSize: 16MB |
最大发送消息大小,支持大报文测试 |
服务端配置了以下参数:
| 配置项 | 说明 |
|---|---|
WithMaxServerRecvMsgSize("16MB") |
最大接收消息大小 |
WithMaxServerSendMsgSize("16MB") |
最大发送消息大小 |
Apache License 2.0
永久链接 · 2026-08-25T21:52:16Z
- 这个是connect编码数据,PR旧版本,现在PR已经是grpc编码,数据绝对值不同、收益比例不同,整体趋势与对应benchstat结果相似,数据待更新
-
Go 版本: 1.25.6
-
OS: Ubuntu 24.04.1 LTS (WSL2, Linux 6.6.87)
-
CPU: Intel Core i7-10750H @ 2.60GHz, 6 核 12 线程
-
内存: 7.6 GiB
-
测试框架: Dubbo-Go(Triple 协议路径)
-
编码: connect编码,非标准grpc编码
-
预热时间: 10s
-
测试时长: 每个用例 60s
-
轮次: 3 轮/场景,取中位数
| 参数 | 值 |
|---|---|
| 报文大小 | 128B / 1KiB / 16KiB / 1MiB |
| 序列化 | protobuf |
| 压缩 | none(消息级)/ 响应 gzip(HTTP 层默认) |
| 并发数 | 50 / 100 |
| 调用模式 | unary |
| 对照项 | duplex(io.Pipe 原路径) / unary fast path |
| 并发数 | duplex | fastpath | 提升 |
|---|---|---|---|
| 50 | 5,329.3 | 6,997.9 | +31.3% ↑ |
| 100 | 5,409.2 | 6,024.1 | +11.4% ↑ |
| 并发数 | duplex | fastpath | 提升 |
|---|---|---|---|
| 50 | 15.99 | 12.67 | -20.8% ↑ |
| 100 | 30.58 | 29.74 | -2.7% ↑ |
↑ 为正收益
| 并发数 | duplex | fastpath | 提升 |
|---|---|---|---|
| 50 | 4,300.3 | 5,196.4 | +20.8% ↑ |
| 100 | 4,368.5 | 5,605.8 | +28.3% ↑ |
| 并发数 | duplex | fastpath | 提升 |
|---|---|---|---|
| 50 | 20.40 | 18.22 | -10.7% ↑ |
| 100 | 37.48 | 26.95 | -28.1% ↑ |
| 并发数 | duplex | fastpath | 提升 |
|---|---|---|---|
| 50 | 2,584.2 | 2,871.6 | +11.1% ↑ |
| 100 | 2,428.2 | 2,815.7 | +16.0% ↑ |
| 并发数 | duplex | fastpath | 提升 |
|---|---|---|---|
| 50 | 37.28 | 30.90 | -17.1% ↑ |
| 100 | 81.24 | 69.07 | -15.0% ↑ |
| 并发数 | duplex | fastpath | 提升 |
|---|---|---|---|
| 50 | 101.3 | 101.7 | +0.4% ↑ |
| 100 | 104.3 | 114.4 | +9.8% ↑ |
| 并发数 | duplex | fastpath | 提升 |
|---|---|---|---|
| 50 | 1,430.17 | 1,479.12 | +3.4% ↓ |
| 100 | 3,179.88 | 2,874.32 | -9.6% ↑ |
| 配置项 | 说明 |
|---|---|
WithClientNoCheck() |
跳过服务检查,减少不必要的开销 |
MaxCallRecvMsgSize: 16MB |
最大接收消息大小,支持大报文测试 |
MaxCallSendMsgSize: 16MB |
最大发送消息大小,支持大报文测试 |
WithUnaryFastPath() |
启用 unary fast path(tripleClient.NewConn 分支,默认关闭) |
服务端配置了以下优化参数:
| 配置项 | 说明 |
|---|---|
WithMaxServerRecvMsgSize("16MB") |
最大接收消息大小 |
WithMaxServerSendMsgSize("16MB") |
最大发送消息大小 |
goos: 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%可得,
| 报文 | duplex (sec/op) | fastpath (sec/op) | 变化 | p 值 | 显著性 |
|---|---|---|---|---|---|
| 128B | 163.6µs | 121.3µs | -25.85% | 0.003 | 显著 |
| 1KiB | 157.4µs | 113.0µs | -28.25% | 0.002 | 显著 |
| 16KiB | 202.0µs | 182.9µs | -9.4% | 0.393 | 不显著 |
| 2MiB | 6.891ms | 7.740ms | +12.3% | 0.796 | 不显著 |
| geomean | 435.1µs | 373.2µs | -14.24% | - | 整体正收益 |
Apache License 2.0
永久链接 · 2026-08-31T11:24:41Z
分析一下负收益部分是因为什么原因
永久链接 · 2026-08-31T20:04:27Z
1MiB场景下 优化前后火焰图diff
1MiB场景下 优化前 memmove展开

1MiB场景下 优化后 memmove展开

采样时间相同,duplex 33.15s / 19.70% → fastpath 36.99s / 21.26%。fastpath 在 bytes.Buffer 里先 Write 再 Read 整包交给传输层,同一条 1MiB 数据比 duplex 的 io.Pipe 流式管道多传输一遍,所以大报文下 fastpath 收益整体为负。
fastpath 节省的是固定开销,而同时存在会随报文增大而增大的可变开销。在128B报文下,每请求成本里io.pipe的固定开销在总开销中占比很高,fastpath 省下的收益 >其带来的拷贝代价 在1MiB报文下,每请求 ~4ms 被 gzip 压缩(23%)和传输(20%)占满,固定开销占总开销比例趋零,收益也 ≈ 0,同时拷贝开销随报文增大而增大,存在负收益。
从源码来看,池化 bytes.Buffer直到 CloseWrite 才同步发起请求,传输层再从该 buffer 整包读走,同一份 1MiB 数据在内存里被 Write + Read 运输两遍。而原 duplex 路径直接把 payload 写入 io.Pipe,net/http 边读边发,数据只运输一次。
duplex(流式,不囤积):
func (d *duplexHTTPCall) Write(data []byte) (int, error) {
...
// 写入 io.Pipe,net/http 边读边发,数据仅运输一次
return d.requestBodyWriter.Write(data)
}fastpath(整包囤积 + 二次搬运):
// 先整包囤进内存(第一次搬运)
func (c *unaryFastPathCall) Write(data []byte) (int, error) {
...
return c.body.Write(data) // 1MiB 拷入 pooled buffer
}
// 传输层再从 buffer 读走(第二次搬运)
func (b *unaryRequestBody) Read(p []byte) (int, error) {
...
return b.buf.Read(p) // 1MiB 拷给网络层
}永久链接 · 2026-08-31T21:49:24Z
近几轮压测出现未控制变量、理解偏差导致数据有效性低的问题复盘:
-
triple协议不需要显示设置,protocol.WithTriple()是设置triple协议,triple_protocol.WithTriple()是换编码为connect,二者不同。之前因为读到wire层注释
clients use the GRPC protocol. Add triple_protocol.WithTriple().误认为框架层默认协议是grpc,dubbo-go的默认协议是triple,协议计入变量 -
connect编码的数据与grpc编码的数据不同,Triple wire 是connect编码,但是triple协议的默认编码为grpc,编码计入变量
-
根据代码现有行为,dubbo-go默认开gzip,grpc端默认不开gzip,gzip计入变量
-
间隙10s无法使得电脑状态恢复,虽然cpu没有降频其他环境信息也无变化,连续压测下会出现先测的绝对值高后测的低、早上性能高晚上性能低,测试时间位置、测试轮数、测试顺序应计入变量
-
同一轮测试存在一定抖动幅度,不同批次的测试,基线和优化均容易取到抖动下的中位数,无法复现性能收益百分比的情况,例如同一个duplex测五轮P99指标,
第一批:20.66,17.25,17.15,18.73,17.10
第二批:22.08,20.84,22.22,18.57,19.92
可能需要提升轮数、提升间隙时间,并注意原始数据
-
以后测试的时候可以用这些方法并固化为脚本:确认协议看日志第一行
URL specified explicitly tri://... → triple,确认编码 tcpdump/代理抓 HTTP/2 头,application/grpc+proto= gRPC 编码;application/triple+wrapper/application/proto= connect 编码,确认gzip 抓包,Content-Encoding: gzip = 开;identity = 关
永久链接 · 2026-09-03T11:50:57Z
step2.2
一、摘要
二、数据分析
三、代码瓶颈定位
四、瓶颈验证
五、方案设计
六、方案验证
七、预估收益与方案边界
八、参考链接
本文针对 Triple unary 发送热路径上的结构性 per-request 分配税,即”每次请求 codec.Marshal 新分配一块 ≈L 的输出切片、再 bytes.NewBuffer 包一层、再全量拷进传输缓冲“做分析。
从分配数据定位到该瓶颈,用pprof等工具进行验证,设计"池 + MarshalAppend"零分配方案——给 Codec 增加可选接口 marshalAppender,envelopeWriter / tripleUnaryMarshaler 从既有 bufferPool 取 *bytes.Buffer 借底层数组做追加目标,未命中(hessian2/msgpack/json/自定义 codec)自动回退旧路径,行为零变化。预估收益:B/op 大包 −10%~−25%、allocs/op 端到端 −2~−6。
这次优化的重点内容是内存分配,所以这边主要采用go test -bench进行测试
定向 marshal bench 的分配绝对量(优化前,每消息):
| 消息 | marshal 层 B/op(优化点 A+B) | 端到端 B/op(fast path 生产入口) | marshal 层占比 |
|---|---|---|---|
| 128B | ≈216 | ≈26.6KB | <1% |
| 1KiB | ≈1223 | ≈32.1KB | ~3.8% |
| 16KiB | ≈18505 | ≈143.5KB | ~12.9% |
| 1MiB | ≈1056870 | ≈6.53MB | ~16.2% |
一些规律:
-
分配量随消息增大:
codec.Marshal的输出切片大小 ≈ 消息字节数(按 size class 取整),消息越大,单次分配越多,这是大包场景收益更明显的原因。 - 小报文分配税占比不足 5%:128B/1KiB 段,marshal 层 B/op 占总 B/op 的比例被 http2/TLS/context 等固定开销淹没,端到端 B/op 收益不可见,只能从定向 bench 看。
- 大报文分配税成为主导项:≥16KiB 段 marshal 层占端到端 B/op 的 13%~16%,是端到端 B/op 中最大的单一项,收益预估在端到端可见。
所以这次优化主要针对的是分配税,大小包都会收益,大包下会更明显,小包相对收益不会明显。
gRPC-Go 的 encoding.Codec.Marshal 也是每次 proto.Marshal 新分配的,但区别在发送路径:
| 开销项 | dubbo-go(Triple,优化前) | gRPC-Go |
|---|---|---|
codec.Marshal 新分配 |
每次(≈L) | 每次(≈L,相同) |
bytes.NewBuffer 包装 |
每次(≈40B 小分配) | 无(直接以 []byte 交 transport) |
| marshal 输出复用 | 仅包装对象进池,底层 []byte 不复用 | 无(每次 proto.Marshal 新分配) |
本次要优化的点有两个:优化点 A是codec.Marshal 每次新分配 ≈L 输出切片并全程清零的开销
优化点 B是bytes.NewBuffer(raw) 每次 ≈40B 逃逸包装的开销。下表按 1MiB 档列出两者的每次开销、gRPC-Go 及上游 connect-go是否有此开销。
| 优化点 | 每次开销(1MiB 档) | gRPC-Go 是否有 | 上游 connect-go 是否已消除 |
|---|---|---|---|
codec.Marshal 输出切片(优化点 A) |
每次 unary 新分配 ≈L(1MiB → ≈1,048,576 B)+ 全程清零(memclr ≈53µs/次) |
有:proto.Marshal 同样每次新分配 ≈L + 清零,无任何复用 |
已消除:marshalAppend 把结果追加进池 buffer,0 分配 0 清零 |
bytes.NewBuffer(raw) 包装(优化点 B) |
每次 unary ≈40B 小分配(*bytes.Buffer 逃逸到堆),与消息大小无关 |
无:marshal 输出 []byte 直接交 transport,不做包装 |
已消除:envelopeWriter 用池内 buffer 包装,无逃逸小分配 |
protocol/triple/triple_protocol/codec.go#L111-L117:
func (c *protoBinaryCodec) Marshal(message any) ([]byte, error) {
protoMessage, ok := message.(proto.Message)
...
return proto.Marshal(protoMessage) // proto.Marshal 内部 o.marshal(nil, ...):以 nil 开头,必然分配
}proto.Marshal 恒以 nil 起步,输出切片只能新分配(大小 ≈L,按 size class)。
protocol/triple/triple_protocol/envelope.go#L69-L95(gRPC wire)与 protocol/triple/triple_protocol/protocol_triple.go#L504-L531(Triple wire 慢路径)结构相同:
raw, err := w.codec.Marshal(message) // ① 优化点 A:全新 []byte,B/op≈L
buffer := bytes.NewBuffer(raw) // ② 优化点 B:*bytes.Buffer 对象(≈40B,逃逸)
defer w.bufferPool.Put(buffer) // 归还的只是包装对象;底层 []byte 每次新分配,不随包装进池复用
envelope := &envelope{Data: buffer}
return w.Write(envelope) // └── 一次写拷贝:Data 全量拷进 writer(io.Copy)所有 profile 均用固定迭代次数(-benchtime=Nx)或按事件采样(memprofile),保证 per-op 可比、不被吞吐污染。
-memprofile -memprofilerate=1 -benchtime=3000x(固定 3000 次),优化前 1MiB unary marshal:
可以观察到:
-
分配几乎 100% 集中在
proto.MarshalOptions.marshal,调用链tripleUnaryMarshaler.Marshal → protoBinaryCodec.Marshal → proto.Marshal → MarshalOptions.marshal; - 3000 次固定迭代累计分配 ≈3.1GB,其中
MarshalOptions.marshal占 99.92%; - 火焰图上的巨型分配热点可以印证章节 2.3 优化点 A 是发送路径上唯一的大分配来源。
同 3000 次固定迭代,-sample_index=alloc_space:
| 指标(3000 次累计) | 占比 |
|---|---|
| 分配总量 | ≈3.1GB |
| 热路径分配(MarshalOptions.marshal) | 99.92% |
优化后热路径 0 分配(benchmem allocs 2→0、B/op 1,056,891→353,见交叉验证表);memprofile 累计(3000 次)仍采到 ≈4.3MB 非热路径分配,来自 bench 进程级采样窗口内的消息构造与池首轮扩容等一次性成本,无法精确拆分 per-op,不作为热路径收益证据
-benchtime=30000x 固定两端 op 数,火焰图宽度 = per-op 时间占比:
| 函数 | 占比 / per-op |
|---|---|
unicode/utf8.ValidString |
40.9% / 84µs |
runtime.memmove |
28.1% / 58µs |
runtime.memclrNoHeapPointers(清零) |
23.5% / 53µs |
-
ValidString(UTF-8 校验)per-op 成本两端几乎相同(84 vs 85µs),是序列化固有成本;它在优化后"占比更大"是因为其他成本消失后成为绝对主体,不是变慢; -
省下的是
memclr53µs + 部分memmove34µs ≈ 87µs,与实测 206→115µs 的 ~91µs 差距数据相符
| 采集内容 | 测试结果 | 结论 |
|---|---|---|
| memprofile 分配热点(按事件) | 分配 99.92% 集中于 MarshalOptions.marshal(≈1MB/次 × 3000 = 3.1GB) |
优化点 A 是唯一大分配来源 |
| alloc pprof(固定 3000 次) | 累计 3.1GB;热路径占比 99.92% | 分配字节开销与占比巨大 |
| CPU profile(固定 30000 次,per-op) |
memclr 53µs 、memmove 58;ValidString 85µs |
省下 ≈87µs = 实测延迟差的全部来源 |
结论:优化核心是去除开销"每次 marshal 新分配 1MiB + 清零"。
源码定位将分配税锁定在 envelopeWriter.Marshal / tripleUnaryMarshaler.Marshal 每次调用中的 codec.Marshal 新分配(优化点 A)与 bytes.NewBuffer(raw) 包装(优化点 B)。两条 wire(gRPC / Triple-connect)共用同一批 marshaler 类型,改动一次客户端发送与 gRPC 双端发送同时受益;
- 零分配:稳态(池命中 + cap 足够)下 marshaler 层每消息 0 分配;
-
兼容性:不应改变任何原有行为,未命中
marshalAppender的 codec(hessian2/msgpack/json/自定义/generic wrapper)自动回退旧路径,wire 字节、错误链、压缩/sendMaxBytes判断顺序与阈值完全不变; -
最小侵入:用可选接口(type assert)而非改造
Codec接口本身,第三方自定义 codec 编译与运行零影响。 -
protobuf-go 零分配保证:
MarshalAppend直接把调用方b传给编码器,而Marshal恒以 nil 起步;cap 判定在 lib 层MarshalOptions门控(生成代码提供Size与 append 编码函数、sizecache缓存 size),cap(b)-len(b) >= size时跳过make+copy走纯 append,零分配条件①:生成消息 + 自备缓冲 cap 足够;
-
codec 层:新增可选接口
marshalAppender { MarshalAppend(dst []byte, m any) ([]byte, error) },protoBinaryCodec实现为proto.MarshalOptions{}.MarshalAppend(dst, protoMessage); -
envelopeWriter.Marshal:开头 type assert,命中则从
bufferPool.Get()取*bytes.Buffer,以buffer.Bytes()为 dst 调MarshalAppend;若扩容(cap(raw) > buffer.Cap())把新数组整块换入 buffer 进池,否则buffer.Write(raw)仅修正长度(0 分配); -
tripleUnaryMarshaler.Marshal:同款改造,未压缩分支把
buffer.Bytes()交给m.write,压缩分支保持*bytes.Buffer。
本方案通过对逐行改动做防御性 review 收敛出的设计约束。风险等级:中等偏低(可选接口 + 分支 + 天然回退,风险面小于 fast path 阶段的配置开关方案),以下是一些落地时需要注意的点:
-
天然回退(零配置零开关):
marshalAppender是可选接口,任何不实现它的 codec 自动走Marshal旧路径,无需配置、无需发布灰度开关即可整体回退——风险面比 fast path 阶段(需unary-fast-path开关)更小; -
pool 不变量(Get 即空、从 0 写入):快路径正确性依赖"
bufferPool.Get()每次返回 Reset 过的 buffer、MarshalAppend(buffer.Bytes())恒以 len 0 起步"。该不变量未被 language-level 强制,一旦未来有人改用未 Reset 的池或在 MarshalAppend 前改动 buffer 长度,cap(raw) > buffer.Cap()分支与buffer.Write(raw)的"修正长度"逻辑会静默错乱,需以用例锁定(见回归测试-池不变量); -
wire 字节一致性:
MarshalAppend与Marshal由同一 protobuf 编码器输出,语义保证相同;仍加"新路径输出 == 旧路径输出"对拍测试锁定,矩阵覆盖空消息/512B 边界/8MiB±1 × 压缩开关 × 双 wire(见回归测试-wire 对拍); -
buffer 归还:
write()内io.Copy同步消费完才defer Put,buffer 不可能在被读时归还;已有 fast path 的 transport 恰好一次归还纪律可复用。池内残留旧明文只驻留在数组、不随信封写出(信封只写Data.Len()),不含线上泄漏,但"读写均在归还前完成"依赖同步语义,不因未来改动破坏; -
快路径作用域边界(防静默失效):
marshalAppender仅protoBinaryCodec实现;protoWrapperCodec/hessian2Codec/msgpackCodec/protoJSONCodec/tripleServerCodecSession均不走快路径。Triple 服务端响应(IDL 与非 IDL 均如此):wire codec 为 proto 时被包成tripleServerCodecSession(protocol_triple.go#L187-L193),恒不命中快路径,服务端发送恒走慢路径。真实收益面 = Triple 客户端发送 + gRPC 双端,不主张整体零分配——收益面按此边界声明; -
gRPC/grpc-web 回归面:
envelopeWriter同时服务 gRPC wire(protocol_grpc.go),codec 为protoBinaryCodec时 gRPC 路径同样命中快路径。wire 字节等价得以保持,但最易被忽略的回归面是 gRPC 而非 triple,对拍用例须在双 wire 各跑一组; -
单级池的内存权衡:
cap(raw) > buffer.Cap()时新数组整块换入池;maxRecycleBufferSize=8MiB兜底丢弃,但 512B~8MiB 区间无 size 分级,一次大消息后大缓冲滞留,会被后续小消息复用,抬高连接常驻内存,属"分配换内存"的既有权衡,经考虑本次方案中不引入分级池; -
快慢路径逻辑重复(防漂移):慢路径(Marshal)与快路径(MarshalAppend)各自维护
sendMaxBytes/compressMinBytes/压缩头判断,将来改动只改一半会造成行为不一致,用契约用例锁定二者对同一输入的错误码与副作用一致; -
backupCodec 回退链等价:快路径先在
MarshalAppend失败、再回退marshalWithFallback(backupCodec,...);慢路径先在Marshal失败。二者回退触发点不同,但应语义等价(主==backup 不二次回退、无死循环、错误码CodeInternal、backup 为 nil 直接返回),需用例锁定; -
规划内的回归测试(约 8 个):
-
wire 字节对拍(最高优先级):同消息快/慢路径逐字节断言写给
io.Writer的 socket 输出(含 5 字节 prefix)一致;矩阵覆盖空/1B/511B/512B(边界)/513B/1KiB/8MiB±1×压缩关闭/开启(含 compressMinBytes 边界)×triple 与 gRPC 双 wire; -
池不变量与边界:Get 后
Len()==0;nil消息走write(nil)分支、空 proto 输出零长度信封、恰 512B 与恰compressMinBytes边界无 panic; -
backupCodec 回退:主 codec 失败→回退 backup 输出正确;主==backup 不二次回退、无死循环、错误码
CodeInternal;backup 为 nil 直接返回错误;快慢路径各一组; -
压缩阈值与 sendMaxBytes 一致:快慢路径对超限消息均返回
CodeResourceExhausted,压缩头tripleUnaryHeaderCompression均被设置; - >8MiB 丢弃与残留:8MiB+1 消息后该缓冲不被复用;大消息后紧接 1B 消息输出不受池内残留影响;
-
并发安全(
-race):多 goroutine 共享同一envelopeWriter/bufferPool并发Marshal,无数据竞争、无同一*bytes.Buffer双归还、输出各自正确; -
类型守卫:断言 wrapper/hessian2/msgpack/json/
tripleServerCodecSession不实现marshalAppender,锁定作用域边界,防未来误给包装 codec 加MarshalAppend造成 wire 不一致; -
快路径 codec 错误守卫:防止快路径吞错/panic。验证
MarshalAppend收到非proto.Message返回errNotProto,快路径(envelopeWriter/tripleUnaryMarshaler)转为CodeInternal且不 panic,与慢路径Marshal行为一致。
-
wire 字节对拍(最高优先级):同消息快/慢路径逐字节断言写给
与其v1.20.0对比,分"可直接对齐"与"必须本地化适配"两类:
与上游相同的点
-
buffer_pool.go:本地与上游逐行一致(initialBufferSize=512、maxRecycleBufferSize=8MiB、Get/Put 逻辑全同),无改动; -
protoBinaryCodec.MarshalAppend:本地实现proto.MarshalOptions{}.MarshalAppend(dst, protoMessage)与上游一致; -
envelopeWriter.marshalAppend:pool.Get → MarshalAppend(buffer.Bytes()) → cap 比较 → 换入或 Write → Write(envelope)方法体与上游逐行一致。
与上游不同的点(本地化适配,不可整段拉取)
-
marshalAppender接口:上游内嵌Codec(type marshalAppender interface { Codec; MarshalAppend([]byte, any) ... }),本地未内嵌(功能等价,建议对齐以保持逐行一致); -
envelopeWriter.Marshal的 appender 分支:本地保留backupCodecfallback(上游无 backupCodec,慢路径直接w.marshal());整段替换上游会丢失 dubbo 特有的 backup codec 兜底语义,故只对齐marshalAppend方法体,分支结构保持本地; -
tripleUnaryMarshaler.Marshal(protocol_triple.go):上游无此类型(unary 走 envelopeWriter),为 fast path 阶段自研类型,改造只能本地实现; -
protoJSONCodec.MarshalAppend:上游已实现(JSON 也走零分配),本地未加(可选:加上则 JSON 场景同样受益,不加行为不变)。
方案以可选接口 + 分支方式落地,共 3 个文件,均为增量改动:
| 文件 | 改动 | 是否影响非 proto codec |
|---|---|---|
codec.go |
新增 marshalAppender 接口 + protoBinaryCodec.MarshalAppend
|
否(可选接口) |
envelope.go |
envelopeWriter.Marshal 增加 appender 分支 + marshalAppend 助手 |
否(type assert 回退) |
protocol_triple.go |
tripleUnaryMarshaler.Marshal 同款改造 + marshalAppend 助手 |
否(type assert 回退) |
新增 marshal_perf_bench_test.go 作为验收证据。改动前后组件职责与调用关系对照:
| 组件 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
Codec 接口 |
仅 Marshal/Unmarshal
|
不变(marshalAppender 为独立可选接口) |
第三方 codec 零影响 |
protoBinaryCodec |
Marshal 每次新分配 |
新增 MarshalAppend(追加到外部缓冲) |
新增方法,Marshal 保留 |
envelopeWriter.Marshal |
codec.Marshal → bytes.NewBuffer → Write
|
断言 marshalAppender,命中走 marshalAppend(池 buffer) |
命中走 appender 分支,未命中自动回退旧路径 |
tripleUnaryMarshaler.Marshal |
codec.Marshal → bytes.NewBuffer → Write(与 envelopeWriter.Marshal 结构相同) |
断言 marshalAppender,命中走 marshalAppend(池 buffer) |
命中走 appender 分支,未命中自动回退旧路径 |
bufferPool |
归还时仅包装对象进池,底层 []byte 不复用 | 扩容结果整块进池,稳态复用底层数组 | 复用语义增强 |
改造前(每消息):
flowchart TB
A["Send(msg)"] --> B["codec.Marshal(message)<br/>① 优化点A 全新[]byte ≈L"]
B --> C["bytes.NewBuffer(raw)<br/>② 优化点B 小分配 ≈40B"]
C --> D["envelope{Data: buffer}"]
D --> E["w.Write(envelope)<br/>io.Copy 一次写拷贝"]
classDef bad fill:#ffcdd2,stroke:#b71c1c;
class B,C bad;
改造后(稳态:池命中 + cap 足够):
flowchart TB
A2["Send(msg)"] --> B2["bufferPool.Get: *bytes.Buffer"]
B2 --> C2["MarshalAppend 追加进池 buffer 底层数组<br/>0 alloc"]
C2 --> D2{"cap 不足?"}
D2 -- 否 --> E2["envelope{Data: buffer}"]
D2 -- 是(首轮) --> F2["扩容一次, 结果进池<br/>amortized 趋 0"]
F2 --> E2
E2 --> G2["w.Write(envelope)<br/>压缩判断用 Len, 同步消费后归还池"]
classDef good fill:#c8e6c9,stroke:#1b5e20;
class B2,C2,D2 good;
-
改造前:
Marshal(新分配 []byte)→bytes.NewBuffer(包装)→Write(io.Copy一次写拷贝)→ 归还包装对象(底层 []byte 丢弃) -
改造后:
MarshalAppend(追加进池 buffer,0 alloc)→Write(io.Copy一次写拷贝,次数不变)→ 消费完归还池;省的是每消息分配 + 清零
调用序列形态不变,仅"分配+包装"收敛为"复用池 buffer",且 MarshalAppend 与 Marshal 输出字节一致(同一编码器),wire 不变。
// codec.go:可选接口 + proto 实现(对齐 connect-go codec.go:108-114)
type marshalAppender interface {
MarshalAppend(dst []byte, message any) ([]byte, error)
}
func (c *protoBinaryCodec) MarshalAppend(dst []byte, message any) ([]byte, error) {
protoMessage, ok := message.(proto.Message)
if !ok {
return nil, errNotProto(message)
}
return proto.MarshalOptions{}.MarshalAppend(dst, protoMessage)
}// envelope.go:envelopeWriter.Marshal 增加 appender 分支(语义对齐 connect-go envelope.go:181-204)
func (w *envelopeWriter) marshalAppend(message any, appender marshalAppender) *Error {
buffer := w.bufferPool.Get()
defer w.bufferPool.Put(buffer)
raw, err := appender.MarshalAppend(buffer.Bytes(), message)
if err != nil {
return errorf(CodeInternal, "marshal message: %w", err)
}
if cap(raw) > buffer.Cap() { // 扩容发生:新数组整块换入池
*buffer = *bytes.NewBuffer(raw)
} else { // 未扩容:仅修正长度,0 分配
buffer.Write(raw)
}
envelope := &envelope{Data: buffer}
return w.Write(envelope)
}tripleUnaryMarshaler.Marshal 同款改造(未压缩分支把 buffer.Bytes() 交给 m.write,压缩分支保持 *bytes.Buffer,判断顺序与阈值不变)。
方案收益(协议层 marshal 净收益)是优化基点,生产集成后受以下要素影响:
- 端到端摊薄(最主要):协议内 A/B 为定向 marshal bench(见章节四)净收益;端到端叠加框架栈/网络/服务端调用链耗时,B/op 收益保留、延迟收益被摊薄(本方案主打分配而非延迟);
- gzip 压缩开关(指标口径):用户开启压缩时,压缩路径自身临时分配会掩盖 B/op 账面收益;分配与延迟收益不受影响;
- 业务负载与并发饱和(收敛):真实调用带业务耗时,marshal 层占比被业务耗时摊薄;纯序列化场景(业务≈0)才接近定向档位;
- GC 与长跑(双向):生产长跑下 bufferPool 复用使 B/op 收益稳定;GC 清池后首轮重建属池化常态,amortized 趋 0;
- 大报文带宽天花板(收敛):1MiB 段受服务端带宽限制,延迟收益收敛,但内存峰值/GC 压力下降是确定收益(本方案定位为 allocation hotspot,非 latency bottleneck);
- 非 proto codec 占比(口径):hessian2/msgpack/json/自定义 codec 走旧路径无收益,收益面覆盖 proto(Triple 主力序列化)。
| 指标 | 预估区间 | 预估依据 |
|---|---|---|
| B/op(1MiB 档) | −10% ~ −25% | 消除每消息 1 次 O(L) 分配 + 包装 |
| B/op(16KiB 档) | −2% ~ −5% | 分配税占比(~12.9%)低于 1MiB 档(~16.2%),放大系数更小 |
| B/op(≤1KiB) | −1% ~ −4% | 分配税占总 B/op 不足 5%,可能被 http2/TLS/context 固定开销淹没 |
| allocs/op | −2 ~ −6 | 端到端被 ~160 固定 alloc 淹没; |
| 延迟(小包) | 端到端不可测(被淹没) | 受固定开销摊薄 |
| 内存峰值 / GC 压力 | 确定下降 | 每请求少一块 L 大小短期垃圾,allocation hotspot 维度的主要收益 |
-
marshalAppender只有protoBinaryCodec实现。hessian2 / msgpack / json / 自定义 codec 不实现,type assert 失败自动走旧Marshal路径。 - Triple 服务端响应被
tripleServerCodecSession包裹,恒不命中快路径,走慢路径。 - 收益面 = Triple 客户端发送 + gRPC 双端(
envelopeWriter同时服务 gRPC wire)。 - 快路径
MarshalAppend失败时仍走backupCodec回退链,语义与慢路径等价。 - 快路径
marshalAppender仅protoBinaryCodec实现,hessian2/msgpack/json/generic wrapper 走旧路径,收益面只覆盖 proto;
-
connect-go 上游
marshalAppender/marshalAppend实现(本方案对标,v1.20.0):codec.go#L56-L66、codec.go#L108-L114、envelope.go#L150-L153、envelope.go#L181-L204 -
google.golang.org/protobuf:proto/encode.go(
MarshalAppend追加语义、cap 门控在MarshalOptions层)、internal/impl/encode.go(纯 append 编码、sizecache;行号随版本漂移,不锁定) 生产落地PR 503 https://github.com/connectrpc/connect-go/pull/503 - grpc-go(官方 v1.64.1):shared_buffer_pool.go#L23-L100(
SharedBufferPool分级池 16B/256B/4KB/64KB/1MB,仅用于收包解析,见文件头部注释)、encoding/gzip/gzip.go#L38-L100(poolCompressor/poolDecompressor压缩器对象池)、internal/transport/http_util.go#L392-L434(writeBufferPoolMap帧写缓冲池);注意 dubbogo fork v1.42.10 为单级池基线,分级池属 v1.63+ 新增,引用时按版本区分
永久链接 · 2026-09-05T03:47:01Z
这个 PR 的服务端非 IDL 路径仍然是:
inner.Marshal → 独立 payload
→ 编码 TripleResponseWrapper,复制 payload
客户端 protoWrapperCodec.Marshal(原文作者本地路径:/C:/Users/m1346/AppData/Local/Temp/dubbo-go-pr3718-review/protocol/triple/triple_protocol/codec.go:267) 甚至还没有接入 appender。
可以分两步:
- 先给客户端 wrapper 增加保持原语义的
MarshalAppend,复用外层输出。 - 再让 Hessian/MsgPack 编码器支持写入调用方缓冲区,直接构建 protobuf wrapper 的字段。
第二步更激进:protobuf 的 bytes 字段需要长度前缀,必须解决长度预计算、预留回填或分段输出;否则只是把复制挪了位置。Java 互通业务较多时,这条线值得单独优化。
永久链接 · 2026-09-05T11:56:12Z
谢谢详细的阅评!那我接下来的方向:
- 提issue 与 pr 先给客户端 protoWrapperCodec 加保持原语义的 MarshalAppend(复用外层输出),这项相对低成本、wire 不变、收益就是消掉 wrapper 层的新分配
- 先提proposal 内容尝试让 hessian/msgpack 编码器支持直接写进调用方缓冲区、构建 wrapper 的字段,解决"长度预计算 / 预留回填 / 分段输出",不能只是把复制挪一个位置而是消除
- 将方向更新至plan
永久链接 · 2026-09-05T03:50:48Z
1. 使用生成的专用编码器,降低编码 CPU
分配消掉之后,复杂消息的字段遍历、长度计算和编码本身可能成为主要开销。可以通过可选能力探测接入 SizeVT/MarshalToSizedBufferVT,继续使用池化目标缓冲区,普通消息保留现有 protobuf 路径。vtprotobuf 文档
这更适合字段多、嵌套深的消息。PR 基准中的单个大字符串主要体现内存分配和复制,不能代表复杂业务对象的编码成本。也不能直接假设更换编码器后逐字节输出完全一致。
2. 更大的架构方向:带生命周期管理的分段缓冲区
让 codec 返回类似:
[协议头] [wrapper 元数据] [payload]
各层传递缓冲区引用,消费完成后统一释放,避免为了满足连续 []byte 接口而反复拼接。gRPC-Go 的 CodecV2 已采用 mem.BufferSlice,明确由框架释放编码结果,可借鉴其所有权设计。[CodecV2 源码](https://github.com/grpc/grpc-go/blob/master/encoding/encoding_v2.go)
但要获得收益,后续请求体、压缩和传输接口都得支持分段消费;中间一旦重新合并成连续内存,复制又回来了。这适合独立的架构改造。
我的实施顺序会是:先做请求体所有权移交,再做 envelope 头部预留;随后根据真实流量选择非 IDL 融合或专用编码器。 前两项不需要改变 wire format,收益来源也最明确。
验证时应使用真实 HTTP/2 请求,比较吞吐、P99、CPU 和峰值内存,并加入混合消息大小及不同可压缩性。当前 io.Discard 微基准看不到请求体复制,也看不到缓冲区移交后持有时间变长的影响。
永久链接 · 2026-09-05T03:51:07Z
这两个不用做 我只是放在这里 可以当一个参考
永久链接 · 2026-09-05T12:01:40Z
明白,谢谢说明! 这两个方向(生成式专用编码器、分段缓冲区)我先作为后续参考记下,不纳入当前 PR 与近2天工作 的范围。
若实施的实施顺序应当为关于先做请求体所有权移交、再做 envelope 头部预留(都不改 wire format、收益最明确),之后根据真实流量再决定走非 IDL 融合还是专用编码器。并用真实 HTTP/2 请求验证多项指标,当前指标无法覆盖
永久链接 · 2026-09-17T14:23:53Z
一、摘要
二、瓶颈代码定位
三、多工具交叉验证瓶颈
四、方案设计与演进
五、方案验证与实施
六、预估收益与方案边界
七、测试构造情况说明
八、参考链接
Triple 是 Dubbo-go 的默认协议层,其流式客户端(CallBidiStream / CallClientStream)支持"一条流上连续发送多条消息"的用法。这类用法的共性是:消息条数多、连发节奏快,单条消息则不一定大。
而当前实现里,每次 Send 都直接把消息写进 duplexHTTPCall 底层的 io.Pipe:它是无缓冲的跨 goroutine 同步交接,写端必须等读端消费完才返回,因此每发一条消息就要做两次同步握手(prefix 与 body 各一次)。消息越多,握手次数越多,per-message 代价与消息条数成正比,在高吞吐连发时成为隐藏的固定开销,构成小报文 × 高吞吐流式场景下的潜在瓶颈。
本文针对 Triple 流式客户端发送路径的结构性开销,验证瓶颈的确存在,确认每条消息两次无缓冲同步交接、并落成两次独立 socket 写是主因;在此基础上提出一个默认关闭的可选批缓冲 streamBufferWriter(32 KiB 水位)方案:把连续小消息聚合、按水位 / CloseRequest 冲刷,摊薄交接次数与 syscall 次数。方案以 WithWriteBuffering() option 显式开启,默认关闭。
前两期的优化都是从相较 gRPC 的基准差距出发反推瓶颈,本期路径不同:triple unary 落地后,unary 侧的固定开销已被吸收,剩余可优化范围收敛到流式客户端,而流式路径并没有现成的数据可供入手分析。因此本期先从静态源码入手、再设计测试验证瓶颈是否真实存在。
CallBidiStream(client.go L186-L195)是"一条流上连续 Send N 条消息"的流式用法。顺着源码往下读四个环节,就能看清一次 Send 到底做了什么。
第一,连接装配:grpcClient.NewConn 建流式连接时,streamWriter 直接就是 duplexHTTPCall,协议层与管道之间没有任何缓冲层(默认配置下,protocol_grpc.go L299-L325):
call = newDuplexHTTPCall(ctx, g.HTTPClient, g.URL, spec, header)
streamWriter = call // 协议层的 writer 就是 duplexHTTPCall
...
conn := &grpcClientConn{
call: call,
marshaler: grpcMarshaler{
envelopeWriter: envelopeWriter{
writer: streamWriter, // envelope 的每次 Write 都直接下沉
...
},
},
}第二,Send 入口:Send 只做一件事,把消息交给 marshaler(protocol_grpc.go L370-L375):
func (cc *grpcClientConn) Send(msg any) error {
if err := cc.marshaler.Marshal(msg); err != nil {
return err
}
return nil
}第三,协议层组帧:marshaler 把消息编好之后,一条消息要写两次,先写 5 字节 prefix(flag + 消息长度),再用 io.Copy 写 body(envelope.go L147-L161):
func (w *envelopeWriter) write(env *envelope) *Error {
prefix := [5]byte{}
prefix[0] = env.Flags
binary.BigEndian.PutUint32(prefix[1:5], uint32(env.Data.Len()))
if _, err := w.writer.Write(prefix[:]); err != nil { // 第 1 次:prefix
...
}
if _, err := io.Copy(w.writer, env.Data); err != nil { // 第 2 次:body
...
}
return nil
}第四,下沉写:这两次 Write 落到 duplexHTTPCall,每次都是直写 io.PipeWriter,中间没有任何攒批逻辑(duplex_http_call.go L108-L126):
func (d *duplexHTTPCall) Write(data []byte) (int, error) {
d.ensureRequestMade() // 首次调用时触发后台 makeRequest goroutine
...
bytesWritten, err := d.requestBodyWriter.Write(data) // 直写 io.PipeWriter
...
}总结来看,四个环节各有开销,且都是每消息的开销。
| 环节 | 代码行为 | 每消息开销 |
|---|---|---|
Send(msg) |
grpcClientConn.Send → grpcMarshaler.Marshal
|
1 次 marshal |
| 协议层组帧 |
envelopeWriter.write 写 prefix + body |
2 次下沉 Write |
duplexHTTPCall.Write |
每次直写 io.PipeWriter
|
2 次跨 goroutine 同步交接 |
| 传输层落 socket | 每个 DATA 帧立即 flush(3.3 实测) | 2 次 write(2) |
初步结论:这段调用链里没有任何聚合点,一个 Send 的固定成本结构是明确的,1 次 marshal、2 次下沉写、2 次同步交接、2 次 socket 写,而后三项只与消息条数有关,与消息内容、报文大小都无关。所以可以大致判断:在小报文 × 高吞吐流式这类条数多、单条小、连发快的场景里,每条消息都要摊一次这段固定开销,写路径可能存在瓶颈。这个判断来自静态读码,是否真的构成瓶颈、开销落在哪一层,还需要在真实链路上实测确认。
把 2.1 的四段代码摊平成一条流水线(每一步都对应上面引用的源码)。一次 Send 会拆成 prefix 与 body 两次 Write,两次都是串行的:① 一路执行(含落 socket)之后才轮到 ②,而这两条路径的形状完全一样。wrCh / rdCh 是 io.Pipe 内部的两个 channel:
flowchart LR
S["Send(msg)<br/>marshal 得到 body"] --> A["① prefix<br/>5 B"]
A --> A1["duplexHTTPCall.Write<br/>直写 io.PipeWriter,写端阻塞"]
A1 --> A2["writeRequestBody 取走这一段<br/>立即 Flush"]
A2 --> A3["socket write(2)"]
A3 --> B["② body<br/>N B<br/>(① 落完 socket 才轮到)"]
B --> B1["duplexHTTPCall.Write<br/>直写 io.PipeWriter,写端阻塞"]
B1 --> B2["writeRequestBody 取走这一段<br/>立即 Flush"]
B2 --> B3["socket write(2)"]
图上可以看出:一条消息要走两次完整的管道交接、落成两次 socket 写,而且这两次是串行的,没有任何合并的机会。把这条流水线拆开,一次 Send 会在 ① ② 两条路径上各完整走一遍下面六步(下面以默认的 gRPC wire 为例,它有 5 字节 prefix;Triple 编码没有 prefix,只走 ② 那一条):
-
序列化:
Send调grpcMarshaler.Marshal把消息编成envelope(body),拿到待写的字节,此刻还没有碰到管道; -
组帧:
envelopeWriter.write用同一个writer发起第一次Write,写 5 字节 prefix(flag + 大端长度); -
下沉到调用对象:这次
Write落到duplexHTTPCall.Write,它先ensureRequestMade()拉起后台makeRequestgoroutine(首次调用时),校验 ctx 未取消,再用d.requestBodyWriter写数据,而这个requestBodyWriter就是io.PipeWriter; -
写端 park(第一个阻塞点):
io.Pipe不做缓冲,写端把这段切片交给读端后,立刻等读端回传已取走的字节数,写端在这两处 channel 操作上阻塞,直到读端出现; -
读端取走并落 socket:读端不是业务代码,是
net/http从http.Request.Body读数据的writeRequestBody。它把这段拷进自己的缓冲区、回传字节数,再按 HTTP/2 帧编码,每个 DATA 帧立即Flush,最终经net.Conn.Write落成write(2); -
写端返回,发起第二次
Write:第 4 步返回之后duplexHTTPCall.Write才返回,envelopeWriter.write随即用io.Copy写 body,把第 3 到第 5 步整个重做一遍。
- 写端 park 的那段时间是记在调度器头上的:pprof 采样会落在
runtime.selectgo而不是io.(*pipe).write; - 传输层对每个 DATA 帧立即 flush,所以一次管道交接最终对应一次
write(2),实测约 2.10 次/消息。
io.Pipe 的写入不是把数据拷进缓冲区就返回,而是与读端做一次同步交接。标准库的实现只有两个 channel:wrCh(传递数据切片)与 rdCh(回传已取走的字节数),写之前还要取 wrMu 把同一个管道上的 Write 串行化(pipe.go L76-L96(原文作者本地路径:file:///home/lizining/go-sdk/go/src/io/pipe.go#L76-L96)):
func (p *pipe) write(b []byte) (n int, err error) {
select {
case <-p.done:
return 0, p.writeCloseError()
default:
p.wrMu.Lock() // 串行化同一管道上的写
defer p.wrMu.Unlock()
}
for once := true; once || len(b) > 0; once = false {
select {
case p.wrCh <- b: // ① 把这段数据交给读端,写端在此阻塞
nw := <-p.rdCh // ② 等读端回传已取走的字节数,写端再次阻塞
b = b[nw:]
n += nw
case <-p.done:
return n, p.writeCloseError()
}
}
return n, nil
}读端与之对应:从 wrCh 取值、copy 进自己的缓冲区、再把实际取走的字节数发到 rdCh(pipe.go L50-L65(原文作者本地路径:file:///home/lizining/go-sdk/go/src/io/pipe.go#L50-L65))。由此有两点结论:
- 一次
Write至少要两次跨 goroutine 唤醒(读端一次、写端一次),读写两端交替推进,写端必须等读端把这段取走才能返回; - 交接以切片为单位:读端缓冲区一次装不下时,写端会在循环里把剩余部分再交接一次(
b = b[nw:])。真实链路的读端是 http2 的writeRequestBody,它用约 16 KiB 的 scratch buffer 读取,因此 5 字节 prefix 与 128 B body 都是整段一次交接完。
这条链路上的读端不是业务代码,而是 net/http:newDuplexHTTPCall 把 io.Pipe 的读端直接挂在 http.Request.Body 上(duplex_http_call.go L76-L94),由 h2c 传输层按需读取、交给 HTTP/2 帧编码器。所以每次 Send 都要等传输层把这一段读走才能返回,同步交接次数随消息条数线性增长。
读源码只能说明代码长这样,还不能说明它一定构成瓶颈。把 2.1、2.2 的代码结论翻译成能在真实链路上检验的预期瓶颈,就是下面内容,测试也是参考它们设计的。
-
管道不做合并:
io.Pipe的职责只是把这一段数据交给读端,它不攒批,所以写一次就是交接一次。这一条已由 2.2 的源码坐实; -
每条消息写两次 socket:协议层把一条消息拆成 prefix 与 body 两次
Write,传输层对每个 DATA 帧又立即 flush,因此write(2)的次数应当跟着消息条数走,而不是跟着字节数走。如果写路径中间存在攒批,写次数就会与消息条数脱钩、远少于消息条数;
客户端以单流连发 N 条消息 → CloseRequest → 收全 N 条响应为一个操作单元计一次 QPS;表中的消息吞吐 = QPS × 每流连发条数,由两者换算得到,衡量单位时间内真正处理的消息条数。
矩阵:5 种报文(128 B / 256 B / 512 B / 1 KiB / 4 KiB)× 3 档并发(32 / 64 / 128)× 2 档连发(128 / 512)= 30 组数据,覆盖多种参数区间。
128 bytes
| Concurrency | QPS (流/s) | 消息吞吐 (msgs/s) | P99 (ms) |
|---|---|---|---|
| 32 | 200.6 | 25 677 | 222.59 |
| 64 | 185.8 | 23 782 | 511.83 |
| 128 | 165.7 | 21 210 | 1125.09 |
256 bytes
| Concurrency | QPS (流/s) | 消息吞吐 (msgs/s) | P99 (ms) |
|---|---|---|---|
| 32 | 186.8 | 23 910 | 259.65 |
| 64 | 143.8 | 18 406 | 716.16 |
| 128 | 139.8 | 17 894 | 1382.00 |
512 bytes
| Concurrency | QPS (流/s) | 消息吞吐 (msgs/s) | P99 (ms) |
|---|---|---|---|
| 32 | 138.9 | 17 779 | 324.66 |
| 64 | 127.6 | 16 333 | 804.96 |
| 128 | 124.0 | 15 872 | 1598.27 |
1024 bytes
| Concurrency | QPS (流/s) | 消息吞吐 (msgs/s) | P99 (ms) |
|---|---|---|---|
| 32 | 102.9 | 13 171 | 433.95 |
| 64 | 99.8 | 12 774 | 937.74 |
| 128 | 94.5 | 12 096 | 1911.53 |
4 KiB
| Concurrency | QPS (流/s) | 消息吞吐 (msgs/s) | P99 (ms) |
|---|---|---|---|
| 32 | 63.3 | 8 102 | 660.56 |
| 64 | 58.6 | 7 501 | 1383.48 |
| 128 | 57.9 | 7 411 | 2858.83 |
128 bytes
| Concurrency | QPS (流/s) | 消息吞吐 (msgs/s) | P99 (ms) |
|---|---|---|---|
| 32 | 46.3 | 23 711 | 954.48 |
| 64 | 44.1 | 22 564 | 1949.47 |
| 128 | 41.9 | 21 432 | 4150.53 |
256 bytes
| Concurrency | QPS (流/s) | 消息吞吐 (msgs/s) | P99 (ms) |
|---|---|---|---|
| 32 | 41.7 | 21 345 | 1066.70 |
| 64 | 39.2 | 20 076 | 2249.60 |
| 128 | 38.6 | 19 758 | 4523.50 |
512 bytes
| Concurrency | QPS (流/s) | 消息吞吐 (msgs/s) | P99 (ms) |
|---|---|---|---|
| 32 | 35.4 | 18 125 | 1213.60 |
| 64 | 35.1 | 17 971 | 2488.90 |
| 128 | 34.1 | 17 459 | 5158.20 |
1024 bytes
| Concurrency | QPS (流/s) | 消息吞吐 (msgs/s) | P99 (ms) |
|---|---|---|---|
| 32 | 28.0 | 14 336 | 1447.00 |
| 64 | 23.8 | 12 186 | 3341.80 |
| 128 | 25.8 | 13 210 | 6054.30 |
4 KiB
| Concurrency | QPS (流/s) | 消息吞吐 (msgs/s) | P99 (ms) |
|---|---|---|---|
| 32 | 16.4 | 8 417 | 2286.95 |
| 64 | 18.4 | 9 441 | 4275.64 |
| 128 | 15.3 | 7 859 | 9969.09 |
特征一:并发提高 → QPS 不增反降(系统近饱和)
以 128 B / 连发 128 为例,并发从 32 提到 128(4×),QPS 反而从 200.6 降到 165.7,消息吞吐从 25 677 降到 21 210 msgs/s;4 KiB / 连发 512 同样如此(16.4 → 15.3 流/s)。新增并发没有带来等比收益,反而因为每条流都在做同样的同步交接、争抢同一份写路径资源,单流效率随之下降,这说明现状已经处在某种饱和状态。
特征二:消息吞吐随报文增大单调下降
并发 32 下,连发 128 条的消息吞吐从 25 677(128 B)降到 8 102(4 KiB),下降约 3.2×。但把它换算成字节吞吐,128 B 约 3.3 MB/s,4 KiB 约 33 MB/s,反而涨了约 10 倍。若瓶颈是搬运字节的带宽,字节吞吐应当先封顶,而它随报文增大一路上升,说明真正被放大的是每条消息摊到的固定成本:小消息每字节分摊的固定开销高、大消息分摊低,于是消息吞吐随报文增大单调下降。
特征三:增加连发条数 → 消息吞吐不再增长
把每流连发条数从 128 提到 512(4×),各组合的消息吞吐基本持平:128 B / c32 从 25 677 微降到 23 711 msgs/s(-7.7 %),512 B / c64 从 16 333 升到 17 971 msgs/s(+10.0 %),4 KiB / c64 从 7 501 升到 9 441 msgs/s。多连发 4 倍的消息,单位时间能处理的消息数并没有变多,吞吐上限由每条消息的固定开销决定,与流内连发多少条无关。
延迟侧的表现更直接:128 B / c32 下单流 P99 从 223 ms 涨到 954 ms(4.3×),与连发条数 4× 几乎等比。若写路径存在聚合,512 条消息的写次数应被摊薄到远小于 4×,单流耗时不会线性放大。
这三个特征互相印证现状写路径存在的瓶颈:每条消息都要走两次独立的同步交接,吞吐上限由固定开销决定而不是由并发或连发条数决定。下文用 CPU profile 与 strace 计数进一步确认这笔开销的来源。
客户端 stream_ab 以 128 B 报文、每流连发 128 条、并发 32 持续压测(未开启任何写缓冲),同时通过 net/http/pprof 抓取客户端进程的 CPU profile:
解读:
-
写路径链路:
http2.(*clientStream).writeRequestBodycum 20.31 %,其下bufio.(*Writer).Flushcum 14.50 % →http2.writeWithByteTimeoutcum 13.69 % →net.(*conn).Writecum 13.29 %,最终落到internal/runtime/syscall.Syscall6(该函数为全进程 flat 23.27 %,同时承担读侧 syscall)。整条栈就是把一段数据写进 socket 这一件事,中间没有任何应用层逻辑; -
并列的宽条是
internal/runtime/syscall.Syscall6(flat 23.27 %)与runtime.futex(flat 17.76 %),合计 41.03 %:前者是进内核写 socket,后者是 goroutine 的唤醒与调度,火焰图右侧runtime.mcall → park_m → schedule → findRunnable → stealWork这条栈清晰可见; -
确实存在管道交接:
io.(*pipe).read → runtime.selectgo这条栈对应传输层从管道取数据的那一侧,说明写路径确实是写端写管道 → 读端取走 → 落 socket 一段一段推进的; -
编解码很小不是主要成本:收侧
grpcUnmarshaler.Unmarshalcum 20.31 %,写侧envelopeWriter.Marshal的占比远小于它下面的 syscall 部分。
但是这里不能只看 CPU profile,CPU profile 只统计 on-CPU 时间,io.Pipe 交接里的等待属于 off-CPU,火焰图上天然看不到。
block profile

占比最高的三个帧都不是业务帧,而是三个等待:runtime.selectgo 62.90 %、runtime.chanrecv1 21.57 %、sync.(*Cond).Wait 15.30 %,三者合计 99.77 %;而写路径自身的帧 io.(*pipe).write 只有 0.51 %(8.24 s),它的读端 io.(*pipe).read 只有 0.06 %(0.98 s)。它排除了别的优化方向,
- 如果这里测出的是计算占满(编解码、拷贝在吃时间),方案应偏向于优化 codec、减少拷贝;
- 实测是等待占满(99.77 %),说明这条路径的耗时形态是等待形态而不是计算形态,不是在编解码上算不过来,而是一条消息一次往返地等过来。方案方向因此定为只能改交接结构,把次数降下来。 指令
# 轮 1:采 CPU
stream-ab -payload 128 -msgs 128 -concurrency 32 -warmup 10s -duration 120s -pprof 127.0.0.1:6060
curl -s -o cpu.prof "http://127.0.0.1:6060/debug/pprof/profile?seconds=20"
# 轮 2:同参数单独一轮采 block
stream-ab -payload 128 -msgs 128 -concurrency 32 -warmup 10s -duration 120s \
-pprof 127.0.0.1:6060 -blockrate 1
curl -s -o block_t0.prof "http://127.0.0.1:6060/debug/pprof/block"
sleep 20
curl -s -o block_t1.prof "http://127.0.0.1:6060/debug/pprof/block"
# 差分出与 CPU 同窗口的 block profile
go tool pprof -proto -base block_t0.prof -output block_window.prof block_t1.prof
go tool pprof -http=:8081 -sample_index=delay block_window.prof采集命令(真实链路,单并发以便用成功流数 × 每流条数反推出确定的消息总数):
strace -c -f -e trace=write -o strace.txt \
stream-ab -payload 128 -msgs 128 -concurrency 1 -duration 5s -warmup 0s其中
-f不可省略:Go 会把 goroutine 调度到多个线程上,不跟随线程会漏掉大部分写
采集结果:客户端报告 Success = 54 条流,即共发送 6912 条 128 B 消息;strace 统计 write 调用 14516 次:
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
100.00 1.694243 116 14516 write
折算:2.10 次 write(2) / 消息(14516 ÷ 6912)。这个数字与源码是相符的:
| 环节 | 代码依据 | 每条消息代价 |
|---|---|---|
| 协议层组帧 |
envelopeWriter.write 先写 5 字节 prefix(flag + 长度),再用 io.Copy 写 body(envelope.go L147-L161) |
2 次 Write 进管道 |
| 传输层落 socket | x/net/http2 writeRequestBody 对每个 DATA 帧都立即 cc.bw.Flush()(注释原文 “TODO(bradfitz): this flush is for latency, not bandwidth”),每次 Write 对应一次真实 write(2)(golang.org/x/net@v0.58.0/http2/transport.go L1566-L1576) |
2 次 write(2) |
实测 2.10 略高于 2,多出的约 0.1 次/消息来自 HEADERS、WINDOW_UPDATE 等控制帧(54 条流合计 692 次)。
解读:
-
次数:
write调用数与消息条数成正比(2.10 次/条),而不是 1/256 次/条。若写路径存在 32 KiB 级别的聚合,128 B 消息要攒 256 条才满一个水位,同样的消息量只需约 1/256 的write(2),差两个数量级,说明每条消息各自触发两次独立写,没有任何合并; -
耗时需谨慎推理:
usecs/call = 116 µs是在 ptrace 跟踪下测得的,每次 syscall 进入 / 退出都要与 tracer 交互,绝对值被明显放大。它只能用来说明每条消息都要完整走一遍 syscall 路径,不能当作真实业务耗时。
- 端到端压测给出外部表现:并发提高不能提高吞吐(特征一)、报文越大吞吐越低(特征二)、连发条数涨 4 倍吞吐也不涨(特征三),三条都指向吞吐上限由每条消息的固定开销决定;
-
CPU profile体现了时间去向:
http2.(*clientStream).writeRequestBodycum 20.31 %;internal/runtime/syscall.Syscall6flat 23.27 % 与runtime.futexflat 17.76 % 合计 41.03 %,代价集中在进内核写 socket与goroutine 唤醒调度,而不是应用逻辑; -
strace 计数给出次数:2.10 次
write(2)/消息,与 prefix + body 两次Write、每个 DATA 帧立即 flush 的代码路径逐层对应,与 32 KiB 聚合应有的量级差两个数量级; -
block profile 给出阻塞构成:同一个 20 s 窗口的差分口径下,流式调用的阻塞时间 99.77 % 落在三个等待原语上(
runtime.selectgo62.90 %、runtime.chanrecv121.57 %、sync.(*Cond).Wait15.30 %),几乎没有 on-CPU 的计算;而一次Write对应读端一段、管道不做合并这一交接语义直接来自标准库源码(pipe.go L76-L96(原文作者本地路径:file:///home/lizining/go-sdk/go/src/io/pipe.go#L76-L96)),不需要额外的合成路径实验来佐证。该占比会因写端先 park 还是读端先 park 在不同窗口之间互补翻转(实测io.(*pipe).write0.51 % ~ 5.89 %),故仅作定性参考,不作为瓶颈的定量证据。
把这四者串起来,瓶颈是结构性的:流式客户端写路径上,每条消息都要做两次无缓冲的跨 goroutine 同步交接,并因此落成两次独立的 socket 写,且没有任何聚合机制来摊薄它。这也决定了后续方案的形状,在不改变 on-wire 语义的前提下,把多次小写聚合成一次大写。
瓶颈锁定在流式客户端每次 Send 的 io.Pipe 同步握手(per-message 代价 ∝ 消息数),无任何聚合。
-
默认零影响:
WithWriteBuffering()默认关闭,未开启时writeBuffer=nil、marshaler.writer=call,与现状逐字节等价; -
语义不变:On-wire 帧格式不变(默认 gRPC 编码仍是 flag + 长度的 5 字节 prefix,Triple 编码仍是裸 body),只是把多次小
Write合并成一次大Write;不改公开接口签名、不改Codec接口、无 on-wire 格式变更。需要说清的是帧格式不变不等于到达时序不变:批边界会改变对端开始消费这条流的数据的时刻(小流甚至要等到CloseRequest才发出第一个字节),这是本方案主动付出的代价之一; -
不丢消息:
CloseRequest必须刷尽剩余缓冲,尾包完整送达(并发契约守护); - opt-in 哲学对齐行业:gRPC-Go / Kitex 均为可关闭的写缓冲开关。
StreamingClientConn 接口契约要求 Send / RequestHeader / CloseRequest 可互相并发,因此聚合层的关闭语义必须与关闭动作在同一临界区内封口。如果把 CloseRequest 写成先 Flush() 再 CloseWrite()(两次独立加锁),就会出现静默丢消息窗口:一个 Send 已被接收、数据还留在缓冲里,紧接着 CloseWrite 关闭管道 → 消息凭空消失;而在无缓冲的现状下,那个并发 Send 会拿到 io.ErrClosedPipe 报错。聚合层不能把报错变成静默丢失。
设计约束由此确定:Close() 在同一临界区内完成刷尽 + 置 closed,封口之后的 Write 返回 io.ErrClosedPipe(错误类型与现状逐字一致);CloseRequest 无论 flush 成败都执行 CloseWrite,错误取第一个非 nil。
对应的守护手段:
-
不变量断言:到达底层的字节数 == 被接收的
Write数 × 消息长度(TestStreamBufferWriterCloseNeverDropsRacingWrite,200 轮竞争); -
反向验证:临时去掉封口(注释掉
w.closed = true),该守护测试在 attempt 0 立即失败,证明断言真的生效而不是恒真。
在把落点收敛到流式客户端每 Send 两次同步交接之前,先排查了两个相邻候选:unary 的 prefix 与 body 分离写入已被上一期的 unaryFastPathCall 吸收成整块 body,没有剩余价值;net.Buffers / writev 合并 prefix 与 body 这条路的排除理由有两点,都不是"Triple 编码没有 prefix":其一,插入点的下游是 duplexHTTPCall,它把字节直写 io.PipeWriter,而 io.PipeWriter 既不是 *net.TCPConn 也不实现 io.ReaderFrom,于是 writev 退化为两次顺序 Write,交接次数一次未省,还要额外付出一次 O(payload) 的拼接拷贝;其二,真正的 socket 写发生在 x/net/http2 内部,http2 本身就用帧缓冲把 prefix 与 body 拼进同一帧一并写出。至于 Triple 编码没有 prefix,那属于范围说明:这条路对它连"要合并的两段"都不存在,但它不是排除 gRPC 编码下该候选的理由。两者均不作为方案,落点收敛到流式客户端。在此基础上,对缓冲载体与触发策略做选型:
| 候选 | 说明 | 结论 |
|---|---|---|
直接用内部 bytes.Buffer(构造时预分配 cap 32 KiB) |
单一流生命周期内反复复用,无归还协议 | 采用(最小、无泄漏风险) |
复用 bufferPool.Get() 但不归还 |
等同普通分配,Pool 无意义 | 放弃 |
| 下沉 unary fast path 攒到 CloseWrite 才发 | 流式若攒到关闭才发,对端在流关闭前收不到数据,破坏流式语义 | 放弃;改为按水位渐进 flush |
net.Buffers/writev 合并 prefix+body |
插入点下游是 io.PipeWriter(不是 TCPConn),writev 退化为两次顺序 Write,交接次数一次未省;且 http2 已在帧内完成 prefix 与 body 的合并 |
放弃 |
| 文件 | 改动 |
|---|---|
buffered_writer.go |
新增 streamBufferWriter(水位 flush + Close() 原子封口) |
option.go |
新增 WithWriteBuffering() ClientOption(默认关) |
protocol.go / client.go
|
透传 WriteBuffering
|
protocol_grpc.go / protocol_triple.go
|
流式分支装配批缓冲 + CloseRequest 封口刷尽且必定 CloseWrite
|
| 测试 / 基准 |
buffered_writer_test.go、buffered_write_bench_test.go
|
| 压测工具 |
tools/benchmark/stream_ab(流式压测客户端)、tools/benchmark/server/dubbo-go
|
聚合层插在协议层与 io.Pipe 之间,它接管的就不只是把字节送出去这一件事,还有原路径上由 io.Pipe 隐式兜住的一批边界。本节按边界类型归成五类,只列现有测试覆盖不到的场景,每一条回答三个问题:边界条件是什么、覆盖不到会发生什么、正确行为应该是什么。
体例说明:已经写出测试的场景不在此列,包括聚合语义、水位触发、超大消息直写、封口后返回
io.ErrClosedPipe、Close幂等、空缓冲 flush 不下沉、Send与CloseRequest竞态不丢包、Flush与Write并发、两条 wire 的装配与 unary fast path 不缓冲、端到端尾包不丢,这些由buffered_writer_test.go的 10 个测试守住。本节共 13 条缺口,逐条的测试规划见 5.3。
流是一次性资源,写失败之后这条流就废了,所以错误路径的核心要求只有两条:失败必须可见(不能吞成 nil),失败之后必须可复现(不能时好时坏)。现有测试只覆盖了「Write 撞上粘滞错误」这一种组合,其余出口都还是缺口。
-
首错冻结之后的另外两个出口(buffered_writer.go L101-L102(原文作者本地路径:
file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/buffered_writer.go#L101-L102)、L121-L122(原文作者本地路径:file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/buffered_writer.go#L121-L122)):TestStreamBufferWriterWriteAfterError只断言了后续Write返回粘滞错误,没有断言Flush与Close也返回同一个错误。覆盖不到的话,「首错即冻结」只算半句话,Close仍可能把已经失败的流当成功收尾。正确行为是err != nil时Write/Flush/Close三个出口一致返回同一个错误。待补:TestStreamBufferWriterStickyErrorOnEveryExit -
水位 flush 失败,但本次字节已入缓冲(L88-L92(原文作者本地路径:
file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/buffered_writer.go#L88-L92)):返回(len(p), nil)会让调用者以为这条消息发出去了、而且流还健康。正确行为是返回(len(p), w.err),字节数照实报(已被接受),错误照实报(后续不可用)。待补:TestStreamBufferWriterWriteReportsFlushFailure -
超标消息直写失败(L81-L87(原文作者本地路径:
file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/buffered_writer.go#L81-L87)):吞掉错误返回成功,调用者会以为这条大消息已经出去了。正确行为是透传底层的(n, err),不做任何包装或改写。待补:TestStreamBufferWriterLargeMessageErrorPropagates -
Close时最后一次 flush 失败(L117-L130(原文作者本地路径:file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/buffered_writer.go#L117-L130)):把失败吞成 nil,调用者会认为尾包已送达。正确行为是返回该错误,同时仍置closed,不重复尝试。待补:TestStreamBufferWriterCloseFlushFailureReturnsError -
CloseRequest中 flush 失败(protocol_grpc.go L381-L397(原文作者本地路径:file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/protocol_grpc.go#L381-L397)):这里有两种坏法,只返回错误而跳过CloseWrite,会让对端读完请求后一直等写端关闭直到超时;两个错误都非 nil 时返回后一个,则掩盖了先发生的那次失败。正确行为是无论 flush 成败都执行CloseWrite,两个错误取第一个非 nil(flush 优先)。待补:TestCloseRequestClosesWriteSideAfterFlushFailure
StreamingClientConn 的接口契约要求 Send / RequestHeader / CloseRequest 三者可互相并发(triple.go L125-L126(原文作者本地路径:file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/triple.go#L125-L126)),这是 4.3 已经展开过的核心约束,此处只列还没有覆盖的两组组合。
-
Close与Flush并发(L97-L130(原文作者本地路径:file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/buffered_writer.go#L97-L130)):同一批数据会被发两次(Flush在Close的尾刷之后又刷一次,或反之)。现有TestStreamBufferWriterConcurrent只并发跑了Write与Flush,没有Close参与的组合。正确行为是closed判定在锁内,谁先拿到锁谁做,后到的直接返回。待补:TestStreamBufferWriterConcurrentCloseAndFlush -
并发写下的缓冲上界(L88-L92(原文作者本地路径:
file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/buffered_writer.go#L88-L92)):多个Send并发时,缓冲是否仍以limit为界、不会无界增长。现有并发测试只断言了字节总量守恒,没有断言途中任意时刻buf.Len() < limit。待补:TestStreamBufferWriterConcurrentBufferBound
聚合层最大的风险不是丢数据,而是改动次序或者少送一段字节:这两类问题在下游看来都像偶发的奇怪故障,最难查。
-
缓冲里已有小消息,紧接着来一条 ≥ 32 KiB 的大消息(L81-L87(原文作者本地路径:
file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/buffered_writer.go#L81-L87)):大消息直写会插队,小消息还留在缓冲里,大消息先落了 wire,同一条流上的消息次序被打乱,接收端解出的序列与发送端不一致。现有TestStreamBufferWriterLargeMessageWritesDirectly只测了直写次数与字节数,没有「先小后大」的次序断言。正确行为是直写之前必须先把缓冲刷出去,保序责任留在聚合层内部,不要求调用者按大小排序。待补:TestStreamBufferWriterLargeMessageFlushesPendingFirst -
下游短写未被处理(L136(原文作者本地路径:
file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/buffered_writer.go#L136-L136)):flushLocked只判err,不看n < len(p);若下游返回「写了部分字节且 err == nil」,紧接着的buf.Reset()会把没写出去的部分丢掉,调用者完全无感。正确行为是核对n == buf.Len(),短写按错误处理并冻结。待补:TestStreamBufferWriterShortWriteIsFailure
CloseRequest 可能被上层在不同路径上重复触发(正常收尾、错误清理、defer),所以第二次调用必须明确是无事发生而不是又来一次。现有测试覆盖了「Close 连续两次」这一组,异常状态下的重复调用还没有覆盖。
-
Close失败之后再次Close(L121-L123(原文作者本地路径:file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/buffered_writer.go#L121-L123)):返回 nil 会让调用者误以为这次关闭成功了。正确行为是在closed判定之前先判err,返回粘滞错误。待补:TestStreamBufferWriterCloseAfterCloseFailure -
Close之后再Flush(L104-L106(原文作者本地路径:file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/buffered_writer.go#L104-L106)):报io.ErrClosedPipe会把一次正常收尾判成错误。正确行为是在关闭时 flush 成功的前提下返回 nil,数据已刷尽,无事可做,也不该报错;若 flush 已经失败,则返回粘滞错误而不是 nil。待补:TestStreamBufferWriterFlushAfterClose
这一类的边界不在聚合层内部,而在它有没有被正确接上去、以及接上去之后默认路径有没有漂移。现有装配测试覆盖了 streaming 与 unary fast path 两格,剩下两格是缺口。
-
unary 但未走 fast path(unary fast path 的编排条件 protocol_grpc.go L286(原文作者本地路径:
file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/protocol_grpc.go#L286-L307) 不成立):也会被套上缓冲。unary 只有一条消息,聚合最多省 1 次交接(Triple 编码只有一次 body 写;gRPC wire 才有 prefix 与 body 两次写),代价是一次额外拷贝加上首包推迟到CloseRequest。现有测试覆盖的是 unary + fast path(不缓冲)与 streaming(缓冲)两格,unary + 非 fast path 这一格没有断言。待补:TestWriteBufferingCoversUnaryNonFastPath -
空流:一次
Send都没发生就CloseRequest:请求根本没发出去,对端永远收不到请求,调用挂死。正确行为是duplexHTTPCall.CloseWrite内先ensureRequestMade()兜底发请求(duplex_http_call.go L130-L134(原文作者本地路径:file:///home/lizining/projects/dubbo-go/protocol/triple/triple_protocol/duplex_http_call.go#L130-L134)),全仓测试对该函数 0 处引用。待补:TestWriteBufferingEmptyStreamStillSendsRequest
从上面可得以下设计约束:
-
关闭必须在同一临界区内刷尽并封口:
Close()在持锁状态下完成刷尽 + 置 closed,于是存在一个可断言的不变量:到达底层的字节数 == 被接收的Write数 × 消息长度。把关闭拆成两段(先 Flush 再 CloseWrite)会让这个不变量失效,而且失效方式是静默的,这正是它必须原子化的原因; - 直写之前先刷缓冲:保序是聚合层自己的责任。把大消息直写当成一条捷径时,很容易忽略捷径不能插队,所以判断顺序必须是先处理缓冲、再处理直写;
- 首次错误即冻结状态:流是一次性资源,写失败后任何再试一次都没有语义,只会把故障暴露时间推迟。冻结的另一个好处是错误可复现:第一次失败之后的每一次调用都返回同一个错误;
-
错误类型与错误来源逐字对齐:聚合层加进来之后,上层不应该观察到任何新形态的错误。所以封口后写沿用
io.ErrClosedPipe,管道真正关闭时仍由duplexHTTPCall.Write统一转成io.EOF,聚合层不新增、不改写任何错误; -
空操作不下沉:空缓冲 flush 直接返回,既不在
io.Pipe上白跑一次交接,也不给 http2 制造空帧的机会。这条同时让Flush的重复调用天然幂等; -
兜底责任不转移给聚合层:
CloseWrite里的ensureRequestMade()继续负责零消息流也要把请求发出去,聚合层不接管这个职责,也就不可能破坏它。
这些约束现有测试已守住了大部分,还需要补下面 13 个单元测试:
| 要补的测试 | 功能 |
|---|---|
TestStreamBufferWriterStickyErrorOnEveryExit |
首错冻结之后 Write / Flush / Close 三个出口返回同一个错误 |
TestStreamBufferWriterWriteReportsFlushFailure |
水位 flush 失败但本次字节已入缓冲,返回 (len(p), err) 而不是 nil |
TestStreamBufferWriterLargeMessageErrorPropagates |
大消息直写失败时,上层拿到的是同一个错误,而不是 nil |
TestStreamBufferWriterCloseFlushFailureReturnsError |
Close 尾刷失败时返回该错误,并置 closed、不重复尝试 |
TestCloseRequestClosesWriteSideAfterFlushFailure |
flush 失败时 CloseWrite 仍被调用,对端能读到流结束 |
TestStreamBufferWriterConcurrentCloseAndFlush |
Close 与 Flush 并发时同一批数据不会被发两次 |
TestStreamBufferWriterConcurrentBufferBound |
并发 Send 下缓冲以 limit 为界,不会无界增长 |
TestStreamBufferWriterLargeMessageFlushesPendingFirst |
大消息直写之前先把缓冲里的小消息刷出去,不插队 |
TestStreamBufferWriterShortWriteIsFailure |
下游短写(n < len(p) 且 err == nil)按失败处理并冻结,不静默丢字节 |
TestStreamBufferWriterCloseAfterCloseFailure |
flush 失败之后再次 Close 返回同一个粘滞错误 |
TestStreamBufferWriterFlushAfterClose |
Close 成功之后再 Flush 返回 nil,不报 io.ErrClosedPipe
|
TestWriteBufferingCoversUnaryNonFastPath |
unary 未走 fast path 时同样被套上缓冲 |
TestWriteBufferingEmptyStreamStillSendsRequest |
一条消息都不发就直接 CloseRequest,服务端仍能收到这次请求 |
单元与装配之外,还需要验证聚合层接进真实调用链之后,服务端能收到全部字节:
| 测试 | 覆盖 |
|---|---|
单元(buffered_writer_test.go) |
聚合、阈值触发、超大消息直写、空 flush 空操作、错误粘滞、并发安全、Write after Close → io.ErrClosedPipe、Close 幂等 |
| 并发契约守护 |
TestStreamBufferWriterCloseNeverDropsRacingWrite(不变量 + 反向验证) |
| 端到端 |
TestWriteBufferingStreamingFlushOnClose:CloseRequest 刷尽、不丢尾包 |
| benchmark |
buffered_write_bench_test.go(机制层证据,ns/op / allocs) |
说明:新增代码只在显式开启
WithWriteBuffering()之后生效;默认路径下writeBuffer=nil,装配给协议层的 writer 仍是duplexHTTPCall,不经过任何批缓冲逻辑。
- 场景特异性(重要):收益集中在小报文 × 高吞吐流式,单流连发大量小消息、消息大小远小于缓冲水位。大报文(≥ 32 KiB 单条)直接直写底层,无收益也无损失;
- 首包延迟:缓冲会攒着不发,牺牲首包 latency 换吞吐,这是 opt-in 的原因;
-
多 goroutine 并发写同一流:聚合之后每次写都要先取锁,
w.mu会成为新的争用点,同一个流被多个 goroutine 并发写时需要自行权衡; - 端到端摊薄:协议内收益端到端叠加框架栈/网络/服务端耗时后收敛;
-
默认行为零变化:未开启时
writeBuffer=nil,与现状逐字节等价;不适用于 unary / 大报文 / 低首包延迟场景。
这是一个针对小报文 × 高吞吐流式场景的 opt-in 吞吐优化,用首包延迟、一次拷贝与一把锁,换掉每条消息两次同步交接与两次 syscall,把它们摊薄到水位级别;默认关闭,只对主动开启该场景的用户生效。它不改变默认行为,也不适用于所有场景。
- 适用:流式客户端(gRPC 编码默认路径 + Triple 编码)、单流连发大量小消息、高吞吐优先;
- 不适用 / 不生效:unary、unary fast path、服务端路径、大报文(直写)、低首包延迟敏感场景;
- 默认:关闭,未开启用户零感知。
收益确实与测试构造相关:收益 = 聚合摊薄的交接次数,与消息条数 N 直接相关;与单消息大小通过 32 KiB 水位间接相关(消息越大,凑满水位的条数越少,摊薄倍数越小;单条 ≥ 32 KiB 时退化为直写)。因此测试矩阵做的是全量取值扫描,而不是只挑对优化最有利的参数组合。
| 来源 | 数值 | 含义 |
|---|---|---|
| OpenTelemetry BatchSpanProcessor | max_export_batch_size = 512 |
每批攒 512 条再导出 |
| 遥测 span 真实大小 | 200~500 B(估算常取 500 B~1 KB) | 单条消息量级 |
gRPC-Go / Kitex WriteBufferSize
|
默认 32 KB | 水位一致 |
Kafka producer batch.size
|
默认 16384(16 KB) | 跨领域同类水位 |
- gRPC-Go
WithWriteBufferSize(默认 32KB、Zero 关闭):https://pkg.go.dev/google.golang.org/grpc - gRPC C++ Performance Notes:Streaming write buffering(
set_buffer_hintopt-in、GRPC_ARG_HTTP2_WRITE_BUFFER_SIZE):http://grpc.github.io/grpc/cpp/md_doc_cpp_perf_notes.html - gRPC-Go 官方博客 gRPC-Go performance Improvements("flush syscall for every data frame"):https://grpc.io/blog/grpc-go-perf-improvements/
- Kitex
WithGRPCWriteBufferSize(默认 32KB):https://www.cloudwego.io/docs/kitex/tutorials/options/client_options/ - tRPC-Go tnet websocket 的 Combined Writes Optimization(小消息合并单 syscall):https://pkg.go.dev/trpc.group/trpc-go/tnet/extensions/websocket
- Kafka Producer Configs(
batch.size/linger.ms):https://kafka.apache.org/41/configuration/producer-configs/ - OpenTelemetry BatchSpanProcessor 默认值:https://opentelemetry.io/docs/zero-code/dotnet/configuration/
永久链接 · 2026-09-18T02:57:18Z
cool
永久链接 · 2026-09-20T02:31:03Z
我发现了一个陈年文档 或许可以做个参考https://github.com/apache/dubbo-go/wiki/Benchmark-test-of-dubbo