GitHub user lizining1231 added a comment to the discussion: [OSPP 2026] Triple 协议性能分析与优化
# 性能瓶颈分析报告(四)Triple Header Metadata 分配专项 ## 目录 一、全文摘要 二、测试与瓶颈 三、代码瓶颈定位 四、多工具交叉验证瓶颈 五、优化方案设计 六、参考链接 ## 一、全文摘要 一次 RPC 调用除了传递业务消息,还会携带链路追踪标识、请求编号、租户信息等附加字段。这些字段称为 metadata。Triple 客户端在发送请求前,需要把调用方提供的字段整理成请求 Header;这项处理在每次调用中都会发生。 测试发现,携带的字段越多,Header 合并产生的内存分配也越多。在四字段、128 B 小报文场景中,合并路径占客户端累计分配的约一成。结合源码检查,可以定位到两类重复工作:部分字段先复制到临时 Header,再复制到最终 context;旧 Header 也被复制,但随后被新 Header 替换。 本文围绕这个内容设计优化:**减少中间复制,保留最终数据与调用方输入的隔离。**主要评估每次调用分配的字节数和次数,重点场景是携带 metadata 的小报文调用。 ## 二、测试与瓶颈 ### 2.1 携带更多 metadata,会增加多少分配? **在本次测试中,业务字段从 4 个增加到 16 个,单次 Header 合并的分配从约 1.86 KiB 增至 6.88 KiB,分配次数从 33 次增至 91 次。也就是每次多分配约 5 KiB,增加 58 次内存分配。** 为了测清字段数量的影响,测试单独执行 Header 合并操作,比较无业务字段、4 字段和 16 字段三组输入。有业务字段的两组均将一半字段放在 context Header 中,另一半放在 attachment 中,模拟客户端从两处收集字段再合并的过程。以下为多轮测试的中位数。 | 业务字段数量 | 每次分配字节(B/op) | 每次分配次数(allocs/op) | | --- | ---: | ---: | | 0 | 1,248 | 15 | | 4 | 1,904 | 33 | | 16 | 7,040 | 91 | 字段数量变为四倍时,分配字节约为原来的 3.7 倍,分配次数则约为 2.8 倍。如果我们关注字段增加带来的成本,每次都需要整理多项 metadata 的调用是尤其多的。要减少这部分成本,需要检查每个字段在合并过程中创建了多少副本,以及哪些副本最终被保留。 补充测试为一个已有字段增加第二个值,单次分配仅增加 32 字节,次数保持 33 次。与新增多个字段的结果相比,这个对照进一步提示应检查逐字段创建和复制的对象。第三章将用源码确认这些对象的用途及可减少的副本。 无业务字段时仍然存在基础分配,因为框架还会处理 timeout、retry 并构造 context。这里展示基础开销;上面的增长比较采用同样包含两处字段来源的 4 字段与 16 字段组。 ### 2.2 小报文中,Header 构造占比更高 Header 合并读取字段与值,不处理请求消息体。独立客户端的基线分配 profile 中,同样携带四个业务字段时,目标调用栈的占比如下。 | 请求消息大小 | `mergeAttachmentToOutgoing` 占客户端累计分配 | | --- | ---: | | 128 B | 9.70% | | 1 MiB | 0.16% | 可以观察到,在小报文下,**按请求发生的 Header 构造更容易成为明显的分配项**,是我们的重点优化场景;因为在大报文下,消息编码与传输涉及的分配增加,Header 在整体分配中的比例降低。之后我们优先评估小报文 metadata 调用,并用大报文检查改动对其他场景的影响。这里比较的是不同基线场景的分配构成。 ### 2.3 一次完整 RPC 的客户端分配 用户发起一次 Triple 调用,请求会经过 Header 合并、编码和网络发送,服务端处理后返回响应。测试使用生成式客户端访问独立服务端进程,并等待响应返回;以下统计客户端完成一次调用产生的分配。 | 测试范围 | 每次分配字节 | 每次分配次数 | | --- | ---: | ---: | | 只执行 Header 合并函数 | 1,904 B | 33 | | 完整 RPC 的客户端 | 21.27 KiB | 240 | 完整调用携带四个业务字段,消息体为 128 B,代表本报告关注的小报文 metadata 场景。合并函数每次产生 33 次分配,完整调用还包含编码、传输等步骤的分配。因此后续以局部测试检查重复副本的减少,以完整客户端测试衡量用户每次调用的变化。 ### 2.4 分配来源与优化假设 1. **减少连续的值切片复制。**字段增加时分配次数上升;profile 又将分配定位到 `cloneOutgoingHeader` 与 `NewOutgoingContext`。两者之间的临时数据只用于组装最终 Header,可以研究在最终保存数据时再复制值切片。 2. **跳过即将被替换的旧 outgoing 组。**`cloneExtraData` 在 profile 中有明确的分配记录;源码显示它复制的旧 outgoing 组随后被覆盖。替换操作可以保留其他 extra data 的复制,跳过旧 outgoing 数据。 3. **减少重复键名处理。**同一个字段在临时 Header 中转为小写,在最终 Header 中再转为标准写法。先保持现有行为完成分配优化,再评估已知键名格式是否可以简化处理。 这三项分别针对复制值切片、复制旧组和处理键名。下一章通过读源码的方式去进行对齐。 ## 三、代码瓶颈定位 ### 3.1 入口:unary 调用之前发生合并 [protocol/triple/triple_invoker.go:90-110](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_invoker.go#L90-L110) ```go callType, inRaw, method, err := parseInvocation(ctx, ti.GetURL(), invocation) if err != nil { result.SetError(err) return &result } ctx, err = mergeAttachmentToOutgoing(ctx, invocation) if err != nil { result.SetError(err) return &result } ``` `parseInvocation` 整理 invocation 后,`mergeAttachmentToOutgoing` 合并 header;非 IDL unary 分支随后经 `clientManager.callUnary` 进入协议客户端。每次走到这一入口的请求都会执行 header 合并。 ### 3.2 中间 Header:复制原值、转换 attachment、再交给 context 构造器 [protocol/triple/triple_invoker.go:185-203](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_invoker.go#L185-L203) ```go // Todo(finalt) Temporarily solve the problem that the timeout time is not valid // timeout 同时写入专用 context 值,后续仍参与 attachment 合并。 if timeout, ok := inv.GetAttachment(constant.TimeoutKey); ok { ctx = context.WithValue(ctx, tri.TimeoutKey{}, timeout) } // 从旧 context 复制 Header,作为合并 attachment 的临时数据。 header := cloneOutgoingHeader(tri.ExtractFromOutgoingContext(ctx)) for key, valRaw := range inv.Attachments() { lowerKey := strings.ToLower(key) if str, ok := valRaw.(string); ok { // 同名 attachment 替换临时 Header 中的旧值。 header[lowerKey] = []string{str} continue } if strs, ok := valRaw.([]string); ok { // 多值 attachment 先复制一次,context 构造时还会再次复制。 header[lowerKey] = append([]string(nil), strs...) continue } return ctx, fmt.Errorf("triple attachments value with key = %s is invalid, which should be string or []string", key) } // 将合并后的临时 Header 复制到新的 context。 return tri.NewOutgoingContext(ctx, header), nil ``` 这里先复制 context 原有 outgoing header。attachment 的 `string` 值生成单值切片,`[]string` 值生成新的值切片;`timeout` 写入专用 context 值。同名 attachment 在临时 map 中覆盖原有 header。随后整个临时 map 交给 `NewOutgoingContext`,形成下一次复制。 [protocol/triple/triple_invoker.go:206-212](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_invoker.go#L206-L212) ```go func cloneOutgoingHeader(header http.Header) http.Header { cloned := make(http.Header, len(header)) for key, vals := range header { // 创建独立值切片,同时把键名转为小写。 cloned[strings.ToLower(key)] = append([]string(nil), vals...) } return cloned } ``` `ExtractFromOutgoingContext` 返回的是 context 内部保存的 `http.Header` 引用,不是一个已经独立的新副本;直接对它写入 attachment 会改变原 context。`cloneOutgoingHeader` 因此承担隔离原 context 的工作,但它在临时 map 中**立即深拷贝每个值切片**。随后 `NewOutgoingContext` 再次复制临时 map 中保留的值,未被 attachment 替换的旧字段产生连续副本。如果输入 map 中同时有 `X-Request-Id` 与 `x-request-id`,转小写后它们成为同一个键;当前代码按 map 遍历顺序写入,哪一个值保留取决于遍历结果。 ### 3.3 最终 Context:规范化并深拷贝,旧 outgoing 条目随后被覆盖 [protocol/triple/triple_protocol/header.go:132-147](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_protocol/header.go#L132-L147) ```go func NewOutgoingContext(ctx context.Context, data http.Header) context.Context { var header = http.Header{} for key, vals := range data { // Context headers use canonical keys so http.Header.Get/Values work as expected. header[http.CanonicalHeaderKey(key)] = append([]string(nil), vals...) } extraData, ok := ctx.Value(extraDataKey{}).(map[string]http.Header) if !ok { extraData = map[string]http.Header{} } else { // 复制已有各组 Header,其中也包含旧 outgoing 组。 extraData = cloneExtraData(extraData) } // 刚复制的旧 outgoing 组在这里被新 Header 替换。 extraData[headerOutgoingKey] = header return context.WithValue(ctx, extraDataKey{}, extraData) } ``` 这一步对临时 map **再次复制值切片**,同时将键名转为 HTTP Header 的标准写法,以满足 `Header.Get/Values` 的读取规则。`NewOutgoingContext` 保存独立副本:调用方之后修改传入的 map 或值切片,已创建的 context 保持原值。内部合并流程的优化应保留这一行为。 [protocol/triple/triple_protocol/header.go:150-160](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_protocol/header.go#L150-L160) ```go func cloneExtraData(data map[string]http.Header) map[string]http.Header { cloned := make(map[string]http.Header, len(data)) for extraKey, header := range data { headerClone := make(http.Header, len(header)) for headerKey, vals := range header { // 每组逐字段复制值切片,即使该组随后会被替换。 headerClone[headerKey] = append([]string(nil), vals...) } cloned[extraKey] = headerClone } return cloned } ``` 如果 `ctx` 已有 extra data,`cloneExtraData` 会逐组复制 map 与值切片,**包括旧的 outgoing 组**。随后 `extraData[headerOutgoingKey] = header` 把刚复制的旧组覆盖。内部替换 outgoing header 时可跳过这组数据;其他组继续复制,以保持原 context 和新 context 相互独立。 ### 3.4 发送端:最终 Context Header 进入请求 Header [protocol/triple/triple_protocol/client.go:133-145](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_protocol/client.go#L133-L145) ```go func (c *Client) CallUnary(ctx context.Context, request *Request, method string, response *Response) error { if c.err != nil { return c.err } ctx, flag, cancel := applyDefaultTimeout(ctx, c.config.Timeout) if flag { defer cancel() } // 最终 context 中的字段进入实际发送的请求 Header。 mergeHeaders(request.Header(), ExtractFromOutgoingContext(ctx)) applyGroupVersionHeaders(request.Header(), c.config) return c.callUnary(ctx, request, method, response) } ``` [protocol/triple/triple_protocol/header.go:53-57](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_protocol/header.go#L53-L57) ```go func mergeHeaders(into, from http.Header) { for k, vals := range from { // 追加到请求侧切片,容量不足时会扩容。 into[k] = append(into[k], vals...) } } ``` `CallUnary` 从最终 context 提取 header,再合入请求 header;这里可能发生新的 map 条目与值切片扩容。请求 Header 随后由发送层使用;这一层的复制发生在合并函数之后。 源码确认了三次相关复制:`cloneOutgoingHeader` 复制已有 header,`NewOutgoingContext` 复制新 header,`cloneExtraData` 复制的旧 outgoing 组随后被覆盖。键名先转小写,再转为 HTTP Header 的标准写法。优化重点是减少内部合并过程中的中间副本,并在最终 context 中保留独立数据。 ### 3.5 一次合并中的数据变化 ```mermaid flowchart TD A["旧 context 的 outgoing Header"] -->|"复制 map 与值切片"| B["临时 Header<br/>优化目标:中间值切片副本"] T["invocation attachment"] -->|"写入并覆盖同名字段"| B B -->|"再次复制值切片并处理键名"| C["新 outgoing Header"] D["旧 context 的 extra data"] -->|"复制其他组"| E["新 extra data"] D -->|"复制旧 outgoing 组"| G["旧 outgoing 副本<br/>优化目标:随后被替换的 map 与值切片"] G -->|"先保存旧组"| E C -->|"替换旧 outgoing 副本"| E E --> F["新 context"] classDef redundantCopy fill:#FFF3E0,stroke:#E87500,color:#7A3600,stroke-width:2px; class B,G redundantCopy; linkStyle 0,1,4 stroke:#E87500,stroke-width:2px; ``` 橙色标出计划减少的复制开销:临时 Header 中的值切片副本,以及随后被替换的旧 outgoing 副本。临时 map 和最终 context 所需的数据仍保留。 | 步骤 | 产生的对象 | 数据最后如何使用 | | --- | --- | --- | | `cloneOutgoingHeader` | 临时 Header map、已有字段的值切片 | 交给 context 构造器;同名 attachment 会替换其中部分值 | | 写入 attachment | 单值切片或多值切片副本 | 交给 context 构造器,再复制到最终 Header | | `NewOutgoingContext` | 最终 Header map 与值切片 | 保存到新 context,供请求层读取 | | `cloneExtraData` | extra data map、各组 Header map 与值切片 | 其他组保存;旧 outgoing 组被最终 Header 覆盖 | 以非空值切片为例,在合并函数范围内可以逐字段说明复制次数: | 字段来源 | 值切片复制 | 最终保留 | | --- | --- | --- | | 旧 context 字段,未被覆盖 | 临时 Header 一次、最终 Header 一次、旧 outgoing 组一次 | 最终 Header 中的一份 | | 旧 context 字段,被 attachment 覆盖 | 临时 Header 一次、旧 outgoing 组一次 | 旧值被替换 | | 多值 attachment | 临时 Header 一次、最终 Header 一次 | 最终 Header 中的一份 | 这些复制主要分配 map 和 `[]string` 的存储。复制值切片会复制其中的字符串引用,字符串内容仍由 Go 的不可变字符串保存。多值字段增加切片长度,更多字段增加需要独立处理的条目。第一份临时副本和最后被覆盖的旧组,是本方案的主要目标。 ## 四、多工具交叉验证瓶颈 CPU 与分配 profile 均来自独立客户端:四个业务字段、128 B 消息体、50 个并发调用,使用 protobuf 且关闭压缩。客户端先预热,再在持续成功调用期间采集 30 秒 profile。它们用于检查客户端请求准备阶段的计算与分配来源。 ### 4.1 CPU profile:合并函数的计算开销 **全局火焰图** <img width="1912" height="878" alt="image" src="https://github.com/user-attachments/assets/bdf92ca4-044b-41f5-bf52-fddc8831b931" /> **Header 合并路径聚焦图** <img width="1917" height="877" alt="image" src="https://github.com/user-attachments/assets/5cb2e9f3-61da-457e-9f82-ebbf1ce753c1" /> CPU profile 共记录 57.88 秒 CPU 样本,合并调用栈累计为 2.08 秒,占 3.59%。其中 `NewOutgoingContext` 为 1.25 秒,`cloneOutgoingHeader` 为 0.41 秒。这里的累计值包含子调用;采样覆盖多个并发执行的 goroutine,CPU 样本时间可大于采集的墙钟时间。 聚焦图中可见合并步骤主要经过 context 构造与旧 Header 复制。结合下一节的分配结果,本方案优先减少分配字节和次数,CPU 与调用耗时用于评估减少复制后的计算成本。全局图显示总体占比,聚焦图放大合并路径,保留从请求入口到复制函数的调用关系。 复现命令 ```bash go tool pprof -peek=mergeAttachmentToOutgoing "tools/benchmark/data/header-metadata-2026-09-25/profiles/baseline-fields-4-size-128-client-cpu/client-cpu.pb.gz" go tool pprof -http=127.0.0.1:8110 "tools/benchmark/data/header-metadata-2026-09-25/profiles/baseline-fields-4-size-128-client-cpu/client-cpu.pb.gz" # 浏览 http://127.0.0.1:8110/ui/flamegraph ``` ### 4.2 分配 profile:Header 复制的分配来源 <img width="1917" height="870" alt="image" src="https://github.com/user-attachments/assets/99a15a58-4485-46f0-9447-fc4c652db72c" /> 客户端累计分配为 4,878.53 MB,合并调用栈为 473.10 MB,占 9.70%。`cloneOutgoingHeader` 累计分配 112.03 MB,`NewOutgoingContext` 含子调用为 334.57 MB。这两个分支分别对应旧 Header 的临时复制和最终 context 构造,父节点的累计值包含它们。 profile 定位了分配发生的函数,临时值切片还会被最终 context 再次复制,旧 outgoing 组被复制后又遭替换。因此,减少这两类副本是具体的分配优化方向。 复现命令 ```bash go tool pprof -sample_index=alloc_space -peek=mergeAttachmentToOutgoing "tools/benchmark/data/header-metadata-2026-09-25/profiles/baseline-fields-4-size-128-client-alloc/client-alloc.pb.gz" go tool pprof -sample_index=alloc_space -http=127.0.0.1:8111 "tools/benchmark/data/header-metadata-2026-09-25/profiles/baseline-fields-4-size-128-client-alloc/client-alloc.pb.gz" # 浏览 http://127.0.0.1:8111/ui/flamegraph ``` ## 五、方案设计 ### 5.1 优化目标与范围 主要指标为客户端 Header 合并阶段的 `B/op` 和 `allocs/op`。方案先减少中间对象,再保持最终 context 所需的独立存储。用户仍可在构造 context 后修改原始 Header,新 context 保存的字段保持原值;attachment 同名覆盖、键名读取和多值顺序沿用既有行为。 具体改动如下,后文分别说明处理顺序与防御性设计。 | 位置 | 当前处理 | 拟改动 | | --- | --- | --- | | `mergeAttachmentToOutgoing` 中的旧 Header 合并 | `cloneOutgoingHeader` 立即复制每个值切片 | 单独创建临时 map,将旧值切片作为只读输入;最终构造 context 时再复制 | | `mergeAttachmentToOutgoing` 中的多值 attachment | `append([]string(nil), strs...)` 创建中间副本 | 临时 map 引用 `strs`,同名覆盖完成后由 context 构造器复制 | | `cloneExtraData` | 复制所有 Header 组 | 增加替换目标参数,跳过该组,深拷贝其他组 | | outgoing、incoming context 构造 | 复制旧组后再覆盖 | 分别传入 outgoing、incoming 组名,最后保存各自的新 Header | 请求层 `mergeHeaders` 继续把最终 context Header 合入发送请求。主要指标为 `B/op`、`allocs/op`,耗时与 CPU 用于观察计算成本。 ### 5.2 方案核心:保留临时 map,延后值切片复制 当前临时 Header 只用于收集旧字段和 attachment,然后交给 `NewOutgoingContext`。可以保留这个 map,但其中的旧值切片仅作为只读输入;`[]string` attachment 也在此阶段只读取。最终 context 构造时,再为保留的字段复制独立值切片。 建议设计: 1. 保留 timeout 写入专用 context 的既有位置。 2. 建立临时 Header map,旧 Header 值暂时引用旧切片,键名仍按当前规则转小写。 3. 按现有类型检查处理 attachment;字符串形成单值输入,多值 attachment 暂时引用输入切片,同名字段仍覆盖旧值。 4. 调用 context 构造器,复制最终保留的值切片,并保存标准格式的键名。 这样旧字段在 attachment 覆盖前只进入临时 map,没有先创建一份即将丢弃的值切片;未被覆盖的旧字段也只在最终保存时复制。临时 map 本身仍会分配,设计先省去证据明确的切片副本。 该方案保留现有两阶段键名处理和 attachment 校验流程,改动范围较小。代价是内部临时数据会引用旧切片,因此临时 Header 的处理必须只读这些切片,最终 context 负责复制。复制完成后,新 context 与旧 context 分别持有自己的值切片。 ### 5.3 替换 outgoing 时,跳过旧组复制 [`extraDataKey`、`headerOutgoingKey`](https://github.com/apache/dubbo-go/blob/develop/protocol/triple/triple_protocol/header.go#L94-L97)均定义在 `triple_protocol` 包内部,Invoker 通过公开接口访问 Header。因此跳过旧 outgoing 组的逻辑应放在协议包的 context 构造阶段。 可以在协议包内增加“复制 extra data,但跳过将被替换组”的辅助操作。`NewOutgoingContext` 构造新的 extra data map,复制 incoming 等其他已有组,最后写入新的 outgoing Header。公开函数签名和输入 Header 的复制规则保持原样。`newIncomingContext` 同样传入 incoming 组作为替换目标,复制保留的 outgoing 等其他组;两个入口分别构造自己的新 Header。 这一步预计减少旧 outgoing 组的 Header map 和值切片分配;其他组保存独立副本。每个组增加一次是否被替换的判断,换取被覆盖组的复制与遍历减少。实现时采用明确的参数或专用辅助函数,让替换目标在调用处可见。 ### 5.4 一些取舍 | 方案 | 预期优化 | 设计取舍 | | --- | --- | --- | | 临时 map 保留,延后值切片复制 | 旧 Header 和多值 attachment 的中间切片副本 | 首选;保持当前校验、覆盖和键名处理顺序 | | context 构造时跳过旧 outgoing 组 | 被覆盖组的 map 与值切片 | 首选;在协议包内重构辅助复制,保持其他组隔离 | | 协议包直接合并旧 Header 与 attachment | 完整临时 Header 的组装和部分重复遍历 | 需为跨包调用设计入口,并安排校验与覆盖顺序;作为进一步方案 | | 简化两阶段键名处理 | 小写与标准格式转换中的重复工作 | 与分配优化分开评估,逐项比较同名和异常键的行为 | | 使用对象池保存 Header | 部分 map 或切片创建 | 不建议,context 可被用户保存,归还时机不明确;首选通过减少副本解决 | 直接合并方案需要先识别 attachment 会替换哪些字段,再复制最终保留的旧值。它能进一步减少临时数据,但也把字段处理集中到一个新入口。首轮选择前两项,是因为它们直接对应已定位的副本,同时复用现有最终 context 构造规则。 对于键名,标准 HTTP Header 与大小写异常键的处理存在差别。基线先 `ToLower`,再 `CanonicalHeaderKey`;按 [`CanonicalHeaderKey` 的规则](https://pkg.go.dev/net/http#CanonicalHeaderKey),含空格或无效字段名字符时保留原输入。第一阶段保留这两个步骤,后续依据具体键名行为决定简化范围。 ### 5.5 防御性设计 #### 1. 提取旧 Header,临时 map 独立,输入切片只读 `ExtractFromOutgoingContext` 返回内部 Header 引用。合并阶段仍需创建新的 map,attachment 的赋值只写这个临时 map。旧 Header 和多值 attachment 的切片暂时共享,但处理过程中仅赋值引用,不修改元素,也不向这些切片追加值。否则即使最终再复制,也已经改变了调用方的数据。 临时 map 的容量可以按旧字段与 attachment 数量预估,但同名覆盖会减少最终条目。容量只是分配提示,逻辑不依赖它;局部 benchmark 同时检查预留容量对小输入的字节成本。 #### 2. 应用 attachment,保留覆盖、类型检查与 timeout 顺序 旧字段先写入,attachment 后写入,同名 attachment 替换整组值。继续只接受 `string` 与 `[]string`,其他类型按现有格式返回错误。timeout 仍在合并前写入专用 context 值;发生错误时按原路径返回 context,已有 Header 的 map 和值保持原样。 对于多个无效 attachment,map 遍历没有固定顺序,测试检查错误格式及对应键,不指定哪个键必须先报错。`traceparent` 作为普通字段处理,保留原值及覆盖行为。 #### 3. 构造最终 Header,保存独立值切片,保留键名规则 `NewOutgoingContext` 继续对最终保留的值执行 `append([]string(nil), vals...)`,再保存到新 context。这是延后复制方案的必要步骤:合并完成后,修改 attachment、旧 Header 或新 Header,其他数据各自保持原值。 键名继续先转小写,再使用 `CanonicalHeaderKey`,保证 `Get/Values` 的读取行为。对于空键、带空格等无效键,沿用现有转换结果;此次不增加新的拒绝规则。原 map 同时包含大小写等价键时,沿用 map 遍历覆盖行为;显式 attachment 则仍在旧字段之后覆盖。 nil Header 可以遍历,临时 map 保持可写。nil 与非 nil 空值切片按原复制规则保存;零长度值不产生额外内容,多值切片保持顺序。 #### 4. 复制 extra data,只跳过指定组,其他组独立 `cloneExtraData` 在每组开始复制前比较组名。outgoing 构造仅跳过旧 outgoing,incoming 构造仅跳过旧 incoming;其他组逐层复制 map 和值切片。调用处使用已有组常量,避免两个入口传错替换目标。 没有 extra data,或取出的值类型不匹配时,仍按现有路径创建新 map。只有 incoming、只有 outgoing、两组并存以及其他内部组的输入均需覆盖。被替换组的旧内容全部由新 Header 替代,保留组在新旧 context 之间各自独立。 #### 5. 读取和发送,追加 API 与请求 Header 保持原有行为 `AppendToOutgoingContext` 原本会修改已有 Header,本次保留该行为;奇数个参数仍按原规则 panic。合并后的新 context 与旧 context 已有独立存储,因此在其中一侧追加值应只影响该侧。 `Client.CallUnary` 继续读取最终 Header,并由 `mergeHeaders` 追加到请求 Header。需要检查多值顺序、同名字段和框架字段是否按原流程进入请求,timeout 的专用值与 `traceparent` 均保留。 多个调用可以只读复用同一个输入 context,各自构造独立结果。并发测试中的输入不被调用方修改;`-race` 检查合并过程是否引入共享写入。 ### 5.6 需要补的测试 以下仅列出需要补充的边界用例,分别在 `triple_invoker_test.go` 与 `triple_protocol/header_test.go` 中新增或扩展现有测试。 | 测试 | 功能 | | --- | --- | | 共享切片的反向修改与追加 | 为输入切片预留额外容量,合并后修改新 Header 的元素并追加值,确认旧 Header 与 attachment 保持原值;再向输入追加值,确认已保存的新 Header 不受影响。 | | 多值覆盖与重复值 | 用包含重复值的多值 attachment 替换旧 Header 的整组值,确认旧值全部被替换,新值数量、重复项及顺序完整保留。 | | 类型错误路径的数据隔离 | 同时提供合法字段、timeout 与不支持的 attachment 类型,确认错误包含相应键和类型要求,旧 Header 保持原值,返回 context 仍保留先写入的 timeout;覆盖 nil 接口和非字符串切片。 | | nil 与空切片 | 覆盖 nil Header、空 Header、nil 值切片及非 nil 空切片,检查合并结果的键、值和 nil 状态符合原复制规则。 | | 非标准键名 | 输入空键、含空格或无效字符的键,确认原有小写转换与 `CanonicalHeaderKey` 处理结果,以及 `Get/Values` 的读取行为。 | | 原 Header 中的大小写等价键 | 通过提取出的 Header 放入两个大小写等价键;无 attachment 时检查结果符合原遍历覆盖规则,有同名 attachment 时确认由 attachment 最终覆盖。 | | extra data 辅助复制的边界 | 覆盖空 map、仅被替换组、多组、额外组及 nil Header 组,确认仅跳过指定组,其他组按原规则复制,修改结果的 map 或值切片不影响输入。 | | extra data 值类型不匹配 | 在内部 context 键下保存非预期类型,确认 outgoing、incoming 构造均按原路径建立可用 Header,提取结果正确。 | | 新旧 context 分别追加 | 对合并前后两个 context 分别调用 `AppendToOutgoingContext`,确认追加只影响被操作的一侧,保持多值顺序;零参数调用保留原数据。 | ### 5.7 后期性能测试 | 测试 | 主要观察 | 对应的设计问题 | | --- | --- | --- | | 0、4、16 字段的局部 benchmark | `B/op`、`allocs/op` 与字段数量的变化 | 中间切片与旧 outgoing 组是否减少 | | 同名覆盖及多值 benchmark | 保留字段与被覆盖字段的分配 | 是否只对最终数据执行必要复制 | | 独立客户端 RPC benchmark | 每次完整调用的分配量与次数 | 局部变化在完整调用中的表现 | | 分配 profile | 临时复制、旧组复制及其他函数的分配 | 减少的对象与修改位置是否对应 | | CPU profile 与 `ns/op` | 键处理、判断和遍历的计算成本 | 减少分配后的 CPU 取舍 | 可以预先固定输入,基线与方案各至少十轮交错运行,以 `benchstat` 汇总分配和耗时。字段数量和值数量分别变化;小报文评估按请求发生的 Header 开销,大报文观察整体调用表现。评估结果应分别列出目标函数减少的分配与完整 RPC 的变化,并通过 profile 核对减少的对象。 ## 六、参考链接 - grpc-go metadata 分配问题与改进:[Issue #8860](https://github.com/grpc/grpc-go/issues/8860)、[PR #9282](https://github.com/grpc/grpc-go/pull/9282)。讨论按键读取 outgoing metadata 时的分配,并提供函数级 benchmark;用于参考如何把 metadata 操作单独测量,以及按实际读取需求减少复制。 - grpc-go metadata 实现:[metadata/metadata.go](https://github.com/grpc/grpc-go/blob/master/metadata/metadata.go)。对照 `NewOutgoingContext`、`AppendToOutgoingContext`、`FromOutgoingContext` 与 `ValueFromOutgoingContext` 的保存和读取方式;本方案借鉴其复制时机,保持 Dubbo-Go 输入 Header 的隔离行为。 - grpc-go metadata 用法:[Documentation/grpc-metadata.md](https://github.com/grpc/grpc-go/blob/master/Documentation/grpc-metadata.md)。说明 outgoing metadata 的追加与替换,作为 context API 行为设计的参考。 - Go HTTP Header:[CanonicalHeaderKey](https://pkg.go.dev/net/http#CanonicalHeaderKey)。用于核对标准键名、无效键名及 `Header.Get/Values` 的读取规则。 - [pprof 火焰图预览与截图归档 SOP](../../../../../../projects/dubbo-go/dev-notes/benchmark/规则与SOP/SOP-pprof火焰图预览与截图归档.md):完整截图方法、样本核对及归档检查。 GitHub link: https://github.com/apache/dubbo-go/discussions/3673#discussioncomment-18624719 ---- This is an automatically sent email for [email protected]. To unsubscribe, please send an email to: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
