1. 写在前面
线上 PHP 服务出问题,最怕的不是报错,而是「不报错但越来越慢」。报错至少有堆栈,慢和内存涨往往悄无声息,等到监控告警时,用户已经在骂了。
这篇文章整理一个我亲身经历的线上排查案例:一个跑了两年多的 PHP 接口服务,某天开始出现内存持续上涨 → 慢请求增多 → MySQL 连接数打满 → 服务雪崩的连锁反应。整个过程从告警到定位根因花了大约 4 小时,最终定位到一处「看起来完全无害」的代码。
文中会覆盖:
- 常见线上问题的分类与第一反应
- 本次案例的完整排查链路(问题 → 排查 → 解决)
- 可复用的排查命令与工具清单
- 事后沉淀的防御性措施
说明:文中涉及的域名、表名、业务字段均已脱敏,代码为还原后的等价片段。
2. 线上问题分类:先判断是哪一类
PHP 服务的线上问题,绝大多数可以归到下面几类。先分类,再动手,能省掉大量瞎猜的时间。
| 问题类型 | 典型表现 | 首要怀疑对象 |
|---|---|---|
| 内存泄漏 / OOM | 进程 RSS 持续上涨,Allowed memory size exhausted |
常驻进程、全局缓存、未释放的资源句柄 |
| 慢请求 | P99 飙升,接口超时 | 慢 SQL、N+1 查询、外部 HTTP 调用无超时 |
| 连接数打满 | Too many connections,请求排队 |
连接未复用、长事务、连接池配置 |
| CPU 打满 | 负载高但内存正常 | 死循环、正则回溯、序列化大对象 |
| 磁盘 / 日志 | 写入失败、日志暴涨 | 日志未切割、debug 日志误开 |
这里有个关键认知:这几类问题经常是连锁的 。内存涨 → GC 频繁 → 请求变慢 → 请求堆积 → 连接被占满。所以看到「连接数打满」不要直接去调 max_connections,那只是把症状往后推。
下面这张图把这几类问题的常见连锁关系串起来,方便建立整体印象:
3. 案例背景
- 服务形态:PHP 8.1 + Swoole 常驻进程,对外提供 HTTP 接口
- 部署:8 个 Pod,每个 Pod 4 个 Worker 进程
- 依赖:MySQL 8.0(主从)、Redis 7、若干内部 HTTP 服务
- 流量:日均请求约 2000 万,峰值 QPS 约 1200
服务本身不复杂,核心是一个「数据聚合接口」:接收一批 ID,去 MySQL 查明细,去 Redis 查缓存,再调用两个内部服务补充字段,最后拼装返回。
4. 问题现象
某天下午 14:20,监控开始告警,现象按时间顺序如下:
- 14:20 --- 接口 P99 从 80ms 涨到 600ms,告警触发
- 14:35 --- 部分 Pod 出现
Allowed memory size of 134217728 bytes exhausted - 14:50 --- MySQL 监控显示连接数从 120 涨到 480(上限 500),开始出现
Too many connections - 15:00 --- 接口错误率超过 30%,用户侧大面积超时
第一反应是「流量突增」,但看了下 QPS 曲线,流量平稳,甚至比昨天同期还低一点。这就排除了流量因素,问题出在服务自身。
把告警时间线画出来,能更直观地看到「雪崩」是怎么一步步发生的:
5. 排查过程
整个排查过程是一条逐步收敛的链路,先看全局再逐层下钻:
5.1 第一步:确认是「全量」还是「局部」
先看是不是所有 Pod 都出问题。
bash
# 查看各 Pod 的内存与 CPU
kubectl top pod -l app=aggregate-api --sort-by=memory
结果:8 个 Pod 内存都在涨,但涨幅不一致,其中 3 个 Pod 已经接近 limit。全量上涨说明不是某台机器的偶发问题,而是代码层面的共性缺陷。
5.2 第二步:确认内存是「真泄漏」还是「缓存膨胀」
这一步很关键。PHP-FPM 模式下进程请求结束就释放,但 Swoole 常驻进程不会,所以「内存涨」有两种可能:
- 真泄漏:对象被全局变量、静态属性、闭包引用住,永远不释放
- 缓存膨胀:本地缓存(如
Swoole\Table、静态数组)无上限增长
先看进程内存曲线是否随请求量线性增长:
bash
# 观察单个 Worker 的内存变化
ps -o pid,rss,cmd -p $(pgrep -f 'aggregate-api' | head -n 4)
观察 10 分钟,发现 RSS 只涨不降,且与请求量正相关。这更像是「引用未释放」或「无上限缓存」。
5.3 第三步:抓内存快照,定位增长对象
Swoole 提供了 Server::getWorkerMemory() 之类的辅助手段,但更直接的是用 memory_get_usage() 打点。我们在关键路径上加了临时埋点:
php
// 临时埋点:记录请求前后的内存变化
$before = memory_get_usage(true);
$result = $this->handle($request);
$after = memory_get_usage(true);
if ($after - $before > 1024 * 1024) { // 单次请求增长超过 1MB
$this->logger->warning('memory_growth', [
'delta' => $after - $before,
'uri' => $request->server['request_uri'] ?? '',
'ids' => count($request->get['ids'] ?? []),
]);
}
埋点上线后,日志里出现了大量 memory_growth 记录,且集中在同一个接口 :批量查询接口,且 ids 数量较大的请求。
5.4 第四步:缩小到具体代码
顺着这个接口往下看,最终定位到一段「批量查询 + 本地缓存」的代码:
php
class DetailRepository
{
// 静态缓存,本意是减少重复查询
private static array $cache = [];
public function getBatch(array $ids): array
{
$result = [];
$missIds = [];
foreach ($ids as $id) {
if (isset(self::$cache[$id])) {
$result[$id] = self::$cache[$id];
} else {
$missIds[] = $id;
}
}
if ($missIds) {
$rows = $this->db->query(
'SELECT id, name, status, extra FROM detail WHERE id IN (' .
implode(',', array_fill(0, count($missIds), '?')) . ')',
$missIds
);
foreach ($rows as $row) {
// 问题就在这里:无上限写入静态缓存
self::$cache[$row['id']] = $row;
$result[$row['id']] = $row;
}
}
return $result;
}
}
问题一目了然:self::$cache 是一个静态数组,只写不删,没有容量上限,也没有过期时间。
在 PHP-FPM 模式下,这个静态数组随请求结束就销毁了,所以本地测试、预发环境完全看不出问题。但 Swoole 常驻进程里,static 属性跨请求存活,随着不同 ID 不断被查询,这个数组会无限膨胀。
5.5 第五步:解释「连接数打满」
到这里内存问题清楚了,但为什么会导致 MySQL 连接数打满?
继续看代码,发现 getBatch 的调用方在循环里调用:
php
// 反例:循环内调用批量接口,退化成 N+1
foreach ($orderList as $order) {
$details = $repository->getBatch($order['detail_ids']);
// ...
}
内存膨胀后,每次请求的耗时变长(数组越大,isset 和内存分配越慢),请求处理时间从 80ms 涨到 600ms。请求变慢 → Worker 被长时间占用 → 新请求排队 → 每个排队请求都持有数据库连接 → 连接数打满。
所以「连接数打满」不是数据库的问题,而是上游变慢导致的连接堆积 。这也解释了为什么调大 max_connections 只能缓解几分钟。
6. 解决方案
解决方案分「止血」和「根治」两条线并行推进,先恢复再修复:
6.1 止血:先恢复服务
线上优先止血,不要一边排查一边改代码。
bash
# 1. 滚动重启,释放膨胀的内存
kubectl rollout restart deployment/aggregate-api
# 2. 临时限流,保护数据库
# 在网关层对该接口限流至 300 QPS
重启后服务在 2 分钟内恢复。但这是治标,必须当天完成根治。
6.2 根治:替换无上限静态缓存
把「无上限静态数组」换成有容量、有过期时间 的缓存。这里用 Swoole\Table 或直接上 Redis 都可以,我们选择了 Swoole\Table,因为它是共享内存,性能好且可控:
php
class DetailRepository
{
private Swoole\Table $cache;
public function __construct(private Db $db)
{
// 容量 10000,每条 1KB,超出后按 LRU 淘汰
$this->cache = new Swoole\Table(10000);
$this->cache->column('data', Swoole\Table::TYPE_STRING, 1024);
$this->cache->column('expire_at', Swoole\Table::TYPE_INT, 8);
$this->cache->create();
}
public function getBatch(array $ids): array
{
$result = [];
$missIds = [];
$now = time();
foreach ($ids as $id) {
$row = $this->cache->get((string) $id);
if ($row && $row['expire_at'] > $now) {
$result[$id] = json_decode($row['data'], true);
} else {
$missIds[] = $id;
}
}
if ($missIds) {
$rows = $this->db->query(
'SELECT id, name, status, extra FROM detail WHERE id IN (' .
implode(',', array_fill(0, count($missIds), '?')) . ')',
$missIds
);
foreach ($rows as $row) {
$this->cache->set((string) $row['id'], [
'data' => json_encode($row),
'expire_at' => $now + 300, // 5 分钟过期
]);
$result[$row['id']] = $row;
}
}
return $result;
}
}
关键点:
- 容量上限 :
Swoole\Table固定容量,写满后不会无限增长 - 过期时间:5 分钟 TTL,避免脏数据长期驻留
- 共享内存:多 Worker 共享,不会每个进程各存一份
6.3 顺手修掉 N+1
把循环内的批量调用提到循环外,一次查完:
php
// 正例:先收集所有 ID,一次批量查询
$allIds = [];
foreach ($orderList as $order) {
$allIds = array_merge($allIds, $order['detail_ids']);
}
$allIds = array_values(array_unique($allIds));
$detailMap = $repository->getBatch($allIds);
foreach ($orderList as $order) {
$details = array_map(
fn ($id) => $detailMap[$id] ?? null,
$order['detail_ids']
);
// ...
}
6.4 补上超时与连接管理
外部调用和数据库查询都必须有超时,否则一个慢依赖就能拖垮整个 Worker:
php
// 数据库连接:设置查询超时
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_TIMEOUT => 3,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// HTTP 调用:必须设置超时
$client = new GuzzleHttp\Client([
'timeout' => 2,
'connect_timeout' => 1,
]);
7. 效果验证
修复上线后,观察 24 小时:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 单 Worker 内存 | 持续上涨至 128MB+ | 稳定在 45MB 左右 |
| 接口 P99 | 600ms | 75ms |
| MySQL 连接数 | 480(接近上限) | 90 左右 |
| 错误率 | 30%+ | 0.01% |
内存曲线从「锯齿状持续上涨」变成「平稳波动」,说明泄漏点已消除。
8. 复盘与沉淀
8.1 根因总结
一句话:在常驻进程模型下,沿用了 PHP-FPM 的「请求级生命周期」思维。
static 数组在 FPM 里是安全的,在 Swoole 里就是内存泄漏。这不是某个人的疏忽,而是模型切换时缺少约束。
8.2 防御性措施
事后我们做了几件事,避免同类问题再发生:
- 代码规范 :常驻进程项目中禁止使用无上限的
static数组缓存,统一走Swoole\Table或 Redis - 静态检查 :加了一条 PHPStan 自定义规则,扫描
static属性写入 - 内存监控:对每个 Worker 的 RSS 做趋势告警,连续 10 分钟上涨超过 20% 就报警
- 压测基线:上线前跑 30 分钟长稳压测,观察内存是否收敛
- 超时兜底:所有 DB / HTTP 调用强制设置超时,Code Review 必查项
8.3 排查工具箱
最后整理一份常用命令,出问题时可以直接抄:
bash
# 1. 看进程内存
ps -o pid,rss,cmd -p $(pgrep -f 'your-service')
# 2. 看 PHP 进程状态
php-fpm -t && ps aux | grep php-fpm
# 3. 看 MySQL 连接
mysql -e "SHOW STATUS LIKE 'Threads_connected';"
mysql -e "SHOW PROCESSLIST;" | head -50
# 4. 看慢查询
mysql -e "SHOW VARIABLES LIKE 'slow_query_log';"
# 5. 看系统负载
top -b -n 1 | head -20
iostat -x 1 3
9. 小结
这次排查最大的收获不是「修了一个 bug」,而是理解了问题之间的因果链:
看到「连接数打满」时,如果直接去调数据库参数,就是治标不治本。顺着因果链往上找,才能找到真正的根因。
几条经验,供参考:
- 常驻进程(Swoole / RoadRunner / FrankenPHP)下,任何「跨请求存活」的状态都要有上限和过期
- 内存问题优先看「是否随请求量线性增长」,这能快速区分泄漏和缓存膨胀
- 连接数打满先看上游耗时,不要急着调
max_connections - 止血和根治分开做,线上先恢复,再从容修复
希望这个案例对你有帮助。如果你也遇到过类似的线上问题,欢迎在评论区交流。