跳到主要内容

大房间方案 — 万人会议、千人连麦、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

维度SimulcastSVC(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。

订阅集合的选择规则按优先级:

  1. UI 焦点(pin / spotlight):用户主动钉住的发言人,必须订阅最高层。
  2. Active Speaker:服务端 dominant-speaker 检测的 Top-K(音频 RMS + VAD)。
  3. 历史活跃度:最近 30s 说话过的用户,作为候选池。
  4. 组织角色:主持人、讲师、连线嘉宾固定优先级。
  5. 网格补位:剩余槽位填发布者列表前 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-SpokeMesh
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 ~ 500msChrome / Edge / Safari 17+准连麦观众,可能升级
LL-HLS1 ~ 3s全平台,iOS Safari 原生大盘观众,跨地域
HTTP-FLV0.8 ~ 2s国内 H5 + Flash 替代国内大盘
RTMP1 ~ 3s推流端兼容旧设备OBS 等推流
DASH2 ~ 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 StreamsNATS 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 ~ 10001.5 Mbps 视频,64 vCPU
单 SFU 下行扇出5000 ~ 700025 GbE 出口,工程留 30% 余量
Origin → Edge 延迟同区 5 ~ 10ms,跨区 30 ~ 200ms专线
客户端解码上限桌面 8 ~ 16 路 720p / 移动 2 ~ 4 路 540p硬解
主讲人切换延迟 p50200 ~ 400ms含信令 + 关键帧请求
主讲人切换 p99800 ~ 1500msSVC 比 Simulcast 快
WebRTC 端到端 p50同区 80 ~ 150ms1 跳 SFU
WebRTC 端到端 p99跨区 250 ~ 380ms2 跳级联
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. 权威资料

核对日期:2026-06-22