一、引言
监控系统突然告警:Prometheus 内存占用超过 30GB,还在持续上涨。
排查结果:500 万个活跃时间序列,根因是错误地把 userId、orderId 作为 Prometheus label,导致每个用户/订单产生独立的时间序列。
修复后:内存从 30GB+ 降至 4GB。
二、事件回顾
2.1 告警触发
监控告警:
┌─────────────────────────────────────────────────────┐
│ │
│ 告警名称:Prometheus 内存使用率过高 │
│ 触发时间:2024-01-15 02:30:00 │
│ 当前值:32GB / 64GB (50%) │
│ 阈值:> 80% │
│ 趋势:持续上涨 │
│ │
└─────────────────────────────────────────────────────┘
2.2 紧急处理
# 查看 Prometheus 状态
curl http://localhost:9090/api/v1/status/config
# 查看当前时间序列数
curl http://localhost:9090/api/v1/query \
-d 'query=prometheus_tsdb_head_series'
# 结果:5,234,567 个时间序列
2.3 分析工具
# 使用 promtool 分析 TSDB
promtool tsdb analyze /path/to/prometheus/data
# 结果摘要:
Found 5,234,567 head series
Top label pairs by value count:
- label_name: "userId"
value_count: 4,891,234
series_count: 4,891,234
- label_name: "orderId"
value_count: 156,789
series_count: 156,789
- label_name: "status"
value_count: 3
series_count: 3
- label_name: "endpoint"
value_count: 12
series_count: 12
分析:
| 标签 | 值数量 | 时间序列数 | 问题 |
|---|---|---|---|
| userId | 4,891,234 | 4,891,234 | ❌ 高基数,每个用户一个时间序列 |
| orderId | 156,789 | 156,789 | ❌ 高基数,每个订单一个时间序列 |
| status | 3 | 3 | ✅ 低基数 |
| endpoint | 12 | 12 | ✅ 低基数 |
三、根因分析
3.1 时间序列爆炸的数学原理
时间序列数量 = 各标签值数量的笛卡尔积
示例:
endpoint: 12 个值
status: 3 个值
userId: 4,891,234 个值
总时间序列数 = 12 × 3 × 4,891,234 = 176,084,424
这就是为什么内存会爆炸!
3.2 内存计算公式
每个时间序列占用内存 ≈ 1-2KB
500 万个时间序列 × 1.5KB/个 ≈ 7.5GB
加上 TSDB head block 开销、索引等 → 30GB+
3.3 错误代码示例
错误做法:把 userId 作为 label
// 错误示例 1:Micrometer
@Slf4j
@Service
public class OrderService {
@Autowired
private MeterRegistry meterRegistry;
public OrderDTO createOrder(OrderCreateRequest request) {
Counter.builder("order.create")
.tag("userId", request.getUserId().toString()) // ❌ 高基数标签
.tag("productId", request.getProductId().toString()) // ❌ 高基数标签
.tag("status", "success")
.register(meterRegistry)
.increment();
return doCreate(request);
}
}
// 错误示例 2:原生 Prometheus Client
@Slf4j
@Service
public class OrderService {
private static final Counter ORDER_CREATE = Counter.build()
.name("order_create_total")
.labelNames("userId", "productId", "status") // ❌ 高基数标签
.help("Total number of orders created")
.register();
public OrderDTO createOrder(OrderCreateRequest request) {
ORDER_CREATE.labels(
request.getUserId().toString(), // ❌ 每个用户一个时间序列
request.getProductId().toString(), // ❌ 每个商品一个时间序列
"success"
).inc();
return doCreate(request);
}
}
产生的时间序列:
order_create_total{userId="1001",productId="2001",status="success"}
order_create_total{userId="1002",productId="2001",status="success"}
order_create_total{userId="1001",productId="2002",status="success"}
order_create_total{userId="1003",productId="2003",status="success"}
...
4,891,234 × 156,789 × 3 = 无数个时间序列
四、正确做法
4.1 标签只放低基数维度
// 正确示例 1:Micrometer
@Slf4j
@Service
public class OrderService {
@Autowired
private MeterRegistry meterRegistry;
public OrderDTO createOrder(OrderCreateRequest request) {
// 只使用低基数标签
Counter.builder("order.create")
.tag("endpoint", "/api/order")
.tag("status", "success")
.tag("instance", "order-service-01")
.register(meterRegistry)
.increment();
// 高基数维度放到日志里
log.info("订单创建成功,userId={}, orderId={}",
request.getUserId(), request.getOrderId());
return doCreate(request);
}
}
// 正确示例 2:原生 Prometheus Client
@Slf4j
@Service
public class OrderService {
private static final Counter ORDER_CREATE = Counter.build()
.name("order_create_total")
.labelNames("endpoint", "status", "instance") // ✅ 低基数标签
.help("Total number of orders created")
.register();
public OrderDTO createOrder(OrderCreateRequest request) {
ORDER_CREATE.labels("/api/order", "success", "order-service-01").inc();
// 高基数维度放到日志里
log.info("订单创建成功,userId={}, orderId={}",
request.getUserId(), request.getOrderId());
return doCreate(request);
}
}
产生的时间序列:
order_create_total{endpoint="/api/order",status="success",instance="order-service-01"}
order_create_total{endpoint="/api/order",status="failure",instance="order-service-01"}
order_create_total{endpoint="/api/order",status="success",instance="order-service-02"}
...
12 endpoints × 3 status × 10 instances = 360 个时间序列
4.2 高基数维度的处理方式
高基数维度处理方式:
┌─────────────────────────────────────────────────────┐
│ │
│ 方式 1:放到日志里(推荐) │
│ ├── 使用 ELK/Loki 聚合分析 │
│ └── 通过 TraceId 关联日志和指标 │
│ │
│ 方式 2:使用 Exemplar(仅用于 histogram) │
│ ├── 附加到 histogram 的某个 bucket │
│ └── 用于关联 TraceId,不是存储高基数数据 │
│ │
│ 方式 3:使用专门的高基数存储 │
│ ├── Tempo(追踪) │
│ ├── Loki(日志) │
│ └── ClickHouse(分析) │
│ │
└─────────────────────────────────────────────────────┘
4.3 Exemplar 使用示例
注意:Exemplar 仅用于 histogram 的 trace ID 关联,不是存储高基数数据的通用方案。
// 原生 Prometheus Client Exemplar 示例
@Slf4j
@Service
public class OrderService {
private static final Histogram ORDER_CREATE_DURATION = Histogram.build()
.name("order_create_duration_seconds")
.labelNames("endpoint", "status")
.help("Duration of order creation")
.register();
public OrderDTO createOrder(OrderCreateRequest request) {
long start = System.currentTimeMillis();
try {
OrderDTO result = doCreate(request);
// 使用 histogram 记录耗时,并附加 trace ID 作为 exemplar
ORDER_CREATE_DURATION.labels("/api/order", "success")
.observeWithExemplar(
(System.currentTimeMillis() - start) / 1000.0,
Exemplar.builder()
.setLabel("traceId", MDC.get("traceId"))
.build()
);
return result;
} catch (Exception e) {
ORDER_CREATE_DURATION.labels("/api/order", "failure")
.observeWithExemplar(
(System.currentTimeMillis() - start) / 1000.0,
Exemplar.builder()
.setLabel("traceId", MDC.get("traceId"))
.setLabel("error", e.getMessage())
.build()
);
throw e;
}
}
}
五、标签基数最佳实践
5.1 低基数 vs 高基数
标签基数分类:
┌─────────────────────────────────────────────────────┐
│ │
│ 低基数标签(推荐作为 label) │
│ ├── 接口名(endpoint) │
│ ├── 实例名(instance) │
│ ├── 状态码(status) │
│ ├── 环境(env) │
│ ├── 数据中心(dc) │
│ └── 集群(cluster) │
│ │
│ 高基数标签(不推荐作为 label) │
│ ├── 用户 ID(userId) │
│ ├── 订单 ID(orderId) │
│ ├── 商品 ID(productId) │
│ ├── 请求 ID(requestId) │
│ ├── IP 地址(ip) │
│ └── 会话 ID(sessionId) │
│ │
└─────────────────────────────────────────────────────┘
5.2 标签数量限制
标签数量限制:
1. 每个指标的标签数量 ≤ 5 个
2. 每个标签的值数量 ≤ 100 个
3. 总时间序列数 ≤ 100 万(根据服务器内存调整)
5.3 监控告警
# Prometheus 告警规则
groups:
- name: prometheus.rules
rules:
- alert: PrometheusHighSeriesCount
expr: prometheus_tsdb_head_series > 1000000
for: 5m
labels:
severity: warning
annotations:
summary: "Prometheus 时间序列数量过高"
description: "当前时间序列数: {{ $value }}, 超过阈值: 1000000"
- alert: PrometheusHighMemoryUsage
expr: (node_memory_MemUsed_bytes / node_memory_MemTotal_bytes) * 100 > 80
for: 5m
labels:
severity: critical
annotations:
summary: "Prometheus 内存使用率过高"
description: "当前内存使用率: {{ $value }}%"
六、修复效果
6.1 修复前后对比
| 指标 | 修复前 | 修复后 | 改善 |
|---|---|---|---|
| 时间序列数 | 5,234,567 | 360 | -99.99% |
| 内存占用 | 32GB | 4GB | -87.5% |
| GC 频率 | 每 30 秒一次 | 每 5 分钟一次 | -90% |
| 查询响应时间 | 5-10s | < 100ms | -98% |
6.2 修复步骤
修复步骤:
┌─────────────────────────────────────────────────────┐
│ │
│ 1. 紧急处理 │
│ ├── 删除包含高基数标签的指标 │
│ └── 重启 Prometheus(清理内存) │
│ │
│ 2. 代码修复 │
│ ├── 移除高基数标签(userId、orderId 等) │
│ ├── 添加低基数标签(endpoint、status、instance) │
│ └── 高基数维度放到日志里 │
│ │
│ 3. 监控告警 │
│ ├── 添加时间序列数告警 │
│ └── 添加内存使用率告警 │
│ │
│ 4. 持续监控 │
│ ├── 观察时间序列数变化 │
│ └── 定期使用 promtool 分析 │
│ │
└─────────────────────────────────────────────────────┘
七、总结
7.1 核心教训
核心教训:
1. 标签只放低基数维度(接口名、实例、状态码等)
2. 高基数维度(userId、orderId 等)放到日志里
3. Exemplar 仅用于 histogram 的 trace ID 关联
4. 监控时间序列数,设置告警阈值
5. 定期使用 promtool tsdb analyze 检查
7.2 公式总结
公式总结:
时间序列数 = 各标签值数量的笛卡尔积
内存占用 ≈ 时间序列数 × 1-2KB/个
低基数标签:值数量 ≤ 100 个
高基数标签:值数量 > 1000 个(不推荐作为 label)
7.3 检查清单
检查清单:
┌─────────────────────────────────────────────────────┐
│ │
│ ✅ 每个指标的标签数量 ≤ 5 个? │
│ ✅ 每个标签的值数量 ≤ 100 个? │
│ ✅ 总时间序列数 ≤ 100 万? │
│ ✅ 没有把 userId、orderId 等作为 label? │
│ ✅ 监控了 prometheus_tsdb_head_series? │
│ ✅ 设置了时间序列数告警? │
│ │
└─────────────────────────────────────────────────────┘
💡 互动话题:你在项目中遇到过 Prometheus 时间序列爆炸的问题吗?是怎么解决的?欢迎在评论区分享!
