录制方案
录制是 WebRTC 项目里最容易"看起来简单、做起来一地鸡毛"的模块。客户端 MediaRecorder 看似 5 行代码搞定,真上量就会被卡顿、上传失败、跨平台解码兼容劝退;服务端旁路 RTP 看起来"专业",但 RTP→FFmpeg→MP4 一路有十几个坑。本文把四种主流方案放在一张工程图上,回答选哪种、怎么选、坏的时候是什么样子。
选型原则:不要为录制的便利牺牲实时链路的稳定性。录制永远应该是旁路或异步通路,不能阻塞实时收发。
1. 四种录制方案的本质
| 方案 | 录制位置 | 数据形态 | 谁解码 | 输出格式 | 典型延迟 |
|---|---|---|---|---|---|
| 客户端 MediaRecorder | 浏览器/App | 媒体流 → 容器 | 客户端 | webm / mp4 | 实时(边录边出) |
| 服务端旁路 RTP→FFmpeg | SFU 旁路 | 原始 RTP | FFmpeg/GStreamer | mp4 / mkv / fmp4 | 0-2s |
| 合流转推 RTMP/HLS | SFU + 合流服务 | RTP → 合流 → RTMP | 合流服务 | flv / mp4 / hls | 2-6s |
| 云端混流 SaaS | LiveKit Egress / 阿里云 / 腾讯云 | API 远程触发 | 云厂商 | mp4 / hls / m3u8 | 3-10s |
2. 横向对比:成本 / 自由度 / 技术栈
| 维度 | 客户端录制 | 服务端旁路 | 合流转推 | 云端混流 |
|---|---|---|---|---|
| 服务端 CPU | 0 | 低(不解码也能写) | 中-高(要解码+合成+编码) | 由云厂商承担 |
| 服务端带宽 | 0 | 低(一份 RTP 拷贝) | 中(出 RTMP) | 中(订阅 SFU) |
| 存储位置 | 客户端→OSS | 自建 OSS/NFS | OSS / CDN | 云厂商 |
| 画面自由度 | 单端原画 | 单路逐流 | 服务端定布局 | 云厂商模板 |
| 多人合成 | 不支持 | 不支持(需自合) | 原生支持 | 原生支持 |
| 录制可靠性 | 弱(客户端崩溃即丢) | 强 | 强 | 强 |
| 合规可控 | 弱(依赖端) | 强 | 强 | 中(数据出域) |
| 接入工作量 | 极低(数小时) | 中(1-2 周) | 中-高(2-4 周) | 极低(1-2 天) |
| 单分钟成本 | 几乎为零 | 低 | 中 | 中-高 |
| 典型场景 | 调试 / 个人录课 / 客户端补录 | 合规存档 / AI 训练数据 | 直播转推 / 双端回放 | 创业期省事 |
一句话决策:MVP 用客户端 + 云端混流;规模化用服务端旁路 + 自合流;合规重的项目必须用服务端旁路并保留单路原始流。
3. 方案一:客户端 MediaRecorder
3.1 为什么选
- 0 服务端成本
- 实时拿到媒体流,可在端做端到端加密
- 端能录到 SFU 之外的内容(比如 Canvas 合成、屏幕标注、本地降噪后的音频)
3.2 为什么不选
- 客户端崩溃 = 数据丢失
- 不同浏览器 / 不同版本支持的 codec 差异大(Safari MediaRecorder 历史悠久但限制多)
- 上传分片、断点续传逻辑要自己写
- 多人合成必须服务端做,端只录单路
3.3 浏览器支持矩阵
| 浏览器 | 容器 | 视频 | 音频 |
|---|---|---|---|
| Chrome / Edge | webm / mp4 | VP8 / VP9 / H.264 / AV1 | Opus / AAC |
| Firefox | webm | VP8 / VP9 | Opus |
| Safari 15+ | mp4 | H.264 / HEVC | AAC |
坑:跨浏览器的最大公约数仍然是 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 不完全友好,建议保留前导分片头并在服务端做 mkvmerge 或 ffmpeg -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 切片不均
- 用
concatfilter 让人离开后再加入,时间轴错位 - 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. 权威资料
- W3C MediaRecorder API: https://www.w3.org/TR/mediastream-recording/
- mediasoup PlainTransport 文档: https://mediasoup.org/documentation/v3/mediasoup/api/#PlainTransport
- LiveKit Egress: https://docs.livekit.io/home/egress/overview/
- Pion Recording examples: https://github.com/pion/example-webrtc-applications/tree/master/save-to-disk
- FFmpeg RTP / SDP guide: https://trac.ffmpeg.org/wiki/StreamingGuide
- GStreamer compositor: https://gstreamer.freedesktop.org/documentation/compositor/
- Janus recorder plugin: https://janus.conf.meetecho.com/docs/janus__recordplay_8c.html
- ISO/IEC 14496-12 (fmp4): https://www.iso.org/standard/68960.html
- 核对日期:2026-06-22