技术文章

一条 Track 如何被发布:从摄像头采集到服务器收到 RTP

2026 年 8 月LiveKit 系列

前言

点下“开始直播”,一两秒后画面就出现在观众端。发布一条视频,API 调用看起来是同步的,但它返回的那一刻,画面其实还没到服务器。

一次发布要解决两件性质完全不同的事,第一,音视频TRACK声明:告诉服务器你要发什么样的 Track;第二,媒体包传输:把编码后的视频数据真正送到服务器。声明走 WebSocket,要求可靠、有序;数据走 WebRTC 媒体通道,实时优先、允许丢包。两条链路各自独立,到达顺序没有保证,最后必须在 Server 内完成对齐。

本文跟踪一条摄像头视频的完整发布流程:从 SDK 启动采集,到 Server 把这条 Track 收入房间、真正开始收 RTP。路线是先看整体架构,再分六阶段拆解,最后讨论这条链路里反复出现的一个设计模式。读完你应该能回答:

  • publish() 返回那一刻,什么已经完成、什么还没完成?
  • Server 怎么把 AddTrack 的声明和 Pion OnTrack 的媒体对上号?
  • 为什么房间里能看到 Track,不等于 Server 在收 RTP?
  • 一次发布里有好几个“完成”信号,如何区别?

好,先从整体架构看起。

架构

这张图回答一个核心问题:App 发起发布后,LiveKit Server 如何既拿到发布声明,又真的收到视频数据?

Track发布-最小整体架构

做法是把发布拆成两条独立链路,最后在 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,都可能先后颠倒。因此设计思路不是“收到一个事件就立刻完成”,而是:

  1. 先记录已到达的局部事件;
  2. 缺少依赖事件时先进入pending/buffer 缓存状态;
  3. 等关键信号和依赖都汇合后,再执行匹配、绑定或者Replay。

这种模式可以概括为异步等待与归拢:SDK 等第一帧、Server 的 pendingTracks/pendingRemoteTracks、candidate 缓存和 Bind 前 Buffer,都是这个范式在不同层的实现:它把网络和并发带来的乱序,从“偶发 bug”转化为可管理、可观测、可恢复的正常状态。


链路数据观测与实践

在这个章节,我们会通过自己添加的数据埋点进行全链路的观察监控,在本地运行LiveStream业务后端以及SFU Server服务端,利用LiveKit Server本身有的room/debug以及Prometheus信息进行观测,从而验证我们发布的流确实Server端真正接收到,并且有确切的数据统计。

数据埋点监控

我们写了一个PublishEvidenceRecorder,包括客户端的、Server侧的,用于观察各个阶段的统计信息,从而从日志上能够直观地体现出来整条链路的进展.

publish-evidence-path

左侧 Swift APP 先完成发布意图、协商和本地发送:拿到 CID/SID 只能说明控制层接受了这条 Track,随后客户端统计持续增长,才能说明媒体确实在从主播端发出。右侧 LiveKit Server 则把收到的远端 Track 与此前的发布信令匹配,为它建立 Receiver 和 Buffer;只有 Buffer 的包、字节等数据持续增长,才能确认 RTP 已真正进入 Server。

部署运行与实验

观察点埋好了,剩下的就是把整条链路跑起来。本地需要起两个服务,再开一个客户端:

  • 控制面后端(Next.js):提供 create_stream API,App 先访问它拿到房间信息,它再经 RoomService API 访问 Server;
  • LiveKit Server:dev 模式启动,同时启用 Prometheus (数据监控)端口与 Room debug功能;
  • Swift Mac App:点 Go Live 发起发布,打开 Options → Publish evidence观察上行TRACK发布状态。

三者都在同一台机器的局域网地址上直连,没有经过公网;真实生产环境还有 NAT 穿透、跨地域延迟和丢包。

流程不复杂:

  1. 起好两个服务,抓一份发布前的 Prometheus 数据;
  2. 点 Go Live;
  3. 抓一份 Room debug 数据,检查当前房间状态;
  4. 等 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
image-20260814173740728

逻辑发布成功后约 3.3 秒,outbound-rtp 开始连续增长。客户端真的在发 RTP,而不是“看起来在发”。

观察点二:Server侧的事件与数据监控。

  1. 观察serverTrackMatched / serverReceiverBound,Server 已把客户端发布声明与真实远端 Track 对齐,并为其建立 Receiver/Buffer。视频有三次绑定,RID 分别为 q、h、f,对应三条 VP8 Simulcast 上行层;其中 q 的 newTrack=true,而 h、f 为 newTrack=false,说明它们不是三条独立视频 Track,而是同一视频 Track 的三个编码层。音频则是一条 audio/red 上行流。

  2. 注意到serverFirstRTP,视频 q、h、f 与音频都出现了首个可观测 RTP 统计。例如视频 q 层已有 43 包、24,262 bytes、41 帧;f 层已有 55 包、46,339 bytes、28 帧。

  3. 观察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 .
image-20260814215200931
  • alsh-w56r 和 RM_z6qYyEAk3Za8 存在,说明当前 Server 内存中仍保留该 Room。
  • kayson 为 ACTIVE,且 PendingTracks 是空对象:当前没有仍在等待完成匹配的 Track。
  • 两个 PublishedTracks 都存在,分别为 Audio TR_AM4TadA6wLN3tv 和 Video TR_VC6Mv6YmhVo2xH;二者 PubMuted: false。
  • Video Receiver 有 q、h、f 三个 RID 和对应 SSRC,说明 Simulcast 的三个上行层已经在 Server 对象中绑定。
  • 两个 DownTracks 均为空:在这个快照时刻没有订阅该 Track 的 Viewer 转发链路。要判断 Viewer 黑屏,需要让第二客户端加入并订阅后,再看是否产生 DownTrack 与 Viewer inbound-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
image-20260814215239733

这三份数据呈现的是一次从“未开播”到“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 感兴趣,可以阅读笔者的系列文章:

← 返回首页