技术文章

Offer/Answer 与 ICE:LiveKit 如何建立 WebRTC 传输

2026 年 8 月LiveKit 系列

前言

很多 WebRTC 文章从协议栈讲起:SDP 的格式、ICE 的状态机、DTLS 的握手细节。这些当然重要,但本文想回答的是另一个问题:在 SFU 架构的真实业务中,Offer/Answer 协商和 ICE 建联应该怎么设计?

Demo 里的建联:两端各创建一个 PeerConnection,交换 SDP,Candidate 对上,媒体就开始流动。但实际运行的直播或会议应用复杂得多。用户可能在 Wi‑Fi 和蜂窝网络之间切换,可能藏在多层 NAT 后面,可能一端支持 H.264 另一端只认 VP8,可能在通话中途开关摄像头。这些场景下,简单的“两端各一个 PC”模型远远不够。

本文以 LiveKit 的服务端源码为线索,梳理它在传输层的设计选择。阅读路线是:

  1. 概念澄清:Offer、Answer、ICE、DTLS、SRTP 各自在整条链路里管什么
  2. 架构总览:LiveKit 的双 PC 模型:为什么一个参与者需要两条独立的 PeerConnection
  3. 流程详解:沿时序图的四个阶段,从 SDP 上行协商、服务端下行协商、ICE 选路到基于 DTLS 的 SRTP 传输
  4. 实践验证:本地跑一个 1v1 通话,采用 Chrome 的 WebRTC 调试面板进行分析

读完这篇文章,你应该能回答以下几个问题:

  • 上行发布和下行订阅为什么用了两套独立的 PeerConnection?
  • 为什么 Publisher 是客户端发 Offer,Subscriber 是服务端发 Offer?
  • SDP 协商完了,为什么媒体还不能传?ICE、DTLS、SRTP 分别扮演什么角色?
  • 建联出问题时,应该怎么定位是哪一层的故障?

好,先从几个容易混淆的概念开始。

相关概念

Offer:发起方提出“这次连接准备怎么通信”的方案,内容是 SDP,包含音视频类型、编解码器、收发方向、ICE 参数、DTLS fingerprint 等。它不是音视频数据本身。

Answer:接收 Offer 的一方给出的可执行回应。它根据自身能力接受、拒绝或收窄 Offer 中的媒体配置,让双方得到一致的会话描述。

ICE:解决真实网络可达性问题。客户端和服务端可能都在 NAT、防火墙或多网卡之后,ICE 负责收集可用地址、互相探测,并选择实际能连通的一对网络路径。

ICE Candidate:ICE 收集到的一条候选入口地址,通常包含 IP、端口和传输协议。host 是本机地址,srflx 是通过 STUN 发现的公网映射地址,relay 是 TURN 中继地址。Trickle ICE 表示候选一发现就立即通过信令发送,不必等全部收集完成。

DTLS:ICE 找到路径后,在该 UDP 路径上完成身份校验和密钥协商。SDP 中的 fingerprint 用来确认对端身份,防止信令和实际连接在传输途中被替换。

SRTP:实际传输 RTP 音视频和 RTCP 控制反馈的安全协议。它用 DTLS 协商出的密钥对媒体包加密和认证。

PC(PeerConnection):WebRTC 的核心连接对象,也是协议状态机。它统一管理轨道、Offer/Answer 协商、ICE、DTLS、DataChannel 和连接状态;在 LiveKit Server 中,底层由 Pion 的 PeerConnection 实现。

概念理清之后,我们先从整体架构看起:LiveKit 的传输层到底长什么样?

双 PC 模型

livekit-source-grounded-architecture.drawio

先看这张图里的三根线,它们代表了系统中三种不同性质的数据流。

蓝线是信令。 客户端和 LiveKit 服务端之间有一条 WebSocket 长连接。所有与“建联”相关的控制消息都走这条通道:Join 请求、Offer、Answer、Trickle ICE Candidate、订阅变更。这条线只传控制指令,不传音视频。

紫色虚线是 SDP/ICE 的归属关系。 从图中可以看到,一个参与者有两套独立的 PeerConnection。紫色虚线表示信令通道上的 Offer/Answer/Candidate 消息最终要落到对应那条 PC 上:Publisher 的 SDP 和 Candidate 只进 Publisher PC,Subscriber 的只进 Subscriber PC。这两条 PC 各自维护独立的 SDP 状态机和 ICE 候选集合。

橙色线是媒体。 上行方向:客户端采集的音频和视频通过 Publisher PC 发到服务端的 Publisher PCTransport,再进入 SFU。下行方向:SFU 把订阅的远端流通过 Subscriber PCTransport 发到客户端的 Subscriber PC。RTCP 反馈(丢包、抖动、带宽估计等)沿同一条 WebRTC 链路回流,服务端据此调整转发策略。


三根线的角色理清之后,把视线聚焦到图里的“一对 PC”上。

Publisher PC 负责上行:客户端是 Offerer,服务端是 Answerer。协商完成后,客户端把本地摄像头、麦克风或屏幕共享的 RTP 流发给服务端。

Subscriber PC 负责下行:方向反过来,服务端是 Offerer,客户端是 Answerer。当有新订阅、取消订阅或轨道暂停时,服务端发起协商,把订阅媒体下发到客户端。

两条 PC 彼此独立。好处是上行和下行可以各自 renegotiate 互不耦合:订阅一个远端发言者时只需要修改 Subscriber PC,不会影响正在发布中的 Publisher PC。代价是信令和状态管理更复杂,后面讲协商二时会展开。


信令消息到达服务端后,怎么知道该进 Publisher PC 还是 Subscriber PC?每条 Offer、Answer 和 Trickle ICE 请求都会标记自己是 PUBLISHER 还是 SUBSCRIBER,服务端据此分派到对应的 PCTransport。具体路由链路会在后面的协商一和协商二中展开。


最后看媒体流。上行方向:客户端采集的音视频经 Publisher PC 进入 SFU。下行方向:SFU 把订阅的远端流经 Subscriber PC 发给客户端。这套转发机制后续会在下行层级选择相关文章中详细讨论。

有了这张架构图打底,下一节把空间视角切换到时间视角:从客户端进房到媒体开始传输,双 PC 模型经历了一个怎样的完整流程。

建联全流程

每个参与者在 LiveKit Server 中拥有两条职责相反的 WebRTC 传输:Publisher PC 负责上行(客户端 Offer,服务端 Answer),Subscriber PC 负责下行(服务端 Offer,客户端 Answer)。

sequenceDiagram
    participant C as 客户端 SDK
    participant S as 信令服务
    participant P as ParticipantImpl
    participant TM as TransportManager
    participant Pub as Server Publisher PC
    participant Sub as Server Subscriber PC

    rect rgb(232, 244, 255)
        Note over C,Pub: 协商一:客户端发布上行媒体
        C->>S: Join + Publisher Offer
        S->>P: 创建 Participant 会话
        P->>TM: HandleOffer(offer)
        TM->>Pub: 设置远端 Offer
        Pub->>Pub: 创建并设置本地 Answer
        S-->>C: Publisher Answer
        C->>C: Publisher PC 设置远端 Answer
    end

    rect rgb(240, 248, 235)
        Note over C,Sub: 协商二:服务端准备向客户端下发订阅媒体
        Sub->>Sub: 创建并设置本地 Offer
        S-->>C: Subscriber Offer
        C->>C: Subscriber PC 设置远端 Offer
        C->>C: 创建并设置本地 Answer
        C->>S: Subscriber Answer
        S->>P: HandleAnswer(answer)
        P->>TM: HandleAnswer(answer)
        TM->>Sub: 设置远端 Answer
    end

    par 两条传输各自继续建连
        C-->>Pub: Trickle ICE Candidate
        Pub-->>C: Trickle ICE Candidate
    and
        C-->>Sub: Trickle ICE Candidate
        Sub-->>C: Trickle ICE Candidate
    end

    Note over C,Sub: ICE 选出可达路径 → DTLS 握手 → 建立 SRTP → 才能真正传媒体

两次协商都经过同一条信令通道,但它们建立的是两条独立的 PeerConnection。因此,收到入房成功或 WebSocket 已连通,并不代表媒体已经可用;还需要分别完成协商、ICE、DTLS,最终才能传输 SRTP 媒体。

第一阶段:上行发布协商。客户端经信令服务发送 Join 和 Publisher Offer;服务端创建这个用户对应的 Participant 会话,将 Offer 交给 Publisher PC 处理,生成 Answer 后沿原信令路径返回。客户端设置远端 Answer 后,双方对“客户端可以向服务端发送哪些媒体、采用什么 codec、使用哪些媒体方向”达成一致。

第二阶段:下行订阅协商。服务端决定向这个客户端发送订阅媒体时,方向反过来:Server Subscriber PC 创建 Offer,经信令服务发给客户端;客户端生成 Answer 后回传,服务端将其设置到 Subscriber PC。至此,双方对“服务端可以向客户端发送哪些订阅媒体”达成一致。两类协商中服务端内部的具体分派链路,会在下面的协商一和协商二中详细展开。

两轮 SDP 协商完成后,连接仍处于“能力已谈妥、网络路径尚未确认”的状态。接下来,Publisher PC 和 Subscriber PC 会各自独立进入 ICE 和 DTLS 阶段,这正是下面两节要讨论的内容。

协商一:客户端发布上行媒体

Publisher PC 的 SDP 协商是整个传输链条的第一步。它要解决的问题是:客户端准备向 SFU 发布哪些媒体、用什么编码、走什么路径?

sequenceDiagram
    participant C as 客户端 SDK
    participant CP as 客户端 Publisher PC
    participant S as 信令服务
    participant P as ParticipantImpl
    participant TM as TransportManager
    participant SP as 服务端 Publisher PC

    C->>S: JoinRequest
    S->>P: 创建 Participant 会话
    S-->>C: JoinResponse(房间、ICE Servers 等初始快照)

    CP->>CP: createOffer + setLocalDescription
    C->>S: SignalRequest.Offer(target=PUBLISHER)
    S->>P: HandleOffer(offer)
    P->>TM: HandleOffer(offer, offerId)
    TM->>SP: HandleRemoteDescription(Offer)
    SP->>SP: setRemoteDescription<br/>createAnswer + setLocalDescription
    SP->>P: OnAnswer(answer)
    P->>S: SignalResponse.Answer(target=PUBLISHER)
    S-->>C: Publisher Answer
    C->>CP: setRemoteDescription(Answer)

    Note over CP,SP: 此 PC 承载客户端 → SFU 的发布媒体

这条 Offer 在说什么

客户端发出的 Publisher Offer,本质上是一份“我准备这样发媒体”的声明。读 SDP 时,可以从五个维度来理解它要传达的信息。

第一,传什么:媒体结构。 Offer 里的 m= 行列出了这条 PeerConnection 上有哪些媒体区段。典型情况是:

m=audio
m=video
m=application

每个区段通过 a=mid:0、a=mid:1 这样的标识与 Transceiver 对应。后续 RTP 包路由、SDP section 匹配,全依赖这个 MID。

第二,方向如何。 每个 m-line 会声明方向:

a=sendonly
a=sendrecv
a=recvonly
a=inactive

Publisher PC 的概念模型里客户端主要是发送方,但具体方向要以实际 SDP 为准,不能因为名字叫 Publisher 就假定所有 m-line 都是 sendonly。

第三,怎么编码:Codec 与 RTP 能力。 Offer 列出客户端支持的编码器和 RTP 扩展:Opus、VP8、H.264、VP9、AV1,以及 RTX(重传)、NACK、PLI(关键帧请求)、TWCC(传输带宽估计)等。服务端 Answer 会从这些候选中选出双方都支持、且房间配置允许的子集。最终协商结果是三层求交:

客户端支持集合
∩ Server 支持集合
∩ Room 允许集合
= 最终协商结果

第四,走哪条路:ICE 参数。 Offer 里带了新一轮 ICE 会话的 ice-ufrag 和 ice-pwd。Candidate 地址可以写在 SDP 里一次性发过去,也可以通过 Trickle ICE 逐个增量发送。服务端必须先设置远端 Offer,才能正确识别后续到达的 Candidate 属于哪一轮 ICE。如果 Candidate 比 SDP 先到,LiveKit 会暂存它们,等 RemoteDescription 设置完毕后再回放。

第五,对面是谁:DTLS 指纹。 Offer 里声明了:

a=fingerprint:sha-256 ...
a=setup:actpass

后续 DTLS 握手时,服务端会把实际收到的证书指纹与 SDP 中的值核对。验证通过,双方才能导出 SRTP 密钥;验证失败,连接直接终止。这说明有人在中间替换了数据流。

合起来看,一份 Publisher Offer 同时定义了:传什么媒体、用什么编码、如何对应 Transceiver、如何进行 ICE、如何验证 DTLS 对端。这五个维度缺一不可。

服务端如何应答

Offer 通过信令到达服务端后,不是直接丢给 PeerConnection 处理的。它要经过两层分发。

第一层是 ParticipantImpl.HandleOffer。它的职责是信令层面的簿记:把 LiveKit 协议对象(livekit.SessionDescription)转成 WebRTC 标准格式(webrtc.SessionDescription),取出 offerId 用于后续的应答匹配,解析并校验 SDP 语法。如果 SDP 本身格式有问题,协商在这里就直接终止,不会继续修改 PeerConnection 状态。

这里有一个需要注意的细节:LiveKit 支持一种一次性信令交换模式,客户端在一次请求中把 SDP 和发布意图一并提交,后续不再维护长连接信令。在这种模式下,HandleOffer 会额外调用 synthesizeAddTrackRequests(parsedOffer),从 SDP 中反推 Track 发布请求。这是因为没有持续的信令通道来独立发送 AddTrackRequest,必须从一次性 SDP 里恢复更多信息。这是一个特定模式的兼容逻辑,不能用来推断所有正常连接都是“从 SDP 自动创建业务 Track”。

第二层是 TransportManager.HandleOffer。它的职责是路由,把 Offer 固定投递给 publisher transport,不能错投给 subscriber。TransportManager 持有 publisher 和 subscriber 两套 transport,它根据 Offer 的类型决定数据进入哪一条 PeerConnection。

到了 Publisher PC 之后,流程和标准 WebRTC 一致:

设置远端 Offer
→ 根据远端 Offer 匹配 Codec 和 Transceiver
→ CreateAnswer
→ SetLocalDescription
→ 通过信令发送 SignalResponse.Answer

客户端收到 Answer 后执行 setRemoteDescription,Publisher PC 的 SDP 协商才真正闭环。最终状态是:

客户端 Publisher PC:
    LocalDescription  = Publisher Offer
    RemoteDescription = Server Answer

服务端 Publisher PC:
    RemoteDescription = Publisher Offer
    LocalDescription  = Server Answer

为什么是客户端先发 Offer

有个问题可以多想一步:为什么不让服务端先给 Publisher PC 发 Offer?

答案是:发布意图由客户端掌握。 服务端可以知道房间允许哪些 Codec,但它无法替客户端决定:

  • 用户有没有授权摄像头
  • 设备是否真实存在
  • 浏览器能不能编码 H.264 / VP9 / AV1
  • 客户端是否准备发布屏幕共享
  • 这次发布需要几个 Simulcast 层

让客户端创建 Publisher Offer,意味着由最接近媒体源的一方来声明:“这是我当前准备建立的上行传输结构,以及我能用的协议能力。“服务端的角色是审核和应答,根据房间配置和能力集合做求交,而不是替客户端做发布决策。

这个设计选择在整个系统中是一以贯之的。下一节你会看到,当下行媒体的供给和编排权在服务端时,Offer 的发起方也跟着换了过来。

协商二:服务端向客户端下发订阅媒体

Subscriber PC 是反向的:服务端是 Offerer,客户端是 Answerer。它负责 SFU 向客户端下发订阅媒体。

sequenceDiagram
    participant C as 客户端 SDK
    participant CS as 客户端 Subscriber PC
    participant S as 信令服务
    participant P as ParticipantImpl
    participant TM as TransportManager
    participant SS as 服务端 Subscriber PCTransport

    Note over P,SS: 订阅集合或下行 transceiver 变化
    P->>TM: NegotiateSubscriber(force)
    TM->>SS: Negotiate(force)

    SS->>SS: createOffer + setLocalDescription
    SS->>P: SubscriberTransportHandler.OnOffer<br/>(offer, offerId, midToTrackID)
    P->>S: SignalResponse.Offer<br/>(target=SUBSCRIBER)
    S-->>C: Subscriber Offer
    C->>CS: setRemoteDescription(Offer)
    CS->>CS: createAnswer + setLocalDescription
    C->>S: SignalRequest.Answer<br/>(target=SUBSCRIBER)
    S->>P: HandleAnswer(answer)
    P->>TM: HandleAnswer(answer, answerId)
    TM->>SS: HandleRemoteDescription(Answer)

何时触发协商

和 Publisher 的方向相反,Subscriber 的协商不由客户端驱动,而是由服务端的订阅状态变化驱动。当有新订阅、取消订阅或轨道暂停时,ParticipantImpl.Negotiate 入口被触发。在非 one-shot signaling 模式、且迁移初始化完成后,它调用 TransportManager.NegotiateSubscriber,后者在双 PC 模式下把请求交给 subscriber.Negotiate(force)。TransportManager 决定变化应落到哪个 transport 对象上,在双 PC 模式中就是 subscriber。

PCTransport.Negotiate(false) 对普通请求做 debounce(合并短时间内的连续请求,只执行最后一次):根据上次协商时间使用 10ms 或 150ms 的窗口,把多次调用合并为一个 signalSendOffer 事件。force=true 则取消待执行的延迟任务,直接推进协商。

为什么需要 debounce?订阅变化通常有突发性:用户入会时可能瞬间订阅多位发言者;active speaker 切换、权限变化或批量订阅更新也会连续修改 transceiver。如果每个变化立即生成一次 Offer,会出现多轮无效 SDP、信令放大和 Answer 交叉到达。合并不是为了省几条 WebSocket 消息。真正的目的是:这样生成的 SDP 更接近最终收敛状态,客户端也不容易短暂创建出一堆易废弃的 Answer。

服务端如何发起 Offer

触发协商后,Subscriber PCTransport 接管了 Offer 的完整生命周期,独占 WebRTC 的 SDP 状态机:何时生成 Offer、何时等待 Answer、何时重试、signalingState、ICE restart、Offer 版本与并发控制,都由它统一管理。

createAndSendOffer 的执行流程是:先 CreateOffer、SetLocalDescription,把内部协商状态设为 WAITING_FOR_REMOTE,再通过 handler 送出 Offer。客户端 Answer 回来后,HandleAnswer 固定转发给 subscriber transport 的 HandleRemoteDescription。

Offer 发出时会附带两个关键信息:

  • Offer ID:标识“这是第几轮 Offer”。网络延迟、重连或协商重试时,服务端需要它能识别迟到或错配的 Answer,不能只假设“收到的 Answer 一定对应最后发出的 Offer”。
  • MID → Track ID 映射:把 SDP 中 m-line 的 MID 与服务端轨道 ID 对齐。SDP 只知道媒体区段编号,业务侧需要知道每个区段对应哪位参与者的哪条轨道。

还有一个并发控制细节:当服务端仍在等待上一轮 Answer 时,新到来的协商需求会被标识为 RETRY,等当前轮完成后再继续,不会同时发出第二份 Offer。这是为了避免典型的 SDP 状态冲突。服务端若在 have-local-offer 时再次不受控地 CreateOffer/SetLocalDescription,客户端收到的 Offer 与自己生成的 Answer 可能不再对应。

Answer 回来并设置完毕后,Subscriber PC 的 SDP 协商完成。但它仍需经历 ICE Candidate 交换、选路、DTLS 握手和 SRTP 建立,才能接入真正的音视频传输链路。

为什么是服务端先发 Offer

答案和协商一镜像对称:下行媒体的供给与编排权在服务端。 用户订阅了谁、应该发哪个 simulcast layer、某条轨道是否暂停、是否新增 transceiver,都是 SFU 掌握的状态。由服务端创建 Offer,可以让它把当前需要的接收槽位和发送方向准确写入 SDP;客户端只需要根据自己的解码能力和本地状态回 Answer。

Trickle ICE Candidate 交换

SDP 已经让双方知道“该建什么连接”,但它不保证双方真的能互相发 UDP 包。客户端可能在 NAT 后,服务端可能有多张网卡,最佳路径也可能是 host、srflx 或 relay Candidate。需要我们把所有可能的 candidate 收集起来,然后挑出最合适的一对来传输音视频数据。

sequenceDiagram
    participant CPC as 客户端 Publisher PC
    participant CSC as 客户端 Subscriber PC
    participant S as 信令服务
    participant P as ParticipantImpl
    participant TM as TransportManager
    participant SPP as 服务端 Publisher PC
    participant SSP as 服务端 Subscriber PC

    par Publisher PC 的 Candidate
        CPC->>S: TrickleRequest(candidate, target=PUBLISHER)
        S->>P: HandleICETrickle
        P->>TM: AddICECandidate(candidate, PUBLISHER)
        TM->>SPP: AddICECandidate

        SPP->>P: OnICECandidate(candidate, PUBLISHER)
        P->>S: SignalResponse.Trickle
        S-->>CPC: candidate
        CPC->>CPC: AddIceCandidate
    and Subscriber PC 的 Candidate
        CSC->>S: TrickleRequest(candidate, target=SUBSCRIBER)
        S->>P: HandleICETrickle
        P->>TM: AddICECandidate(candidate, SUBSCRIBER)
        TM->>SSP: AddICECandidate

        SSP->>P: OnICECandidate(candidate, SUBSCRIBER)
        P->>S: SignalResponse.Trickle
        S-->>CSC: candidate
        CSC->>CSC: AddIceCandidate
    end

    Note over P,TM: target 决定 Candidate 注入 publisher 或 subscriber PC
    Note over SPP,SSP: SDP 尚未设为 RemoteDescription 时,PCTransport 会暂存远端 Candidate,随后重放

客户端有 Publisher PC,也有 Subscriber PC,服务端也是成对存在。TrickleRequest 的每个 Candidate 都带 target,表明 Candidate 应该注入到哪一条连接,分流到哪一个 PCTransport。

Trickle ICE 和一般的 ICE 策略有什么区别?传统做法要等本机把 host、STUN、TURN 等所有 Candidate 都收集完,再把完整 SDP 发给对端。这种策略的最大问题是慢的 TURN 分配会拖住整轮协商。

而 Trickle ICE 是“边收集 Candidate,边发送、边尝试连通”的策略:

  1. 先完成 Offer/Answer,建立连接参数框架。
  2. 每发现一个 Candidate,就通过信令立即发给对端。
  3. 对端立刻加入 Candidate,并开始 ICE 连通性检查。
  4. 一旦某对 Candidate 可达,就可进入后续 DTLS/SRTP,不必等待其余 Candidate。

它的收益是降低首帧建立时间,尤其适合 NAT 复杂、TURN 较慢或移动网络环境。

代价是信令乱序成为正常情况:Candidate 可能先到、远端 SDP 后到。因此 LiveKit 会按 PUBLISHER/SUBSCRIBER 投递 Candidate;若远端 SDP 还没设置好,先缓存,等 SetRemoteDescription 完成后再应用。

在ICE链路上安全地传输音视频

SDP 协商让双方知道了“能传什么”,Candidate 交换让双方知道了“有哪些入口”。接下来要回答的是:选出一条可达路径后,怎么保证上面跑的媒体是安全的?

sequenceDiagram
    participant CP as 客户端 Publisher PC
    participant SP as 服务端 Publisher PC
    participant SS as 服务端 Subscriber PC
    participant CS as 客户端 Subscriber PC

    par Publisher PC:上行媒体
        CP->>SP: ICE Connectivity Checks
        SP-->>CP: STUN Binding 成功
        Note over CP,SP: 选出可达 Candidate Pair,ICE = connected
        CP->>SP: DTLS Handshake
        SP-->>CP: DTLS Handshake 完成
        Note over CP,SP: 导出 SRTP 密钥;RTP/RTCP 可受保护传输
        CP->>SP: SRTP:发布音视频 / RTCP
    and Subscriber PC:下行媒体
        SS->>CS: ICE Connectivity Checks
        CS-->>SS: STUN Binding 成功
        Note over SS,CS: 选出可达 Candidate Pair,ICE = connected
        SS->>CS: DTLS Handshake
        CS-->>SS: DTLS Handshake 完成
        Note over SS,CS: 导出 SRTP 密钥;RTP/RTCP 可受保护传输
        SS->>CS: SRTP:订阅音视频 / RTCP
    end

    Note over CP,CS: 两条 PC 独立建连;一条成功不代表另一条已可传媒体

对照这张图,从下往上读就是建链的完整过程。

第一步,ICE 选路。 双方通过 STUN Binding 探测 Candidate Pair,选出一对真正可达的 IP:端口。此时 UDP 包已经能发过去,但还不能传媒体:路径通了,对面是谁还不知道,也没有加密密钥。

第二步,DTLS 握手。 在选定的 UDP 路径上,双方用 SDP 中预先交换的证书指纹核对身份。握手成功后产出一组会话密钥。如果指纹对不上,连接直接终止。

第三步,SRTP 加密传输。 后续音视频 RTP 包使用 DTLS 协商出的密钥按 SRTP 格式加密和校验。这样做适合实时媒体:每个包独立保护,允许少量丢包和乱序,不会像 TCP/TLS 那样为了补一个丢失包阻塞后续媒体。

第四步,SRTCP 保护反馈。 同一条路径上的 RTCP 反馈(NACK 重传请求、PLI 关键帧请求、丢包与带宽统计)也需要加密。否则攻击者可以伪造反馈,让服务端错误降码率、频繁发关键帧,甚至扰乱拥塞控制。

四步走完,链路分工如下:ICE 找路,DTLS 确认对面是谁并协商密钥,SRTP/SRTCP 让之后每一个媒体包和控制包安全地跑在这条路上。

原理讲完,来看两个常见的工程问题。

  1. 如果 ICE 已连、但 PeerConnection 长时间未变为 connected 状态,优先看 DTLS 失败、UDP 中间设备策略、Candidate Pair 类型与两端时钟/网络设备问题;不要反复做无意义的 SDP renegotiation。

  2. 从 Wi‑Fi 切到蜂窝网络后,旧的 Candidate Pair 可能失效;此时最小恢复单位通常是 ICE,而不是整场会议。LiveKit 为 PCTransport 提供 ICERestart(),并将它作为一个 transport 内部事件处理;它不会直接重建所有房间、轨道和订阅状态。

实践策略应是:

  1. 记录失效的是 Publisher 还是 Subscriber。
  2. 尝试对应路径的 ICE restart,让双方重新收集 Candidate 并选择新路径。
  3. 若信令也断开,走协议定义的 reconnect 流程,只重建必要的 ICE 路径(更新 ICE Servers 等信息),尽量保住原有 Participant、轨道、订阅关系和 PeerConnection,降低重连后的首帧和状态恢复成本。
  4. 只有恢复失败,才升级为完整重连。相当于从房间移除用户,重新 JOIN,然后响应 JoinResponse。通常发生在 Resume 后客户端报告的已发布 Track 在服务端找不到,又譬如关键 DataChannel 失败,这些场景下都会升级为完整的重连。

理论分析到此结束。下面我们实际跑一个 1v1 通话,用 Chrome 的调试工具来验证前面的每一层(信令协商、ICE 选路、DTLS 握手和 SRTP 传输)在真实环境中是什么样子的。

基于本地Server的1V1通话实践

前面的分析都是站在源码和协议的角度。这一节换个方式:在本地跑一个真实的 LiveKit 服务端,用两个浏览器模拟 1v1 通话,然后打开 Chrome 的 webrtc-internals 调试面板,对照前面的每一层理论,看实际建联过程中信令协商、ICE 选路、DTLS 握手和 SRTP 传输分别长什么样。

先从启动服务端开始。

搭建环境

服务器启动

 go run ./cmd/server --dev

注意它只绑定 127.0.0.1 和 ::1,因此当前只适合本机两个浏览器窗口实验;手机双设备网络切换实验需要后续部署到公网或至少 LAN 可达的测试环境。

服务端跑起来之后,还需要为测试用户生成一个接入 Token。

生成某个用户的TOKEN

lk token create \                                                             
 --api-key devkey --api-secret secret \
 --join --room ice-lab --identity Alice \  
 --valid-for 24h      

访问 LiveKit Meet。这是 LiveKit Meet 的“自定义接入”入口页。它拿到地址与 TOKEN 信息后,作为 LiveKit Web 客户端去连接已有服务端。

LiveKit Meet 自定义接入页面

这是一个 LiveKit Meet 风格的房间页,两人都把媒体发布给 LiveKit SFU,再由 SFU 订阅转发。

Alice 浏览器 ── 发布 Alice 摄像头 ──► LiveKit SFU ──► Bob 浏览器
Bob 浏览器   ── 发布 Bob 摄像头   ──► LiveKit SFU ──► Alice 浏览器

截图里:

  • 左侧 alice:Alice 的本地预览。它通常直接渲染本地 MediaStreamTrack,不应把它当作“从网络收到 Alice 的视频”。
  • 右侧 bob:Alice 从 SFU 订阅并接收的 Bob 视频。

调试信息解读

房间跑起来之后,我们打开 Chrome 的 chrome://webrtc-internals,看看这条连接在协议层面经历了什么。

Publisher PeerConnection 初始建连记录

这张图是某个浏览器的 Publisher PeerConnection 初始建连记录。浏览器不是一上来就传音视频的。它先把 DataChannel 建好,再在第二轮协商中加入 audio/video transceiver。下面按时间线,用四个观察点对照前面的理论。

观察点一:信令状态,验证协商一的“客户端 Offer”

Signaling state:
new → have-local-offer → stable → have-local-offer → stable

have-local-offer 意味着浏览器主动创建了 Offer,这是 Publisher 侧的特征(协商一讲过,Publisher PC 由客户端发起 Offer)。Subscriber 侧的日志应该是 have-remote-offer → stable,会出现 createAnswer。

观察点二:建连时间线,DataChannel 先于音视频

09:49:10  createDataChannel × 3
09:49:10  onnegotiationneeded
09:49:10  createOffer
09:49:10  setLocalDescription(offer)

浏览器在 09:49:10 这一秒内依次完成了:创建 3 个 DataChannel → 触发协商 → 创建 Offer → 设置本地描述。三个 DataChannel 中,截图展开的第一个是 _lossy(ordered=false, maxRetransmits=0),表示“尽力而为、过期就丢”的通道,适合低延迟、不需要重传的控制消息。其余两个通常对应可靠通道和应用数据通道。

观察点三:第一轮 SDP,只协商 DataChannel,没有音视频

这一轮 Offer 的 SDP 只有一条 m= 行:

m=application 9 UDP/DTLS/SCTP webrtc-datachannel

其中:

a=group:BUNDLE 0      同一条 ICE/DTLS transport 复用
a=setup:actpass       Offer 把 DTLS 主动角色留给 Answer 决定
a=mid:0               这条 m-line 的标识
a=ice-options:trickle Candidate 随后通过 Trickle ICE 逐个补充

c=IN IP4 0.0.0.0 和端口 9 是 SDP 中的占位符,不是真实地址。真正选出的通路在页面顶部:

192.168.101.37:52610 <=> 192.168.101.37:7882

浏览器 UDP 源端口 52610,直连 LiveKit 的 UDP 7882。两端 IP 相同,说明这是本机 host candidate 路径,没有走 TURN 中继,验证了 Trickle ICE 的 candidate 收集和选路过程。

观察点四:ICE 和连接建立,验证 ICE→DTLS 建链

ICE:        new → checking → connected
Connection: new → connecting → connected

ICE 从 checking 到 connected,说明候选对探测成功。Connection 进入 connected,意味着 DTLS 握手也完成了。这正是 ICE 链路上安全传输那一节讲的建链顺序。

之后出现的第二次 have-local-offer → stable,才是应用加入 audio/video transceiver 后的第二轮协商。

媒体统计

第二轮协商完成后,媒体正式开始传输。Chrome 的 webrtc-internals 页面可以让我们看到具体的编码参数:哪条 simulcast 层在发送、分辨率是多少、用了什么编码器:

iShot_2026-08-11_09.56.18

图中 rid=f 是 Alice 的高分辨率 simulcast 层,frameWidth=1280、frameHeight=720 确认它在发 1280×720 VP8。

除了实时快照,webrtc-internals 还提供了以时间为横轴的统计曲线,可以直观地观察发送码率、编码帧率等指标随时间的变化:

iShot_2026-08-11_10.54.29

iShot_2026-08-11_10.54.45

图右端:约 10:53:40 后,若干条瞬时曲线([bytesSent_in_bits/s],[framesEncoded/s],[framesSent/s])同时掉到 0,这里主要是因为通话页面的标签页被切到了后台,浏览器自动关闭了发送与订阅。如果需要正常观看数据,就需要把通话页面一直保持在前台。

下面这张表汇总了图中可观察到的关键指标:

指标 观察到的值 含义
[bytesSent_in_bits/s] 峰值约 1.6–1.8 Mbps 高层视频实际已通过 RTP 发往 LiveKit
[framesEncoded/s] / [framesSent/s] 约 30 fps 编码与发送正常、没有堆积
bytesSent 累计升至约 2.5 亿字节 不是只有本地预览,确实存在网络发送
framesEncoded / framesSent 累计约 3.8 万帧 编码出的帧基本都已送出
frameWidth × frameHeight 1280 × 720 当前 f 层的分辨率
targetBitrate 约 1.6 Mbps Chrome 编码器当前目标码率
[totalPacketSendDelay/packetsSent_in_ms] 约 0.5 ms 浏览器 RTP 发送队列没有明显拥塞
nackCount / 重传 0 这段采样里没有看到接收方 NACK 导致的重传
pliCount 约 20 对端曾多次请求关键帧;在反复开关、订阅建立/恢复时常见,单独看不等于网络坏
qualityLimitationResolutionChanges 4 编码分辨率曾发生四次调整,可能与摄像头重建、带宽估计或 simulcast 状态变化有关

到这里,我们从搭建环境、建立连接、到观察实际媒体传输,完整走通了一个 1v1 通话的调试流程。前面理论章节讲的 SDP 协商、ICE 选路、DTLS 握手和 SRTP 加密,在 webrtc-internals 里都能找到对应的时间线和状态迁移。

结语

这篇文章走了一条从架构到源码、从源码到实践的路线。核心线索是 LiveKit 的双 PC 模型:一个参与者同时持有 Publisher 和 Subscriber 两条独立的 PeerConnection。理解了这个模型,后面一切设计选择都有了统一的解释框架:

  • 上行和下行独立协商,互不耦合。 订阅新演讲者时修改 Subscriber PC,不会打断正在稳定发布摄像头的 Publisher PC。代价是维护两套 SDP、ICE、DTLS 状态,信令和诊断也更复杂,但这个代价在需要独立 renegotiate 的场景中是必要的。
  • 发起权跟着状态走。 发布状态在客户端,所以 Publisher 由客户端 Offer;订阅状态在服务端,所以 Subscriber 由服务端 Offer。关键是谁掌握那条传输上需要什么媒体、什么能力的最新信息。
  • 四层各司其职。 SDP 管“能传什么”,ICE 管“走哪条路”,DTLS 管“对面是谁”,SRTP 管“传得安全”。每一层出问题,现象不同,排查方向也不同。

下一篇我们会进入媒体传输的下一环:LiveKit 的 Simulcast 与下行层级选择。当一条上行流同时编码了高、中、低三个分辨率,SFU 怎么决定给每个订阅者转发哪一层?带宽估计、拥塞控制和轨道选择策略如何协同工作?这些都是在本文讲完的传输基础上展开的,到时我们继续结合源码来分析。

← 返回首页