无尘阁日记

无尘阁日记

一次真实的 Docker + PHP-FPM 内存溢出排障全过程——从“慢查询查不到”到“OOM Killer 现场还原”
2026-01-28

这是一次典型但又极容易被误判的线上事故

接口偶发卡死、失败,慢查询查不到,MySQL 看起来也“没报错”,
但系统却在某个时间点反复异常。

下面完整记录一次从零线索开始,到最终定性并给出可执行解决方案的全过程。

一、问题起点:昨天 11 点左右系统异常,但慢查询是关的

事故发生时间非常明确:
昨天上午 11 点左右,系统出现异常。

第一反应是查 MySQL 慢查询,但很快确认:

  • slow_query_log = OFF

  • 事后无法回溯历史慢 SQL

结论 1(必须先接受的现实):

MySQL 没有提前开启慢查询,SQL 层已经“不可追溯”。

这一步不是失败,而是排除了一个方向,逼着我们往更底层走。

二、转换排查方向:是否发生了内存溢出(OOM)

在 Docker + PHP-FPM 架构下,内存问题是少数“事后还能查到尸检报告”的问题,因为:

  • Linux 内核会记录 OOM

  • Docker / cgroup 会记录内存约束

  • PHP-FPM 会留下异常退出痕迹

使用的关键命令:

journalctl -k | grep -Ei "oom|killed process"

image.png

三、第一份铁证:内核 OOM 日志(决定性证据)

从内核日志中,出现了反复的关键信息:

  • php-fpm invoked oom-killer

  • constraint=CONSTRAINT_MEMCG

  • oom_memcg=/docker/xxxxx

  • task=php-fpm

这里信息量极大:

  1. 不是 MySQL OOM

  2. 不是整台宿主机 OOM

  3. Docker cgroup 内存限制触发

  4. 被杀进程是 php-fpm worker

而且时间点正好覆盖昨天 11 点左右,并且并非第一次发生(历史上多次)。

结论 2:

这是一个长期存在的 PHP-FPM 内存问题,不是偶发事故。

四、第二份铁证:PHP-FPM 自身的 SIGKILL 日志

继续查看 PHP-FPM 的 error log,发现如下内容:

[27-Jan-2026 11:48:57] WARNING: [pool www] child 13605 exited on signal 9 (SIGKILL)
[27-Jan-2026 11:48:57] NOTICE: [pool www] child 13707 started

这两行日志非常关键。

含义拆解:

  • SIGKILL (9)
    不是 PHP 自己退出,而是被系统强制杀死

  • child exited 后立刻 child started
    FPM master 在自救,重启 worker

结合内核 OOM 日志,可以确定:

结论 3:

PHP-FPM worker 在执行过程中被 OOM Killer 直接处决。

五、第三步:确认 Docker 容器的内存配置

接下来要确认:
是不是容器内存本身太小?

执行:

docker inspect <container> | grep -i memory

image.png

结果显示:

  • Memory = 4GB

  • MemorySwap = 8GB

这一步非常重要,因为它直接排除了一个常见误解。

结论 4:

4GB 内存 + 4GB swap,本身并不算“小容器”。

也就是说:
问题不是“内存配得太抠”,而是某些请求在疯狂吃内存

六、进一步定位:为什么 PHP 会吃掉这么多内存?

此时我们已经可以确定:

  • OOM 发生在 PHP-FPM

  • 是容器 MEMCG 限制触发

  • 不是 SQL 慢、不是 MySQL 爆

接下来必须结合业务。

一个关键事实被确认:

存在一个“单次 11MB 数据写入数据库”的操作,并且可能同时跑 5 个。
表数据规模:几千万级

这正是整个事故的根因入口。

七、关键认知转变:11MB ≠ 内存里的 11MB

这是很多人(包括有经验的工程师)最容易误判的地方。

在真实 PHP 运行时,11MB 会经历什么?

  1. JSON / 请求体解析

    • 11MB JSON → PHP 数组

    • 内存膨胀 3~10 倍(zval 结构)

  2. 数组加工 / 映射 / 合并

    • array_map

    • array_merge

    • 数据校验、字段补全
      👉 产生多份副本

  3. SQL 构造

    • 批量 INSERT 拼成长字符串
      👉 又是一份大内存

  4. ORM / ActiveRecord

    • 一行数据 = 一个对象

    • 几千行就是几千个对象

一个保守的真实模型:

  • 11MB 输入

  • 内存峰值 300MB~1GB / 单请求

5 个并发:

  • 300MB × 5 = 1.5GB(轻度)

  • 1GB × 5 = 5GB(直接穿)

再加上:

  • PHP-FPM worker 不会及时释放内存

  • 其他 worker + opcache + 系统开销

4GB 内存 + swap 被顶穿,完全合理。

八、为什么 swap 没救回来?

这是另一个常见疑问。

关键点在于:
这是 cgroup MEMCG OOM,不是传统意义上的“全局内存耗尽”。

  • 内核在容器级别判断内存不可满足

  • 高阶页分配失败

  • swap 使用受策略限制

  • OOM Killer 会提前介入

结论 5:

在 Docker 场景下,不能指望 swap 兜底。

九、为什么“ps 查 RSS”也查不到昨天的数据?

你曾尝试:

ps -o pid,rss,cmd -C php-fpm

但想加上时间范围(11:40–12:00)。

这里需要明确一个事实:

结论 6:

Linux 不保存历史进程 RSS。
/proc 是实时视图,不是日志。

进程被 kill 后:

  • /proc/<pid> 消失

  • RSS 信息不可恢复

所以这是工具能力边界问题,不是命令写错

十、最终结论(完整定性)

综合所有证据:

  1. 内核 OOM 日志(MEMCG)

  2. PHP-FPM SIGKILL

  3. Docker 内存配置

  4. 业务写入模型

  5. 并发事实

最终定性:

多个并发的大数据写入请求,导致 PHP-FPM worker 内存持续膨胀,
在 Docker 容器内触发 cgroup OOM,被内核强制 kill。

十一、可落地的解决方案(不是“建议”,而是顺序)

1️⃣ 立刻止血(不改业务逻辑也要做)

PHP-FPM pool 配置:

pm.max_requests = 300
php_admin_value[memory_limit] = 512M

作用:

  • 防止 worker “慢性变胖”

  • 让超限请求在 PHP 层失败,而不是拖死整个容器

2️⃣ 并发控制(极其关键)

对于该写入接口:

  • 同一时间并发 ≤ 1~2

  • 可用:

    • Redis 锁

    • 队列

    • Nginx 限流

3️⃣ 写入方式改造(核心)

  • 不要一次性吃 11MB

  • 分批(500~2000 行)写入

  • 每批写完释放内存

  • 避免 all()、大数组长期驻留

4️⃣ 架构级优化(中长期)

  • 写入从 HTTP → 队列 → CLI worker

  • CLI worker 单独提高 memory_limit

  • 表分区 / 分表

  • 减少写入时的索引负担

十二、这次排障真正的价值

这次问题的价值,不在于“解决了一个 OOM”,而在于:

  • 学会了 证据链式排障

  • 明白了 Docker + PHP 的真实内存模型

  • 知道了 什么问题是事后查不到的

  • 以及:
    为什么很多系统“看起来没问题”,但会突然炸

图片插入顺序总结(你写文章时用)

  1. 图片 1
    journalctl -k | grep -Ei "oom|killed process" 的结果
    👉 放在「内核 OOM 定性」那一节

  2. 图片 2
    docker inspect <container> | grep -i memory 的结果
    👉 放在「Docker 内存配置确认」那一节