本文目录导读:

- 为什么 PHP 需要 OpenTelemetry?——可观测性的痛点与救星
- OpenTelemetry 核心概念速览
- PHP 环境安装与扩展配置
- 手写第一个 Instrumentation:追踪一次 MySQL 查询
- 上下文传播:跨服务传递 Trace ID
- 导出数据到 Jaeger / Zipkin / 云端
- 实战问答:项目中的常见坑与性能优化
- 总结:从“能用”到“好用”的路线图
《PHP 可观测性实战:从零到一接入 OpenTelemetry 的完整指南》**
目录导读
- 为什么 PHP 需要 OpenTelemetry?——可观测性的痛点与救星
- OpenTelemetry 核心概念速览(Trace / Metric / Log)
- PHP 环境安装与扩展配置(Otlp + 自动注入)
- 手写第一个 Instrumentation:追踪一次 MySQL 查询
- 上下文传播:跨服务传递 Trace ID(HTTP Header)
- 导出数据到 Jaeger / Zipkin / 云端(gRPC vs HTTP)
- 实战问答:项目中的常见坑与性能优化
- 从“能用”到“好用”的路线图
开始**
为什么 PHP 需要 OpenTelemetry?——可观测性的痛点与救星
在现代微服务架构中,PHP 往往被贴上“胶水语言”或“传统 Web 脚本”的标签,但 Laravel、Symfony 等框架早已支撑起千万级 PV 的系统,当系统出现“某个接口慢 2 秒”时,传统日志只能告诉你“哪一行报错”,却无法告诉你“这一次请求在哪个服务、哪条 SQL、哪个 Redis 调用上耗费了时间”,这就是可观测性(Observability)要解决的问题。
OpenTelemetry(简称 OTel)是 CNCF 孵化项目,它统一了 Trace(链路追踪)、Metrics(指标)、Logs(日志)三大信号。对于 PHP 而言,OTel 解决了一个关键矛盾:语言生态的碎片化,以前你想接入 Zipkin 要用 Jaeger 客户端,想上报 Prometheus 又要写一个单独的 exporter,你只需要一套 SDK,通过 OTLP(OpenTelemetry Protocol)协议导出到任意后端。
OpenTelemetry 核心概念速览
在写代码之前,我们必须理解三个术语:
- Trace:一次用户请求从入口到出口的全过程,由多个 Span 组成,Span 是带有开始/结束时间、名称、属性(Attributes)的最小工作单元,一个
GET /user请求的 Trace 包含controllerSpan、mysql_querySpan、curl_external_apiSpan。 - Context(上下文):Trace 的传播载体,包含
TraceID和SpanID,使用W3C Trace Context规范,通过 HTTP Header 传递。 - SpanProcessor:负责在 Span 结束时处理它(如批处理、导出),PHP 是请求即生命周期的语言,注意进程内 Span 不能跨请求共享。
PHP 环境安装与扩展配置
你的 PHP 版本需要 4 以上(推荐 8.1+),我们使用官方推荐的 open-telemetry/opentelemetry-php 库,它依赖 gRPC 或 protobuf 扩展。
安装扩展(以 Ubuntu 为例)
# 安装 gRPC 和 protobuf 扩展 pecl install grpc protobuf echo "extension=grpc.so" >> /etc/php/8.1/cli/conf.d/grpc.ini echo "extension=protobuf.so" >> /etc/php/8.1/cli/conf.d/protobuf.ini # 验证 php -m | grep grpc
Composer 安装 SDK
composer require open-telemetry/sdk open-telemetry/opentelemetry-auto-php
初始化 SDK(建议放在公共入口文件)
<?php
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
use OpenTelemetry\SDK\Trace\Exporter\Otlp\OtlpHttpExporter;
$exporter = new OtlpHttpExporter('http://collector:4318/v1/traces');
$spanProcessor = new SimpleSpanProcessor($exporter);
$tracerProvider = new TracerProvider($spanProcessor);
$tracer = $tracerProvider->getTracer('my-php-app');
手写第一个 Instrumentation:追踪一次 MySQL 查询
我们不用侵入业务代码,直接在数据库连接层包装,以下是使用 PDO 的示例:
class TracingPDO extends PDO
{
private $tracer;
private $span;
public function __construct($dsn, $user, $pass, $tracer)
{
parent::__construct($dsn, $user, $pass);
$this->tracer = $tracer;
}
public function query($query, ...$args)
{
$span = $this->tracer->spanBuilder('mysql_query')
->setSpanKind(SpanKind::KIND_CLIENT)
->setAttribute('db.system', 'mysql')
->setAttribute('db.statement', $query)
->startSpan();
try {
$result = parent::query($query, ...$args);
$span->end();
return $result;
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus(StatusCode::STATUS_ERROR);
$span->end();
throw $e;
}
}
}
关键点:SpanKind::KIND_CLIENT 用于数据库/外部调用,可以标记为 “in-process” 或 “client” 类型,别忘了在 Try/Catch 中记录异常,否则 Trace 会“丢失”错误段。
上下文传播:跨服务传递 Trace ID
PHP 作为后端服务,接收前端或网关的请求时,必须从 HTTP Header 中提取 Trace 上下文,使用中间件更优雅:
// Laravel 中间件示例
use OpenTelemetry\Context\Context;
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
public function handle($request, Closure $next)
{
$carrier = $request->headers->all();
$context = TraceContextPropagator::getInstance()->extract($carrier);
Context::storage()->attach($context); // 将上下文附加到当前执行流
$response = $next($request);
// 响应头也带上 traceparent,方便前端/网关对齐
TraceContextPropagator::getInstance()->inject(
$response->headers->all(),
null,
$response->header('traceparent')
);
Context::storage()->detach();
return $response;
}
注意陷阱:PHP 进程模型下,若没有 Context::storage()->detach(),可能造成内存泄漏或上下文串号,特别是使用 Swoole 或 Workerman 常驻内存时,必须清理。
导出数据到 Jaeger / Zipkin / 云端
Otel 的牛逼之处在于“协议统一”,你不需要改业务代码,只需改变 Exporter 实例。
| 后端 | 协议/端点 | 推荐场景 |
|---|---|---|
| Jaeger | http://jaeger:4318/v1/traces |
私有化部署,UI 强大 |
| Zipkin | http://zipkin:9411/api/v2/spans |
传统 Java 团队共存 |
| 云端 | https://api.honeycomb.io/v1/traces |
SaaS 免运维,支持高基数 |
性能调优:不要用 SimpleSpanProcessor(同步阻塞),改用 BatchSpanProcessor,它将 Span 缓存在内存,每 5 秒或 5000 条批量推送,在 PHP-FPM 场景下,务必在请求结束前调用 TracerProvider::forceFlush() 确保 Span 完整导出。
$spanProcessor = new BatchSpanProcessor($exporter, new Clock(), 5, 2048, 5000);
实战问答:项目中的常见坑与性能优化
Q1:开启 OTel 后,接口响应时间变慢 30%,怎么回事?
答:最常见的是同步导出阻塞,检查 Exporter 是否使用 HTTP/2 gRPC(比 HTTP/1.1 快),并改用异步 BatchSpanProcessor,检查你的 SQL 中是否大量使用 setAttribute 记录大对象(如长 SQL),这会序列化增加开销,建议只保留必要属性,大数据放到 events 或 links 中。
Q2:为什么在 Laravel 中,跨控制器的 Span 会丢失?
答:这是因为你在构造函数中创建了 Tracer,但 Span 生命周期未正确作用域化,确保每个请求都从 Context::storage() 获取当前 Span,而不是用全局变量,推荐使用 Tracer::spanBuilder() 在真正需要时创建新 Span,并设置父 Span 为 Context::storage()->current()。
Q3:能不能只追踪错误请求,节省性能?
答:能,使用 Sampler 决策,ParentBased + TraceIdRatioBased(如只采样 10%),注意:这会导致低频故障可能被漏掉,建议在错误日志中显式增加 forceFlush 一个独立 Span 来补偿。
Q4:PHP-FPM 下的 Span 在请求结束后才 push,为什么 Jaeger 看不到?
答:因为 FPM 为每个请求创建新进程,进程退出时内存中的 Span 还没导出,解决方法有两个:一是注册 register_shutdown_function 调用 forceFlush;二是使用 swoole 常驻内存跑服务。
从“能用”到“好用”的路线图
第一步(本周):接入 opentelemetry-auto-php 自动探测库,它能自动为 Laravel/Symfony 生成框架级的 Trace(包括路由、控制器、数据库、HTTP 客户端),无需写一行业务代码,你就能在 Jaeger 看到完整的请求瀑布流。
第二步(本月):手动注入关键业务 Span(如“结算服务”“推送队列”),并使用 setAttribute 添加用户 ID、订单号等上下文信息。
第三步(长期):将 Metrics(如请求速率、DB 连接数)通过同一 Exporter 上报,实现在 Grafana 中统一看 Trace 和 Metrics。
最后提醒:可观测性不是“装了就完”,建议为每个 Trace 持续集成测试,在 CI 中跑一次请求并断言 TraceID 能正确生成且导出成功,避免升级依赖时静默失效。
本文所有域名均已替换为本地端口或通用占位符,实操时请替换为你的 Collector 真实地址。