lizining1231 opened a new issue, #3717: URL: https://github.com/apache/dubbo-go/issues/3717
### 摘要 当前 Triple unary 发送热路径每次请求都要承受**与业务无关的分配税**:`codec.Marshal` 新分配一块 ≈L 的输出切片(优化点 A,1MiB 消息约 1,048,576 B + 全程清零 ≈53µs)、再 `bytes.NewBuffer` 包一层 ≈40B 逃逸包装(优化点 B)、再全量拷进传输缓冲(多一次写拷贝)。 该分配税随消息大小线性增长。定向 marshal bench 实测,每消息:128B ≈216B、16KiB ≈18.5KB、1MiB ≈1,056,870B。端到端视角下小报文段占比不足 5%(被 http2/TLS/context 等固定开销淹没),大报文段(≥16KiB)占比 **13%\~16%**,是端到端 B/op 中最大的单一项。 | 消息 | marshal 层 B/op | 端到端 B/op | marshal 层占比 | | ----- | -------------- | -------- | ----------- | | 128B | ≈216 | ≈26.6KB | **<1%** | | 1KiB | ≈1223 | ≈32.1KB | \~3.8% | | 16KiB | ≈18505 | ≈143.5KB | **\~12.9%** | | 1MiB | ≈1056870 | ≈6.53MB | **\~16.2%** | `-memprofile -memprofilerate=1 -benchtime=3000x`(固定 3000 次),按事件采样: <img width="1919" height="952" alt="Image" src="https://github.com/user-attachments/assets/fad7f1a8-ad67-47a0-b06d-48d049e9a360" /> 分配几乎 100% 集中在`proto.MarshalOptions.marshal`调用链 ### 相关代码 | 位置 | 问题 | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | [codec.go L111-117](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_protocol/codec.go#L111-L117)(`protoBinaryCodec.Marshal`) | `proto.Marshal` 恒以 `nil` 起步,输出切片只能新分配(大小 ≈L,按 size class),并全程清零(memclr) | | [envelope.go L69-95](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_protocol/envelope.go#L69-L95)(`envelopeWriter.Marshal`) | `codec.Marshal` 新分配后,再 `bytes.NewBuffer(raw)` 包一层 ≈40B 逃逸小分配(优化点 B) | | [protocol\_triple.go L504-531](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_protocol/protocol_triple.go#L504-L531)(`tripleUnaryMarshaler.Marshal`) | 与 `envelopeWriter.Marshal` 结构相同(Triple wire 的 unary 生产入口),同样每次新分配 | | [envelope.go L125-139](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_protocol/envelope.go#L125-L139)(`envelopeWriter.write`) | `io.Copy` 把 Data 全量一次写拷贝进传输 writer(双方共有成本,且收益较低,本方案不作消除) | gRPC-Go 的 `encoding.Codec.Marshal` 同样每次 `proto.Marshal` 新分配 ≈L + 清零,但**输出** **`[]byte`** **直接交 transport 引用,无包装**;写拷贝双方等价(各一次传输写拷贝),非差异项。上游 connect-go 已通过 `marshalAppend` 把结果追加进池 buffer,**0 分配 0 清零**。优化点 A 与 gRPC-Go 共有,优化点 B 是 dubbo-go 相对 gRPC-Go 的**额外**开销,两者都已被上游 connect-go 消除。 ### 建议优化 为 `Codec` 增加可选接口 `marshalAppender`(type assert,非侵入,第三方 codec 编译运行零影响),`envelopeWriter.Marshal` / `tripleUnaryMarshaler.Marshal` 命中时从既有 `bufferPool` 取 `*bytes.Buffer` 借底层数组做追加目标,未命中(hessian2/msgpack/json/自定义 codec)自动回退旧路径,行为零变化。以下是一些实现注意的要点: **1. codec 层:可选接口 + proto 实现** ```go // marshalAppender is an extension to Codec for serializing into a caller-provided buffer. 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) } ``` protobuf-go 的 `MarshalAppend` 直接把调用方 `b` 传给编码器,cap 判定在 lib 层 `MarshalOptions` 门控(生成代码提供 `Size` 与 append 编码函数、`sizecache` 缓存 size),`cap(b)-len(b) >= size` 时跳过 `make+copy` 走纯 append,**零分配**。 **2. 发送路径:从池 buffer 借数组做追加目标** ```go 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() { // 扩容发生:新数组整块换入池,amortized 趋 0 *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`,判断顺序与阈值不变)。 **3. 归还buffer** `write()` 内 `io.Copy` 同步消费完才 `defer Put`,buffer 不可能在被读时归还;扩容结果整块进池,稳态复用底层数组,无二次分配。 **4. 天然回退(零配置零开关)** `marshalAppender` 是可选接口,任何不实现它的 codec 自动走 `Marshal` 旧路径,无需配置、无需发布灰度开关即可整体回退。 **改造前(每消息)**: ```mermaid flowchart TD A["codec.Marshal(message)"] --> B["① 优化点A 全新 []byte ≈L + 全程清零"] B --> C["② 优化点B bytes.NewBuffer 包装 ≈40B 逃逸"] C --> D["envelope{Data: buffer}"] D --> E["w.Write → io.Copy 一次写拷贝"] style B fill:#ffe0b2,color:#e65100 style C fill:#ffe0b2,color:#e65100 ``` **改造后(稳态:池命中 + cap 足够)**: ```mermaid flowchart TD A2["bufferPool.Get: *bytes.Buffer"] --> B2["MarshalAppend 追加进池 buffer 底层数组<br/>0 alloc"] B2 --> C2{"cap 不足?"} C2 -- 否 --> D2["buffer.Write 仅修正长度<br/>0 alloc"] C2 -- 是(首轮) --> E2["扩容一次, 结果整块进池<br/>amortized 趋 0"] D2 --> F2["w.Write 同步消费后归还池"] E2 --> F2 style B2 fill:#c8e6c9,color:#1b5e20 style D2 fill:#c8e6c9,color:#1b5e20 ``` > 图例:橙色块 = 消除的每消息分配,绿色块 = 改造后的复用路径。`bufferPool`、HTTP/2 transport 复用现有实现。 **方案可能引入的风险(防御性设计)**: 1. **pool 不变量(Get 即空、从 0 写入)**:快路径正确性依赖"`bufferPool.Get()` 每次返回 Reset 过的 buffer、`MarshalAppend` 以 `buffer.Bytes()`(len 0)为起点"。该不变量未被语言层强制;一旦未来有人改用未 Reset 的池、或在 MarshalAppend 前改动 buffer 长度,`cap(raw) > buffer.Cap()` 分支与 `buffer.Write(raw)` 的"修正长度"逻辑会静默错乱,输出错误字节而不报错; 2. **buffer 归还时机(use-after-return)**:buffer 在 `marshalAppend` 内 `defer Put`,依赖"`w.Write` 内 `io.Copy` 同步消费完、返回前归还"的同步语义。若未来改动在 Write 后仍持有 buffer 引用、或改异步写入,会出现归还后仍被读取的竞争与跨请求污染; ### 测试 根据已知风险和方案,计划新增以下单元测试 1. **wire 字节对拍**:防止快路径报文与服务端/其他客户端不兼容、协议漂移。验证同消息快/慢路径写给 `io.Writer` 的 socket 输出(含 5 字节 prefix)逐字节一致;矩阵覆盖 `空/1B/511B/512B(边界)/513B/1KiB/8MiB±1` × `压缩关闭/开启(含 compressMinBytes 边界)` × `triple 与 gRPC 双 wire`; 2. **池不变量与边界**:防止 Get 非空起点导致 `cap(raw) > buffer.Cap()` 判断错乱。验证 Get 后 `Len()==0`;`nil` 消息走 `write(nil)` 分支、空 proto 输出零长度信封、恰 512B 与恰 `compressMinBytes` 边界无 panic; 3. **backupCodec 回退等价**:防止快/慢路径回退语义漂移。验证主 codec 失败→回退 backup 输出正确;主==backup 不二次回退、无死循环、错误码 `CodeInternal`;backup 为 nil 直接返回错误;快慢路径各一组; 4. **压缩阈值与 sendMaxBytes 一致**:防止快慢路径对同一输入的错误码/副作用不一致。验证超限均返回 `CodeResourceExhausted`,压缩头 `tripleUnaryHeaderCompression` 均被设置; 5. **>8MiB 丢弃与残留**:防止大缓冲滞留池中带出上一请求残留数据。验证 8MiB+1 消息后该缓冲不被复用;大消息后紧接 1B 消息输出不受池内残留影响; 6. **并发安全(`-race`)**:防止多 goroutine 共享 `envelopeWriter`/`bufferPool` 时数据竞争或同一 buffer 双归还。验证 `-race` 下并发 `Marshal` 无竞争、输出各自正确; 7. **类型守卫**:防止未来误给包装 codec 加 `MarshalAppend` 造成 wire 不一致。验证 wrapper/hessian2/msgpack/json/`tripleServerCodecSession` 不实现 `marshalAppender`,锁定作用域边界。 8. **快路径 codec 错误守卫**:防止快路径吞错/panic。验证 `MarshalAppend` 收到非 `proto.Message` 返回 `errNotProto`,快路径(envelopeWriter/tripleUnaryMarshaler)转为 `CodeInternal` 且不 panic,与慢路径 `Marshal` 行为一致。 ### 复现方式 定向 marshal bench(协议内 A/B): ```bash cd protocol/triple/triple_protocol go test -run '^$' -bench 'BenchmarkUnaryMarshaler(Fast|Slow)Path|BenchmarkEnvelopeWriter(Fast|Slow)Path' \ -benchmem -benchtime=1s -count=5 ``` 内存分配 profile(固定迭代次数,per-op 可比): ```bash go test -run '^$' -bench 'BenchmarkUnaryMarshaler(Fast|Slow)Path' \ -benchmem -benchtime=3000x -memprofile mem_fast.prof -memprofilerate=1 go tool pprof -sample_index=alloc_space mem_fast.prof ``` 端到端测试: ```bash cd protocol/triple/triple_protocol go test -run '^$' -bench 'BenchmarkUnaryFastPathProduction' -benchmem -benchtime=1s -count=3 ``` ### 参考实现 上游 connect-go v1.20.0 已同构落地 `bufferPool + marshalAppender + envelopeWriter.marshalAppend`([codec.go#L56-L66](https://github.com/connectrpc/connect-go/blob/v1.20.0/codec.go#L56-L66)、[codec.go#L108-L114](https://github.com/connectrpc/connect-go/blob/v1.20.0/codec.go#L108-L114)、[envelope.go#L150-L153](https://github.com/connectrpc/connect-go/blob/v1.20.0/envelope.go#L150-L153)、[envelope.go#L181-L204](https://github.com/connectrpc/connect-go/blob/v1.20.0/envelope.go#L181-L204))。该机制由上游 [PR #503(Reduce marshaling allocations with MarshalAppend)]https://github.com/connectrpc/connect-go/pull/503。本方案直接对齐,并做以下**本地化适配**: - `marshalAppender` 接口:上游内嵌 `Codec`,本地未内嵌(功能等价,建议对齐以保持逐行一致); - `envelopeWriter.Marshal` 的 appender 分支:本地保留 dubbo 特有的 `backupCodec` fallback(上游无 backupCodec),只对齐 `marshalAppend` 方法体,分支结构保持本地; - `tripleUnaryMarshaler.Marshal`(protocol\_triple.go):上游无此类型(unary 走 envelopeWriter),改造本地实现; - `protoJSONCodec.MarshalAppend`:上游已实现(JSON 也走零分配),本地未加(可选:加上则 JSON 场景同样受益,不加行为不变)。 ### 参考链接 1. 性能瓶颈定位报告请见:https://github.com/apache/dubbo-go/discussions/3673 2. connect-go 参照请见: - 实现落地:https://github.com/connectrpc/connect-go/pull/503 - `marshalAppender` / `marshalAppend` 上游实现:[codec.go#L56-L66](https://github.com/connectrpc/connect-go/blob/v1.20.0/codec.go#L56-L66)、[codec.go#L108-L114](https://github.com/connectrpc/connect-go/blob/v1.20.0/codec.go#L108-L114)、[envelope.go#L150-L153](https://github.com/connectrpc/connect-go/blob/v1.20.0/envelope.go#L150-L153)、[envelope.go#L181-L204](https://github.com/connectrpc/connect-go/blob/v1.20.0/envelope.go#L181-L204)(v1.20.0) 3. [google.golang.org/protobuf](https://github.com/protocolbuffers/protobuf-go) `MarshalAppend` 语义:[proto/encode.go](https://github.com/protocolbuffers/protobuf-go/blob/master/proto/encode.go)(`MarshalAppend` 追加语义、cap 门控在 `MarshalOptions` 层)、[internal/impl/encode.go](https://github.com/protocolbuffers/protobuf-go/blob/master/internal/impl/encode.go) -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
