Colibri 这个词你可能在好几个地方都见过:西班牙语里它是蜂鸟,有个做了快一百年的打火机牌子叫 Colibri,某家嵌入式模组厂也拿它当产品线命名。但干我们这一行的,真正会天天跟它打交道的地方只有一个——Jitsi Videobridge(下称 JVB)对外暴露的那套会议控制接口,官方名字就叫 Colibri。它不是用户能看到的界面,也不是媒体流本身,而是一本"账本":谁在开会、每个人在发哪几路流、每一路流该往哪几个端点转发、什么时候该把谁踢出去。
我最早接触到它是给一家做在线教育的客户做私有化部署,会议室一开就两百多人,前端画面卡成幻灯片,后台日志里全是colibri开头的报错。那会儿我以为是网络问题,折腾了两天带宽和 NAT,最后发现是 Colibri 描述里last-n的取值和 simulcast 层选择没配合好,单个端点的下行被拉到了十几兆。这件事之后我把这套协议从头到尾捋了一遍。下面这篇东西,就是我这几年在 Colibri 上踩过的坑、拆过的包、改过的配置,适合正在自建视频会议后端、或者已经在用 JVB 但被性能和排障折磨的同学看。不需要你先懂 WebRTC 底层,我会从它在整套系统里站的位置讲起。
1. Colibri 指的是哪一套东西
1.1 同名不同物,先把范围钉死
因为"Colibri"这个名字被用得太散,先花半分钟把边界划清楚,省得你搜出来的资料驴唇不对马嘴。蜂鸟那个是生物学命名,跟技术无关;打火机品牌是消费品的商标;嵌入式领域有个模组系列也叫这个名字,属于硬件范畴。而我们这篇文章要聊的,是Jitsi 生态里 JVB 组件的控制平面协议。
严格说,Colibri 不是一份 RFC,也没有独立的规范文档,它更像"JVB 这个服务对外承诺的一组接口约定",包含两个部分:一是走 HTTP 的Colibri REST 接口,用来做会议对象的创建、查询、局部更新和删除;二是走 WebSocket 的Colibri WebSocket 通道,用来在会议进行中做高频的增量控制,比如某个端点切换了订阅目标、某个 source 的转发层级变了。前者是"慢通道",后者是"快通道",两者描述的其实是同一个对象的两面。
所以你在日志里看到colibri-ws、/colibri/conferences、ColibriWebSocket这些字眼,说的都是同一件事。搞混了容易白折腾——我就见过有人为了排查 WebSocket 断连,把 JVB 的 REST 端口从头配了一遍,方向完全跑偏。
1.2 视频会议后端为什么需要一套专门的控制协议
要理解 Colibri 存在的意义,得先明白 JVB 在架构里的角色。Jitsi 走的是 SFU(选择性转发单元)模型:所有参会者把音视频流推到 JVB 这台上,JVB 再按需转发给其他人。它不做混流合成(除了可选的音频混合),所以一个 200 人的会议,JVB 里同时存在 200 路上行和理论上的 200×199 路下行——当然实际不会这么干,靠的就是"选择性"三个字。
问题来了:谁来决定"转发给谁"?这件事不能由客户端自己做主,因为它不知道全局情况;也不适合塞进 SDP 里反复协商,因为 SDP 是会话级的,改一次要把整个会话重谈一遍,开销太大。于是就需要一层独立于信令和媒体之外的控制面,专门回答三个问题:
- 有哪些会议存在,每个会议里有哪些端点——这是对象管理;
- 每个端点在发几路流,每路流的 SSRC 是什么,编码参数是什么——这是转发依据;
- 某个端点此刻想看谁,最多同时看几路——这是订阅策略。
Colibri 就是这三个问题的答案载体。它的设计取向很明确:控制面只描述状态,不关心怎么实现。JVB 拿到一份描述之后自己决定用哪个 socket、发到哪个 IP、丢包了怎么办。这种分工让 JVB 可以专注于转发性能,也让上层(Jicofo)可以灵活地做策略调度。
1.3 Colibri 在整套架构里处在哪一层
先把几个角色的分工列清楚,不然后面看字段会晕。
| 组件 | 角色 | 和 Colibri 的关系 |
|---|---|---|
| Prosody | XMPP 信令服务器 | 不直接碰 Colibri,负责 MUC 和信令转发 |
| Jicofo | 会议协调者(focus) | Colibri 的主要调用方,负责拼装会议描述 |
| JVB | 媒体转发服务 | Colibri 的服务提供方,解析描述并执行转发 |
| 客户端 | 浏览器 / 移动端 | 不直接调 REST,通过 Colibri WebSocket 提交订阅意图 |
一次典型的开会流程是这样的:客户端在 MUC 里出现,Jicofo 感知到之后,从它掌握的可用 JVB 列表里挑一台,把这台 JVB 的地址拿出来,然后用 HTTP 调它的 Colibri REST 接口创建会议;创建的时候会把当前已知的端点、每个端点的 ICE 候选、DTLS 参数、SSRC 列表一股脑儿塞进去。JVB 收到之后分配内部资源,返回一个会议对象。后续有人加入、有人退出、有人切换画面,Jicofo 就通过后续的更新请求或者让客户端直接走 WebSocket 去改。
这里面有个容易被忽略的点:Jicofo 并不是只调一台 JVB。大会议里它可以做级联,把不同会场的端点挂到不同 bridge 上,Colibri 描述里的channel-bundle就是实现这个的关键(后面细讲)。另外 Jicofo 怎么知道有哪些 JVB 可用?它和 JVB 都加入了同一个 XMPP 的 brewery MUC,JVB 在 presence 里上报自己的状态和地址,Jicofo 从里面挑。所以 Colibri 虽然不走 XMPP,但它的"入口信息"是 XMPP 给的,两条路配合着用。
2. Colibri 的设计思路拆解
2.1 声明式描述,而不是一串命令
Colibri 最值得学的一点,是它选择了声明式而不是命令式。你发给 JVB 的不是"请把 A 的流发给 B"这种动作指令,而是一份完整的状态快照:会话里有哪些 content、每个 content 下有哪些 channel、每个 channel 里有哪些 endpoint、每个 endpoint 有哪些 source。JVB 自己去做 diff,判断哪些是新增、哪些是更新、哪些该清理。
这么做有很实际的好处。第一是幂等:同一份描述发两次,结果是一样的,网络抖动导致的重试不会造成重复资源。第二是可恢复:如果 JVB 重启了,Jicofo 只要把当前该有的状态重新推一遍就能恢复,不需要回放历史指令。第三是可调试:出了问题时,你只要GET一下会议对象,就能看到 JVB 心里的"真相"是什么,不用猜。
代价也很明显:描述会很大。一个 50 人会议、每个人带音频+视频+RTX,再加上 ICE 候选,一份完整的 JSON 轻松上到几百 KB。所以 Colibri 设计了 PATCH 语义的局部更新,以及 WebSocket 通道来做增量,避免每次都推全量。
2.2 REST 管生命周期,WebSocket 管高频意图
这两条通道的分工,是我觉得 Colibri 设计得最聪明的地方。
REST 通道处理的是低频、重状态的操作:会议创建、端点加入、端点离开、会议销毁。这些操作频率低(一个人一分钟也就能进出一次),但携带的数据量巨大(ICE 候选、DTLS 指纹、所有 SSRC)。用 HTTP 很合适,因为可以复用成熟的连接池、超时、重试机制。
WebSocket 通道处理的是高频、轻状态的操作:最典型的就是"我现在想看谁的画面"。在一个 20 人的会议里,用户快速翻页切换画面,一秒内可能发好几次意图。这种东西如果每次都走 HTTP,光 TLS 握手和 JSON 解析就能把 CPU 吃掉一大块。走 WebSocket 之后,一条几百字节的消息就搞定了。
我实测过一个对比:同样的画面切换操作,走 REST 更新平均延迟在 120ms 上下,走 Colibri WebSocket 能压到 20ms 以内。对用户体验来说,这就是"点了就切"和"点了等一下"的区别。所以如果你的部署里 WebSocket 没通、退回了 HTTP 模式,用户不会收到报错,但会明显感觉"卡顿、迟钝"。这个坑我在一个客户的现场遇到过,他们换了 nginx 之后忘了把Upgrade头带上去,客户端默默降级,谁都没报错,只有用户抱怨"切画面慢"。
2.3 expire 机制:让资源自己会过期
Colibri 描述里有个字段叫expire,一般出现在 channel 或 endpoint 层面,单位是秒。它的含义是:如果我这么久没有收到关于你的更新,我就认为你已经不在了,主动把你清掉。
这是个"租约(lease)"式的设计。好处是容错:如果 Jicofo 崩了、客户端断网了、进程被 kill 了,JVB 不会永远抱着一个僵尸端点不放,白白占用转发资源。坏处是要求调用方必须周期性刷新——只要会议还在,Jicofo 或客户端就得定期把描述重新推一遍(哪怕内容没变),相当于心跳。
注意:
expire设得太短,会在大会议里造成"刷新风暴",每次刷新都是一次完整的端点列表比对;设得太长,端点异常退出后资源回收不及时,表现为"幽灵参与者"一直占着下行带宽。我的经验是把刷新周期设为 expire 值的三分之一左右,比如 expire 给 60 秒,刷新周期 20 秒,既留了两次重试余量,又不会太频繁。
2.4 三个决定带宽的设计:channel-bundle、SSRC、last-N
如果说 Colibri 描述里只有三个字段真正影响你的服务器成本,那一定是这三个。
channel-bundle决定的是连接复用。传统做法是音频一条 ICE 传输、视频一条、数据一条,三条 DTLS 握手、三组候选、三份连通性检查。channel-bundle 把这些 content 绑在一个传输上,只做一次 ICE 和 DTLS。对 JVB 来说,这意味着每端点省下两次握手;对客户端来说,意味着更少的连接数和更快的建立速度。层级关系是:一个 content 里的多个 channel 共享同一个 channel-bundle,而 channel-bundle 里放着真正的 ICE 参数和 DTLS 指纹。
SSRC 与 ssrc-groups决定的是转发粒度。SSRC 是每一路 RTP 流的身份证,视频的 RTX 重传流有另一个 SSRC,两者用FID语义成组;simulcast 的三个分辨率层各有自己的 SSRC,用SIM语义成组。JVB 要靠这些组信息才能"只转发某一层",而不是把全部层都推给订阅者。这也是为什么不能随便伪造 SSRC——JVB 会校验,对不上就直接丢弃。
last-N决定的是下行上限。它规定每个端点最多同时接收多少个其他端点的视频流。这个值直接把"每人下行"从 N-1 路砍到 N 路,是 SFU 能在普通带宽下撑住大会议的核心。后面我会单独拿一节讲怎么算。
3. 关键字段逐个拆解:一份 Colibri 会议描述长什么样
3.1 顶层骨架与 contents 数组
先看整体形状。Jicofo 发给 JVB 的 JSON 大致长这样(这是我从实际流量里精简出来的最小骨架,真实报文会胖得多,参数也随版本有增减):
{ "id": "a1b2c3d4e5f6", "contents": [ { "name": "audio", "channels": [ { "id": "a1b2c3d4e5f6-audio", "expire": 60, "endpoints": [], "sources": [] } ] } ] }顶层的id就是会议标识,通常是随机 hex 串,它在 JVB 内部唯一。contents是个数组,每个元素代表一个媒体类型,常见的name有audio、video,还有一类特殊的data用来承载 SCTP 数据通道。每个 content 下面是channels数组,channel 才是真正装端点和 source 的容器。
这里有个实践中的细节值得说:同一种媒体类型可以有多于一个 content。比如你想给"演讲者的大屏"和"其他人的小窗"走不同的 payload type 配置,就可以拆成两个 video content。JVB 不关心你为什么拆,它只按你给的配置转发。我见过有人为了给不同分辨率设置不同的 RTCP 反馈策略而这么干,可行,但会让描述体积明显变大,得不偿失。
还有一点:channels数组的存在感经常被误解。很多人以为一个端点对应一个 channel,其实不对。channel 是"一组有共同转发策略的端点"的集合,一个 channel 里可以挂多个 endpoint,一个 endpoint 也可以出现在多个 channel 里(比如它既在音频 channel 里,又在视频 channel 里)。理解这一点,看日志的时候就不会被"为什么同一个 ID 出现在好几处"搞懵。
3.2 endpoint、channel-bundle 与 ICE 参数
endpoint 是 Colibri 里最核心的对象,你可以把它理解成"一个参会者的媒体身份"。它至少包含三个信息块:自己的标识、统计用的 ID、以及怎么连上它的传输参数。
{ "id": "5f3c1a7e", "stats-id": "client-5f3c1a7e", "channel-bundle": { "id": "5f3c1a7e-bundle", "transport": { "xmlns": "urn:xmpp:jingle:transports:ice-udp:1", "rtcp-mux": true, "ice": { "ufrag": "abcd", "pwd": "0123456789abcdef0123456789", "candidates": [ { "component": 1, "foundation": "1", "generation": "0", "id": "1", "ip": "203.0.113.10", "port": 51234, "priority": "2130706431", "protocol": "udp", "type": "host" } ] } } } }id一般就是端点在 MUC 里的资源标识,8 位左右的短串。stats-id是给统计上报用的,跟id不一定相同——如果你做自有监控,认准这个字段来关联数据,不要用id硬拼。
channel-bundle里的transport才是干货。ufrag和pwd是 ICE 的凭据,用来生成消息完整性校验;candidates是候选地址列表,type有host、srflx、relay三种,protocol有udp和tcp。这里最容易出问题的是地址通告:如果你的 JVB 在 NAT 后面,而它通告的是内网地址,客户端根本连不上;反过来通告了公网地址但防火墙没放行,表现是 ICE 一直卡在 checking。
提示:改动 ICE 相关配置后一定要清空会话重启,因为 ICE 参数是会话级协商的结果,改了配置但复用旧会话的连接不会自动重协商,你会看到"配置明明改了却没生效"的假象。这个坑我至少栽过两次。
rtcp-mux: true表示 RTP 和 RTCP 复用同一个端口,现代浏览器默认都开,别关掉,关掉会让端口占用直接翻倍。
3.3 sources、ssrc-groups 与 simulcast 的对应关系
再往里一层看 source。它是给 JVB 的转发指令,告诉它"这个端点会从这条传输上发来哪些流"。
{ "name": "5f3c1a7e-video", "ssrc-groups": [ { "semantics": "SIM", "sources": [1111, 1112, 1113] }, { "semantics": "FID", "sources": [1111, 2111] } ], "sources": [ { "ssrc": 1111, "rtp-level-relay-type": "translator" }, { "ssrc": 1112, "rtp-level-relay-type": "translator" }, { "ssrc": 1113, "rtp-level-relay-type": "translator" }, { "ssrc": 2111, "rtp-level-relay-type": "translator" } ] }semantics里SIM是 simulcast 组,通常按分辨率从高到低排列;FID是 RTX 重传组,第一个是主流的 SSRC,后面是它对应的重传流。rtp-level-relay-type常见取值是translator,音频在开启混音时会变成mixer。
我特别想强调 simulcast 和 last-N 的联动关系,因为这是性能问题最常见的根因。一个开启 simulcast 的视频源会有 3 路不同分辨率的流同时到达 JVB,JVB 会根据订阅者的意图只转发其中一层。如果你在描述里只声明了 SSRC 却没有正确分组,JVB 会把它们当成三个独立的视频源,于是订阅者会被分配三倍的转发资源,而下行看起来只有一路画面——带宽莫名其妙涨了三倍,日志里却没有任何异常。这个现象我在一个客户那里定位了整整一个下午。
3.4 payload-types 与 header-extensions:能力协商的落点
剩下两个块,一个是payload-types,一个是header-extensions,它们决定了媒体流的"语法"。
payload type 描述的是编码格式和它的参数,比如 Opus 音频:
{ "id": 111, "name": "opus", "clock-rate": 48000, "channels": 2, "parameters": { "minptime": 10, "useinbandfec": "1" }, "rtcp-fb": [{ "type": "transport-cc" }] }clock-rate是采样时钟,channels是声道数,parameters里useinbandfec打开带内前向纠错,弱网下能显著减少断音。rtcp-fb声明 RTCP 反馈能力,transport-cc是传输层拥塞控制,nack是丢包重传,goog-remb是接收端带宽估计。三个里至少要有transport-cc或goog-remb之一,否则 JVB 没法做带宽自适应,弱网下画面会一直糊着不回升。
header-extensions 是 RTP 头扩展,常见的比如urn:ietf:params:rtp-hdrext:ssrc-audio-level(音量指示)、urn:ietf:params:rtp-hdrext:toffset(音视频同步)、urn:3gpp:video-orientation(画面旋转)。这些扩展的id在 SDP 里协商、在 Colibri 里再声明一次,两边必须一致,不一致的话 JVB 会静默丢弃对应的扩展数据。表现是:音量指示条不动、竖屏拍摄的视频方向不对、音画不同步。都不是致命问题,但用户会明确感觉到"哪里不对劲"。
3.5 last-N 到底该给多少
last-N不是一个写在 Colibri 描述里的字段,而是 Jicofo 或客户端在订阅策略里施加的约束,但它通过 Colibri 通道生效,所以放在这里讲。
它的逻辑很简单:每个端点最多同时接收 N 路其他端点的视频。选中的 N 个人按优先级排序:正在讲话的、被固定在画面上的、最近活跃的,其余一律不转发。这样每个端点的下行从"随人数线性增长"变成了"常数"。
代价是画面质量不公平:没被选中的人在你的界面上显示为静止头像,直到他说话或者你手动切过去。对会议场景来说这完全可以接受,因为人眼在一个屏幕上本来就只能看清少数几路画面。
怎么定 N?我的算法是拿单端点上行带宽倒推。假设你要保证每路视频在 360p(约 500 kbps)到 720p(约 1.5 Mbps)之间,考虑到浏览器通常同时显示 4 到 9 个画面:
| 会议规模 | 建议 last-N | 单端点下行估算(视频) | 适用场景 |
|---|---|---|---|
| 2-6 人 | -1(不限) | 与人数线性相关,最多约 7 Mbps | 小团队评审,要求所有人都清晰 |
| 7-20 人 | 8-12 | 约 6-10 Mbps | 日常例会,画面以缩略图为主 |
| 21-50 人 | 5-8 | 约 4-8 Mbps | 培训、直播式会议 |
| 50 人以上 | 3-5 | 约 3-6 Mbps | 大课、全员会,只关注讲者 |
要注意的是,-1不是"性能最好的选择",而是"最贵的选项"。我见过一个 30 人的项目例会用了-1,结果所有人的笔记本风扇都狂转,原因是每台机器都在解码 29 路视频。改成 6 之后,CPU 占用直接掉了一个数量级。
4. 动手:本地把 Colibri 调通并抓一次真实会话
光看字段不够,得真的跑一次,把数据抓在手里,你才有"手感"。
4.1 环境与端口规划
先在脑子里过一遍端口,这块糊涂后面排查会很痛苦。
| 端口 | 协议 | 用途 | 是否对公网开放 |
|---|---|---|---|
| 8080 | TCP | Colibri REST 接口 | 绝对不要,只给 Jicofo 访问 |
| 9090 | TCP | Colibri WebSocket、统计接口 | 通过反向代理暴露 |
| 10000 | UDP | 媒体传输的主端口 | 是,必须放行 |
| 4443 | TCP | ICE-TCP 回退 | 视情况,弱网环境建议开 |
| 443 | TCP | 网页与信令 | 是 |
注意:Colibri REST 接口本身没有身份认证,任何能访问到 8080 的人都可以创建、查询、删除会议。这是 JVB 的设计假设——它默认跑在受信任的内网里。我见过有人图省事把 8080 直接映射到公网做"接口调试",等于把整个媒体服务交出去了。别干这个。
4.2 起一套最小可用环境
最省事的办法是用官方的容器编排仓库:
git clone https://github.com/jitsi/docker-jitsi-meet.git cd docker-jitsi-meet cp env.example .env ./gen-passwords.sh mkdir -p ~/.jitsi-meet-cfg/{web,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb,jigasi,jibri} docker compose up -dgen-passwords.sh会往.env里写入各组件的内部凭据,这一步不能跳,跳了各组件之间认证会失败。启动完成之后,.env里几个关键变量建议按需改:PUBLIC_URL填你实际访问的域名或 IP,HTTP_PORT、HTTPS_PORT按需改,ENABLE_LETSENCRYPT本地测试填 0 免得反复申请证书。改完.env之后docker compose up -d会重建受影响的容器。
我这套环境跑在一台 4 核 8G 的机器上,同时开 3 个 720p 的视频端点,CPU 占用大概在 40% 左右。如果你只是学习,单机完全够用。
4.3 从容器网络里手写 curl 创建会议
JVB 的 8080 端口默认没有映射到宿主机,这正好符合安全原则。但我们调试的时候又需要访问它。有两个办法:一是临时docker compose exec进容器,二是起一个临时容器挂到同一个网络里。我更喜欢后者,因为不污染业务容器:
# 先看一眼网络名,通常是 <项目名>_default docker network ls | grep jitsi # 起一个带 curl 的临时容器,挂进同一个网络 docker run --rm -it --network docker-jitsi-meet_default \ curlimages/curl:latest sh进去之后,先列一下当前有哪些会议(大概率是空的):
curl -s http://jvb:8080/colibri/conferences然后手工创建:
cat > /tmp/conf.json <<'EOF' { "id": "deadtester01", "contents": [ { "name": "audio", "channels": [ { "id": "deadtester01-audio", "expire": 60, "endpoints": [], "sources": [] } ] } ] } EOF curl -s -X POST -H "Content-Type: application/json" \ --data-binary @/tmp/conf.json \ http://jvb:8080/colibri/conferences返回的就是 JVB 眼中的会议对象。这里返回的 JSON 和请求略有不同,JVB 会把默认值补全,你把两边对比着看,能很快搞明白哪些字段是必填的、哪些是可选的。
接着做一次局部更新,模拟端点加入:
curl -s -X PATCH -H "Content-Type: application/json" \ --data-binary @/tmp/patch.json \ http://jvb:8080/colibri/conferences/deadtester01PATCH的语义是"合并"而不是"替换",所以你只需要给出变化的部分。这一点很关键:我在早期做运维脚本的时候误用了POST全量替换,结果每次更新都把没有变化的端点当成新增,JVB 里堆了一大堆重复的 SSRC,日志刷得飞起。
用完记得删:
curl -s -X DELETE http://jvb:8080/colibri/conferences/deadtester014.4 观察真实会议的 Colibri 描述
手工造的会议太干净,看不出门道。真正的收获来自观察一次真实会议。
在浏览器里开一个会议室,让两三个客户端加入并打开摄像头,然后在刚才那个临时容器里执行:
curl -s http://jvb:8080/colibri/conferences | python3 -m json.tool拿到会议 ID 之后:
curl -s http://jvb:8080/colibri/conferences/<会议ID> | python3 -m json.tool你会看到一份"胖"得多的描述。这时候拿它对照第 3 节的字段说明逐个核对,有几个点特别值得看:
endpoints数组的长度应该和当前参会人数一致,如果你发现有 5 个客户端但只有 3 个 endpoint,说明有两个端点的注册请求丢了,去翻 Jicofo 的日志找colibri相关的错误;- 每个视频 source 的
ssrc-groups里是不是有SIM组,组内元素个数是不是 3(三层 simulcast),少了说明客户端的编码配置没生效; candidates里的 IP 是不是你期望的那张网卡的地址,如果不是,八成是通告地址配错了;payload-types里rtcp-fb有没有transport-cc,没有的话带宽自适应是关着的。
顺手再看一眼统计接口,能对上号:
curl -s http://jvb:9090/colibri/stats | python3 -m json.tool统计里有一堆计数器,我平时最关注的两个是当前会议数和端点总数,用来做容量告警的基线。当端点总数逼近这台机器的压测上限时,就该考虑加 bridge 了。
4.5 Colibri WebSocket 的验证
WebSocket 这条通道最容易"静默失败",因为它不通的时候客户端会自动降级,界面上什么都不显示,只是变慢。所以要主动验证。
从浏览器开发者工具的 Network 面板里过滤colibri-ws,你能看到一条 WebSocket 连接,URL 形如:
wss://<你的域名>/colibri-ws/<会议ID>/<端点ID>?pwd=<一串令牌>这个令牌是短期有效的,用来校验连接的合法性,客户端不需要理解它的内容,照拼就行。要注意的是,不同版本的拼接细节(是否带 JVB 主机段)会有差异,以你自己的部署里实际发出的请求为准,别照抄网上的示例。
想从命令行验证,可以用 websocat:
websocat -v "wss://meet.example.com/colibri-ws/<会议ID>/<端点ID>?pwd=<令牌>"连不上时,第一时间去看反向代理有没有传递升级头。nginx 里这段配置必须存在,缺一不可:
location /colibri-ws/ { proxy_pass http://jvb:9090/colibri-ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; }proxy_http_version 1.1和Connection "upgrade"这两行是很多"换了个 nginx 就出问题"的元凶。默认的 HTTP/1.0 不支持协议升级,连接会被直接掐断。
5. 常见问题与排查实录
5.1 故障速查表
这几年我在生产环境遇到过的问题,归纳下来其实就那么几类。先给你一张表,命中之后直接跳到后面的定位方法。
| 现象 | 高概率原因 | 定位入口 |
|---|---|---|
| 会议创建返回 400 | JSON 结构不合法,缺必填字段 | JVB 日志里的解析异常栈 |
| 能进会议室但看不到别人 | last-N 过小或订阅意图没送达 | 浏览器 Network 面板的 colibri-ws 帧 |
| 画面切换明显迟钝 | WebSocket 未升级,退回 HTTP 轮询 | 反代配置、WebSocket 握手状态码 |
| 带宽异常高 | simulcast 未分组,被当成多路独立源 | Colibri 描述中的 ssrc-groups |
| 参与者列表出现幽灵 | expire 未刷新或设得过长 | 定期刷新日志、会议描述中的 endpoint |
| 弱网下画面持续模糊 | payload-types 缺拥塞控制反馈 | Colibri 描述中的 rtcp-fb |
| 只有声音没画面 | source 缺失或视频 content 未创建 | Colibri 描述中的 contents 数组 |
| 连接卡在建立阶段 | ICE 通告地址错误或防火墙未放行 | jvb 日志中的 ICE 状态 |
5.2 三条定位路径
第一条,看描述。GET /colibri/conferences/{id}是排查的起点,它能告诉你 JVB 心里的真相。我习惯把它存成文件,跟上一小时的快照做 diff,变化的部分往往就是问题所在。
第二条,看统计。/colibri/stats里的计数器能反映趋势。比如端点总数在缓慢上升但会议数没变,说明有端点在泄漏,多半是 expire 刷新逻辑写错了。
第三条,看 WebSocket 帧。订阅意图是走这条通道的,浏览器开发者工具能直接看到收发的 JSON 帧。这一层的信息比前面两条都"近",因为它是客户端的真实意图,能看到"客户端到底有没有发出这个请求"。
排查的顺序我一般反过来:先看 WebSocket 帧确认意图发出去了,再看 Colibri 描述确认 JVB 收到了,最后看统计确认资源分配了。这样能快速判断问题出在"没发出"、"没收到"还是"收到了没执行",省掉大量瞎猜。
5.3 我踩过的几个坑
第一个坑是用会议 ID 做业务标识。Colibri 的会议 ID 是 JVB 内部生成或 Jicofo 指定的随机串,JVB 重启之后可能就变了,而且级联场景下同一场会议在不同 bridge 上的 ID 也不一样。拿它当业务主键,做报表的时候一定会对不上。要么用 MUC 的房间名,要么自己生成一个业务 ID 塞进自定义字段。
第二个坑是忘了在描述里声明数据通道。Jitsi 的很多高级功能(举手、聊天、屏幕共享的元信息)走的是数据通道,如果描述里没有对应的 content,这些功能会全部静默失效。现象很奇怪:视频音频都正常,就是聊天发不出去、举手没反应。我查了两个小时信令,最后发现是数据通道没建。
第三个坑是把 refresh 做成全量推。前面说过PATCH是合并语义,但如果你每次刷新都推一份"完整"的描述,而这份描述里包含了刚刚离开的端点(因为你本地缓存的列表没更新),JVB 会认为那个端点又回来了,于是"幽灵"永远清不掉。刷新逻辑要保证推的是"当前真实状态",不是"我记得的状态"。
第四个坑是忽视 rtp-level-relay-type。音频如果配成了mixer但 JVB 没启用混音模块,音频会直接断掉。改这个字段之前,先确认你的部署里到底有没有开音频混合。
6. 从 Colibri 的视角做容量与调优
6.1 让 last-N 和 simulcast 配合起来
这两个是配套的,单看任何一个都调不好。
simulcast 让一个视频源产生多个清晰度层,last-N 决定你能同时看几个人。理想的状态是:至少要有人看的那一层,在订阅者的带宽里放得下。如果 last-N 给到 12 而总带宽只有 5 Mbps,那平均每路分不到 450 kbps,只能拿到最低层,效果是"能看但不清晰"。这时候把 last-N 降到 6、每路分到 800 kbps,拿到的就是中间层,主观体验反而更好。
我的调法是从小往大试:先把 last-N 设成 4,观察主观画质和下行带宽;逐步加,直到带宽接近用户端可承受的上限(家宽场景我按 8 Mbps 预留)为止。这个上限跟会议性质有关——如果是"每个人都要看清的评审会",宁可少几个人同时入画;如果是"听讲为主的大课",last-N 给 3 都够。
另外要注意,发送端的 simulcast 层数是有限的。低端设备或者弱网下浏览器可能只发一层,这时候 last-N 调多大都没意义,因为根本没有更高的层可以选。遇到画质上不去又查不出原因,先看一眼 ssrc-groups 里 SIM 组到底有几路。
6.2 端口和带宽的估算
单台 JVB 的容量主要受三样东西限制:CPU(做 RTP 转发和加密)、网络带宽、以及并发端点数。
带宽的估算方法是:上行总带宽 = 端点数 × 每端点上行码率,下行更复杂,因为取决于订阅关系。一个粗算公式是:下行总带宽 ≈ 端点数 × last-N × 单路视频码率 + 端点数 × 音频码率。举个例子,50 人会议、last-N 给 6、每路视频平均 600 kbps、音频 40 kbps:
- 下行 ≈ 50 × 6 × 0.6 Mbps + 50 × 0.04 Mbps ≈ 180 + 2 = 182 Mbps
- 上行 ≈ 50 × 0.64 Mbps ≈ 32 Mbps
也就是说这样一台机器至少要 250 Mbps 以上的带宽余量。这是我给出"50 人以上建议 last-N 降到 5 以下"这个建议的算法来源——不是拍脑袋。
CPU 方面,我实测的经验值是:现代 x86 服务器上,单核大约能扛 60 到 100 路并发媒体流(取决于是否开启加密和各层码率)。加密是绕不开的,因为 WebRTC 强制 DTLS-SRTP。所以 8 核机器大致在 500 到 800 路流之间,换算成端点大概是一两百人的规模,具体还是要压测。
6.3 什么时候该加 bridge
判断信号有三个:一是统计接口里的端点总数持续接近压测出的上限;二是网络出口的带宽利用率长期超过 70%;三是对端点到 JVB 的 RTT 明显上升——这通常意味着流量绕远了,也就是该在被服务的区域附近再放一台 JVB 了。
加 bridge 的好处是 Colibri 的级联支持能让你把端点分散到多台上,而且对客户端几乎是透明的。加完之后,Jicofo 会根据它掌握的负载信息来挑,你要做的是保证新 JVB 的配置(尤其是 ICE 通告地址和 WebSocket 域名)和现有的完全一致,否则会出现"有的人连得上、有的人连不上"的诡异现象。
顺便提一句,Jicofo 选 bridge 的依据是负载而不是 Colibri——Colibri 只管"建好了之后怎么转发","建在哪台"是 Jicofo 决定的。所以调优的时候别只盯着 Colibri 的字段,把 Jicofo 的负载阈值也一起看,两边是对称的。
最后分享一个我自己的小习惯:每次做完配置变更,我都会用第 4 节那套 curl 流程手工建一个会议、跑一轮真实通话、再GET一次描述存成基线文件。下次出问题的时候,拿当前描述和基线做 diff,通常五分钟之内就能定位到是哪块配置动了手脚。这套土办法比翻日志快得多,尤其是当你手上有几十台 JVB、配置又不可能全部记住的时候。