前言

「爆内存」在不同的人嘴里往往指三件不同的事,先分清楚才能对症下药:


  • 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×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');

正确做法是永远只把路径交给 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 侧内存打爆
PHPproc_open 边跑边读 + 日志截尾消除管道死锁与日志堆积
ffmpeg-threads 2峰值内存最直接的下降点
ffmpeg滤镜链最前面做 scale后续环节全部在小帧上工作
ffmpeg-loglevel warning -nostats减少输出量,降低管道压力
编码器必要时调低 x264 前瞻帧数省内存,代价是码率控制精度
并发worker 数 × threads ≤ 核数控制总量,避免系统级 OOM
兜底容器内存上限 + 超时终止失败可重试,而不是机器卡死

解决 4K 爆内存的核心是把内存账算清楚:先用「单帧大小 = 宽 × 高 × 1.5」估算量级,再用 -threads 把最大的一项压下来,同时确保 PHP 侧既没有把文件读进内存,也没有因为不读管道而卡死。剩下的都是在这个基础上的微调,而且每一项都应该用 VmHWM 之类的实测指标去验证,而不是凭感觉调参。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐