Envoy 统计名称零分配优化:StatNameJoiner对无操作拼接(no-op join)的 elide 机制解析
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
导读
本文围绕 Envoy 近期一项针对 stats 热路径的性能优化展开:在拼接统计名称(stat name)时,如果参与拼接的操作数中最多只有一个非空名称,则不再分配内存,而是直接引用该非空名称本身。这一优化同时作用于TagStatNameJoiner与 HTTP 响应码(response-code)统计辅助函数,使得 router、ext_authz 和 ratelimit 在计入响应码统计时,每个 upstream 响应可减少 4 次堆分配,且最终生成的统计名称完全不变。读完本文,你将理解 Envoy 的StatName拼接机制、该优化在源码中的具体落点,以及它为何能在保持统计语义不变的前提下显著降低热路径内存开销。
变更背景:为什么统计名称拼接会“白花钱”
在 Envoy 中,统计系统对名称进行**符号化(symbolization)**处理:每个统计名称被编码为紧凑的StatName(见 symbol_table.h),并配合SymbolTable进行内存复用与去重,从而避免为每一条统计都保存一份完整字符串。
拼接多个StatName时,传统做法是为拼接结果分配一块新内存,再把各操作数的字节复制进去。但有一种常见场景是拼接中存在空操作数:
- 名称 A 为空、名称 B 非空 → 拼接结果与 B 字节完全相同;
- 名称 A 非空、名称 B 为空 → 拼接结果与 A 字节完全相同。
这类“无操作拼接”(no-op join)白白分配了一块字节完全相同的副本。而 Envoy 的 router、ext_authz 与 ratelimit 在计入响应码统计时,恰好都是**以空前缀(empty prefix)**去拼接响应码名称的,于是每次 upstream 响应都会触发多次此类无意义分配。本变更正是针对这一热点做了 elide(消除)处理。
优化核心:StatNameJoiner::join的 elide 逻辑
该优化的底层落点在StatNameJoiner::join,实现位于 symbol_table.cc:
void StatNameJoiner::join(absl::Span<const StatName> stat_names, const SymbolTable& symbol_table) { // A join with at most one non-empty name produces bytes identical to that name, so the // allocation can be skipped and the name referenced directly. StatName sole_name; bool needs_join = false; for (StatName stat_name : stat_names) { if (stat_name.empty()) { continue; } if (!sole_name.empty()) { needs_join = true; break; } sole_name = stat_name; } if (needs_join) { storage_ = symbol_table.join(stat_names); stat_name_ = StatName(storage_.get()); } else { storage_.reset(); stat_name_ = sole_name; } }其判断逻辑可以概括为三步:
- 遍历全部操作数,跳过所有
empty()的StatName; - 记录第一个非空名称
sole_name,若再遇到第二个非空名称,则置needs_join = true并跳出循环; - 需要真实拼接时,调用
SymbolTable::join分配新存储(见 symbol_table.cc);不需要拼接时,释放storage_,让statName()直接返回那个唯一的非空名称。
零分配分支的内存语义
当走 elide 分支时,storage_被reset(),stat_name_直接指向调用方传入的名称。这意味着StatNameJoiner不再拥有拼接结果的内存。这一点在 symbol_table.h 的类注释中写得非常明确:
When at most one of the names is non-empty the joined bytes are identical to that name, so no storage is allocated and
statName()references the caller's name directly. Callers must therefore keep the joined names valid for the lifetime of this object.
也就是说:调用方必须保证传入的名称在StatNameJoiner的生命周期内持续有效。同时,StatNameJoiner被设计为可移动但不可拷贝(move-only):拼接字节位于堆上,移动只需转移所有权,而拷贝会重复复制存储。这在接口层面为“引用非自有内存”提供了安全边界。
上层联动:TagStatNameJoiner与统计名称的两种形态
TagStatNameJoiner是 Envoy 在带标签(tagged)场景下对上述机制的封装,定义于 tag_utility.h,实现在 tag_utility.cc。它负责把 scope 前缀、统计名与标签组合成最终名称,并对外暴露两个结果:
nameWithTags():完整名称,包含标签后缀;tagExtractedName():去除标签后的名称。
它内部持有两个StatNameJoiner(prefix_joiner_与full_name_joiner_),因此本优化对TagStatNameJoiner自动生效:当“前缀 + 名称”中只有一个非空操作数时,prefix_joiner_不再为 tag-extracted 名称分配副本;当无标签或只有前缀标签时,name_with_tags_直接复用既有名称,见 tag_utility.cc 中“无标签直接复用”与“仅有前缀标签时拼接 tagged_prefix 与名称”的分支。
TagStatNameJoiner的主要使用方是ThreadLocalStoreImpl::ScopeImpl,在 thread_local_store.cc 等多处被用于创建 Counter、Gauge、Histogram 时构造统计全名。因此该优化覆盖了通过 scope 创建统计的主流路径。
调用链与收益:为什么一次响应省下 4 次堆分配
响应码统计的统一入口是CodeStatsImpl(codes.h 与 codes.cc)。其核心计入函数chargeBasicResponseStat会依次执行三类计入(codes.cc):
incCounter(scope, prefix, upstream_rq_completed_);- 若响应码有分组,
incCounter(scope, prefix, rq_group); incCounter(scope, prefix, upstreamRqStatName(response_code))。
而incCounter内部正是通过StatNameJoiner完成拼接:
void CodeStatsImpl::incCounter(Stats::Scope& scope, Stats::StatName a, Stats::StatName b) const { const Stats::StatNameJoiner joined({a, b}, symbol_table_); scope.counterFromStatName(joined.statName()).inc(); }(codes.cc,另见recordHistogram同样使用StatNameJoiner,codes.cc)
三个组件、空前缀、四次分配
- router:在 router.cc 调用
code_stats.chargeResponseStat(info, ...); - ext_authz:在 ext_authz.cc 调用
chargeResponseStat(info, false); - ratelimit:同样经由
CodeStats计入响应码统计(其自身统计名通过StatNamePool预先构建,见 stat_names.h)。
这三个组件在计入响应码统计时都使用空前缀。对于chargeBasicResponseStat的完整路径(含 canary/internal/external 分类),每次 upstream 响应原本会产生多处StatNameJoiner拼接;其中可被 elide 的无操作拼接合计为4 次堆分配。优化后这 4 次分配被完全消除,取而代之的是直接引用既有名称。
为什么“统计名称不变”是安全的
elide 的正确性来源于字节等价性:空操作数与任何名称拼接,结果字节与原名称逐字节相同;而SymbolTable的编码是确定性的(见 symbol_table.cc 中按dataSize()累加并追加数据的过程),因此跳过分配不会改变最终统计名称。统计的语义、名称去重与下游聚合行为均不受影响,这正是文档中“The resulting stat names are unchanged”的底层依据。
适用前提与注意事项
- 生命周期约束:elide 分支返回的
StatName指向调用方名称而非 Joiner 自有存储,调用方必须保证名称在 Joiner 存活期间有效;TagStatNameJoiner的头文件注释(tag_utility.h)同样要求调用方保持标签数据有效。 - 空名称的语义:
StatName::empty()用于判断空操作数,两个都为空时sole_name保持为空,结果同样是空名称,无需分配。 - 收益场景特定:该优化针对“至多一个非空操作数”的拼接;当存在多个非空操作数(例如带多个标签值的完整拼接)时,仍走
SymbolTable::join分配路径,行为不变。 - 可观测性:这是一次纯内部的分配消除,不引入新的配置项或运行时标志,也不改变任何统计输出,因此对使用者完全透明。
总结
本变更通过为StatNameJoiner增加“无操作拼接识别”逻辑,让 Envoy 在统计名称拼接这一高频热路径上跳过无意义的堆分配:TagStatNameJoiner自动继承该能力,router、ext_authz 与 ratelimit 的空前缀响应码统计则直接受益,每次 upstream 响应减少 4 次堆分配。由于拼接结果在字节上与原名称完全一致,统计名称与语义均保持不变,是一项在保持行为兼容前提下的低风险、高收益的运行时优化。
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考