大房间方案 — 万人会议、千人连麦、SVC 与选择性订阅、SFU 级联、CDN 混合
本文针对的目标规模:单房间 1000 路连麦 + 10000 路只看观众。低于这个量级用 LiveKit / mediasoup 单点 SFU 加 Simulcast 就够了,不必读本文。
1. 为什么万人房间不能纯 SFU
1.1 带宽爆炸的定量分析
纯 SFU 模型下,每个发布者把流推到 SFU,SFU 把每路流复制给每个订阅者。带宽随订阅关系线性增长。
设:
- 连麦人数
N_pub = 1000,每人上行视频720p@30fps ≈ 1.5 Mbps,音频Opus 32 kbps。 - 观众数
N_sub = 10000,全部订阅所有发布者。
如果不做选择性订阅,单 SFU 出口带宽:
出口带宽 = N_pub × bitrate_per_pub × (N_pub + N_sub - 1)
≈ 1000 × 1.5 Mbps × 10999
≈ 16.5 Tbps
16.5 Tbps 是单台 SFU 出口的几千倍。即使做最激进的"每人只订阅 1 路视频 + 6 路音频",仍然有:
出口 ≈ 11000 × (1.5 Mbps + 6 × 32 kbps)
≈ 11000 × 1.7 Mbps
≈ 18.7 Gbps
单台 25 GbE 网卡的物理上限就在这里,再考虑 RTP 头、RTCP、FEC、协议栈开销,实际可用大约只剩 70%。这是必须级联的硬约束。
1.2 信令风暴
WebRTC 信令的本质是 N 对 N 的事件广播:每个 publish/unpublish/track-state-change/dominant-speaker 变更,都需要扩散到所有需要感知的端。
不分片的广播信令在 11000 端连接下:
- 每秒主讲人切换事件 ≈ 2 ~ 5 次(自然对话节奏)
- 每事件广播 ≈ 11000 条消息
- 每条消息 ≈ 200 字节 JSON
得到 ≈ 11000 × 5 × 200 B = 11 MB/s = 88 Mbps 纯信令流量,远超 WebSocket 网关单连接处理上限,且 GC、序列化和 epoll 唤醒会把 CPU 打满。
1.3 客户端解码上限
Chrome 桌面端硬解最多大约同时 8 ~ 16 路 720p H.264 视频(取决于显卡和驱动),软解会把 CPU 打到 100% 并掉帧。
iOS / Android 端硬解器路数更紧:
- iPhone 上限大约 4 路 720p。
- 中端 Android(骁龙 7 系列)大约 2 ~ 4 路 540p。
结论:哪怕服务端能给你 1000 路下行,客户端也根本解不完。选择性订阅不是优化,是物理必需。
1.4 控制面状态空间
万人房间内的成员、订阅关系、网络状态都是状态机。状态空间随订阅关系增长是 O(N²),单进程无法承载。
| 项 | 1k 房间 | 10k 房间 |
|---|---|---|
| 内存(粗略) | 几百 MB | 数十 GB |
| 心跳 RTT 雪崩 | 偶尔 | 必现 |
| Presence 同步 | 单广播 | 必须分桶 |
2. 总体架构
要点:
- Origin SFU 只负责接收发布者上行 + 转发到 Edge,不直接服务大量订阅。
- Edge SFU 离观众最近,承担下行扇出的主要带宽压力。
- CDN 链路通过 Mixer 把多路 SVC 合成单路 LL-HLS / FLV,给 10k 只看观众用,把 SFU 释放给真正可能上麦的人。
- 控制面与媒体面物理隔离,避免媒体抖动影响信令。
3. SVC + 选择性订阅
3.1 为什么选 SVC 而不是 Simulcast
| 维度 | Simulcast | SVC(VP9 / AV1 / H.264 SVC) |
|---|---|---|
| 上行带宽 | 多档独立编码,叠加(如 1.7+0.5+0.15 ≈ 2.35 Mbps) | 单路分层编码,叠加(base + enhancement ≈ 1.5 Mbps) |
| 切层延迟 | 必须重新选层 + 等关键帧 | 丢高层即可,无需关键帧 |
| 服务端复杂度 | 选轨即可 | 解析 RTP payload descriptor,按 spatial / temporal 丢包 |
| 客户端 CPU | 编码器实例 ×3 | 单实例多层 |
| 浏览器支持 | 全部 | Chrome / Edge VP9 SVC、AV1 SVC;Safari 待跟进 |
万人会议优先 VP9 L3T3(3 个空间层 × 3 个时间层 = 9 个可选档位),原因:
- 上行只有 ~1.5 Mbps,比 Simulcast 节省一半上行。
- SFU 能精确按订阅者带宽逐层丢,不必让发布者重编码。
- L3T3 让"省电模式"的客户端可以只拿到 180p@7.5fps,主屏拿到 720p@30fps。
什么时候不用 SVC:
- 端到端必须用硬件编码且硬编不支持 SVC(部分 Android)。
- 观众端 100% 是 H.264 only 的旧设备 — H.264-SVC 部署还不广泛,落到 Simulcast 三档更稳。
- 流出 CDN 必须转 H.264 — Mixer 转码成本不变,但 SVC 的带宽优势在 CDN 段消失。
3.2 选择性订阅算法
每个客户端只订阅 N 路(Last-N)。N 通常 = 主屏 1 路 + 网格 6 ~ 9 路 + 音频 Top-K。
订阅集合的选择规则按优先级:
- UI 焦点(pin / spotlight):用户主动钉住的发言人,必须订阅最高层。
- Active Speaker:服务端 dominant-speaker 检测的 Top-K(音频 RMS + VAD)。
- 历史活跃度:最近 30s 说话过的用户,作为候选池。
- 组织角色:主持人、讲师、连线嘉宾固定优先级。
- 网格补位:剩余槽位填发布者列表前 N 个,保证不空屏。
服务端推送 dominant-speaker 事件应做:
- Hold-down:连续 ≥ 1.5s 高于阈值才升为主讲人,避免咳嗽抖动。
- Hysteresis:切换需要 ≥ 800ms 持续低于上一主讲人,避免快速来回切。
- Rate-limit:每秒最多 1 次切换事件广播。
4. 客户端订阅策略实现
下面是一个可直接放进项目的 TypeScript 实现,覆盖 last-N、动态调整、SVC 降阶切换。
// subscription-manager.ts
// 客户端订阅控制器:维护 last-N 主讲人,动态选择 SVC 层级
import type { RemoteParticipant, RemoteTrack } from './types'
export interface SubscriptionConfig {
/** 最多同时订阅的视频路数,含 pin 与网格 */
maxVideoSubs: number
/** 最多订阅的音频路数 */
maxAudioSubs: number
/** 主屏分辨率档位 */
primaryLayer: { spatial: 0 | 1 | 2; temporal: 0 | 1 | 2 }
/** 网格分辨率档位 */
gridLayer: { spatial: 0 | 1 | 2; temporal: 0 | 1 | 2 }
/** Hold-down 阈值,毫秒 */
speakerHoldMs: number
}
export interface SubscriptionDecision {
participantId: string
audio: boolean
video: boolean
spatial: 0 | 1 | 2
temporal: 0 | 1 | 2
reason: 'pin' | 'speaker' | 'role' | 'grid' | 'drop'
}
export class SubscriptionManager {
private pinned = new Set<string>()
private activeSpeakers: string[] = []
private roles = new Map<string, 'host' | 'panelist' | 'audience'>()
private lastDecision = new Map<string, SubscriptionDecision>()
/** 客户端实测下行带宽,由 BWE / getStats 提供,单位 kbps */
private downlinkKbps = 4000
/** 客户端解码资源指标:CPU、丢帧 */
private cpuPressure: 'nominal' | 'fair' | 'serious' | 'critical' = 'nominal'
constructor(
private cfg: SubscriptionConfig,
private send: (cmd: SubscriptionCommand) => void,
)
/** 主讲人事件,由 SFU 通过信令推送 */
onDominantSpeakers(ids: string[]): void {
this.activeSpeakers = ids
this.recompute()
}
/** 用户在 UI 上 pin 某人 */
pin(id: string): void {
this.pinned.add(id)
this.recompute()
}
unpin(id: string): void {
this.pinned.delete(id)
this.recompute()
}
/** 来自 RTCPeerConnection.getStats 的下行估计 */
updateNetwork(downlinkKbps: number, cpu: typeof this.cpuPressure): void {
this.downlinkKbps = downlinkKbps
this.cpuPressure = cpu
this.recompute()
}
/** 重新计算订阅集合并向 SFU 发送变更 */
private recompute(): void {
const all = this.knownParticipants()
const candidates = this.rankCandidates(all)
const budget = this.computeBudget()
const decisions = new Map<string, SubscriptionDecision>()
let videoUsed = 0
let audioUsed = 0
for (const p of candidates) {
const reason = this.pickReason(p.id)
const wantVideo =
videoUsed < budget.maxVideo && reason !== 'drop' && reason !== 'audience-only'
const wantAudio = audioUsed < budget.maxAudio
const layer = this.pickLayer(reason, budget)
if (wantVideo) videoUsed += 1
if (wantAudio) audioUsed += 1
decisions.set(p.id, {
participantId: p.id,
audio: wantAudio,
video: wantVideo,
spatial: layer.spatial,
temporal: layer.temporal,
reason: wantVideo || wantAudio ? reason : 'drop',
})
}
this.diffAndCommit(decisions)
}
/** 计算可用预算:按下行带宽 + CPU 压力 */
private computeBudget(): { maxVideo: number; maxAudio: number; layerCap: 0 | 1 | 2 } {
// 经验公式:720p ≈ 1500 kbps,540p ≈ 800 kbps,180p ≈ 200 kbps
let maxVideo = this.cfg.maxVideoSubs
let layerCap: 0 | 1 | 2 = 2
if (this.downlinkKbps < 1500) {
maxVideo = Math.min(maxVideo, 4)
layerCap = 0 // 只取 base layer 180p
} else if (this.downlinkKbps < 4000) {
maxVideo = Math.min(maxVideo, 6)
layerCap = 1
}
if (this.cpuPressure === 'serious') {
maxVideo = Math.min(maxVideo, 4)
layerCap = Math.min(layerCap, 1) as 0 | 1
}
if (this.cpuPressure === 'critical') {
maxVideo = 1
layerCap = 0
}
return {
maxVideo,
maxAudio: this.cfg.maxAudioSubs,
layerCap,
}
}
/** 候选人排序:pin > speaker > role > grid */
private rankCandidates(all: RemoteParticipant[]): RemoteParticipant[] {
return [...all].sort((a, b) => this.priority(b.id) - this.priority(a.id))
}
private priority(id: string): number {
if (this.pinned.has(id)) return 1000
const speakerIdx = this.activeSpeakers.indexOf(id)
if (speakerIdx >= 0) return 800 - speakerIdx
if (this.roles.get(id) === 'host') return 600
if (this.roles.get(id) === 'panelist') return 400
return 100
}
private pickReason(id: string): SubscriptionDecision['reason'] {
if (this.pinned.has(id)) return 'pin'
if (this.activeSpeakers.includes(id)) return 'speaker'
const role = this.roles.get(id)
if (role === 'host' || role === 'panelist') return 'role'
return 'grid'
}
private pickLayer(
reason: SubscriptionDecision['reason'],
budget: { layerCap: 0 | 1 | 2 },
): { spatial: 0 | 1 | 2; temporal: 0 | 1 | 2 } {
const cap = budget.layerCap
if (reason === 'pin') {
const s = Math.min(this.cfg.primaryLayer.spatial, cap) as 0 | 1 | 2
return { spatial: s, temporal: this.cfg.primaryLayer.temporal }
}
const s = Math.min(this.cfg.gridLayer.spatial, cap) as 0 | 1 | 2
return { spatial: s, temporal: this.cfg.gridLayer.temporal }
}
/** 与上次决策对比,只发送变更 */
private diffAndCommit(next: Map<string, SubscriptionDecision>): void {
const cmds: SubscriptionCommand[] = []
for (const [id, d] of next) {
const prev = this.lastDecision.get(id)
if (
!prev ||
prev.audio !== d.audio ||
prev.video !== d.video ||
prev.spatial !== d.spatial ||
prev.temporal !== d.temporal
) {
cmds.push({ type: 'update', ...d })
}
}
for (const id of this.lastDecision.keys()) {
if (!next.has(id)) cmds.push({ type: 'unsubscribe', participantId: id })
}
if (cmds.length === 0) return
this.send({ type: 'batch', cmds })
this.lastDecision = next
}
private knownParticipants(): RemoteParticipant[] {
// 由外部 store 注入,这里省略
return []
}
}
export type SubscriptionCommand =
| ({ type: 'update' } & SubscriptionDecision)
| { type: 'unsubscribe'; participantId: string }
| { type: 'batch'; cmds: SubscriptionCommand[] }
落地要点:
- 批量提交:UI 焦点切换 + 主讲人切换 + 网络抖动可能在 100ms 内连续触发,必须 debounce + 合批,否则 SFU 处理不过来。
- 降阶不丢弃:主屏检测到带宽下降,先把 spatial 从 2 降到 1,再到 0,最后才考虑停订视频,不要立刻 unsubscribe,否则用户感知是"突然黑屏"。
- 保留音频:任何情况下音频订阅最后掉,掉视频不掉音频是体验底线。
5. SFU 级联拓扑
5.1 Hub-and-Spoke vs Mesh
| 维度 | Hub-and-Spoke | Mesh |
|---|---|---|
| Origin 数量 | 1(主备) | N |
| 跳数 | Pub → Origin → Edge → Sub,固定 2 跳 | Pub → 任意 SFU → Sub,最多 2 跳,但路径不唯一 |
| 复杂度 | 低 | 高,需要路由表 + 防环 |
| 跨区延迟 | Origin 到 Edge 跨区 | 任意两 SFU 之间互联 |
| 适合场景 | 单房间发布者地理集中 | 全球均匀分布发布者 |
万人会议绝大多数发布者来自少数几个区域(比如讲师在国内、海外是观众),用 Hub-and-Spoke 更容易做容量规划。Mesh 仅在跨多个云、且每区都既有发布者又有观众时才划算。
5.2 跨区域路由
每个房间路由表大致:
room_id: "r-1024"
publishers:
- participant_id: P1, origin: cn-east-1
- participant_id: P2, origin: cn-east-1
edges:
- cn-east-1: [P1, P2] # 同区直接转发
- cn-north-1: [P1, P2] # 跨区从 origin 拉
- us-west-1: [P1, P2] # 跨海从 origin 拉
跨区互联用 私网专线 / 同 VPC 对等,不要走公网。公网跨区平均 RTT:
| 路径 | RTT |
|---|---|
| 上海 ↔ 北京 公网 | 25 ~ 35 ms |
| 上海 ↔ 北京 专线 | 12 ~ 18 ms |
| 上海 ↔ 美西 公网 | 150 ~ 220 ms |
| 上海 ↔ 美西 专线 | 130 ~ 160 ms |
| 上海 ↔ 法兰克福 专线 | 200 ~ 240 ms |
每多 1 跳级联:
- 媒体延迟 +1 RTT 单程(约 +RTT/2)。
- 抖动叠加:每跳 +5 ~ 15 ms p99 抖动,需要更大的 NetEQ / Jitter Buffer。
硬规则:连麦端到端单程延迟必须 ≤ 400ms(参照 ITU-T G.114),双向交互体验勉强可接受。所以:
- 同区互联可级联 2 跳(Pub → Origin → Edge)。
- 跨区只允许 1 次级联,且优先把 Origin 放在发布者最密集的区。
5.3 Edge 容量规划
单台 Edge SFU 出口 25 GbE 物理网卡,预留 30% 余量,可用约 17.5 Gbps。
订阅者平均下行 ≈ 1 路 720p + 4 路 180p + 6 路 audio = 1.5 + 4×0.2 + 6×0.032 ≈ 2.5 Mbps。
单 Edge 承载 ≈ 17500 Mbps / 2.5 Mbps ≈ 7000 订阅
考虑 GC、上行 RTCP、TURN 中转、TLS 握手 burst,工程上按 5000 订阅 / 台规划更稳。10000 观众至少 2 台 Edge,再加 1 台冷备 + 1 台跨区,总 4 台。
CPU 维度也要看:
- 每路转发的 SFU 开销:一路 forwarder 大约 0.3% CPU(mediasoup, libwebrtc PacingController),5000 路约占 1500% = 15 核心。
- 加密开销:SRTP AES-128-GCM 每 Gbps 约 1 核。17.5 Gbps 约 18 核。
- 总 CPU:建议 64 vCPU 以上,预留突发 keyframe burst 的处理空间。
6. CDN 混合:观众端不走 SFU
6.1 两条独立链路
连麦域:Pub ↔ SFU ↔ Sub (WebRTC,端到端 < 400ms)
观众域:Pub → Mixer → CDN 推流 → 观众 (准实时 0.8 ~ 3s)
万人观众如果全部从 SFU 拉流,单 Edge 跑不动;走 CDN 后 1 元 / GB 级别的边际成本,体验也够。代价是:
- 延迟更高:LL-HLS p50 约 1.5s,FLV 约 0.8 ~ 2s。
- 互动单向:观众无法直接说话,需要"申请连麦"才升级到 WebRTC。
6.2 协议选择
| 协议 | 延迟 | 兼容性 | 适合场景 |
|---|---|---|---|
| WHEP(WebRTC 拉流) | 200 ~ 500ms | Chrome / Edge / Safari 17+ | 准连麦观众,可能升级 |
| LL-HLS | 1 ~ 3s | 全平台,iOS Safari 原生 | 大盘观众,跨地域 |
| HTTP-FLV | 0.8 ~ 2s | 国内 H5 + Flash 替代 | 国内大盘 |
| RTMP | 1 ~ 3s | 推流端兼容旧设备 | OBS 等推流 |
| DASH | 2 ~ 5s | 海外 | 长尾点播兼容 |
工程组合:
- WHEP 给"可能上麦"的观众:保持低延迟,升级时只换信令通道。
- LL-HLS / FLV 给纯观看的大盘:用 CDN 抗压,节省 SFU。
6.3 连麦升降级流程
观众想上麦时,从 LL-HLS 切到 WebRTC:
关键:
- 不要先断 LL-HLS 再连 WebRTC:网络抖动可能让 ICE 卡住,用户感知是"卡了几秒"。
- 预热 ICE Server:在用户点"申请连麦"按钮时就开始 ICE gathering,等审批通过直接 setRemoteDescription。
- 降级回观众:用户主动下麦或被踢,先发 LL-HLS 拉流请求,等首个 segment 解码出来再断 WebRTC,反向同样原则。
6.4 Mixer 转码
Mixer 把 SVC 多路合成为 H.264 单路给 CDN,要做:
- 选取主屏 + 网格 N 路布局合流(演讲模式 / 平铺模式)。
- 转码 H.264 baseline,兼容老设备。
- 多档码率(1080p / 720p / 540p / 360p)输出,CDN 端 ABR。
Mixer 是 CPU 大户,单机最多合 4 ~ 8 个房间。建议用 GPU 编码(NVENC)把单房间合流压到 1 ~ 2 核。
7. 信令系统设计
7.1 控制面与媒体面隔离
媒体面的拥塞、丢包、扇出风暴不应影响信令面。两者必须:
- 不同物理机或至少不同网卡队列。
- 信令走独立的 WebSocket / QUIC 网关,不复用 SFU 的端口。
- 信令网关无状态,所有状态在 Redis / NATS。
7.2 分片广播
把房间拆成 shard,每个 shard ≤ 200 端连接。事件先发到本 shard 内的所有连接,再跨 shard 转发。
event publish flow:
client -> ws-gateway-A -> redis-stream "room:r-1024:shard-3"
\-> ws-gateway-B (consumer) -> shard-3 内的连接
\-> ws-gateway-C (consumer) -> shard-3 内的连接
广播链路用 Redis Streams 或 NATS JetStream:
- Redis Streams:吞吐 50 万 msg/s 级,单点。
- NATS JetStream:原生 Pub/Sub,水平扩展更顺。
事件大小用 Protobuf 压缩到 < 100 字节,避免 JSON 反复序列化。
7.3 Presence 设计
成员的在线状态、角色、设备能力、网络等级是 Presence 的一部分。万人房间 Presence 必须做:
- 冷分桶:按用户 ID hash 拆 32 桶,每桶单独维护,避免单 key 撑爆 Redis。
- 增量同步:客户端入会拿快照,之后只推增量事件。
- 客户端节流:UI 聚合 200ms 渲染一次,不 1 个事件 1 次重渲。
7.4 主讲人广播
dominant-speaker 事件不能广播给所有 11000 人,只发给:
- 同区 SFU(更新转发权重)。
- 已订阅当前主讲人或 Top-K 候选的客户端。
- 房间主持人 / 控制台。
剩余的纯观众通过 LL-HLS 流自带的合流画面感知主讲人,无需服务端推事件。
7.5 Backpressure
WebSocket 连接的 send buffer 不能无限增长。每连接维护:
- 最大 outbound buffer 1MB。
- 超过则降级到 "lossy 模式",丢弃低优先级事件(如 typing indicator),保留 join/leave、主讲人、PauseResume。
- 还溢出就主动断开,让客户端重连同步快照。
8. 真实性能数字
下面是参考数据,按 2024 ~ 2026 年公开的腾讯会议、声网、LiveKit 工程博客与本人在金融客户的实测整理。
| 项 | 数字 | 备注 |
|---|---|---|
| 单 SFU 上行入数 | 500 ~ 1000 | 1.5 Mbps 视频,64 vCPU |
| 单 SFU 下行扇出 | 5000 ~ 7000 | 25 GbE 出口,工程留 30% 余量 |
| Origin → Edge 延迟 | 同区 5 ~ 10ms,跨区 30 ~ 200ms | 专线 |
| 客户端解码上限 | 桌面 8 ~ 16 路 720p / 移动 2 ~ 4 路 540p | 硬解 |
| 主讲人切换延迟 p50 | 200 ~ 400ms | 含信令 + 关键帧请求 |
| 主讲人切换 p99 | 800 ~ 1500ms | SVC 比 Simulcast 快 |
| WebRTC 端到端 p50 | 同区 80 ~ 150ms | 1 跳 SFU |
| WebRTC 端到端 p99 | 跨区 250 ~ 380ms | 2 跳级联 |
| LL-HLS 端到端 | 1.2 ~ 3s | 取决于 part duration |
| 房间最大规模 | 10k ~ 50k | 取决于上麦比例 |
腾讯会议公开过 "300 人同时入会、26 人画面、信令 < 1s" 数字;LiveKit Cloud 公布过单房间 10000 人 + 1000 上麦的级联拓扑;声网公布过万人互动直播 < 800ms 端到端。
9. 反模式
9.1 所有人互订阅
把"M 个发布者 × N 个订阅者"全连,下行带宽 O(M·N) 直接爆炸。永远只订阅 last-N。
失败时的样子:客户端 CPU 100%,浏览器标签卡死,SFU 出口被打满,整个房间出现连锁掉线。
9.2 信令广播不分片
10k 端连一个 WebSocket 广播,Node.js 单进程 GC 立刻 stop-the-world,最长一次 GC 几百毫秒,期间所有事件堆积。必须分片 + 异步消费。
失败时的样子:主讲人切换事件延迟到 5 ~ 10s,连麦音视频已经切了画面还停留在上一个人,体验"鬼畜"。
9.3 不用 SVC 强行多 Simulcast 层
发布者上行带宽吃满 3 倍,移动端 4G 直接掉到 base layer 再也升不上去。客户端 CPU 也吃 3 个编码器。SVC 上行更省,能用就用。
失败时的样子:iPhone 连麦发烫降频,码率从 1.5Mbps 掉到 300kbps,对端看到马赛克。
9.4 连麦切换无降级
观众点上麦立刻断 LL-HLS,等 ICE。ICE 慢则 3 ~ 5s,体验"切麦黑屏"。必须双流并存几秒过渡。
失败时的样子:主持人邀请上台,用户屏幕黑掉两秒,以为掉线,惊慌挂断。
9.5 不分 Origin 和 Edge
所有 SFU 节点平等,发布者随机连一个,导致跨区上行延迟高、热点节点扇出爆炸。Origin 收上行、Edge 扇下行,职责必须分。
失败时的样子:某个 SFU 因为收了主讲人的上行同时又被分配了大量观众,CPU 90%,丢包率上升,全房间卡顿。
9.6 把 CDN 观众也算进 SFU 容量
容量规划时把 10k 都按 WebRTC 算,提前买几十台 SFU。纯观众走 CDN 边际成本接近 0,不要烧 SFU 钱。
失败时的样子:账单超预算 10 倍,会议体验也没更好。
9.7 Presence 单 key 同步
把整个房间的成员列表塞一个 Redis key 反复 GET / SET,万人房间这个 key 几十 MB,每次更新都全量推。必须分桶 + 增量。
失败时的样子:Redis CPU 100%,所有房间一起卡。
9.8 跨区不做 Origin 切换
发布者从国内切到海外,仍然连国内 Origin,上行多走半圈地球。Origin 必须按发布者位置就近选择,跨区漫游时切换 Origin。
失败时的样子:海外连麦端到端 600ms+,互动如打太极。
10. 失败模式与应急预案
10.1 Edge SFU 故障
- 客户端检测到 SFU 心跳超时(3s),发起
reconnect。 - 信令面将订阅者重定向到健康 Edge。
- 重新建联期间客户端切到"占位画面 + 音频",避免黑屏。
- 目标 RTO < 5s。
10.2 Origin 主备切换
- Origin 双活,发布者同时推到主备。
- 主 Origin 故障,Edge 立即切换拉流目标。
- 关键帧请求 burst:所有 Edge 同时 PLI,主备需做合并去重,否则发布者被打成"关键帧机关枪"。
- 目标 RTO < 2s。
10.3 信令风暴
- 入会 burst(开播瞬间 5000 人涌入):在 WS Gateway 做令牌桶限速,超过则排队几百毫秒入会,不要一拥而入。
- 配合客户端做 100 ~ 500ms 抖动入会,避免雪崩。
10.4 单房间热点
- 热门房间瞬间涌入观众:CDN 链路自动扩,SFU 链路因为只服务连麦保持稳定。
- 必要时关闭"申请连麦"功能 → 强制走 CDN,保护 SFU。
11. 落地清单
设计阶段:
- 明确目标房间规模、连麦上限、观众上限。
- 选择 SVC 还是 Simulcast,确认编解码兼容性。
- 设计 Origin / Edge 拓扑、跨区路由、容量公式。
- 确定 CDN 协议组合(WHEP / LL-HLS / FLV)。
- 信令协议定 Protobuf,分片 + Pub/Sub 后端选型。
实现阶段:
- 客户端订阅控制器:last-N、动态降阶、debounce。
- SFU 实现 SVC 选层、PLI 合并、级联协议。
- Mixer 多档码率转码。
- Presence 分桶 + 增量。
- 主讲人 hold-down + hysteresis + rate-limit。
验证阶段:
- 模拟 1k 连麦 + 10k 观众压测,断网、丢包、CPU 限制场景全跑一遍。
- 端到端延迟 p50/p99 数字达标。
- 单 Edge / Origin 故障演练,RTO 达标。
- 客户端解码路数上限保护。
12. 权威资料
- Zoom 工程博客:Multimedia framework for the modern web — 关注 SVC、Hybrid 架构。
- Google Meet 工程团队 ICCV / SIGCOMM 发表的论文(搜索关键词 "Google Duo congestion control"、"Vidaurre Meet SVC")。
- 腾讯会议技术博客:https://cloud.tencent.com/developer/team/tencent-meeting
- 声网 (Agora) 工程博客:https://www.agora.io/cn/community/blog/
- LiveKit Cloud 架构与级联:https://docs.livekit.io/home/cloud/architecture/
- mediasoup 多 router pipeTransport:https://mediasoup.org/documentation/v3/mediasoup/api/#PipeTransport
- IETF AVTCORE working group drafts:
- draft-ietf-avtext-rtp-svc RTP payload 与 SVC
- draft-ietf-avtcore-rtp-vp9 VP9 SVC 描述子
- draft-ietf-avtcore-rtp-av1 AV1 SVC 描述子
- WHIP / WHEP 标准:
- RFC 9725 — WebRTC-HTTP Ingestion Protocol
- draft-ietf-wish-whep WebRTC-HTTP Egress Protocol
- Apple LL-HLS:https://developer.apple.com/documentation/http-live-streaming/enabling-low-latency-http-live-streaming-hls
- ITU-T G.114 端到端时延建议:https://www.itu.int/rec/T-REC-G.114
核对日期:2026-06-22