
前言「爆内存」在不同的人嘴里往往指三件不同的事先分清楚才能对症下药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.xCLI 运行。一、先算清楚一帧到底占多少内存视频解码后的原始帧是 YUV 格式最常见的 YUV420P 每像素 1.5 字节。单帧大小用这个公式估算单帧字节数 ≈ 宽 × 高 × 1.5分辨率像素数YUV420P 单帧相对 1080p1280×720921,600约 1.3 MB约 0.44 倍1920×10802,073,600约 3.0 MB1 倍2560×14403,686,400约 5.3 MB约 1.8 倍3840×21608,294,400约 11.9 MB4 倍单帧 12 MB 听起来不多但解码器不会只持有 1 帧。为了让解码和显示并行帧级多线程会持有「线程数」个帧缓冲参考帧ref frames还要额外保存若干帧用于运动补偿滤镜链上的每一级也会各自持有一帧。所以实际的量级是解码内存 ≈ 单帧大小 ×解码线程数 参考帧数 滤镜缓冲数按这个公式4K 下把线程数从 8 调到 2内存大致会降到原来的三分之一上下。这不是省一点而是从「跑不起来」到「跑得起来」的区别。具体数值和实现细节会随 ffmpeg 版本变化读者应当用下面给出的脚本实测而不是套用任何文章里的数字。二、分清爆的是谁现象爆的位置首要排查方向PHP 报 memory exhaustedPHP 进程是否把视频内容读进了 PHP 内存退出码 137 /Killedffmpeg 子进程或系统ffmpeg 参数线程数、分辨率、滤镜链机器卡死、swap 打满系统总量并发 worker 数 × 单进程内存转码中途变慢后失败系统多个 worker 争抢内存导致的整体抖动第一类问题PHP 自己的内存在视频场景里几乎总是同一个原因把文件内容读进了 PHP。// ❌ 4K 视频动辄几个 GB这行代码必然把 memory_limit 打爆 $data file_get_contents(/data/raw/4k.mp4);正确做法是永远只把路径交给 ffmpegPHP 侧不碰文件内容。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 encoderlibx264降低前瞻帧数能省内存代价是码率控制精度下降、画质在快速运动场景下更容易波动。先用默认值只有在内存确实不够时才动它并且用实测数据验证收益。四、PHP 侧真正容易踩的坑管道死锁这个问题和内存看起来无关实际经常一起出现用proc_open起 ffmpeg然后把 stdout / stderr 的管道放着不读等进程结束后再一次性读取。ffmpeg 会持续往 stderr 打进度日志管道缓冲区通常几十 KB一满ffmpeg 就阻塞在写日志上而 PHP 阻塞在等待进程结束上——双方互等形成死锁。4K 转码耗时长、日志量大所以这个问题在 4K 场景下特别容易触发而小文件在缓冲区写满之前就结束了于是表现为「小文件正常大文件卡死」。正确的做法是边跑边读用stream_select做非阻塞轮询?php declare(strict_types1); // 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 arrayint, 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 是 KBmacOS 是字节 $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。 ✅ 只传路径给 ffmpegPHP 侧不接触文件内容。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 是 KBmacOS 是字节换算错了会得到差 1024 倍的荒谬结论。总结层面措施效果PHP只传路径不读文件内容避免 PHP 侧内存打爆PHPproc_open边跑边读 日志截尾消除管道死锁与日志堆积ffmpeg-threads 2峰值内存最直接的下降点ffmpeg滤镜链最前面做scale后续环节全部在小帧上工作ffmpeg-loglevel warning -nostats减少输出量降低管道压力编码器必要时调低 x264 前瞻帧数省内存代价是码率控制精度并发worker 数 × threads ≤ 核数控制总量避免系统级 OOM兜底容器内存上限 超时终止失败可重试而不是机器卡死解决 4K 爆内存的核心是把内存账算清楚先用「单帧大小 宽 × 高 × 1.5」估算量级再用-threads把最大的一项压下来同时确保 PHP 侧既没有把文件读进内存也没有因为不读管道而卡死。剩下的都是在这个基础上的微调而且每一项都应该用VmHWM之类的实测指标去验证而不是凭感觉调参。