Prometheus 序列由指标名与完整标签集合识别。在计数器上增加客户或请求身份,即使所有面板都会立刻把这些身份求和,也仍可能创建大量序列。查询聚合改变返回结果,不会删除已经入库的原始序列。存储和写入成本在用户打开面板之前就已经产生,因此不能只看面板返回多少行来判断仪表化是否足够节制。
统计实际组合,而不是孤立取值 一个新的标签值,不是在整个数据库中自动对应一条新序列。它在指标名及其他标签形成不同组合的位置上,分别构成新身份。在明确指标与目标范围内,可能取值数的乘积给出笛卡尔组合上界;只有每种组合实际出现时,它才等于观测数量。维度之间存在约束时,上界可能远大于现实。真实数量还受到目标标签与指标族形态影响,不能把理论乘积直接宣称为已经发生的序列数量。
来自少量路由模板的标签可以有边界,包含标识符和查询参数的完整 URL 往往没有。客户身份、请求编号、任意错误文本和时间戳也需要明确审视。带多个桶序列的直方图,还会进一步放大每组标签组合的影响。预算应依据真实输出和预期工作负载,而不是只看某一个维度有多少取值,也不能因为每条样本数值很小便忽略身份成本。
查询少几行,不代表少存几行 按路由求和的表达式,可以读取十二条客户级序列后只返回三个结果,但十二条输入仍然存在。如果指标契约需要的就是路由总量,仪表化可以直接维护路由级计数器,只导出三条序列。这是在源头减少输出身份,不是要求每个消费者都重复隐藏额外维度。两种方式可能显示相同总数,却具有不同的数据保留和成本含义。
入库时删除标签不等于完成上述累计。不同样本可能获得同一个身份,而重新标记不会替它们决定如何相加。应使用明确的仪表化变更,或定义清楚的记录查询;后者会新增派生序列,原始数据仍按照存储策略保留。确实需要详细关联时,可以放入日志或追踪,但它们同样需要各自的权限、保留期限与成本约束,不是完全没有代价的替代存储。
使用正确范围的清单诊断增长 TSDB 状态接口提供头部统计,其中包括按指标名汇总的序列数量。这些值有助于定位大型指标族,但不是完整成本模型。采样间隔、标签长度、身份更替、数据块处理、保留策略和存储行为都会影响资源。不能把一个小实验中减少的序列比例,直接换算成生产内存或磁盘会减少的相同比例,更不能把该比例当成容量压测结果。
当一些头部序列已经没有近期可选择样本时,即时查询计数与头部序列统计可能不同;历史存储块又是另一种范围。比较报告时应记录测量时刻和统计总体。某次发布改变标签值后,旧身份仍可能留在存储中,因此查询当前返回更少结果,不代表之前的全部成本都已经消失。需要分别观察新增速度、当前可见序列和保留历史。
坏掉的样子 新增无边界标签可能持续增加写入与内存压力,直到抓取或查询受到影响。应先定位指标族与仪表化改动,再从源头或经过审阅的样本过滤策略阻止不必要的新身份。立即删除历史数据不是所有场景都必须采用的恢复步骤,改变保留时间也不会瞬间清除全部资源成本。处理方式应依据实际瓶颈,而不是套用统一删除命令。
更隐蔽的失败是查询聚合很成功,面板看起来很小,但原始指标仍持续增长。应同时审阅导出身份和查询输出,为新增维度说明预期取值范围、有用消费者与负责人。生产计数器速率查询还应保留正确的重置处理;本实验用于比较固定总量的即时求和,不能代替先求速率再聚合的运行监控语义。
快速验证 保存程序为 cardinality.py,执行 python3 cardinality.py。需要 Docker 与 Prometheus 3.5.1,必要时下载镜像。本地文本端点为三个路由分别导出四个合成客户身份,同时独立导出已经累计好的路由总量。真实服务器抓取这些样本,并提供查询结果与 TSDB 统计,实验不会使用任何真实客户信息。
import http.client import io import json import subprocess import time import uuid from urllib.parse import quote name = "xbcatm-monitoring-" + uuid.uuid4().hex[:12] class ResponseSocket: def __init__(self, data): self.data = data def makefile(self, mode): return io.BytesIO(self.data) def request(method, path, body="", content_type="application/json"): body = body.encode() headers = (f"{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n" f"Connection: close\r\nContent-Type: {content_type}\r\n" f"Content-Length: {len(body)}\r\n\r\n").encode() result = subprocess.run( ["docker", "exec", "-i", name, "/bin/nc", "-w", "3", "127.0.0.1", str(port)], input=headers + body, capture_output=True, timeout=5, ) if result.returncode: raise RuntimeError(result.stderr.decode()) response = http.client.HTTPResponse(ResponseSocket(result.stdout)) response.begin() return response.status, response.read().decode() def get_json(path): status, body = request("GET", path) assert status == 200, (status, body) return json.loads(body) def eventually(check): for attempt in range(150): try: value = check() if value: return value except (RuntimeError, AssertionError, http.client.HTTPException): pass time.sleep(0.1) raise AssertionError("Expected local server state did not appear") def query(expression): response = get_json("/api/v1/query?query=" + quote(expression, safe="")) assert response["status"] == "success" return response["data"]["result"] def value(expression): result = query(expression) assert len(result) == 1, (expression, result) return float(result[0]["value"][1]) port = 9090 # Requires Docker and the Prometheus 3.5.1 image. script = r"""set -eu mkdir -p /tmp/www /tmp/targets cat > /tmp/prom.yml <<'PROM_CONFIG' global: scrape_interval: 1s scrape_timeout: 500ms scrape_configs: - job_name: cardinality metrics_path: /metrics.txt static_configs: - targets: ['127.0.0.1:8080'] PROM_CONFIG cat > /tmp/www/metrics.txt <<'METRIC_DATA' # TYPE raw_requests_total counter # TYPE bounded_requests_total counter raw_requests_total{route="/orders",customer_id="synthetic-1"} 1 raw_requests_total{route="/orders",customer_id="synthetic-2"} 2 raw_requests_total{route="/orders",customer_id="synthetic-3"} 3 raw_requests_total{route="/orders",customer_id="synthetic-4"} 4 bounded_requests_total{route="/orders"} 10 raw_requests_total{route="/cart",customer_id="synthetic-1"} 1 raw_requests_total{route="/cart",customer_id="synthetic-2"} 2 raw_requests_total{route="/cart",customer_id="synthetic-3"} 3 raw_requests_total{route="/cart",customer_id="synthetic-4"} 4 bounded_requests_total{route="/cart"} 10 raw_requests_total{route="/search",customer_id="synthetic-1"} 1 raw_requests_total{route="/search",customer_id="synthetic-2"} 2 raw_requests_total{route="/search",customer_id="synthetic-3"} 3 raw_requests_total{route="/search",customer_id="synthetic-4"} 4 bounded_requests_total{route="/search"} 10 METRIC_DATA /bin/httpd -f -p 127.0.0.1:8080 -h /tmp/www > /tmp/httpd.log 2>&1 & exec /bin/prometheus --config.file=/tmp/prom.yml --storage.tsdb.path=/tmp/data --web.listen-address=127.0.0.1:9090 """ try: subprocess.run( ["docker", "run", "-d", "--rm", "--name", name, "--network", "none", "--entrypoint", "/bin/sh", "prom/prometheus:v3.5.1", "-c", script], check=True, capture_output=True, text=True, timeout=20, ) eventually(lambda: len(query('raw_requests_total')) == 12) assert value('count(raw_requests_total)') == 12 assert value('count(bounded_requests_total)') == 3 assert value('sum(raw_requests_total)') == value('sum(bounded_requests_total)') == 30 raw_by_route = {x["metric"]["route"]: float(x["value"][1]) for x in query('sum by (route) (raw_requests_total)')} bounded_by_route = {x["metric"]["route"]: float(x["value"][1]) for x in query('bounded_requests_total')} assert raw_by_route == bounded_by_route statistics = get_json('/api/v1/status/tsdb')["data"] counts = {x["name"]: x["value"] for x in statistics["seriesCountByMetricName"]} assert counts["raw_requests_total"] == 12 and counts["bounded_requests_total"] == 3 print(json.dumps({"raw_series": 12, "bounded_series": 3, "snapshot_total": 30, "per_route": raw_by_route, "tsdb_counts_confirmed": True}, indent=2)) finally: subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 断言确认原始表示有十二条序列,受控表示有三条,两者总量都为三十,每个路由合计十。执行查询聚合之后,TSDB 仍报告十二条原始序列,证明返回结果变小没有擦除输入。隔离容器没有外部网络、宿主机挂载或公开端口,退出时删除。结果验证身份数量和算术,不是生产容量基准。
...