Skip to content

Discussion 3673

alanxtl edited this page Sep 20, 2026 · 1 revision

[OSPP 2026] Triple 协议性能分析与优化

讨论目录 · GitHub 原讨论

  • 编号:#3673
  • 分类:General
  • 状态:打开 / 无已采纳答案
  • 创建:2026-08-17T10:38:17Z
  • 更新:2026-09-20T02:31:03Z
  • 作者:lizining1231

社区原始记录;其中的建议、未决提案和版本描述需结合 版本与升级 核对。快照日期:2026-09-20。

阅读说明:原文引用作者本地磁盘文件的链接已转为文字路径,避免产生无效 Wiki 导航;原始内容完整保存在 sources/discussions.json。

讨论正文

Dubbo-Go Triple 协议性能分析与优化

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。

Step 1: 基线建立

Step 2: 瓶颈定位与优化设计

(从已定位的低风险固定开销开始验证,再根据正式 profiling 数据决定是否深入 Envelope、Buffer 或 Codec 热路径)

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文档形式发在评论区,原始数据以链接形式放在文档里。

评论与回复

评论 1 · lizining1231

永久链接 · 2026-08-18T20:48:48Z

这里是一份初步的性能基准测试报告:

Dubbo-Go grpc协议 基准测试报告

目录

  • 一、测试环境
  • 二、测试配置
  • 三、测试数据
  • 四、服务端内存分配(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

三、测试数据

1. 128 bytes 报文

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 延迟 (ms)

并发数 dubbo-go grpc dubbo-java
50 19.56 3.58 6.74
100 33.29 5.54 12.23

2. 1024 bytes 报文

QPS(每秒请求数)

并发数 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

P99 延迟 (ms)

并发数 dubbo-go grpc dubbo-java
50 21.51 4.09 7.79
100 42.96 6.80 14.07

3. 16384 bytes 报文

QPS(每秒请求数)

并发数 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

P99 延迟 (ms)

并发数 dubbo-go grpc dubbo-java
50 43.24 10.07 21.03
100 93.16 16.28 32.91

4. 1048576 bytes 报文

QPS(每秒请求数)

并发数 dubbo-go grpc dubbo-java
50 122.9 284.5 288.4
100 119.4 274.0 293.5

P99 延迟 (ms)

并发数 dubbo-go grpc dubbo-java
50 1,074.68 261.09 279.11
100 2,400.09 511.09 697.37

四、服务端内存分配(B/op、allocs/op)测试数据

1. 128 bytes 报文

B/op(每请求分配字节)

并发数 dubbo-go grpc dubbo-java
50 23,307 5,899 20,345
100 22,976 5,895 20,342

allocs/op(每请求分配次数)

并发数 dubbo-go grpc
50 151.5 79.4
100 151.5 79.3

2. 1024 bytes 报文

B/op(每请求分配字节)

并发数 dubbo-go grpc dubbo-java
50 27,659 8,847 23,160
100 27,317 8,844 23,163

allocs/op(每请求分配次数)

并发数 dubbo-go grpc
50 153.1 79.6
100 153.1 79.4

3. 16384 bytes 报文

B/op(每请求分配字节)

并发数 dubbo-go grpc dubbo-java
50 123,700 60,638 86,883
100 113,426 60,625 86,922

allocs/op(每请求分配次数)

并发数 dubbo-go grpc
50 161.2 81.7
100 164.5 81.4

4. 1048576 bytes 报文

B/op(每请求分配字节)

并发数 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

allocs/op(每请求分配次数)

并发数 dubbo-go grpc
50 407.4 119.7
100 406.9 104.9

五、特定场景测试数据摘要

1. 128B × 100 并发场景三端表现

指标 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

2. 1MiB × 50/100 并发场景三端表现

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

六、性能优化

Dubbo-Go 客户端优化

参照社区已有benchmark,为获得最佳性能,Dubbo-Go 客户端使用了以下优化配置:

配置项 说明
WithClientNoCheck() 跳过服务检查,减少不必要的开销
MaxCallRecvMsgSize: 16MB 最大接收消息大小,支持大报文测试
MaxCallSendMsgSize: 16MB 最大发送消息大小,支持大报文测试

Dubbo-Go 服务端优化

参照社区已有benchmark,服务端配置了以下优化参数:

配置项 说明
WithMaxServerRecvMsgSize("16MB") 最大接收消息大小
WithMaxServerSendMsgSize("16MB") 最大发送消息大小

七、许可证

Apache License 2.0

评论 2 · lizining1231

永久链接 · 2026-08-18T22:20:38Z

非瓶颈分析定位,先对这份报告做出一些初步解释:

1. 无法复现社区现有数据趋势:

测试显示在本机环境下dubbo-go的性能表现略弱于dubbo-java,与社区测试数据结论相违背。目前排除wsl2拖慢golang的可能性,现有数据报告显示部分场景dubbo-go与gRPC部分场景QPS比值接近1:5,在本机被放大为1:10,问题需进一步换环境/切版本分支等 尝试推进排查

2. dubbo-java无allocs/op数据:

Java 标准 API 只能统计分配字节数,没有精确的分配次数接口

3. 对benchmark工具进行了一些修复适配:

行为 说明
dubbo.protocol.name=tri 协议由 dubbo 改为 Triple(SPI 扩展名 tri)
@EnableDubbo(scanBasePackages="benchmark") 扫描包对齐 Go 端 benchmark.BenchmarkService
protobuf 生成类 (BenchmarkProto) 消息契约由手写 POJO 改为 protobuf 生成类,序列化与 Go 端一致
dubbo-rpc-triple 依赖 引入 Triple 协议实现模块

4. 与社区一致的指标与趋势:

测试与社区现有测试数据绝对值与指标比例均不符合,但是:

①测试出的饱和拐点与社区一致

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
image

6. 本次推进带来的新事项:

  • 修复benchmark保证测试成功率 #3677
  • benchmark修复与提交,使其存在测试java的能力 #3689
  • 解读性能报告,初步确定瓶颈并进一步推进瓶颈分析

7. 这份测试报告在参照社区测试报告(benchmark/README.md)的基础上扩展了哪些内容:

①增加指标allocs/op 与 B/op

②增加轮次、环境信息

8. 一些本机与社区测试都可以得出的优化方向结论:

①小包相较于大包是 Triple 短板

都出现小包下与 grpc 差距大于大包下与grpc差距 (社区测得128B 低 5.4x vs 1MiB 低 4.1x ,本机测得128B 低 9.2x vs 1MiB 低 2.3x)

②大包路径需优化

从绝对数值来看1 MiB 大包 都出现QPS断崖下跌,P99 崩塌(QPS 跌 57 倍、P99 795ms QPS 跌 38 倍、P99 2,400ms)

回复 2.1 · Alanxtl

永久链接 · 2026-08-19T01:48:04Z

和社区结果不一致这个事情在这个阶段不用纠结

回复 2.2 · lizining1231

永久链接 · 2026-08-19T05:07:19Z

和社区结果不一致这个事情在这个阶段不用纠结

好,那我接下来的方向调整为:

  • 提交benchmark的issue与PR以支持java
  • 定位分析一个明显的瓶颈并提出可能的解决方案

产出内容是相关pr和瓶颈分析报告。并对近期TODO同步调整,删除排查数据不一致问题的任务

评论 3 · lizining1231

永久链接 · 2026-08-21T22:27:02Z

这里是一份针对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%、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(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{ ... }
}

3.2 每请求 goroutine

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 关闭请求体
}

3.3 写路径的同步交接

(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.go L526-L527);envelopeWriter(5 字节 prefix + body 分离两次写)仅 gRPC 协议使用(protocol_grpc.go L206 / 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 image

gRPC-go image

解读一下,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 image

dubbo-go的syscall6展开 image

grpc-go image 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 image gRPC-go image 从阻塞的角度来看,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,开关关闭时 = 默认路径), 每请求两个执行上下文

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
Loading

每请求固定开销:① 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;
Loading

(突出色块 = 相对 duplex 的差异点:去 pipe 改池化缓冲、去后台 goroutine 改同步发送、新增 Content-Length 声明、新增锁保护归还)

该路径由 unary-fast-path 开关启用(默认关闭,开启方式:WithUnaryFastPath() option 或 protocol_config.triple.unary-fast-path: true),异常时改配置重启即可回退到上方社区路径。对比上方社区路径:消除 io.Pipe + makeRequest goroutine + responseReady 等待;新增 pooled buffer 累积 + 显式 Content-Length(O3)+ 锁保护归还。

6.1.3 组件职责与调用关系对照

组件 改造前(duplex) 改造后(fastpath) 变化
tripleUnaryClientConn 持有 *duplexHTTPCall 持有 unaryClientCall 接口 接口化解耦,改动收敛于 NewConn 一行分支
marshaler(tripleUnaryRequestMarshaler) writer = duplexHTTPCall writer = unaryFastPathCall 复用,仅 writer 实现替换
unmarshaler(tripleUnaryUnmarshaler) reader = duplexHTTPCall reader = unaryFastPathCall 复用
请求体承载 io.Pipe(读端交 http2,写端阻塞) pooled buffer 直接引用 消除管道交接
请求发起 每请求 goroutine 异步 Do CloseWrite 同步 Do 消除 goroutine + channel 等待
Content-Length 未知(流式管道) 显式声明(O3) 新增,服务端预分配
body 归还 无池化 unaryRequestBody.Close → bufferPool.Put(锁保护) 新增(#F2)
错误链 context / h2c / gRPC 提示 / RST / Unavailable 同链逐行对齐 不变
header / gzip / trailer 语义 原样 原样 不变

6.1.4 调用序列对比(同一 unary 请求)

  • 改造前:Send(写 pipe) → CloseWrite(EOF 通知) →〔后台 goroutine:读 pipe + Do + close responseReady〕→ Receive(BlockUntilResponseReady 等) → CloseResponse
  • 改造后:Send(累积 buffer) → CloseWrite(同步 Do + 直接读 buffer) → Receive(直接读 response,无等待) → CloseResponse

调用序列形态完全不变(协议层 Client 与 marshaler/unmarshaler 无需任何改动),仅每次 RPC 内部的执行上下文由 2 个收敛为 1 个。

以下为快路径核心实现(unary_fastpath.go):unary_fastpath_bench_test.go 为协议内 A/B 基准。

6.3 原型代码:unary 快路径(A/B 两版原型测试代码)

原型通过测试代码提炼社区原版与设计版 triple-unary 的关键逻辑进行 A/B 验证。两版原型共用同一服务端与报文档位(协议内 bench 走 httptest h2c 服务端、端到端走真实服务端 :20000),仅请求发送路径不同,形成 A/B 对照组。

当前原型(对照组):DubboGoClient(dubbo_client.go),完整 dubbo-go 框架栈,unary 经 duplexHTTPCall:io.Pipe 管道交接、每请求 makeRequest 后台 goroutine、responseReady 信号等待

// 构造:完整 dubbo-go 框架客户端(NewDubboGoClient)
cli, err := client.NewClient(
	client.WithClientNoCheck(),
	client.WithClientSerialization("protobuf"),
	client.WithClientParam(constant.SerializationKey, "protobuf"),
	client.WithClientParam("compression", "none"),
)
service, err := benchmark.NewTripleBenchmarkService(cli,
	client.WithURL(fmt.Sprintf("tri://%s/%s", addr, benchmark.BenchmarkServiceName)),
	client.WithSerialization("protobuf"),
	client.WithParam(constant.MaxCallRecvMsgSize, "16MB"),
	client.WithParam(constant.MaxCallSendMsgSize, "16MB"),
	client.WithParam("compression", "none"),
)

// 每次调用:内部进入 duplexHTTPCall,io.Pipe 交接、makeRequest goroutine、responseReady 等待
func (c *DubboGoClient) unaryCall(ctx context.Context) error {
	req := &benchmark.BenchmarkRequest{Payload: c.payload}
	_, err := c.client.UnaryCall(ctx, req)
	return err
}

设计优化原型(实验组):ProtoFastPathClient(proto_fastpath_client.go),raw HTTP/2 直发,无 duplexHTTPCall、无 io.Pipe、无每请求 goroutine(完整实现):

func (c *ProtoFastPathClient) Call(ctx context.Context) error {
	// O1 池化 marshal:MarshalAppend 直接写入池化 buffer
	reqBuf := c.pool.Get().(*bytes.Buffer)
	data, err := proto.MarshalOptions{}.MarshalAppend(reqBuf.Bytes()[:0],
		&benchmark.BenchmarkRequest{Payload: c.payload})
	if err != nil {
		c.put(reqBuf)
		return fmt.Errorf("marshal request: %w", err)
	}

	// O4 零分配 body:fastPathBody 直接引用 marshal 结果,不经 bytes.Reader + NopCloser
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, &fastPathBody{data: data})
	if err != nil {
		c.put(reqBuf)
		return fmt.Errorf("new request: %w", err)
	}
	req.Header.Set("Content-Type", "application/proto")
	req.ContentLength = int64(len(data)) // O3 显式 Content-Length

	resp, err := c.httpClient.Do(req) // O1 同步直发:无 io.Pipe、无后台 goroutine
	if err != nil {
		c.put(reqBuf)
		return fmt.Errorf("do request: %w", err)
	}
	if resp.StatusCode != http.StatusOK {
		resp.Body.Close()
		c.put(reqBuf)
		return fmt.Errorf("unexpected status %d", resp.StatusCode)
	}

	// O2 池化读响应:ReadFrom 入池化 buffer,对齐 tripleUnaryUnmarshaler
	respBuf := c.pool.Get().(*bytes.Buffer)
	body := io.Reader(resp.Body)
	if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") {
		gz, gzErr := gzip.NewReader(body)
		if gzErr != nil {
			resp.Body.Close()
			c.put(reqBuf)
			c.put(respBuf)
			return fmt.Errorf("new gzip reader: %w", gzErr)
		}
		defer gz.Close()
		body = gz
	}
	if _, err := respBuf.ReadFrom(body); err != nil {
		resp.Body.Close()
		c.put(reqBuf)
		c.put(respBuf)
		return fmt.Errorf("read response: %w", err)
	}
	resp.Body.Close()
	c.put(reqBuf) // unary 语义:服务端读完请求体才响应,此回收点安全

	var out benchmark.BenchmarkResponse
	if err := proto.Unmarshal(respBuf.Bytes(), &out); err != nil {
		c.put(respBuf)
		return fmt.Errorf("unmarshal response: %w", err)
	}
	c.put(respBuf)
	return nil
}

验证点:

  • 无 io.Pipe 分配,无 makeRequest goroutine;
  • MarshalAppend 单次拷贝入池化 buffer,消除 codec.Marshal + bytes.NewBuffer(raw) 二次拷贝;
  • 响应按 Content-Encoding: gzip 手动解压,协议语义与生产一致;
  • ContentLength 声明后,服务端按长度预分配 http2dataBuffer,避免动态扩容。

6.4 方案原型 A/B 实测(协议内 bench,同栈同服务端)

数据口径:go test -benchmem(httptest h2c 服务端,BenchmarkUnaryPathAB),A/B 双方为同栈 tripleUnaryClientConn(共享 marshaler/unmarshaler/bufferPool),仅发送路径不同:原版 duplexHTTPCall vs 优化原型 unaryFastPathCall;count=3 取中位数,原始数据见 data/unary-fastpath-proto/ab_path_20260822.log。QPS/P99 依赖端到端固定并发压测口径,测试原型代码不产出该指标,故本表不列。

6.4.1 128B

指标 duplex(原版) fastpath(优化原型) 变化
allocs/op 141 140 ≈0%
B/op 30.6KB 27.6KB -10% ↑

6.4.2 1024B

指标 duplex(原版) fastpath(优化原型) 变化
allocs/op 139 139 持平
B/op 34.9KB 30.5KB -13% ↑

6.4.3 16384B

指标 duplex(原版) fastpath(优化原型) 变化
allocs/op 147 145 -1%
B/op 165.8KB 150.9KB -9% ↑

6.4.4 2MiB

指标 duplex(原版) fastpath(优化原型) 变化
allocs/op 372 324 -13% ↑
B/op 13.5MB 13.0MB -4% ↑
  • 同栈下内存指标改善温和:allocs 基本持平(协议层 duplex 与 fastpath 共享 bufferPool/marshaler),B/op 小中包 -10%~-13%、大包 -4% 上下。发送路径优化的主要收益在延迟/吞吐侧,内存侧不是本轮重点。
  • 第 4 项优化 fastPathBody:零额外分配 io.ReadCloser 替代 io.NopCloser+bytes.Reader 双重包装,是使方案原型 B/op/allocs 由负转正的关键

七、预估收益

7.1 生产中影响因素(预估收敛要素)

方案原型收益(协议层发送路径净收益)是优化基点,生产集成后受以下要素影响,预估时需计入:

  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 分配,换取服务端预分配收益

八、参考链接


许可

Apache License 2.0

回复 3.1 · Alanxtl

永久链接 · 2026-08-22T12:00:02Z

分析的不错 如果可以的话可以写个博客 让雨哥发在公众号里😃

回复 3.2 · lizining1231

永久链接 · 2026-08-22T13:27:29Z

好!

回复 3.3 · Alanxtl

永久链接 · 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 也不等于两次系统调用,收益需要实测。

评论 4 · lizining1231

永久链接 · 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版本:

Dubbo-Go Triple 基准测试报告(QPS、P99指标)

基准测试报告

测试环境

  • 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 原路径,未优化基线)

128 bytes 报文

QPS(每秒请求数)

并发数 duplex
50 5,329.3
100 5,409.2

P99 延迟 (ms)

并发数 duplex
50 15.99
100 30.58

1024 bytes 报文

QPS(每秒请求数)

并发数 duplex
50 4,300.3
100 4,368.5

P99 延迟 (ms)

并发数 duplex
50 20.40
100 37.48

16384 bytes 报文

QPS(每秒请求数)

并发数 duplex
50 2,584.2
100 2,428.2

P99 延迟 (ms)

并发数 duplex
50 37.28
100 81.24

1048576 bytes (1MiB) 报文

QPS(每秒请求数)

并发数 duplex
50 101.3
100 104.3

P99 延迟 (ms)

并发数 duplex
50 1,430.17
100 3,179.88

性能优化

Dubbo-Go 客户端配置(duplex 基线)

配置项 说明
WithClientNoCheck() 跳过服务检查,减少不必要的开销
MaxCallRecvMsgSize: 16MB 最大接收消息大小,支持大报文测试
MaxCallSendMsgSize: 16MB 最大发送消息大小,支持大报文测试

Dubbo-Go 服务端配置

服务端配置了以下参数:

配置项 说明
WithMaxServerRecvMsgSize("16MB") 最大接收消息大小
WithMaxServerSendMsgSize("16MB") 最大发送消息大小

许可证

Apache License 2.0

评论 5 · lizining1231

永久链接 · 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

128 bytes 报文

QPS(每秒请求数)

并发数 duplex fastpath 提升
50 5,329.3 6,997.9 +31.3% ↑
100 5,409.2 6,024.1 +11.4% ↑

P99 延迟 (ms)

并发数 duplex fastpath 提升
50 15.99 12.67 -20.8% ↑
100 30.58 29.74 -2.7% ↑

↑ 为正收益

1024 bytes 报文

QPS(每秒请求数)

并发数 duplex fastpath 提升
50 4,300.3 5,196.4 +20.8% ↑
100 4,368.5 5,605.8 +28.3% ↑

P99 延迟 (ms)

并发数 duplex fastpath 提升
50 20.40 18.22 -10.7% ↑
100 37.48 26.95 -28.1% ↑

16384 bytes 报文

QPS(每秒请求数)

并发数 duplex fastpath 提升
50 2,584.2 2,871.6 +11.1% ↑
100 2,428.2 2,815.7 +16.0% ↑

P99 延迟 (ms)

并发数 duplex fastpath 提升
50 37.28 30.90 -17.1% ↑
100 81.24 69.07 -15.0% ↑

1048576 bytes (1MiB) 报文

QPS(每秒请求数)

并发数 duplex fastpath 提升
50 101.3 101.7 +0.4% ↑
100 104.3 114.4 +9.8% ↑

P99 延迟 (ms)

并发数 duplex fastpath 提升
50 1,430.17 1,479.12 +3.4% ↓
100 3,179.88 2,874.32 -9.6% ↑

性能优化

Dubbo-Go 客户端优化(Triple 协议路径)

配置项 说明
WithClientNoCheck() 跳过服务检查,减少不必要的开销
MaxCallRecvMsgSize: 16MB 最大接收消息大小,支持大报文测试
MaxCallSendMsgSize: 16MB 最大发送消息大小,支持大报文测试
WithUnaryFastPath() 启用 unary fast path(tripleClient.NewConn 分支,默认关闭)

Dubbo-Go 服务端优化

服务端配置了以下优化参数:

配置项 说明
WithMaxServerRecvMsgSize("16MB") 最大接收消息大小
WithMaxServerSendMsgSize("16MB") 最大发送消息大小

benchstat显著性检验 count -10

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

回复 5.1 · Alanxtl

永久链接 · 2026-08-31T11:24:41Z

分析一下负收益部分是因为什么原因

评论 6 · lizining1231

永久链接 · 2026-08-31T20:04:27Z

fastpath大报文场景下负收益原因

1MiB场景下 优化前后火焰图diff

image

1MiB场景下 优化前 memmove展开 image

1MiB场景下 优化后 memmove展开 image

采样时间相同,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 拷给网络层
}

评论 7 · lizining1231

永久链接 · 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 = 关

评论 8 · lizining1231

永久链接 · 2026-09-03T11:50:57Z

step2.2

性能瓶颈分析报告(二)Triple-unary marshal 内存分配专项

目录

一、摘要

二、数据分析

三、代码瓶颈定位

四、瓶颈验证

五、方案设计

六、方案验证

七、预估收益与方案边界

八、参考链接

一、摘要

本文针对 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进行测试

2.1 分配税与消息大小的关系(从实测分配占比看)

定向 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%

一些规律:

  1. 分配量随消息增大:codec.Marshal 的输出切片大小 ≈ 消息字节数(按 size class 取整),消息越大,单次分配越多,这是大包场景收益更明显的原因。
  2. 小报文分配税占比不足 5%:128B/1KiB 段,marshal 层 B/op 占总 B/op 的比例被 http2/TLS/context 等固定开销淹没,端到端 B/op 收益不可见,只能从定向 bench 看。
  3. 大报文分配税成为主导项:≥16KiB 段 marshal 层占端到端 B/op 的 13%~16%,是端到端 B/op 中最大的单一项,收益预估在端到端可见。

所以这次优化主要针对的是分配税,大小包都会收益,大包下会更明显,小包相对收益不会明显。

2.2 相对 gRPC 的额外开销

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 新分配)

2.3 瓶颈定位

本次要优化的点有两个:优化点 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 包装,无逃逸小分配

三、代码瓶颈定位

3.1 codec.Marshal 每次新分配(优化点 A)

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)。

3.2 包装 + 传输写拷贝(优化点 B)

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 可比、不被吞吐污染。

4.1 内存分配 profile(memprofile,按事件采样)

-memprofile -memprofilerate=1 -benchtime=3000x(固定 3000 次),优化前 1MiB unary marshal:

image

可以观察到:

  • 分配几乎 100% 集中在 proto.MarshalOptions.marshal,调用链 tripleUnaryMarshaler.Marshal → protoBinaryCodec.Marshal → proto.Marshal → MarshalOptions.marshal;
  • 3000 次固定迭代累计分配 ≈3.1GB,其中 MarshalOptions.marshal 占 99.92%;
  • 火焰图上的巨型分配热点可以印证章节 2.3 优化点 A 是发送路径上唯一的大分配来源。

4.2 alloc pprof(累计分配字节对比)

同 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,不作为热路径收益证据

4.3 CPU profile(固定迭代 30000x,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),是序列化固有成本;它在优化后"占比更大"是因为其他成本消失后成为绝对主体,不是变慢;
  • 省下的是 memclr 53µs + 部分 memmove 34µ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 + 清零"。

五、方案设计

5.1 问题定位

源码定位将分配税锁定在 envelopeWriter.Marshal / tripleUnaryMarshaler.Marshal 每次调用中的 codec.Marshal 新分配(优化点 A)与 bytes.NewBuffer(raw) 包装(优化点 B)。两条 wire(gRPC / Triple-connect)共用同一批 marshaler 类型,改动一次客户端发送与 gRPC 双端发送同时受益;

5.2 设计目标

  • 零分配:稳态(池命中 + 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 足够;

5.3 方案要点

  1. codec 层:新增可选接口 marshalAppender { MarshalAppend(dst []byte, m any) ([]byte, error) },protoBinaryCodec 实现为 proto.MarshalOptions{}.MarshalAppend(dst, protoMessage);
  2. envelopeWriter.Marshal:开头 type assert,命中则从 bufferPool.Get() 取 *bytes.Buffer,以 buffer.Bytes() 为 dst 调 MarshalAppend;若扩容(cap(raw) > buffer.Cap())把新数组整块换入 buffer 进池,否则 buffer.Write(raw) 仅修正长度(0 分配);
  3. tripleUnaryMarshaler.Marshal:同款改造,未压缩分支把 buffer.Bytes() 交给 m.write,压缩分支保持 *bytes.Buffer。

5.4 回滚与防御性设计

本方案通过对逐行改动做防御性 review 收敛出的设计约束。风险等级:中等偏低(可选接口 + 分支 + 天然回退,风险面小于 fast path 阶段的配置开关方案),以下是一些落地时需要注意的点:

  1. 天然回退(零配置零开关):marshalAppender 是可选接口,任何不实现它的 codec 自动走 Marshal 旧路径,无需配置、无需发布灰度开关即可整体回退——风险面比 fast path 阶段(需 unary-fast-path 开关)更小;
  2. pool 不变量(Get 即空、从 0 写入):快路径正确性依赖"bufferPool.Get() 每次返回 Reset 过的 buffer、MarshalAppend(buffer.Bytes()) 恒以 len 0 起步"。该不变量未被 language-level 强制,一旦未来有人改用未 Reset 的池或在 MarshalAppend 前改动 buffer 长度,cap(raw) > buffer.Cap() 分支与 buffer.Write(raw) 的"修正长度"逻辑会静默错乱,需以用例锁定(见回归测试-池不变量);
  3. wire 字节一致性:MarshalAppend 与 Marshal 由同一 protobuf 编码器输出,语义保证相同;仍加"新路径输出 == 旧路径输出"对拍测试锁定,矩阵覆盖空消息/512B 边界/8MiB±1 × 压缩开关 × 双 wire(见回归测试-wire 对拍);
  4. buffer 归还:write() 内 io.Copy 同步消费完才 defer Put,buffer 不可能在被读时归还;已有 fast path 的 transport 恰好一次归还纪律可复用。池内残留旧明文只驻留在数组、不随信封写出(信封只写 Data.Len()),不含线上泄漏,但"读写均在归还前完成"依赖同步语义,不因未来改动破坏;
  5. 快路径作用域边界(防静默失效):marshalAppender 仅 protoBinaryCodec 实现;protoWrapperCodec/hessian2Codec/msgpackCodec/protoJSONCodec/tripleServerCodecSession 均不走快路径。Triple 服务端响应(IDL 与非 IDL 均如此):wire codec 为 proto 时被包成 tripleServerCodecSession(protocol_triple.go#L187-L193),恒不命中快路径,服务端发送恒走慢路径。真实收益面 = Triple 客户端发送 + gRPC 双端,不主张整体零分配——收益面按此边界声明;
  6. gRPC/grpc-web 回归面:envelopeWriter 同时服务 gRPC wire(protocol_grpc.go),codec 为 protoBinaryCodec 时 gRPC 路径同样命中快路径。wire 字节等价得以保持,但最易被忽略的回归面是 gRPC 而非 triple,对拍用例须在双 wire 各跑一组;
  7. 单级池的内存权衡:cap(raw) > buffer.Cap() 时新数组整块换入池;maxRecycleBufferSize=8MiB 兜底丢弃,但 512B~8MiB 区间无 size 分级,一次大消息后大缓冲滞留,会被后续小消息复用,抬高连接常驻内存,属"分配换内存"的既有权衡,经考虑本次方案中不引入分级池;
  8. 快慢路径逻辑重复(防漂移):慢路径(Marshal)与快路径(MarshalAppend)各自维护 sendMaxBytes/compressMinBytes/压缩头判断,将来改动只改一半会造成行为不一致,用契约用例锁定二者对同一输入的错误码与副作用一致;
  9. backupCodec 回退链等价:快路径先在 MarshalAppend 失败、再回退 marshalWithFallback(backupCodec,...);慢路径先在 Marshal 失败。二者回退触发点不同,但应语义等价(主==backup 不二次回退、无死循环、错误码 CodeInternal、backup 为 nil 直接返回),需用例锁定;
  10. 规划内的回归测试(约 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 行为一致。

5.5 与上游 connect-go 的对齐与差异适配

与其v1.20.0对比,分"可直接对齐"与"必须本地化适配"两类:

与上游相同的点

  1. buffer_pool.go:本地与上游逐行一致(initialBufferSize=512、maxRecycleBufferSize=8MiB、Get/Put 逻辑全同),无改动;
  2. protoBinaryCodec.MarshalAppend:本地实现 proto.MarshalOptions{}.MarshalAppend(dst, protoMessage) 与上游一致;
  3. envelopeWriter.marshalAppend:pool.Get → MarshalAppend(buffer.Bytes()) → cap 比较 → 换入或 Write → Write(envelope) 方法体与上游逐行一致。

与上游不同的点(本地化适配,不可整段拉取)

  1. marshalAppender 接口:上游内嵌 Codec(type marshalAppender interface { Codec; MarshalAppend([]byte, any) ... }),本地未内嵌(功能等价,建议对齐以保持逐行一致);
  2. envelopeWriter.Marshal 的 appender 分支:本地保留 backupCodec fallback(上游无 backupCodec,慢路径直接 w.marshal());整段替换上游会丢失 dubbo 特有的 backup codec 兜底语义,故只对齐 marshalAppend 方法体,分支结构保持本地;
  3. tripleUnaryMarshaler.Marshal(protocol_triple.go):上游无此类型(unary 走 envelopeWriter),为 fast path 阶段自研类型,改造只能本地实现;
  4. protoJSONCodec.MarshalAppend:上游已实现(JSON 也走零分配),本地未加(可选:加上则 JSON 场景同样受益,不加行为不变)。

六、方案验证与实施

6.1 生产落地改动点

方案以可选接口 + 分支方式落地,共 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 不复用 扩容结果整块进池,稳态复用底层数组 复用语义增强

6.1.1 流程图

改造前(每消息):

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;
Loading

改造后(稳态:池命中 + 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;
Loading

6.1.3 调用序列对比(同一 unary 请求)

  • 改造前:Marshal(新分配 []byte)→ bytes.NewBuffer(包装)→ Write(io.Copy 一次写拷贝)→ 归还包装对象(底层 []byte 丢弃)
  • 改造后:MarshalAppend(追加进池 buffer,0 alloc)→ Write(io.Copy 一次写拷贝,次数不变)→ 消费完归还池;省的是每消息分配 + 清零

调用序列形态不变,仅"分配+包装"收敛为"复用池 buffer",且 MarshalAppend 与 Marshal 输出字节一致(同一编码器),wire 不变。

6.2 关键代码片段

// 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,判断顺序与阈值不变)。

七、预估收益与方案边界

7.1 生产中影响因素

方案收益(协议层 marshal 净收益)是优化基点,生产集成后受以下要素影响:

  1. 端到端摊薄(最主要):协议内 A/B 为定向 marshal bench(见章节四)净收益;端到端叠加框架栈/网络/服务端调用链耗时,B/op 收益保留、延迟收益被摊薄(本方案主打分配而非延迟);
  2. gzip 压缩开关(指标口径):用户开启压缩时,压缩路径自身临时分配会掩盖 B/op 账面收益;分配与延迟收益不受影响;
  3. 业务负载与并发饱和(收敛):真实调用带业务耗时,marshal 层占比被业务耗时摊薄;纯序列化场景(业务≈0)才接近定向档位;
  4. GC 与长跑(双向):生产长跑下 bufferPool 复用使 B/op 收益稳定;GC 清池后首轮重建属池化常态,amortized 趋 0;
  5. 大报文带宽天花板(收敛):1MiB 段受服务端带宽限制,延迟收益收敛,但内存峰值/GC 压力下降是确定收益(本方案定位为 allocation hotspot,非 latency bottleneck);
  6. 非 proto codec 占比(口径):hessian2/msgpack/json/自定义 codec 走旧路径无收益,收益面覆盖 proto(Triple 主力序列化)。

7.2 生产预期区间(预测)

指标 预估区间 预估依据
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 维度的主要收益

7.3 方案的边界

  1. marshalAppender 只有 protoBinaryCodec 实现。hessian2 / msgpack / json / 自定义 codec 不实现,type assert 失败自动走旧 Marshal 路径。
  2. Triple 服务端响应被 tripleServerCodecSession 包裹,恒不命中快路径,走慢路径。
  3. 收益面 = Triple 客户端发送 + gRPC 双端(envelopeWriter 同时服务 gRPC wire)。
  4. 快路径 MarshalAppend 失败时仍走 backupCodec 回退链,语义与慢路径等价。
  5. 快路径marshalAppender 仅 protoBinaryCodec 实现,hessian2/msgpack/json/generic wrapper 走旧路径,收益面只覆盖 proto;

八、参考链接


回复 8.1 · Alanxtl

永久链接 · 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 互通业务较多时,这条线值得单独优化。

回复 8.2 · lizining1231

永久链接 · 2026-09-05T11:56:12Z

谢谢详细的阅评!那我接下来的方向:

  1. 提issue 与 pr 先给客户端 protoWrapperCodec 加保持原语义的 MarshalAppend(复用外层输出),这项相对低成本、wire 不变、收益就是消掉 wrapper 层的新分配
  2. 先提proposal 内容尝试让 hessian/msgpack 编码器支持直接写进调用方缓冲区、构建 wrapper 的字段,解决"长度预计算 / 预留回填 / 分段输出",不能只是把复制挪一个位置而是消除
  3. 将方向更新至plan

评论 9 · Alanxtl

永久链接 · 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 微基准看不到请求体复制,也看不到缓冲区移交后持有时间变长的影响。

回复 9.1 · Alanxtl

永久链接 · 2026-09-05T03:51:07Z

这两个不用做 我只是放在这里 可以当一个参考

回复 9.2 · lizining1231

永久链接 · 2026-09-05T12:01:40Z

明白,谢谢说明! 这两个方向(生成式专用编码器、分段缓冲区)我先作为后续参考记下,不纳入当前 PR 与近2天工作 的范围。

若实施的实施顺序应当为关于先做请求体所有权移交、再做 envelope 头部预留(都不改 wire format、收益最明确),之后根据真实流量再决定走非 IDL 融合还是专用编码器。并用真实 HTTP/2 请求验证多项指标,当前指标无法覆盖

评论 10 · lizining1231

永久链接 · 2026-09-17T14:23:53Z

性能瓶颈分析报告(三)Triple 流式客户端写路径批缓冲专项

目录

一、摘要

二、瓶颈代码定位

三、多工具交叉验证瓶颈

四、方案设计与演进

五、方案验证与实施

六、预估收益与方案边界

七、测试构造情况说明

八、参考链接

一、摘要

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 侧的固定开销已被吸收,剩余可优化范围收敛到流式客户端,而流式路径并没有现成的数据可供入手分析。因此本期先从静态源码入手、再设计测试验证瓶颈是否真实存在。

2.1 流式 Send 的写路径没有聚合

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.2 从 Send 到 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)"]
Loading

图上可以看出:一条消息要走两次完整的管道交接、落成两次 socket 写,而且这两次是串行的,没有任何合并的机会。把这条流水线拆开,一次 Send 会在 ① ② 两条路径上各完整走一遍下面六步(下面以默认的 gRPC wire 为例,它有 5 字节 prefix;Triple 编码没有 prefix,只走 ② 那一条):

  1. 序列化:Send 调 grpcMarshaler.Marshal 把消息编成 envelope(body),拿到待写的字节,此刻还没有碰到管道;
  2. 组帧:envelopeWriter.write 用同一个 writer 发起第一次 Write,写 5 字节 prefix(flag + 大端长度);
  3. 下沉到调用对象:这次 Write 落到 duplexHTTPCall.Write,它先 ensureRequestMade() 拉起后台 makeRequest goroutine(首次调用时),校验 ctx 未取消,再用 d.requestBodyWriter 写数据,而这个 requestBodyWriter 就是 io.PipeWriter;
  4. 写端 park(第一个阻塞点):io.Pipe 不做缓冲,写端把这段切片交给读端后,立刻等读端回传已取走的字节数,写端在这两处 channel 操作上阻塞,直到读端出现;
  5. 读端取走并落 socket:读端不是业务代码,是 net/http 从 http.Request.Body 读数据的 writeRequestBody。它把这段拷进自己的缓冲区、回传字节数,再按 HTTP/2 帧编码,每个 DATA 帧立即 Flush,最终经 net.Conn.Write 落成 write(2);
  6. 写端返回,发起第二次 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.3 对瓶颈的预期

读源码只能说明代码长这样,还不能说明它一定构成瓶颈。把 2.1、2.2 的代码结论翻译成能在真实链路上检验的预期瓶颈,就是下面内容,测试也是参考它们设计的。

  1. 管道不做合并:io.Pipe 的职责只是把这一段数据交给读端,它不攒批,所以写一次就是交接一次。这一条已由 2.2 的源码坐实;
  2. 每条消息写两次 socket:协议层把一条消息拆成 prefix 与 body 两次 Write,传输层对每个 DATA 帧又立即 flush,因此 write(2) 的次数应当跟着消息条数走,而不是跟着字节数走。如果写路径中间存在攒批,写次数就会与消息条数脱钩、远少于消息条数;

三、多工具交叉验证瓶颈

3.1 端到端压测:现状吞吐与延迟表现

客户端以单流连发 N 条消息 → CloseRequest → 收全 N 条响应为一个操作单元计一次 QPS;表中的消息吞吐 = QPS × 每流连发条数,由两者换算得到,衡量单位时间内真正处理的消息条数。

矩阵:5 种报文(128 B / 256 B / 512 B / 1 KiB / 4 KiB)× 3 档并发(32 / 64 / 128)× 2 档连发(128 / 512)= 30 组数据,覆盖多种参数区间。

(1)连发 128 条 × 5 报文 × 3 并发

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

(2)连发 512 条 × 5 报文 × 3 并发

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 计数进一步确认这笔开销的来源。

3.2 客户端 CPU profile 与 block profile

客户端 stream_ab 以 128 B 报文、每流连发 128 条、并发 32 持续压测(未开启任何写缓冲),同时通过 net/http/pprof 抓取客户端进程的 CPU profile:

image

解读:

  • 写路径链路:http2.(*clientStream).writeRequestBody cum 20.31 %,其下 bufio.(*Writer).Flush cum 14.50 % → http2.writeWithByteTimeout cum 13.69 % → net.(*conn).Write cum 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.Unmarshal cum 20.31 %,写侧 envelopeWriter.Marshal 的占比远小于它下面的 syscall 部分。

但是这里不能只看 CPU profile,CPU profile 只统计 on-CPU 时间,io.Pipe 交接里的等待属于 off-CPU,火焰图上天然看不到。

block profile image

占比最高的三个帧都不是业务帧,而是三个等待: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

3.3 strace write 计数

采集命令(真实链路,单并发以便用成功流数 × 每流条数反推出确定的消息总数):

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 路径,不能当作真实业务耗时。

3.4 交叉验证小结

  • 端到端压测给出外部表现:并发提高不能提高吞吐(特征一)、报文越大吞吐越低(特征二)、连发条数涨 4 倍吞吐也不涨(特征三),三条都指向吞吐上限由每条消息的固定开销决定;
  • CPU profile体现了时间去向:http2.(*clientStream).writeRequestBody cum 20.31 %;internal/runtime/syscall.Syscall6 flat 23.27 % 与 runtime.futex flat 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.selectgo 62.90 %、runtime.chanrecv1 21.57 %、sync.(*Cond).Wait 15.30 %),几乎没有 on-CPU 的计算;而一次 Write 对应读端一段、管道不做合并这一交接语义直接来自标准库源码(pipe.go L76-L96(原文作者本地路径:file:///home/lizining/go-sdk/go/src/io/pipe.go#L76-L96)),不需要额外的合成路径实验来佐证。该占比会因写端先 park 还是读端先 park 在不同窗口之间互补翻转(实测 io.(*pipe).write 0.51 % ~ 5.89 %),故仅作定性参考,不作为瓶颈的定量证据。

把这四者串起来,瓶颈是结构性的:流式客户端写路径上,每条消息都要做两次无缓冲的跨 goroutine 同步交接,并因此落成两次独立的 socket 写,且没有任何聚合机制来摊薄它。这也决定了后续方案的形状,在不改变 on-wire 语义的前提下,把多次小写聚合成一次大写。

四、方案设计与演进

4.1 问题定位

瓶颈锁定在流式客户端每次 Send 的 io.Pipe 同步握手(per-message 代价 ∝ 消息数),无任何聚合。

4.2 设计目标

  • 默认零影响: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 均为可关闭的写缓冲开关。

4.3 并发相关的边界条件

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 立即失败,证明断言真的生效而不是恒真。

4.4 已排查候选与备选方案对比

在把落点收敛到流式客户端每 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 的合并 放弃

五、方案验证与实施

5.1 改动点清单

文件 改动
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

5.2 现有测试未覆盖的边界场景

聚合层插在协议层与 io.Pipe 之间,它接管的就不只是把字节送出去这一件事,还有原路径上由 io.Pipe 隐式兜住的一批边界。本节按边界类型归成五类,只列现有测试覆盖不到的场景,每一条回答三个问题:边界条件是什么、覆盖不到会发生什么、正确行为应该是什么。

体例说明:已经写出测试的场景不在此列,包括聚合语义、水位触发、超大消息直写、封口后返回 io.ErrClosedPipe、Close 幂等、空缓冲 flush 不下沉、Send 与 CloseRequest 竞态不丢包、Flush 与 Write 并发、两条 wire 的装配与 unary fast path 不缓冲、端到端尾包不丢,这些由 buffered_writer_test.go 的 10 个测试守住。本节共 13 条缺口,逐条的测试规划见 5.3。

5.2.1 错误路径(5 条)

流是一次性资源,写失败之后这条流就废了,所以错误路径的核心要求只有两条:失败必须可见(不能吞成 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

5.2.2 并发与竞态(2 条)

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

5.2.3 顺序与语义等价(2 条)

聚合层最大的风险不是丢数据,而是改动次序或者少送一段字节:这两类问题在下游看来都像偶发的奇怪故障,最难查。

  • 缓冲里已有小消息,紧接着来一条 ≥ 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

5.2.4 幂等与重复调用(2 条)

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

5.2.5 生命周期与装配(2 条)

这一类的边界不在聚合层内部,而在它有没有被正确接上去、以及接上去之后默认路径有没有漂移。现有装配测试覆盖了 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

5.3 设计约束与待补测试

从上面可得以下设计约束:

  1. 关闭必须在同一临界区内刷尽并封口:Close() 在持锁状态下完成刷尽 + 置 closed,于是存在一个可断言的不变量:到达底层的字节数 == 被接收的 Write 数 × 消息长度。把关闭拆成两段(先 Flush 再 CloseWrite)会让这个不变量失效,而且失效方式是静默的,这正是它必须原子化的原因;
  2. 直写之前先刷缓冲:保序是聚合层自己的责任。把大消息直写当成一条捷径时,很容易忽略捷径不能插队,所以判断顺序必须是先处理缓冲、再处理直写;
  3. 首次错误即冻结状态:流是一次性资源,写失败后任何再试一次都没有语义,只会把故障暴露时间推迟。冻结的另一个好处是错误可复现:第一次失败之后的每一次调用都返回同一个错误;
  4. 错误类型与错误来源逐字对齐:聚合层加进来之后,上层不应该观察到任何新形态的错误。所以封口后写沿用 io.ErrClosedPipe,管道真正关闭时仍由 duplexHTTPCall.Write 统一转成 io.EOF,聚合层不新增、不改写任何错误;
  5. 空操作不下沉:空缓冲 flush 直接返回,既不在 io.Pipe 上白跑一次交接,也不给 http2 制造空帧的机会。这条同时让 Flush 的重复调用天然幂等;
  6. 兜底责任不转移给聚合层: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,服务端仍能收到这次请求

5.4 端到端装配验证

单元与装配之外,还需要验证聚合层接进真实调用链之后,服务端能收到全部字节:

测试 覆盖
单元(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,不经过任何批缓冲逻辑。

六、预估收益与方案边界

6.1 生产中影响因素

  1. 场景特异性(重要):收益集中在小报文 × 高吞吐流式,单流连发大量小消息、消息大小远小于缓冲水位。大报文(≥ 32 KiB 单条)直接直写底层,无收益也无损失;
  2. 首包延迟:缓冲会攒着不发,牺牲首包 latency 换吞吐,这是 opt-in 的原因;
  3. 多 goroutine 并发写同一流:聚合之后每次写都要先取锁,w.mu 会成为新的争用点,同一个流被多个 goroutine 并发写时需要自行权衡;
  4. 端到端摊薄:协议内收益端到端叠加框架栈/网络/服务端耗时后收敛;
  5. 默认行为零变化:未开启时 writeBuffer=nil,与现状逐字节等价;不适用于 unary / 大报文 / 低首包延迟场景。

6.2 方案边界声明

这是一个针对小报文 × 高吞吐流式场景的 opt-in 吞吐优化,用首包延迟、一次拷贝与一把锁,换掉每条消息两次同步交接与两次 syscall,把它们摊薄到水位级别;默认关闭,只对主动开启该场景的用户生效。它不改变默认行为,也不适用于所有场景。

  • 适用:流式客户端(gRPC 编码默认路径 + Triple 编码)、单流连发大量小消息、高吞吐优先;
  • 不适用 / 不生效:unary、unary fast path、服务端路径、大报文(直写)、低首包延迟敏感场景;
  • 默认:关闭,未开启用户零感知。

七、测试构造情况说明

7.1 测试构造本身是收益的变量

收益确实与测试构造相关:收益 = 聚合摊薄的交接次数,与消息条数 N 直接相关;与单消息大小通过 32 KiB 水位间接相关(消息越大,凑满水位的条数越少,摊薄倍数越小;单条 ≥ 32 KiB 时退化为直写)。因此测试矩阵做的是全量取值扫描,而不是只挑对优化最有利的参数组合。

7.2 测试配置参照系

来源 数值 含义
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) 跨领域同类水位

八、参考链接

回复 10.1 · Alanxtl

永久链接 · 2026-09-18T02:57:18Z

cool

评论 11 · Alanxtl

永久链接 · 2026-09-20T02:31:03Z

我发现了一个陈年文档 或许可以做个参考https://github.com/apache/dubbo-go/wiki/Benchmark-test-of-dubbo

Clone this wiki locally