PHP 性能优化实战:一次线上内存泄漏与连接数打满的排查复盘

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,那只是把症状往后推。

下面这张图把这几类问题的常见连锁关系串起来,方便建立整体印象:

flowchart LR A["内存泄漏 / 缓存膨胀"] --> B["GC 频繁、内存分配变慢"] B --> C["请求处理耗时上升"] C --> D["Worker 被长时间占用"] D --> E["请求排队堆积"] E --> F["数据库连接被占满"] F --> G["接口超时、错误率上升"]

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,监控开始告警,现象按时间顺序如下:

  1. 14:20 --- 接口 P99 从 80ms 涨到 600ms,告警触发
  2. 14:35 --- 部分 Pod 出现 Allowed memory size of 134217728 bytes exhausted
  3. 14:50 --- MySQL 监控显示连接数从 120 涨到 480(上限 500),开始出现 Too many connections
  4. 15:00 --- 接口错误率超过 30%,用户侧大面积超时

第一反应是「流量突增」,但看了下 QPS 曲线,流量平稳,甚至比昨天同期还低一点。这就排除了流量因素,问题出在服务自身。

把告警时间线画出来,能更直观地看到「雪崩」是怎么一步步发生的:

timeline title 故障时间线 14:20 : P99 从 80ms 涨到 600ms,告警触发 14:35 : 部分 Pod 出现内存耗尽错误 14:50 : MySQL 连接数涨到 480,出现 Too many connections 15:00 : 接口错误率超过 30%,用户侧大面积超时

5. 排查过程

整个排查过程是一条逐步收敛的链路,先看全局再逐层下钻:

flowchart TD S["告警:P99 飙升 + 内存耗尽"] --> A["5.1 确认范围:全量还是局部"] A -->|"8 个 Pod 全量上涨"| B["5.2 判断性质:真泄漏还是缓存膨胀"] B -->|"RSS 只涨不降,与请求量正相关"| C["5.3 埋点抓取:定位增长对象"] C -->|"集中在批量查询接口"| D["5.4 缩小代码:定位静态缓存"] D --> E["5.5 解释连接数打满的因果链"] E --> F["进入解决方案"]

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. 解决方案

解决方案分「止血」和「根治」两条线并行推进,先恢复再修复:

flowchart LR subgraph P1["止血(分钟级)"] A1["滚动重启释放内存"] --> A2["网关层临时限流"] A2 --> A3["服务恢复"] end subgraph P2["根治(当天完成)"] B1["静态数组换成 Swoole\\Table"] --> B2["补容量上限与 TTL"] B2 --> B3["消除循环内 N+1 调用"] B3 --> B4["DB / HTTP 强制超时"] end A3 --> B1

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 防御性措施

事后我们做了几件事,避免同类问题再发生:

  1. 代码规范 :常驻进程项目中禁止使用无上限的 static 数组缓存,统一走 Swoole\Table 或 Redis
  2. 静态检查 :加了一条 PHPStan 自定义规则,扫描 static 属性写入
  3. 内存监控:对每个 Worker 的 RSS 做趋势告警,连续 10 分钟上涨超过 20% 就报警
  4. 压测基线:上线前跑 30 分钟长稳压测,观察内存是否收敛
  5. 超时兜底:所有 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」,而是理解了问题之间的因果链:

flowchart TD A["静态缓存无上限"] --> B["Worker 内存持续上涨"] B --> C["请求处理变慢"] C --> D["Worker 被长时间占用"] D --> E["请求排队堆积"] E --> F["数据库连接被占满"] F --> G["接口大面积超时"]

看到「连接数打满」时,如果直接去调数据库参数,就是治标不治本。顺着因果链往上找,才能找到真正的根因。

几条经验,供参考:

  • 常驻进程(Swoole / RoadRunner / FrankenPHP)下,任何「跨请求存活」的状态都要有上限和过期
  • 内存问题优先看「是否随请求量线性增长」,这能快速区分泄漏和缓存膨胀
  • 连接数打满先看上游耗时,不要急着调 max_connections
  • 止血和根治分开做,线上先恢复,再从容修复

希望这个案例对你有帮助。如果你也遇到过类似的线上问题,欢迎在评论区交流。

相关推荐
vipxieliang2 小时前
使用 PHP 构建命令行工具:从 $argv 到 Symfony Console 与 Cron 集成
php
vipxieliang2 小时前
ThinkPHP 8.0 路由完全重写:新写法与迁移适配指南
php·thinkphp
AlanAshley3 小时前
无 SIM 卡也能打 110?手机紧急呼叫能力的软硬件技术探究
开发语言·智能手机·php
Escalating_xu16 小时前
【Linux 网络编程】DNS 与 ICMP:从域名解析到 ping、traceroute,把“网络通不通”问清楚
linux·网络·php
niucloud-admin18 小时前
NIUCLOUD PHP SAAS上门家政使用文档--服务订单:订单列表
开发语言·php
一枕槐安48718 小时前
大模型限流、重试、超时处理:API 调用健壮性方案
linux·服务器·php·大模型api·指数退避·限流控制·超时预算
李纲明18 小时前
WordPress 白屏或提示「出现了严重错误」:先开 WP_DEBUG 抓致命错误,再二分插件与主题
运维·php·debug·wordpress·白屏·插件冲突·devdiy
其实防守也摸鱼20 小时前
Chrome 扩展安全攻防实录:从攻击面到防御实践
开发语言·前端·网络·chrome·安全·web安全·php
niucloud-admin1 天前
NIUCLOUD PHP SAAS上门家政使用文档--服务订单:评价列表
开发语言·php