Prometheus 序列由指标名与完整标签集合识别。在计数器上增加客户或请求身份,即使所有面板都会立刻把这些身份求和,也仍可能创建大量序列。查询聚合改变返回结果,不会删除已经入库的原始序列。存储和写入成本在用户打开面板之前就已经产生,因此不能只看面板返回多少行来判断仪表化是否足够节制。

统计实际组合,而不是孤立取值

一个新的标签值,不是在整个数据库中自动对应一条新序列。它在指标名及其他标签形成不同组合的位置上,分别构成新身份。在明确指标与目标范围内,可能取值数的乘积给出笛卡尔组合上界;只有每种组合实际出现时,它才等于观测数量。维度之间存在约束时,上界可能远大于现实。真实数量还受到目标标签与指标族形态影响,不能把理论乘积直接宣称为已经发生的序列数量。

来自少量路由模板的标签可以有边界,包含标识符和查询参数的完整 URL 往往没有。客户身份、请求编号、任意错误文本和时间戳也需要明确审视。带多个桶序列的直方图,还会进一步放大每组标签组合的影响。预算应依据真实输出和预期工作负载,而不是只看某一个维度有多少取值,也不能因为每条样本数值很小便忽略身份成本。

查询少几行,不代表少存几行

按路由求和的表达式,可以读取十二条客户级序列后只返回三个结果,但十二条输入仍然存在。如果指标契约需要的就是路由总量,仪表化可以直接维护路由级计数器,只导出三条序列。这是在源头减少输出身份,不是要求每个消费者都重复隐藏额外维度。两种方式可能显示相同总数,却具有不同的数据保留和成本含义。

入库时删除标签不等于完成上述累计。不同样本可能获得同一个身份,而重新标记不会替它们决定如何相加。应使用明确的仪表化变更,或定义清楚的记录查询;后者会新增派生序列,原始数据仍按照存储策略保留。确实需要详细关联时,可以放入日志或追踪,但它们同样需要各自的权限、保留期限与成本约束,不是完全没有代价的替代存储。

使用正确范围的清单诊断增长

TSDB 状态接口提供头部统计,其中包括按指标名汇总的序列数量。这些值有助于定位大型指标族,但不是完整成本模型。采样间隔、标签长度、身份更替、数据块处理、保留策略和存储行为都会影响资源。不能把一个小实验中减少的序列比例,直接换算成生产内存或磁盘会减少的相同比例,更不能把该比例当成容量压测结果。

当一些头部序列已经没有近期可选择样本时,即时查询计数与头部序列统计可能不同;历史存储块又是另一种范围。比较报告时应记录测量时刻和统计总体。某次发布改变标签值后,旧身份仍可能留在存储中,因此查询当前返回更少结果,不代表之前的全部成本都已经消失。需要分别观察新增速度、当前可见序列和保留历史。

每次抓取的样本上限是保护措施,不是选择性聚合。指标重新标记之后仍超过该上限时,Prometheus 会把整个抓取视为失败,并不是悄悄保留一个便宜的子集。因此,保护资源的同时也可能失去有用观测。应根据真实输出制定预算,并对抓取失败设置清楚的信号,不能把限制触发后的缺失误解成业务没有流量。

坏掉的样子

新增无边界标签可能持续增加写入与内存压力,直到抓取或查询受到影响。应先定位指标族与仪表化改动,再从源头或经过审阅的样本过滤策略阻止不必要的新身份。立即删除历史数据不是所有场景都必须采用的恢复步骤,改变保留时间也不会瞬间清除全部资源成本。处理方式应依据实际瓶颈,而不是套用统一删除命令。

更隐蔽的失败是查询聚合很成功,面板看起来很小,但原始指标仍持续增长。应同时审阅导出身份和查询输出,为新增维度说明预期取值范围、有用消费者与负责人。生产计数器速率查询还应保留正确的重置处理;本实验用于比较固定总量的即时求和,不能代替先求速率再聚合的运行监控语义。

快速验证

保存程序为 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 仍报告十二条原始序列,证明返回结果变小没有擦除输入。隔离容器没有外部网络、宿主机挂载或公开端口,退出时删除。结果验证身份数量和算术,不是生产容量基准。

本轮没有修改此运行实验,也没有重新执行。文中的预期输出和此前记录的观察,与本轮执行证据分别保留,不能据此声称真实集成已经再次通过。

来源