前言
点下“开始直播”,一两秒后画面就出现在观众端。发布一条视频,API 调用看起来是同步的,但它返回的那一刻,画面其实还没到服务器。
一次发布要解决两件性质完全不同的事,第一,音视频TRACK声明:告诉服务器你要发什么样的 Track;第二,媒体包传输:把编码后的视频数据真正送到服务器。声明走 WebSocket,要求可靠、有序;数据走 WebRTC 媒体通道,实时优先、允许丢包。两条链路各自独立,到达顺序没有保证,最后必须在 Server 内完成对齐。
本文跟踪一条摄像头视频的完整发布流程:从 SDK 启动采集,到 Server 把这条 Track 收入房间、真正开始收 RTP。路线是先看整体架构,再分六阶段拆解,最后讨论这条链路里反复出现的一个设计模式。读完你应该能回答:
publish()返回那一刻,什么已经完成、什么还没完成?- Server 怎么把 AddTrack 的声明和 Pion OnTrack 的媒体对上号?
- 为什么房间里能看到 Track,不等于 Server 在收 RTP?
- 一次发布里有好几个“完成”信号,如何区别?
好,先从整体架构看起。
架构
这张图回答一个核心问题:App 发起发布后,LiveKit Server 如何既拿到发布声明,又真的收到视频数据?

做法是把发布拆成两条独立链路,最后在 Server 内汇合成一条 MediaTrack。蓝色是声明链路,紫色是媒体链路。
声明链路。SDK 的 SignalClient.sendAddTrack 把 AddTrackRequest 经 WebSocket 送到 Server 的 Participant。请求里全是发布意图:音频还是视频、Track 名字、来源、编码层、客户端标识(CID)。Server 据此建立发布意图、分配 Track SID、把 Track 纳入房间状态管理。这一步完成后,视频包一个都没到——Server 只知道有人计划发一条视频。
媒体链路。同一个 LocalVideoTrack 同时挂到 Publisher PeerConnection 上。PC 负责 SDP Offer/Answer 和 ICE 连通性检查;通道建立后,编码视频封装成 RTP,经 SRTP 加密发往 Server。图里紫色箭头落到 Server 的 Receiver/Buffer,那里才是真正的视频包。
为什么拆两条?声明负责 Track 生命周期和房间状态,要求可靠、有序,走 WebSocket;视频数据量大、实时性强、可容忍丢包,走 UDP/WebRTC。混在一条通道里,媒体传输会被业务信令拖累,重协商、弱网、乱序都无法独立处理。
拆开之后,Server 的难点就来了:两条链路天然异步。
- 信令可能先到:Server 已知道要发一条摄像头视频,RTP 还没影。
- 媒体也可能先被 WebRTC 栈识别:Server 看到了某个 SSRC/MID/RID 的流,但业务层的 Track 信息还没到。
因此,Participant 不等于媒体流,Receiver/Buffer 也不等于房间里的 Track。中间需要一个汇合点:MediaTrack。它把发布描述里的逻辑身份(Track SID)和 WebRTC 媒体身份(SSRC/MID/RID)对应起来。LiveKit Server 源码 里,这份对齐工作围绕 ParticipantImpl 的 AddTrack、mediaTrackReceived 和 MediaTrack 的创建展开。
对齐之后,MediaTrack 把对应的 RTP 流接入 Receiver/Buffer。Buffer 不只是收包:它缓存上游 RTP,让后面多个订阅者都能从这同一个上游建立各自的转发路径。
最后,Server 把这条逻辑 Track 纳入 Room:房间里的其他参与者会收到“某人发布了一条 Track”的事件,可以据此发起订阅。这里有个关键点:房间可见是控制层状态,Buffer 持续收 RTP 是媒体层事件——两者是两个不同的完成条件,先后顺序没有保证。
发布的六个阶段
架构看完,我们从时间上分析发布的各个阶段。一次发布按顺序分成六个阶段,中间有三次汇合。先看简化版总图:
flowchart LR
A["① SDK 等待真实帧<br/>计算发布参数"] --> B["② 声明 + PC 配置<br/>并行推进"]
B --> C["③ 第一次汇合<br/>publish() 返回"]
C --> D["④ WebRTC 协商<br/>Offer/Answer + ICE"]
D --> E["⑤ 第二次汇合<br/>Server 对齐两种身份"]
E --> F["⑥ 第三次汇合<br/>RTP 真正写入 Buffer"]
三次汇合,三次完成:SDK 本地汇合一次(publish 返回),Server 身份对齐一次(MediaTrack 建立),媒体包真正抵达一次(Buffer 收包)。含义完全不同,后面逐一拆开。(这张总图是全文的地图,每一章都会回到它,再展开解读。)
sequenceDiagram
autonumber
actor App
participant SDK as Swift SDK
participant PC as Publisher PeerConnection
participant Server as LiveKit Server
participant Media as Pion / Receiver / Buffer
App->>SDK: 发布摄像头视频
rect rgb(255, 248, 220)
Note over SDK: ① 准备要发送的视频
SDK->>SDK: 启动摄像头
SDK->>SDK: 等待第一个有效视频帧
SDK->>SDK: 得到真实尺寸
SDK->>SDK: 计算编码参数和视频层
end
rect rgb(235, 242, 252)
Note over SDK,Server: ② 两个发布事务并行推进
par AddTrack 控制面
SDK->>Server: AddTrack(CID、尺寸、视频层)
Server->>Server: 保存 pending TrackInfo
Server->>Server: 分配 Track SID
Server-->>SDK: TrackPublishedResponse
and Publisher PC 配置
SDK->>PC: 配置 Sender / Transceiver
SDK->>PC: 安排 WebRTC 协商
Note over SDK,PC: 这里只是安排 negotiate,不等待协商完成
end
end
rect rgb(226, 243, 224)
Note over App,SDK: ③ 第一次汇合:SDK 本地发布完成
SDK->>SDK: TrackInfo Ready
SDK->>SDK: Negotiation Scheduled
SDK->>SDK: 创建 LocalTrackPublication
SDK-->>App: publish() 返回
end
Note over App,PC: publish() 已返回,但网络协商和媒体发送仍可继续
rect rgb(242, 235, 248)
Note over PC,Server: ④ WebRTC 协商异步继续
PC->>Server: SDP Offer
Server->>Media: 应用 Offer,创建 Answer
Media-->>Server: SDP Answer
Server-->>PC: SDP Answer
par Trickle ICE
PC-->>Server: Client ICE Candidate
and
Server-->>PC: Server ICE Candidate
end
PC->>PC: 应用 Answer
PC->>PC: ICE → DTLS → SRTP
end
rect rgb(255, 242, 204)
Note over Server,Media: ⑤ 第二次汇合:Server 识别这条 Track
Media-->>Server: Pion OnTrack<br/>MID / RID / SSRC / RTPReceiver
Server->>Server: pending TrackInfo + Pion OnTrack
alt 两路信息尚未对齐
Server->>Server: 放入 pendingRemoteTracks
Server->>Server: 信息补齐后 replay
else 匹配成功
Server->>Media: 创建 MediaTrack
Server->>Media: AddReceiver + Buffer.Bind
Server->>Server: Track 加入 Room
end
end
rect rgb(220, 245, 220)
Note over PC,Media: ⑥ 第三次汇合:Server 收到真实媒体
PC->>Media: 加密视频数据 SRTP
Media->>Media: 解密并拆出 RTP
Media->>Media: Buffer.Write(RTP)
Media->>Media: packets / bytes 持续增长
end
这张图涉及五个角色:App 和 SDK 在客户端一侧,Server 是 LiveKit 服务端,Publisher PeerConnection 与 Pion/Receiver/Buffer 是双方各自的 WebRTC 栈。六个阶段各回答一个问题:
- 阶段一,准备视频:拿什么参数发布?
- 阶段二,声明与 PC 配置:怎么同时让 Server 认识 Track、让 WebRTC 准备发送?
- 阶段三,publish() 返回:这个 API 的返回门槛到底在哪?
- 阶段四,WebRTC 协商:publish() 返回后,通道怎么建立?
- 阶段五,身份对齐:声明和媒体两路信息怎么对上号?
- 阶段六,真实收包:从哪一刻起,Server 才算真正收到这条视频?
下面我们顺着这张图逐阶段展开。
阶段一:从创建 Track 到发布参数就绪
App 触发发布后,SDK 先采集摄像头画面,信令靠后。这一步的时序图只有 App、LocalParticipant、LocalVideoTrack 和 CameraCapturer 四个角色:
sequenceDiagram
actor App
participant LP as LocalParticipant
participant Track as LocalVideoTrack
participant Cap as CameraCapturer
App->>LP: setCamera(true)
LP->>Track: createCameraTrack()
LP->>Track: start()
Track->>Cap: startCapture()
Cap-->>Track: native capture started
Note over Track,Cap: started 不等于已有视频帧
Cap-->>LP: 第一个有效帧<br/>dimensionsCompleter 完成
LP->>LP: 计算 encodings / VideoLayers
这条链上其实只有两件事:先把采集管道搭起来,再等第一帧画面。前者由 start() 完成,后者由 dimensionsCompleter 完成,SDK 把这两件事刻意分开。下面一步步看。
第一步,把采集管道搭起来。 App 调用 LocalParticipant.setCamera(true),SDK 走 set(source: .camera):先由 LocalVideoTrack.createCameraTrack() 创建视频源、采集器和 native RTC track(WebRTC 层真正的轨道对象),再调用 _publish(track:)。_publish 接着调 track.start()——这一步只等摄像头启动完成,不等帧。start() 返回只说明采集管道就位。
第二步,等第一个有效帧。 SDK 接着等 dimensionsCompleter。这个 Completer 属于采集器,要等第一帧成功经过裁剪、VideoProcessor(自定义画面处理插件,如果有)和旋转处理后才会完成,返回值是真实帧的尺寸。拿到尺寸,SDK 才开始算编码参数:computeVideoEncodings 算 encodings,再推导 VideoLayers。
- Encodings(编码参数):同一路画面编码出的若干档位,每档是一组参数:目标码率、帧率,以及相对原图的分辨率档位。
computeVideoEncodings用真实尺寸算出这些档位。- VideoLayers(视频层描述):把档位翻译成 Server 能读的层描述,每层带 rid(层的标识,约定为 q/h/f,即原图的四分之一、一半、全尺寸)、宽高、码率。AddTrack 声明时会把这份描述发给 Server。
为什么非要等第一帧画面
拿到尺寸才算参数,看着像多等了一拍——为什么不直接用 CameraCaptureOptions.dimensions?那只是期望配置,文档里明确说是 “preferred dimensions”,可能被覆盖。格式匹配、裁剪缩放、屏幕方向、自定义 Processor 都可能改变实际输出。采集器按宽高比和 fps 挑最接近的 AVCaptureDevice 格式,未必准;第一帧还要过裁剪和旋转。
如果拿期望尺寸去算三层 simulcast,而真正送进 encoder(编码器)的帧尺寸不同,Sender(发送端)配置和 Server 收到的 VideoLayer 描述就对不齐。SDK 于是在这里承受一次启动延迟,换来编码配置基于真实帧。
start() 与第一帧,为什么要分开
如果合并成一个 API ,问题在于:start() 不是只有发布路径在调。SDK 源码里,视频 track 的 unmute 也会调 start()——_unmute() 对视频 track 就是 start() → `startCapture()。这类调用方只需要“状态就位”,不需要画面。如果 start 要等第一帧,unmute 就得等一帧画面回来才返回,而这帧多久来、来不来,SDK 说了不算。
更极端的是外部帧源。BufferCapturer、App 自己塞 pixelBuffer 的源,没有“设备启动”这个过程,第一帧什么时候到完全由 App 决定——App 不塞帧数据,就永远没有第一帧。start 一旦等第一帧,这类场景下,start函数会直接永久挂起。再退一步,start() 的失败和“没有帧”是两类不同的问题:前者是权限、设备配置,确定、可处理;后者取决于上游,不可控。混进同一个 API,失败更难以排查。
阶段一结束,SDK 手上有了发布参数。接下来要同时做两件事:让 Server 认识这条 Track,让 WebRTC 准备发送这条 Track。
阶段二:AddTrack 与 Publisher PC 配置同时进行
参数就绪后,SDK 同时解决这两个问题。两件事性质不同、互不依赖,SDK 选择并行:
sequenceDiagram
participant LP as LocalParticipant
participant Signal as SignalClient
participant Server as ParticipantImpl
participant PC as Publisher PC
par AddTrack 控制面
LP->>Signal: sendAddTrack(CID, layers)
Note over Signal: 为 CID 创建 completer,等待响应
Signal->>Server: AddTrackRequest
Server->>Server: HandleSignalMessage → AddTrack
Server->>Server: CanPublishSource 权限 + 类型检查
Server->>Server: addPendingTrack:构造 TrackInfo、分配 SID、存入 pendingTracks[CID]
Server-->>Signal: TrackPublishedResponse
Signal-->>LP: 按 CID 唤醒 completer,拿到 TrackInfo
and Publisher PC 配置
LP->>PC: addTransceiver(sendOnly, encodings 写入 LKRTCRtpTransceiverInit)
LP->>PC: 绑定 Sender 与 LocalTrack
LP->>PC: 设置 degradation preference / 首选 codec
LP->>LP: publisherShouldNegotiate() 安排协商
end
AddTrack 分支。SDK 的 SignalClient.sendAddTrack 先给 CID 创建一个 completer,把 AddTrackRequest 发往 WebSocket,然后等待响应。收到 TrackPublished 消息后,根据 CID 唤醒 completer,拿到 TrackInfo。
Server 这边,消息经 rtcSessionWorker 交给 ParticipantImpl.HandleSignalMessage,再分发到 AddTrack。AddTrack 做两轮检查:CanPublishSource 校验发布权限,再校验类型是音频还是视频。通过后 addPendingTrack 构造 TrackInfo、分配 Track SID、存进 pendingTracks[CID],然后立即返回 TrackPublishedResponse。
Publisher PC 分支。SDK 创建一个 sendOnly transceiver,把 encodings 配置到 LKRTCRtpTransceiverInit,绑定 Sender 和 LocalTrack,设置 degradation preference 和首选 codec,最后调用 publisherShouldNegotiate() 安排协商。
AddTrack与Publisher PC为什么分开走
AddTrack 走 WebSocket,管业务身份:CID、SID、Track 类型、source、预计视频层。Publisher PeerConnection 管媒体描述与传输:m-section、codec、RID、ICE、DTLS、SRTP。两套独立的状态机,没有理由让其中一条链拖慢另一条链。并行缩短了 publish() 的等待时间,代价是 Server 必须接受乱序:Pion OnTrack 可能先于 AddTrack,也可能晚于它。后面阶段五的 pendingTracks、pendingRemoteTracks 和 replay 机制,都是为这个代价买单。
Track发布声明重复的处理
正常的情况下,当客户端发布addTrack后,track声明先排队进 pendingTracks,等 OnTrack 来认领。
一次发布只发一次 AddTrack——Swift SDK 的 publish 就是如此。Server 的 addPendingTrack 构造 TrackInfo、分配 SID,存进 pendingTracks[CID],然后立刻回 TrackPublished。此时它只是“Track发布声明已到、媒体未到”的暂存条目。之后 Pion 的 OnTrack 到达,mediaTrackReceived 按 SDP 里的 CID 认领:先查已发布 Track,查不到再从 pendingTracks[CID] 弹出第一个 TrackInfo,创建 MediaTrack。
但,同一个 CID是有可能多次 AddTrack的,常见三种:① 前一条声明还没被 OnTrack 认领(还在 pending),客户端又发了一次;② 同 CID 的 Track 已经匹配成功(已发布),客户端再次声明;③ 重连/迁移后重新声明。
面对这种重复发布Track的情况,Server端如何处理?
addPendingTrack 发现同 CID 已有已发布或 pending 的条目,不会直接拒绝,而会把新 TrackInfo 追加进该 CID 的队列。服务端消费队列时, 按顺序认领;如果前一条声明已匹配完成,后一条会等当前 Track 关闭后补位。
阶段三:SDK 创建 LocalTrackPublication 并返回
这是第一次汇合:AddTrack 分支拿到了 TrackInfo,Publisher PC 分支配置完成。SDK 把两份结果合成一个本地发布对象,还给 App:
sequenceDiagram
actor App
participant LP as LocalParticipant
participant Signal as AddTrack Branch
participant PC as Publisher PC Branch
Signal-->>LP: TrackInfo Ready
PC-->>LP: Negotiation Scheduled
LP->>LP: 创建 LocalTrackPublication
LP->>LP: 绑定 LocalTrack并加入 publications
LP-->>App: publish() 返回
Note over App,PC: 不等待 Offer、Answer、ICE connected 或首个 RTP
publish() 返回前,到底在等什么
Fast publish 模式用两个 async let 并行启动 AddTrack 和 negotiate 闭包,等两者都返回。AddTrack 闭包一直等到 TrackPublishedResponse;negotiate 闭包只等 transceiver 和 Sender 配置完成,把协商任务丢给 debounce 就返回,不涉及 Offer/Answer。两项都完成后,SDK 用 TrackInfo 创建 LocalTrackPublication,绑定 LocalTrack,加入本地 Publication 集合,触发 didPublish delegate 回调,最后把 Publication 返回给 App。
为什么不等到媒体真正到达 Server
如果 publish() 一直等到 ICE、DTLS 和首个 RTP,API 的完成时间就取决于网络质量和关键帧间隔。弱网下 App 可能好几秒拿不到 Publication,也没法及时执行 mute、unpublish 或显示明确状态。
所以 LiveKit 把“本地已提交”和“媒体已送达”拆成两个门槛:publish() 只在第一个门槛上返回 – 控制层响应更快。
debounce 里的失败,由谁兜底
Debounce.schedule 内部执行延后任务时用了 try? await action()。debounce 真正触发后,Offer 创建或发送失败不会再回传到已完成的 publish() 调用栈。异步化之后的错误不会再通过返回值传递,因此,后续只能靠 Transport 状态、错误日志和 RTC stats 来观测协商是否成功。
阶段四:Offer、Answer 与 Trickle ICE 在后台继续
Publication 已经在准备好了,Publisher PeerConnection 还在后台完成媒体协商和传输选路。当debounce 触发后,协商真正开始:
sequenceDiagram
participant PC as Swift Publisher PC
participant Signal as WebSocket Signaling
participant Part as ParticipantImpl
participant Pion as Server PCTransport/Pion
PC->>PC: debounce后 createOffer
PC->>PC: setLocalDescription
PC->>Signal: SignalRequest.offer
Signal->>Part: HandleSignalMessage
Part->>Pion: HandleOffer
Pion->>Pion: 事件队列串行执行<br/>SetRemoteDescription → CreateAnswer → SetLocalDescription
Pion-->>Part: Answer
Part-->>Signal: SignalResponse.answer
Signal-->>PC: Answer
PC->>PC: setRemoteDescription
par Client ICE
PC-->>Pion: Trickle candidate
and Server ICE
Pion-->>PC: Trickle candidate
end
debounce 触发后,PC 把当前所有 transceiver 的状态汇总成一份 Offer:要发哪些媒体(m-section)、用什么 codec、simulcast 各层怎么排(RID),全部写进 SDP,然后调用 setLocalDescription设置给到本地。Offer 打包成 SignalRequest.offer,走 WebSocket 信令通道,Server 端经 HandleSignalMessage 按消息类型分发,落到 HandleOffer,再交给 Pion 的 PCTransport。
在Pion 侧,所有操作事件队列串行执行——先SetRemoteDescription 接受对方的 Offer,其次CreateAnswer 按自己支持的 codec 生成应答,最后SetLocalDescription 落地。Answer 生成后交回 ParticipantImpl。
ParticipantImpl 把 Answer 打包成 SignalResponse.answer,沿同一条 WebSocket 送回客户端;客户端 setRemoteDescription,把 Server 的应答落到本地。到这里 SDP 握手完成,双方都知道了对方的媒体描述,后面才谈得上真正发包。
与 SDP 往来并行的是 candidate 交换,而且是双向的:客户端每发现一个 candidate 就发给 Server,Server 也往客户端发——这正是 trickle 的意义,不等收集完,边发边收。
candidate 凑齐后,ICE 做连通性检查选出可用路径,接着 DTLS 握手、SRTP 密钥,媒体数据才真正开始传输。
Pion侧采用队列处理SDP事件
Offer 到了 Server,PCTransport.HandleRemoteDescription 其实只是把事件投进一个 队列中,由队列串行执行 SetRemoteDescription、CreateAnswer、SetLocalDescription,最后 Answer 通过信令送回客户端。
为什么加一道队列?因为 Offer、candidate、ICE restart 都可能从不同 goroutine 同时到达,而 Pion 的 SDP 状态机不支持并发调用。LiveKit 的做法是让所有这些操作排队:一律投进同一个事件队列,串行执行。对不支持并发的状态机,串行执行本身就是并发控制,比满处加锁省事,也更容易理清。
以缓存应对乱序的常态
Trickle ICE 的目的就是不等所有 candidate 收集完再发 SDP,所以 candidate 可能在 RemoteDescription 之前到达。Server 把提前到达的 candidate 存进 pendingRemoteCandidates,SetRemoteDescription 成功后再逐个加入。
为什么需要等remote description到位之后才能添加ICE candidate?这就涉及到WebRtc的协议设计了,内容较多,后续有机会再展开分析,这里先留个坑位。
协商完成,媒体通道建立。Server 真正的难题来了,如何把信令里的声明和 OnTrack 里的媒体对上号。
阶段五:Server 对齐 AddTrack 与 Pion OnTrack
第二次汇合发生在 Server 内部,AddTrack和Pion OnTrack两个输入可以任意先后到达,前者携带着业务信息,包括CID、SID、类型、source、codec、预计视频层,而OnTrack 带的是媒体信息,包括SDP Track ID、MID、RID、SSRC、RTPReceiver,两者通过SID实现对齐。
sequenceDiagram
participant Signal as AddTrack Path
participant Part as ParticipantImpl
participant Pion as Publisher Pion
participant MT as MediaTrack
Note over Signal,Pion: 两路输入可以任意先后到达
Signal-->>Part: pending TrackInfo<br/>CID + Track SID
Pion-->>Part: OnTrack<br/>Track ID + MID + RID + SSRC
Part->>Part: mediaTrackReceived()
alt MID或TrackInfo尚未就绪
Part->>Part: park pendingRemoteTracks
Part->>Part: 条件补齐后 replay
else 匹配成功
Part->>MT: addMediaTrack
Part->>MT: AddReceiver
end
这里得先认识一下图中的多个ID,区别清楚。
LiveKit 业务侧(AddTrack 声明携带):
- CID(client ID):客户端给轨道起的名字,SDK 生成。关键点:它同时被写进 SDP 的 msid——SDP 里的 track id 就是 CID。这是业务侧与媒体侧之间的桥,后面匹配全靠它。
- SID(server ID):Server 收到声明后分配的轨道 ID,是这条 Track 在房间里的逻辑身份。
WebRTC 媒体侧(SDP 和 RTP 自带,Pion 解析后随 OnTrack 交出):
- SDP Track ID:msid 里的 track id——其实就是 CID,同一个值,只是从媒体侧看叫这个名字;
- MID:SDP 媒体线的编号(
a=mid:0),标识“这条流挂在第几行媒体描述上”;- RID:媒体线内 simulcast 层的标签(
a=rid:q send),q/h/f 各一层;- SSRC:RTP 包头里的同步源字段,每个实际媒体流一个——同一层的每个 RTP 包都带同一个 SSRC;
- RTPReceiver:Pion 收到远端流时创建的接收端对象,握有 codec、SSRC,通过它才能读出 RTP 包;OnTrack 事件把它和 TrackRemote 一起交出来。
mediaTrackReceived 拿到 OnTrack 后开始匹配。
首先,先取 MID。MID 是这条流在 SDP 里的媒体身份,取不到说明 SDP 还没就绪,流挂不到任何媒体描述上——无法继续,直接 park,等条件补齐再重新匹配。
其次,查询已发布 Track(按 SDP Track ID 查)。命中,则说明这条流属于一条已经匹配好的 Track,譬如在simulcast场景下,q 层先建好 MediaTrack, simulcast 的第二个、第三个层(h/f 层)到达时就都从这里命中,不用重新声明新的MediaTrack,把接收器加进现有 MediaTrack 即可(实践章节里 q 的 newTrack=true、h/f 为 false,就是这一步的体现)。
第三步,查不到再调 getPendingTrack,从待匹配的声明里找,条件逐层放宽:先精确 CID → 再匹配 codec 级别的 Cid(多 codec simulcast 时每个 codec 有自己的 SDP track id)→ 最后退到“同类型的任意一条 pending Track”。
最后,当三步全落空,就先park等待,等 AddTrack 到达后再触发重放。
第三步的放宽是有代价的,同类型 fallback 处理的情况是:客户端重建 transceiver有可能导致 SDP track id 漂移,这个有个风险是,多个同类型 pending Track 并存时可能认错。不过,整个匹配用顺序表达优先级——精确的优先、宽松的兜底,比写一套复杂的规则判断简单得多,简单意味着稳定。
park/replay:把乱序转化为显式状态
在最后一步中,mediaTrackReceived 在 MID 为空、找不到对应 pending TrackInfo时,pendingRemoteTracks会进入park状态,等条件补齐后replay重放。
问题来了,我们为什么不在 Pion 回调里同步等待track信令?实际上,如果这样做,会让媒体回调依赖于网络控制通道,goroutine、锁和超时等逻辑会全搅在一块。通过park/replay 把协议乱序转化为显式状态,让两个独立异步输入进行会合,这种模式比同步等待更简单稳定。
匹配成功、MediaTrack 建立之后,剩下的问题是:真正的 RTP 包怎么进来,房间什么时候看见这条 Track?
阶段六:从 MediaTrack 建立到真正收到 RTP
当阶段五的身份对齐后,Server 还要把具体上行流接进来,让房间能够看见这条 Track。
sequenceDiagram
participant Part as ParticipantImpl
participant MT as MediaTrack
participant Buffer as Receiver / Buffer
participant Room as Room
Part->>MT: 创建逻辑 MediaTrack
Part->>MT: AddReceiver(RTPReceiver, MID)
MT->>Buffer: GetBufferPair + AddUpTrack + Bind
par 逻辑 Room 路径
Part-->>Room: 异步 OnTrackPublished
Room->>Room: RoomTrackManager.AddTrack
Room->>Room: 广播成员状态 + auto-subscribe
and 真实媒体路径
Buffer->>Buffer: Buffer.Write(RTP)
Buffer->>Buffer: packets / bytes持续增长
end
Note over Buffer,Room: 两条路径没有固定先后保证
MediaTrack创建流程
MediaTrack 的创建过程遵循一条原则:同一逻辑 Track 可关联多个 receiver,以支持 simulcast 的多个 RID 或多个编解码器。
MediaTrack 创建完成后即调用 AddReceiver,它并非简单地将 receiver 附加到 Track,更重要的是,把已由信令关联好的上行 RTP 流接入 SFU 的媒体处理链路。
GetBufferPair(ssrc) 根据 SSRC 获取到对应的 RTP Buffer 和 RTCP reader。RTP Buffer 负责缓存近期数据包、累计接收统计、解析序号/时间戳及扩展字段,当发生NACK 重传、关键帧判断和多订阅者转发时,一律从这个buffer获取数据。RTCP reader 则负责接收RTP流的 Sender Report、XR 等控制信息,用于映射 RTP 时间戳与 NTP 时间,并更新 RTT 等状态。
如果当前 MediaTrack 还当没有关联某个codec的 WebRTCReceiver,则会新建一个,专门负责接收和统一管理对应的codec下的各 RID/层、流质量与活跃层判定、上行 RTCP 反馈以及订阅端 DownTrack 的转发入口。不同 simulcast 层都是同个codec类型,共享同一个 WebRTCReceiver,这样一来就不用给每一层simulcast重复维护转发与控制逻辑。
随后,WebRTCReceiver.AddUpTrack(track, buff) 将当前到达的Pion的 TrackRemote 及其对应的 Buffer 挂靠上对应的编码层(simulcast的某一个编码层)的上游输入, TrackRemote 是Pion的对象,携带SDP track id, RID,SSRC, Codec和RTP包等信息。接着,启动 forwarder goroutine,它主要干三件事:
for ... {
extPkt, err = buff.ReadExtended(pktBuf) // ① 从共享 Buffer 阻塞读下一个包
...
if extPkt.Packet.PayloadType != ... { drop; continue } // ② 载荷类型不符就丢
...
r.downTrackSpreader.Broadcast(func(dt TrackSender) { // ③ 广播给所有订阅者
dt.WriteRTP(extPkt, spatialLayer)
})
// ④ 统计延迟/抖动
}
持续从Buffer 中读取 RTP 包,并按订阅者当前选择的层分发给各个 DownTrack,这一步其实就是整个SFU的核心 – 一个上行包,扇出给每一个订阅者的 DownTrack。
最后,Bind() 将 WebRTC 协商得到的编解码器参数、协议头部扩展及预期码率绑定到 Buffer 上,Buffer有了这些信息,就可以开始正确解析该RTP包,如果没有这些信息,那么进入到Buffer队列中的RTP包就只是干排队缓存着,即无法解析也无法统计RTCP信息。
发布确认与房间广播
mediaTrackReceived 在第一次匹配到该媒体流时,异步调用 handleTrackPublished,其作用是向 Room 通知:发布者的一条逻辑 Track 已经成立,可作为房间内的发布资源使用。
随后进入 Room.onTrackPublished,执行三项操作:
RoomTrackManager.AddTrack:将该 MediaTrack 注册到 Room 的 Track 管理器中,使 Room 能按 Track ID 查找该轨道。从这一刻起,它不再仅是发布者内部的对象,而是 Room 已知的一条发布流。- 广播成员状态:向房间内其他成员发送发布者更新后的状态,其中包含新 Track 的信息,使其他成员获知该发布者新增了一条音频或视频轨道。
- auto-subscribe:对满足条件的其他房间内的成员,触发它们订阅该 Track,Room 会尝试将这条已登记的发布流转发给这些成员。
需要强调一下,这一步是异步的,也就是说,RTP 包的到达与写入 Buffer,与这个异步 Room 回调没有固定的先后关系。RTP 包可能先于回调到达并被 Buffer 暂存起来,也有可能在这个异步 Room 回调之后再到达。
MediaTrack 与 Receiver 的分层设计
MediaTrack 是房间业务层的逻辑 Track,以 Track SID 标识,可聚合 simulcast 多层和多个编解码器的 receiver。WebRTCReceiver 对应具体的编解码器、SSRC/RID 和上行 RTP 流。
举例而言,Alice 发布了一条 camera 上行流,无论使用 VP8 还是 AV1 编码,均归属于同一条 MediaTrack。对于每种编解码器的上行流,又可按 simulcast 分为不同层,同样归属于同一条 MediaTrack。
1 条 MediaTrack(Alice 的 camera)
│
├─ VP8 WebRTCReceiver
│ ├─ low RID / SSRC A / Buffer A
│ └─ high RID / SSRC B / Buffer B
│
└─ AV1 WebRTCReceiver(若该逻辑 Track 同时存在该编解码器)
└─ high RID / SSRC C / Buffer C
分层之后,房间和订阅逻辑围绕稳定的 Track SID 运转,媒体层独立管理不同的上行层和编解码器。对于订阅者而言,它订阅的是某一条 MediaTrack,而非某个具体编解码器或层的 Track。服务端 SFU 根据订阅者的能力、带宽及当前选择,决定从其媒体层内部的哪个编解码器、哪个层的 Buffer 取包并转发给订阅者。
Buffer 允许先收包后 Bind 的考量
未 Bind 时,Buffer 的 Write 方法会将 RTP 包缓存到有界的 pending 队列(超出容量则按包数裁剪),Bind 成功后再统一重放。这样做的目的是避免丢失首批包含关键帧的包:若直接丢弃,订阅者在 Bind 后仍需等待新的关键帧才能解码,造成首帧延迟。
注意,并非所有 RTP 包都会被缓存。probe 包(SSRC 为 0)不缓存,这类包属于探测流量,只会占用内存而无实际媒体价值。
异步等待与归拢模式
回头看这六个阶段,可以提炼出一个反复出现的模式:
- SDK 等第一帧,是等参数依据齐了再声明;
- Server 的 pendingTracks/pendingRemoteTracks,是把“声明先到还是媒体先到”变成显式状态;
- 两端的 candidate 缓存、Buffer 的 Bind 前缓存,是同类操作:前提不满足就先缓存,满足后再重放。
在实时媒体系统中,不能假设事件按理想顺序到达——发布声明、远端媒体 Track、ICE candidate、Buffer 的 Bind,都可能先后颠倒。因此设计思路不是“收到一个事件就立刻完成”,而是:
- 先记录已到达的局部事件;
- 缺少依赖事件时先进入pending/buffer 缓存状态;
- 等关键信号和依赖都汇合后,再执行匹配、绑定或者Replay。
这种模式可以概括为异步等待与归拢:SDK 等第一帧、Server 的 pendingTracks/pendingRemoteTracks、candidate 缓存和 Bind 前 Buffer,都是这个范式在不同层的实现:它把网络和并发带来的乱序,从“偶发 bug”转化为可管理、可观测、可恢复的正常状态。
链路数据观测与实践
在这个章节,我们会通过自己添加的数据埋点进行全链路的观察监控,在本地运行LiveStream业务后端以及SFU Server服务端,利用LiveKit Server本身有的room/debug以及Prometheus信息进行观测,从而验证我们发布的流确实Server端真正接收到,并且有确切的数据统计。
数据埋点监控
我们写了一个PublishEvidenceRecorder,包括客户端的、Server侧的,用于观察各个阶段的统计信息,从而从日志上能够直观地体现出来整条链路的进展.

左侧 Swift APP 先完成发布意图、协商和本地发送:拿到 CID/SID 只能说明控制层接受了这条 Track,随后客户端统计持续增长,才能说明媒体确实在从主播端发出。右侧 LiveKit Server 则把收到的远端 Track 与此前的发布信令匹配,为它建立 Receiver 和 Buffer;只有 Buffer 的包、字节等数据持续增长,才能确认 RTP 已真正进入 Server。
部署运行与实验
观察点埋好了,剩下的就是把整条链路跑起来。本地需要起两个服务,再开一个客户端:
- 控制面后端(Next.js):提供
create_streamAPI,App 先访问它拿到房间信息,它再经 RoomService API 访问 Server; - LiveKit Server:dev 模式启动,同时启用 Prometheus (数据监控)端口与 Room debug功能;
- Swift Mac App:点 Go Live 发起发布,打开 Options → Publish evidence观察上行TRACK发布状态。
三者都在同一台机器的局域网地址上直连,没有经过公网;真实生产环境还有 NAT 穿透、跨地域延迟和丢包。
流程不复杂:
- 起好两个服务,抓一份发布前的 Prometheus 数据;
- 点 Go Live;
- 抓一份 Room debug 数据,检查当前房间状态;
- 等 10 秒,再抓一份 Prometheus 数据,与发布前做 diff。
观察与分析
观察点一:验证客户端是否真的在发 RTP。
一次真实记录的视频 Track(自 Go Live 起):
CID 532AF32C-…-807CECCE5B40 SID TR_VCqG7fd8zC3h3P
publishStarted +39.2 ms
trackAccepted +201.5 ms
publicationCommitted +201.5 ms
outboundBaseline +2255.5 ms
outboundDelta +3295.7 ms
clientMediaSending +3295.8 ms
逻辑发布成功后约 3.3 秒,outbound-rtp 开始连续增长。客户端真的在发 RTP,而不是“看起来在发”。
观察点二:Server侧的事件与数据监控。

-
观察
serverTrackMatched/serverReceiverBound,Server 已把客户端发布声明与真实远端 Track 对齐,并为其建立 Receiver/Buffer。视频有三次绑定,RID 分别为q、h、f,对应三条 VP8 Simulcast 上行层;其中q的newTrack=true,而h、f为newTrack=false,说明它们不是三条独立视频 Track,而是同一视频 Track 的三个编码层。音频则是一条audio/red上行流。 -
注意到
serverFirstRTP,视频 q、h、f 与音频都出现了首个可观测 RTP 统计。例如视频 q 层已有 43 包、24,262 bytes、41 帧;f 层已有 55 包、46,339 bytes、28 帧。 -
观察
serverRTPDelta与serverMediaReceiving,这是最关键的证实持续接收埋点,后续多轮 delta 都在增长:
- 视频 q层(低质量层) 持续有约 18~23 包、13~20 KB、15 帧的增量。
- 视频 h 层(中质量层)持续有约 34~52 包、31~51 KB、20 帧的增量。
- 视频 f 层(高质量层)增量最大,约 84~136 包、82~146 KB、26~28 帧。
- 音频也持续增长,约 49~51 包、13 KB。
观察点三:检查房间状态。
Room debug 页面里,这个 Room 下确实挂着刚发布的视频与音频 Track。
curl --noproxy '*' -sS http://127.0.0.1:17070/debug/rooms | jq .
alsh-w56r和RM_z6qYyEAk3Za8存在,说明当前 Server 内存中仍保留该 Room。kayson为ACTIVE,且PendingTracks是空对象:当前没有仍在等待完成匹配的 Track。- 两个
PublishedTracks都存在,分别为 AudioTR_AM4TadA6wLN3tv和 VideoTR_VC6Mv6YmhVo2xH;二者PubMuted: false。 - Video Receiver 有
q、h、f三个 RID 和对应 SSRC,说明 Simulcast 的三个上行层已经在 Server 对象中绑定。 - 两个
DownTracks均为空:在这个快照时刻没有订阅该 Track 的 Viewer 转发链路。要判断 Viewer 黑屏,需要让第二客户端加入并订阅后,再看是否产生 DownTrack 与 Viewerinbound-rtp增量。
观察点四:通过metrics观察Server in-bound趋势。
metrics='http://192.168.101.37:16789/metrics'
pattern='livekit_(packet_(total|bytes)|track_publish_counter|track_published_total|pubsubtime_ms)'
//启动server之后,Go live之前
curl --noproxy '*' -s "$metrics" | rg "$pattern" > /tmp/livekit-before.prom
//Go Live之后
curl --noproxy '*' -fsS "$metrics" > /tmp/livekit-published.prom
//Go Live且等了10秒之后
curl --noproxy '*' -fsS "$metrics" > /tmp/livekit-running-10s.prom
这三份数据呈现的是一次从“未开播”到“Server 持续收 RTP”的清晰变化。
before.prom 中 Room 和 Participant 都是 0,说明抓基线时 Server 上没有这次实验的 Room 或主播。livekit_packet_total/bytes 没有出现,不是采样失败,而是当时还没有对应的媒体流量时间序列。
published.prom 中 Room=1、Participant=1,说明控制面已创建 Room,Publisher 已进房。与此同时 Server 已累计收到 13 个入站包、10,247 bytes,并有少量出站包/字节。这说明发布后的早期阶段已经有网络媒体或相关协议流量进入 Server,但单独这一份只能证明“开始有流量”,还不能说明持续发送。
关键看 published → running:
- 入站包:13 → 4,446,新增 4,433 包
- 入站字节:10,247 → 4,026,925,新增 4,016,678 bytes,约 4.02 MB
- 出站包:19 → 28,仅新增 9 包
- 出站字节:10,929 → 11,101,仅新增 172 bytes
- Room 和 Participant 始终是 1
这说明这段窗口内 Server 持续收到大量上行 RTP,出站流量几乎不动,且只有一个 Participant,符合当前没有 Viewer 订阅、没有 DownTrack 媒体转发的状态。
结语
在这一条发布的链路上,跑着两个数据链路,一条是实时可丢包的用于轨道声明的信令链路,另一条是要求可靠有序的音视频媒体链路,两者独立推进,没有时序保证,LiveKit 没有试图抹平这种异步状态,而是把“谁先到”变成显式状态:事件没齐就先缓存,等到齐了再Replay执行。publish() 的快速返回、OnTrack 的提前触发、Room 内部的异步注册,都是同一个设计取舍:先把“完成”信号返回,不阻塞链路,而代价是,每个接口实际上有没有完成它的名称所表示的任务,不确定!!得等后续链路的信号才知道!所以,必须认清楚,每一步都只做了什么事,publish() 返回的只是本地事务提交而没有媒体数据发布,OnTrack 只意味着媒体轨道到达,而不保证SDP协商完成,房间可见只是控制层的注册信号,只有观察到 Buffer 的 packets/bytes 增长了,才知道音视频传输跑通了。
到这发布就讲完了,但这条 Track 才刚进门。下一篇我们看订阅方向:房间里的订阅者如何发现这条 Track、如何协商,Server 又怎样把 Buffer 里的包转发给每个订阅者——那是 LiveKit 作为 SFU 的核心:一对多的转发。
一次发布,两条链路,三次汇合
如果你对 LiveKit 感兴趣,可以阅读笔者的系列文章: