技术文章

SFU 的媒体转发内核

2026 年 9 月LiveKit 系列

前言

服务端转发单元(SFU)是当前主流实时音视频架构的基础,发布端只向服务器发送一份媒体流,服务器再根据各订阅者的网络状况、解码能力和订阅需求,把媒体分别转发给多位订阅者。相比 MCU(多点控制单元)的转码混流,SFU 省去了昂贵的媒体处理;相比 P2P 网格,SFU 又减轻了发布端的带宽压力。

LiveKit 基于 Pion 构建,引入了 Receiver → Buffer → DownTrack 三层解耦设计,用共享接收池避免 N 倍上行处理成本,用独立的 DownTrack 隔离网络差异。围绕这条链路,我们把 LiveKit 媒体转发内核的工程设计与取舍摸索清楚,尽量通过图示把握住每个设计,脑子里不再只停留在“转发”的概念层面上。

第一章:全景架构与四个问题

首先先看下这条链路的全景架构,发布者发布上行 Track,RTP数据在服务器里缓存一份,再分化为多条彼此隔离的 DownTrack,将改写后的RTP下发给到各个订阅者。SFU的 ReceiverBase作为上行Track的接收与协调者,内部维护多个 Buffer、层状态以及当前所有下行订阅者的集合。

livekit-layered-forwarding

沿着这条链路往下走,我们会遇到四个问题。

共享缓存:上行媒体只在 SFU 里缓存一份。如果每个订阅者各自缓存一份,内存和 CPU 会随订阅数线性膨胀,百人以上的房间就跑不动了,因此,接收和缓存必须只做一次。

独立时间线:同一份上行媒体,要按人分发,翻译成多条不同的下行流。上行流的码率、层数、关键帧位置是确定的,但每个订阅者的带宽和想订阅的层不一样:A 想收高层,B 网络不好只能收低层,甚至暂时收不到。所以分发不是把同一个包复制给所有人,而是给每个订阅者翻译出属于他的那条下行流。

时间线连续:Simulcast/SVC 切层或重启流时,订阅端看到的流不能跳。Simulcast 各层从各自的 timestamp 起始值计数,切层时直接把新层时间戳写进下行,订阅端的 jitter buffer 就会看到一次异常跳变,这就要求,上游变化和下游稳定之间必须有一层换算。

反馈闭环:转发本身是一条开环链路,订阅端网络一变,SFU 收不到反馈就不知道该降层还是升层,订阅端要么卡顿,要么被硬塞高码率。所以 SFU 要靠反馈判断每条连接的网络状况,决定升层还是降层,必要时再让发布端跟着降码。但回路有时滞,读到的带宽总是过时的,所以调整的同时得有节奏,调猛了会震荡,调慢了会卡顿。

第二章:上行链路的共享接收与缓存

2.1 ReceiverBase:上行媒体的协调中心

ReceiverBase 是这条上行媒体在 SFU 内的协调中心。

receiver-coordination示意图

一份上行 RTP 只来了一次,却可能要转发给几十个订阅者。如果为每个订阅者各收一份、各存一份,内存和解析开销会随订阅数线性膨胀——100 个订阅者就是 100 倍,根本跑不动。

所以 Receiver 的定位就是:一次接收、一份缓存,多个订阅者共用。

在这个前提下,Receiver 的职责被限制在共享层。Receiver 只关心媒体本身的状态:包到了没有、当前有哪些空间层可用、各层码率是多少、丢包了多少、是否需要向上游发起 NACK。至于某个订阅者订阅哪一层、是否暂停发送、下行的序列号和时间戳如何构造,Receiver 不关心这些。

与之相对,每个 DownTrack 独立维护自己订阅者的状态。同一个上行包到达时,Receiver 只是把它交给所有已注册的 DownTrack;每个 DownTrack 是转发、丢弃还是改写,Receiver 既不感知也不干预。

2.2 Buffer:共享媒体池

Buffer 处在发布端网络包和 SFU 转发器之间,作用是判断一个包在上行时间线中的位置:它是新包、乱序包、重复包,还是代表丢失;再把纯 padding 这类不应进入媒体时间线的包剔除出去。

在这条规范化后的时间线上,Receiver 把短期原始包保存在 packet bucket,把转发所需的媒体信息放在 ExtPacket 队列。packet bucket 让正常转发和后续 RTX 能按序列号找回数据,ExtPacket 队列让 forwardRTP() 知道这是不是关键帧、属于哪一层、该如何被下游选择。

Buffer 同时也是上行反馈的汇合点,根据接收序列号发现缺口并安排 NACK,依据接收状态生成 RTCP 报告,对 PLI 节流,并记录 TWCC。下游拿到的,是一条带恢复能力和媒体语义的上行流,而不是未经处理的原始网络包。

flowchart LR
  P["发布端 RTP<br/>乱序、丢包、padding、重传"] --> I["Buffer 接收路径"]

  I --> T["RTPStats + snRangeMap<br/>识别顺序、丢包与有效序列号"]
  I --> M["媒体解析<br/>关键帧、层、帧率、音量"]
  I --> H["packet bucket<br/>保留短期原始包"]
  I --> Q["ExtPacket 队列<br/>保存转发所需元信息"]

  Q --> F["forwardRTP()"]
  H --> F
  F --> D["DownTrack"]
  T --> N["NACK / PLI / Receiver Report / TWCC"]
  N --> P

2.2.1 存储架构:packet bucket + ExtPacket 队列

packet bucket 是一个按 RTP 扩展序列号索引的固定容量环形缓存区。它维护最新 RTP 序列号 headSN 和当前写入位置;新包推进 head,槽位索引取模回绕。而窗口外的旧包,会被覆盖或标记失效。

packet-bucket-环形缓存示意图

Buffer 里有两份职责不同的状态:

flowchart LR
  P["上行 RTP 包"] --> B["Buffer"]

  B --> E["ExtPacket 队列<br/>决定下一包转发什么"]
  B --> K["packet bucket<br/>按扩展序列号保存原始 RTP"]

  E --> F["forwardRTP()"]
  K --> F
  K --> R["DownTrack NACK 后<br/>Receiver.ReadRTP() 回读原包"]

  F --> D["DownTrack.WriteRTP()"]

ExtPacket 是 Buffer 对原始 RTP 包的一层轻量包装,附加了关键帧标记、空间层、时域层等供转发决策使用的元信息,原始 payload 仍由 packet bucket 持有。每收到一个有效 RTP 包,Buffer 解析出时间戳、空间层、关键帧等元信息,生成 ExtPacket,交给 forwardRTP() 按到达顺序转发。

但 ExtPacket 不长期持有完整的原始 RTP 字节。原包被写入 packet bucket,并用扩展序列号索引。等 forwardRTP() 真正取到 ExtPacket 时,ReadExtended() 会再从 bucket 取回原始包,补全 RawPacket 和 payload 引用,然后才交给 DownTrack.WriteRTP()。这样,转发队列保存轻量的、转哪一个包的描述,bucket 保存可回读的原始媒体数据。

这份packet bucket 还有第二个消费者:订阅端发来 NACK 时,DownTrack 先把下行序列号映射回上行扩展序列号,再经 Receiver.ReadRTP() 从对应 Buffer 的 bucket 取回原包,重新封装成下行 RTX。也就是说,bucket 是上行 RTP 的短期来源缓存,而不是每个订阅者各自的重传缓存。

它采用环形槽位,而不是按包无限增长的 map。视频初始保存 300 包,音频初始保存 70 包;新包推进 headSN,超出窗口的旧槽位失效。读一个过旧、尚未到达或已被覆盖的序列号会失败。这个限制是有意设计的:NACK 恢复只需要覆盖一个有限的 RTT 窗口,不能为了极晚到的重传无限占用内存。

从Buffer将RTP包转发到DownTrack,不能直接用原始序列号,还要定义另一套干净的转发编号。

2.2.2 两条序列号视图:原始编号 vs 转发编号

Buffer 同时维护两条序列号视图。

  • RTPStats 记录发布端真实发来的 RTP 时间线:包含 padding、丢包、乱序和重复包。
  • Buffer 向 Forwarder 暴露的是一条剔除 padding 后可转发的时间线。
sequenceDiagram
  participant P as Publisher
  participant S as RTPStats
  participant M as snRangeMap
  participant B as Buffer / bucket
  participant F as Forwarder
  participant D as DownTrack

  P->>S: #40 媒体包
  S-->>M: 原始 ExtSN=40,正常
  M-->>B: offset=0,转发 ExtSN=40

  P->>S: #41 纯 padding
  S-->>M: 原始 ExtSN=41,发现无payload
  M->>M: 排除 #41,后续 offset=1
  M-->>B: 不入 bucket,不进 ExtPacket 队列

  P->>S: #42 媒体包(#41 暂时未到,乱序)
  S-->>M: 原始 ExtSN=42
  M-->>B: 42 - 1 = 转发 ExtSN=41
  B-->>F: ExtPacket(#41)
  F->>D: 生成订阅者自己的下行序列号

在上面的例子中,#40 是正常媒体包,#41 是纯 padding 包,#42 是正常媒体包(#41 此时未到,属于乱序迟到)。

RTPStats 始终按发布端的真实编号工作。它看到的是 #40 → #41 → #42,所以在 #41 到达时虽然发现无 payload,但仍会记录它确实由发布端发送过,是网络统计的一部分。

但 Buffer 不会把 #41 当媒体包保存。纯 padding 的作用是填充带宽或配合拥塞控制,不包含可解码媒体;它不进入 packet bucket,也不会生成交给 forwardRTP() 的 ExtPacket。

这就产生了 offset。Buffer 已经从媒体转发链路移除了一个真实编号 #41,所以后续的媒体包不能继续原样使用发布端编号:#42 减去一个被移除的编号,映射成转发编号 #41。这样 Buffer 保存的媒体包编号是连续化的。

最后得到的映射是:

发布端原始编号 包类型 Buffer 转发编号
#40 媒体 #40
#41 纯 padding,主动丢弃 无
#42 媒体(#41 迟到) #41

2.2.3 从 RTP 中提取媒体信息

除了存储和编号连续化,Buffer 还要把 RTP 包翻译成可供其他模块决策的媒体信息。

flowchart LR
  RTP["RTP 包"] --> B["Buffer"]

  B --> K["编码负载 / DD<br/>关键帧、空间层、时域层、帧依赖"]
  B --> V["关键帧或 DD<br/>视频尺寸"]

  K --> FW["Forwarder / VideoLayerSelector<br/>安全转发与层切换"]
  V --> TI["TrackInfo 与订阅层信息"]

视频按 codec 解析负载。VP8、VP9、H.264、AV1、H.265 都有各自的关键帧识别方式,解析结果写入 ExtPacket,随后随包进入 DownTrack 和 Forwarder。订阅者刚开始接收、发生空间层切换或旧同步点失效时,下游不能随便从一个预测帧开始转发;关键帧标记给它提供了可以建立解码上下文的候选同步点。

对于携带 Dependency Descriptor(依赖描述符)的流,Buffer 可以直接从描述符中读取当前帧的空间层、时域层、帧间参考关系和解码目标掩码(Activity Decode-target Mask)。当解码目标掩码发生变化,对应输出策略或网络条件切换时,Buffer 会结合已缓存的帧依赖关系,重新计算当前可达的最高空间层和最高时域层,并用这个上限值指导后续的数据取舍。超出这个层级的帧被判定为当前不可达,直接丢弃,避免不可解码帧占用带宽。

视频尺寸也不是每个 RTP 包都去解析。对 VP8、VP9、H.264 和 H.265,Buffer 主要在关键帧中提取宽高;对携带 Dependency Descriptor 的流,也可以从 descriptor 读取尺寸。尺寸变化会触发 TrackInfo 更新和订阅层信息刷新。

到这里,原始网络包已经变成了能直接拿去决策的媒体信息。当媒体信息里出现包丢失时,下一步就是靠上行反馈链路把它补回来。

2.3 上行恢复:NACK

SFU 发现发布端少了一个 RTP 包,不能立即判定为永久丢失。它可能只是乱序迟到,也可能需要发布端用 RTX 重传。

sequenceDiagram
  participant P as Publisher
  participant B as Buffer / RTPStats
  participant N as NACKQueue
  participant R as RTCP feedback

  P->>B: RTP #100
  B->>B: 正常接收

  P->>B: RTP #102
  B->>B: 发现 #101 缺失
  B->>N: Push(#101)

  Note over N: 不立刻发 NACK<br/>至少等 20ms,给乱序包一次机会

  B->>N: Pairs()
  N-->>B: #101 到期,允许请求
  B->>R: RTCP TransportLayerNack(#101)
  R->>P: 请求重传 #101

  alt #101 作为 RTX 或迟到原包到达
    P->>B: RTP #101
    B->>B: 标记乱序;撤销丢包统计
    B->>N: Remove(#101)
  else 仍未到达
    B->>N: Pairs()
    Note over N: 按 RTT 和退避间隔决定是否重试
    N-->>B: 再次请求,或达到上限后放弃
  end

上行丢包恢复从 RTPStats 发现序列号缺口开始。Buffer 已收到 #100,随后直接收到 #102。RTPStats 会把 #101 记为丢失区间;Buffer 随即将 #101 放入 NACKQueue。此时仅记录待恢复状态,尚未向发布端发送 RTCP。

不立刻发送 NACK 是为了给网络乱序预留缓冲窗口。NACKQueue 中的新条目至少等待 20ms;如果 #101 在这段时间内作为迟到原包或 RTX 修复包抵达,Buffer 会将它标为乱序包,撤销相应的丢包记录,并从 NACK 队列移除 #101。

等待结束后,Buffer 在处理后续入站 RTP 时检查 NACKQueue。可请求的序列号会被压缩为 RTCP TransportLayerNack 的 NackPair,经 Buffer 的 RTCP feedback 回调发送给发布端。

如果发布端没有及时修复,队列不会无限重试。默认配置最多请求 5 次,最长保留 2 分钟,队列容量最多 100 个待恢复序列号。每次重试之间采用退避:间隔从 RTT 推导,按约 1.25 倍递增,并被限制在 20ms 到 400ms 之间。

至此,上行链路解决了“怎么收”和“怎么补”。但每个订阅者看到的下行流仍是独立的——不同带宽、不同层数、不同丢包率,要求同一份上行媒体在下行方向被分别翻译和裁剪。这正是下一章要解决的问题。

第三章:下行链路的独立翻译与分发

3.1 从上行到下行:一个 RTP 包的旅程

一个 RTP 包从发布者到订阅者的完整路径如下:

sequenceDiagram
    actor P as 发布者
    participant B as Buffer(空间层 L)
    participant F as ReceiverBase.forwardRTP
    participant S as DownTrackSpreader
    participant A as DownTrack A
    actor UA as 订阅者 A
    participant C as DownTrack B
    actor UB as 订阅者 B

    P->>B: 1. RTP 到达,形成 ExtPacket
    B->>F: 2. ReadExtended() 返回 ExtPacket
    F->>S: 3. Broadcast(writer)

    Note over S: 一次逻辑广播;并行度由下行数量与阈值决定

    par A 的独立下行处理
        S->>A: 4a. WriteRTP(extPkt, L)
        A->>A: 5a. GetTranslationParams<br/>翻译或丢弃
        A-->>UA: 6a. 若可发送,写入改写后的 RTP
    and B 的独立下行处理
        S->>C: 4b. WriteRTP(extPkt, L)
        C->>C: 5b. GetTranslationParams<br/>翻译或丢弃
        C-->>UB: 6b. 若可发送,写入改写后的 RTP
    end

    Note over A,C: 共享同一上行媒体内容;各自维护 RTP 翻译、层策略和发送状态

整个流程按处理方式分成两个阶段。

第一阶段是共享处理阶段,对应图中上半部分的步骤 1 到 3。来自发布者的 RTP 包先进入接收缓冲区(Buffer),Buffer 负责处理乱序、丢包检测等基础排序。随后这个包经 forwardRTP 做初步的转发预处理(例如检查是否该转发、是否需要更新某些头部字段),最后送达 Broadcast。

第二阶段是独立分发阶段,对应图中下半部分的步骤 4 到 6。Broadcast 收到包后并不直接发出,而是复制并分发给当前所有 DownTrack。每个 DownTrack 代表一个独立的订阅者通道,因此每个 DownTrack 都会收到同一个包的副本,但后续动作完全独立:每个 DownTrack 根据自己的订阅参数(空间层、时域层、SSRC 映射等)决定是否转发这个包,以及在需要时翻译(重写 SSRC、调整时间戳等)。

3.2 广播机制:从共享到多份独立

时序图中的 Broadcast 调用是分发的入口。它的实现决定了同一包发往多个订阅者时的并发模型和性能特征。

DownTrackSpreader.Broadcast 通常是顺序执行每个 DownTrack.WriteRTP,但也支持并行对多个 DownTrack 发包。

flowchart TB
  P["一个 ExtPacket"] --> B["DownTrackSpreader.Broadcast"]
  B --> Q{"是否达到并行阈值?"}

  Q -->|"否:串行扇出"| S1["WriteRTP(B)"]
  S1 --> S2["WriteRTP(C)"]
  S2 --> S3["WriteRTP(D)"]

  Q -->|"是:分批并行"| SPLIT["按 DownTrack 列表拆成多个批次"]
  SPLIT --> G1["批次 1<br/>WriteRTP(B), WriteRTP(C)"]
  SPLIT --> G2["批次 2<br/>WriteRTP(D), WriteRTP(E)"]
  G1 --> J["等待各批次完成"]
  G2 --> J

小房间(通常 20 个订阅者以下)保持串行扇出,降低 goroutine 调度和原子操作的开销;大房间则按每批 2 条 DownTrack 并行处理。LiveKit给出的实测参考是,单次 WriteRTP 平均约 50µs,调度一次 goroutine 约 100µs,因此每批 2 条 DownTrack 恰好平衡并行收益与竞争开销,既避免单个慢速订阅者长期占住工作,也不至于因批次过细而频繁触发原子操作。

3.3 DownTrack:订阅者独立的通道

DownTrack.WriteRTP() 是一位订阅者的下行包生产线,先判断这个订阅者是否应收这包,再生成属于这个订阅者的序列号、时间戳、SSRC 和扩展头,登记重传索引,最后交给 pacer 发送。

sequenceDiagram
  participant F as forwardRTP
  participant D as DownTrack.WriteRTP
  participant W as Forwarder
  participant S as Sequencer / RTPStats
  participant P as Pacer
  participant T as Subscriber Transport

  F->>D: ExtPacket + 当前空间层
  D->>W: GetTranslationParams()

  alt 此订阅者不该收此包
    W-->>D: shouldDrop = true
    D-->>F: 返回,不入队
  else 应转发
    W-->>D: 下行 SN/TS、PT、Marker、codec/DD 改写信息
    D->>D: 复制 payload,构造新的 RTP Header
    D->>S: 记录"下行 SN ↔ 上行扩展 SN"
    D->>S: 更新下行发送统计
    D->>P: Enqueue(packet)
    P->>T: 按带宽节奏实际写入传输层
  end

WriteRTP() 的第一步是调用 Forwarder.GetTranslationParams()。Forwarder 是每个 DownTrack 内部的选层加翻译状态机,它给出一个 TranslationParams,包含:

  • shouldDrop:这包是否不该下发;
  • 新的扩展序列号和扩展时间戳;
  • 下行的 Payload Type、Marker;
  • codec 头部改写结果,例如 VP8/VP9/H264 等包内字段;
  • Dependency Descriptor 的下行版本;
  • 是否发生层切换、恢复发送、开始发送等事件。

丢包并不一定是异常导致的,订阅者被暂停、目标层尚未确定、当前包不属于选中的空间层/时域层、层切换尚未到安全同步点,都可能令 shouldDrop=true。

接下来要新构造一个 RTP Header,不能复用上行包。上行包的 SSRC 属于发布者,下行包的 SSRC 属于这条订阅轨道;同理,序列号、时间戳、Payload Type、Marker 也都是下行视角的值。Forwarder 内部包含一个 RTPMunger 子模块,专门负责序列号和时间戳的偏移计算与层切换时的平滑过渡。

writeRTP-订阅者RTP翻译示意图

构造完包后,WriteRTP() 会将映射写入序列号映射管理器 Sequencer:这个下行序列号对应哪一个上行扩展序列号,以及当时的 RTP 头信息。后续订阅者发来 NACK 时,SFU 先根据这份映射理解它丢的是哪一个下行包,再回到对应的上行 Buffer 查包或执行重传逻辑。

最后,包进入 pacer,按拥塞控制和发送节奏决定实际发送时机。

3.4 RTP 头翻译详解

Forwarder.GetTranslationParams 是一个下行 RTP 翻译器,它把上游不规则、可切层、可过滤的包流,整理成某个订阅者能稳定接收的一条 RTP 流。翻译覆盖 RTP 头的四个字段:序列号(SN)、时间戳(TS)、SSRC 和 Payload Type(PT)。

flowchart TB
  subgraph U[发布者上行 RTP]
    U1[低层:SSRC=1111 PT=96<br/>SN=1000 TS=90000]
    U2[低层:SN=1001<br/>被时间层策略过滤]
    U3[高层关键帧:SSRC=2222 PT=96<br/>SN=7000 TS=450000]
  end

  F[Forwarder.GetTranslationParams<br/>选层 + 切源 + 计算 SN/TS 偏移]
  W[DownTrack.WriteRTP<br/>写入订阅者自己的 RTP Header]

  subgraph D[订阅者下行 RTP]
    D1[SSRC=9999 PT=102<br/>SN=5000 TS=90000]
    D2[不暴露被过滤包形成的空洞]
    D3[SSRC=9999 PT=102<br/>SN=5001 TS 向前推进]
  end

  U1 --> F
  U2 -. PacketDropped 调整偏移 .-> F
  U3 --> F
  F --> W
  W --> D1 --> D2 --> D3

3.4.1 序列号的重写

下行序列号由 DownTrack 独立维护,从初始值开始连续递增,与上行序列号没有数值上的继承关系。

但 DownTrack 必须知道发出的下行 SN=5000 对应的是上行哪个包,因为订阅者后续可能针对这个下行 SN 发来 NACK。这个映射由 Sequencer 记录:每次 WriteRTP 实际发送一个包时,Sequencer 存入一条记录(下行 SN → 上行扩展 SN + 空间层)。

当订阅端的 NACK 到达时,DownTrack 先查 Sequencer 得到上行扩展 SN,再调用 Receiver.ReadRTP(层, 上行SN) 从 Buffer 的 packet bucket 回读原始包,最后按这个订阅者的翻译状态重构成 RTX 包发出。

被过滤掉的包不写入 Sequencer。假设上行低层包的原始序列号为 #1000, #1001, #1002,DownTrack 的下行初始序列号为 #5000,则转换后为 #5000 → #1000、#5001 → #1001、#5002 → #1002。若某个包被过滤,下行序列号仍然推进(例如 #5003 被丢弃,#5004 对应上行 #1003),这样订阅端看到的序列号始终连续,不会暴露上行被过滤造成的空洞。

3.4.2 时间戳的重排

层切换的真正难点在于时间戳的对齐。订阅者原本在低层,最后发出的包下行时间戳是 100000;现在切换到高层,高层当前包的上行扩展时间戳是 800000。这两个时间戳不能直接换算,因为不同 simulcast 层可能从各自独立的 timestamp 起始值开始计数,直接写入会导致接收端看到一次异常的时间跳变,影响 jitter buffer 和解码器的稳定。

下行扩展时间戳主要根据以下公式计算:

下行扩展时间戳 = 上行扩展时间戳 - tsOffset

正常转发时,tsOffset 不变。因此同一层连续到来的包会保留原本的媒体时间间隔,只是整体平移到订阅者当前的时间轴上。当发生层切换、恢复发送或者源SSRC改变时,就得重新计算tsOffset,如下图所示:

flowchart TB
  A["上行 ExtPacket<br/>extTS = 900000"] --> B["RTPMunger<br/>outExtTS = extTS - tsOffset"]
  B --> C["DownTrack RTP Header<br/>Timestamp = uint32(outExtTS)"]

  D["层切换 / 恢复发送 / 源 SSRC 改变"] --> E["重新计算下一包应落到的下行时间 extNextTS"]
  E --> F["更新 tsOffset<br/>tsOffset = incomingExtTS - extNextTS"]
  F --> B

Forwarder 会先估算目标层当前包在参考层时间轴上的位置:

  1. 读取参考层和目标层的 RTCP Sender Report;
  2. 用两个 Sender Report 的 NTP 时间差换算成 RTP tick;
  3. 将目标层当前 RTP timestamp 换算到参考层的时间基准;
  4. 与此前最后发送的下行时间戳比较,得到这包合理的 extNextTS。

确定 extNextTS 后,代码设置:

tsOffset = incomingExtTS - extNextTS

于是下一次常规计算必然得到 outExtTS = incomingExtTS - tsOffset = extNextTS。

层切换时有三个保护规则:

  • 目标层换算后的时间戳略微落后于最后发送包时,不倒退,改为 lastTS + 1;
  • 明显落后时,认为切换点不安全,暂不切换,等待更合适的包;
  • 恢复发送(如取消静音)时,除了参考层时间,还会参考根据发送统计推算的当前应有时间。

3.4.3 SSRC 的重写

上行包切换 simulcast 层或切换源时,SSRC 可能改变。例如,低层来自 1111,高层来自 2222。这两种场景虽然都表现为 SSRC 变化,但处理方式不同:

  • Simulcast 层切换:同一编码器输出的不同分辨率版本,各层之间虽然 timestamp 起始值不同(各自独立计数),但内容在时间上是连续的,同一时刻的低层画面和高层画面对应的是同一帧。因此只需做时间基准换算:用两层的 RTCP Sender Report 中的 NTP 时间差,把目标层当前 RTP timestamp 映射到参考层时间轴,再与下行最后发出的 timestamp 对齐即可。订阅端看到的是同一轨道的画质变化,而不是流的中断。

  • 源切换(如编码器重启、发布端重推):新旧两条流之间通常没有 NTP 时间上的参照关系,它们可能来自完全不同的编码实例或时间原点。此时不能做换算,只能保守重置:清空旧的时间戳偏移和序列号映射,以新流的第一包为基准重新建立下行时间轴,同时等关键帧到位后再开始转发,避免订阅端解码失败。

无论哪种切换,Forwarder 都会在内部重建序列号映射、时间戳偏移和 codec 翻译状态,但下行方向每个 DownTrack 的 SSRC 保持不变,订阅者始终看到同一个 SSRC。

flowchart TB
  A["上行低层<br/>SSRC=1111, PT=96"] --> F["Forwarder<br/>识别 source/layer switch<br/>重建翻译 offset"]
  B["上行高层<br/>SSRC=2222, PT=96"] --> F

  F --> D["DownTrack.WriteRTP"]
  D --> X["订阅者 X<br/>SSRC=5001, PT=102"]
  D --> Y["订阅者 Y<br/>SSRC=6001, PT=98"]

对订阅端而言,切层或切源仍是同一条订阅轨道的媒体连续变化,而不是突然出现一个新的 RTP 源。RTCP Receiver Report、NACK 和发送统计都挂在同一个固定的下行 SSRC 上。

3.4.4 Payload Type 的重写

PT(Payload Type)决定这个 RTP payload 按哪套协商参数解释。它本身不表示固定的 codec(96 不一定是 VP8,102 也不一定是 VP8),只是 SDP/WebRTC 协商过程中的一个动态编号。发布者和订阅者各自与 SFU 协商时,可能拿到完全不同的 PT 编号。

因此,SFU 在转发 RTP 包时必须做 PT 映射:上行包的 Header 必须使用发布者与 SFU 协商的 PT,下行包的 Header 必须使用 SFU 与当前订阅者协商的 PT。绑定与匹配流程如下:

flowchart LR
  U["发布者的 codec 列表<br/>PT 96 = VP8<br/>PT 98 = H264"] --> M["DownTrack.Bind<br/>匹配上下游兼容 codec"]
  S["订阅者协商的 codec 列表<br/>PT 102 = VP8<br/>PT 127 = H264"] --> M

  M --> R["选中:上游 VP8 ↔ 下游 VP8<br/>保存 d.payloadType = 102"]
  R --> W["WriteRTP<br/>每个普通媒体包写 PT = 102"]

DownTrack.Bind 会按 MIME 类型(如 video/VP8)匹配,同时考虑编码参数(如 H264 的 profile-level-id、packetization-mode 等)是否兼容。匹配成功后,将下游对应的 PT 保存到 DownTrack 实例中。

若 DownTrack.Bind 找不到兼容的 codec,这个 DownTrack 会进入不可用状态,对应的订阅者无法收到这条流,需要等待重协商或由应用层处理。

第四章:RTCP 反馈与控制闭环

Receiver、Buffer、DownTrack 共同构成转发流水线,RTCP 反馈则把这条流水线从开环广播变成闭环控制。同一条上行媒体会发给许多订阅者,但每个订阅者的网络状况不同,SFU根据订阅者的反馈信息进行调整。

订阅端的 RTCP 反馈按功能分为三层:

  • 丢包修复(NACK):解决单包丢失,由 DownTrack 局部重传。
  • 状态恢复(PLI/FIR):解决关键帧缺失,由 Buffer 节流后转发给发布端。
  • 拥塞控制(REMB/TCC):解决带宽分配,由 StreamAllocator 决定升降层。
flowchart LR
  SUB["订阅端的 RTCP"] --> Q{"反馈类型"}

  Q -->|"NACK"| N["查下行重传映射与 Buffer"]
  Q -->|"PLI / FIR"| K["向发布侧请求关键帧"]
  Q -->|"RR"| R["更新 RTT、抖动与统计"]
  Q -->|"TWCC / REMB"| W["带宽估计与层分配"]

4.1 NACK:单包丢失的局部重传

NACK 是最细粒度的反馈:订阅者发现某个下行包没收到,请求 SFU 补发。

某个 DownTrack 已向对应的订阅者发出下行包 SN=41001,订阅者没收到,于是发送 NACK。

DownTrack 不会直接拿 41001 去上行 Buffer 查包,因为 41001 是这个订阅者的下行序列号。它先查询 Sequencer,发现对应的上行包的序列号是SN=9001,再去packet bucket 中找原始包。

找到原始包后,SFU 有两种方式重新发送媒体包,一种是之前已经协商过RTX,那么就会使用这个订阅者的 RTX SSRC、RTX PT 和新的 RTX 序列号重构构建媒体包,另外一种是之前未协商过RTX,那么就直接使用原下行序列号重新发送媒体包,两种情况下都会经由 pacer 入队,发给订阅者。

4.2 PLI / FIR:关键帧缺失的局部请求

当订阅者无法继续解码视频,例如参考帧丢失,可能发送 PLI,请求一个完整的关键帧来重建解码上下文。DownTrack 先确认当前应向哪个空间层请求恢复,确定存在有效层之后,再请求这个有效空间层的关键帧。

LiveKit 会在这个空间层对 PLI 做节流,当多个订阅者同时卡住时,SFU 不会把每一份 PLI 都转发给发布端,从而避免关键帧请求风暴。

而FIR的触发场景往往是当有新的订阅者加入,需要尽快取得完整的关键帧,而不能干等着,又或者是订阅者切换了正在观看的视频源,需要一个独立可解码的关键帧来初始化新的订阅流。

4.3 Transport-CC 与 REMB:带宽估计的双通道输入

Transport-CC 和 REMB 是拥塞控制层的两种反馈输入,最终都汇入 StreamAllocator 做带宽预算分配。在 LiveKit 中,服务端配置默认走 REMB(RemoteBWE)。

Transport-CC

Transport-CC 是随 RTP transport-wide sequence number 上报的逐包到达反馈,用于发送端估计下行路径的拥塞状态与可用带宽。

SFU 发送下行 RTP 时,会在 RTP Header Extension 中写入一个 transport-wide sequence number;它不是某条视频轨道自己的 RTP 序列号,而是这条传输连接范围内用于拥塞控制的编号。订阅端随后回报类似的信息:

transport seq: 1000  1001  1002  1003  1004
接收状态:       到达    到达    丢失    到达    到达
到达时间差:     +0ms   +2ms     -     +18ms   +2ms

这组数据说明这批下行网络包哪些到了、多久到、到达间隔有没有拉长。SFU 据此推断排队、拥塞和可用带宽的变化。

REMB(Receiver Estimated Maximum Bitrate)

RTCP 包携带接收端估计的最大可接收码率及其关联 SSRC 反馈给 SFU。实际估计码率为:

bitrate = BR Mantissa × 2^BR Exp

例如 BR Exp = 2、BR Mantissa = 375000 时:bitrate = 375000 × 2² = 1,500,000 bps,即接收端认为这条连接大约可承受 1.5 Mbps。

带宽预算管理

Transport-CC 和 REMB 被送入带宽预算管理器 StreamAllocator,两者对 StreamAllocator 的影响路径不同,但最终都落在同一个数字上:这个订阅者目前可分给所有视频轨道多少带宽。

同一订阅者可能同时订阅多路视频。这些 DownTrack 共用一条网络连接,也就共用可用带宽。StreamAllocator 收到这个订阅者的 Transport-CC 或 REMB 后,估计的是这条连接的容量上限,再在这条连接内的多条视频轨道之间分配预算、决定哪些轨道降层、暂停或恢复。

flowchart TB
  T["Transport-CC<br/>逐包到达、丢失、到达时间差"] --> B["BWE:发送端带宽估计"]
  R["REMB<br/>接收端给出的总码率估计"] --> B

  B --> E["容量估计 / 拥塞状态变化"]
  E --> S["StreamAllocator"]
  S --> A["重新分配多条视频 DownTrack"]
  A --> L["升层 / 降层 / 暂停 / 探测"]

假设订阅者当前有三路视频,总需求 2.3 Mbps:

屏幕共享:1.2 Mbps
摄像头 1:0.6 Mbps
摄像头 2:0.5 Mbps

当 Transport-CC 推断或 REMB 直接报告连接只剩 1.5 Mbps 时,StreamAllocator 不会机械地给每路等比例限速。它按轨道优先级和可用层,尽量让更多轨道至少以低层继续播放;带宽不足时才让低优先级轨道降层或暂停。网络恢复后,StreamAllocator 通过探测和逐步升层回到更高质量。

控制闭环解决了“何时降层、何时升层、何时暂停”的问题,但整个转发链路在运行时还面临一个更底层的问题:goroutine 如何组织?当流发生重启或重建时,新旧时间线如何不交错?这是第五章要解决的问题。

第五章:并发与重启

5.1 按层转发的 goroutine 模型

Buffer 是并发和顺序的单位,不同 Simulcast 层有各自的 Buffer,可以独立读取和转发;SVC 只有一个 Buffer,因此只有一条转发 goroutine。启动流程如下:

flowchart TB
    subgraph SIM["Simulcast:按层并发"]
        direction LR
        I1["输入流"] --> B0["层 0 Buffer"] --> G0["转发 goroutine"]
        I1 --> B1["层 1 Buffer"] --> G1["转发 goroutine"]
        I1 --> B2["层 2 Buffer"] --> G2["转发 goroutine"]
    end

    subgraph SVC["SVC:共享转发链"]
        direction LR
        I2["输入流"] --> B["共享 Buffer"] --> G["转发 goroutine"]
    end

每一层启动独立的 forwardRTP goroutine,经 readCond 条件变量等待新包到达。同一层的所有订阅者共享这份包的读取,但每条 DownTrack 的 WriteRTP 仍在各自的上下文中独立执行。

这项设计带来的代价是:

  • 活跃的层数越多,goroutine 数量、Buffer 内存、StreamTracker(持续监控每层实际到达码率的模块)和 Broadcast 调用次数就越多;
  • 每一层的包都可能 fan-out 到大量 DownTrack,即使其中多数订阅者最终在 WriteRTP 中判定丢弃;
  • 层间读取隔离并不等于完全的性能隔离,同一订阅者的 Forwarder 锁、pacer、CPU 和网卡仍是共享资源。

5.2 two-phase restart:Generation 切换防止时间线交错

当 SFU 判断当前到来的 RTP 已经不再能用旧的序列号、时间戳和层状态解释时,必须把整条转发链路从旧状态切换到新状态。旧转发 goroutine 可能还在处理缓存中的旧包,而新的包已经从上行 Buffer 进来了,如果新旧包交错发往订阅端,订阅者的 jitter buffer 会看到序列号或时间戳的异常跳变。

触发场景

按从外部到内部的顺序,主要有三类触发场景:

  1. ICE restart 网络路径变化、候选地址失效、ICE 配置变化,或系统决定需要重新建立 WebRTC 连通性。这类场景通常是传输层的重建,媒体转发状态也必须随之重启,避免断开前旧媒体上下文污染新连接。

  2. TrackInfo 的时间版本变化 ReceiverBase 会比较 TrackInfo 的 TimedVersion。只要版本变化,就调用 Restart("update-track-info")。这相当于信令侧明确告诉服务器,这条 Track 的描述已经进入新版本,需要让媒体转发状态重新同步。常见于上游 Track 的 codec、层信息或其他影响媒体解释的描述发生更新。

  3. RTP 连续性检测到跳变 Buffer 接收 RTP 时会持续维护序列号和时间戳范围,如果新包出现的跳变无法按正常乱序、重传或 rollover 解释,就会触发 restart。譬如说,发布端进程重启、编码器重建、长时间断流后恢复但序列号不连续。

核心流程

generation 是流的一段连续时间线,同一个 generation 内的 RTP 包,序列号、时间戳和层状态连续。当流无法再用旧的 generation 解释时,SFU 触发 restart,进入新的 generation。restart 要做的,就是把新旧两个 generation 彻底隔离,保证订阅端始终只收到同一个 generation 的连续流,而不是跨越 generation 的乱序包。

Restart 完成有顺序的 generation 切换如下图所示。

sequenceDiagram
  participant R as Receiver
  participant F as 旧转发 worker
  participant B as Buffer
  participant D as DownTrack
  participant N as 新转发 worker

  R->>F: 标记旧 worker 失效,并唤醒读取
  F->>F: 完成当前包的处理后退出
  R->>F: 等待全部旧 worker 停止

  R->>B: 清除旧上行流状态
  R->>D: 清除旧下行翻译状态

  R->>N: 启动新一代转发 worker
  N->>B: 读取新流 RTP
  N->>D: 转发给订阅者

Receiver 先把旧的转发 worker 标记为失效,同时唤醒那些正在等 RTP 的 worker。这些 worker 不会立刻被杀死,而是把手里这包处理完就自行退出;Receiver 等全部旧 worker 都退出后,再继续下一步,这样就不会有旧包在清状态之后还被发出去。

接下来,Receiver 清除 Buffer 的上行流状态——待转发的包、丢包恢复和统计窗口全部清零——同时让每个 DownTrack 重置自己的下行翻译状态,丢掉旧流的序列号、时间戳和层选择上下文。

状态清理完成后,新一代 worker 才会启动,从 Buffer 读取新流 RTP,开始转发。

结语:从数据转发到控制回路

一个上行包到了 SFU,在 Buffer 里留下唯一的一份拷贝——共享缓存。随后它被分发给每个 DownTrack,每条 DownTrack 按自己订阅者的状况决定翻译还是丢弃——每个订阅的流都有自己独立的时间线。如果中途发生切层,DownTrack 会把新层的时间戳换算到旧的时间轴上,订阅端毫无察觉;如果整条流重启,就先停旧转发、清旧状态、再启新转发——保持下行流的时间线连续。包发出去之后,还需要等候订阅端的反馈,SFU 据此补包、降层或者向发布端重新请求关键帧——完成反馈闭环。这就是一个包从进来、出去,再到反馈回来的完整一生

SFU 就像是一个自动控制系统,从下图能更清楚地看到整条链路:

flowchart LR
    subgraph Controller["控制器 (SFU)"]
        BWE["带宽估计器 (TCC/REMB)"]
        ALLOC["StreamAllocator<br/>层分配/码率分配"]
    end

    subgraph Plant["被控对象 (发布端)"]
        ENC["视频编码器<br/>码率/层数/帧率"]
    end

    subgraph Sensor["传感器 (订阅端)"]
        FEEDBACK["RTCP 反馈<br/>NACK/REMB/TCC/RR"]
    end

    ENC -->|"上行 RTP(含媒体信息)"| SFU_CORE["Receiver + Buffer + DownTrack"]
    SFU_CORE -->|"下行 RTP"| FEEDBACK
    FEEDBACK -->|"测量值(丢包/延迟/带宽)"| BWE
    BWE -->|"容量估计"| ALLOC
    ALLOC -->|"控制量(目标层/码率上限)"| ENC

    SFU_CORE -.->|"PLI/NACK 转发"| ENC

在这个回路中:

  • 被控对象是发布端的编码器,输出包括码率、层数和帧率,是要调节的控制变量;
  • 传感器是每个订阅端的 RTCP 反馈,测量与反馈下行链路的丢包、延迟、到达间隔和可用带宽;
  • 控制器是 SFU 的带宽估计器和层分配逻辑,它根据传感器读数计算出新的目标层和码率预算;
  • 执行器是发送给发布端的 RTCP(REMB/Transport-CC 反馈)以及 SFU 自身对 DownTrack 的丢弃/降层决定。

LiveKit 的 SFU 内核做的远不止把 RTP 包从发布端复制给各个订阅端,它是一个分布式媒体控制系统的执行节点,在共享媒体内容之上,根据每个订阅者的反馈调整下行输出和上游信号,让系统在动态网络里保持稳定、低延迟和可解码。从这个控制视角回看Simulcast的平滑切层、 Buffer 的 NACK 节流、带宽估计和视频层管理、Receiver 的两阶段重启,这些工程设计的目标都是同一个——让控制回路既不振荡也不过冲,扰动之后快速收敛。

如果你对 LiveKit 感兴趣,可以阅读笔者的系列文章:

← 返回首页