Administrator
Administrator
发布于 2026-11-03 / 0 阅读
0
0

Prometheus 从入门到生产实践(04):业务埋点实战:请求次数、延迟与并发如何观测

本篇是《Prometheus 从入门到生产实践》第 4 / 24 篇。配置以文中版本与环境前提为准,实验和故障均为教学场景。完整程序、配置及校验范围见配套实验包。

抓到指标只是第一步,指标是否表达了正确业务含义更加重要。本篇提供一个本机教学服务,把请求次数、耗时分布和正在处理的请求数连起来,作为后续 PromQL 的统一数据源。

业务事件到 Counter、Histogram 与 Gauge

1. 先定义观测边界

业务端点是 /api;?fail=1 模拟 HTTP 500,?slow=1 增加约 0.4 秒等待,普通请求约等待 0.02 秒。/metrics 本身不计入业务请求,避免采集动作反过来污染业务流量。

请求结束时增加 counter 并观察耗时;处理期间增加 inflight gauge,结束后减少。指标表达的是这个教学处理边界,不是完整网络往返或数据库事务耗时。

标签 method 固定 GET,route 固定模板 /api,code 固定两种。不能直接把 query、原始 URL、用户名或异常全文放成标签。

2. 完整教学服务

保存包中的 demo_exporter.py,使用 Python 3 运行:

"""Loopback-only instructional metrics endpoint; use an official client SDK in production."""
import argparse
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

LOCK = threading.Lock()
COUNTS = {'200': 0, '500': 0}
DURATIONS = {code: {'buckets': [0, 0, 0], 'sum': 0.0, 'count': 0} for code in COUNTS}
BUCKETS = [0.1, 0.3, 1.0]
INFLIGHT = 0


def metrics():
    with LOCK:
        lines = ['# HELP ops_demo_requests_total Completed instructional requests.',
                 '# TYPE ops_demo_requests_total counter']
        for code, count in COUNTS.items():
            lines.append(f'ops_demo_requests_total{{method="GET",route="/api",code="{code}"}} {count}')
        lines += ['# HELP ops_demo_request_duration_seconds Instructional request duration.',
                  '# TYPE ops_demo_request_duration_seconds histogram']
        for code, values in DURATIONS.items():
            labels = f'method="GET",route="/api",code="{code}"'
            for upper, count in zip(BUCKETS, values['buckets']):
                lines.append(f'ops_demo_request_duration_seconds_bucket{{{labels},le="{upper}"}} {count}')
            lines.append(f'ops_demo_request_duration_seconds_bucket{{{labels},le="+Inf"}} {values["count"]}')
            lines.append(f'ops_demo_request_duration_seconds_sum{{{labels}}} {values["sum"]}')
            lines.append(f'ops_demo_request_duration_seconds_count{{{labels}}} {values["count"]}')
        lines += ['# HELP ops_demo_inflight_requests Current instructional requests.',
                  '# TYPE ops_demo_inflight_requests gauge',
                  f'ops_demo_inflight_requests {INFLIGHT}']
        return ('\n'.join(lines) + '\n').encode()


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        global INFLIGHT
        if self.path == '/metrics':
            body, status, kind = metrics(), 200, 'text/plain; version=0.0.4; charset=utf-8'
        elif self.path.split('?', 1)[0] == '/api':
            from urllib.parse import parse_qs, urlsplit
            query = parse_qs(urlsplit(self.path).query)
            status = 500 if query.get('fail') == ['1'] else 200
            started = time.perf_counter()
            with LOCK:
                INFLIGHT += 1
            try:
                time.sleep(0.4 if query.get('slow') == ['1'] else 0.02)
            finally:
                elapsed = time.perf_counter() - started
                with LOCK:
                    INFLIGHT -= 1
                    COUNTS[str(status)] += 1
                    histogram = DURATIONS[str(status)]
                    histogram['count'] += 1
                    histogram['sum'] += elapsed
                    for index, upper in enumerate(BUCKETS):
                        histogram['buckets'][index] += int(elapsed <= upper)
            body, kind = json.dumps({'status': status}).encode(), 'application/json'
        else:
            body, status, kind = b'not found\n', 404, 'text/plain'
        self.send_response(status)
        self.send_header('Content-Type', kind)
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        try:
            self.wfile.write(body)
        except ConnectionError:
            pass

    def log_message(self, *args):
        pass


if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--port', type=int, default=19100)
    args = parser.parse_args()
    ThreadingHTTPServer(('127.0.0.1', args.port), Handler).serve_forever()
python3 demo_exporter.py
curl --max-time 3 http://127.0.0.1:19100/api
curl --max-time 3 'http://127.0.0.1:19100/api?fail=1&slow=1'
curl --max-time 3 http://127.0.0.1:19100/metrics

注意第二条业务请求的 HTTP 500 是故意产生的。curl 默认可能仍返回成功传输退出码;若用它判断健康,需要另行指定失败策略。

3. 手写格式是为了理解,生产优先 SDK

程序显式输出 HELP、TYPE、标签和数值,设置 exposition Content-Type,并以换行结束。直方图桶为累计计数,+Inf 与 count 相同;sum 保存观察值总和。

这里用锁避免多个请求同时更新共享计数,桶、总和和次数增量维护,不保留所有请求的明细,内存不会因每次请求多存一个对象。

不过手写服务没有完整的生产部署、认证、优雅终止、采样或库生态。真实业务优先使用 官方客户端库 提供的计数和序列化能力,再把业务定义测清楚。格式参考 exposition formats。

4. 让数据可以重复生成

配套 traffic.py 会产生有限次数的请求:

python3 traffic.py --count 60 --interval 0.1

在干净服务中运行一次,预期总业务次数为 60,其中 500 为 12 次;慢请求共 6 次。具体落入哪个有限桶受实际调度影响,所以要根据观测的时间与桶计数判断,不把 sleep 值当作无误差测量。

短流量结束后,Counter 总值保留,rate 在窗口逐渐滑过请求后会下降,最后可能为零。这不是数据被删除,而是窗口里没有新增请求。进程重启又会使计数从头开始,后续查询需要处理 reset。

5. 指标验收不只看有没有 HELP

先检查 sum(ops_demo_requests_total{job="demo"}),再按 code 分组。检查每个 code 的 count 与对应 counter 是否一致、桶是否不递减、+Inf 是否等于 count、sum 是否非负。

确认 /metrics 抓取不会增加业务 counter。再同时发多个慢请求,观察 inflight 临时升高并最终回到零。Gauge 卡住不归零可能是异常路径漏了收尾,不能总靠面板把数值截断为零。

原生直方图具有不同的样本表示与协议协商条件,本服务只导出经典直方图。不要把原生 histogram 查询复制过来却期待它自动生成原生数据。

6. 练习:为指标写一份契约

写出每项指标的名字、单位、标签、增长或减少时机、重启行为,以及不包含什么。用固定请求数验证 counter,用一次慢请求验证桶累积。

如果要增加 POST,先决定 method 维度;如果新增路由,先规范化模板;如果要记录异常原因,先评估有限分类与日志关联,而不是把所有错误文字变成标签。


上一篇:时间序列、类型与标签:指标设计先控制基数 · 系列目录 · 下一篇:Exporter与黑盒探测:主机正常不代表用户路径正常


评论