Vector 内存缓慢泄漏排查:datadog_metrics sink 的增量转换状态永不过期
AI 生成声明:本文内容由 AI 生成或经 AI 辅助完成,请注意甄别。
今天看到博客园团队发布的《Agent 的经验,不该只留在聊天框里:cnblogs-publish 上线》,便想用 cnblogs-publish 实际发一篇试试,于是有了这篇文章。
问题:aggregator 的内存只涨不降
我们的指标链路是两层 Vector:每个 ECS 任务里跑一个 Vector sidecar(下文叫 agent),采集应用指标和 Vector 自身的 internal metrics,再通过 ECS Service Connect 轮询发给一个多副本的共享 Vector 集群(下文叫 aggregator),由它用 datadog_metrics sink 统一发到 Datadog。

aggregator 的内存一直在缓慢上涨:重启后约 15%,之后每天涨 10 个百分点左右,大约 7 天涨到 90%,触发自动重启,然后重新开始。

第一轮尝试:加资源、找嫌疑组件
这个问题 2025 年 5 月就发现了,当时每天涨约 3%。
加内存。 任务内存从 2 GB 加到 4 GB,再加到 8 GB。这只是把重启周期拉长了,线性增长的泄漏加多少资源都只能推迟重启。
找嫌疑组件。 在上游提了 issue vectordotdev/vector#23093。2025 年 8 月升级到 0.48,开启 allocation tracing(--allocation-tracing)后,vector_component_allocated_bytes 显示内存涨在一个 route transform 名下,从 273 MB 一路涨到 1.1 GB。但 maintainer 回复:
the
Routetransform doesn't maintain a per-event state. … I would recommend also checking the utilization and buffer metrics to understand if there's any backpressure.
buffer 指标是正常的,线索断了。
后来上游把这个 issue 关联到了 aggregate transform 的内存优化(#24357、#24943,v0.55 合入)。升级到 0.56 后泄漏照旧。
这一轮还留下一个没解释的现象:datadog_metrics sink 的 Events In 和 Events Out 对不上,发出的总比收到的少。
换个角度:allocation tracing 记的是“谁分配的”
2026 年 10 月重新排查,先看测试环境 aggregator 上一次重启前后各组件的 allocated bytes:

中间我们在 route 后面加了一个 aggregate(处理 internal metrics),涨得最多的组件就从 route 换成了 aggregate;route 自己反而是平的。datadog_metrics sink 名下也在涨,而且是阶梯式的。sink 的 buffer 一直只有几百个事件、不到 1 MB,排除了积压。
“嫌疑组件”随拓扑变化而变化,说明问题可能不在这些组件本身。Vector 官方介绍 allocation tracing 的博客里写得很清楚:
Vector uses a custom memory allocator implementation that captures each allocation/deallocation and associates it with the currently-executing component.
We currently do not provide support for tracking memory ownership between components. If you looked at the memory usage metrics, you would see most of it being attributed to components that either created the events … or processed the events … rather than the sink which is batching the events.
也就是说,一个事件在上游 transform 里创建,交给下游 sink 后即使被长期保存,这块内存仍然记在上游名下:

再看上游这几个组件自己有没有状态:
route只做分流,不保存状态,maintainer 也确认了;aggregate默认的 Auto 模式每次 flush 都清空内部 map(std::mem::take(&mut self.map));remap不保存状态。
它们分配的内存没有被释放,只能是被下游唯一的 datadog_metrics sink 拿着。
根因:datadog_metrics sink 的 MetricSet 没有上限
Vector 的指标分累计值(absolute)和增量(incremental)两种。Datadog 的 count 类型要求上报增量,所以 datadog_metrics sink 在发送前会做 normalize:把累计值转成增量。转换需要记住每条 series 的上一个值,这份状态存在一个 MetricSet 里。
以下源码均为 Vector v0.56.0。
sink 构造 normalizer 时用的是默认配置:
.normalized_with_default::<DatadogMetricsNormalizer>()
最终得到的是 MetricSet::default(),而默认设置的三项全是 None:
#[derive(Debug, Clone, Copy, Default)]
pub struct MetricSetSettings {
pub max_bytes: Option<usize>,
pub max_events: Option<usize>,
pub time_to_live: Option<u64>,
}
没有容量策略时底层是一个无界的 IndexMap,没有 TTL 就不会清理。datadog_metrics sink 也没有任何配置项能改它。
DatadogMetricsNormalizer 对 counter、histogram、distribution 调用 make_incremental,它只在输入是累计值时才动状态:
pub fn make_incremental(&mut self, metric: Metric) -> Option<Metric> {
self.maybe_cleanup();
match metric.kind() {
MetricKind::Absolute => self.absolute_to_incremental(metric),
MetricKind::Incremental => Some(metric),
}
}
| 进入 sink 的指标 | 是否在 MetricSet 里存状态 |
|---|---|
| 累计值 counter / histogram / distribution | 存,每条 series 一份基线,永不清理 |
| 增量 counter / histogram | 不存,原样通过 |
| 累计值 gauge | 不存 |
| 增量 gauge | 存(方向相反的转换) |
absolute_to_incremental 第一次见到某条 series 时只记录基线、不输出:
None => {
// No reference so store this and emit nothing
self.insert(metric, timestamp);
None
}
源码注释说明这是为了避免 Vector 重启后把累计值当成一次巨大的增量发出去。所以 series 不断更换时,sink 发出的事件总比收到的少,这就是第一轮留下的 Events In ≠ Events Out 的答案。
对比一下,prometheus_remote_write sink 用的是同一套机制(方向相反,增量转累计值),但它提供了 expire_metrics_secs,文档里写着:
If unset, sending unique incremental metrics to this sink will cause indefinite memory growth.
datadog_metrics 有同样的问题,却连这个开关都没有。
落到我们的场景:旧版 agent 上报的 internal metrics 是累计值,而且带着任务级别的 tag(ecs_task_id、host 等)。服务每部署、扩缩容或重启一次,就会产生一批新的 series;旧 series 不再出现,但它们的基线永远留在 MetricSet 里。内存随“累计的任务替换次数”线性增长,只有重启 aggregator 才能清空。
验证:最小复现
为了排除其他因素,用 docker compose 做了一个最小复现(完整配置见 #23093 的评论):Vector 0.56.0,开 --allocation-tracing,datadog_metrics sink 指向一个对所有请求都返回 202 的 mock。一个 Lua transform 每秒生成 200 条 counter / histogram series,tag 模仿 ECS 任务,分三组对照:
- abs-churn:累计值,每 2 秒换一个
ecs_task_id; - abs-fixed:累计值,tag 固定;
- inc-churn:增量值,每 2 秒换一个
ecs_task_id。

只有“累计值 + series 不断更换”会让内存持续增长:20 分钟从 50 MiB 涨到 619 MiB(约 1.7 GiB/h),粗算每条 series 常驻约 4.9 KB。series 固定,或者进 sink 前已经是增量值,内存都是平的。
同一组里 allocation tracing 的拆分,也复现了线上的“误导”:

内存主要记在上游 Lua transform 名下(470 MiB),sink 名下只有 61 MiB,并且是阶梯式增长,符合哈希表容量翻倍扩容的特征。
解决:在 agent 端把累计值转成增量
sink 只为累计值保存状态,那就让进入 aggregator 的指标都已经是增量值:

这个转换放在 agent 端还有一个更根本的原因:正确性。aggregator 有多个副本,前面是轮询负载均衡,同一条 series 的相邻两个点可能落到不同副本,各副本只看到一部分序列,算出来的增量本身就是错的。agent 是单实例,拿得到完整序列;转成增量后再经过轮询,增量可以直接相加,分到哪个副本都没问题。
Vector 目前还没有内置的“累计值转增量” transform,暂时用 Lua v2 transform 实现。counter 的核心逻辑:
-- key = namespace + name + 排序后的 tags
local prev_val = prev[key]
prev[key] = cur
if prev_val == nil then return end -- 第一个点只记录基线
local delta = cur - prev_val
if delta < 0 then return end -- counter 重置,丢弃这一个点
m.kind = "incremental"
m.counter.value = delta
emit(event)
histogram 按 bucket 逐个相减,第一次见到、bucket 边界变化或数值变小时丢弃这个点。Lua 里的 prev 表同样不清理,但一个 agent 只服务一个任务,series 集合在它的生命周期内基本固定,不会无限增长。
要让 aggregator 的内存真正平下来,需要所有 agent 都升级到带这项转换的版本,再重启一次 aggregator 清掉已有状态。目前升级还在推进中,上面生产曲线的最后一个周期仍在上涨。
上游方面,我们在 #23093 下补充了根因分析和复现,之后有了几项相关进展(截至 2026-11-11):
| PR | 内容 | 状态 |
|---|---|---|
| #27042 | 给 datadog_metrics sink 加 expire_metrics_secs |
open,待 review |
| #26080 | internal_metrics source 新增 emit_incremental,直接输出增量 |
已合入 master,尚未发布 |
| #24839 | 内置的 absolute_to_incremental transform |
open |
等 #26080 发布后,internal metrics 这部分可以直接用 emit_incremental 取代 Lua。
小结
- allocation tracing 记的是“谁分配的”,不是“谁持有的”。 某个组件名下的内存一直涨,先确认它自己有没有状态;没有的话,去下游找持有者。
- sink 里也可能有无界状态。 累计值与增量互转都要为每条 series 记状态,series 不断更换时,没有 TTL 的状态就是泄漏。
Events In ≠ Events Out是线索。 它说明 sink 在“吞”事件,往往意味着有状态的转换。- 有状态的转换放在拿得到完整序列的地方。 在 agent 端转成增量,既消除了泄漏,也让下游可以随意扩缩容。

浙公网安备 33010602011771号