本文目录导读:

在 ThinkPHP 项目中实现链路追踪与日志关联,主要目的是在微服务或分布式系统中,将一次请求经过的所有服务、所有日志串联起来,方便排查问题。
核心思路是:生成一个全局唯一的 TraceID(链路ID),并将其注入到日志、缓存、数据库等所有记录中。
以下是针对 ThinkPHP 项目的完整实施方案,由浅入深,分为 轻量级 和 重量级 两种方案。
轻量级方案(推荐大多数项目使用)
适用场景:单体应用,或基于 HTTP 内部调用的中小型项目,无需引入额外的庞大依赖。
核心步骤:生成 TraceID -> 存入上下文(Context) -> 日志绑定 -> 跨服务传递。
生成并管理 TraceID
创建中间件,在请求进入时生成或获取 TraceID。
文件:app/middleware/TraceMiddleware.php
<?php
declare(strict_types=1);
namespace app\middleware;
use Closure;
use think\facade\Request;
use think\Response;
use think\helper\Str;
class TraceMiddleware
{
// 用于存储当前请求的 TraceID(进程内单例)
protected static $traceId = '';
public static function getTraceId(): string
{
return self::$traceId;
}
public function handle($request, Closure $next)
{
// 1. 尝试从 Header 中获取上游传递的 TraceID (分布式追踪关键)
$traceId = $request->header('X-Trace-Id', '');
// 2. 如果没有,则生成一个新的
if (empty($traceId)) {
$traceId = strtoupper(Str::uuid()); // 生成无横线UUID
}
// 3. 存储到静态属性(或使用 think\facade\Context 容器)
self::$traceId = $traceId;
// 4. 绑定到请求对象(便于在控制器中调用)
$request->traceId = $traceId;
/** @var Response $response */
$response = $next($request);
// 5. 将 TraceID 通过响应头返回给前端,方便定位问题
$response->header(['X-Trace-Id' => $traceId]);
return $response;
}
}
注册全局中间件
在 app/middleware.php 中注册:
return [
// 全局请求缓存等...
\app\middleware\TraceMiddleware::class,
];
日志关联(关键步骤)
ThinkPHP 使用 think\log\Channel 或 think\Log,我们需要重写日志格式,将 TraceID 注入。
文件:config/log.php
return [
'default' => 'file',
'channels' => [
'file' => [
'type' => 'File',
'path' => runtime_path() . 'logs',
'single' => false,
'json' => false, // 建议改成 true 输出结构化日志,方便ELK采集
'format' => '[%s][%s] %s', // 这行可能被覆盖,见下方详解
'realtime_write' => true,
// 核心:自定义日志处理器
'processor' => [app\common\LogProcessor::class, 'process'],
],
],
];
注意:TP8 的标准写法是在 config/log.php 中定义 processors,我们定义一个自定义处理器。
文件:app/common/LogProcessor.php
<?php
declare(strict_types=1);
namespace app\common;
use app\middleware\TraceMiddleware;
class LogProcessor
{
/**
* 处理日志记录,注入上下文信息
*/
public function __invoke(array $record): array
{
// 注入 TraceID
$record['context']['trace_id'] = TraceMiddleware::getTraceId();
// 注入用户ID(如果有登录用户)
if (function_exists('get_current_user_id')) {
$record['context']['user_id'] = get_current_user_id();
}
// 注入请求路径
$record['context']['path'] = request()->baseUrl();
$record['context']['method'] = request()->method();
return $record;
}
}
输出效果(JSON格式):
{
"message": "数据库查询失败",
"context": {
"trace_id": "8F2B1C...",
"user_id": 123,
"path": "/api/user/info"
}
}
跨服务传递(HTTP Client)
在调用下游服务时,必须手动携带 X-Trace-Id 头,否则链路就断了。
使用 ThinkPHP 自带 HttpClient (TP8):
use think\facade\Http;
$response = Http::get('http://internal-service/api/data', [
// 携带 TraceID 头
'X-Trace-Id' => TraceMiddleware::getTraceId(),
]);
如果使用 Guzzle:
$client = new Client(['headers' => ['X-Trace-Id' => TraceMiddleware::getTraceId()]]);
数据库 SQL 日志关联(可选进阶)
在开发环境或需要排查慢查询时,让 SQL 日志也带上 TraceID,只需在数据库配置中开启日志,并依赖上面的 LogProcessor 即可,因为 ThinkPHP 的 SQL 日志会通过 Log::write() 写入,自动带上 context。
重量级方案(引入专用链路追踪系统)
适用场景:微服务架构、需要可视化链路树、跨语言多团队协作。
推荐组件:
- SkyWalking(推荐中国开发者使用,支持 PHP Agent,但配置较重)。
- Jaeger / Zipkin(OpenTracing 标准,适合有 Swoole 常驻内存环境)。
- Dapper/鹰眼 等自研系统。
集成步骤(以 OpenTracing + Jaeger 为例,适用于Swoole常驻内存模式):
- 安装:
composer require jonahgeorge/jaeger-client-php - 初始化 Tracer:在 Swoole 启动事件或
App::initialize时创建全局 Tracer。 - 拦截中间件:
- 请求开始:
$span = $tracer->startSpan('RequestHandler'); - 注入:将 SpanContext 注入到 HTTP Headers。
- 请求结束:
$span->finish(); $tracer->flush();
- 请求开始:
- 日志集成:将
TraceID和SpanID输出到日志中。
// 伪代码示例
$spanContext = $span->getContext();
Log::info('业务处理', ['trace_id' => $spanContext->getTraceId(), 'span_id' => $spanContext->getSpanId()]);
实战:如何排查问题(工作流)
假设用户反馈“订单支付失败”。
- 定位入口:查看 Nginx 或前端网络请求,找到
X-Trace-Id头(假设值为A1B2C3)。 - 全局搜索:在日志系统(ELK)中搜索
trace_id: A1B2C3。 - 时间线分析:
- 看到 10:00:00 日志:
订单服务 - 接收请求,参数:xxx - 看到 10:00:01 日志:
支付服务 - 回调失败,错误:xxx - 看到 10:00:01 日志:
订单服务 - 更新状态为失败
- 看到 10:00:00 日志:
- 串联上下文:通过 TraceID,即使流量经过了 3 个不同服务,也能通过一个搜索词完整看到调用链,而不用盲目翻各个服务器日志。
最佳实践建议
-
使用 JSON 日志格式:配置
'json' => true,这极大方便了 Loki/ELK 的字段索引,不要用纯文本正则解析。 -
必须传递 Header:在你的所有 HTTP 客户端封装类中,统一注入
X-Trace-Id,不要依赖开发人员手动在业务代码中加。 -
日志级别标准化:链路追踪需要完整记录
请求开始、外部调用、SQL查询、异常堆栈,建议配合以下结构:Log::channel('business')->info('order.create.started', ['order_sn' => $sn]); // 业务名词做Message Log::channel('sql')->debug('find_user'); // SQL记录 -
不要过度设计:对于多数 TP 单体项目,方案一 加上 ELK 已经非常够用,完全没必要引入 Zipkin,链路追踪的核心是定位慢在哪和影响范围有多大,而不是为了炫技。
附加题:使用 Swoole 常驻内存时的注意事项
如果使用 ThinkPHP Swoole,由于一个 Worker 进程会处理多个请求,LogProcessor 中的静态变量 $traceId 容易被串数据(并发时)。
解决办法:
- 使用 协程上下文
\Swoole\Coroutine::getContext()持有 TraceID,而不是静态变量。 - 或者在
TraceMiddleware中,请求开始时写入,请求结束时($response->send()后)必须清空TraceMiddleware::$traceId = '',防止污染下一个请求。
修改中间件如下:
public function handle($request, Closure $next)
{
// 使用协程上下文存储
if (class_exists('\Swoole\Coroutine')) {
\Swoole\Coroutine::getContext()['trace_id'] = $traceId;
}
// ... 逻辑相同
}
在 LogProcessor 中获取时也改为从协程上下文取。