实时音视频 SFU 控制面:Colibri 协议与订阅带宽实践
Colibri 这个词,我第一次撞见是在一份网桥(SFU,Selective Forwarding Unit)的控制面日志里——满屏的 colibri-ws 连接、Conference IQ 报文,跟旁边滚过去的 RTP 统计完全不是一回事。后来慢慢摸清楚,蜂鸟这个命名挺讲究:体量小、振翅快、能悬停,恰好是控制面消息该有的样子——报文要小、交互要快、订阅关系要准。很多人做实时音视频,把注意力全砸在编码器和丢包重传上,结果会议一超过十几个人就各种糊、各种卡、各种幽灵用户,最后发现根因不在媒体面,而在控制面那几百字节的消息没配对。
这篇内容我打算把 Colibri 这条控制面彻底拆开:它到底管什么、一条消息里装了什么、从零怎么把链路跑通、订阅路数和带宽的账怎么算、端点级 WebSocket 信令怎么写、踩过的坑怎么排。适合正在自建会议/连麦/远程协作/在线课堂这套东西的后端和运维同学,也适合刚接手一套现成服务、看着日志一脸懵的人。代码和配置我按我手上跑的那套写,字段名可能跟着版本有微调,但语义模型是通的,你可以直接抄过去改。
1. Colibri 管什么:把控制面和数据面切开聊
1.1 一句话定位
Colibri 是一套跑在网桥与会议控制器之间的控制面协议。它不搬一帧视频,只负责回答四个问题:谁在这个会议里、每个端点往哪发、每个端点该收谁的流、这些关系什么时候过期。真正的媒体数据走的是另外一条完全独立的路——ICE 打洞、DTLS 握手、SRTP 加密、RTP 转发,那套东西归数据面管,跟 Colibri 一点关系都没有。
把这两件事分开,是整套架构里最值钱的一个决定。你可以把它类比成快递分拣中心:控制面是那份不断更新的分拣清单(哪个包裹去哪个格口),数据面是传送带上的包裹本身。清单改一行,包裹的走向立刻变;反过来,包裹再重,也不会把清单压坏。
1.2 为什么不能把控制逻辑塞进媒体通道
早期我也动过歪脑筋:既然已经有 RTP 通道了,干脆在 RTCP 的扩展字段里塞点自定义指令,省一套连接。实测下来这条路走不通,原因有三个。
第一是 时机不对 。控制消息往往需要在媒体通道建立之前就送达,比如"这个端点该订阅谁"必须在编码器开始出流之前定下来,而这时候 SRTP 还没握手完。第二是 可靠性要求不同 。RTP 允许丢包,丢一帧画面无所谓;但"把 A 踢出会议"这条指令丢了,A 会一直占着端口和 CPU 到超时。第三是 可观测性 。控制面独立之后,我可以在不影响媒体的前提下抓包、重放、做灰度,排查问题时能一刀切开"是信令错了还是网络抖了"。
所以现在的做法是:控制面走一条独立的、可重连的、带校验的信令通道(早期是 XMPP 的 IQ 报文,现在主流是 WebSocket),数据面专心搬音视频。两边通过一个稳定的标识符——端点 ID——挂钩。
1.3 哪些场景绕不开它
只要你的业务里出现"多路流、动态订阅、随时切换谁看谁"这三个特征,就绕不开。典型的有:在线课堂里老师看全班、学生只看老师;远程巡检里值班员需要临时调出某个点位的画面;直播连麦里观众切到某个嘉宾;甚至远程问诊、远程面试、协同白板里的摄像头小窗,背后都是一套订阅策略在跑。
反过来,如果是一对一通话,或者纯推流给 CDN 的单向直播,控制面的复杂度可以压到极低,Colibri 这套东西属于杀鸡用牛刀。判断标准很简单: 会议里同时存在的人数超过 4 个,且不同人看到的画面不一样 ,那就该认真做控制面了。
2. 一条控制消息里到底装了什么
2.1 四层结构:Conference / Content / Channel / SCTP
Colibri 的消息是嵌套的,从上到下四层,理解了这个层次,读日志的时候就不会晕。
最外层是 Conference ,代表一场会议,带上会议的唯一 ID 和所属网桥的信息。往里一层的 Content ,代表一条媒体类别,通常就是 audio、video、data 三条。这里有个新手容易搞混的点:Content 不是一个"流",而是一类流的集合,一场会议一般固定三条 Content,哪怕里面有二十个人。第三层是 Channel ,这才是真正对应"某个端点的一路流"的地方,一个端点可能有多个 Channel(比如同时推摄像头和屏幕共享)。最后的 SCTP 是数据通道,负责传白板笔迹、文件、聊天这类非媒体数据。
注意:Content 数量少、Channel 数量多,这个比例关系决定了消息体的膨胀速度。二十人的会议,Channel 数量是四十甚至六十,消息体能到几十 KB。如果你的信令通道有单包大小限制,这里就是第一个要压的地方。
2.2 关键字段逐个说
下面这张表是我在做故障排查时抄在便签上的一版,字段名做了通用化处理,实际叫法可能略有差别,但语义是通的。
| 字段 | 挂在哪一层 | 作用 | 实际踩坑点 |
|---|---|---|---|
| conference id | Conference | 会议全局唯一标识 | 复用了旧 ID 会导致新会议挂到旧的状态上 |
| endpoint id | Endpoint | 端点在会议内的唯一标识 | 重连换 ID 会留下幽灵端点,占着订阅位 |
| stats-id | Endpoint | 统计上报时的可读名 | 别拿它做业务逻辑,会变 |
| channel id | Channel | 一路流的编号 | 屏幕共享和摄像头是两个不同的 channel |
| last-n | Channel/会议级 | 最多同时订阅几路视频 | 设太大人人卡,设太小人人糊 |
| max-bitrate | Channel | 单路流的上限码率 | 只在推流端生效,收流端管不了 |
| ssrc | Channel | 媒体流的同步源标识 | 重新协商时 ssrc 会变,订阅关系要跟着更新 |
| expire | Channel | 租约到期时间 | 心跳没续上,端点会被静默清掉 |
| sctp port | SCTP | 数据通道端口 | 端口冲突会让白板直接不可用 |
2.3 为什么 endpoint id 必须保持不变
这一条值得单独拎出来说,因为它是所有"幽灵用户""画面错位""音视频对不上"问题的共同源头。
端点 ID 是控制面里所有关系的锚点:订阅表是
endpoint A → endpoint B
,路由表是
channel X → endpoint A
,统计上报也挂在它身上。一旦网络抖动导致客户端重连并生成了一个新的 ID,服务端这边的事件顺序通常是:旧 ID 的租约还没到期,新 ID 已经进来了。结果就是列表里出现两个"你",其他人看到两个一模一样的画面缩略图,其中一个永远是黑屏。更糟的是,旧 ID 还占着一条上行通道,网桥的入口带宽被白白吃掉一份。
做法很直接:客户端把首次拿到的 endpoint id 存在本地会话里,重连时通过信令参数带回来,服务端优先复用。只有当租约明确过期、或者服务端主动判定该端点已失效时,才允许分配新 ID。这条规则写进客户端代码里,比事后在服务端做去重便宜得多。
2.4 抓一条真实的消息看看
排查问题时,最有效的动作是把控制面消息抓下来看。XMPP 场景下是这样:
# 只抓控制面,别把媒体流一起抓进来
tcpdump -i any -n -s 0 'tcp port 5222' -c 500 -w control.pcap
WebSocket 场景下,直接在浏览器开发者工具的 WS 面板里看帧就行,比抓包快。你会看到类似这样的结构(做了简化):
{
"type": "ChannelAllocation",
"conference": "conf-7f3a91",
"endpoint": "e1a2b3c4",
"contents": [
{
"name": "video",
"channels": [
{ "id": "ch-001", "endpoint": "e1a2b3c4", "ssrc": 3421987, "direction": "send" }
]
}
],
"expire": 60
}
提示:生产环境抓包一定要加
-c限制包数,或者提前按端口过滤。我见过有人在网桥上裸抓全量流量,五分钟把系统盘写满,服务直接挂了。
3. 从零把控制链路跑通:部署与配置的关键几步
3.1 组件角色划分
一套能跑的控制链路至少三个角色: 网桥 (真正转发媒体、承载 Colibri 端点的那台)、 会议控制器 (负责分配会议落在哪个网桥上、下发订阅策略)、 信令层 (客户端和控制器之间的入口)。三者之间的顺序是:客户端先到信令层建会话,信令层向控制器申请会议资源,控制器挑一个网桥并下发 Colibri 指令,网桥回一个带媒体参数的应答,客户端才拿到 ICE 候选开始打洞。
这个顺序很重要,因为它决定了排查的入口。如果客户端连 ICE 候选都拿不到,问题在控制面前半段;如果拿得到候选但媒体不通,问题在数据面。把这条链画在白板上,比背任何文档都管用。
3.2 配置文件里真正要动的几项
以我手上的配置为例,通常需要动的就这几处,其余保持默认:
videobridge {
# 单端口模式:把一大片 UDP 端口收成一个,防火墙配置成本直线下降
ice {
udp {
port = 10000
}
tcp {
enabled = true
port = 443
}
}
# 端点级信令通道
websockets {
enabled = true
domain = "rtc.example.internal"
tls = true
server-id = "bridge-a"
}
# 数据通道开关
sctp {
enabled = true
}
# 会议默认订阅路数,实际会被端点级消息覆盖
last-n = 5
# 统计上报间隔,单位毫秒
stats {
interval = 5000
}
}
几个字段的取舍逻辑说明一下。 单端口模式 省事,但代价是 ICE 候选维度变少,遇到对称型网络时穿透成功率会下降,这时候需要中继服务兜底,所以我把 TCP 443 也开着,作为最后一道退路。 last-n 设 5 是我在十几人到二十人会议里试出来的甜点位,后面第四节会讲这个数字怎么算。 统计间隔 5 秒 是权衡后的结果,太快会给控制面加负担,太慢则监控曲线看不到尖峰。
3.3 验证链路:三条命令确认它活着
部署完别急着开客户端,先确认服务本身健康。
# 1. 看控制面统计端点是否响应
curl -s http://127.0.0.1:8080/colibri/stats | jq '{
conferences,
participants,
bit_rate_download,
bit_rate_upload,
largest_conference,
threads
}'
# 2. 看媒体端口是否真的在监听
ss -lunp | grep 10000
# 3. 抓两百个包,确认媒体确实在走
tcpdump -i any -n -s 0 'udp portrange 10000-10100' -c 200 -w media.pcap
第一条命令返回的 JSON 里,我最先看的是
participants
和
threads
的比值。正常情况下一个参与者对应几个到十几个线程,如果比值突然飙到几十,说明有端点在反复重连、每次重连都新建了一堆工作线程,这时候该去查客户端的心跳逻辑了。
提示:验证阶段一定先把两个浏览器放在同一台机器上测通,再上公网。把"代码问题"和"网络问题"分开验证,能省掉至少一半的排查时间。
4. 带宽与订阅策略的账怎么算
4.1 先把公式写出来
这是我用得最多的一组估算公式,粗糙但够用。
单个端点的下行带宽 ≈ 订阅路数 × 单路码率 + 音频码率。
单个端点的上行带宽 ≈ 自身推流路数 × 单路码率 + 音频码率。
网桥的总出口带宽 ≈ 单个端点下行 × 端点数量(这是最坏情况,实际会因为分层订阅和暂停订阅省下一些)。
算完之后再往上加 20% 到 30% 的冗余 ,用来吃掉重传、前向纠错和瞬时码率尖峰。这个冗余千万别省,我见过按理论值买带宽、结果学生集体打开摄像头那一刻全线崩掉的场面。
4.2 一个 8 人会议的真实估算
假设 8 个人同时开摄像头,订阅路数设为 4,视频走 720p 约 1.5 Mbps,音频按 50 kbps 算。
单个端点下行 = 4 × 1.5 + 0.05 ≈ 6.05 Mbps 。
单个端点上行 = 1 × 1.5 + 0.05 ≈ 1.55 Mbps 。
网桥出口 ≈ 6.05 × 8 ≈ 48.4 Mbps 。
网桥入口 ≈ 1.55 × 8 ≈ 12.4 Mbps 。
加上 25% 冗余,出口要按 60 Mbps 以上 准备。这意味着如果你的机器是千兆网卡但和别的服务混跑,出口这部分很快会成为瓶颈。8 个人还好,40 个人的话出口接近 300 Mbps,就必须考虑拆机器了。
4.3 分层与 maxHeight:比硬砍 lastN 更聪明的做法
很多人的第一反应是"卡了就把 last-n 从 5 调到 3"。这招有效,但代价很大:所有人都看不到足够多的画面,体验断崖式下滑。更好的做法是 按画面位置给不同的分辨率 ——主画面给 720p,缩略图墙给 180p。
| 分辨率 | 典型帧率 | 常见码率 | 适合的订阅位置 |
|---|---|---|---|
| 320×180 | 15 fps | 120–250 kbps | 缩略图墙、几十人的画面列表 |
| 640×360 | 30 fps | 400–700 kbps | 小窗、移动端 |
| 1280×720 | 30 fps | 1.2–2.0 Mbps | 主画面、单人讲话 |
| 1920×1080 | 30 fps | 2.5–4.0 Mbps | 双人对话、屏幕共享 |
按这个方案重算 8 人会议:如果只有一个主画面是 720p,其余三个缩略图按 200 kbps 算,下行 = 1.5 + 3 × 0.2 + 0.05 ≈ 2.15 Mbps ,比之前的 6.05 Mbps 省了将近三分之二。带宽没变,体验反而更好,因为人眼本来就不会盯着四个小窗口看细节。
实现上要依赖推流端的分层编码能力:同一路视频编出高中低几个层,服务端按订阅关系挑层转发,不重新编码,CPU 开销几乎为零。这也是 SFU 架构在带宽上唯一能打的牌。
注意:分层不是免费的。层数每多一层,推流端的编码开销和上行带宽都要涨。我的经验是三层(180p/360p/720p)足够覆盖绝大部分场景,四层以上收益递减得很快。
5. ColibriWebSocket:端点级信令与动态订阅
5.1 为什么每个端点要一条自己的通道
早期的做法是所有端点共享一条会议级信令通道,服务端广播、客户端过滤。人一多这条通道就成了灾难:每次有人切画面,全会议所有人都要收一条几十 KB 的消息,然后丢掉 90% 的内容。
端点级通道把这个模型翻了过来。每个客户端建立一条自己的 WebSocket,连接地址里带三个信息:会议标识、端点标识、以及一个短期有效的校验串。服务端就能精确地把消息推给需要的人,一条消息只发给一个人,天然避免了广播风暴。
wss://rtc.example.internal/colibri-ws/<conference-id>/<endpoint-id>?pwd=<token>
5.2 消息类型与 JSON 结构
日常打交道最多的是订阅约束类消息,形如:
{
"lastN": 6,
"selectedEndpoints": ["e1a2b3c4"],
"onStageEndpoints": ["e1a2b3c4"],
"constraints": {
"e1a2b3c4": { "maxHeight": 720 },
"e5f6a7b8": { "maxHeight": 180 },
"e9c0d1e2": { "maxHeight": 180 }
}
}
这里有个设计细节值得琢磨:为什么用
maxHeight
(分辨率上限)而不是
maxBitrate
(码率上限)?因为分辨率在编码端是
离散可枚举
的几个层,约束它等于直接指定"给我哪一层",切换时直接换转发目标,代价极低。而码率是连续值,服务端拿到之后还得靠带宽估计去凑,来回试探的过程本身就是抖动源。做控制面设计时,能约束离散量就不要约束连续量。
另一类消息是端点间的自定义消息,用来传举手状态、静音提示、表情这类小数据,走的是数据通道对应的信令侧,特点是低延迟、不保证可靠送达。
5.3 重连、token 与端点 ID 的三条铁律
这三条是我在线上事故里一条一条换来的,写进客户端规范里,能省掉后面无数的夜班。
第一条,token 要有有效期,且与会议绑定。 校验串过期后必须重新申请,不能拿旧 token 硬连。见过有人为了图省事把 token 设成永不过期,结果是有人把地址复制出去,半年前的通话内容还能被接进去。
第二条,重连时复用端点 ID,但必须换新 token。 ID 复用保证订阅关系不断,token 更新防止被盗用,两件事不冲突。
第三条,重连要退避,别裸奔。 断线后 0.5 秒、1 秒、2 秒、4 秒这样翻倍退避,上限压到 8 到 10 秒。我踩过一个坑:客户端无限速重连,服务端每次重连都新建一堆状态,两台机器五分钟内被打到拒绝服务,日志刷了几十万行。
6. 那些年踩过的坑:现象、原因、处理
6.1 速查表
| 现象 | 可能根因 | 验证方式 | 处理办法 |
|---|---|---|---|
| 通话正常但全员黑屏 | 数据通道未建立 | 看 DTLS 握手日志、SCTP 端口监听 | 检查开关配置与 UDP 放行 |
| 画面长期停在低清 | 带宽估计上不去 | 看丢包率与接收端反馈 | 检查链路路径是否绕远、限制推流码率 |
| WebSocket 每分钟断一次 | 中间设备空闲超时 | 看断开时间是否规律 | 心跳压到 15–20 秒 |
| 单端点把网桥 CPU 拉满 | 该端点推了超高码率或多层 | 按 endpoint 看统计 | 下发码率上限、减少层数 |
| 会议人数一多就崩 | UDP 端口耗尽或线程数飙升 |
ss -lunp
数端口占用
| 开单端口模式、拆会议到多台网桥 |
| 列表里出现重复的自己 | 重连生成了新端点 ID | 查端点列表与租约 | 客户端复用 ID、服务端做失效清理 |
6.2 三个最容易被误判的问题
第一个是**"画面糊"被误判成带宽不够**。实际情况往往是订阅约束没生效,客户端一直收着低层流,但因为它自己以为在收高清,界面上也不报错。验证方法很土但很准:看网桥上这个端点的出口码率,如果只有两三百 kbps,那就不是带宽问题,是约束下发链路断了。
第二个是**"卡顿"被误判成编码问题**。有次排查了整整两天,最后发现是服务端的时间同步有问题,统计上报的时间戳错乱,监控曲线看着像抖动,实际链路好得很。控制面里凡是带时间戳的东西,都值得先确认一下时钟。
第三个是**"掉线"被误判成网络故障**。绝大多数掉线是租约没续上:客户端在后台被系统挂起,心跳停了,服务端按超时清掉了端点。移动端尤其明显。解决办法是把心跳做在能被系统唤醒的通道上,而不是纯靠定时器。
6.3 独家避坑心得
分享几个文档里不会写的东西。
日志里给每个端点打一个短哈希前缀。 端点 ID 又长又像,人眼在日志里根本分不清。我习惯在日志输出时取 ID 前六位做前缀,排查时一眼就能跟到某个具体的人。
控制面消息加一个自增序号。 出现乱序或者重复下发时,有序号就能一眼看出来,否则你会怀疑是自己代码的并发问题。
每次改订阅策略前,先记录当前的订阅快照。 出了问题能对比回滚,这比翻日志猜快得多。我现在的做法是每隔三十秒把订阅关系写一份到本地文件,保留最近一小时。
不要把控制面和数据面部署在同一块网卡上抢带宽。 控制面消息小,但延迟敏感;媒体流大,但允许丢包。让它们互相抢,两边都难受。
7. 扩容与监控:什么时候加机器,什么时候改策略
7.1 该看哪几个指标
我盯的就六个:会议数、参与人数、出口码率、入口码率、最大单会议规模、线程数。前四个决定资源水位,第五个决定风险集中度,第六个决定进程健康。
判断是否需要扩容,我用两条经验线: CPU 持续超过 60% 到 70%,或者出口带宽用到 70% ,就开始准备加机器。注意是"持续",瞬时尖峰不用管,会议刚开始那几秒所有人一起推流,曲线冲一下很正常。
线程数这个指标容易被忽略。它的绝对值不重要,重要的是随参与人数的增长曲线是否线性。如果二十个人占了两千个线程,而按经验五十个人才该到这个数,那说明有人在反复重连。
# 每 10 秒采一次,写进时序库
while true; do
curl -s http://127.0.0.1:8080/colibri/stats \
| jq -c '{t: now, c: .conferences, p: .participants,
out: .bit_rate_download, in: .bit_rate_upload, th: .threads}' \
>> /var/log/rtc-stats.jsonl
sleep 10
done
7.2 级联与拆会:两种扩容路子的取舍
人多了有两个方向: 拆会 ,把不同会议分到不同网桥上; 级联 ,把一个大会议拆到多台网桥上,让跨机流量只在网桥之间走一趟。
拆会简单,但解决不了单场大会的问题。级联能解决,代价是控制面复杂度上一个台阶:你得维护一张"哪个端点在哪个网桥上"的全局视图,跨机订阅要走网桥之间的通道,多了一跳延迟。我的经验是单场会议超过 80 到 100 人再考虑级联,低于这个数,优化订阅策略的收益比级联大得多,而且不动架构。
真要做级联,务必要保证两点:网桥之间的链路质量要比客户端到网桥的链路好一个档次,否则级联那跳会成为抖动放大器;以及控制面要有明确的"谁来当主"的规则,避免两边同时下指令。
7.3 灰度与告警阈值
任何控制面改动都先灰度。我的做法是选中 1% 的会议开新策略,观察四个指标:订阅切换耗时、丢包率、出口码率、客户端重连次数。切换耗时是最灵敏的,正常情况下应该在一百毫秒以内完成,如果飙到几百毫秒,说明约束下发的链路有问题,赶紧回滚。
告警阈值我设了三档:CPU 70% 告警、出口带宽 70% 告警、单会议人数超过设计容量 1.2 倍告警。前两个是资源告警,第三个是容量告警,触发后要人工确认是不是有人在压测或者被恶意刷了。
注意:控制面的告警一定要带上会议标识。只报"某台网桥 CPU 高",你得自己一个个会议去翻;带上会议标识,三十秒就能定位到是哪一场。
最后说个我自己在用的土办法。每次上线新策略之前,我会先在一个二十人的固定测试会议里跑一遍完整流程——全部开摄像头、轮流共享屏幕、中途拔一次网线再插上、模拟两个人同时进会。这套动作能覆盖八成的边界情况,比看任何文档都实在。这套会议我用了快两年,端点 ID 复用、约束下发、重连退避这几个最容易出问题的地方,基本都在里面被提前炸出来过。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)