
Prometheus高基数时间序列性能问题从Label治理到Recording Rules的端到端优化方案一、当Prometheus内存从8GB膨胀到64GB高基数问题的雪崩式扩散Prometheus运维中最隐秘的性能杀手不是磁盘空间不足也不是查询超时而是高基数时间序列High Cardinality Time Series引发的一系列连锁反应。每条时间序列由指标名 Labels键值对唯一确定当某个Label的值空间过大时时间序列数量呈爆炸式增长。一个典型的反例在Prometheus中http_requests_total{user_id...}这样的指标如果Label包含用户ID一个有100万用户的系统就会创建100万条唯一时间序列。Prometheus需要为每条时间序列维护内存中的索引结构倒排索引 时间序列元数据100万条时序大约需要2GB内存。问题是这种基数膨胀很容易突破到千万级别——如果在多个Label上同时出现高基数如user_id×session_id×request_id时序数量会以笛卡尔积的方式指数增长。高基数问题的连锁反应包括Prometheus OOM导致查询不可用、Remote Write下游存储如VictoriaMetrics写入延迟飙升、Grafana面板加载超时、告警规则评估延迟从秒级退化为分钟级、TSDB compaction因内存不足而挂起。本文将围绕Label治理和Recording Rules两面大旗给出端到端的优化方案。graph TD A[Prometheus Scrapebr/指标采集] -- B{Label基数检查} B --|高基数Labelbr/如user_id| C[Label重写规则br/relabel_configs] B --|正常基数| D[Prometheus TSDBbr/时序存储] C -- E[聚合降基数br/drop/hashmod/labeldrop] C -- F[Recording Rulesbr/预计算聚合] E -- D D -- G[内存索引膨胀] G --|OOM危险| H[增加内存/] F -- I[Remote Writebr/降采样后发送] I -- J[长期存储br/VictoriaMetrics/Thanos] subgraph LOSS[高基数治理四步法] K1[1. 识别tsdb analyze] K2[2. 治理relabel_configs] K3[3. 聚合Recording Rules] K4[4. 长期Remote Write降采样] end style B fill:#fff3cd,stroke:#ffc107 style G fill:#ffebee,stroke:#f44336 style J fill:#e8f5e9,stroke:#4caf50 style LOSS fill:#e3f2fd,stroke:#2196f3二、高基数问题的底层原理与诊断方法Prometheus TSDB的存储模型可以用三层结构来理解最上层是Head Block内存中的WAL 热数据中间层是Compacted Blocks磁盘上的持久化块最外层是索引文件index存储Label到时间序列的映射。高基数问题首先击穿的是Head Block的内存索引。每条时间序列在内存中需要存储Label name/value字符串、倒排索引的Posting List指针、当前时间窗口内的chunk引用。单条时序的内存开销约2KB1000万条时序需要约20GB内存——已经超出了大多数Prometheus实例的配置。当Head Block超过配置的内存限制时Prometheus会被OS OOM Killer杀掉。诊断高基数问题的第一步是量化。Prometheus自带的tsdb analyze工具可以分析数据目录中的Label基数分布# 分析Prometheus数据目录输出Label基数统计 promtool tsdb analyze /var/lib/prometheus/data/ # 输出示例 # Label Name Unique Values Series Count % of Total # ------------------------------------------------------------ # instance 50 50000 5.2% # job 10 50000 5.2% # user_id 250000 500000 52.1% ← 高基数 # request_id 450000 500000 48.3% ← 高基数 # pod 2000 80000 8.3%另一个有效的诊断方式是通过PromQL查询实时基数。以下查询可以快速定位高基数Label# 统计每个Label的唯一值数量近似值依赖topk采样 # 找出基数最高的10个指标 topk(10, count by(__name__)({__name__~.})) # 统计特定指标的Label组合数量 # 如果返回数 10000需要警惕 count({__name__http_requests_total}) # 按Label名统计唯一值数量 # 替换 label_name 为需检查的Label count(count by(label_name)({__name__~.}))三、Label治理与Recording Rules的工程实践3.1 采集层Label治理从源头掐断高基数Label治理的第一道防线是采集配置中的relabel_configs。以下是一个完整的治理配置涵盖高基数常见场景的处理# prometheus.yml — 采集层Label治理配置 scrape_configs: - job_name: kubernetes-pods kubernetes_sd_configs: - role: pod # relabel_configsLabel治理核心 relabel_configs: # 场景1直接丢弃高基数Label # 如 pod_uid、container_id 等唯一标识符 - source_labels: [__meta_kubernetes_pod_uid] target_label: pod_uid replacement: # 空值丢弃该Label - action: labeldrop regex: container_id|pod_ip|host_ip # 场景2使用hashmod降基数 # 将用户ID通过哈希分桶从100万降至100 - source_labels: [user_id] target_label: user_bucket regex: (.) replacement: ${1} # 注意hashmod需要在metric_relabel_configs中实施 # 此处先保留原始Label # 场景3保留业务维度丢弃请求级唯一标识 - source_labels: [__name__] regex: http_request_duration_.* action: keep - source_labels: [request_id, trace_id, span_id] regex: .* action: labeldrop # 场景4仅保留关键Label白名单模式 # 适用于已经知道需要哪些Label的场景 # - action: labelkeep # regex: (job|instance|namespace|pod|service|__name__) # metric_relabel_configs指标级后处理 metric_relabel_configs: # hashmod降基数 # 将 user_id 哈希到100个桶中 - source_labels: [user_id] target_label: user_bucket regex: (.) modulus: 100 action: hashmod # 删除原始高基数Label避免双重存储 - source_labels: [user_id] regex: .* action: labeldrop # 聚合替换将methodPOST/PUT/PATCH归为write - source_labels: [method] target_label: method_group regex: (POST|PUT|PATCH|DELETE) replacement: write - source_labels: [method] target_label: method_group regex: (GET|HEAD|OPTIONS) replacement: read # 场景5防止恶意高基数 # 限制Label值的最大长度防止URL参数被打入Label - source_labels: [__name__] regex: (.) target_label: __name__ replacement: ${1}3.2 查询层治理Recording Rules预计算聚合Recording Rules是解决高基数数据查询而非高基数数据存储问题的利器。当原始数据因为业务需要必须保留高基数Label时通过Recording Rules提前预计算聚合结果让Grafana面板和告警规则查询聚合后的低基数时序# recording_rules.yml — 预计算聚合规则 groups: # 组1全局服务级聚合5分钟窗口 - name: service_aggregations_5m interval: 5m # 每5分钟评估一次 rules: # QPS按service和method_group聚合 - record: job:http_requests_total:rate5m expr: | sum by(service, method_group) ( rate(http_requests_total[5m]) ) # P50/P90/P99延迟聚合 - record: job:http_request_duration_seconds:quantiles5m expr: | histogram_quantile(0.5, sum by(service, le) ( rate(http_request_duration_seconds_bucket[5m]) ) ) labels: quantile: p50 - record: job:http_request_duration_seconds:quantiles5m expr: | histogram_quantile(0.99, sum by(service, le) ( rate(http_request_duration_seconds_bucket[5m]) ) ) labels: quantile: p99 # 组2错误率聚合1分钟窗口 - name: error_rate_aggregation_1m interval: 1m rules: - record: job:http_errors_total:rate1m expr: | sum by(service, status_group) ( rate(http_requests_total{status~5..}[1m]) ) # 错误率百分比在聚合后计算避免高基数除法 - record: job:http_error_rate:ratio1m expr: | job:http_errors_total:rate1m / job:http_requests_total:rate5m # 组3资源使用率聚合 - name: resource_aggregations interval: 5m rules: # 按 namespace deployment 聚合CPU使用量 - record: namespace:container_cpu_usage:sum5m expr: | sum by(namespace, deployment) ( rate(container_cpu_usage_seconds_total[5m]) ) # 按 namespace deployment 聚合内存使用量 - record: namespace:container_memory_usage:sum5m expr: | sum by(namespace, deployment) ( container_memory_working_set_bytes )Recording Rules命名规范社区推荐的命名格式为level:metric:operations如job:http_requests_total:rate5m。这有助于在PromQL查询中快速区分原始指标和聚合指标。以下Python脚本用于分析Recording Rules的有效性和查询覆盖范围#!/usr/bin/env python3 Prometheus Recording Rules 覆盖分析脚本 分析Grafana面板和告警规则中使用的PromQL查询 检查是否有对应的Recording Rules可以替代高基数查询。 import re import json from typing import List, Dict, Set, Tuple from pathlib import Path import logging logging.basicConfig(levellogging.INFO) logger logging.getLogger(__name__) class RecordingRuleAnalyzer: Recording Rules与查询的覆盖分析器 分析流程 1. 解析Recording Rules文件提取所有rule的名称和表达式 2. 解析Grafana Dashboard JSON提取所有PromQL查询 3. 检查每个查询是否可使用现有Recording Rule替代 4. 对高基数查询标记告警 # 识别高基数风险的模式 HIGH_CARDINALITY_PATTERNS [ # 包含用户/请求级别Label的聚合 rby\s*\(.*user.*\), rby\s*\(.*request.*\), rby\s*\(.*session.*\), rby\s*\(.*trace.*\), # 不含聚合的直接查询 r^(?![^{]*\{[^}]*\}[^{]*\{)[^s]*\{[^}]*\}, ] def __init__(self, rules_dirs: List[str]): Args: rules_dirs: Recording Rules文件所在目录列表 self.rules_dirs rules_dirs self.rules: Dict[str, str] {} # rule_name - expr self.uncovered_queries: List[str] [] def parse_rules(self) - int: 解析所有Recording Rules文件 yaml_files [] for rules_dir in self.rules_dirs: path Path(rules_dir) if not path.exists(): logger.warning(f规则目录不存在{rules_dir}) continue yaml_files.extend(path.rglob(*.yml)) yaml_files.extend(path.rglob(*.yaml)) if not yaml_files: logger.warning(未找到任何YAML规则文件) return 0 # 简化的YAML解析生产环境建议使用PyYAML rule_pattern re.compile( r- record:\s*(.)\n\s*expr:\s*\|\s*\n\s*(.*?)(?\n\s*$|\n\slabels:), re.DOTALL, ) inline_pattern re.compile( r- record:\s*(.)\n\s*expr:\s*(.), ) for yaml_file in yaml_files: try: content yaml_file.read_text(encodingutf-8) except (IOError, UnicodeDecodeError) as e: logger.error(f读取文件失败 {yaml_file}: {e}) continue # 匹配多行表达式 for match in rule_pattern.finditer(content): name match.group(1).strip() expr match.group(2).strip() self.rules[name] expr # 匹配单行表达式 for match in inline_pattern.finditer(content): name match.group(1).strip() expr match.group(2).strip() self.rules[name] expr logger.info(f解析到 {len(self.rules)} 条Recording Rules) return len(self.rules) def analyze_dashboard(self, dashboard_path: str) - List[Dict]: 分析Grafana Dashboard中的查询 Args: dashboard_path: Grafana Dashboard JSON文件路径 Returns: 分析结果列表每条包含查询和建议 findings [] try: with open(dashboard_path, r, encodingutf-8) as f: dashboard json.load(f) except (json.JSONDecodeError, IOError) as e: logger.error(f解析Dashboard失败 {dashboard_path}: {e}) return findings # 递归提取所有PromQL表达式 queries self._extract_promql(dashboard) for query in queries: # 检查是否为高基数查询 risk_score self._assess_cardinality_risk(query) if risk_score 0: continue # 低基数跳过 # 尝试匹配现有的Recording Rule alternatives self._find_rule_alternatives(query) findings.append({ query: query[:120], # 截断过长查询 risk_level: high if risk_score 1 else medium, alternatives: alternatives, suggestion: ( 建议创建Recording Rule预计算此查询的聚合结果 在Grafana面板中替换原始查询 ) if not alternatives else ( f已存在可替代的Recording Rule f{, .join(alternatives)} ), }) return findings def _extract_promql(self, obj) - List[str]: 递归提取嵌套结构中的PromQL表达式 queries [] if isinstance(obj, dict): # 检查是否为Prometheus数据源面板 if obj.get(targets): for target in obj[targets]: expr target.get(expr, ) if expr and not expr.startswith($): queries.append(expr) # 递归子对象 for v in obj.values(): queries.extend(self._extract_promql(v)) elif isinstance(obj, list): for item in obj: queries.extend(self._extract_promql(item)) return queries def _assess_cardinality_risk(self, query: str) - int: 评估查询的高基数风险等级 Returns: 0 低风险, 1 中风险, 2 高风险 risk 0 # 检查是否按高基数维度聚合 for pattern in self.HIGH_CARDINALITY_PATTERNS: if re.search(pattern, query, re.IGNORECASE): risk 1 # 检查是否使用了histogram_quantile通常伴随高基数 if histogram_quantile in query: risk 1 # 检查查询范围是否过大 if re.search(r\[[1-9]\d[mh], query): risk 1 return min(risk, 2) def _find_rule_alternatives(self, query: str) - List[str]: 查找可以替代当前查询的Recording Rules alternatives [] # 提取查询中的指标名 metric_match re.findall( r([a-zA-Z_:][a-zA-Z0-9_:]*(?::[a-zA-Z0-9_])*), query, ) query_metrics set(metric_match) for rule_name, rule_expr in self.rules.items(): # 提取Recording Rule中的指标名 rule_metrics set( re.findall( r([a-zA-Z_:][a-zA-Z0-9_:]*(?::[a-zA-Z0-9_])*), rule_expr, ) ) # 如果原始查询的指标在Recording Rule的输出中 if rule_name in query or query_metrics rule_metrics: alternatives.append(rule_name) return alternatives[:5] # 最多返回5个候选 if __name__ __main__: analyzer RecordingRuleAnalyzer( rules_dirs[/etc/prometheus/rules/] ) analyzer.parse_rules() # 分析Grafana Dashboard findings analyzer.analyze_dashboard( dashboard.json ) # 输出分析报告 high_risk [f for f in findings if f[risk_level] high] if high_risk: logger.warning(f发现 {len(high_risk)} 个高风险查询) for f in high_risk: logger.warning( f - 查询: {f[query]}...\n f 建议: {f[suggestion]} ) else: logger.info(所有查询无高基数风险)四、优化策略的副作用与长期维护成本高基数治理存在明确的权衡。Recording Rules的双刃剑效应Recording Rules虽然减少了查询时的基数但自身也创造新的时间序列。如果Recording Rule的输出Label维度过多问题会从查询慢转移到存储膨胀。必须严格控制Recording Rules的by子句——建议聚合后的Label数不超过3-4个且每个Label的值空间不超过100。数据精度损失的不可逆性labeldrop和hashmod操作是不可逆的——一旦在采集层丢弃了user_id下游存储中再也无法还原按用户的详细数据。这种精度损失可能在某次排障中成为障碍能不能把用户12345过去十分钟的QPS单独拉出来看看。缓解方案是分层存储Prometheus保留最近2小时的完整数据含全量LabelRemote Write到VictoriaMetrics时再降维。relabel_configs的性能开销每条scrape的指标都会经过metric_relabel_configs处理。对于大metrics量的job过于复杂的relabel规则会增加Prometheus的CPU开销。建议每个job的relabel规则控制在20条以内复杂逻辑通过Recording Rules而非relabel处理。不适用场景极少数场景下高基数Label恰恰是排障的核心信息——如在APM场景中trace_id对于链路追踪是必需的。此时不应在Prometheus中存储带trace_id的指标而应该使用Tempo/Jaeger等专用链路追踪系统仅在Prometheus中通过exemplar记录trace_id的映射关系。五、总结Prometheus高基数问题的治理需要分层施策采集层用relabel_configs掐断源头存储层用Recording Rules预计算降维查询层强制使用聚合指标替代原始查询长期存储层按需降采样。实施路线建议分四步。第一步运行promtool tsdb analyze诊断当前的高基数Label分布建立基线。第二步在采集配置中实施labeldrop和hashmod剔除无排障价值的唯一标识Label将核心Label的基数控制在1000以内。第三步为高频查询的Grafana面板创建Recording Rules将面板数据源从原始指标切换到聚合指标。第四步在VictoriaMetrics或Thanos中配置长期存储的降采样规则如按小时和天聚合进一步控制历史数据的存储成本。监控高基数也需要可观测性。在Prometheus中通过prometheus_tsdb_head_series指标监控当前的时间序列总数设置告警阈值如超过500万条触发告警将高基数遏制在演变为OOM之前。Label治理本质上是在数据的可查询性和系统的可运维性之间寻找平衡点。过度治理会丧失排障所需的细粒度数据治理不足会让Prometheus沦为不可用的黑盒——这个平衡点需要根据团队的实际排障需求和系统容量持续校准。