指标与日志是对已观测行为的不同表示,并不是互相竞争的真相定义。指标通常在一组维度下汇总测量结果,日志记录则描述一个事件,并能够携带更具体的上下文。只要数据覆盖与语义支持,两者都可以回答聚合问题。不能因为某张仪表盘更整齐,就认为它天然更权威;也不能因为日志字段很多,就默认其中包含完整且可直接比较的业务总体。

对于结账服务,一个已完成请求计数器可以把每个最终结果归为成功或错误。路由、结果等少量有界维度,方便持续比较流量与失败比例。诊断日志则可以利用关联编号、错误类型和依赖响应,解释某一次失败请求的具体原因。这两类信息互相帮助,但它们采用的分母仍然需要分别确定。共享服务名称或时间图形,并不足以证明两份数据统计了相同的请求集合。

先决定测量包含什么

累计计数器本身不是每秒请求数。实际速率查询需要跨时间观测,并考虑进程重启等导致的计数重置。不过,一个起点、终点和成员都明确的完整合成批次,可以直接计数。不能悄悄把生产中的时间窗口速率换成两个无关快照的比例,也不能把经过选择的事件流与覆盖全部请求的计数器混为同一个统计总体。计算形式相似,并不代表时间口径已经一致。

日志也不是只能靠人逐条阅读。只要终态事件结构稳定且记录完整,就能聚合出与请求计数器相同的总量。选择时需要考虑事件体积、索引、查询延迟、保留期限和维护复杂度。指标经常适合反复执行的汇总查询,但如果每个请求都生成一个新的标签值,指标存储也可能迅速变得昂贵。究竟哪种方式成本较低,取决于实际维度和使用方式,而不是单凭指标或日志这个名称判断。

坏掉的样子

假设一百个已完成请求里有四个失败。为了排查问题,日志系统或人工查询特意保留其中五个请求,里面包含两个失败。完整总体的失败比例是百分之四,被选日志的失败比例则是百分之四十。两次算术运算都没有错,但把后者宣称为整个服务的失败率就错了,因为这五条记录既不是全部请求,也没有证明是能够代表总体的样本。排障数据的选择目的必须与统计推断分开。

把重试过程中的失败次数当成最终失败请求数,也会造成类似误差:同一个请求的一次尝试可能失败,随后却成功完成。日志缺失、重复投递、时间窗口不同和路由归类不一致,都可能令看起来相关的图表互相矛盾。用于可靠性计算时,应明确每个请求只有一个最终结果,同时保留尝试级证据用于诊断。发现不一致后,不能只改图表标题,或者简单平均两个比例来掩盖口径差异。

快速验证

将代码保存为 metrics-logs.py,使用 Python 3,并把官方 promtool 可执行程序放入 PATH。本次完整运行采用 Python 3.12.14 与 promtool 3.14.0。真实校验器从标准输入读取生成的指标文本,不建立服务器、容器或网络连接。合成请求集合按构造保证完整,诊断日志子集则是刻意选取,并非随机抽样。程序同时输出实际工具版本,避免把历史容器版本误写成这次使用的版本。

import json
import subprocess
from collections import Counter

# A complete synthetic population, with exactly one terminal outcome per request.
requests = [{"request_id": f"r{i}", "route": "/checkout",
             "outcome": "error" if i in {7, 18, 53, 94} else "success"}
            for i in range(1, 101)]
counter = Counter(request["outcome"] for request in requests)
selected_logs = [request for i, request in enumerate(requests, 1)
                 if i in {1, 7, 18, 33, 55}]
logged_errors = sum(record["outcome"] == "error" for record in selected_logs)
text = "# HELP demo_requests_total Completed synthetic requests.\n# TYPE demo_requests_total counter\n"
for outcome, count in sorted(counter.items()):
    text += f'demo_requests_total{{route="/checkout",outcome="{outcome}"}} {count}\n'
version = subprocess.run(["promtool", "--version"], capture_output=True, text=True, check=True).stdout.splitlines()[0]
result = subprocess.run(["promtool", "check", "metrics"], input=text,
                        text=True, capture_output=True, timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
population_rate = counter["error"] / len(requests)
selected_rate = logged_errors / len(selected_logs)
assert population_rate == 0.04 and selected_rate == 0.4
assert "request_id" not in text and len(counter) == 2
# Full event coverage would recover the same aggregate; these five logs cannot.
assert sum(r["outcome"] == "error" for r in requests) == counter["error"]
print(json.dumps({"requests": len(requests), "errors": counter["error"],
                  "population_error_rate": population_rate, "exposed_metric_series": len(counter),
                  "selected_logs": len(selected_logs), "selected_log_errors": logged_errors,
                  "selected_log_error_rate": selected_rate,
                  "exposition_validated_by": version}, indent=2))

输出应包含一百个请求、四个错误及零点零四的总体比例。筛选后的五条日志中有两个错误,得到零点四。导出文本只有两条按结果区分的计数序列,标签中不包含请求编号。Promtool 负责验证格式,Python 断言负责验证算术结果和事先定义的成员关系。这不是实际抓取流程、计数器重置处理或存储费用基准测试,因此不能把格式检查通过扩大为整套监控链路已经验证。

实际排查时,可以先用有界指标维度确定受影响的路由、结果或部署,再沿关联编号进入日志或链路,寻找单个请求的上下文。普通指标标签通常不应加入请求编号,除非已有明确的基数预算;当整套技术栈支持时,也可以通过示例数据关联追踪,而不把每个请求都变成独立序列。最后应把事件完整性、选择规则与派生比例的说明放在一起,让后来阅读图表的人能够重现它的单位、总体和时间口径。

延伸阅读

来源