Colibri 是蜂鸟的名字,也是我给自己这套实时音视频转发服务起的内部代号。名字不是随手找个动物,蜂鸟每秒振翅几十次,体重不到十克,却能悬停在花前一厘米的位置精确取食——我要的东西差不多:一套能在两核四 G 的机器上撑起几十个并发房间、每秒搬运几十万个 RTP 小包、端到端延迟压在三百毫秒以内的服务。整套东西没有任何转码,没有任何录制,所有复杂度都集中在“怎么把一个包准确、及时、按需地复制给该看它的人”这一件事上。

如果你做过 WebRTC 客户端,那你大概率只接触过 SDP、ICE、PeerConnection 这一层;如果你正打算自研一个中继或者转发服务,那你需要知道的恰恰是上面那层看不到的东西:发布订阅模型怎么建、抖动缓冲该开多大、关键帧请求为什么会把整个房间拖垮、带宽估计该信谁、单机到底能开几个房间。下面这些内容是我把一个原型改成能长期跑的服务、又在真实会议室里被用户投诉到改代码的全过程记录,涉及选型推演、核心转发逻辑、性能账、以及三四个我用了几小时才定位的线上现象。基础不深也能看懂,但如果你已经写过 RTP 解析,收获会更大。

1. Colibri 的架构定调:从通话人数倒推出来的三条结论

1.1 人数一过 5,端到端直连的带宽账就没法看了

做架构决策最忌讳“先选技术再想场景”。我的做法是先算账:假设单路 720p 视频稳定占用 1.5 Mbps(这是我从几百个真实客户端统计出来的中位值,低运动画面可以压到 600 kbps,讲 PPT 加摄像头合成画面会冲到 2.5 Mbps),那么端到端直连模式下,每一个参与者的上行带宽等于 (N-1) × 1.5 Mbps,下行也一样,同时每端要同时维持 N-1 个编码器和 N-1 个解码器。

参与人数 单端上行 单端下行 单端编码器数量 现实判断
3 3 Mbps 3 Mbps 2 能跑,但没有任何余量
5 6 Mbps 6 Mbps 4 家庭宽带开始吃力,Wi-Fi 抖动放大
8 10.5 Mbps 10.5 Mbps 7 手机端必然降频、发热、掉帧
20 28.5 Mbps 28.5 Mbps 19 不可行,编码器排队导致延迟爆炸

这张表里最关键的不是带宽,而是最后一列的“编码器数量”。带宽可以靠降分辨率硬扛,编码器不行——每一路编码都要实时吃掉一个核心的一部分,同时维持 19 路编码意味着端侧 CPU 长时间满载,风扇狂转,然后系统开始限制后台进程,音视频线程被抢走时间片。用户看到的症状是“进了会议就特别烫,然后声音开始断续”。

换成服务端转发就完全是另一个数量级了。每个参与者只上传一层(或者为支持分层订阅上传多层的 simulcast),下行只接收自己订阅的那几路。20 人房间,如果每人上行 1 路 1.5 Mbps,下行订阅 8 路低分辨率(每路 250~350 kbps)加 1 路高清(1.5 Mbps),下行总量大约 4 Mbps,端侧只需要 1 个编码器加不超过 9 个解码器。差距的来源很清楚:转发节点只做包级复制,不做编解码。

1.2 为什么不走混流那条路,也不做“智能降级”的中间路线

混流方案(服务端把所有画面拼成一张图再重新编码)有一个非常诱人的优点:每个端只收一路、只解一路,端侧算力和下行带宽都省到极致,几百人的大会也能撑。代价全部转移到了服务端:转发节点要为每个房间解码所有上行流、做画面合成、再重新编码,一个 20 人房间直接吃掉好几个核心,而且画面质量被合成后的统一分辨率拖累,细节全丢;延迟上还要多出解码、合成、编码三段流水线,实测比纯转发多 50~150 ms。我的目标场景是内部会议,参会设备都是近几年机型,没有必要为了省端侧算力去烧服务端 CPU,所以混流从第一版就被排除了。

还有一个更隐蔽的中间路线是“按房间大小动态切换”,小房间端到端直连、大房间走转发。听起来聪明,实际上是把两套复杂度都留下了:直连路径要处理 ICE 连通性检查、双方 NAT 类型不匹配时的失败回退,转发路径要处理房间迁移、状态同步,而且“多大算大”这个阈值你调不准——真实数据里 6 个人的房间经常因为其中一个人的网络质量差而比 12 人房间更难跑。最后我选择只保留一条路径,把复杂度用在把这一条路径做扎实上。

1.3 “轻量”被我量化成了四条必须守住的线

“轻量”这个词如果没有数字,就只是一句自我安慰。我给自己定了四条硬线,后面所有的工程取舍都用这四条来判断对错:

  • 单房间常驻内存不超过 2 MB(不含重传缓存),重传缓存按节点峰值码率动态分配,硬上限 4 MB。这样一台 4 GB 内存的机器在内存维度上不会成为瓶颈。
  • 单个物理核(3.0 GHz 级别)能同时转发至少 40 路 720p 订阅流,也就是大约 60 Mbps 入向加 60 Mbps 出向,此时 CPU 占用不超过 70%。
  • 端到端延迟(采集到播放)p95 不超过 300 ms,p99 不超过 500 ms。这是我反复测出来的用户感知门槛——超过 350 ms 时,两个人会开始出现“抢话”。
  • 进程启动到能接受第一个房间不超过 2 秒,扩容单个房间的容量不需要重启进程。

第三条线最容易被忽视。很多自研服务把延迟做成了 p50 很漂亮、p95 很难看,因为抖动缓冲和重传策略只考虑了平均情况。而会议里“偶尔卡一下”比“整体延迟高 50 ms”更影响体验,所以我把注意力放在了尾部延迟上。

1.4 责任边界:Colibri 只做四件事

定调的最后一步是明确“不做什么”,这比“做什么”更重要。Colibri 只做四件事:收包(收 RTP、做初步去重和排序)、选层(决定某个订阅者当前应该收到哪一层)、重写(序列号、时间戳、SSRC 的映射)、发包(排队、加密、出网卡)。它不做转码,不做录制,不做画面合成,不承载任何业务逻辑——房间号、用户身份、权限判断全部由上层信令服务处理,Colibri 只接收一份“谁订阅谁”的指令清单。

这条边界一旦立住,后面所有的技术选择都能用一句话解释清楚。比如“为什么不做端到端加密下的转发”——因为端到端加密意味着服务端读不到 RTP 头,而读不到 RTP 头就没法做序列号重写,这就直接撞到了“重写”这条核心职责上,只能退化成纯端口转发并放弃分层能力,那就不在 Colibri 的范围内了。

2. 房间内部的数据流:Colibri 里的发布订阅怎么落地

2.1 三个核心对象决定了后面所有代码的形状

在客户端视角里,一个浏览器连上来就是一条 PeerConnection。但在服务端,我把它拆成了三个对象,这个拆分决定了后面所有代码的形状:

  • Transport :对应一条 PeerConnection 的传输通道,绑定到一个参与者,包含 ICE、DTLS、SRTP 的全部上下文。
  • Publication :一条上行媒体流。它带一个来源标识、一组分层描述(每层有 rid、ssrc、目标码率范围)、一份共享的重传缓存。
  • Subscription :某个接收者订阅某条 Publication 的记录,它带一个“当前选中层”字段,以及这个订阅专属的出向 SSRC 和序列号计数器。
对象 数量级(10 人房间) 生命周期 挂载资源
Transport 10 参与者加入至离开 ICE/DTLS/SRTP 上下文、收发队列
Publication 10~20(含屏幕共享) 轨道开始至结束 重传缓存、分层映射、关键帧请求状态
Subscription 40~60 订阅建立至取消 出向 SSRC 空间、序列号与时间戳偏移

为什么不用“接收端→发送端”的直接映射表?因为一对多的时候必须复用同一份上行包。如果按点对点维护映射,10 人房间要做 90 条映射,每条映射都要独立维护重传缓存、独立重写序列号,内存和 CPU 都是平方级增长;而用发布订阅模型,只有 10 条 Publication 和 45 条 Subscription,重传缓存挂在 Publication 上被所有订阅者共享,一份缓存服务所有人。这个差别在小房间里看不出来,到 30 人以上时就是能不能跑动的差别。

2.2 一条包在 Colibri 里走过的三段路

我把整条转发链路切成了三段,这个切法直接对应了后面的线程模型:接入段(收到 UDP 报文 → 解 SRTP → 解析 RTP 头 → 乱序重排和重复包丢弃)、转发核心(找到对应的 Publication → 遍历它的所有 Subscription → 判断这一包对每个订阅者要不要发、以什么序列号和时间戳发)、发送段(按节奏排队 → SRTP 加密 → 交给内核发出)。

切成这三段的理由是状态性质不同。接入段是有状态的:它维护抖动估计、丢包检测、重排窗口,每个 Transport 一份。转发核心是接近无状态的:输入是一个包加一个订阅列表,输出是一组改写过的新包,纯映射关系。发送段又是有状态的:它维护发送队列、重传缓存索引和 pacing 节奏。

把“有状态”和“无状态”分开,带来一个很实际的好处:无状态的转发核心可以放进线程池里被任意线程执行,不需要对房间加锁;而有状态的接入段和发送段各自归属于一个事件循环,互不干扰。如果混在一起写,一个 30 人房间里的每包处理都要抢同一把房间锁,锁争用会直接吃掉一半 CPU。

2.3 转发核心那一层:分层选择的最小可用实现

转发核心里唯一有点“智能”的地方是选层。真实实现会比下面这段复杂,但决策骨架就这几行:

def select_layer(sub, est_bitrate_kbps, rtt_ms):
    # 从高到低找第一个不超过可用带宽的层
    usable = est_bitrate_kbps * 0.85      # 留 15% 给重传、头部和突发
    if rtt_ms > 250:
        usable *= 0.7                     # 高 RTT 下反馈滞后,保守一点
    for layer in sorted(sub.publication.layers, key=lambda l: -l.bitrate):
        if layer.bitrate <= usable:
            return layer
    return sub.publication.layers[0]      # 最低层兜底,绝不返回 None

那两个系数不是随手写的。0.85 是因为转发出去的实际流量不只是媒体码率:RTP 头加扩展大概占 3~5%,重传会额外贡献 5~10%,再加上编码器实际输出经常比目标码率高 5% 左右,合起来留 15% 是实测比较合适的余量——留少了会频繁触发丢包,留多了高清层永远选不上。0.7 是针对高延迟链路的:RTT 超过 250 ms 时,带宽估计的反馈滞后已经比较严重,估计值偏高的概率明显变大,此时按七折算能避免“一升层就拥塞、一拥塞就降层”的来回摇摆。

还有一个必须配套的规则: 层切换必须先等到新层的关键帧 。如果只是把 Sequence 序号接上去,订阅端会收到一串无法解码的帧间差分帧,画面会花屏几百毫秒。正确做法是标记这个 Subscription 处于“等待关键帧”状态,先从旧层继续发(如果有旧层),同时向发布端请求一次关键帧,等关键帧到达再正式切换。

2.4 房间状态机:从信令交换到第一帧画面

整个建立过程分七个阶段,每个阶段都需要超时保护,否则一个卡住的环节会挂住整个房间的资源:

阶段 正常耗时 超时阈值 超时处理
信令交换(Offer/Answer) 50~200 ms 10 s 关闭 Transport
ICE 连通性检查 100~500 ms 5 s(无候选对) 报错并释放
DTLS 握手 100~400 ms 10 s 报错并释放
SRTP 密钥导出与就绪 < 50 ms 2 s 报错并释放
首包到达 200~800 ms 5 s 标记“无上行”并告警
关键帧到达 0~2000 ms 2 s 主动请求关键帧
正常转发 - 无媒体 30 s 清理房间

这里有一条来自用户反馈的经验: 不要等“全部就绪”才让界面显示出画面 。第一版我是等首帧到达才通知客户端“可以显示”,结果网络稍差的用户会盯着一个空白框两三秒,直接反馈“会议坏了”。改成先显示占位头像加一个连接状态提示,首帧到达后再无感切换,投诉量掉了一大截。技术上的问题其实一直存在,但用户感知的问题往往出在状态呈现上。

3. 转发路上四个绕不开的硬骨头:抖动、丢包、关键帧、带宽

3.1 抖动缓冲是在延迟和卡顿之间做对赌

抖动从哪来?网络排队、Wi-Fi 层的自动重传、端侧编码器输出节奏不均、以及操作系统调度。结果是发送端每 33 ms 发一个包,接收端可能是 5 ms 内收到三个、然后 80 ms 什么也没有。抖动缓冲做的事情,就是把这些非均匀到达的包重新排成均匀的发送节奏。

抖动幅度本身要估计,我用的是 RTP 规范里的经典递推:

J(i) = J(i-1) + (|D(i-1, i)| - J(i-1)) / 16
其中 D(i-1, i) 是相邻两包“到达间隔”与“发送间隔”之差(用单调时钟计算)

有了抖动估计,目标延迟就可以动态定: target = clamp(3.5 × J, min_ms, max_ms) 。三个参数里最需要经验的是上下限,我给出一组按场景分类的参考值:

场景 min max 说明
局域网或同城办公网 20 ms 60 ms 抖动小,压到 20 ms 能把总延迟做到很低
家庭宽带 + Wi-Fi 40 ms 150 ms 最常见的场景,max 超过 150 ms 用户会察觉对话迟滞
移动网络 60 ms 200 ms 蜂窝网络抖动大,缓冲留足
跨地域链路 80 ms 250 ms 超过 250 ms 应当提示用户网络质量差,而不是继续加大缓冲

比上限更重要的是 自适应回收 逻辑。抖动缓冲最容易犯的错误是只会加不会减:一出现乱序就加大缓冲,然后延迟一路爬到上限再也不下来,用户感觉整个会议“慢半拍”。我的做法是连续 5 秒没有乱序也没有丢包,就把目标延迟下调 10%,最低不低于 min;一旦检测到乱序,立刻上调到 3.5 × J,不做渐进。这种“慢降快升”的策略在实测中能把平均延迟压低 30~40 ms,同时不乱序。

3.2 NACK、PLI、FIR:用错一个,整个房间集体花屏

NACK 是接收端发现序列号跳空后请求重传。服务端要支持它,就必须有重传缓存。缓存留多久?至少要覆盖“接收端发现丢失 → 发出 NACK → 服务端收到 → 重传到达”这一整圈,也就是一个来回加上排队时间。我用的是 cache_ms = clamp(2 × RTT + 100, 200, 1000) ,实际配置大多落在 400~700 ms。缓存大小的字节数按峰值码率算,不按平均码率算——这一点在第 6 章还会再提一次,因为它是我踩过的坑。

PLI 和 FIR 都是请求关键帧,区别在于语气:PLI 是“麻烦给我一个关键帧”,FIR 是“命令你立刻给关键帧”。不同浏览器实现偏好不同,新版本多用 PLI。真正的问题不是格式,而是 关键帧请求风暴 。

设想一个 20 人房间,第 21 个人加入。他发现没有关键帧,发一个 PLI;发布端产出关键帧,但在他收到之前,另外几个订阅者的网络抖动导致他们也在请求;更糟的是,如果服务端把每个订阅者的 PLI 都原样转发给发布端,发布端会在极短时间内被要求产出十几个关键帧。而关键帧的体积通常是普通帧的 3~5 倍,编码器为了优先产出它还会打乱正常的帧率节奏。结果是:发布端上行瞬时飙到 5 倍码率、整房间开始丢包、所有人一起卡两秒。

治理手段有三个,缺一不可:

  1. 请求去重 :同一 Publication 上的关键帧请求,在 500 ms 窗口内合并成一个。窗口不能太短,200 ms 在实测中仍然会漏掉一部分并发请求。
  2. 每秒上限 :每流每秒最多产出 2 个关键帧,超出的请求直接丢弃或者排队,同时靠第 3 条兜底。
  3. 周期性关键帧 :要求发布端每隔固定时间(我设的是 2 秒)无条件产出一次关键帧。这条是兜底——即使请求被丢,最坏情况也只是等 2 秒,而不是永远黑屏。

这三条加上之后,我把一个 20 人房间的关键帧请求速率从峰值 60 次/秒压到了 3 次/秒以内,紧接着的一次真实测试里,整房间卡顿的现象再没出现过。经验总结起来就一句话: 关键帧是跨订阅者共享的稀缺资源 ,任何“每个订阅者各自请求一次”的设计都会炸。

3.3 带宽信号该信谁:包级反馈与粗粒度反馈的取舍

带宽估计有两种主流输入。一种是包级反馈:接收端把每个包的实际到达时间报回来,服务端据此算出每个包在网络中的排队时延变化,进而推出可用带宽。它的精度高、响应快,适合细粒度地调整发送节奏。另一种是接收端直接告诉发送端一个可用带宽数值,粒度粗、实现简单、兼容性广,但更新频率低、容易跳变。

我的做法是两个都收,然后按下面的顺序决策:

  1. 平时以包级反馈的结果为主,它决定了发送节奏和码率曲线。
  2. 用粗粒度反馈作为上界约束——如果它报告的数值长期低于包级反馈的估计,说明接收端上层(或者它自己的应用层)在限速,此时按更低的那个走。
  3. 如果两者差距超过 3 倍且持续 5 秒以上,记录下来作为异常指标,因为这通常意味着某一侧的反馈路径出了问题,而不是网络真的这么差。

需要强调的是: 带宽估计只决定码率上限,不直接决定选哪一层 。实际选层还要考虑关键帧代价、层切换本身的成本、以及这个订阅者是不是当前的主要观看对象。比如一个订阅者刚从低层切到高层,即使带宽够,也应该先在高层的第一个关键帧到达前继续发低层,避免画面中断。

3.4 序列号、时间戳、SSRC 的重写:最容易埋雷的地方

这一段是转发服务里最枯燥但最容易出事的部分。四项必须重写:

  • SSRC :每个 Subscription 必须有自己独立的 SSRC 空间。否则同一个接收端订阅两个不同发布者的流时,两个流可能撞上同一个 SSRC,接收端会直接判断为“流冲突”并把其中一路丢掉。
  • 序列号 :必须按 Subscription 独立重写成连续序列。因为重传、层切换、丢包都会造成空洞,如果原样透传,接收端的抖动缓冲会算错乱序,把正常的层切换当成大规模乱序。
  • 时间戳 :同一 Subscription 内必须单调递增。跨层切换时要做偏移对齐,把新层的时间戳映射到旧层的时间轴上,否则播放器会看到时间倒跳,画面会顿一下。
  • RTCP 发送端报告 :里面的 NTP 时间戳和 RTP 时间戳都要按重写后的值重新生成,否则音视频同步会错位,用户会看到嘴型和声音差个一两百毫秒。

核心逻辑大概这么短:

def rewrite_for_subscription(pkt, sub):
    pkt.ssrc = sub.out_ssrc
    pkt.seq  = sub.seq_next
    pkt.ts   = sub.ts_offset + (pkt.ts - sub.layer_first_ts)
    sub.seq_next += 1
    return pkt

可以偷懒不重写吗?单层、单发布者、无重传的场景下确实可以不重写,很多演示代码就是这么干的。但只要你加入分层订阅、屏幕共享(一个参与者同时有摄像头和屏幕两条轨道)、或者服务端重传,坑就一定会踩。我的建议是从第一版就把重写做进去,代价不大,省掉的排查时间极多。

顺带说一句端到端加密模式下的特殊情况:如果启用了端到端加密,服务端读不到 RTP 头,序列号重写就做不了。这时候可行的做法是通过数据通道让客户端周期性上报“SSRC 与层标识的对应关系”,服务端只按包外信息做粗粒度的选择性转发,放弃重写能力。这是一个明确的取舍,不是缺陷。

4. 单进程怎么扛住:线程模型、内存分配与必看的六个数字

4.1 每房间一线程在 20 个房间时就撑不住了

线程模型我试过三种,最后选了最不“标准”的那一种。

方案 优点 缺点 实测结论
每房间一线程 逻辑简单,房间之间几乎无干扰,尾延迟好 线程数与房间数线性增长,栈内存和上下文切换成本高,跨房间限流难做 20 房间还能跑,50 房间时调度开销已经不可忽略
全异步单线程 内存最省,逻辑最干净 一个房间的计算卡住会拖住所有房间,多核完全用不上 单核吞吐尚可,但一核跑满就再也扩不上去
混合:事件循环 + 工作线程池 能把多核吃满,跨房间配额可控 需要自己设计队列和公平调度 最终选择

具体分工是:每个 Transport 的收发、解析、排序这类 I/O 相关工作放在事件循环里;SRTP 加密、层决策、序列号重写这类计算放在固定大小的工作线程池里,线程数等于物理核数。房间不绑定线程,靠任务队列分发。

为什么这样设计?因为媒体转发的瓶颈在系统调用和加解密,不在业务逻辑。用队列加线程池才能把 CPU 吃满,同时我用一个配额机制把“每房间一线程”唯一的优势补了回来:每轮调度里,同一个房间最多处理固定数量的包,超过的部分放回队列尾部。这样即使某个房间突然来了一大波重传请求,也不会把其他房间饿死。防尾延迟靠的不是隔离线程,而是公平配额。

4.2 每包一次动态分配是性能杀手

第一版我写得很自然:收到一个 UDP 包,申请一块缓冲区,拷贝进去,处理完释放。单房间测试完全没问题,到 50 个房间、每秒 30 万个包的时候,内存分配器成了最大的 CPU 消耗来源,而且分配速率指标一路上涨,说明碎片和延迟释放都在恶化。后来改了四点:

  • 缓冲池 :按 1500 字节对齐预分配缓冲块,按组(4096 块)管理,用无锁栈取用和归还。整个转发路径上不再有任何每包分配。
  • 批量收包 :用批量接收系统调用一次读多个包,把系统调用次数降到原来的十分之一左右。
  • 按需解析 :只解析需要的字段,也就是 SSRC、序列号、时间戳、负载类型和头部扩展,不做整包拷贝,更不做整包解析。
  • 纯转发不解密载荷 :这是最大的一笔节省。SRTP 默认只加密媒体载荷,RTP 头部是明文,所以纯转发场景下读头部和改写序号完全不需要解密。只有在需要读被加密的头部扩展时才需要解密,这一条把 CPU 占用砍掉了差不多一半。

另外有一个细节值得单独说:发送路径上的加密要做成“一次成型”,也就是在包进入发送队列时加密一次,而不是在每次重传时重新加密。重传缓存里存的是加密后的字节,重传直接复制发送。

4.3 六个必须盯住的数字,以及它们的因果关系

监控指标不在多,在于能不能串成一条因果链。我只看六个:

指标 含义 正常范围 告警阈值 排查方向
转发延迟 p95 从收包到出包的时间 < 30 ms > 80 ms 队列积压、线程池排队
出向丢包率 发出但未被确认的包比例 < 0.5% > 2% 网卡队列、发送节奏
NACK 率 收到的 NACK 数 / 转发的包数 < 3% > 10% 下行链路质量、重传缓存太短
关键帧请求频次 每流每秒的关键帧请求数 < 2 > 5 链路持续恶化或层切换过于频繁
单核订阅路数 用于容量水位判断 视硬件 超过设计值 80% 该扩机器了
分配速率 每秒内存分配字节数 接近 0 持续增长 有代码在每包分配

为什么要按因果链来看?因为用户看到的永远是链条末端。用户说“卡”,可能是出向丢包导致重传、重传导致带宽超限、带宽超限触发降层、降层需要关键帧、关键帧没及时到于是画面花屏。你如果只盯着“关键帧请求频次高”去调,只会把问题推来推去。正确的顺序是先看出向丢包,再看 NACK,再看关键帧请求,顺着链条往上找。

4.4 一台机器开到什么时候该加机器

扩容判断我用三个水位,谁先到就谁触发:CPU 持续超过 70%、出向带宽持续超过 70%、转发延迟 p95 持续超过 40 ms。三个指标对应三种不同的瓶颈,处理方式也不同:CPU 高说明要加机器,带宽高说明要么加机器要么提高分层订阅的使用率,延迟高说明可能是队列设计或者调度出了问题,加机器只能缓解不能解决。

房间亲和性是另一个要点:同一个房间的所有参与者必须尽量落在同一台转发节点上,否则跨机转发会引入额外的跳数和带宽消耗。我的做法是在路由层按房间标识做散列,保证房间内的参与者落到同一台;如果某个房间的规模超出一台机器的设计容量,就直接拒绝,而不是尝试把它切开。级联方案(多台机器各选一个代表互相转发)我也评估过,代价是每个代表节点要额外承担“把自己的所有层转给对端”的带宽,一跳多出 20~60 ms 延迟,而且级联链路本身会成为新的瓶颈点。在房间规模还没有突破单机容量之前,我不打算引入这个复杂度。

5. 排障实录:三个花了几小时才定位的现象

5.1 新加入者黑屏三秒,同时还拖累了全房间

现象很具体:一个 20 人房间里,第 21 个人加入,他的画面黑 3 秒后突然出现,而且这 3 秒内房间里其他人也出现了轻微卡顿。第一反应是网络问题,但同一时间其他服务都正常,于是开始按链路排查。

排查顺序是这样的:

  1. 先看这个新订阅者的入向统计:包在持续到达,但全部是非关键帧。这说明发布端在发流,只是没给关键帧。
  2. 再看发布端的关键帧输出:本该每 2 秒一个周期关键帧,但这次中断了 3 秒。编码器明显被压住了。
  3. 调出这 3 秒内的关键帧请求计数:21 次。也就是说每个订阅者都各自请求了一次,加上重试,发布端连续产出了 21 个关键帧,瞬时码率飙到正常的数倍。
  4. 检查去重逻辑:窗口设的是 200 ms,太短,而且完全没有每秒上限。
  5. 修复:去重窗口改成 500 ms,每流每秒最多产出 2 个关键帧,超出丢弃并依赖周期性关键帧兜底。

修复之后,新加入者的黑屏时间从 3 秒降到了 400 ms 左右,正好是下一个周期关键帧的等待时间上限;同时“新成员加入导致全房间卡顿”的投诉也消失了。这个案例让我彻底改变了一个认知: 关键帧请求不是请求,而是对全房间共享资源的竞争 ,服务端必须做仲裁。

5.2 症状到层次的映射:别一看到“卡”就去调缓冲

排障最怕的是乱试参数。我把常见症状和对应层次整理成了一张表,现在基本可以做到五分钟内判断方向:

症状 最可能的层次 第一步查什么
房间里所有人都卡 发布端上行或转发节点 CPU 发布端上行丢包、转发节点 CPU 水位
只有某一个人卡 这个人的下行链路或解码能力 该订阅者的 NACK 率、渲染帧率
有声音没画面 视频层选择或关键帧 该订阅者是否处于等待关键帧状态
画面周期性模糊一两秒又恢复 层切换过于频繁 带宽估计的波动幅度、升降层的滞回参数
声音断续但画面正常 音频路径的抖动缓冲或重传 音频抖动缓冲的上限值、音频是否走了重传
画面慢慢变糊且不恢复 带宽估计持续下降 该订阅者的总订阅路数是否超了它的下行能力

这张表里最容易被误判的是“画面周期性模糊”。很多人第一反应是编码质量问题,实际上是带宽估计抖动导致层切换来回摇摆。解决办法不是在编码端调码率,而是给层切换加滞回:升层需要带宽超过目标层码率的 1.2 倍并持续 3 秒,降层只需要低于 0.9 倍且持续 1 秒。降快升慢,画面就不会频繁糊了。

5.3 压测为什么必须用真实浏览器跑最后一遍

合成 RTP 流能压出吞吐上限,压不出真实行为。真实浏览器会做这些事:按带宽估计调整码率、在丢包时发 NACK、在切层时请求关键帧、音频和视频同时发送、在 Wi-Fi 抖动时改变发包节奏、在后台标签页时降低帧率。

我遇到过一次很有代表性的情况:合成流压到 200 路订阅,p95 延迟 25 ms,一切正常;换成真实浏览器,60 个就出现 p95 延迟翻倍。原因是真实客户端的 NACK 风暴把发送队列填满了,而合成流从不发 NACK,这个路径根本没被压到。所以我的压测流程固定是三段:先合成流压吞吐上限,再混合流压反馈路径,最后真实浏览器压端到端体验。

抓包分析的流程也固定下来:在转发节点侧按 SSRC 过滤抓 RTP/RTCP,重点看三样东西。一是序列号空洞的分布形态——连续成段的空洞是网络丢包,散点式的空洞多半是抖动导致的乱序,两者的处理方式完全不同,前者要加大重传缓存,后者要加大抖动缓冲。二是 NACK 发出到重传到达的往返时间,超过一个 RTT 加 50 ms 就说明重传路径本身有问题,而不是网络问题。三是关键帧请求与关键帧产出的对应关系,看看有没有请求石沉大海的情况。

5.4 浏览器之间的差异比想象中大

几个实测过的差异点,都值得写进兼容性清单:

  • 分层流的标识映射:某些实现在 SDP 里声明的标识顺序与它实际发流时使用的标识顺序不一致,绝对不能靠顺序猜测,必须按 SDP 里声明的映射关系建表。
  • 只发一层时的简化:部分实现在只上传单层时会省略分层标识,这时要按 SSRC 兜底匹配,否则你会收到一堆“未知来源”的包。
  • 重传机制的支持差异:不是所有实现都默认开通重传,遇到不开通的客户端,重传就全落在服务端头上,重传缓存需要留得更长一些。
  • 声明与实际的偏差:SDP 里写的参数与实际发出的包不一致的情况确实存在,尤其是扩展头的声明。

我的应对办法很土但有效: 别信声明,信实际到达的包 。每个 Transport 建立后 3 秒内做一次“实测画像”,记录实际出现的 SSRC、分层标识、负载类型、扩展头布局,然后用这份画像修正内部映射表。这个机制上线之后,我们遇到的客户端兼容问题数量至少下降了一半。

6. 上线之后:边界、成本和我不会再做的事

6.1 什么情况下真的不该自研

Colibri 上线跑了几个月之后,我对“什么时候不该自研”有了很明确的认识。如果只是三到五人的通话,端到端直连或者直接用成熟方案都是更好的选择,自研的开发和运维成本远超收益,光是把排障能力建立起来就要花掉一个工程师好几个月的精力。如果需要几百人的大会、需要录制、需要转码分发,那是另一套架构,纯转发的设计做不了,硬做只会得到一个两头都不讨好的系统。最后一条最现实:如果团队里没有人能看懂 RTCP 反馈和抓包结果,出问题的时候会完全卡在“不知道该看哪里”这一步,这时候应该先补能力,而不是先写代码。

6.2 转发服务的成本结构和一个反直觉的结论

粗算一下真实数字:两核四 G 的机器,跑 8 个 8 人房间(约 64 个参与者,每房间 25~35 路订阅),CPU 峰值在 55% 左右,出向带宽大约 70 Mbps。这个配置在容量上还有余量,按第 1 章的四条线来看,瓶颈会先出现在带宽而不是 CPU。

由此得到一个和普通服务完全不同的结论: 转发服务的成本几乎线性于下行流量 ,而不是线性于请求数。普通 Web 服务的成本主要在 CPU 和数据库,转发服务的成本主要在出向带宽。这意味着“优化码率”比“加机器”更省钱——把平均码率从 1.2 Mbps 降到 800 kbps,成本差不多降三分之一,而用户体验的损失往往并不明显。这也是分层选择算法值得反复打磨的根本原因,它不是一个技术玩具,而是一个直接影响账单的组件。

6.3 三个“配置看起来没问题”但会出事的地方

  • 重传缓存按平均码率分配 :讲者画面在切屏或者突然运动时码率会飙升好几倍,按平均值定的缓存会在这几秒溢出,NACK 全部落空,用户看到的是一段糊掉的画面。必须按峰值码率再加 2 秒来分配。
  • 用系统时间做抖动缓冲的调度时钟 :系统时间被时间同步校正的时候会发生跳变,只要跳一次,当轮缓冲的包就会全部乱序,表现为整个房间卡一下。一定要用单调时钟,这类问题在开发环境里几乎不可能复现。
  • 收包和发包共用一把锁 :单房间测试完全看不出来,到 50 个房间时锁争用成了最主要的开销,CPU 明明不高但延迟就是降不下来。改成每 Transport 独立队列加无锁结构之后,延迟曲线立刻变平。

这三个坑有一个共同点:单房间、短时间测试时全都是正常的,只有跑到一定规模和一定时长之后才会暴露。所以我现在对新功能的验收标准里必然有一条“连续跑 24 小时、至少 30 个并发房间”。

Colibri 现在跑在我们内部几十间会议室里,从最初的单机 8 房间,到后面扩成一组按房间散列的节点,中间经历过的改动比最初写代码的时间长得多。我自己最大的体会是,媒体转发服务的复杂度从来不在“转发”这两个字上,而在那些错误路径上——丢了怎么办、晚了怎么办、客户端要什么而你没有、客户端不要什么你却在发。你能提前想到的错误路径越多,这套东西跑起来就越省心;反过来,只要有一条错误路径没想清楚,它一定会在某次全员大会上准时出现。

Logo

邀请您加入社区

更多推荐