跳到主要内容

录制方案

录制是 WebRTC 项目里最容易"看起来简单、做起来一地鸡毛"的模块。客户端 MediaRecorder 看似 5 行代码搞定,真上量就会被卡顿、上传失败、跨平台解码兼容劝退;服务端旁路 RTP 看起来"专业",但 RTP→FFmpeg→MP4 一路有十几个坑。本文把四种主流方案放在一张工程图上,回答选哪种、怎么选、坏的时候是什么样子

选型原则:不要为录制的便利牺牲实时链路的稳定性。录制永远应该是旁路或异步通路,不能阻塞实时收发

1. 四种录制方案的本质

方案录制位置数据形态谁解码输出格式典型延迟
客户端 MediaRecorder浏览器/App媒体流 → 容器客户端webm / mp4实时(边录边出)
服务端旁路 RTP→FFmpegSFU 旁路原始 RTPFFmpeg/GStreamermp4 / mkv / fmp40-2s
合流转推 RTMP/HLSSFU + 合流服务RTP → 合流 → RTMP合流服务flv / mp4 / hls2-6s
云端混流 SaaSLiveKit Egress / 阿里云 / 腾讯云API 远程触发云厂商mp4 / hls / m3u83-10s

2. 横向对比:成本 / 自由度 / 技术栈

维度客户端录制服务端旁路合流转推云端混流
服务端 CPU0低(不解码也能写)中-高(要解码+合成+编码)由云厂商承担
服务端带宽0低(一份 RTP 拷贝)中(出 RTMP)中(订阅 SFU)
存储位置客户端→OSS自建 OSS/NFSOSS / CDN云厂商
画面自由度单端原画单路逐流服务端定布局云厂商模板
多人合成不支持不支持(需自合)原生支持原生支持
录制可靠性弱(客户端崩溃即丢)
合规可控弱(依赖端)中(数据出域)
接入工作量极低(数小时)中(1-2 周)中-高(2-4 周)极低(1-2 天)
单分钟成本几乎为零中-高
典型场景调试 / 个人录课 / 客户端补录合规存档 / AI 训练数据直播转推 / 双端回放创业期省事

一句话决策:MVP 用客户端 + 云端混流;规模化用服务端旁路 + 自合流;合规重的项目必须用服务端旁路并保留单路原始流。

3. 方案一:客户端 MediaRecorder

3.1 为什么选

  • 0 服务端成本
  • 实时拿到媒体流,可在端做端到端加密
  • 端能录到 SFU 之外的内容(比如 Canvas 合成、屏幕标注、本地降噪后的音频)

3.2 为什么不选

  • 客户端崩溃 = 数据丢失
  • 不同浏览器 / 不同版本支持的 codec 差异大(Safari MediaRecorder 历史悠久但限制多)
  • 上传分片、断点续传逻辑要自己写
  • 多人合成必须服务端做,端只录单路

3.3 浏览器支持矩阵

浏览器容器视频音频
Chrome / Edgewebm / mp4VP8 / VP9 / H.264 / AV1Opus / AAC
FirefoxwebmVP8 / VP9Opus
Safari 15+mp4H.264 / HEVCAAC

:跨浏览器的最大公约数仍然是 video/webm; codecs="vp8,opus"。如果团队需要直接出 mp4 给运营,安卓/iOS 端用原生 SDK 录会更省心。

3.4 代码示例(Web)

type RecorderOptions = {
stream: MediaStream;
onChunk: (chunk: Blob, seq: number) => Promise<void>;
mimeType?: string;
timesliceMs?: number;
};

class ClientRecorder {
private rec: MediaRecorder | null = null;
private seq = 0;

start(opts: RecorderOptions): void {
const mimeType = opts.mimeType ?? this.pickMimeType();
if (!MediaRecorder.isTypeSupported(mimeType)) {
throw new Error(`unsupported mimeType: ${mimeType}`);
}
this.rec = new MediaRecorder(opts.stream, {
mimeType,
videoBitsPerSecond: 1_500_000,
audioBitsPerSecond: 96_000,
});

this.rec.ondataavailable = async (e) => {
if (e.data.size === 0) return;
// 分片立刻上传,避免端崩溃丢全量
await opts.onChunk(e.data, this.seq++);
};

this.rec.onerror = (e) => console.error('[recorder]', e);
// 每 2s 切片一次,断电也只丢 2s
this.rec.start(opts.timesliceMs ?? 2000);
}

stop(): void {
this.rec?.stop();
this.rec = null;
}

private pickMimeType(): string {
const candidates = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/mp4;codecs=h264,aac',
];
return candidates.find(MediaRecorder.isTypeSupported) ?? 'video/webm';
}
}

3.5 分片上传策略

// 用预签名 URL + Multipart 写入对象存储
async function uploadChunk(roomId: string, userId: string, chunk: Blob, seq: number) {
const key = `recordings/${roomId}/${userId}/part-${String(seq).padStart(6, '0')}.webm`;
const presigned = await fetch(`/api/sign?key=${encodeURIComponent(key)}`).then((r) => r.json());
await fetch(presigned.url, {
method: 'PUT',
body: chunk,
headers: { 'Content-Type': 'video/webm' },
});
}

服务端最后用 cat part-*.webm | ffmpeg -i pipe:0 -c copy out.mp4 拼回完整文件,或者用 ffmpeg -f concat 列表合并。WebM 的 EBML 容器对单纯 cat 不完全友好,建议保留前导分片头并在服务端做 mkvmergeffmpeg -i input.webm -c copy out.mkv 重封装。

3.6 失败的样子

  • iOS Safari 切到后台 30s,MediaRecorder 自己停了,没有 onerror
  • 端崩溃后最后一片没 flush,丢到 ondataavailable 之前的 buffer
  • 端时间戳被 Date.now() 改写,跨片对齐错位
  • 用户在录制中断网,fetch 阻塞导致主线程帧延迟跳变

4. 方案二:服务端旁路 RTP→FFmpeg

4.1 为什么选

  • 客户端无感,端崩溃不影响录制
  • 拿到的是原始 RTP,可逐路保留供 AI 训练 / 审计
  • 与 SFU 解耦,录制服务可水平扩

4.2 为什么不选

  • 多人合成需另起合流服务(见方案三)
  • 跨编码兼容(VP9/AV1/H.264/HEVC)要在 FFmpeg 端处理
  • 落盘 IO 与时钟漂移要自己 cover

4.3 架构

要点:

  • 一路 Producer 对应一个 Recorder Worker,进程隔离方便重启
  • 录的是原始 RTP,记得保留 SDP(payload 类型、SSRC、时钟率),不然回放对不上
  • fmp4 分片落盘比单文件录制更抗崩溃

4.4 mediasoup PlainTransport 旁路示例

// 在 SFU 上为某个 Producer 起 PlainTransport 把 RTP 旁路出去
async function startPlainRecord(router: Router, producer: Producer) {
const transport = await router.createPlainTransport({
listenIp: { ip: '127.0.0.1' },
rtcpMux: false,
comedia: false,
});
await transport.connect({ ip: '127.0.0.1', port: 5004, rtcpPort: 5005 });

const consumer = await transport.consume({
producerId: producer.id,
rtpCapabilities: router.rtpCapabilities,
paused: false,
});

// 生成 SDP,FFmpeg 凭这个 SDP 拉 RTP
const sdp = buildSdp({
payloadType: consumer.rtpParameters.codecs[0].payloadType,
codec: consumer.rtpParameters.codecs[0].mimeType,
clockRate: consumer.rtpParameters.codecs[0].clockRate,
port: 5004,
ssrc: consumer.rtpParameters.encodings![0].ssrc,
});

await fs.writeFile(`/tmp/${producer.id}.sdp`, sdp);
return { transport, consumer };
}

4.5 FFmpeg 拉 RTP 落盘

# 用 SDP 启动 FFmpeg,落 fmp4 分片,每 4s 一个 chunk
ffmpeg -loglevel warning \
-protocol_whitelist file,udp,rtp \
-i /tmp/producer-abc.sdp \
-c copy \
-f segment -segment_time 4 -reset_timestamps 1 \
-movflags +frag_keyframe+empty_moov+default_base_moof \
-strftime 1 \
/data/recordings/room-001/producer-abc-%Y%m%d-%H%M%S-%03d.mp4

字段解释:

  • protocol_whitelist file,udp,rtp:FFmpeg 默认会拒绝 SDP,必须显式开
  • -c copy:不解码,直接转封装到 mp4,性能极高
  • -f segment + -reset_timestamps:分片落盘,崩溃只丢一个片
  • +frag_keyframe:fmp4 关键帧分段,便于在线播放器边下边播

4.6 Go 实现的 RTP 录制器(用 Pion)

package main

import (
"io"
"os"

"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media/oggwriter"
"github.com/pion/webrtc/v4/pkg/media/ivfwriter"
)

func recordTrack(track *webrtc.TrackRemote, dir string) error {
codec := track.Codec().MimeType
var writer interface {
WriteRTP(packet *rtp.Packet) error
Close() error
}
var err error

switch codec {
case webrtc.MimeTypeOpus:
writer, err = oggwriter.New(dir+"/audio.ogg", 48000, 2)
case webrtc.MimeTypeVP8:
writer, err = ivfwriter.New(dir + "/video.ivf")
default:
return io.ErrShortWrite
}
if err != nil {
return err
}
defer writer.Close()

for {
pkt, _, readErr := track.ReadRTP()
if readErr != nil {
if readErr == io.EOF {
return nil
}
return readErr
}
if writeErr := writer.WriteRTP(pkt); writeErr != nil {
return writeErr
}
}
}

func ensureDir(dir string) error {
return os.MkdirAll(dir, 0o755)
}

要点:Pion 自带 oggwriter / ivfwriter / h264writer / webm-saver,省去 FFmpeg 进程。但出来的是单 codec 单文件,最后还是要走一次 ffmpeg -c copy 合成可发布格式。

4.7 失败的样子

  • 没保留 SSRC / 时钟率,回放音视频不同步
  • 单进程录全部 producer,OOM 后整个房间录制丢失
  • 用 UDP 端口被运维 firewall 限制为 50000-50100,房间多了端口不够
  • 没处理 RTCP SR,长时间录像音视频漂移

5. 方案三:合流转推 RTMP / HLS

5.1 为什么选

  • 输出单路 RTMP 给 CDN,万人观看的成本远低于 SFU 扇出
  • 直接出 mp4 / m3u8,端无需做合成
  • 与现有直播基础设施天然兼容

5.2 为什么不选

  • 服务端解码 + 编码,成本是旁路方案的 5-10 倍
  • 服务端定布局,端不能自定义大小
  • 重编码会造成 1-3s 额外延迟

5.3 架构

5.4 FFmpeg 合流命令(宫格 4 人)

ffmpeg -loglevel info \
-protocol_whitelist file,udp,rtp \
-i sdp/user-1.sdp \
-i sdp/user-2.sdp \
-i sdp/user-3.sdp \
-i sdp/user-4.sdp \
-filter_complex "
[0:v]scale=640:360,setsar=1[v0];
[1:v]scale=640:360,setsar=1[v1];
[2:v]scale=640:360,setsar=1[v2];
[3:v]scale=640:360,setsar=1[v3];
[v0][v1]hstack=2[top];
[v2][v3]hstack=2[bottom];
[top][bottom]vstack=2[vout];
[0:a][1:a][2:a][3:a]amix=inputs=4:duration=longest[aout]
" \
-map "[vout]" -map "[aout]" \
-c:v libx264 -preset veryfast -tune zerolatency \
-profile:v main -level 4.0 -g 60 -keyint_min 60 -sc_threshold 0 \
-b:v 2500k -maxrate 3000k -bufsize 5000k \
-c:a aac -b:a 128k -ar 48000 \
-f flv "rtmp://cdn.example.com/live/room-001"

调优要点:

  • -preset veryfast 是延迟与画质的平衡点;ultrafast 画质差,medium 太慢
  • GOP(-g)设为帧率的 2 倍,保证 2s 一关键帧,配合 LL-HLS
  • -tune zerolatency 关掉 B 帧
  • amix duration=longest:有人离开不至于音轨断流

5.5 GStreamer 替代(更精细控制)

gst-launch-1.0 -v \
udpsrc port=5004 caps="application/x-rtp,media=video,encoding-name=VP8,payload=96" ! \
rtpvp8depay ! vp8dec ! videoscale ! video/x-raw,width=640,height=360 ! mix.sink_0 \
udpsrc port=5006 caps="application/x-rtp,media=video,encoding-name=VP8,payload=96" ! \
rtpvp8depay ! vp8dec ! videoscale ! video/x-raw,width=640,height=360 ! mix.sink_1 \
compositor name=mix \
sink_0::xpos=0 sink_0::ypos=0 \
sink_1::xpos=640 sink_1::ypos=0 \
! x264enc tune=zerolatency speed-preset=veryfast bitrate=2500 ! \
flvmux ! rtmpsink location=rtmp://cdn.example.com/live/room-001

GStreamer 的优势是动态合流:人进/出房间时可以热改 sink_pad 不重启进程。FFmpeg 改布局必须重启。

5.6 与 LL-HLS 联动

# 同进程出两路:RTMP 给老 CDN,fmp4 给 LL-HLS
ffmpeg ... \
-f tee -map 0:v -map 0:a \
"[f=flv]rtmp://cdn/live/room-001|
[f=hls:hls_time=1:hls_segment_type=fmp4:hls_playlist_type=event:hls_flags=independent_segments+program_date_time]/data/hls/room-001/index.m3u8"

5.7 失败的样子

  • 单进程合 16 路 1080p,CPU 100% 帧率掉到 8
  • 没设 GOP,CDN 切片不均
  • concat filter 让人离开后再加入,时间轴错位
  • RTMP 推流断后 FFmpeg 直接退出,没有 -reconnect 兜底

6. 方案四:云端混流(SaaS)

6.1 为什么选

  • 不养基础设施,按分钟付费
  • LiveKit Egress / 阿里云 / 腾讯云 / Agora 直接给 API
  • 商务上是合规的"已审计"链路

6.2 为什么不选

  • 数据出域、合规风险
  • 单价比自建高 2-5 倍
  • 高并发被云厂商配额限制
  • 布局深度定制要么不支持要么贵

6.3 LiveKit Egress 示例

package main

import (
"context"
lksdk "github.com/livekit/server-sdk-go/v2"
livekit "github.com/livekit/protocol/livekit"
)

func startRoomComposite(ctx context.Context, room string) (*livekit.EgressInfo, error) {
egressClient := lksdk.NewEgressClient("https://your-livekit", apiKey, apiSecret)
return egressClient.StartRoomCompositeEgress(ctx, &livekit.RoomCompositeEgressRequest{
RoomName: room,
Layout: "grid",
Output: &livekit.RoomCompositeEgressRequest_File{
File: &livekit.EncodedFileOutput{
FileType: livekit.EncodedFileType_MP4,
Filepath: "recordings/{room_name}/{time}.mp4",
Output: &livekit.EncodedFileOutput_S3{
S3: &livekit.S3Upload{
AccessKey: "AKIA...",
Secret: "...",
Region: "ap-northeast-1",
Bucket: "livekit-recordings",
},
},
},
},
})
}

6.4 失败的样子

  • 配额没申请,房间一多录制任务排队
  • 模板布局不支持自定义水印 / logo,运营崩溃
  • S3 凭证泄露,录像被列举/下载

7. 录制元数据:不能只录文件

无论用哪种方案,元数据必须落库,否则录像变成"一堆 mp4 不知道是谁"。

7.1 表结构(PostgreSQL)

CREATE TABLE recording_session (
id BIGSERIAL PRIMARY KEY,
room_id TEXT NOT NULL,
user_id TEXT NOT NULL,
track_kind TEXT NOT NULL CHECK (track_kind IN ('audio', 'video', 'screen')),
codec TEXT NOT NULL,
ssrc BIGINT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ,
duration_ms BIGINT,
bytes BIGINT,
storage_uri TEXT NOT NULL,
sdp_snapshot TEXT,
status TEXT NOT NULL DEFAULT 'recording'
CHECK (status IN ('recording', 'finished', 'failed', 'uploaded')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_recording_room_time ON recording_session(room_id, start_at);
CREATE INDEX idx_recording_status ON recording_session(status, updated_at);

7.2 常用查询

-- 单房间所有录制片段
SELECT user_id, track_kind, codec, start_at, end_at, storage_uri
FROM recording_session
WHERE room_id = 'room-001'
ORDER BY start_at;

-- 状态卡在 recording 超过 1 小时的脏数据
SELECT id, room_id, user_id, start_at
FROM recording_session
WHERE status = 'recording' AND start_at < NOW() - INTERVAL '1 hour';

-- 按月统计录制时长(小时)
SELECT date_trunc('month', start_at) AS month,
round(sum(duration_ms) / 1000.0 / 3600.0, 2) AS hours
FROM recording_session
WHERE status = 'finished'
GROUP BY 1
ORDER BY 1;

7.3 监控指标(PromQL)

# 在录会话数
sum(recorder_session_active{status="recording"})

# 5 分钟内失败率
sum(rate(recorder_session_failed_total[5m]))
/
sum(rate(recorder_session_started_total[5m]))

# FFmpeg worker CPU
avg by (worker) (rate(process_cpu_seconds_total{job="recorder"}[1m]))

# 上传到 OSS 的 P95 耗时
histogram_quantile(0.95, sum by (le) (rate(recorder_upload_duration_seconds_bucket[5m])))

8. 端到端加密下的录制

E2EE(Insertable Streams / SFrame)开启后,SFU 看到的是密文,旁路出来的 RTP 直接录是密文垃圾。常见解法:

解法思路代价
客户端录制(方案一)在端解密后录端崩溃丢数据
端密钥托管 + 服务端解密录制服务保管会话密钥破坏 E2EE 承诺
录密文 + 离线解密服务端只存密文,事后由授权端解密流程复杂、不能即时回放

合规录制 + E2EE 本质冲突,必须先和法务/合规对齐能不能"端转译给托管录制器"。

9. 落地清单(直接抄)

  • 录制是旁路通路,不阻塞实时链路
  • 元数据先于文件落库(哪怕文件还在写)
  • 分片或 fmp4 落盘,崩溃只丢 < 5s
  • 保留 SDP / SSRC / 时钟率 用于回放还原
  • 上传走多通道,OSS / 备份 OSS / 异步对账
  • 监控:在录数 / 失败率 / Worker CPU / 上传 P95
  • E2EE 开启时录制方案要重新评审
  • 留 FFmpeg -reconnect 兜底,RTMP / S3 都加 retry
  • 录制完成有事件通知业务(Webhook / Kafka)

10. 反模式

反模式后果替代
用 SFU 主进程做合流主链路被合流抖动拖垮旁路独立进程
单文件录到底崩溃丢全量fmp4 分片
录前不写元数据文件孤儿先 INSERT 再开 FFmpeg
录后再合成 mp4工作流复杂、用户等很久录的时候就 mp4
FFmpeg 退出无重连RTMP 断一次就丢全场-reconnect 1 -reconnect_streamed 1
客户端录制不分片长会议内存涨爆start(timeslice) 切片
重新编码 1080p 给 720p 输出多余成本SFU 选 simulcast 中层直接 copy
E2EE 房间硬塞服务端录制录到密文垃圾客户端录或合规审批
录制 worker 共享主网卡一个房间打满网卡影响全集群独立网卡 / QoS
录制完成不做对账文件丢失发现晚元数据状态机 + 对账任务

11. 一张图选方案

12. 权威资料