ThinkPHP项目链路追踪与日志关联

wen PHP项目 3

本文目录导读:

ThinkPHP项目链路追踪与日志关联

  1. 方案一:轻量级方案(推荐大多数项目使用)
  2. 方案二:重量级方案(引入专用链路追踪系统)
  3. 实战:如何排查问题(工作流)
  4. 最佳实践建议
  5. 附加题:使用 Swoole 常驻内存时的注意事项

在 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\Channelthink\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。


重量级方案(引入专用链路追踪系统)

适用场景:微服务架构、需要可视化链路树、跨语言多团队协作。

推荐组件

  1. SkyWalking(推荐中国开发者使用,支持 PHP Agent,但配置较重)。
  2. Jaeger / Zipkin(OpenTracing 标准,适合有 Swoole 常驻内存环境)。
  3. Dapper/鹰眼 等自研系统。

集成步骤(以 OpenTracing + Jaeger 为例,适用于Swoole常驻内存模式):

  1. 安装composer require jonahgeorge/jaeger-client-php
  2. 初始化 Tracer:在 Swoole 启动事件或 App::initialize 时创建全局 Tracer。
  3. 拦截中间件
    • 请求开始:$span = $tracer->startSpan('RequestHandler');
    • 注入:将 SpanContext 注入到 HTTP Headers。
    • 请求结束:$span->finish(); $tracer->flush();
  4. 日志集成:将 TraceIDSpanID 输出到日志中。
// 伪代码示例
$spanContext = $span->getContext();
Log::info('业务处理', ['trace_id' => $spanContext->getTraceId(), 'span_id' => $spanContext->getSpanId()]);

实战:如何排查问题(工作流)

假设用户反馈“订单支付失败”。

  1. 定位入口:查看 Nginx 或前端网络请求,找到 X-Trace-Id 头(假设值为 A1B2C3)。
  2. 全局搜索:在日志系统(ELK)中搜索 trace_id: A1B2C3
  3. 时间线分析
    • 看到 10:00:00 日志:订单服务 - 接收请求,参数:xxx
    • 看到 10:00:01 日志:支付服务 - 回调失败,错误:xxx
    • 看到 10:00:01 日志:订单服务 - 更新状态为失败
  4. 串联上下文:通过 TraceID,即使流量经过了 3 个不同服务,也能通过一个搜索词完整看到调用链,而不用盲目翻各个服务器日志。

最佳实践建议

  1. 使用 JSON 日志格式:配置 'json' => true,这极大方便了 Loki/ELK 的字段索引,不要用纯文本正则解析。

  2. 必须传递 Header:在你的所有 HTTP 客户端封装类中,统一注入 X-Trace-Id,不要依赖开发人员手动在业务代码中加。

  3. 日志级别标准化:链路追踪需要完整记录 请求开始外部调用SQL查询异常堆栈,建议配合以下结构:

    Log::channel('business')->info('order.create.started', ['order_sn' => $sn]); // 业务名词做Message
    Log::channel('sql')->debug('find_user'); // SQL记录
  4. 不要过度设计:对于多数 TP 单体项目,方案一 加上 ELK 已经非常够用,完全没必要引入 Zipkin,链路追踪的核心是定位慢在哪影响范围有多大,而不是为了炫技。


附加题:使用 Swoole 常驻内存时的注意事项

如果使用 ThinkPHP Swoole,由于一个 Worker 进程会处理多个请求,LogProcessor 中的静态变量 $traceId 容易被串数据(并发时)。

解决办法

  1. 使用 协程上下文 \Swoole\Coroutine::getContext() 持有 TraceID,而不是静态变量。
  2. 或者在 TraceMiddleware 中,请求开始时写入,请求结束时($response->send()后)必须清空 TraceMiddleware::$traceId = '',防止污染下一个请求。

修改中间件如下:

public function handle($request, Closure $next)
{
    // 使用协程上下文存储
    if (class_exists('\Swoole\Coroutine')) {
        \Swoole\Coroutine::getContext()['trace_id'] = $traceId;
    }
    // ... 逻辑相同
}

LogProcessor 中获取时也改为从协程上下文取。

抱歉,评论功能暂时关闭!