先说我踩的坑
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,在应用代码的关键节点埋点记录耗时,最终汇聚成一条链路。
市面上有成熟方案:
| 方案 | 接入成本 | 功能 | 依赖组件 |
|---|---|---|---|
| SkyWalking | Java 代理自动注入,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.8s | 1.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 的 lPush 和 setex 两个操作来存储追踪数据。在请求量大的时候,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 模拟生产环境的资源限制,再压测一步,别直接拿本地数据当基准。
六、总结一下这套方法论
把这三件事做好,接口响应慢的排查能力直接提升一个量级:
- 分层:确认慢在客户端、网关、应用、数据库哪一层
- 追踪:用 trace_id 串起整条请求链路,每个关键操作单独计时
- 分析:用 Redis 存储最近 N 条请求的链路数据,配合告警脚本,变事后追查为实时发现
上面这套代码在 Laravel 11 上可以直接跑通,其他框架改改中间件接口就行。如果你的项目连 Redis 都不想引入,那就把 trace 数据写到日志里,用 grep 也能查,只是慢一点。
排查接口慢问题,方法比经验重要。经验再多,没有工具支撑也是抓瞎。