OpenTelemetry + Jaeger 自建链路追踪实战:一次请求慢在哪,瀑布图一眼揪出来

当用户说“网站偶尔很慢”,你怎么找到那一次请求

监控告警能告诉你“接口 P99 涨了”,日志能告诉你“某次请求报了 500”,但它们都回答不了一个更难的问题:那一次具体的、慢得离谱的请求,到底慢在哪个环节?是 Nginx 排队久,还是 PHP-FPM 池满,还是某条 SQL 卡了 900 毫秒?日志是散点,指标是聚合,只有“链路追踪(distributed tracing)”能把一次请求从入口到出口的每一步串成一条时间线。

链路追踪听起来像大厂专属,其实个人站长也能自建。核心工具是 OpenTelemetry(简称 OTel,负责采集与导出遥测数据的开放标准)加 Jaeger(负责接收、存储、展示 trace 的开源后端)。整套东西用 Docker 就能跑在一台 2G 内存的机器上。这篇文章讲清楚三个问题:trace 的基本模型是什么、怎么用 OTel 在 Nginx 和 PHP 里埋点、以及如何用 Jaeger 把慢请求一眼揪出来。全文以自建博客站的真实场景展开。

先搞懂 trace / span 这套模型

一次请求在系统里的完整旅程叫一个 trace,它由若干个 span 组成。每个 span 代表一个操作(比如“处理 HTTP 请求”“查询数据库”“调用外部 API”),带三样关键信息:开始时间、持续时长、以及它属于哪个父 span。span 之间靠 TraceID(整条链路的唯一标识)和 SpanID / ParentSpanID(父子关系)串联。

举个具体例子。用户访问 /article/1405.html,这条 trace 可能是这样的:

trace (TraceID=abc123, 总耗时 820ms)
├─ span: Nginx 接收请求          (5ms)
├─ span: PHP-FPM 处理            (810ms)
│   ├─ span: 路由与鉴权           (12ms)
│   ├─ span: 查询文章 SQL         (740ms)  ← 元凶在这
│   └─ span: 渲染模板             (50ms)
└─ span: 发送响应                (5ms)

一眼就能看出 740ms 花在了那条 SQL 上,这就是 trace 的价值——它把“慢”这个大问题,精准地归因到某一个 span。只要每台机器、每个服务在生成 span 时都把同一个 TraceID 往前传(这叫“上下文传播”,通常通过 HTTP 头 traceparent),跨服务的链路就能自动拼起来。

用 Docker 起一套 Jaeger

Jaeger 从 1.35 之后推荐用 all-in-one 镜像做单机部署,它把 collector、query、UI 和一个内存存储打包在一起,适合个人站。生产上要长期保存就把存储换成 Elasticsearch/Cassandra,但对排查偶发慢请求,内存存储配合采样的量级完全够用。

docker run -d --name jaeger \
  -e COLLECTOR_OTLP_ENABLED=true \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/all-in-one:1.53

端口含义:16686 是 Jaeger 的 Web UI,4317 是 OTLP gRPC 接收口,4318 是 OTLP HTTP 接收口。开 COLLECTOR_OTLP_ENABLED=true 是关键,否则新版 OTel 的 OTLP 数据发不进去。启动后访问 http://你的IP:16686,能看到 Jaeger 界面就成功了。

安全提醒:Jaeger UI 默认无鉴权,千万别把 16686 直接暴露公网。用 Nginx 加一层 basic auth,或者只监听内网、通过 SSH 隧道访问。采集端口 4317/4318 也建议只对内网开放。

在 PHP 应用里埋点:OTel SDK

多数 PHP 站点(包括 Typecho、自研框架)可以用官方 OTel PHP SDK。以 Composer 安装:

composer require open-telemetry/sdk \
  open-telemetry/exporter-otlp \
  open-telemetry/opentelemetry-auto-psr18

然后在应用入口(比如 index.php 的最前面)初始化 tracer:

<?php
use OpenTelemetry\API\Globals;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\Contrib\Otlp\SpanExporterFactory;
use OpenTelemetry\Contrib\Otlp\SpanProcessorFactory;

$exporter = (new SpanExporterFactory())->create();   // 读 OTEL_* 环境变量
$provider = TracerProvider::builder()
    ->addSpanProcessor((new SpanProcessorFactory())->create($exporter))
    ->getTracerProvider();
Globals::registerInitializer(fn() => $provider);

$tracer  = $provider->getTracer('blog-web');
$span    = $tracer->spanBuilder('http.request')->startSpan();
$span->setAttribute('http.url', $_SERVER['REQUEST_URI']);
// ... 执行你的业务 ...
$span->end();

关键的环境变量:

OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_SERVICE_NAME=blog-web
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_TRACES_SAMPLER=parentbased_tracesidratio
OTEL_TRACES_SAMPLER_ARG=0.1

最后两行是采样:只采 10% 的请求,避免 trace 数据把磁盘写爆。对排查偶发慢请求,10% 已经足够——真出问题时把比例临时调到 1.0 全量采即可。

SDK 会自动给 PSR-18 的 HTTP 客户端(Guzzle 等)插桩,所以调用外部 API 会生成子 span;数据库查询如果用的是 PDO,需要额外的 PDO 插桩包才能自动捕获 SQL span。用 setAttribute 把 SQL 语句、耗时、行数记进去,后面在 Jaeger 里就能直接看到哪条 SQL 拖后腿。

让 Nginx 与 PHP 共用一个 TraceID

日志和 trace 对不上,是新手最常见的痛点:日志里记了 request_id,Jaeger 里是 TraceID,两个系统各说各话。解决办法是统一 ID——让 Nginx 生成/透传 traceparent 头,PHP 端的 OTel SDK 复用它。

Nginx 侧用 $request_id 作为 trace 关联基础,透传给后端:

log_format json_trace escape=json
  '{"time":"$time_iso8601","rid":"$request_id",'
  '"traceparent":"$http_traceparent","uri":"$request_uri",'
  '"status":$status,"rt":$request_time}';

server {
    location ~ \.php$ {
        # 若上游未带 traceparent,用 request_id 兜底生成一个
        set $tp $http_traceparent;
        if ($tp = "") {
            set $tp "00-$request_id-0000000000000001-01";
        }
        proxy_set_header traceparent $tp;
        add_header X-Trace-Id $request_id;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        include fastcgi_params;
    }
}

这样日志里的 rid 或 traceparent 就能直接拿去 Jaeger 搜索框里查,从一个 slow span 反查全部相关日志,或者反过来从一条 5xx 日志跳进对应的 trace。这种方法不强求格式完全符合 W3C traceparent 规范,个人站做关联查询够用;如果要严格规范,用 Nginx 的 njs 或 OTel Collector 生成合法的 traceparent 更严谨。

用 OTel Collector 做中枢,别让每台机器直连 Jaeger

应用直接推 Jaeger 在单机场景没问题,但多台机器时建议加一层 OpenTelemetry Collector,好处是:应用只认一个本地接收地址,采集格式变更不影响应用;可以在 Collector 层统一做采样、脱敏、批量、再分发。最简 Collector 配置:

# otel-collector.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  batch:
    timeout: 5s
  attributes:
    actions:
      - key: http.client_ip
        action: delete   # 脱敏,别把真实 IP 存进 trace

exporters:
  otlp:
    endpoint: jaeger:4317
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch, attributes]
      exporters: [otlp]

batch 处理器把 span 攒批再发,显著降低网络与存储压力;attributes 用来删掉不该留的字段。跑法:

docker run -d --name otelcol \
  -v /opt/otel-collector.yaml:/etc/otelcol/config.yaml \
  -p 4317:4317 -p 4318:4318 \
  otel/opentelemetry-collector-contrib:0.94.0

应用的 OTEL_EXPORTER_OTLP_ENDPOINT 就指向这台 Collector,而不是直接指向 Jaeger。多台 VPS 上只装一个 Collector + 一个 OTel agent 也可以,但对个人站,直接在应用所在机器跑 Collector、转发到中心 Jaeger 更简单。

在 Jaeger UI 里定位慢请求

数据流起来之后,排查就顺手了。假设有人反馈“昨晚 23 点文章页卡了十几秒”,排查步骤:

第一,Jaeger 搜索框选 service blog-web、operation http.request,设置时间范围,把 Min Duration 填 2s,回车。这会筛出所有超过 2 秒的请求。

第二,点进最慢的那条 trace,看瀑布图(waterfall)。每个 bar 的长度就是 span 时长,一眼看出瓶颈在哪一层。常见形态有几种:PHP-FPM 处理 span 整体很长,说明后端计算或 IO 慢;某个 SQL span 单独突出,说明缺索引或锁等待;多个下游调用串行叠在一起,说明可以改并行。

第三,点击具体 span 看 tags,里面有你用 setAttribute 记下的 SQL、行数、错误码。配合前面 Nginx 日志里的 $request_id,去日志里把这次请求前后的上下文补齐。

第四,如果同一种慢请求反复出现,Jaeger 的 Compare 功能可以对比两条 trace,看差异出在哪个 span——这比对着两条日志肉眼找区别高效太多。

几个实战坑

1. 采样策略选错,导致“需要的时候没有数据”。固定比率采样(如 10%)简单,但你可能恰好没采到那次报错的请求。生产上更好的方案是“尾部采样”(tail sampling),由 OTel Collector 的 tail_sampling 处理器实现:先全部接住,再按规则决定留哪些——比如“所有 5xx 全留、所有超过 2 秒的慢请求全留、其余按 10% 抽”。代价是 Collector 需要缓存一段时间的数据,内存消耗更大。个人站在排查期可以临时全量采样。

2. 时钟不同步会让瀑布图错乱。span 的开始时间是各机器自己打的,如果机器之间时钟差几百毫秒,Jaeger 里父子 span 会出现诡异的重叠或负时长。务必保证所有机器都跑着 chrony/NTP 且时间同步正常。

3. 别把敏感信息塞进 span。OTel 默认可能会记录请求 URL、header,如果 URL 里带 token,或 header 里有 Cookie,全量进 trace 就是泄漏。用 Collector 的 attributes 处理器删除,或在应用里设置 OTEL_ATTRIBUTE_COUNT_LIMIT 并手动只记必要字段。

4. 存储会涨。Jaeger 内存存储进程一重启就清空,适合临时排查;要长期保存换 ES 存储并配好索引生命周期策略(ILM)。个人站更务实的做法是:内存存储 + 采样,只保留最近几小时的数据,够用就行。

5. 埋点带来的性能开销。每个请求都起 span、序列化、导出是有成本的。用批量导出 + 合理采样,开销通常在个位数百分比;但如果发现网站明显变慢,先怀疑是不是采样率设太高、或导出端阻塞了请求。OTel 的导出默认是异步的,正常不该阻塞业务。

小结

链路追踪补上了指标和日志之间缺失的那一环:它回答“这一次请求慢在哪”。OpenTelemetry 提供了统一的采集标准,Jaeger 提供了一个开箱即用的后端,两者用 Docker 加起来不到五分钟就能跑起来。对个人站长而言,真正要花心思的不是部署,而是埋点设计——span 要切在哪一层、哪些属性值得记、采样率怎么定。把这套东西搭好,下次再有人反馈“网站偶尔很慢”,你就不用凭猜,打开 Jaeger 按耗时排序,元凶就摆在那张瀑布图上了。

Last modification:October 5th, 2026 at 12:28 pm

Leave a Comment