文章 601
评论 5
浏览 235761
Prometheus 内存占用爆炸——metric 标签滥用血案

Prometheus 内存占用爆炸——metric 标签滥用血案

一、引言

监控系统突然告警: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

分析

标签值数量时间序列数问题
userId4,891,2344,891,234❌ 高基数,每个用户一个时间序列
orderId156,789156,789❌ 高基数,每个订单一个时间序列
status33✅ 低基数
endpoint1212✅ 低基数

三、根因分析

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,567360-99.99%
内存占用32GB4GB-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 时间序列爆炸的问题吗?是怎么解决的?欢迎在评论区分享!


标题:Prometheus 内存占用爆炸——metric 标签滥用血案
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/07/24/1784449985622.html
公众号:服务端技术精选

服务端开发博客:后端架构、高并发、性能优化与微服务实战教程

取消