02 · 音视频系统:从采样到流媒体

“为什么直播延迟总是 3-10 秒,而不是 0.3 秒?这不是技术做不到,是权衡的结果。“


开场:一个”简单”的问题

小林:为什么我看直播,主播说话后,我要等好几秒才听到?技术上不能做到实时吗?

老陈:能。视频会议的延迟可以做到 100-200ms。那为什么不把直播做成那样?

小林:……技术难度大?

老陈:不全是。我问你:视频会议 10 个人,直播 100 万人。这两个场景一样吗?

小林:……不一样。

老陈:哪里不一样?

小林:人数差很多。

老陈:对。人数差 10 万倍,架构就完全不同。 我再问:视频会议卡一下,你能接受吗?

小林:……不太能,会很难受。

老陈:直播卡一下呢?

小林:……卡一下也能接受,只要不一直卡。

老陈:这就是核心差异:

视频会议直播
人数几个到几十几万到几百万
延迟要求< 200ms1-10 秒可接受
卡顿容忍低(影响交流)较高(缓冲可吸收)
架构P2P / SFUCDN 分发

直播牺牲了延迟,换来了”百万并发”和”抗抖动”。这是权衡,不是技术限制。


第一部分:音频

声音的数字化:三步

连续的声音波形(模拟信号)
    ↓ ① 采样 (Sampling)
离散的时间点上的幅度值
    ↓ ② 量化 (Quantization)
离散的幅度值(整数)
    ↓ ③ 编码 (Encoding)
二进制数据

① 采样:Nyquist 定理

采样率(Sample Rate):每秒采集多少个点

常见的采样率:
  · 8000 Hz   - 电话(人声够用)
  · 44100 Hz  - CD 音质(★ 最常用)
  · 48000 Hz  - 专业音频、视频伴音
  · 96000 Hz  - 高解析音频

Nyquist-Shannon 采样定理:

要无失真地还原一个信号,采样率必须至少是信号最高频率的 2 倍。

人耳能听到的频率范围: 20 Hz - 20000 Hz
→ 采样率至少 40 kHz
→ 所以 CD 用 44.1 kHz(略高于 40k,留余量)

电话: 人声主要在 300-3400 Hz
→ 采样率 8 kHz 够用(略高于 6800)

为什么会有混叠(Aliasing)?

如果采样率不够:
  一个 30kHz 的信号,用 40kHz 采样
  → 会被"折叠"成 10kHz 的假信号
  → ★ 这就是混叠

解决:采样前先做低通滤波(抗混叠滤波器)
     把高于采样率一半的频率滤掉

② 量化:位深

位深(Bit Depth):每个采样点用多少位表示

· 8 bit   - 256 个级别,电话音质,动态范围 48dB
· 16 bit  - 65536 个级别,CD 音质,动态范围 96dB  ★
· 24 bit  - 专业录音,动态范围 144dB
· 32 bit float - 音频处理中间格式

量化噪声:量化必然有误差(真实值 vs 最近的离散值)

位深越大,量化噪声越小
每增加 1 bit,信噪比提升约 6 dB

★ 这就是 "16bit = 96dB" 的来源: 16 × 6 = 96

★ 算一下:原始音频的数据量

CD 音质立体声:
  采样率 44100 Hz
  位深 16 bit = 2 字节
  声道 2

  数据量 = 44100 × 2 × 2 = 176400 字节/秒 ≈ 172 KB/s
      
一分钟 = 10.3 MB
一小时 = 618 MB  ★ 太大了!

所以必须压缩。

③ 编码:音频压缩的原理

音频编码的两大类:

无损有损
代表FLAC、ALAC、APEMP3、AAC、Opus
压缩率50-70%5-10%
音质完全还原有损失(但听不出来)

有损压缩利用了人耳的两个特性:

特性 1 · 听觉掩蔽(Auditory Masking)

强音会"掩盖"同时存在的弱音

例子:
  一个 1000Hz 的强音(80dB)
  旁边有个 1100Hz 的弱音(30dB)
  → 弱音根本听不见
  → ★ 编码时可以直接丢弃

频域掩蔽: 强音频率附近的弱音被掩盖
时域掩蔽: 强音前后的一小段时间内的弱音被掩盖
         (前掩蔽 ~20ms,后掩蔽 ~200ms)

特性 2 · 听觉范围有限

· 人耳对 2-5kHz 最敏感,对高频和低频不敏感
· 超出 20kHz 的完全听不见
→ 可以直接丢弃高频分量

AAC 编码流程(简化)

PCM 数据
  ↓ ① 分帧(1024 个采样点一帧)
  ↓ ② 时频变换(MDCT,改进离散余弦变换)
  频域系数
  ↓ ③ 心理声学模型分析
  → 计算每个频带的"掩蔽阈值"
  ↓ ④ 量化(★ 根据掩蔽阈值,分配比特)
  → 重要的频带多分配比特,被掩蔽的少分配甚至不分配
  ↓ ⑤ 熵编码(Huffman)
  → 进一步压缩
  ↓ ⑥ 组帧(加 ADTS 头)
AAC 码流

常见音频编码对比:

编码码率(立体声)延迟场景
MP3128-320 kbps高(~100ms)音乐存储
AAC-LC96-256 kbps中(~50ms)★ 通用,流媒体
AAC-LD64-128 kbps低(20ms)视频会议
Opus6-510 kbps极低(5-60ms)★ 实时通信首选
Speex2-44 kbps低语音(已过时)

Opus 是现代实时通信的首选:

  • 延迟可配置(5ms 到 60ms)
  • 码率范围极宽(6kbps 语音到 510kbps 音乐)
  • 同一编解码器处理语音和音乐(内部切换 SILK 和 CELT)
  • 免费、开源、无专利问题
  • WebRTC 的标准音频编解码器

第二部分:视频

颜色空间:为什么用 YUV 而不是 RGB

RGB:红绿蓝三个分量,直观但不是最优。

YUV(YCbCr):

  • Y:亮度(Luminance)
  • U/Cb:蓝色色度分量
  • V/Cr:红色色度分量

★ 为什么视频用 YUV?

理由 1 · 人眼对亮度敏感,对色度不敏感
  → 可以降低色度的分辨率(色度子采样)而不明显影响观感

理由 2 · 兼容黑白电视
  → 黑白电视只用 Y 分量就行(历史原因,但设计保留了)

理由 3 · 压缩效率高
  → 亮度和色度可以分别压缩

色度子采样(Chroma Subsampling):

4:4:4 - 无子采样(每个像素都有完整的 YUV)
        Y: ●●●●    U: ●●●●    V: ●●●●
        数据量: 3 bytes/pixel

4:2:2 - 水平方向色度减半
        Y: ●●●●    U: ●●      V: ●●
        数据量: 2 bytes/pixel  (省 33%)

4:2:0 - 水平和垂直方向都减半 ★ 最常用
        Y: ●●●●    U: ●       V: ●
           ●●●●
        数据量: 1.5 bytes/pixel (省 50%)

4:1:1 - 水平方向色度 1/4
        数据量: 1.5 bytes/pixel

算一下:1080p 60fps 的原始数据量

分辨率: 1920 × 1080 = 2073600 像素
YUV 4:2:0: 每像素 1.5 字节
每帧: 2073600 × 1.5 = 3.1 MB
60 fps: 3.1 × 60 = 186 MB/s   ★★

一分钟 = 11 GB   ★ 必须压缩(100-500 倍)

视频压缩:为什么能压缩这么多

视频有大量的冗余:

冗余类型说明压缩手段
空间冗余相邻像素相似帧内预测 + DCT 变换
时间冗余相邻帧相似★ 帧间预测(运动估计)
视觉冗余人眼对某些信息不敏感量化(丢弃高频)
编码冗余数据分布不均熵编码

★ 时间冗余是视频压缩的核心:相邻帧之间,画面大部分区域是不变的(只有运动的部分变了)。

帧的三种类型

I 帧 (Intra / 关键帧):
  · 独立编码,不依赖其他帧
  · 压缩率最低(类似 JPEG)
  · ★ 随机访问的入口(拖动进度条要跳到 I 帧)

P 帧 (Predictive):
  · 参考前面的 I 帧或 P 帧
  · 只编码"与参考帧的差异"
  · 压缩率中等

B 帧 (Bi-directional):
  · 参考前面和后面的帧
  · 压缩率最高
  · ★ 但增加延迟(要等后面的帧)

GOP(Group of Pictures)结构:

典型的 GOP(IBBP 结构,GOP=12):

I B B P B B P B B P B B I B B P ...
└───── GOP 1 ─────┘└───── GOP 2 ...

显示顺序:  I B B P B B P B B P B B
编码顺序:  I P B B P B B P B B P B
           ↑ 注意:B 帧要等它参考的 P 帧先编码

★ GOP 的关键权衡:

GOP 越长:
  · I 帧越少 → 压缩率越高(文件越小)
  · 但:随机访问慢(要解码更多帧才能显示)
  · 错误传播范围大(一个 I 帧坏了,整个 GOP 都坏)

GOP 越短:
  · I 帧越多 → 文件越大
  · 但:seek 快、错误恢复快

直播:    GOP 通常 1-2 秒(快速起播、快速恢复)
点播:    GOP 通常 4-10 秒(压缩率高)
视频会议: 可能全 I 帧(最低延迟,但带宽大)

H.264 编码流程

原始帧
  ↓
┌──────────────────────────────────────┐
│  ① 分块                                │
│  帧 → 16×16 宏块(Macroblock)         │
└──────────────┬───────────────────────┘
               ▼
┌──────────────────────────────────────┐
│  ② 预测                                │
│  ├─ 帧内预测 (Intra):用本帧已编码的    │
│  │            相邻像素预测             │
│  └─ 帧间预测 (Inter):运动估计,        │
│               在参考帧里找最相似的块    │
│               记录运动矢量 (MV)         │
└──────────────┬───────────────────────┘
               ▼ 得到"残差"(原始 - 预测)
┌──────────────────────────────────────┐
│  ③ 变换 + 量化                          │
│  · DCT 变换(整数变换)                 │
│  · ★ 量化:丢弃高频(有损!)            │
│    QP 参数控制量化强度                  │
└──────────────┬───────────────────────┘
               ▼
┌──────────────────────────────────────┐
│  ④ 熵编码                              │
│  CAVLC 或 CABAC(上下文自适应)         │
└──────────────┬───────────────────────┘
               ▼
┌──────────────────────────────────────┐
│  ⑤ 环路滤波 (Deblocking Filter)        │
│  消除块效应(块边界的不连续)            │
└──────────────┬───────────────────────┘
               ▼
          H.264 码流

★ 运动估计的直觉:

当前帧的一个块,在参考帧里找最匹配的位置

参考帧:                    当前帧:
┌───────────────┐         ┌───────────────┐
│               │         │               │
│    [汽车]      │         │         [汽车] │  ← 汽车向右移动了
│               │         │               │
└───────────────┘         └───────────────┘

编码器不编码"汽车的每个像素"
只编码: "把这个块从 (-30, 0) 位置搬过来"
        = 运动矢量 (MV) + 残差(细节差异)

★ 这就是视频压缩能做到 100-500 倍的关键

编码标准对比

标准年份压缩率复杂度专利
H.264 / AVC2003基准中有(但有免费阈值)
H.265 / HEVC2013+50%高复杂(多个专利池) ★
VP92013+50%高Google,免版税
AV12018+70%极高免版税(AOMedia)
H.266 / VVC2020+100%极高复杂

★ 为什么 H.264 至今仍是主流?

① 硬件支持最广泛
   · 几乎所有手机、浏览器、芯片都支持硬编硬解
   · H.265/AV1 的硬件支持较晚

② 专利问题
   · H.265 有多个专利池(MPEG LA、HEVC Advance、Velos Media)
   · 收费复杂且不透明
   · ★ 这就是为什么 VP9/AV1 被推出来

③ 性能/复杂度平衡好
   · H.264 的编码复杂度适中
   · 直播场景需要实时编码,H.265 的复杂度可能是问题

★ 我的建议:
   · 实时通信: H.264(硬件编解码普及)
   · 点播存储: H.265 或 AV1(省带宽,可以离线编码)
   · 新兴场景: AV1(免版税,但编码慢,适合点播)

第三部分:封装与传输

封装格式(Container)

封装 ≠ 编码。封装是”把音频、视频、字幕等打包在一起”。

┌────────────────────────────────────┐
│  视频轨 (H.264)                     │
│  音频轨 (AAC)                       │
│  字幕轨                              │
│  元数据(时长、分辨率...)            │
└────────────────────────────────────┘
        ↓ 封装
   MP4 / FLV / TS / WebM / MKV
格式特点场景
MP4支持随机访问,结构复杂★ 点播
FLV简单,流式友好直播(RTMP)
TS固定 188 字节包,抗错误★ HLS、数字电视
WebM开源,基于 MatroskaWeb 端
MKV功能最全本地存储

三种直播协议的对比

RTMPHLSWebRTC
延迟1-3 秒5-20 秒 ★< 500ms
原理长连接流式切片下载P2P/SFU
穿透性需要 1935 端口HTTP(穿透好)UDP(可能受限)
扩展性需要专门服务器★ CDN 友好难扩展
移动端支持一般完美支持好
场景推流(上行)拉流(下行)视频会议

★ 现代直播的典型架构:RTMP 推流 + HLS/FLV 拉流

主播端 ──RTMP──► 流媒体服务器 ──┬──► HLS (m3u8 + ts)  ──► iOS / Safari
                                ├──► HTTP-FLV         ──► PC / Android
                                └──► WebRTC           ──► 低延迟场景

HLS 为什么延迟大

HLS 的原理:

1. 服务端把流切成小片段(每个 5-10 秒)
   segment0.ts, segment1.ts, segment2.ts...

2. 生成一个播放列表(m3u8)
   #EXTM3U
   #EXT-X-TARGETDURATION:10
   #EXTINF:10.0,
   segment0.ts
   #EXTINF:10.0,
   segment1.ts
   ...

3. 客户端定时拉取 m3u8,下载最新的 ts 播放

延迟来源:

① 切片时长: 必须等一个切片完整生成才能分发
   → 6 秒切片 = 至少 6 秒延迟

② 客户端缓冲: 为了抗抖动,客户端会缓冲 2-3 个切片
   → 又增加 12-18 秒

③ 播放列表刷新间隔
   → 客户端每 5-10 秒才去拉一次 m3u8

★ 总计: 6 + 15 + 5 ≈ 25 秒?(实际优化后 5-20 秒)

优化:
  · 减小切片时长(LL-HLS 用 1-2 秒的部分切片)
  · 减少客户端缓冲
  · ★ 但代价是:抗抖动能力下降,更容易卡顿

老陈:这就是本节的开场问题的答案。

延迟和流畅度是一对矛盾:

  • 缓冲越大 → 越流畅,但延迟越高
  • 缓冲越小 → 延迟越低,但更容易卡顿

直播选择”流畅优先”(缓冲大),视频会议选择”延迟优先”(缓冲小)。

没有对错,取决于场景。这就是架构权衡。


第四部分:直播系统架构

┌─────────────────────────────────────────────────────┐
│                      主播端                            │
│  采集 → 前处理 → 编码 → RTMP 推流                      │
│  (摄像头) (美颜/滤镜) (H.264)                          │
└──────────────────────┬──────────────────────────────┘
                       │ RTMP
┌──────────────────────▼──────────────────────────────┐
│                  接入层(边缘节点)                     │
│  · RTMP 收流                                          │
│  · 转封装 / 转码                                       │
│  · ★ 鉴权(防止非法推流)                              │
└──────────────────────┬──────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────┐
│                  中心层                                │
│  · 转码集群(多码率:1080p/720p/480p)                 │
│  · 录制(存 MP4)                                      │
│  · 截图(封面)                                        │
│  · 审核(AI 鉴黄)                                     │
└──────────────────────┬──────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────┐
│                  分发层(CDN)                          │
│  · 边缘节点缓存                                        │
│  · HLS / HTTP-FLV 分发                                │
└──────────────────────┬──────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────┐
│                     观众端                             │
│  拉流 → 解封装 → 解码 → 渲染                           │
│        (HLS)   (H.264)                               │
└─────────────────────────────────────────────────────┘

用 Go 实现一个简单的 RTMP 服务器(核心部分)

package main
 
import (
	"bufio"
	"fmt"
	"io"
	"net"
	"sync"
)
 
// ============ RTMP 消息 ============
 
type Message struct {
	TypeID      byte      // 8=音频, 9=视频, 18=元数据
	Timestamp   uint32
	StreamID    uint32
	Payload     []byte
}
 
// ============ 流管理 ============
 
type Stream struct {
	key       string
	publisher net.Conn
	mu        sync.RWMutex
	// 元数据(分辨率、编码信息等)
	metadata  []byte
	// 最近的关键帧(用于新观众立即出画面)★
	keyframe  []byte
	// 订阅者
	subscribers map[net.Conn]bool
	// GOP 缓存(新观众加入时先发一个 GOP)
	gopCache  [][]byte
}
 
type RTMPServer struct {
	mu      sync.RWMutex
	streams map[string]*Stream
}
 
func NewRTMPServer() *RTMPServer {
	return &RTMPServer{
		streams: make(map[string]*Stream),
	}
}
 
func (s *RTMPServer) HandleConn(conn net.Conn) {
	defer conn.Close()
 
	// 1. RTMP 握手(C0+C1, S0+S1+S2, C2)
	if err := s.handshake(conn); err != nil {
		fmt.Println("握手失败:", err)
		return
	}
 
	// 2. 读取 chunk
	reader := bufio.NewReader(conn)
	for {
		msg, err := readChunk(reader)
		if err != nil {
			if err == io.EOF {
				return
			}
			fmt.Println("读取消息失败:", err)
			return
		}
 
		// 3. 处理命令消息(AMF0)
		if msg.TypeID == 20 {   // AMF0 Command
			cmd, params := parseAMFCommand(msg.Payload)
			switch cmd {
			case "connect":
				s.onConnect(conn, params)
			case "publish":
				s.onPublish(conn, params)
			case "play":
				s.onPlay(conn, params)
			}
		} else if msg.TypeID == 9 {  // 视频
			s.onVideo(conn, msg)
		} else if msg.TypeID == 8 {  // 音频
			s.onAudio(conn, msg)
		}
	}
}
 
func (s *RTMPServer) onVideo(conn net.Conn, msg *Message) {
	s.mu.RLock()
	stream := s.findStreamByPublisher(conn)
	s.mu.RUnlock()
 
	if stream == nil {
		return
	}
 
	// ★ 检测是不是关键帧
	//   H.264 的第一个字节: 0x17 = 关键帧, 0x27 = 非关键帧
	if len(msg.Payload) > 0 {
		frameType := msg.Payload[0]
		isKeyframe := (frameType & 0xf0) == 0x10
 
		stream.mu.Lock()
		if isKeyframe {
			// 新 GOP 开始,清空缓存
			stream.gopCache = stream.gopCache[:0]
			stream.keyframe = msg.Payload
		}
		// 加入 GOP 缓存
		stream.gopCache = append(stream.gopCache, msg.Payload)
		if len(stream.gopCache) > 500 {   // 限制缓存大小
			stream.gopCache = stream.gopCache[1:]
		}
		stream.mu.Unlock()
 
		// 广播给所有订阅者
		stream.broadcast(msg)
	}
}
 
func (s *Stream) broadcast(msg *Message) {
	s.mu.RLock()
	defer s.mu.RUnlock()
 
	for sub := range s.subscribers {
		go func(c net.Conn) {
			writeChunk(c, msg)
		}(sub)
	}
}
 
// ★ 关键:新观众加入时,先发一个完整的 GOP
//   否则要等到下一个关键帧才能出画面(可能要等几秒)
func (s *RTMPServer) onPlay(conn net.Conn, params []interface{}) {
	if len(params) < 2 {
		return
	}
	streamKey, _ := params[1].(string)
 
	s.mu.RLock()
	stream := s.streams[streamKey]
	s.mu.RUnlock()
 
	if stream == nil {
		fmt.Println("流不存在:", streamKey)
		return
	}
 
	// 1. 加入订阅者列表
	stream.mu.Lock()
	stream.subscribers[conn] = true
	gopCache := make([][]byte, len(stream.gopCache))
	copy(gopCache, stream.gopCache)
	metadata := stream.metadata
	stream.mu.Unlock()
 
	// 2. ★ 先发元数据
	if metadata != nil {
		writeChunk(conn, &Message{
			TypeID:  18,
			Payload: metadata,
		})
	}
 
	// 3. ★ 再发缓存的 GOP(这样观众能立即出画面)
	for _, frame := range gopCache {
		writeChunk(conn, &Message{
			TypeID:  9,
			Payload: frame,
		})
	}
 
	fmt.Printf("观众加入: %s, 已发送 %d 帧缓存\n", streamKey, len(gopCache))
}
 
// ============ 以下是简化/占位实现 ============
 
func (s *RTMPServer) handshake(conn net.Conn) error {
	// RTMP 握手是 3 个包:C0+C1, S0+S1+S2, C2
	// 简化:直接读写固定字节
	buf := make([]byte, 1537)
	if _, err := io.ReadFull(conn, buf); err != nil {
		return err
	}
	// 回 S0+S1+S2
	s1 := make([]byte, 1536)
	s1[0] = 3   // RTMP 版本
	// ... 填充时间戳等
	conn.Write([]byte{3})
	conn.Write(s1)
	conn.Write(buf[1:])   // 回显 C1 作为 S2
	// 读 C2
	c2 := make([]byte, 1536)
	io.ReadFull(conn, c2)
	return nil
}
 
func readChunk(r *bufio.Reader) (*Message, error) {
	// RTMP chunk 格式:
	//   Basic Header (1-3 bytes) + Message Header (0/3/7/11) + Extended Timestamp + Payload
	// 真实实现要处理 chunk 分片和重组
	// 这里简化为占位
	return nil, io.EOF
}
 
func writeChunk(w io.Writer, msg *Message) error {
	// 简化实现
	return nil
}
 
func parseAMFCommand(payload []byte) (string, []interface{}) {
	// AMF0 解析:命令名 + 事务ID + 参数
	return "", nil
}
 
func (s *RTMPServer) onConnect(conn net.Conn, params []interface{}) {}
 
func (s *RTMPServer) onPublish(conn net.Conn, params []interface{}) {
	if len(params) < 2 {
		return
	}
	streamKey, _ := params[1].(string)
 
	s.mu.Lock()
	s.streams[streamKey] = &Stream{
		key:         streamKey,
		publisher:   conn,
		subscribers: make(map[net.Conn]bool),
	}
	s.mu.Unlock()
 
	fmt.Println("开始推流:", streamKey)
}
 
func (s *RTMPServer) findStreamByPublisher(conn net.Conn) *Stream {
	for _, st := range s.streams {
		if st.publisher == conn {
			return st
		}
	}
	return nil
}
 
func (s *RTMPServer) onAudio(conn net.Conn, msg *Message) {
	// 类似 onVideo,广播给订阅者
	stream := s.findStreamByPublisher(conn)
	if stream != nil {
		stream.broadcast(msg)
	}
}
 
func main() {
	server := NewRTMPServer()
	listener, err := net.Listen("tcp", ":1935")
	if err != nil {
		panic(err)
	}
	fmt.Println("RTMP 服务器启动在 :1935")
 
	for {
		conn, err := listener.Accept()
		if err != nil {
			continue
		}
		go server.HandleConn(conn)
	}
}

★ GOP 缓存是直播系统的关键优化

问题:
  观众加入时,如果直接从头发流,他可能收到的是 P 帧
  → P 帧没有参考帧,无法解码
  → 画面黑屏,直到下一个 I 帧(可能要等 2-10 秒)

解法:
  服务端缓存最近的一个 GOP(从关键帧开始)
  新观众加入时,先发这个 GOP
  → ★ 立即出画面

代价:
  · 服务端内存(每个流缓存一个 GOP,几 MB)
  · 观众看到的画面比"最新"落后一个 GOP(半秒到 2 秒)
  
★ 用一点延迟,换"秒开"体验。值得。

思考题 ·【应用层】

你要设计一个”超低延迟直播”系统,要求延迟 < 1 秒,支持 10 万并发观众。请对比 HLS、HTTP-FLV、WebRTC 三种方案,说明各自的延迟构成,并给出你的架构设计和关键优化点。

参考答案

先拆解延迟构成

端到端延迟 = 采集 + 编码 + 上行传输 + 服务端处理 + 下行传输 + 缓冲 + 解码 + 渲染

典型值:
  采集:        10-30ms
  编码:        10-50ms(硬件编码)/ 50-200ms(软件编码)
  上行传输:    20-100ms(取决于网络)
  服务端处理:  10-100ms(转码、转封装)
  下行传输:    20-100ms
  客户端缓冲:  ★ 0-15 秒(这是最大的变量!)
  解码:        5-20ms
  渲染:        5-15ms

★ 关键:除了"客户端缓冲",其他都是几十毫秒级
  所以延迟的差异主要来自:
  ① 客户端缓冲策略
  ② 传输协议的"切片/累积"特性

三种方案的延迟对比

HLS:5-20 秒

延迟构成:
  ① 切片等待:    必须等一个完整切片生成
                 默认 6-10 秒/切片
                 ★ 这是最大的延迟源
  ② 播放列表刷新: 客户端定时拉 m3u8(3-10 秒)
  ③ 客户端缓冲:  2-3 个切片(12-30 秒)
  ④ 传输:       几百毫秒

总延迟: 6 + 6 + 18 + 0.5 ≈ 30 秒(默认配置)
       优化后: 5-20 秒

优化(LL-HLS):
  · 部分切片(Partial Segment): 切片可以细到 200-500ms
  · 阻塞式播放列表刷新: 服务端 hold 住请求,有新切片立即返回
  · 预加载提示
  ★ 能把延迟降到 2-5 秒
  ★ 代价: 服务端复杂度大幅上升,CDN 支持不好

HTTP-FLV:1-3 秒

原理:
  · 不是切片,是"流式"传输
  · 服务端收到一个 tag(帧)就立即发给客户端
  · 客户端边收边播

延迟构成:
  ① 服务端缓冲: 通常 1 个 GOP(0.5-2 秒)
  ② 客户端缓冲: 0.5-1 秒
  ③ 传输:      几百毫秒

总延迟: 1-3 秒

优势:
  · 比 HLS 延迟低
  · 基于 HTTP,穿透性好,CDN 支持较好
  
劣势:
  · 需要专门的播放器(flv.js)
  · iOS Safari 不支持(只能用 HLS)

WebRTC:< 500ms

原理:
  · 基于 UDP(RTP/RTCP)
  · 无切片,帧级别传输
  · 有拥塞控制、FEC、NACK 等实时优化

延迟构成:
  ① 编码:      10-30ms(硬件)
  ② 传输:      20-100ms
  ③ 抖动缓冲:  ★ 0-50ms(自适应)
  ④ 解码:      5-20ms

总延迟: 50-300ms

优势:
  · 延迟最低
  · 浏览器原生支持

劣势:
  ★ 扩展性差!
    · WebRTC 设计上是 P2P 或小会议(SFU)
    · 要支持 10 万观众,需要 SFU 级联 or 转成其他协议
  · UDP 穿透问题(需要 TURN 服务器)
  · 带宽成本高(每个观众一条流,无法像 CDN 那样复用)

关键权衡:延迟 vs 扩展性 vs 成本

方案延迟扩展性(10万观众)成本兼容性
HLS5-20s★★★★★ CDN 完美支持低★★★★★ 全平台
HTTP-FLV1-3s★★★★ CDN 支持较好中★★★ PC/Android,iOS 不行
WebRTC<500ms★★ 需要 SFU 集群高★★★★ 现代浏览器

★ 核心矛盾:CDN 的缓存复用机制要求”数据块化”,而低延迟要求”立即发送”。

CDN 的工作方式:
  边缘节点缓存内容 → 多个观众请求同一份内容时复用
  → 带宽成本极低
  → ★ 但缓存需要"完整的数据块"

低延迟要求:
  来一个帧就发一个帧
  → 无法形成可复用的缓存块
  → ★ 每个观众一条独立流,带宽成本高

这是本质的矛盾,没有完美解法

我的架构设计

目标:延迟 < 1 秒,10 万并发

结论:纯 WebRTC 做不到(成本),纯 HLS 做不到(延迟)。

方案:WebRTC 用于”互动” + HTTP-FLV/LL-HLS 用于”观看”,分层设计。

但题目明确要求"延迟 < 1 秒,10 万并发"
→ 那就要用 WebRTC + SFU 级联

架构:WebRTC SFU 级联

                          主播(WebRTC 推流)
                                │
                                ▼
┌─────────────────────────────────────────────┐
│              源站 SFU(接入层)                │
│  · 接收主播的 WebRTC 流                      │
│  · 不转码(转发原始流)                       │
└────────────────────┬────────────────────────┘
                     │ 内部高速网络
        ┌────────────┼────────────┐
        ▼            ▼            ▼
┌──────────┐  ┌──────────┐  ┌──────────┐
│ 边缘 SFU 1│  │ 边缘 SFU 2│  │ 边缘 SFU 3│
│ (3万观众) │  │ (3万观众) │  │ (4万观众) │
└──────────┘  └──────────┘  └──────────┘
     │             │             │
     ▼             ▼             ▼
  观众们         观众们         观众们

SFU(Selective Forwarding Unit)是什么?

三种多方通信架构:

① Mesh: 每个人发给其他所有人
   N 个人 → N² 条流
   ★ 只能支持 4-6 人

② MCU (Multipoint Control Unit): 服务端混合所有流成一路
   N 个人 → 每个人 1 条流
   ★ 服务端压力大(要解码+混合+编码)
   ★ 延迟高(100-300ms 额外)

③ SFU (Selective Forwarding Unit): 服务端只转发,不处理
   N 个人 → 每个人 N-1 条流(或者选择性转发)
   ★ 服务端压力小(只转发)
   ★ 延迟低(几十 ms)
   ★ 支持的人数取决于带宽

★ 直播场景(1 个主播 + N 个观众):
   SFU 只需要"1 进 N 出"
   → 每个观众一条流
   → ★ 10 万观众 = 10 万条流,单台 SFU 扛不住
   → 需要级联

级联的关键设计:

源站 SFU → 边缘 SFU(一跳)
   
   · 主播的流只推一次到源站
   · 源站转发给 N 个边缘 SFU
   · 每个边缘 SFU 服务几千到几万观众

★ 优势:主播上行只有一条流
★ 代价:多一跳,增加 20-50ms 延迟

关键优化点

优化 1:不做转码(最关键)

❌ 转码: 解码 + 编码
   延迟增加 50-200ms
   CPU 消耗巨大

✅ 只转发(SFU 的本质)
   延迟增加 1-5ms
   CPU 消耗小

★ 如果需要多码率(1080p/720p/480p):
   让主播端推多路(simulcast)
   或者用 SVC(可伸缩视频编码)

Simulcast: 主播同时推 3 路不同分辨率的流

主播 ──1080p──► SFU ──► 带宽好的观众
     ──720p──►     ──► 带宽中的观众
     ──360p──►     ──► 带宽差的观众

★ 上行带宽×3,但避免了服务端转码
★ 观众可以动态切换(不中断)

SVC (Scalable Video Coding): 分层编码

一层基础层(360p)+ 两层增强层(+360p +360p = 1080p)
→ 服务端可以根据带宽丢弃增强层
→ ★ 更灵活,但编码复杂度和兼容性是问题

优化 2:抖动缓冲自适应

JitterBuffer 的作用:吸收网络抖动

固定缓冲:  100ms
  · 网络好时: 白白增加 100ms 延迟
  · 网络差时: 还是可能卡顿

★ 自适应缓冲:
  · 实时监测网络抖动
  · 抖动小 → 缓冲降到 20ms
  · 抖动大 → 缓冲升到 200ms
  · ★ 在"延迟"和"流畅"之间动态调整

这就是 WebRTC 的 NetEQ 算法做的事

优化 3:拥塞控制

问题:UDP 没有拥塞控制,会压垮网络

WebRTC 的方案:
  · GCC (Google Congestion Control)
  · 基于延迟梯度检测拥塞
  · 动态调整发送码率

★ 关键:
  网络变差时,主动降低码率(而不是让丢包发生)
  → 宁可画质差一点,也不要卡顿

优化 4:FEC 和 NACK

两种抗丢包手段:

① NACK (Negative Acknowledgement): 丢包重传
   接收方发现丢包 → 请求重传
   ★ 适合 RTT 小的情况
   ★ 延迟 = 1 个 RTT

② FEC (Forward Error Correction): 前向纠错
   发送冗余数据,接收方可以"算"出丢失的包
   ★ 不需要重传,延迟低
   ★ 代价:额外带宽(10-50%)

★ 组合策略:
   RTT < 50ms  → 主要用 NACK(重传快)
   RTT > 100ms → 主要用 FEC(重传太慢)
   
   WebRTC 会自适应地调整两者比例

优化 5:关键帧请求(PLI/FIR)

问题: 观众加入时没有关键帧 → 黑屏

解法: 观众通过 RTCP 发送 PLI (Picture Loss Indication)
     请求服务端让编码器产生一个关键帧

★ 代价: 关键帧很大(是 P 帧的 5-10 倍)
       会造成带宽尖峰

优化: 
  · 服务端缓存最近的关键帧
  · 新观众加入时直接发缓存的关键帧(不用等新的)
  ★ 这和前面 RTMP 的 GOP 缓存是一回事

优化 6:TURN 服务器(穿透失败时)

WebRTC 优先 P2P,但需要 NAT 穿透:
  · STUN: 获取自己的公网地址(80% 情况够用)
  · TURN: 中继(穿透失败时用)
  
★ 20% 的情况需要 TURN
★ TURN 服务器成本高(所有流量都要中转)

成本估算

10 万观众,720p 码率 1.5 Mbps:

总带宽 = 100000 × 1.5 Mbps = 150 Gbps
       = 18.75 GB/s
       = 67.5 TB/小时

按 CDN 价格 0.1 元/GB:
  每小时成本 = 67500 GB × 0.1 = 6750 元/小时
  
★ 一场 2 小时的直播 = 13500 元

对比 HLS (CDN 复用):
  边缘节点复用后,实际出口带宽可能只有 1/10
  → 成本降低 10 倍

★ 这就是"低延迟"的代价

我的最终建议

如果延迟要求真的是 < 1 秒:

架构: WebRTC + SFU 级联
  · 主播用 WebRTC 推流
  · 源站 SFU 接收
  · 边缘 SFU 级联分发
  · 观众用 WebRTC 拉流

关键优化:
  ① 不转码(Simulcast 代替)
  ② 自适应抖动缓冲
  ③ GCC 拥塞控制
  ④ FEC + NACK 组合
  ⑤ 关键帧缓存

预期延迟: 300-800ms
成本:     高(CDN 无法复用)

但如果可以放宽到 1-3 秒:

架构: RTMP 推流 + HTTP-FLV 拉流
  · 延迟 1-3 秒
  · CDN 可以部分复用
  · 成本降低 3-5 倍
  · ★ 性价比最高

★ 我的建议: 先做 HTTP-FLV,验证业务
  如果确实有强互动需求(比如直播带货要实时互动)
  再上 WebRTC

如果可以放宽到 5-10 秒:

架构: RTMP 推流 + HLS 拉流
  · 成本最低(CDN 完美支持)
  · 兼容性最好(全平台)
  · ★ 大多数直播场景这个延迟是可以接受的

一句话总结

超低延迟直播的核心是:用”成本”换”延迟”。

  • HLS:延迟高,但 CDN 友好,成本最低
  • HTTP-FLV:延迟中,CDN 部分支持,性价比高
  • WebRTC:延迟最低,但 CDN 无法复用,成本高 5-10 倍

选型的关键问题是:你的业务真的需要 < 1 秒的延迟吗?

需要低延迟的场景:
  · 直播带货(主播和观众实时互动)
  · 在线教育(老师提问,学生回答)
  · 体育赛事(不想被剧透)
  · 监控/云游戏

不需要的场景:
  · 演唱会直播
  · 游戏直播(非互动)
  · 会议直播
  ★ 这些场景,5-10 秒延迟完全没问题

理解了业务的真实需求,才能做出正确的技术选型。这又回到了第 8 章第 01 节的核心:架构是在约束下做决策。


小结:这一节你应该带走的东西

  1. 音频数字化的三步:采样(Nyquist 定理)、量化(位深)、编码(心理声学模型)。

  2. 视频用 YUV 而不是 RGB:人眼对亮度敏感、对色度不敏感,可以做色度子采样(4:2:0 省 50%)。

  3. 视频压缩的核心是消除时间冗余:I/P/B 帧 + 运动估计。这是能做到 100-500 倍压缩率的关键。

  4. GOP 的权衡:长 GOP 压缩率高但 seek 慢、错误恢复慢;短 GOP 反之。直播用短 GOP。

  5. HLS 延迟大的原因:切片等待 + 播放列表刷新 + 客户端缓冲。这是”流畅优先”的选择。

  6. GOP 缓存是直播秒开的关键:新观众加入时先发一个完整 GOP,用一点延迟换”立即出画面”。

  7. 延迟和流畅度是矛盾:缓冲越大越流畅但延迟越高。直播选流畅,视频会议选延迟。

  8. 超低延迟的核心矛盾:CDN 的缓存复用要求”数据块化”,而低延迟要求”立即发送”。这个矛盾没有完美解法,只能按业务需求选边。


第 8 章总结

架构部分

  1. 架构 = 在约束下做决策。判断标准:能说出”我放弃了什么”。

  2. ★ 造轮子的四个判断:有现成方案吗 → 是核心竞争力吗 → 有能力维护吗 → 成本收益合理吗?

  3. 演进不要跳步:模块化单体 → 按需拆分 → 平台化。

  4. 架构师最重要的判断力:知道什么不该做。

音视频部分

  1. 音频:采样 → 量化 → 编码。Opus 是实时通信首选。

  2. 视频:YUV 4:2:0 + I/P/B 帧 + 运动估计 = 100-500 倍压缩。

  3. 传输:HLS(延迟高、成本低)、HTTP-FLV(折中)、WebRTC(延迟低、成本高)。

  4. 延迟 vs 流畅 vs 成本是这个领域的核心三角。


全书总结

你走过的路

第 1 章  计算机底层        从晶体管到进程,理解机器
第 2 章  数据结构与算法     为存储层次设计数据结构
第 3 章  语言运行时        内存自动管理、代码执行
第 4 章  存储引擎          B+Tree、WAL、MVCC、LSM
第 5 章  网络与分布式      epoll、Raft、一致性
第 6 章  系统与性能        容器、观测、调优
第 7 章  框架设计          IoC、AOP、Web 容器
第 8 章  架构与多媒体      决策框架、音视频系统

贯穿全书的七条法则

#法则出现的地方
1让相关的东西物理上靠近缓存行 → 内存页 → NUMA → B+Tree 节点 → 数据分片
2层次化缓存CPU L1/L2/L3 → TLB → Go mcache → Buffer Pool → CDN
3用约束换性能Arena 分配 → 栈分配 → 不分代 GC → 一致性级别
4把信息提前到编译期静态类型 → 静态链接 → wire → PGO
5操作贵不贵看它破坏了什么上下文切换 → GC STW → 页分裂
6先减少工作量,再优化效率Top-K → 布隆过滤器 → 索引 → 剪枝
7把复杂挪到不常发生的时刻编译期 → 配置期 → 离线重建 → 增量索引

你现在具备的能力

  1. 向下追问的能力——遇到怪问题能追到底层
  2. 识别统一模式的能力——看到不同层次的同构性
  3. 权衡取舍的判断力——能说出”我放弃了什么”
  4. 动手造轮子的能力——内存分配器、编译器、GC、存储引擎、Raft、容器、框架

最后一句话

这份教程给你的不是”知识”,是”判断力”。

知识会过时——Go 的 GC 会改、Linux 的调度器会变、新的编码标准会出现。

但判断力不会:知道什么该做、什么不该做、为什么这么做、以及放弃了什么。

老陈:现在,去找一个真实的问题,用你学到的东西解决它。只有用过,这些才是你的。


全书完 · 约 80 万字 · 覆盖 25 门 S 级课程