技术文章

LiveKit 如何完成信令入房——从 JoinRequest 到 JoinResponse 的完整链路

2026 年 8 月LiveKit 系列

前言

在音视频应用里点下“加入房间”,到真正看到远端画面、听到声音,中间那一两秒,服务端在干什么?

最简单直接的理解是,发个请求,返回“成功”,连接建立。但实际上,情况复杂得多,这一瞬间,服务端做了一串精心编排的判断:验证客户端身份、定位 Room 的归属节点、区分新会话与断线重连、把 Participant 写入 Room 的正式成员名单、返回一份房间当前状态的完整视图、最后把连接交给一个长期运行的信令循环。

这篇文章会用五步走完整条入房链路。每到一个节点就停下来,看清楚它解决了什么实时通信问题,以及 LiveKit 为什么选了这种解法。

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

  • JoinRequest 发出后,服务端经过了几道闸门才返回 JoinResponse?
  • 为什么 LiveKit 选择先验证再升级 WebSocket,反过来会有什么麻烦?
  • 同一个 identity 再次出现时,服务端怎么区分新会话、重复进入和断线重连?
  • 为什么“先写入正式状态、再对外响应”是整条链路最核心的约束?
  • JoinResponse 返回之后,后续的 Offer、Answer、ICE 由谁持续接管?

整条链路分五步,每一步对应一个需要解决的问题:

步骤 解决的问题
认证与准入 客户端是否具备入房资格?
路由 Room 的状态归属在哪个节点?
进入 owner 会话是新建、替换还是恢复?
登记为成员 写入与响应的先后顺序如何保证?
长期信令 入房之后,后续消息由谁接管?

OK,在具体分析之前,我们先了解几个贯穿全文的核心概念。


相关概念

进入源码之前,先了解一下几个反复出现的术语。

ParticipantInit(入房申请表)。客户端发起入房时,服务端将三方信息汇聚成一份申请表:业务后端在 JWT 中签发的身份与权限、客户端 SDK 上报的能力与连接偏好、服务端推导的 IP 和 Region 等环境参数。Router 拿它寻找 owner,StartSession 拿它创建 Participant,Room.Join 拿它决定是否接纳。

Room 与 Participant。Room 持有房间的正式成员名单和全局状态。Participant 是单个用户在会话中的完整化身——持有 PeerConnection、Track 列表、订阅关系和权限边界。一个 Room 可以有多个 Participant,一个 identity 在同一 Room 中只允许一个 Participant。

Owner 节点。在 LiveKit 集群中,一个 Room 的运行时状态只由一个 RTC 节点持有。无论用户从哪个接入节点进入,成员列表、Track 和订阅关系始终由同一个 owner 维护。单节点部署时当前节点就是 owner。

PSRPC 信令中继。当客户端连到的接入节点不是 owner 时,信令请求通过 PSRPC 从接入节点中继到 owner 节点。这条内部通道只传 StartSession、Offer、Answer、ICE candidate 等控制信令,不传 RTP 音视频包。媒体流在客户端和 owner 之间直连。

MessageSource / MessageSink。LiveKit 将信令通道抽象为一对读写接口。Worker 面对这对抽象——不关心底层是 WebSocket 还是 PSRPC,从 Source 读、往 Sink 写。

信令与媒体。全文在两条并行的通道上展开:WebSocket/PSRPC 承载控制面(Join、Offer、Answer、ICE candidate、订阅变更),WebRTC PeerConnection 承载数据面(SRTP 音视频、SRTCP 反馈)。入房成功只意味着控制通道通了——媒体流还没有开始传输。

这五个概念的关系:

flowchart LR
    PI["ParticipantInit<br/>入房申请表"] -->|"Router 寻址"| OWNER["Owner 节点<br/>Room 状态唯一归属"]
    OWNER -->|"StartSession"| P["Participant<br/>用户会话化身"]
    P -->|"Room.Join"| R["Room<br/>正式成员名单"]
    P <-.->|"PSRPC 信令中继<br/>不传媒体"| CLIENT["客户端"]
    P <-.->|"WebRTC 媒体<br/>SRTP/SRTCP"| CLIENT

从左到右:ParticipantInit 经 Router 寻址找到 Owner 节点,Owner 通过 StartSession 创建 Participant,再通过 Room.Join 写入 Room 的正式成员名单。两条通道从 Participant 出发——PSRPC 走信令(虚线),WebRTC 走媒体(实线)。概览章会展开为完整的架构图。


概览

全景图:组件与通信方式

一次入房请求会经过两类节点:客户端连到的接入节点,和真正持有 Room 状态的 Owner 节点。它们可能在同一台机器上,也可能不在。

组件与通信方式

图中有四条通道,各走各的:

  • ① WebSocket:客户端和接入节点之间的信令通道。Join 请求、后续的 Offer、Answer、ICE candidate 全走这里。
  • ② Redis 查询:只在 Router 寻址时读一次,不在每条信令或每个 RTP 包的路径上。
  • ③ PSRPC 信令中继:仅当接入节点不是 owner 时才走这条。把客户端的信令请求转发给 owner,再把 owner 的响应送回接入节点。不传音视频。
  • ④ WebRTC 媒体:客户端和 owner 之间直连。ICE 做连通、DTLS 加密、SRTP/SRTCP 传音视频。

单节点部署时,接入节点就是 owner,通道 ③ 在进程内完成,不经过网络。逻辑上的职责划分不变。

一次入房请求的五道闸门

组件架构清楚了。一次入房请求经过五道闸门,每道回答一个必须解决的问题:

flowchart LR
    A["① 认证与准入<br/>你是谁?<br/>有没有资格?"] --> B["② 路由<br/>Room 在<br/>哪个节点?"]
    B --> C["③ 进入 owner<br/>新会话、替换<br/>还是恢复?"]
    C --> D["④ 登记为成员<br/>写入与响应<br/>谁先谁后?"]
    D --> E["⑤ 长期信令<br/>后续消息<br/>由谁接管?"]

五步走完,客户端拿到 JoinResponse,信令循环开始运转。

这张时序图是全文的主地图,后面每一章都会回到它,放大其中一步。图中的 ① 到 ⑦ 是上面五道闸门在代码层的展开(第三步拆成了 StartSession + NewParticipant,第四步拆成了 Room.Join + JoinResponse):

sequenceDiagram
    participant C as "Client SDK"
    participant R as "RTCService"
    participant RT as "Router"
    participant S as "Signal Relay"
    participant M as "RoomManager"
    participant RM as "Room"
    participant P as "Participant"

    C->>R: "WebSocket upgrade request + JWT + join parameters"
    R->>R: "① validateInternal"
    R->>RT: "② StartParticipantSignal"
    RT->>S: "relay to room owner when needed"
    S->>M: "③ StartSession"
    M->>M: "getOrCreateRoom + 判断 identity"
    M->>P: "④ NewParticipant"
    M->>RM: "⑤ Room.Join"
    RM-->>C: "⑥ JoinResponse"
    M->>M: "⑦ start rtcSessionWorker"
    Note over C,M: "Offer/Answer、ICE、Track、RTP 从这里开始,留给下一篇"

七个角色的分工:RTCService 是入口;Router 回答“Room 在哪个节点”;RoomManager 管会话生命周期;Room 持有房间正式成员状态;Participant 是用户在会话中的完整化身。

五步与图中 ①-⑦ 的对应关系:

步骤 图中对应 解决的问题
① 认证与准入 validateInternal 身份和权限是否有效?
② 路由 SelectRoomNode + StartParticipantSignal Room 的 owner 在哪个节点?
③ 进入 owner StartSession + NewParticipant 会话是新建、替换还是恢复?
④ 登记为成员 Room.Join + JoinResponse 写入内部状态和对外响应谁先谁后?
⑤ 长期信令 rtcSessionWorker 后续 Offer/Answer/ICE 由谁接管?

按 ① 到 ⑦ 走一遍:① validateInternal 验证身份,产出 ParticipantInit。② 先向 Redis 确认 Room 已有 owner,再建立接入节点到 owner 的信令中继——先确保有人接手,再发出会话请求。③ StartSession 判明身份——新会话、重复进入还是断线重连。④ NewParticipant 创建带完整规则的 Participant 对象。⑤ Room.Join 在锁内将 Participant 写入成员名单。⑥ JoinResponse 返回房间当前状态。⑦ rtcSessionWorker 启动信令循环,接管之后的所有消息。

下面沿着这张图,从头走一遍。


第一步:认证与准入——客户端是否具备入房资格

在连接建立之前,服务端的全部工作就是验证。后面的一切——路由、会话、成员登记——都以这一步的结果为前提。

RTCService 接到请求后执行 validateInternal:发起请求的客户端,是否有资格申请进入这个 Room。

sequenceDiagram
    participant B as 业务后端
    participant C as 客户端
    participant A as 鉴权中间件
    participant R as RTCService

    B->>C: 签发 JWT
    C->>A: 请求 /rtc
    A->>A: 验签,提取 grant 与过期时间
    A->>R: 请求 context 已带认证结果
    R->>R: 读取连接参数<br/>补充 IP、User-Agent、Region、Room 配置
    R->>R: 生成 ParticipantInit

图中从左到右是认证链的完整时序。按时间顺序拆为五个环节:

  1. 业务后端签 Token:定下 identity、Room、发布/订阅权限。
  2. 客户端调用 connect():SDK 附加客户端能力、连接参数、重连标记。
  3. Server 收到请求:验签、解析连接参数、补充 IP 和 Region 等环境信息。
  4. validateInternal 完成:得到 ParticipantInit。
  5. StartSession 之后:才会生成 Participant SID、ICE 配置和 JoinResponse。

经过这五步,服务端手里多了一样东西:ParticipantInit。

ParticipantInit = Token 中记载的身份与权限 + 客户端上报的能力与连接偏好 + 服务端推导的环境参数与默认值

ParticipantInit 是入房申请表。这份申请表由三方信息汇聚——业务后端的授权、客户端的意愿、服务端的环境推导。它将贯穿整条链路:Router 拿它寻找 owner,StartSession 拿它创建 Participant,Room.Join 最终拿它决定是否接纳。

为什么在升级 WebSocket 之前完成验证

LiveKit 选了先验证,再升级 WebSocket。这个顺序是刻意设计的。

验证与 WebSocket 升级顺序

两种路径的差异:

上半条绿色路径:验证是 WebSocket 升级的前置条件,验证失败直接返回 HTTP 错误(401 / 503),客户端能精确区分失败原因。

下半条红色路径:WebSocket 先升级,后续任何环节失败都只能关闭连接——客户端看到的只是“连接断开”,无法判断原因,且必须清理已创建的残留状态。

WebSocket 一旦完成 101 Switching Protocols,HTTP 层面的错误码就全部失效。此时没法再返回 401 Unauthorized 或 503 Service Unavailable。客户端看到的只是“连接建立后很快关闭”,分不清是 Token 过期、Room 不存在还是节点不可用。这对调试和客户端重试策略是灾难:不同的错误需要不同的处理方式,但 WebSocket 关闭事件把它们全部抹平了。

更深一层的问题是半初始化。如果先升级了 WebSocket,再创建 Participant、写入 Room,中途任何一个环节失败,都必须精确清理:Participant 是否已创建、Room 里是否已写入、跨节点 signal relay 是否已注册、worker 是否已启动。漏掉一个,就是僵尸连接——客户端已经退出,服务端仍保留着它的 Participant 和订阅关系,占用 Room 的成员名额。

把验证收在 WebSocket 升级之前,失败就不会留下残留状态。客户端侧也因此简化:WebSocket open 就等于入房成功,SDK 不用在应用层再维护一套状态机去判断入房是否真正完成。

验证通过了,ParticipantInit 拿到了。下一步要回答:这个 Room 的状态归属在哪个节点。


第二步:路由——Room 的状态归属在哪个节点

验证通过了,ParticipantInit 拿到了。但客户端连到的这台机器,不一定就是 Room 的 owner。下一步是要找到正确的那台机器,那个Room owner。

startConnection 做了三件事:确认 Room 有 owner、建立信令中继、等 owner 的首条响应。

三步的顺序

sequenceDiagram
    participant R as RTCService
    participant RT as Router
    participant O as Owner 节点

    R->>R: 接住客户端请求
    R->>RT: ① SelectRoomNode
    RT->>RT: 查询 Redis,确认 Room 有 owner
    RT-->>R: owner 节点地址
    R->>RT: ② StartParticipantSignal
    RT->>O: 建立 Signal Relay
    O-->>R: ③ JoinResponse(readInitialResponse 等待)
    Note over R: 拿到 JoinResponse 后<br/>upgrader.Upgrade 升级 WebSocket

图中三步依次执行:① SelectRoomNode 查询 Redis 确认 Room 有 owner,② StartParticipantSignal 建立到 owner 的信令中继,③ readInitialResponse 等待 owner 的首条响应。第三步是关键——readInitialResponse 等的实际是 owner 侧 Room.Join 完成后的 JoinResponse。这个信号标志着 Participant 已被写入 Room 的正式成员名单。拿到 JoinResponse,接入节点才能放心升级 WebSocket——此时 owner 侧已经完成了所有状态变更。如果 owner 在规定时间内没有返回响应,接入节点会重试;重试耗尽后返回 HTTP 错误(此时 WebSocket 还未升级,失败仍然是轻量的)。

远端路由的拓扑

owner 在远端时,消息怎么跨节点过去的?

远端路由拓扑

从分叉点开始,两条路最终汇入同一个 RoomManager.StartSession。左边分支本机 owner,LocalRouter 直接跳转,全程在进程内。右边分支远端 owner,接入节点查 Redis 拿到目标地址,通过 SignalClient 走 PSRPC 把请求中继到 Owner 节点上的 SignalServer,再交给 HandleSession。无论走哪条分支,StartSession 的输入都是同一份 ParticipantInit。

这里有个关键约束:客户端通过负载均衡连到的机器,不一定是 Room 的 owner。如果两个用户分散到不同节点、各自在本地创建了 Room,就会产生两个独立副本,彼此的成员、Track、订阅关系互不可见。

LiveKit 的解法:一个 Room,只由一个 RTC 节点持有其运行时状态。Router 的职责很单一——回答“这个 Room 当前由哪个节点负责”,不参与媒体转发。单节点部署时当前节点就是 owner;集群中通过 Redis 维护 Room 与节点的归属映射。无论用户从哪个接入节点进来,成员列表、Track 和订阅关系始终由同一个 owner 维护。

当接入节点不是 owner 时,SignalClient 通过 PSRPC 把 ParticipantInit 中继到 owner 节点的 SignalServer。这条内部通道传的是 StartSession、Join、Offer、Answer、ICE candidate 等控制信令,不传 RTP 音视频。媒体流在客户端和 owner 之间直连。

源码里有一段重试循环需要单独说:startConnection 不是调一次就完了,最多重试若干次,每次超时递增(第一次 3 秒,第二次 6 秒,以此类推)。owner 节点暂时不可达或响应慢时,接入节点不会立即放弃。

超过配置次数后,接入节点直接返回 HTTP 错误。此时 WebSocket 还没升级,失败仍然是轻量的。回头看第一步中“先验证再升级”的原则:不只是在验证阶段控制失败代价,路由阶段同样如此。WebSocket 升级就像一个门槛,跨过去之前,所有失败都是廉价的。

owner 找到了,ParticipantInit 送到了,首条响应也拿到了。这时 WebSocket 才升级。接下来,请求进入 owner 节点内部。owner 拿到申请表后,第一个判断是:这个 identity 之前出现过吗?


第三步:进入 owner——新会话、替换还是恢复

请求到达 owner 节点后,RoomManager 接管。RoomManager 要做的第一件事是判明身份:这个 identity 之前来过没有?同一个人从不同入口进来,和同一个人断线后回来,处理方式完全不同。

RoomManager.StartSession 先通过 getOrCreateRoom 取得 Room。可能是业务后端预先创建的,也可能是首个用户进入时按需创建的。然后检查:这个 identity 是否已经在 Room 里了。

在这之前,有一道容易忽略的检查:owner 节点上的 SignalServer 在收到中继过来的请求后,会再做一次路由查询,确认自己仍然是这个 Room 的 owner。中继消息在网络上传输是异步的,消息到达时 Room 可能已经迁移到另一个节点了。确认自己还是 owner 之后,才把请求交给 RoomManager.StartSession。

回到 StartSession 的主流程。同一个 identity 再次出现,有三种可能:

flowchart TD
    A["StartSession"] --> B{"Room 中已有 identity?"}

    B -- "否" --> C["创建新 Participant"]
    B -- "是,普通重复进入" --> D["移除旧 Participant"]
    D --> C
    B -- "是,Reconnect" --> E["尝试恢复已有 Participant"]
    E -- "可恢复" --> F["替换信令通道,沿用原 SID"]
    E -- "不可恢复" --> G["发送 Leave,要求客户端完整重连"]

三条分支对应三种处理策略:

头一次来(图中“否”分支):新建 Participant,完整走一遍入房流程。

重复进入但不是重连:用户刷新了页面、换了设备登录、旧标签页尚未关闭。先把旧的 Participant 清理掉,再创建新的。不能让同一个 identity 同时持有两套 Track 和订阅关系。

断线重连:手机切了 Wi-Fi、蜂窝换基站、信号短暂丢失。旧 Participant 还在,保留它的 SID 和媒体状态,只换掉已断开的信令通道。

这三种情况背后是一个产品决策:LiveKit 默认一个 identity 只能在一个设备上参会。identity 有生命周期——系统必须区分“同一个人回来了”和“同一个人从另一个入口进来了”。前者要恢复,后者要替换。

创建 Participant:不只是 new 一个对象

走新建路线时,RoomManager 调用 rtc.NewParticipant。这个创建做的事情远比 new 一个对象多——NewParticipant 要把整场会话过程中需要遵守的全部规则,一次性写进去。

flowchart TB
    A["ParticipantInit<br/>身份、grant、客户端选择"] --> P["rtc.NewParticipant"]

    B["Server Config<br/>网络、拥塞、DataChannel、恢复"] --> P
    C["Room Config<br/>codec、播放延迟、同步"] --> P
    D["运行时依赖<br/>日志、运行数据、Room 回调、统计"] --> P

    P --> E["一个带完整运行规则的 Participant"]

rtc.NewParticipant 接收四类输入:ParticipantInit 来自认证阶段,Server Config 和 Room Config 来自部署配置,运行时依赖由 Room 注入。四者合一,产出带完整运行规则的 Participant。

下面看三个最有代表性的规则。

网络路径。默认 UDP 优先,但服务端为 UDP 不可达的场景准备了 TCP fallback 和 TURN 中转。同样是“加入成功”,一个光纤用户和一个 4G 用户后面走的具体路径可以完全不同。具体路径在创建时已经设定,客户端 UI 在运行时改不了。

故障恢复。什么错误局部修复即可,什么错误必须推倒重来。这个取舍在创建时就写定了。能局部修复就不打断整场通话;状态已经不可信时要求完整重连,不带着错误继续跑。

媒体订阅。AdaptiveStream 根据订阅端窗口尺寸和网络条件选择合适流层;订阅上限防止单个 Participant 拉取过多轨道。多人房间不能无差别地将所有媒体全速推给所有人。

其余的权限边界、Token 续期、DataChannel 缓冲上限、运行数据采集等等,都遵循同一个设计原则:

规则在创建时一次性给定,运行时不再询问。Participant 后续收到发布、订阅、修改状态的请求时,不需要临时查询权限。答案在创建时就已经在了。整场会话的行为可预期,不会在运行时分叉出不一致的状态路径。

Participant 现在是一个“已准备好参与 RTC 会话”的对象。接下来要回答:从哪个时间点开始,Room 正式将这个对象纳为成员。


第四步:登记为成员——写入与响应的先后顺序

这是整条链路中状态变化最密集的一步。前面的验证、路由、会话判定都属于准备工作。到这里,才真正把一个客户端的身份写入房间的正式状态。

Room.Join 的核心逻辑用一张简图说清:

flowchart LR
    A["获取 Room 锁"] --> B["三道检查"]
    B --> C["写入 Participant<br/>到成员名单"]
    C --> D["构造并发送<br/>JoinResponse"]
    D --> E["释放锁"]
    E --> F["启动后续<br/>自动订阅 + Worker"]

一句话概括:锁内检查并写入,锁外启动后续。 下面分四个阶段逐一展开。完整时序图放到了附录,需要对照全局时可以查询。

阶段一:加锁,检查,写入

Room.Join 接到请求后,第一步是获取 Room 锁,然后在锁内完成三道检查:

sequenceDiagram
    participant M as RoomManager
    participant R as Room

    M->>R: Join(participant, requestSource, opts, iceServers)
    Note over R: 获取 Room 锁
    R->>R: ① Room 是否已关闭?
    R->>R: ② identity 是否已在 Room 中?
    R->>R: ③ 人数是否已达上限?
    alt 任一检查失败
        R-->>M: 直接返回错误,不留下残留状态
    else 全部通过
        R->>R: 写入 participants<br/>participantOpts<br/>participantRequestSources
        Note over R: 从此刻起,Room 中正式有了这个成员
    end

三道关口缺一不可:

  • Room 是否已关闭? 会议结束或已删除,后续到达的请求不应进入。
  • 同一个 identity 是否已在 Room 中? 这是最后一道防线——StartSession 已经做过去重判断,但锁内的检查防止并发请求绕过。
  • 人数是否已达上限? 房间有容量限制,超出则拒绝。

任何一步失败,直接返回错误,不留下残留状态。检查必须在锁内做——如果检查和写入分开,两个并发请求可能同时通过检查、然后都尝试写入,人数限制和 identity 去重就形同虚设了。

阶段二:先写入,再响应

检查通过、Participant 已写入内部状态。下一步是构造 JoinResponse 并发送给客户端。这一步的时序很简短:

sequenceDiagram
    participant R as Room
    participant P as Participant
    participant S as ResponseSink
    participant C as Client

    R->>R: 构造 JoinResponse
    R->>P: SendJoinResponse(JoinResponse)
    P->>S: WriteMessage(SignalResponse)
    S-->>C: Room、参与者、ICE、配置

写入和响应的先后顺序大有讲究:

写入与响应顺序

上半条绿色路径是 LiveKit 的做法:写入内部状态 → 构造 JoinResponse → 发送给客户端,三步严格有序。下半条红色路径是反面:先响应客户端,再尝试写入,写入失败时客户端已认为入房成功——这就是状态分裂的根源。

JoinResponse 远不止一个“ok”,它是一份入房时刻的房间完整快照:

  • 加入的是哪个 Room;
  • 服务端分配的 Participant SID;
  • 房间里当前有哪些成员;
  • 可用的 ICE Server 列表;
  • 后续连接采用的配置。

新加入的客户端并不知道自己在哪个房间、自己的 SID 是什么、里面都有谁、ICE Server 用哪个——JoinResponse 一次性全部交付。

这个次序——先写入正式状态,再对外返回成功——是整条链路最核心的约束。反过来做会导致客户端和服务器对“当前状态是什么”的认知错位,这种错位比直接失败更隐蔽、更难排查。

写入之后:验证超时

写入和响应完成了入房手续,但 LiveKit 还多做了一步兜底:Join 在写入 Participant 之后,启动一个 1 分钟的超时计时器。如果 Participant 在这个窗口内没有完成后续的协商验证,Room 会主动将它移除。

这个机制要防止的场景是:入房流程在写入成员名单之后、协商建立之前卡住了。可能是因为网络异常,也可能是客户端的 bug。没有超时兜底的话,这个“半成品成员”就会一直占着 Room 的名额。1 分钟窗口是一个权衡:足够让正常的协商完成,又不至于让异常会话无限期占用资源。

阶段三:锁释放,启动后续

上面的检查、写入、超时设置都在 Room 锁内完成。锁释放之后,两项工作在锁外并行展开:

sequenceDiagram
    participant R as Room
    participant M as RoomManager
    participant P as Participant

    Note over R: 释放 Room 锁
    R-->>M: Join 成功
    par 会话交班
        M->>M: 注册会话、记录运行数据
        M->>M: 启动 rtcSessionWorker
    and 媒体准备(异步)
        R->>P: 自动订阅房间内已有 Track
        P->>P: 触发 Negotiate
    end

会话交班(图中左边分支)。Room.Join 返回成功后,RoomManager 注册会话、记录运行数据、启动 rtcSessionWorker。入房事务到此结束,信令循环从此接管。

媒体准备(图中右边分支)。如果 AutoSubscribe 开启,在 goroutine 中异步订阅房间内已有的 Track 并触发协商。注意这一步只是完成了订阅关系的建立和协商准备——媒体流还没有开始传输,真正的音视频数据要等下一篇的 ICE/DTLS/SRTP 建链之后才流动。

JoinResponse 发送完毕,入房事务结束。但会话刚刚开始——后续的 Offer、Answer、ICE candidate 和订阅变更,由谁持续处理?


第五步:长期信令——入房之后,后续消息由谁接管

Room.Join 完成后,链路从“请求-响应”的事务模式切换为一条持续运行的会话。

flowchart LR
  A["初始化事务<br/>验证 → 路由 → StartSession → Room.Join → JoinResponse"] --> B["长期会话<br/>Offer、Answer、ICE、Update、Leave、Token refresh"]

rtcSessionWorker 是这条长期会话的引擎。它的核心循环只用三个节点就能表达:

flowchart LR
    A["ReadChan()<br/>收到信令消息"] --> B["HandleSignalMessage<br/>交给 Participant 处理"]
    B --> C["WriteMessage<br/>响应写回客户端"]
    C --> A

从 channel 读 → 交给 Participant 处理 → 写回响应 → 继续读。 Worker 不关心消息的语义——Offer、Answer、ICE candidate 对它来说都一样,统一丢给 Participant 内部消化。后续要支持新的信令类型,Worker 代码不用改。

Worker 在循环中面对四种情况,下面逐一展开。完整的时序图放在了附录,需要对照全局时可以查询。

场景一:正常信令收发

这是最频繁的路径。客户端发来控制信令,Worker 交给 Participant 处理,响应写回客户端:

sequenceDiagram
    participant C as Client
    participant W as rtcSessionWorker
    participant P as Participant

    C->>W: Offer / Answer / ICE / 发布轨道 / 订阅变更…
    W->>P: HandleSignalMessage(message)
    P->>P: 更新 PeerConnection、轨道或订阅状态
    P-->>W: SignalResponse
    W-->>C: Answer / ICE / 状态更新

requestSource.ReadChan() 取到的是控制信令,不是 RTP 音视频包。Worker 不管消息的具体类型,统一交给 Participant.HandleSignalMessage——消息语义由 Participant 内部消化。

场景二:Token 刷新

Worker 启动时立即尝试一次 Token 刷新(处理“客户端 Token 临近过期”的边缘情况),之后以分钟间隔周期刷新。和第一步“一次性验证”互补——入房前做完整验证,入房后做增量续期。

sequenceDiagram
    participant W as rtcSessionWorker
    participant P as Participant
    participant C as Client

    loop 分钟间隔
        W->>P: refreshToken()
        P-->>C: 刷新后的 Token
    end

场景三:通道关闭

客户端主动离开、网络中断、或信令处理出现致命错误,都会导致循环退出。三种关闭路径的触发源不同,但最终都收敛到同一个动作——Close(requestSource):

flowchart LR
    A["Participant 已断开<br/>Disconnected()"] --> D["Close(requestSource)"]
    B["信令通道关闭<br/>ReadChan() 返回 nil"] --> D
    C["信令处理出错<br/>HandleSignalMessage 返回 error"] --> D
    D --> E["会话循环结束"]

Worker 是这条通道唯一的拥有者——没有其他 goroutine 会去触碰它。需要注意:通道关闭的路径中,只有“信令通道关闭”需要额外做一次比对(见场景四),另外两条路径直接关闭即可。

场景四:旧连接不误杀新连接

三个退出场景中最容易踩坑的是“信令通道关闭”。重连是 RTC 中最高频的异常——切换网络、Wi-Fi 与蜂窝切换、信号波动都会触发。重连后,同一个 Participant 会分配一条新的 requestSource。旧通道关闭时,如果不做比对,就会误杀新连接:

旧连接关闭保护

关键在这一步:旧 Worker 关闭前先问 Room——“我还是当前登记的那条通道吗?“是,才通知 Participant 处理关闭;不是,只关闭自己,不动新会话。没有这个检查,一次网络抖动就可能把用户踢出房间。

Room.Join 完成了“把成员登记进房间”,rtcSessionWorker 开始持续处理该会话的信令消息直至结束。


实践验证:抓一次真实的 /rtc 信令连接

我们在本地跑一个 LiveKit 服务端,打开 Chrome DevTools,观察 /rtc 握手和消息格式,看一下实际数据长什么样。

搭建环境

启动服务端:

go run ./cmd/server --dev

它只绑定 127.0.0.1 和 ::1,适合本机实验。

生成测试 Token:

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

访问 LiveKit Meet,填入服务端地址和 Token,点击连接。

LiveKit Meet 自定义接入页面

观察Network数据

打开 Chrome DevTools → Network,选择 Socket 类型。如果 DevTools 是在房间连接成功后才打开的,则需要刷新页面或重新连接一次;否则 Network 不会补录已经完成的 WebSocket 握手。名称以 rtc?access_token=... 开头的请求,就是目标信令连接。

观察点一:/rtc 完成 WebSocket 升级

image-20260812093952688

从Request URL可以观察到,环境是本地 LiveKit Server、Chrome 151 和 LiveKit JS SDK 2.20.1。

Request URL:
ws://localhost:7880/rtc
  ?access_token=<JWT 已脱敏>
  &auto_subscribe=1
  &sdk=js
  &version=2.20.1
  &protocol=17
  &client_protocol=1
  &adaptive_stream=1

Status: 101 Switching Protocols
Type:   websocket
Time:   Pending

从这组数据中,我们可以观察到三个事实:

  • Token 和客户端能力参数随 /rtc 的 HTTP 握手请求送达服务端。
  • 101 Switching Protocols 表示服务端接受升级,HTTP 请求从这里切换为长连接上的 WebSocket frame。
  • Pending 不是请求卡住,而是连接仍然打开;关闭房间或断开信令后,这一状态才会结束。

这也解释了为什么服务端必须在升级前完成 Token、Room 与节点可用性检查。返回 101 之后,后续失败只能用信令错误或关闭连接表达,无法再改成 HTTP 401、400 或 503。

观察点二:Messages 中是 Protobuf 二进制帧

点开 /rtc 请求,再进入 Messages:

LiveKit WebSocket Messages 中的二进制信令帧

当前版本的 JS SDK 与服务端交换的是 Protobuf 编码的 Binary Message,无法直接显示 JoinResponse、Offer 或 Trickle 的字段名。不过数据方向仍然可以直接判断:红色向下箭头表示服务端发给客户端,绿色向上箭头表示客户端发给服务端。

Chrome 能直接证明连接和帧的方向,但如果要进一步识别 Protobuf 中的具体消息类型,还需要额外解码。

同一次抓包中,稳定连接阶段出现了下面这组周期性帧:

00:42:25.910  ↑  7 B
00:42:25.910  ↑ 12 B
00:42:25.910  ↓  8 B
00:42:25.910  ↓ 17 B

00:42:30.911  ↑  7 B
00:42:30.911  ↑ 12 B
00:42:30.912  ↓  8 B
00:42:30.912  ↓ 17 B

两组帧相隔约 5 秒,可以确认这条信令连接在房间建立后仍持续双向通信。


同一个问题,不同的系统

讲到这里,整条入房链路就全部走完了。我们停下来想一个问题:这五步里反复出现了一个模式——在关键操作之前,总是先验证前提条件仍然成立。验证身份之后才创建状态,确认 owner 身份之后才处理会话,StartSession 去重之后 Room.Join 锁内再查一次,写入成员之后还设了一个 1 分钟验证窗口。每一步其实都在表达一个设计理念:实时系统中,条件可能在任意两步之间发生变化,“刚才检查过”不等同于“永远成立”。

这五步背后的设计选择——准入先于状态创建、一个 Room 一个状态归属、identity 有生命周期、先写入正式状态再对外响应、以完整状态为起点用增量事件描述变化——不限于音视频领域。只要系统满足“多人同时在线、共享同一份实时状态”,这些问题就会以不同形态反复出现。

协作文档中,谁拥有文档状态的权威?用户断网后回来,是恢复原编辑会话还是新建?新用户进入时,收到完整文档快照还是只收后续增量?游戏房间里,玩家连入、断线重连、世界状态同步面对的是同一组问题。Agent 会话中,会话上下文、任务状态和工具调用进度的管理同样遵循这些模式。

LiveKit 信令层的五个选择,迁移到这些领域时表象不同,内核相通。阅读 LiveKit 源码的价值不限于音视频——它给出了一套经过生产环境验证的实时状态管理方案。


结语

点下“加入房间”时,LiveKit 所做的是:验证一份申请表,找到 Room 唯一的状态归属节点,判明这是一次新会话、重复进入还是断线重连,创建一个带完整规则的会话对象,然后在锁内检查、写入正式成员名单、返回房间当前状态的完整视图,最后把连接交给一个信令循环持续处理直至结束。

从这五步中可以提炼出几条设计原则:

  • 验证先于状态创建。 WebSocket 升级前完成所有校验,失败时返回 HTTP 错误码而不是只能关闭连接。这条原则在路由阶段同样适用——WebSocket 升级之前,所有失败都是廉价的。
  • 一份状态一个归属。 Room 的运行时状态只由一个 owner 节点持有,Router 只管寻址不管转发。杜绝多节点各自维护一份 Room 副本导致成员互相不可见。
  • identity 有生命周期。 同一个 identity 再次出现时,系统必须区分新会话、重复进入和断线重连。三种情况的处理策略完全不同:新建、替换、恢复。
  • 先写入再响应。 内部状态写入成功后才对外返回成功。反过来做会导致客户端和服务器对“当前状态”的认知分裂——这种错位比直接失败更隐蔽。
  • 以完整状态为起点,以增量事件描述变化。 JoinResponse 给的是入房瞬间的房间完整快照,后续变化通过递增的 Offer/Answer/ICE 事件接力。

信令层到这里就讲完了。但入房成功只意味着控制通道通了,媒体流还没有开始传输。下一篇我们将沿着 rtcSessionWorker 收到的第一条 Offer 开始,看信令如何通过 ICE 收集候选地址、DTLS 完成密钥协商、最终在 SRTP/SRTCP 上传输实际音视频——从信令到媒体的最后一步。

如果你对 LiveKit 的端到端直播链路感兴趣,可以阅读笔者的系列文章:


附录

本文所有源码引用均来自 LiveKit ——一个开源、可自部署的 WebRTC SFU 和实时通信服务端。

Room.Join 完整时序图

第四步正文中按四个阶段拆成了聚焦图。下面这张完整时序图汇总了 Room.Join 的全部流程,方便需要时对照全局:

sequenceDiagram
    participant M as RoomManager
    participant R as Room
    participant P as Participant
    participant S as ResponseSink
    participant C as Client

    M->>R: Join(participant, requestSource, opts, iceServers)
    activate R

    Note over R: 获取 Room 锁
    R->>R: 检查 Room 是否关闭
    R->>R: 检查 identity 是否重复
    R->>R: 检查人数上限

    alt 不可加入
        R-->>M: 直接返回错误,不留下残留状态
    else 可以加入
        R->>R: 写入 participants、opts、requestSource
        R->>R: 构造 JoinResponse

        R->>P: SendJoinResponse(JoinResponse)
        P->>S: WriteMessage(SignalResponse Join)
        S-->>C: Room、成员、ICE 与房间当前状态

        opt 自动订阅已有 Track
            R->>P: SubscribeToTrack / Negotiate
        end

        Note over R: 释放 Room 锁
        R-->>M: Join 成功
        deactivate R

        M->>M: 注册会话、记录运行数据、启动 rtcSessionWorker
    end

rtcSessionWorker 完整时序图

第五步正文中按场景拆成了四张聚焦图。下面这张完整时序图汇总了所有分支,方便需要时对照全局:

sequenceDiagram
    participant C as Client
    participant B as 信令通道<br/>WebSocket / PSRPC Relay
    participant W as rtcSessionWorker
    participant P as Participant
    participant R as Room

    Note over W: Room.Join 成功后启动

    W->>P: refreshToken()<br/>立即尝试刷新临近过期的 Token

    loop 会话存活期间
        alt 客户端发来后续信令
            C->>B: Offer / Answer / ICE / 发布轨道 / 订阅变更…
            B->>W: requestSource.ReadChan()
            W->>P: HandleSignalMessage(message)
            P->>P: 更新 PeerConnection、轨道或订阅状态
            P-->>B: SignalResponse
            B-->>C: Answer / ICE / 状态更新
        else Token 刷新周期到达
            W->>P: refreshToken()
            P-->>C: 刷新后的 Token
        else Participant 已断开
            P-->>W: Disconnected()
            W->>B: Close(requestSource)
            Note over W: 会话循环结束
        else 信令通道关闭
            B-->>W: ReadChan() 返回 nil
            W->>R: 确认仍是当前 requestSource
            W->>P: HandleSignalSourceClose()
            W->>B: Close(requestSource)
            Note over W: 会话循环结束
        else 信令处理出错
            P-->>W: error
            W->>B: Close(requestSource)
            Note over W: 按致命错误结束会话循环
        end
    end
← 返回首页