这个系统主要研究LiveKit Server,也就是SFU设计,如有疏漏,欢迎交流指正。
由于文章中mermaid图相对多而复杂,可以先收藏或者转发,方便在电脑上阅读。
前言
设想一下,在一个实时系统中,当我们向服务器发送一条订阅请求,服务端会做什么?最简单的理解,就是找到发布者的 Track,创建一个下行发送对象,把 RTP 复制过去。但在实际业务中,并没有这么简单。请求到达的那一刻,目标 Track 可能还没发布,订阅权限可能正在变更,订阅者的浏览器可能还没完成下行协商;每个订阅者的网络质量又各不相同。LiveKit 把订阅拆成四个阶段来处理。
这条链路的阅读顺序分四段,对应全文的四个阶段。第一段是控制环节,建立谁订阅哪条轨的关系;第二段是协商,让下行轨进入订阅者的 WebRTC 连接;第三段是媒体环节,上行共享、下行分发;第四段是反馈环节,订阅者网络状态反向影响发送。在这篇文章中,我们会重点讨论每个环节的设计与取舍,不会逐行讲代码。
读完后,你应该能说清楚这几件事:
- 为什么订阅请求不会立刻创建下行轨,要先记下一个目标,等条件满足才真正创建?
- 为什么 DownTrack 要等 Bind 回调之后才接入 ReceiverBase?
- 同一份上行包,为什么每个订阅端都要重新映射序列号和时间戳?
- NACK、PLI/FIR、带宽反馈,分别在什么故障规模下起作用?
订阅的架构设计
这是一个简单的场景,譬如,Alice 发布一条视频,Bob 和 Carol 各自订阅:
flowchart LR
A["Alice 发布端"] -->|"上行 RTP"| U["共享上行媒体"]
U --> B["Bob 的下行分支"]
U --> C["Carol 的下行分支"]
同一个上行包进到共享的接收器,之后按订阅者分叉成互不干扰的下行分支。再看下面这张完整架构图。你会发现,刚才那幅图只是它的简化版。

在进入流程前,先简单认识一下这几个对象:
- Room:房间对象,维护房间内所有参与者和 Track 的关系,订阅请求先落到它这里。
- Participant:某个客户端在服务端的会话实体,记录身份、权限、WebRTC 传输状态。
- MediaTrack:发布者一条媒体轨在服务端的逻辑代表,关联轨道描述和上行接收器。
- SubscribedTrack:表示某订阅者订阅某条发布轨的关系,保存这个订阅者对这条轨的订阅参数。
- DownTrack:订阅者专属的下行发送对象(下文常直接叫下行轨),负责下行 RTP 的状态映射、重传、带宽控制等。
- ReceiverBase:接收发布者上行 RTP 的组件(下文叫上行接收器),缓存并分发到多个 DownTrack。
- PeerConnection:WebRTC 连接对象。
从数据方向看,紫色线是信令,从订阅者客户端进入Participant、Room和SubscriptionManager,最后落到发布者的MediaTrack。蓝色线是媒体,从发布者进入ReceiverBase,经DownTrack、Forwarder / Pacer送回订阅者。橙色线是反馈,从订阅者回到DownTrack,再连接到重传、关键帧请求和带宽分配。
这张图里有两个汇合点。MediaTrack把订阅控制与上行媒体接起来,一边是控制环节,知道 Bob 想订阅什么;一边是媒体环节,知道这条视频的包从哪里来。DownTrack把订阅关系与订阅者专属的下行媒体接起来。这条关系属于 Bob,下行发送状态也属于 Bob。
订阅的整体流程
整个过程可以概括为四个阶段:
- 控制环节:建立谁订阅哪条轨的关系。
- 协商:让下行轨进入订阅者的 WebRTC 连接。
- 媒体环节:上行共享,下行分发。
- 反馈环节:订阅者网络状态反向影响发送。
sequenceDiagram
participant S as 订阅者客户端
participant C as 服务端订阅控制<br/>SignalHandler + Room + SubscriptionManager
participant T as 发布者媒体轨<br/>MediaTrack + ReceiverBase
participant D as 订阅者下行轨<br/>SubscribedTrack + DownTrack
participant W as 订阅者 WebRTC 传输<br/>PeerConnection + Pacer
participant P as 发布者客户端
Note over S,D: 一、控制环节:建立“谁订阅哪条轨”的关系
S->>C: 订阅请求:目标 Track ID
C->>T: 查找 Track,并检查订阅权限
T-->>C: Track 可订阅
C->>T: 添加订阅者
T->>D: 创建这位订阅者的下行轨
D-->>C: DownTrack 已创建
Note over C,W: 二、协商:让下行轨进入订阅者的 WebRTC 连接
C->>W: 将 DownTrack 加入下行传输
W->>S: Offer:新增接收媒体的协商信息
S->>W: Answer:接受协商
W-->>D: Bind 完成回调
D-->>C: 订阅已绑定,可发送
Note over P,S: 三、媒体环节:上行共享,下行分发
loop 发布者持续采集与编码
P-)T: 上行 RTP
T-)D: 将共享 RTP 分发给这位订阅者
D->>D: 选层、改写 RTP 序列号和时间戳
D-)W: 下行 RTP
W-)S: 解码并播放
end
Note over S,P: 四、反馈环节:订阅者网络状态反向影响发送
S-)D: 下行 RTCP:丢包、关键帧、带宽反馈
D->>D: 重传、调层或调整发送节奏
D-)T: 必要时请求上游关键帧
T-)P: PLI/FIR 等反馈
第一阶段:创建属于订阅者的 DownTrack
先看整体时序,订阅请求从客户端进入服务端,第一站只做登记,是否会真正地创建下行轨,要等条件满足,包括身份认证,权限,订阅限制以及 Track 是否可用等。
sequenceDiagram
participant S as 订阅者客户端
participant C as 房间订阅控制<br/>Room + SubscriptionManager
participant T as 发布者媒体轨<br/>MediaTrack
participant D as 订阅者专属下行轨<br/>SubscribedTrack + DownTrack
S->>C: 我要订阅 Track X
C->>C: 检查身份、权限、订阅限制
C->>T: 查找 Track X
T-->>C: Track 可用且允许订阅
C->>T: 将订阅者加入这条 Track
T->>D: 创建订阅者专属下行轨
D-->>C: 下行发送对象已准备
C-->>S: 订阅关系建立<br/>下一步进入 WebRTC 协商
MediaTrackSubscriptions.AddSubscriber会创建SubscribedTrack,再由它创建DownTrack。此时服务端已经准备好了这条下行流所需的独立状态:
- 订阅者可接受的 codec 与 RTCP feedback;
- 这个订阅者独立的 SSRC、序列号、时间戳映射;
- 独立的 pacer、重传记录、带宽与质量统计;
- 视频层选择、静音状态与播放延迟策略。
每个订阅者只持有一份独立的状态信息,保证诸如下行序列号、丢包统计和重传记录这些状态不能相互污染。
接下来,系统要把这条下行轨放入 Bob 的PeerConnection。这一步交接有明确分工。订阅控制层知道 Bob 应该看 Alice,只有PeerConnection知道浏览器目前有哪些可接收媒体的 Transceiver、协商了哪些 codec、可用的发送槽位在哪里。所以DownTrack的下一站是这条传输连接;接进 RTP 分发器要等绑定回调,这一点放到本节末尾再展开。
第二阶段:协商,将 DownTrack 绑定给订阅者
前一阶段完成后,此时系统只是在服务端准备好一条下行媒体轨,订阅者客户端还不知道这条轨存在,更没有准备好进行协商传输。
sequenceDiagram
participant SM as SubscriptionManager
participant MTS as MediaTrackSubscriptions
participant DT as SubscribedTrack / DownTrack
participant PC as 服务端 Subscriber PeerConnection
participant B as 订阅客户端
participant RX as ReceiverBase
SM->>MTS: AddSubscriber(Bob)
MTS->>DT: 创建 SubscribedTrack 和 DownTrack
MTS->>PC: AddTrack 或复用 Transceiver
alt 复用已有 Transceiver
PC-->>DT: Bind 回调
else 新增下行媒体槽位
PC->>B: Server Offer
B-->>PC: Client Answer
PC-->>DT: Bind 回调
end
DT->>RX: AddDownTrack
DT-->>SM: Bound 回调,订阅真正成立
loop 后续持续转发
RX->>DT: 上行 RTP
DT->>PC: 改写并发送下行 RTP
PC->>B: 媒体到达浏览器
end
一般情况下,新增下行轨通常会改变 Subscriber PeerConnection 的媒体描述。服务端发 Offer,订阅者客户端回应 Answer 确认。为什么由服务端主导,不由客户端发起?因为订阅拓扑由房间内的权限、订阅选择和服务端状态决定,客户端只需确认自己的 WebRTC 能力与最终协商结果。
承载这个媒体描述的对象就是 Transceiver,每一个 Transceiver 会对应 SDP 中的一个 m-line,携带媒体类型、方向、codec、SSRC 等参数。新增一个 Transceiver 就会在 SDP 里多一条 m-line。
AddTrack 或复用 Transceiver,如果有可复用的 Transceiver,服务端可以用新的DownTrack替换旧 Track,省掉一次完整 Offer/Answer。
举个例子,会议产品里有这样的场景,主持人把观众拉上台、又切回观众席,本质上,对于观众而言,短暂重连后还是接收同一条 Track。这个复用 Transceiver 的优化,控制的是频繁上下麦的协商成本和 SDP 中长期积累的失效媒体槽位。每多一个 Transceiver,SDP 就多一条 m-line;即使后来不再使用,旧 m-line 也会在后续每一次协商中保留。SDP 越大,解析和协商越慢,浏览器兼容性风险也越高,某些实现可能因 m-line 过多或参数冲突而拒绝重协商。因此,复用 Transceiver 可以尽量避免这些问题。
无论是否重协商,系统仍然要等到同一个交接点:Bind。Bind发生在 Offer/Answer 完成、Pion 实际把本地 Track 绑定到 Transceiver 的发送侧之后,它完成 codec、下行 SSRC、WriteStream、RTCP Reader 和重传状态的初始化,随后 LiveKit 才把DownTrack注册到发布轨的ReceiverBase。从这一刻开始,发布者的共享上行 RTP 才会分发到订阅者。
等 Bind 回调后再将 DownTrack 接入 SFU
客户端 Answer 到达后,Pion 在实际绑定本地 Track 时触发DownTrack.OnBinding回调,这个回调里发生两件事:
1. ReceiverBase.AddDownTrack(downTrack)
2. SubscribedTrack.Bound()
第一件事把DownTrack加入上行 Receiver 的下游分发器。
第二件事把订阅标记为 bound,并恢复或设置视频订阅参数。例如自适应流默认从低层还是直接从期望清晰度开始,接下来实际发送哪一层、以多大的带宽预算开始发送。
如果跳过 Bind 回调,提前把 DownTrack 加入ReceiverBase,SFU 就会向一个尚未协商完成的下行轨写包,结果会是无效发送并导致错误统计,如果在高并发房间中,大量未协商成功的下行轨会同时接收 RTP,并试图通过 PeerConnection 发出,瞬间无效包将占满网络带宽,影响其他正常媒体流。
为什么用 reconcile 而不是一次性检查
stateDiagram-v2
[*] --> 目标已记录: 用户请求订阅
目标已记录 --> 尝试收敛: desired = true
尝试收敛 --> 等待下一次协调: Track不存在、权限不足、容量已满
等待下一次协调 --> 尝试收敛: 状态变化或定时协调
尝试收敛 --> 等待绑定: 创建下行发送分支
等待绑定 --> 已可发送: WebRTC绑定成功
等待绑定 --> 目标已取消: 绑定失败
已可发送 --> 尝试收敛: Track关闭或预期恢复
已可发送 --> 目标已取消: 用户取消订阅
目标已取消 --> [*]: 清理完成
以 Bob 订阅 Alice 已发布的视频轨为例。
如果采用一次性检查方式,那么我们关注的是:“现在能不能订阅?”。而如果采用 reconcile,我们则会关注这样一个问题:“只要 Bob 仍然想订阅,系统怎样在状态不断变化时,持续把实际结果拉回这个目标?”
Bob 请求订阅 Alice 的视频时,LiveKit 不会把这次请求直接等同于“立刻创建一条下行轨”。它会先记住一个目标:desired = true,即 Bob 仍希望收到这条 Track。在后续的协调中,reconcile 会反复 check 条件:这条 Track 是否已经存在、Bob 是否仍有订阅权限、Alice 是否允许 Bob 订阅、当前订阅容量是否足够。
这样做是因为订阅请求和资源真正分配之间并不是原子操作。当 Bob 发出请求时,Alice 的 Track 可能还没到达 SFU,权限可能刚被收回,另一条订阅也可能暂时占满容量。如果只在入口检查一次,那么 LiveKit 会把“当时不可行”误当作“永远不可行”,之后即使 Track 发布、权限恢复或容量释放,也不会自动补上订阅。
所以,reconcile 维护的核心不是一次检查结果,而是“期望状态”和“实际状态”之间的差距。只要 desired 仍为 true,条件暂不满足通常不会取消目标,而是等待下一次协调。Track 的发布或关闭、订阅关闭后释放容量、权限相关状态变化,以及内部定时协调,都会让它重新尝试。
条件满足后,LiveKit 才创建 Bob 私有的下行发送分支,并把它接入 Bob 的 Subscriber PeerConnection。接下来,WebRTC 还要完成本地 Track 的绑定,只有绑定成功,SFU 才把这条下行分支加入上游 RTP 的转发集合。从这里开始,Alice 的共享上行媒体才会真正向 Bob 分发。
OnBind 绑定如果失败,LiveKit 则会触发退订,把 desired 置为 false,与此同时把是否再次订阅交给上层决定。另外,如果 Track 长时间不存在,LiveKit 也会在超时后取消目标,把 desired 置为 false。
因而,reconcile 不是保证“最终一定订阅成功”,而是保证:在目标仍有效且条件后来满足时,LiveKit 不会因为第一次检查失败而错过这次订阅;同时,在目标失效或明确失败时,它能收回资源并停止重试。
第三阶段:上行共享,下行分发
前两阶段已经建立并绑定了订阅关系,这条下行轨也就具备了发送条件。接下来的问题是,每个 RTP 包如何穿过 SFU,最后进入订阅客户端的播放器?
sequenceDiagram
participant PUB as 发布客户端
participant B as 上行媒体 Buffer
participant R as ReceiverBase<br/>forwardRTP
participant S as DownTrackSpreader
participant D as 订阅端的 DownTrack<br/>WriteRTP
participant P as Pacer
participant W as Pion 写流
participant SUB as 订阅客户端
loop 每收到一个上行 RTP 包
PUB->>B: 上行 RTP
R->>B: ReadExtended
B-->>R: ExtPacket
R->>S: Broadcast
Note over S,D: 对每位订阅端分别执行<br/>这里展开其中一个订阅端
S->>D: WriteRTP
D->>D: 选择视频层<br/>映射序列号、时间戳、SSRC
D->>P: Enqueue 下行 RTP 包
P->>P: 按发送节奏取包
P->>W: WriteRTP
W->>SUB: 发送下行 SRTP
SUB->>SUB: 解码并渲染
end
假设发布端正在推送一条可分层的视频,多个订阅客户端正在观看。RTP 先进入 Buffer,ReceiverBase.forwardRTP为每个活跃视频层运行一个转发循环,等待ReadExtended返回下一个ExtPacket。这个对象除了 RTP 本身,还带着扩展序号、空间层、关键帧等 SFU 后续判断需要的信息。
ReceiverBase拿到包后,把它交给DownTrackSpreader.Broadcast,这是共享路径的汇集处,同一个上行媒体包会分发给所有已订阅的DownTrack。订阅端较多时,LiveKit 会并行处理多个DownTrack.WriteRTP,避免某个订阅端的处理延迟拖慢其他下行流。
数据进入某个DownTrack后,就带上了订阅端自己的属性,它知道这位订阅端当前应该接收哪一层、下行 RTP 序列号如何连续、哪些包该进入发送队列。DownTrack.WriteRTP根据当前层选择和历史状态,把上行包映射成属于这位订阅端的下行 RTP,重新编号下行序列号、映射时间戳和 SSRC,再构造包体。
同一份上行包,为什么每个订阅端都要重新映射一遍?这是因为,不同订阅端虽然观看的是同一条发布轨,但却各自拥有独立的清晰度、下行时间线、重传状态和发送行为。
这就是 SFU 的取舍。上行编码、媒体接收和缓存按 Track 共享,省掉了为每个订阅端重复编解码的开销;下行 RTP 状态按订阅端隔离,保证某一个弱网订阅端的降层、丢包恢复或限速不会直接影响其他订阅客户端,而相应的代价是,订阅端增长也会带来更多下行状态、包处理和网络出口压力。
DownTrack完成加工后,把包交给Pacer。Pacer 跟着订阅端走,在合适的时机把下行轨的包发出去。它最终调用 Pion 的TrackLocalWriter.WriteRTP,把 RTP 写进 WebRTC 传输。包经 SRTP 到达订阅客户端,订阅客户端才能解码并显示画面。
Simulcast 的升层与降层
前面我们提过,假设发布端正在推送一条可分层的视频,那么在订阅观看过程中,视频轨是会由于带宽或者其他原因发生切层的。先看三条切换路径的时序:
sequenceDiagram
autonumber
participant A as 带宽分配器
participant F as Forwarder / selector
participant D as DownTrack
participant P as 发布端
participant S as 订阅端
rect rgb(231, 245, 255)
Note over A,S: 升层:以连续画面换取短暂的旧层带宽
A->>F: 目标改为高层
F-->>S: 继续转发当前低层
D->>P: PLI:请求高层关键帧
P-->>F: 高层关键帧到达
F->>S: 从关键帧切到高层
end
rect rgb(255, 242, 230)
Note over A,S: 拥塞降层:以短暂冻结换取立即减载
A->>F: 目标改为低层,标记带宽不足
F-x S: 立即停止当前高层包
D->>P: PLI:请求低层关键帧
P-->>F: 低层关键帧到达
F->>S: 从关键帧恢复低层画面
end
Note over F: 用户主动限层但未带宽不足时,保留旧层直到低层关键帧,走平滑切换
一路视频流拥有不同的空间分层,多个订阅端各自按带宽和用户偏好决定要哪一层。每个订阅者背后都有一条独立的Forwarder,它是这条链路的转发决策中心。带宽分配器把目标层、带宽是否不足等状态交给它,订阅限制也在这里生效。
Forwarder的切换目标来自带宽分配器,可能切到高层,也可能降到低层,但目标能不能立刻生效,取决于当前正在转发哪一层,以及能切入的关键帧是否到达。
逐包判断由Forwarder内部的 selector 完成,selector 标记着当前正在转发的层,平时只放行这一层的 RTP 包,关键帧和非关键帧都可以通行。但当目标层一变,selector 就得等一个关键帧出现在当前层与目标层之间,这个包会成为新的当前层入口,从它之后才开始放行新层的后续包。它不会在一个非关键帧的半截流里突然切进去,那会让订阅端解码花屏甚至崩溃。
从收到目标切换指令,到能切入的关键帧真正到达,中间有一段等待的时间。那么这段等待时间里,旧层的包怎么处理?
具体处理方式根据带宽状况分为三种场景。在下图中,带宽分配器写入目标层与带宽状态,入站 RTP 逐包经过 selector,两条输入在“最终转发策略”这一步汇合,决定哪些包放行、哪些丢弃。
flowchart LR
A["带宽分配器<br/>给出目标层与带宽状态"]
P["入站 RTP 包<br/>来自各个 simulcast 层"]
D["DownTrack<br/>发送给订阅端"]
subgraph F["Forwarder:订阅链路的策略与传输控制"]
S["分配状态<br/>target / IsDeficient / 订阅上限"]
V["selector:包级可解码性状态机<br/>currentLayer / targetLayer"]
G{"最终转发策略"}
M["RTP 连续性、时间戳、<br/>RTX 与编码头处理"]
end
A --> S
S -->|"写入目标层"| V
P -->|"逐包 Select"| V
V -->|"是否选中、是否切层"| G
S -->|"拥塞降层?"| G
G -->|"升层或用户主动降层:<br/>保留旧层等关键帧"| M
G -->|"拥塞降层:<br/>立即丢弃旧高层"| D
M --> D
场景一:带宽充足时的升层
当用户从 360p 切到 720p,带宽分配器把目标切到高层,selector 逐个判断进来的包。低层的包继续过,高层的包因为关键帧还没到,不能过。于是订阅端收到的仍是低层画面,清晰度没变,画面一直连续。
同时,DownTrack向发布端发一个 PLI 请求,请它尽快编码一个目标层的关键帧推下来。当这个关键帧一到,selector 把当前层更新为高层,从此高层包放行,低层包停止。
反映到订阅端上,呈现出画面一直没卡,几百毫秒后清晰度突然变高,大概等了一个关键帧的时间。
场景二:用户主动降层(非拥塞)
用户将 720p 手动改成 360p,带宽分配器把目标设为低层,但没有打上带宽不足的标记。这时Forwarder的行为和升层几乎一样,继续转发旧的高层包,同时请求低层关键帧,等低层关键帧到了,再平滑切过去。从观众侧看,订阅端画面一直流畅,只是清晰度在切换点突然降低。
场景三:拥塞导致的强制降层
网络已经拥塞时,带宽分配器把目标降到低层,同时给这个订阅打上IsDeficient = true的标记,意思是带宽已经不够用了,必须立刻减载。
Forwarder看到这个标记后,selector 照旧选中旧的高层包,但Forwarder在逐包路径上直接丢弃它们,不再发给订阅端。也就是说,旧层的画面戛然而止,订阅端屏幕会卡住,因为没有新帧到达;网络上的高码率流量就瞬间断流。这个截断只在带宽不足标记存在时生效,主动降层不会触发。
为什么拥塞时不能沿用场景二的做法?如果还像场景二那样让旧高层继续转发,直到低层关键帧到来,这段等待期内,高码率的包会继续挤占本就拥堵的发送队列,丢包和延迟只会更多。旧高层还在抢带宽时,低层关键帧和 RTCP 反馈报文都会延后甚至丢失,切换点迟迟不来,拥塞反而加剧。
拥塞时只能接受画面冻结一小段时间,这样带宽马上就释放出来了。等低层关键帧终于到达 selector,它把当前层更新为低层,画面从这个关键帧开始恢复,以低清晰度继续播放。
第四阶段:RTCP 反馈调整后续发送
前一阶段的 RTP 路径从发布端流向订阅客户端。这一阶段方向反过来。订阅客户端把“我丢了哪些包、我能承受多少带宽、我是否已经无法解码”反馈给服务端。DownTrack是反馈进入 SFU 的第一站,因为它最清楚这位订阅端实际看到的是哪条下行 SSRC、发过哪些包、当前选择了哪一层视频。
这一阶段多了三个新角色。丢包重传时,Sequencer + Receiver根据订阅端报告的下行序列号,找回对应的原始上行媒体包。解码链断了,发布端会收到新的关键帧请求。带宽反馈进来后,StreamAllocator根据它调整后续下行轨的预算与层选择。
sequenceDiagram
participant SUB as 订阅客户端
participant W as 服务端 WebRTC 传输
participant D as 订阅端的 DownTrack<br/>handleRTCP
participant R as Sequencer 与上行 Receiver
participant A as StreamAllocator
participant P as Pacer
participant PUB as 发布端
SUB->>W: 下行 RTCP 反馈
W->>D: handleRTCP
alt NACK:缺少某些 RTP 包
D->>R: 根据下行序列号查找原始包
R->>D: 返回可重传的上行媒体包
D->>P: 将重传包加入发送队列
P->>W: 写入重传 RTP
W->>SUB: 补发缺失媒体包
else PLI 或 FIR:当前画面无法恢复
D->>R: SendPLI 请求关键帧
R->>PUB: 向发布端发送关键帧请求
PUB->>R: 后续上行媒体出现关键帧
R->>D: 正常转发新的关键帧
D->>P: 加入发送队列
P->>SUB: 订阅端重新获得可解码画面
else TWCC 或 REMB:网络带宽发生变化
D->>A: 上报带宽反馈
A->>D: 更新后续层选择与发送预算
Note over D,P: 影响之后的 RTP 转发<br/>例如降层、暂停或恢复
end
SUB->>W: Receiver Report:接收质量与 RTT
W->>D: handleRTCP
D->>D: 更新 RTT、抖动和下行统计
下面按问题从轻到重,把这四条路径过一遍。
订阅端发现“我的下行序列号缺了一段”,发来 NACK。DownTrack借助自己的序列映射找到对应的原始上行包,再从 Receiver 的缓存中取回。找得到,就直接重传;这比让发布端重新编码一个关键帧成本更低,也能更快恢复局部丢包。
NACK 找回的是已经丢掉的包。如果订阅端缺少了解码所需的参考帧,找回多少包也恢复不了画面,它就会发 PLI 或 FIR。DownTrack请求当前选中层的关键帧。LiveKit 对关键帧请求做了节流,同一层上,低层默认 500ms 内最多发一次,中、高层为 1s。窗口内重复的 PLI/FIR 不再触发新请求,避免把订阅端的连发反馈放大成连续关键帧请求。在会议产品里,常见的表现就是短暂冻结后画面重新出现;这条路径要的是尽快恢复可解码状态,而不是无限追赶过去的帧。
前两类反馈针对的都是丢包和断链,TWCC 和 REMB 报告的是接下来还能发多少包。DownTrack把反馈交给StreamAllocator,由它更新这位订阅端后续的层选择和带宽预算。第三阶段场景三里带宽分配器给订阅打上IsDeficient标记,依据的正是这类反馈。已经进入网络的 RTP 改不回来了,这个决策从下一批包开始生效;Pacer 再把新的预算变成实际发送节奏。
最后是 Receiver Report,它持续报告 RTT、抖动和丢包情况,通常不直接触发一次明显的媒体调整,但判断网络状态、记录下行质量、调整播放延迟,都离不开它。
这四条路径连起来看,丢了包先用 NACK 把包找回来,解码链断了再请求关键帧,带宽不够就调整之后的发送策略,而 Receiver Report 始终在报告 RTT 和丢包,让这些恢复动作适应当前往返时延。
结语:订阅链路上的四个设计判断
到这里,一条 Track 从 Subscription 到 DownTrack 的链路就走完了。四个阶段各自解决一个问题:订阅关系怎么建立、怎么绑定进浏览器的连接、包怎么穿过 SFU、反馈怎么影响发送。梳理这四个问题,能看到 LiveKit 确立的四个设计判断:
- 订阅目标与实现解耦分离。订阅接口入口保存的只是一个目标,而不是马上实现,还需要等到所有的条件满足,才会真正创建,如果条件发生了变化,那么就得重新协调。
- Bind 是控制环节与媒体环节的交接点。绑定成功之前只有资源规划;绑定之后媒体才具备发送条件。
- 上行共享一份,下行各自隔离。上行接收与缓存按 Track 共享一份,下行 RTP 状态按订阅者各自持有;一个弱网订阅者的降层和重传,不会影响其他订阅者的观看状态。
- 恢复手段按故障规模升级。局部丢包走重传,解码链断裂请求关键帧,带宽持续不足才调整层选择和预算。
这四个判断不只在 LiveKit 里成立,资源会晚到、会变化、会失败,处理这类资源的系统都需要有类似的边界划分。这些机制在弱网下表现如何,下一篇《下行订阅链路弱网实践:从客户端统计到服务端切层》会把这条链路放进实际的网络里验证。
如果你对 LiveKit 感兴趣,可以阅读笔者的系列文章: