前言
「爆内存」在不同的人嘴里往往指三件不同的事,先分清楚才能对症下药:
- PHP 报
Allowed memory size of ... bytes exhausted—— 这是 PHP 进程自己的memory_limit被打满。 - 进程被内核杀掉,日志里出现
Killed或退出码 137 —— 这是操作系统 OOM Killer 干掉了 ffmpeg 或 PHP 进程。 - 机器整体卡死、swap 被打满 —— 这是并发叠加后的总量问题,单个进程看起来很「正常」。
4K 素材(3840×2160)把这三类问题的触发概率同时放大了:单帧原始数据是 1080p 的四倍,而 ffmpeg 为了保证吞吐,会同时持有若干帧的缓冲。1080p 时代「随便跑跑」的参数组合,换到 4K 就直接把内存吃干净。
本文按「先算清楚一帧占多少内存 → 再分清爆的是谁 → 再逐个拧紧旋钮」的顺序展开,最后给出一份带非阻塞日志读取和内存统计的完整 PHP 脚本(PHP 8.x,CLI 运行)。
一、先算清楚:一帧到底占多少内存
视频解码后的原始帧是 YUV 格式,最常见的 YUV420P 每像素 1.5 字节。单帧大小用这个公式估算:
单帧字节数 ≈ 宽 × 高 × 1.5| 分辨率 | 像素数 | YUV420P 单帧 | 相对 1080p |
|---|---|---|---|
| 1280×720 | 921,600 | 约 1.3 MB | 约 0.44 倍 |
| 1920×1080 | 2,073,600 | 约 3.0 MB | 1 倍 |
| 2560×1440 | 3,686,400 | 约 5.3 MB | 约 1.8 倍 |
| 3840×2160 | 8,294,400 | 约 11.9 MB | 4 倍 |
单帧 12 MB 听起来不多,但解码器不会只持有 1 帧。为了让解码和显示并行,帧级多线程会持有「线程数」个帧缓冲;参考帧(ref frames)还要额外保存若干帧用于运动补偿;滤镜链上的每一级也会各自持有一帧。所以实际的量级是:
解码内存 ≈ 单帧大小 ×(解码线程数 + 参考帧数 + 滤镜缓冲数)按这个公式,4K 下把线程数从 8 调到 2,内存大致会降到原来的三分之一上下。这不是省一点,而是从「跑不起来」到「跑得起来」的区别。具体数值和实现细节会随 ffmpeg 版本变化,读者应当用下面给出的脚本实测,而不是套用任何文章里的数字。
二、分清爆的是谁
| 现象 | 爆的位置 | 首要排查方向 |
|---|---|---|
| PHP 报 memory exhausted | PHP 进程 | 是否把视频内容读进了 PHP 内存 |
退出码 137 /Killed | ffmpeg 子进程或系统 | ffmpeg 参数:线程数、分辨率、滤镜链 |
| 机器卡死、swap 打满 | 系统总量 | 并发 worker 数 × 单进程内存 |
| 转码中途变慢后失败 | 系统 | 多个 worker 争抢内存导致的整体抖动 |
第一类问题(PHP 自己的内存)在视频场景里几乎总是同一个原因:把文件内容读进了 PHP。
// ❌ 4K 视频动辄几个 GB,这行代码必然把 memory_limit 打爆 $data = file_get_contents('/data/raw/4k.mp4');正确做法是永远只把路径交给 ffmpeg,PHP 侧不碰文件内容。ffmpeg 自己会流式读取,内存占用与文件大小无关,只与分辨率有关。
// ✅ PHP 只传路径 $cmd = ['ffmpeg', '-y', '-i', $srcPath, ...];三、ffmpeg 侧需要拧紧的三个旋钮
3.1 线程数
-threads是影响内存最直接的参数。默认值是按 CPU 核数自动决定,核数越多,帧缓冲越多。对 4K 素材,把它显式调小:
-threads 2代价是编码变慢。这是一个明确的取舍:在内存受限的机器上,宁可慢一点也不要被 OOM Killer 杀掉。而且当多个转码任务并行时,超配线程数并不会带来吞吐提升——CPU 就那么多,上下文切换反而吃掉一部分。
滤镜链另有一套线程控制,在滤镜很重(缩放、降噪、锐化叠加)时也值得单独限制:
-filter_threads 13.2 分辨率与像素格式
如果最终产物根本不需要 4K,那么在滤镜链的最前面就把尺寸降下来,而不是等解码、滤镜处理完再在编码输出时缩小:
-vf scale=-2:1080scale=-2:1080里的-2表示「宽度按比例自动算,且必须能被 2 整除」(很多编码器要求宽高为偶数)。把缩放放在链首,后续滤镜和编码都在更小的帧上工作,内存和 CPU 都会下降。
3.3 编码器的前瞻与参考帧
x264 为了做码率控制会保留若干帧做前瞻(lookahead),参考帧也会占用缓冲区。这些参数可以通过-x264-params调整。具体可调项和当前默认值,用这条命令看编码器自己列出来的参数说明:
ffmpeg -h encoder=libx264降低前瞻帧数能省内存,代价是码率控制精度下降、画质在快速运动场景下更容易波动。先用默认值,只有在内存确实不够时才动它,并且用实测数据验证收益。
四、PHP 侧真正容易踩的坑:管道死锁
这个问题和内存看起来无关,实际经常一起出现:用proc_open起 ffmpeg,然后把 stdout / stderr 的管道放着不读,等进程结束后再一次性读取。ffmpeg 会持续往 stderr 打进度日志,管道缓冲区(通常几十 KB)一满,ffmpeg 就阻塞在写日志上,而 PHP 阻塞在等待进程结束上——双方互等,形成死锁。
4K 转码耗时长、日志量大,所以这个问题在 4K 场景下特别容易触发;而小文件在缓冲区写满之前就结束了,于是表现为「小文件正常,大文件卡死」。
正确的做法是边跑边读,用stream_select做非阻塞轮询:
<?php declare(strict_types=1); // mem_safe_transcode.php —— PHP 8.x CLI 运行 // 用法: php mem_safe_transcode.php input.mp4 output.mp4 final class FfmpegRunner { /** @var resource|null */ private $proc = null; /** @var array<int, resource> */ private array $pipes = []; public function __construct( private array $cmd, private int $timeoutSec = 1800, ) {} /** 返回退出码;日志边跑边落盘,避免管道写满导致双方互等 */ public function run(string $logFile): int { $descriptors = [ 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; // PHP 7.4+ 支持数组形式的命令,不经过 shell,路径含空格也安全 $proc = proc_open($this->cmd, $descriptors, $pipes); if (!is_resource($proc)) { throw new RuntimeException('无法启动 ffmpeg'); } $this->proc = $proc; $this->pipes = $pipes; stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); $log = fopen($logFile, 'ab'); if ($log === false) { throw new RuntimeException('无法打开日志文件'); } $deadline = microtime(true) + $this->timeoutSec; $tail = ''; // 只保留最后一段输出用于报错,避免自己把内存吃光 $exitCode = 0; while (true) { $read = [$pipes[1], $pipes[2]]; $write = null; $except = null; if (@stream_select($read, $write, $except, 1) === false) { break; } foreach ($read as $pipe) { $chunk = fread($pipe, 8192); if ($chunk === false || $chunk === '') { continue; } fwrite($log, $chunk); // 关键:只留尾部,不要把整份日志积在 PHP 内存里 $tail = substr($tail . $chunk, -2000); } $status = proc_get_status($proc); if (!$status['running']) { $exitCode = (int) $status['exitcode']; break; } if (microtime(true) > $deadline) { proc_terminate($proc, 9); fclose($log); $this->closePipes(); throw new RuntimeException("转码超时,已终止。最后的输出:\n" . $tail); } } fclose($log); $this->closePipes(); proc_close($proc); $this->proc = null; return $exitCode; } private function closePipes(): void { foreach ($this->pipes as $p) { if (is_resource($p)) { fclose($p); } } $this->pipes = []; } } function humanBytes(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB']; $i = 0; $v = (float) $bytes; while ($v >= 1024 && $i < count($units) - 1) { $v /= 1024; $i++; } return sprintf('%.1f %s', $v, $units[$i]); } // ---------- 主流程 ---------- if ($argc < 3) { exit("用法: php mem_safe_transcode.php input.mp4 output.mp4\n"); } $in = $argv[1]; $out = $argv[2]; $runner = new FfmpegRunner([ 'ffmpeg', '-y', '-nostdin', // 不要让 ffmpeg 去读标准输入 '-loglevel', 'warning', // 4K 转码日志量很大,降到 warning 减少管道压力 '-nostats', // 关闭周期性进度统计 '-i', $in, '-threads', '2', // 内存吃紧时最有效的一个旋钮 '-filter_threads', '1', '-vf', 'scale=-2:1080', // 不需要 4K 就尽早降分辨率,放在滤镜链最前面 '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', $out, ], 3600); $t0 = microtime(true); $code = $runner->run(__DIR__ . '/transcode.log'); $cost = microtime(true) - $t0; printf("退出码: %d\n", $code); printf("耗时 : %.1f 秒\n", $cost); // 读取 PHP 进程的峰值内存 $usage = getrusage(); if (is_array($usage) && isset($usage['ru_maxrss'])) { // 注意单位:Linux 是 KB,macOS 是字节 $rss = (int) $usage['ru_maxrss']; $bytes = PHP_OS_FAMILY === 'Darwin' ? $rss : $rss * 1024; printf("PHP 峰值常驻内存: %s(注意这里只统计 PHP 自己,不含 ffmpeg 子进程)\n", humanBytes($bytes)); } printf("PHP 峰值分配内存: %s\n", humanBytes((int) memory_get_peak_usage(true)));在 Linux 上想观察ffmpeg 子进程的真实峰值内存,可以另外开一个终端:
# 找出 ffmpeg 进程的 PID pgrep -a ffmpeg # 看它的峰值常驻内存(VmHWM 单位 KB) grep -E 'VmHWM|VmPeak' /proc/<pid>/statusVmHWM是进程生命周期内的常驻内存峰值,比top里看到的瞬时值更能反映真实占用。这个数字才是判断「参数改动是否有效」的依据。
五、并发层面的总量控制
单进程调优之后,还要算总账:
峰值内存 ≈ worker 数 × (ffmpeg 单进程内存 + PHP 进程内存 + 页面缓存余量)几条经验规则:
- worker 数不要超过物理核数,尤其当每个 ffmpeg 还要用多线程时。4K 场景建议
worker 数 × threads ≤ 核数,甚至更保守。 - 给系统留出余量。文件系统缓存被榨干后,磁盘 IO 会显著变慢,转码时间延长又反过来增加内存占用时间。
- 用 cgroup 或容器内存上限做硬约束,而不是靠估算。让容器 OOM 掉一个 worker(可重试),比整台机器卡死(需要人工介入)要好得多。
常见坑点
1. 把视频读进 PHP 内存
❌$bin = file_get_contents($path);再想办法传给 ffmpeg。 ✅ 只传路径给 ffmpeg,PHP 侧不接触文件内容。
4K 素材动辄几 GB,这一步必然打爆memory_limit,而且就算调到几个 G 也是纯浪费。
2.proc_open之后不读管道
❌ 等proc_close()返回后再stream_get_contents($pipes[2])。 ✅ 用stream_select边跑边读,或至少把 stderr 重定向到文件。
管道缓冲区写满后 ffmpeg 会阻塞,PHP 又在等进程结束,形成死锁。表现是「小文件正常、大文件卡住不动」,很有迷惑性。
3. 把整份 ffmpeg 日志存在 PHP 变量里
❌$output .= $chunk;一路累积到进程结束。 ✅ 逐块写文件,只保留最后 2 KB 用于报错。
4K 转码几十分钟,进度日志可以有几十 MB。你本来是在解决内存问题,结果自己又制造了一个。
4. 认为memory_limit能限制住 ffmpeg
❌ini_set('memory_limit', '512M')之后就放心跑。 ✅ 明白那是 PHP 自己的限制,ffmpeg 子进程的 RSS 完全不受它约束。
memory_limit只作用于 PHP 内部的内存分配器。ffmpeg 是独立进程,用的是自己的堆内存,两者互不相干。
5. 只看平均值就定参数
❌ 看top里 ffmpeg 用了 300 MB,就按这个数乘以并发数去配机器。 ✅ 用VmHWM或容器的内存峰值指标,看的是峰值而不是瞬时值。
内存峰值通常出现在解码启动和滤镜链建立的那几秒,瞬时采样很容易错过。
6. 忘记-nostdin
❌ 在循环里反复proc_openffmpeg,不加-nostdin。 ✅ 加上-nostdin。
不加时 ffmpeg 会尝试读取标准输入,在某些环境下会导致进程行为异常(比如读到前台终端的输入而卡住)。
7. 用-preset placebo追求「最好画质」
❌ 4K 批量转码用最慢的 preset。 ✅ 按对时间的容忍度选,veryfast或faster已经能满足大多数分发场景。
越慢的 preset 意味着更多的分析缓冲与更长的处理链,内存占用和时间成本同时上涨。批量任务里这个取舍尤其明显。
8. 在 Windows 上用getrusage()测内存
❌ 在 Windows 上调用getrusage()期待拿到峰值内存。 ✅ 该函数在 Windows 上不可用;用任务管理器或 Process Explorer 观察,或改在 Linux 上测。
另外即使在 Unix 系上,ru_maxrss的单位也不统一(Linux 是 KB,macOS 是字节),换算错了会得到差 1024 倍的荒谬结论。
总结
| 层面 | 措施 | 效果 |
|---|---|---|
| PHP | 只传路径,不读文件内容 | 避免 PHP 侧内存打爆 |
| PHP | proc_open边跑边读 + 日志截尾 | 消除管道死锁与日志堆积 |
| ffmpeg | -threads 2 | 峰值内存最直接的下降点 |
| ffmpeg | 滤镜链最前面做scale | 后续环节全部在小帧上工作 |
| ffmpeg | -loglevel warning -nostats | 减少输出量,降低管道压力 |
| 编码器 | 必要时调低 x264 前瞻帧数 | 省内存,代价是码率控制精度 |
| 并发 | worker 数 × threads ≤ 核数 | 控制总量,避免系统级 OOM |
| 兜底 | 容器内存上限 + 超时终止 | 失败可重试,而不是机器卡死 |
解决 4K 爆内存的核心是把内存账算清楚:先用「单帧大小 = 宽 × 高 × 1.5」估算量级,再用-threads把最大的一项压下来,同时确保 PHP 侧既没有把文件读进内存,也没有因为不读管道而卡死。剩下的都是在这个基础上的微调,而且每一项都应该用VmHWM之类的实测指标去验证,而不是凭感觉调参。