1. 从一次"无声崩溃"说起:为什么业务类初始化值得单独拎出来讲
如果你正在用 mediasoup 做流媒体服务端开发,大概率经历过这样的场景:Worker 起来了,Router 也创建了,信令通道看起来一切正常,但客户端就是拉不到流,或者推流推到一半突然断掉,日志里干干净净,什么错误都没有。这种"无声崩溃"十有八九不是媒体协商的问题,而是业务类在初始化阶段就埋了雷。
mediasoup 本身是一个偏底层的 SFU 库,它只负责媒体转发这一件事,至于房间怎么管、参与者怎么映射、Transport 什么时候创建、Producer 和 Consumer 怎么关联,这些统统属于"业务类"的范畴。很多教程讲到 mediasoup 的初始化,往往只讲到 Worker 和 Router 就停了,剩下的全丢给读者自己悟。但实际项目里,真正决定系统稳不稳的,恰恰是这些业务类的初始化顺序和状态管理。
这篇内容适合两类人:一类是刚接触 mediasoup、已经能跑通官方 demo 但不知道如何组织业务代码的开发者;另一类是做了一段时间、系统开始出现各种诡异问题、想回头梳理初始化链路的老手。我会围绕业务类初始化的核心逻辑展开,把顺序、依赖、状态、常见坑都拆开讲清楚,尽量做到你看完就能对照自己的代码改。
需要先说明一点:mediasoup 的版本迭代比较快,不同版本 API 有差异,下面讲到的思路和结构是通用的,具体方法名请以你所用版本的文档为准。我这边主要以 v3 系列的常见实践为参考。
2. 业务类初始化的整体分层:谁先谁后不是随便定的
2.1 三层结构:进程级、房间级、参与者级
在动手写代码之前,先把业务类的层次理清楚,否则很容易写成一锅粥。我习惯把 mediasoup 相关的业务类分成三层:
- 进程级(Worker 层):整个服务进程共享的 Worker 池,负责 CPU 资源的分配。这一层通常在服务启动时初始化一次,生命周期跟进程一致。
- 房间级(Router 层):每个房间对应一个 Router,Router 承载了这个房间里所有参与者的媒体转发逻辑。房间创建时初始化,房间销毁时释放。
- 参与者级(Transport / Producer / Consumer 层):每个加入房间的用户都有自己的 Transport,推流产生 Producer,拉流产生 Consumer。用户加入时初始化,离开时清理。
这三层的初始化顺序是严格自上而下的。Worker 没起来,Router 创建会直接抛错;Router 没创建,Transport 无从谈起。听起来是废话,但实际项目里我见过不少人在房间还没完全初始化完就把用户塞进去,结果就是各种空指针和超时。
2.2 为什么不能"用到再创建"
有人会想,何必这么麻烦,等真正需要的时候再懒加载不就行了?比如用户要推流的时候再创建 Transport。这个思路在小规模场景下能跑,但一旦并发上来就会出问题。
原因在于 mediasoup 的很多操作是异步的,创建 Transport 需要跟底层 Worker 通信,这个往返是有延迟的。如果等到用户点击"推流"按钮才开始创建,用户会明显感觉到卡顿。更麻烦的是,如果多个操作并发触发,可能出现重复创建或者状态竞争。所以我的做法是:在用户加入房间的信令处理阶段,就把该用户需要的 Transport 全部创建好,等真正推拉流时直接复用。
2.3 初始化失败的兜底策略
初始化链路一长,失败点就多。Worker 可能因为资源不足起不来,Router 可能因为参数错误创建失败,Transport 可能因为网络问题超时。每一层都要有兜底。
我的经验是:进程级初始化失败直接让服务启动失败,因为 Worker 都起不来,这个进程留着也没用,不如让编排系统重新拉起。房间级初始化失败要回滚,把已经创建的资源释放掉,避免内存泄漏。参与者级初始化失败要通知客户端,让客户端知道是哪一步出了问题,而不是干等。
这里有个细节:mediasoup 的资源释放是异步的,close() 调用之后不代表资源立刻回收。所以在回滚逻辑里,不要假设 close() 之后对象就没了,该置空的引用还是要置空。
3. Worker 层的初始化:别小看这几行代码
3.1 Worker 数量怎么定
Worker 是 mediasoup 的媒体处理进程,每个 Worker 是一个独立的子进程。Worker 数量直接决定了这个服务能榨干多少 CPU。
官方建议是每个 CPU 核心对应一个 Worker。但这个建议有个前提:你的服务是独占这台机器的。如果是容器化部署,容器本身有 CPU limit,那就不能按物理核心数来算,要按 limit 来算。比如容器限制 2 核,你起 8 个 Worker,结果就是互相抢 CPU,性能反而下降。
我一般的做法是:
const os = require('os'); const numWorkers = process.env.WORKER_COUNT ? parseInt(process.env.WORKER_COUNT) : Math.min(os.cpus().length, 4);用环境变量覆盖,默认取 CPU 核心数和 4 的较小值。为什么封顶 4?因为大多数中小规模场景 4 个 Worker 已经够用,起太多反而增加调度开销和内存占用。等真的压测发现不够,再往上调。
3.2 Worker 的创建参数
创建 Worker 时可以传一些参数,最常用的是rtcMinPort和rtcMaxPort,指定 WebRTC 使用的 UDP/TCP 端口范围。这个范围要跟你的防火墙规则对齐,否则媒体流会被挡在外面。
const worker = await mediasoup.createWorker({ logLevel: 'warn', rtcMinPort: 40000, rtcMaxPort: 40100, });端口范围给多大?每个 Transport 会占用端口,具体占用多少跟 ICE 候选有关。保守估计,每个参与者可能占用 2 到 4 个端口。如果你预计单机最多 100 个参与者,那至少准备 400 个端口。我一般会留一倍余量,所以上面例子里给了 100 个端口是远远不够的,实际项目里我会开到 40000 到 41000 这样的范围。
注意:端口范围不是越大越好。范围太大,防火墙规则不好写,排查问题也麻烦。够用就行,留点余量。
3.3 Worker 异常退出的监听
Worker 是子进程,子进程是会挂的。挂的原因可能是内存溢出、可能是底层崩溃、也可能是被系统 kill 掉。如果不监听,Worker 挂了你的服务还在傻跑,新来的请求全部失败。
worker.on('died', (error) => { console.error('worker died', error); // 标记该 worker 不可用,从池子里移除 // 如果池子里没有可用 worker,考虑重启服务 });这里有个坑:Worker 挂掉之后,绑定在它上面的 Router、Transport 全部失效。如果你有房间映射到这些 Router,需要把这些房间标记为异常,通知里面的用户重新加入。这个逻辑不做,用户就会卡在一个永远连不上的房间里。
4. Router 层的初始化:房间的真正起点
4.1 Router 与 Worker 的绑定关系
Router 必须创建在某个 Worker 上。一个 Worker 可以承载多个 Router,一个 Router 只能属于一个 Worker。这个绑定关系在创建时就确定了,之后不能改。
所以创建 Router 的时候,要先从 Worker 池里挑一个。挑的策略有几种:
- 轮询:简单,均匀,但没考虑负载。
- 最少 Router 数:挑当前承载 Router 最少的 Worker,相对均衡。
- 按房间 ID 哈希:同一个房间总是落到同一个 Worker,方便调试,但可能不均。
我一般用最少 Router 数这个策略,实现简单,效果也还行。如果你的场景对房间和 Worker 的绑定有特殊要求,再考虑哈希。
function pickWorker(workers) { return workers.reduce((min, w) => w.routerCount < min.routerCount ? w : min ); }4.2 Router 的 RTP 能力配置
创建 Router 时需要指定这个 Router 支持哪些 RTP 能力(codec、header extension 等)。这个配置决定了房间里能用什么编码格式。
const router = await worker.createRouter({ mediaCodecs: [ { kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2, }, { kind: 'video', mimeType: 'video/VP8', clockRate: 90000, }, ], });这里的关键是:Router 的 mediaCodecs 必须跟客户端的能力有交集。如果 Router 只支持 VP8,客户端只会 H264,那协商就会失败。实际项目里,我一般会同时支持 VP8、H264、VP9,让客户端去选。
还有一个容易忽略的点:Router 创建后,mediaCodecs 不能改。所以如果你有不同编码需求的房间,要么创建不同的 Router,要么一开始就把所有可能用到的 codec 都配上。
4.3 房间与 Router 的生命周期管理
房间创建时创建 Router,房间销毁时关闭 Router。听起来简单,但"房间什么时候算销毁"这个问题很微妙。
是最后一个用户离开就销毁吗?不一定。如果用户只是临时断线,马上重连,你把房间销毁了,他重连回来发现房间没了,体验很差。所以通常会给房间加一个"空置超时",最后一个用户离开后,等一段时间(比如 30 秒),如果没人回来再销毁。
function onUserLeave(room) { if (room.users.size === 0) { room.emptyTimer = setTimeout(() => { room.router.close(); rooms.delete(room.id); }, 30000); } } function onUserJoin(room) { if (room.emptyTimer) { clearTimeout(room.emptyTimer); room.emptyTimer = null; } }这个空置超时的时长要权衡:太短,用户重连来不及;太长,资源回收慢。30 秒是个比较稳妥的值,你可以根据自己场景调整。
5. 参与者级初始化:Transport、Producer、Consumer 的创建顺序
5.1 Transport 的两种类型与创建时机
mediasoup 里有两种 Transport:WebRtcTransport和PlainTransport。前者用于浏览器等 WebRTC 客户端,后者用于跟其他媒体系统对接(比如跟 FFmpeg、跟其他 SFU)。
大多数场景用的是 WebRtcTransport。每个参与者通常需要两个 Transport:一个用于发送(推流),一个用于接收(拉流)。也可以只用一个双向 Transport,但我不推荐,因为分开更清晰,权限控制也更容易。
创建 WebRtcTransport 时需要指定监听 IP:
const transport = await router.createWebRtcTransport({ listenIps: [ { ip: '0.0.0.0', announcedIp: '你的公网IP' } ], enableUdp: true, enableTcp: true, preferUdp: true, });announcedIp这个参数是坑最多的地方。它告诉客户端"你应该连我这个地址"。如果你填的是内网地址,客户端在外网就连不上。如果你不填,mediasoup 会用 listenIp,那多半也是内网地址。所以生产环境一定要显式指定 announcedIp 为公网地址。
5.2 Transport 创建后要立刻做的事
Transport 创建出来只是第一步,接下来还要:
- 获取 ICE 候选和 DTLS 参数,发给客户端。客户端用这些信息建立连接。
- 监听 dtlsstatechange,DTLS 连接建立成功才算 Transport 真正可用。
- 监听 close,Transport 关闭时清理相关资源。
const { id, iceParameters, iceCandidates, dtlsParameters } = transport; transport.on('dtlsstatechange', (state) => { if (state === 'closed') { transport.close(); } });这里有个经验:不要假设 Transport 创建成功就一定能连上。DTLS 握手可能因为各种原因失败(证书问题、网络问题、防火墙问题)。所以要有超时机制,比如 10 秒内没收到 dtlsstatechange 为 connected,就认为这个 Transport 有问题,通知客户端重试。
5.3 Producer 的创建与暂停恢复
用户开始推流时,客户端会发一个 produce 请求,服务端据此创建 Producer。
const producer = await transport.produce({ kind: 'video', rtpParameters: clientRtpParameters, });创建 Producer 之后,要把它跟这个用户的会话关联起来,方便后续管理。同时要监听 Producer 的事件:
- transportclose:Transport 关了,Producer 也就没了。
- pause / resume:用户暂停或恢复推流。
- close:Producer 被关闭。
Producer 的暂停恢复是个容易被忽略的功能。用户可能想临时关掉摄像头但保持连接,这时候用 pause 比关闭再重建要高效得多。但要注意,pause 之后 Producer 还在,只是不发送媒体数据,资源占用会降低但不会归零。
5.4 Consumer 的创建与层级选择
拉流时创建 Consumer。创建 Consumer 需要指定一个 Producer 作为源,还要指定 RTP 能力(通常从客户端的 recvTransport 拿)。
const consumer = await transport.consume({ producerId: producer.id, rtpCapabilities: clientRtpCapabilities, paused: true, // 先暂停,等客户端准备好再恢复 });这里有个关键点:Consumer 创建时先设为 paused。为什么?因为创建 Consumer 是异步的,创建完成到客户端真正准备好接收之间有时间差。如果不暂停,媒体数据可能在这段时间里发出去然后丢掉,造成花屏或者卡顿。等客户端确认准备好了,再调用 resume。
Consumer 还有层级的概念(consumer.setPreferredLayers),用于 simulcast 场景。如果 Producer 推了多个分辨率的流,Consumer 可以根据自己的网络情况选择拉哪个层级。这个功能在弱网环境下特别有用,但配置起来有点绕,后面有机会单独讲。
6. 初始化链路的常见坑与排查思路
6.1 坑一:Worker 端口范围与防火墙不匹配
这个坑的典型表现是:信令一切正常,ICE 候选也交换了,但媒体就是不通。看日志发现 ICE 一直处于 checking 状态,最后超时。
原因通常是 Worker 的 rtcMinPort/rtcMaxPort 指定的端口范围,跟服务器防火墙放行的范围不一致。mediasoup 会在这个范围里随机选端口,如果选中的端口被防火墙挡了,媒体就通不了。
排查方法:先确认 Worker 的端口范围,然后在服务器上检查防火墙规则,确保这个范围是放行的。如果是云服务器,还要检查安全组规则。
6.2 坑二:announcedIp 配置错误
这个坑的表现是:内网测试一切正常,一上外网就拉不到流。
原因就是 announcedIp 填了内网地址。客户端拿到 ICE 候选后,尝试连内网地址,当然连不上。
排查方法:检查创建 WebRtcTransport 时的 announcedIp 参数。如果是动态获取公网 IP,要确保获取逻辑正确,并且在 IP 变化时能更新。
6.3 坑三:Router 的 mediaCodecs 与客户端不匹配
表现是:produce 请求被拒绝,错误信息里提到 codec 不支持。
原因是 Router 配置的 mediaCodecs 跟客户端想推的编码格式没有交集。比如客户端想推 H264,但 Router 只配了 VP8。
排查方法:打印 Router 的 rtpCapabilities,跟客户端的 rtpCapabilities 对比,看有没有交集。如果没有,要么改 Router 配置,要么让客户端换编码。
6.4 坑四:资源释放不彻底导致内存泄漏
表现是:服务跑一段时间后内存持续上涨,最后 OOM。
原因是各种 close() 调用不完整。比如用户离开时只关了 Transport,没关 Producer 和 Consumer;或者房间销毁时只关了 Router,没清理房间内的其他资源。
排查方法:给每个资源都加上 close 监听,在监听里做级联清理。同时用工具(比如 clinic、heapdump)定期检查内存快照,看有没有异常增长的对象。
7. 一套可复用的初始化流程模板
把上面的内容串起来,一个完整的初始化流程大概是这样:
服务启动阶段:
- 根据 CPU 配置创建 Worker 池。
- 为每个 Worker 注册 died 事件监听。
- 启动信令服务,等待客户端连接。
用户加入房间阶段:
- 查找或创建房间。
- 房间不存在则创建 Router,配置 mediaCodecs。
- 为用户创建 sendTransport 和 recvTransport。
- 把 ICE 参数和 DTLS 参数返回给客户端。
- 等待客户端确认 Transport 连接成功。
用户推流阶段:
- 客户端发送 produce 请求。
- 服务端在 sendTransport 上创建 Producer。
- 关联 Producer 到用户会话。
- 通知房间内其他用户有新的流可用。
用户拉流阶段:
- 客户端发送 consume 请求。
- 服务端在 recvTransport 上创建 Consumer,初始为 paused。
- 返回 Consumer 参数给客户端。
- 客户端准备好后,服务端 resume Consumer。
用户离开阶段:
- 关闭该用户的所有 Producer 和 Consumer。
- 关闭该用户的 Transport。
- 从房间用户列表移除。
- 如果房间空了,启动空置超时。
这套流程我在几个项目里用过,基本能覆盖大多数场景。当然具体实现要根据你的信令协议和业务需求调整,但大框架是通用的。
8. 关于状态同步的一点个人体会
最后聊一个容易被忽略但很重要的话题:状态同步。
mediasoup 的服务端状态和客户端状态是两套东西。服务端认为某个 Producer 存在,不代表客户端也认为它存在。网络抖动、页面刷新、进程崩溃都可能导致两边状态不一致。
我的做法是:服务端维护一份权威状态,客户端定期同步。客户端加入房间时,服务端把当前房间的所有 Producer 列表发过去;客户端拉流时,以服务端的列表为准。如果客户端发现本地状态跟服务端不一致,以服务端为准重建。
这个机制听起来简单,但能避免很多诡异问题。比如用户 A 的 Producer 因为网络问题在服务端被关闭了,但用户 B 的客户端还认为这个 Producer 存在,尝试拉流就会失败。有了状态同步,用户 B 下次同步时就会发现这个 Producer 没了,主动清理本地状态。
状态同步的频率要权衡:太频繁浪费带宽,太稀疏发现问题慢。我一般用 30 秒一次的心跳同步,加上关键操作(加入、离开、推流、停止推流)时的即时同步。这样既能及时发现问题,又不会给服务端太大压力。
这套东西没有标准答案,每个项目的业务特点不同,需要根据自己的情况调整。但核心思路是一致的:服务端权威,客户端跟随,定期同步,异常重建。把这十六个字记住,大部分状态问题都能迎刃而解。