PHP8怎么解决ffmpeg处理4k视频爆内存
前言
「爆内存」在不同的人嘴里往往指三件不同的事,先分清楚才能对症下药:
- 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 1
3.2 分辨率与像素格式
如果最终产物根本不需要 4K,那么在滤镜链的最前面就把尺寸降下来,而不是等解码、滤镜处理完再在编码输出时缩小:
-vf scale=-2:1080
scale=-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>/status
VmHWM 是进程生命周期内的常驻内存峰值,比 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_open ffmpeg,不加 -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 之类的实测指标去验证,而不是凭感觉调参。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)