内存上涨时,先检查指标的“身份”
监控系统自己开始内存告警时,除了检查业务流量,还应该问一个问题:
每次业务请求,究竟是在更新已有时间序列,还是在创建新的时间序列?
在 Prometheus 中,一条时间序列由指标名称和完整的标签集合共同标识。指标名称相同,只要某个标签值不同,就可能是另一条时间序列。因此,“项目里只有几十个指标”并不能说明监控成本很低。
以自定义指标 app_http_requests_total 为例,下面这些标签看起来都有排障价值,但增长方式完全不同:
| 标签 | 示例 | 增长方式 |
|---|---|---|
method |
GET、POST |
通常可以限制为固定集合 |
route |
/api/orders/{id} |
可以与接口定义数量保持一致 |
uri |
/api/orders/938271 |
随实际请求路径不断增加 |
user_id |
938271 |
随用户数量增加 |
request_id |
每次请求生成的标识 | 可能随请求次数持续增加 |
Prometheus 官方明确提醒,不要把用户编号、邮箱地址等高基数或无界集合直接作为标签。判断一个标签是否合适,不能只看“现在有多少个值”,还要看它的取值空间是否受控。
将用户编号转换为哈希值,也没有解决这个问题。只要不同用户仍然对应不同标签值,时间序列数量就没有实质减少。
高基数治理控制的是不同身份的数量,而不是字符串看起来有多短。
一个直方图,为什么能扩展出数万条序列
假设一个服务的 HTTP 延迟指标包含以下维度。这里使用的是容量推演数据,不是线上实测结果。
| 维度 | 取值数量 |
|---|---|
| 实例 | 6 |
| 路由模板 | 80 |
| 请求方法 | 4 |
| 结果分类 | 3 |
当这些维度可以形成全部组合时,标签组合数量为:
这只是标签组合,还没有计算指标类型带来的扩展。
经典 Histogram 通常会暴露多个 _bucket 序列,以及 _sum 和 _count。其中,+Inf 桶也占用一条序列。
假设配置了 10 个有限边界桶,那么每组标签会对应:
总量就变成:
这还没有计算客户端可能额外暴露的其他指标。
如果路由标签不再是 80 个模板,而是 80,000 个实际路径,在其他假设不变时,理论组合规模还会扩大 1,000 倍。
需要注意,乘法结果是“所有组合都出现时”的估算,不代表 Prometheus 会提前创建整个笛卡尔积。实际数量取决于应用真正暴露并被采集到的组合。
这个推演的意义是:审查一个 Histogram,不能只问“配置了几个桶”,还必须检查它与多少组业务标签、多少个实例相乘。
高基数与序列抖动,不是同一个问题
高基数描述的是存量:当前存在很多不同的时间序列。
序列抖动描述的是变化:不断有新序列出现,旧序列又停止更新。
例如,把容器标识、任务执行编号或请求编号放进标签,即使某个时刻看到的业务规模并不大,也可能持续制造新的序列身份。
对 Prometheus 来说,新增序列和向已有序列追加样本是不同的操作。其自身提供了 Head 中序列总数,以及创建、移除序列的累计计数器。
假设 Prometheus 自监控的 job 名称是 prometheus,可以分别查询:
prometheus_tsdb_head_series{job="prometheus"}
rate(
prometheus_tsdb_head_series_created_total{job="prometheus"}[5m]
)
第一条观察 Head 中的序列存量,第二条观察最近五分钟的新增速度。它们都应该与发布、扩缩容和业务变化放在同一时间轴上分析。
仅看存量可能漏掉持续的创建与淘汰;仅看新增速度,也不能说明历史积累已经清理完毕。
使用 Remote Write 时,还需要留意额外成本。Prometheus 会维护序列编号与标签值之间的映射,序列抖动会增加相关内存压力,发送分片及其队列也需要内存。远端发送跟不上时,可以检查 prometheus_remote_storage_samples_pending。
在应用侧,Micrometer 的 Meter 同样以名称和标签维度区分。动态标签不仅影响 Prometheus,也可能先让应用注册出大量 Meter。
graph LR
A["原始路径或业务编号进入标签"] --> B["应用产生大量 Meter"]
B --> C["抓取样本与新增序列增加"]
C --> D["Prometheus 存储与索引承压"]
D --> E["查询成本上升"]
C --> F["Remote Write 缓存与队列承压"]
因此,只给 Prometheus 增加内存,未必能消除整个链路的问题。
沿着目标、指标、标签逐层定位
下面假设被排查服务的 job 名称为 order-api。实际使用时,需要替换为自己的 job 和指标名称。
从抓取目标判断异常范围
先检查哪个实例暴露的样本最多:
topk(
10,
scrape_samples_scraped{job="order-api"}
)
再检查最近一次抓取新增序列较多的目标:
topk(
10,
scrape_series_added{job="order-api"}
)
这两个指标的含义不同:scrape_samples_scraped 表示目标暴露的样本数量,scrape_series_added 表示本次抓取新增序列数量的近似值。后者不是累计计数器,不应该直接对它使用 rate()。
判断时要结合上下文。新实例刚启动,第一次抓取出现大量新增序列并不意外;如果实例长期稳定运行,业务维度也没有扩张,却持续产生大量新序列,就值得检查动态标签。
用 TSDB 统计找出主要贡献者
Prometheus 的 TSDB 状态接口可以返回序列和标签基数统计:
set -o pipefail
curl -fsS \
--connect-timeout 3 \
--max-time 20 \
'http://127.0.0.1:9090/api/v1/status/tsdb?limit=10' \
| jq '.data | {
headStats,
seriesCountByMetricName,
labelValueCountByLabelName,
memoryInBytesByLabelName
}'
其中,headStats 用于查看 Head 概况,seriesCountByMetricName 帮助定位序列数量较多的指标,labelValueCountByLabelName 帮助识别取值过多的标签。
memoryInBytesByLabelName 尤其容易被误读。它按标签值的长度进行统计,不是完整的内存归因报告,不能据此断言“删除这个标签,进程就会释放同等大小的内存”。
这个接口适合低频排查,不适合在已经过载的实例上反复刷新。limit=10 限制的是返回条目数量,不应被当作服务端计算成本的保证。
缩小范围后,再运行聚合查询
当 Prometheus 仍有足够查询余量时,可以在指定 job 内统计序列较多的指标:
topk(
10,
count by (__name__) (
{job="order-api"}
)
)
定位到具体指标后,再检查可疑标签的不同取值数量:
count(
count by (uri) (
app_http_requests_total{
job="order-api",
uri!=""
}
)
)
内层按 uri 分组,外层计算分组数量。这里统计的是查询时刻可见的不同 URI,而不是这个指标在全部历史中出现过多少个 URI。count_values() 则统计不同样本值,并不是统计标签取值的替代写法。
也不要认为套了一层 topk(10, ...),查询就只需要处理十条序列。聚合前仍可能需要读取大量输入数据;Prometheus 官方也提醒,输出很少的聚合查询依然可能产生较高负载。
诊断顺序应当是缩小目标、缩小指标,再检查标签,而不是一开始就在整个实例上执行宽范围聚合。
止血时,最容易犯的错误是直接删除标签
发现 user_id 导致高基数后,一个直觉做法是通过 labeldrop 删除它。
问题在于:删除标签不是聚合数据。
假设同一次抓取中存在以下两个样本,其他标签完全相同:
| 指标 | user_id |
method |
值 |
|---|---|---|---|
app_http_requests_total |
u1 |
GET |
3 |
app_http_requests_total |
u2 |
GET |
7 |
删除 user_id 后,这两个样本会拥有相同的序列身份,但 Prometheus 不会自动把它们相加为 10。
labeldrop 的实现只是移除匹配的标签,没有累计、合并或聚合计数的逻辑。对不同计数器直接这样处理,可能造成重复样本冲突或破坏数据语义。
同样的问题也会出现在抓取阶段的路径替换上:把 /api/orders/1 和 /api/orders/2 都改成 /api/orders/{id},不等于把两个独立 Counter 正确聚合成一个 Counter。
只有确认移除标签后,所有样本仍然保持正确且唯一的身份,才适合使用这类处理。
紧急措施应该明确承认数据损失
当某个自定义指标已经威胁监控系统稳定性时,可以在确认告警和看板依赖后,暂时停止摄入这个指标。
以下片段添加到已经定位的 scrape job 中,不是替换整个 Prometheus 配置:
metric_relabel_configs:
- source_labels: [__name__]
regex: "app_http_requests_total"
action: drop
sample_limit: 30000
这个示例会丢弃整个 app_http_requests_total,不是降低其精度,也不是保留聚合结果。使用前需要明确受影响的观测能力,并为修复后的指标准备恢复路径。
metric_relabel_configs 在摄入前处理样本,所以它能够阻止这些样本进入存储,但不能消除应用生成指标、构造响应及传输响应的成本。
sample_limit: 30000 也不是“超过三万条以后,只丢弃多出来的部分”。它是每个目标、每次抓取在 metric relabeling 之后的样本数量限制;超限会让整次抓取被视为失败。这里的三万只是示例阈值,需要根据正常峰值与容量预算设置。
它还不能防止低速序列抖动:一个目标每次只暴露少量样本,但每次都换成全新的标签值,依然可能持续创建新序列。
修改配置后,检查并验证加载结果
可以先使用与运行环境版本匹配的 promtool 检查配置:
promtool check config /etc/prometheus/prometheus.yml
检查通过后,按部署方式触发配置重载。已经启用 --web.enable-lifecycle 的实例,可以使用:
curl -fsS \
--max-time 10 \
-X POST \
http://127.0.0.1:9090/-/reload
HTTP 重载接口默认关闭;发送 SIGHUP 也是官方支持的重载方式。配置语法检查通过,只代表配置有效,仍需验证抓取状态和实际过滤效果。
在 Java 源头,让标签集合有上界
采集侧止血之后,真正的修复点应该回到埋点。
对于 HTTP 指标,可以制定一个明确约束:进入 MeterRegistry 的路由只能来自已知模板,请求方法只能来自固定集合,未知输入统一进入有限的兜底分类。
归一化应发生在注册和更新 Meter 之前。这样,不同请求会累加到同一个计数器,而不是先生成大量计数器,再尝试修改它们的名字。
下面是一份不依赖 Spring 的 Micrometer 示例:
import io.micrometer.core.instrument.MeterRegistry;
import java.util.Objects;
import java.util.Set;
public final class ApiRequestMetrics {
public static final String METER_NAME =
"app.http.request.results";
private static final Set<String> ROUTES = Set.of(
"/api/orders",
"/api/orders/{id}",
"/api/products/{id}"
);
private static final Set<String> METHODS = Set.of(
"GET", "POST", "PUT", "PATCH",
"DELETE", "HEAD", "OPTIONS"
);
private final MeterRegistry registry;
public ApiRequestMetrics(MeterRegistry registry) {
this.registry = Objects.requireNonNull(
registry, "registry must not be null"
);
}
public void record(
String matchedRouteTemplate,
String requestMethod,
int statusCode
) {
String route =
matchedRouteTemplate != null
&& ROUTES.contains(matchedRouteTemplate)
? matchedRouteTemplate
: "UNMATCHED";
String method =
requestMethod != null
&& METHODS.contains(requestMethod)
? requestMethod
: "OTHER";
String statusClass =
statusCode >= 100 && statusCode < 600
? (statusCode / 100) + "xx"
: "OTHER";
registry.counter(
METER_NAME,
"route", route,
"method", method,
"status_class", statusClass
).increment();
}
}
matchedRouteTemplate 应当来自框架已经匹配到的路由模板,而不是直接使用用户请求的完整路径。示例中的允许列表需要与实际接口定义同步。
即使调用方误传了大量不同的未知路径,也只会落入 UNMATCHED,不会按原始字符串继续扩展。
在没有额外动态公共标签的前提下,这段代码的标签组合上界为:
这里分别包括路由、方法和状态分类的兜底值。实际注册量则取决于哪些组合被调用。
示例有意使用了新的指标名称。真实改造中,如果标签结构和统计语义已经发生变化,可以通过新旧指标迁移来更新看板与告警,避免让同一个名称在发布前后代表不同口径。
MeterFilter 是护栏,不是业务归一化的替代品
Micrometer 提供了 maximumAllowableTags,可以限制匹配指标某个标签的取值数量:
import io.micrometer.core.instrument.config.MeterFilter;
// 在相关业务 Meter 注册之前完成配置。
registry.config().meterFilter(
MeterFilter.maximumAllowableTags(
ApiRequestMetrics.METER_NAME,
"route",
128,
MeterFilter.deny()
)
);
这段配置限制的是匹配范围内 route 的取值数量,不是整个系统的时间序列数量。达到限制后使用 deny(),新 Meter 会被拒绝,并返回不记录数据的 NOOP Meter;它不会把超额数据自动合并进 OTHER。
因此,不能把“监控内存不涨了”直接视为修复成功。它也可能意味着一部分指标已经不再记录。
同时,不宜把这个过滤器当作严格的并发配额系统。其实现采用检查已见集合后再添加的方式;真正稳定的维度上界,应该由前面的允许列表与归一化逻辑保证。
如果增加拒绝计数或诊断日志,记录本身也应该使用固定维度,不能又把被拒绝的原始 URI 填进告警指标标签。
回归测试要验证“不会增长”,也要验证“没有漏记”
只检查接口返回正常,覆盖不到高基数问题。
测试需要不断输入新的业务数据,然后验证 Meter 数量是否稳定,以及预期计数是否完整。
下面的测试使用 JUnit Jupiter 和 Micrometer 的 SimpleMeterRegistry:
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ApiRequestMetricsTest {
@Test
void dynamicInputsMustNotCreateUnboundedMeters() {
try (SimpleMeterRegistry registry =
new SimpleMeterRegistry()) {
ApiRequestMetrics metrics =
new ApiRequestMetrics(registry);
for (int i = 0; i < 10_000; i++) {
metrics.record(
"/api/orders/{id}",
"GET",
200
);
metrics.record(
"/unknown/" + i,
"GET",
404
);
}
assertEquals(
2,
registry.find(
ApiRequestMetrics.METER_NAME
).counters().size()
);
double unmatchedCount = registry.get(
ApiRequestMetrics.METER_NAME
)
.tags(
"route", "UNMATCHED",
"method", "GET",
"status_class", "4xx"
)
.counter()
.count();
assertEquals(
10_000.0,
unmatchedCount,
0.0
);
double total = registry.find(
ApiRequestMetrics.METER_NAME
)
.counters()
.stream()
.mapToDouble(Counter::count)
.sum();
assertEquals(
20_000.0,
total,
0.0
);
}
}
}
这组断言提出了两个要求:不同的未知路径只能进入有限的兜底维度;所有预期事件仍然被记录,不能仅靠拒绝 Meter 来维持数量稳定。
SimpleMeterRegistry 将 Meter 的值保存在内存中,不负责向 Prometheus 导出数据。因此,这个测试验证的是应用侧行为,不等于完整的采集链路测试。
集成测试仍应检查实际导出内容、Histogram 的扩展情况,以及 Prometheus 附加的目标标签。
降采样、预聚合和原生直方图,各有边界
延长抓取周期,减少的是样本频率
把抓取周期从 30 秒延长到 60 秒,可以减少单位时间内采集的样本,但如果目标持续暴露同样的标签组合,这些序列身份依然存在。
Prometheus 的存储文档也区分了减少序列数量和延长抓取间隔,并指出减少序列数量往往更有效。
因此,抓取周期适合根据指标变化速度和告警要求调整,不适合用来掩盖无界标签。
Recording Rule,不会替换已经摄入的原始序列
记录规则会预先计算表达式,并把结果保存成新的时间序列。这可以减少看板重复执行昂贵查询的成本,但不会自动停止采集、删除或替换原始高基数指标。
例如,把按用户区分的请求量聚合为按服务统计,可能让看板查询变快,却没有解决按用户序列仍在进入 TSDB 的问题。
查询成本与摄入成本,需要分别治理。
Native Histogram,不能消除动态业务标签
原生直方图把计数、总和及桶信息放在复合样本中,相比经典 Histogram 的多条浮点序列表示方式,可以减少桶展开带来的成本。
但不同用户、不同请求编号或不同原始路径,仍然是不同的标签组合。根据 Prometheus 的序列身份规则,换一种 Histogram 表示方式,并不会让这些组合自动消失。
原生直方图解决的是直方图表示与处理效率,标签治理解决的是维度空间。两者可以配合,不能互相替代。
验收标准不能只有“内存降了”
一次完整的高基数修复,至少需要同时观察增长、完整性和历史回收。
增长是否受控。 在正常预热和发布变化之外,业务不断产生新的订单或请求时,不应再按业务编号持续创建新的 Meter。Prometheus 的新增序列速度应当回到与接口数量、实例变化相匹配的水平。
采集是否完整。 检查 up 是否正常,并对比 scrape_samples_scraped 与 scrape_samples_post_metric_relabeling。如果只在抓取侧丢弃指标,前者可能仍然很高;如果修复已经进入应用源头,目标暴露的样本数量也应得到控制。
还要验证请求总数、失败数量和延迟指标仍符合预期,避免把触发 sample_limit、拒绝 Meter 或误删指标造成的数据缺失,当成资源优化成果。
历史是否逐步退出。 停止暴露旧序列,不代表它们立即从全部存储结构中消失。序列在查询中被标记为过期,与 Head、持久化块及相关数据的回收不是同一件事。进程重启也可能需要回放 WAL,因此不应把反复重启当作标签治理方案。
判断修复效果,应先确认新增来源已经被切断,再观察存量如何随系统正常运行逐步变化,而不是规定“配置重载后几分钟内,RSS 必须下降多少”。
业务编号可以不断增加,监控维度却应该有明确的规模预算与生命周期。让请求继续累加到有限、稳定、语义清楚的指标中,才是高基数治理真正要完成的工作。

评论区 1