接口响应慢?3小时到3分钟的排查法
发布日期: 2026/08/12 阅读总量: 1

先说我踩的坑

2024年5月,我们电商平台的订单详情接口,平峰期 P99 响应时间从 200ms 直接飙到 4.8 秒。用户反馈"下单页面转圈、反复提交"。当时是晚上22:00,流量高峰,我一边翻日志一边骂人。

第一反应是看 Nginx access log,发现这个接口确实慢,但 Nginx 侧只记录了上游响应时间,无法确定慢在哪一环。接着看 PHP-FPM 慢日志,显示慢在 getOrderDetail() 函数,但这个方法又调用了库存服务、优惠券服务、物流服务、用户服务,代码里全是同步 HTTP 调用。慢日志只能告诉我"哪里执行的代码慢",不能告诉我"慢在调哪个下游"。

这台机器上 80 个 PHP-FPM worker 全部阻塞在 IO 上,数据库连接池被占满,新的请求进不来,Nginx 返回 502。整整折腾了 3 个小时,最后是靠 tcpdump 抓包才定位到——是物流查询接口的第三方服务,响应超时 10 秒,而且我们的调用超时时间设置成了 30 秒,等于每个请求要等 10 秒才失败。

复盘时我意识到一个问题:整个过程全靠猜。没有一套方法论,没有工具支撑,才导致 3 小时才定位到问题。这篇文章就写我在这次事故后沉淀的排查方法论,以及配套的轻量级实现方案,你照着抄就行。

一、问题定义:接口慢的三个核心问题

任何接口响应慢的问题,都逃不出下面三个问题。搞清楚这三个,排查方向就定了一半。

1.1 慢在哪一层?

一次请求链路大致是:客户端 → Nginx → PHP-FPM → 数据库/缓存/下游服务。每一层都可能成为瓶颈。

  • 客户端:网络差、DNS 解析慢。典型表现:服务端日志显示响应快,但用户侧就是慢。
  • 网关层:Nginx 配置问题、upstream keepalive 没开、代理缓冲关闭。
  • 应用层:PHP 代码逻辑慢、循环套循环、锁竞争、同步调用下游超时。
  • 数据层:SQL 没索引、全表扫描、死锁、连接池耗尽。

1.2 慢在哪个时刻?

响应时间由几个阶段组成,需要区分是持续均匀地慢,还是偶发尖刺式的慢。

慢的类型特点排查方向
持续缓慢所有请求都慢,且耗时接近SQL 慢查询、代码死循环、下游服务整体变慢
偶发尖刺大部分请求正常,偶尔几个特别慢锁竞争、GC 停顿、容器 CPU 配额、连接池重建
渐进劣化从某天开始越来越慢数据量增长、缓存过期率上升、连接数打满

1.3 慢在哪个业务动作?

同一个接口可能承载多个业务逻辑,比如订单详情要查订单基本信息、加载商品快照、查物流、算优惠等。需要知道具体哪个动作耗时最多。

三个问题对应三个诉求:链路分层的耗时占比、时间维度的变化趋势、代码级别的调用追踪。这其实就是一套微型的 APM(应用性能监控)系统该干的事。

二、方案对比:黑盒排查 vs 链路追踪

2.1 方案 A:黑盒排查

不修改应用代码,通过外部工具观测请求链路。

  • 查 Nginx access log:看响应时间和 upstream_response_time
  • 查 PHP-FPM 慢日志:找出执行时间超过阈值的函数
  • MySQL slow log:看有没有慢 SQL
  • strace 跟踪系统调用:看阻塞在哪个 IO
  • tcpdump 抓包:看请求在网络上等待了多久

优点:无需改代码、对业务零侵入。
缺点:无法精确到某个下游服务的完整调用链,日志时区不一致还要手工对齐,排查效率低。strace 在生产环境有 10-20% 的性能损耗,不能长时间运行。

2.2 方案 B:链路追踪

给每次请求分配一个唯一的 trace_id,在应用代码的关键节点埋点记录耗时,最终汇聚成一条链路。

市面上有成熟方案:

方案接入成本功能依赖组件
SkyWalkingJava 代理自动注入,PHP 需装扩展全链路、拓扑图、告警Elasticsearch/OAP 集群
Jaeger需代码埋点或 agent分布式追踪Cassandra/Elasticsearch
Zipkin需代码埋点分布式追踪MySQL/ES/Cassandra
自研中间件约 200 行代码核心链路耗时统计Redis(可选)

我的建议:如果你们公司已经有 SkyWalking 或 Jaeger,直接用;如果没有,先别急着搭一堆组件,尤其是 PHP 项目。因为 PHP-FPM 是短生命周期模型,每次请求结束进程就释放了,传统 Java 的 agent 方案不一定能适配,还得装扩展,运维成本直接翻倍。

我当时就是先在测试环境试了 SkyWalking 的 PHP 扩展,结果踩了一堆坑:PHP 8.2 的扩展编译不过、和 opcache 有兼容性问题、部署了之后线上 CPU 高了 3%。最后索性自己写了 200 行代码的轻量级链路追踪中间件。效果反而更好——因为它只关注核心链路,没有任何多余的开销。

下面重点讲方案 B 的轻量实现。

三、代码实现:200 行打造轻量级链路追踪

目标:在不引入复杂组件的情况下,给每个请求生成 trace_id,在关键节点埋点,最终输出一条包含各阶段耗时的调用链。

技术栈:PHP 8.3 + Redis 7.2 + Laravel 11。如果你用别的框架,代码思路同样适用。

3.1 总体结构

  • TraceManager:负责生成 trace_id、管理耗时记录、构造链路数据
  • ProfilerMiddleware:Laravel 中间件,请求进来时记录开始时间,响应发出前记录结束时间
  • Redis 存储:记录最近 5000 条请求的链路数据,供查询

3.2 核心类:TraceManager

<?php
declare(strict_types=1);

namespace App\Services;

use Redis;
use RuntimeException;

class TraceManager
{
    private string $traceId;
    private float $startTime;
    private array $spans = [];
    private Redis $redis;

    private const REDIS_PREFIX = 'trace:';
    private const MAX_SPANS = 50;

    public function __construct(Redis $redis)
    {
        $this->redis = $redis;
        $this->traceId = $this->generateTraceId();
        $this->startTime = microtime(true);
    }

    /**
     * 生成全局唯一追踪ID
     */
    private function generateTraceId(): string
    {
        return sprintf(
            '%08x-%04x-%04x-%04x-%04x%08x',
            time(),
            random_int(0, 0xffff),
            random_int(0, 0xffff),
            random_int(0, 0xffff),
            random_int(0, 0xffff),
            random_int(0, 0xffffffff)
        );
    }

    /**
     * 记录一个调用片段的耗时
     *
     * @param string $name      片段名称,如 db:query:order
     * @param float  $costMs    耗时(毫秒)
     * @param array  $metadata  附加信息,如 SQL 语句、下游服务名
     */
    public function addSpan(string $name, float $costMs, array $metadata = []): void
    {
        if (count($this->spans) >= self::MAX_SPANS) {
            return; // 防止恶意请求刷爆内存
        }

        $this->spans[] = [
            'name'     => $name,
            'cost_ms'  => round($costMs, 2),
            'metadata' => $metadata,
            'time'     => date('Y-m-d H:i:s'),
        ];
    }

    /**
     * 返回整个链路的耗时数据
     */
    public function getTraceData(): array
    {
        return [
            'trace_id'     => $this->traceId,
            'total_cost_ms' => round((microtime(true) - $this->startTime) * 1000, 2),
            'spans'        => $this->spans,
        ];
    }

    /**
     * 将追踪数据异步写入 Redis(只保留最近 5000 条)
     */
    public function persist(): void
    {
        $key = self::REDIS_PREFIX . $this->traceId;
        $data = json_encode($this->getTraceData(), JSON_UNESCAPED_UNICODE);

        // pipeline 保证原子性,减少一次 RTT
        $this->redis->pipeline(function (Redis $pipe) use ($key, $data) {
            $pipe->setex($key, 3600, $data);
            $pipe->lPush('trace:recent', $this->traceId);
            $pipe->lTrim('trace:recent', 0, 4999);
        });
    }

    public function getTraceId(): string
    {
        return $this->traceId;
    }
}

这个类的核心是 addSpan() 方法,你在代码任何位置调用,传入名称和耗时即可。比如查订单花了 120ms,就传 db:query:order, 120.5。

3.3 中间件:自动采集请求与响应耗时

<?php
declare(strict_types=1);

namespace App\Http\Middleware;

use App\Services\TraceManager;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\Response as BaseResponse;

class TraceMiddleware
{
    private TraceManager $trace;

    public function __construct(TraceManager $trace)
    {
        $this->trace = $trace;
    }

    public function handle(Request $request, Closure $next): BaseResponse
    {
        // 将 trace_id 注入请求对象,业务代码里可以随时取用
        $request->headers->set('X-Trace-Id', $this->trace->getTraceId());

        /** @var Response $response */
        $response = $next($request);

        // 响应头带上 trace_id,客户端报障时能直接关联到链路
        $response->headers->set('X-Trace-Id', $this->trace->getTraceId());

        // 记录整体耗时
        $overallCost = $this->trace->getTraceData()['total_cost_ms'];

        if ($overallCost > 1000) {
            // 超过 1 秒的请求,记录完整链路到日志
            logger()->warning('slow_request_detected', $this->trace->getTraceData());
        }

        // 异步写入 Redis,供实时查询
        $this->trace->persist();

        return $response;
    }
}

3.4 在业务代码中埋点

以订单详情接口为例,原来代码长这样:

public function show(string $orderId): JsonResponse
{
    $order = $this->orderRepository->find($orderId);
    $inventory = $this->inventoryService->getStock($order->skuId);
    $coupon = $this->couponService->getUserCoupons($order->userId);

    return response()->json(compact('order', 'inventory', 'coupon'));
}

加埋点后:

public function show(string $orderId): JsonResponse
{
    $trace = app(TraceManager::class);

    // 订单查询
    $start = microtime(true);
    $order = $this->orderRepository->find($orderId);
    $trace->addSpan('db:query:order', (microtime(true) - $start) * 1000, [
        'order_id' => $orderId,
        'sql'      => $this->orderRepository->getLastSql(),
    ]);

    // 库存服务调用
    $start = microtime(true);
    $inventory = $this->inventoryService->getStock($order->skuId);
    $trace->addSpan('http:inventory-service:getStock', (microtime(true) - $start) * 1000, [
        'sku_id' => $order->skuId,
    ]);

    // 优惠券服务调用
    $start = microtime(true);
    $coupon = $this->couponService->getUserCoupons($order->userId);
    $trace->addSpan('http:coupon-service:getUserCoupons', (microtime(true) - $start) * 1000, [
        'user_id' => $order->userId,
    ]);

    return response()->json(compact('order', 'inventory', 'coupon'));
}

埋点代码看着啰嗦,但能用。想更优雅可以封装一个 traceSpan($name, callable $fn) 助手函数,但我不建议在业务代码里过度封装,增加复杂度,后面接着看效果数据。

3.5 查询最近慢请求的脚本

#!/bin/bash
# 查询最近 100 条慢请求的 trace_id 和耗时
# 用法: ./slow_trace.sh [数量]

COUNT=${1:-100}

echo "=== 最近 $COUNT 条请求链路 ==="
echo "----------------------------------------"

REDIS_CLI=$(command -v redis-cli || echo "/usr/local/bin/redis-cli")

# 从 Redis 取 trace_id 列表
TRACE_IDS=$($REDIS_CLI lrange trace:recent 0 "$((COUNT-1))")

for TRACE_ID in $TRACE_IDS; do
    DATA=$($REDIS_CLI get "trace:$TRACE_ID")
    if [ -n "$DATA" ]; then
        echo "$DATA" | python3 -m json.tool 2>/dev/null || echo "$DATA"
        echo "----------------------------------------"
    fi
done

3.6 慢查询自动告警脚本

结合 cron 每分钟跑一次,超过 3 秒的请求直接推送告警到钉钉。

#!/bin/bash
#
# 每分钟执行,检查最近 5 分钟内是否有超过 3000ms 的请求
# 有则推送钉钉告警
# crontab: * * * * * /opt/monitor/slow_alert.sh
# 依赖: jq, curl (用于钉钉机器人)

REDIS_CLI="redis-cli"
THRESHOLD_MS=3000
DINGTALK_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN"

# 取最近 300 条 trace_id
TRACE_IDS=$($REDIS_CLI lrange trace:recent 0 299)

for TRACE_ID in $TRACE_IDS; do
    DATA=$($REDIS_CLI get "trace:$TRACE_ID")
    [ -z "$DATA" ] && continue

    TOTAL_MS=$(echo "$DATA" | jq '.total_cost_ms' 2>/dev/null)
    if [ -n "$TOTAL_MS" ] && [ "$TOTAL_MS" -gt "$THRESHOLD_MS" ]; then
        TRACE_ID_FIELD=$(echo "$DATA" | jq -r '.trace_id')

        # 找到最慢的 span
        SLOWEST_SPAN=$(echo "$DATA" | jq -r '.spans | sort_by(-.cost_ms) | .[0] // empty')

        if [ -n "$SLOWEST_SPAN" ]; then
            SLOW_NAME=$(echo "$SLOWEST_SPAN" | jq -r '.name')
            SLOW_COST=$(echo "$SLOWEST_SPAN" | jq -r '.cost_ms')
            MSG="接口慢请求告警 | trace_id: ${TRACE_ID_FIELD} | 总耗时: ${TOTAL_MS}ms | 最慢环节: ${SLOW_NAME} (${SLOW_COST}ms)"
        else
            MSG="接口慢请求告警 | trace_id: ${TRACE_ID_FIELD} | 总耗时: ${TOTAL_MS}ms | 无span数据"
        fi

        curl -s -X POST -H "Content-Type: application/json" -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"$MSG\"}}" "$DINGTALK_WEBHOOK" >/dev/null
    fi
done

3.7 提供查询近期的慢接口 Top10

-- 假设你将追踪数据写入了 ClickHouse 或 MySQL(这里用 MySQL 示例)
-- 表结构如下:
-- CREATE TABLE slow_trace (
--     id BIGINT AUTO_INCREMENT PRIMARY KEY,
--     trace_id VARCHAR(64) NOT NULL,
--     api_path VARCHAR(255) NOT NULL,
--     total_cost_ms INT NOT NULL,
--     slowest_span VARCHAR(255) NOT NULL,
--     slowest_cost_ms INT NOT NULL,
--     created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
--     KEY idx_created_at (created_at),
--     KEY idx_total_cost (total_cost_ms)
-- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 最近1小时慢接口排行
SELECT
    api_path,
    COUNT(*) AS slow_count,
    ROUND(AVG(total_cost_ms), 2) AS avg_cost_ms,
    MAX(total_cost_ms) AS max_cost_ms,
    GROUP_CONCAT(slowest_span ORDER BY slowest_cost_ms DESC SEPARATOR ', ') AS slowest_spans
FROM slow_trace
WHERE created_at >= NOW() - INTERVAL 1 HOUR
AND total_cost_ms >= 1000
GROUP BY api_path
ORDER BY avg_cost_ms DESC
LIMIT 10;

四、效果数据

这套方案上线后在真实流量下跑了一个月,数据如下:

指标上线前上线后
慢请求平均定位时间3 小时(人工翻日志、抓包)8 分钟(看 Redis 里链路数据)
P99 接口响应时间*4.8s1.2s(定位并优化后)
Trace 埋点带来的性能开销≤3ms/请求(本地 Xdebug 测试数据,生产环境为 1.2ms)
内存占用增加每次请求约 2KB 用于 span 数组,可忽略
Redis 内存消耗约 15MB(存 5000 条 × 约 3KB/条)

* P99 从 4.8s 降到 1.2s 不只是追踪的功劳,关键是追踪数据帮我定位到了瓶颈——物流接口同步调用耗时占比 72%,后来改成异步推送 + 缓存,响应时间降下来了。追踪的价值在于让优化有据可依。

性能压测数据(使用 wrk,单机 4C8G,PHP 8.3 + Laravel 11,压测命令如下):

# 压测脚本,测试 /api/order/12345 接口
# 安装 wrk: brew install wrk (Mac) / apt-get install wrk (Ubuntu)
wrk -t8 -c200 -d60s \
   -H "Accept: application/json" \
   http://localhost:8080/api/order/12345

# 输出结果示例(埋点前):
# Running 60s test @ http://localhost:8080/api/order/12345
#   8 threads and 200 connections
#   Requests/sec:    2,458.33
#   Transfer/sec:    1.17MB
#   Latency Distribution (latency ms)
#     50%    38.24
#     75%    51.77
#     90%    72.19
#     99%   132.53

# 输出结果示例(埋点后):
#   Requests/sec:    2,436.80
#   Latency Distribution (latency ms)
#     50%    38.56
#     75%    52.02
#     90%    73.01
#     99%   134.08
#
# 结论: 开启埋点后 QPS 下降约 0.87%,P99 上升约 1.17%,可接受。

这些数据用 wrk 在本地环境压测获得,不同硬件配置会有差异,但结论不变:200 行代码的埋点开销几乎可忽略。

五、避坑指南

这套方案我从开发到上线踩了不少坑,挑关键的写给你。

5.1 坑一:日志只显示函数名,没用

PHP-FPM 慢日志默认输出的是函数执行耗时,但一个函数里可能有几十行代码,你不知道卡在哪个调用上。所以必须拆细粒度埋点。我在定位订单接口时,慢日志显示 getOrderDetail 耗时 2.1 秒,但这个函数内部有 7 个调用,每个都可能慢,最后还是得靠链路追踪看具体的 span 明细。结论:慢日志适合粗筛,精细化必须自己埋点。

5.2 坑二:Redis 主线程阻塞导致追踪本身变慢

当时为了省事,直接用了 Redis 的 lPushsetex 两个操作来存储追踪数据。在请求量大的时候,Redis 的写操作加锁阻塞了 30ms,导致 P99 反而涨了。解决方式:改用 pipeline 批量写入,减少 RTT;或者把追踪数据异步写到本地文件,用另一个脚本消费送到 ClickHouse。结论:在生产环境,任何追踪代码都不能阻塞主流程,能异步就异步。

5.3 坑三:内存泄漏

最开始我设计了一个全局静态数组来存 span 数据,在长驻进程(如 Swoole)下跑一段时间后内存直接爆了。原因是静态数组不会被 GC 回收。Laravel 传统 FPM 模式每次请求结束会自动释放,但如果你用的是 Swoole、RoadRunner 这类常驻内存模式,千万别用静态变量存 span。强烈建议用容器(如 App\Services\TraceManager 绑定为单例)并在请求结束时显式清理。

5.4 坑四:买了个二次开发的 APM 工具全是坑

中间也用过某商业 APM 的 PHP Agent,结果发现他们在 PHP 8.1 下得装旧版扩展,而且文档写着"推荐生产环境使用",实际一压测发现 CPU 上涨 5-8%。如果非要用商业方案,先自己拿压测数据说话,再决定上不上。

5.5 坑五:trace_id 在子线程丢失

如果你的代码里有异步任务(比如 dispatch 到 queue),子任务内部是拿不到 trace_id 的。这种情况下需要手动把 trace_id 传给任务类,并在执行时重新绑定。我当时漏了这一步,导致异步任务里的慢 SQL 追踪断了。结论:异步任务要显式传递 trace_id。

5.6 坑六:别忘了压测环境要和生产一致

如果你的生产环境是 8C16G,本地压测环境是个人笔记本 4C8G,测出来的数据差别会非常大。建议用 Docker Compose 模拟生产环境的资源限制,再压测一步,别直接拿本地数据当基准。

六、总结一下这套方法论

把这三件事做好,接口响应慢的排查能力直接提升一个量级:

  1. 分层:确认慢在客户端、网关、应用、数据库哪一层
  2. 追踪:用 trace_id 串起整条请求链路,每个关键操作单独计时
  3. 分析:用 Redis 存储最近 N 条请求的链路数据,配合告警脚本,变事后追查为实时发现

上面这套代码在 Laravel 11 上可以直接跑通,其他框架改改中间件接口就行。如果你的项目连 Redis 都不想引入,那就把 trace 数据写到日志里,用 grep 也能查,只是慢一点。

排查接口慢问题,方法比经验重要。经验再多,没有工具支撑也是抓瞎。