02 · 音视频系统:从采样到流媒体
“为什么直播延迟总是 3-10 秒,而不是 0.3 秒?这不是技术做不到,是权衡的结果。“
开场:一个”简单”的问题
小林:为什么我看直播,主播说话后,我要等好几秒才听到?技术上不能做到实时吗?
老陈:能。视频会议的延迟可以做到 100-200ms。那为什么不把直播做成那样?
小林:……技术难度大?
老陈:不全是。我问你:视频会议 10 个人,直播 100 万人。这两个场景一样吗?
小林:……不一样。
老陈:哪里不一样?
小林:人数差很多。
老陈:对。人数差 10 万倍,架构就完全不同。 我再问:视频会议卡一下,你能接受吗?
小林:……不太能,会很难受。
老陈:直播卡一下呢?
小林:……卡一下也能接受,只要不一直卡。
老陈:这就是核心差异:
视频会议 直播 人数 几个到几十 几万到几百万 延迟要求 < 200ms 1-10 秒可接受 卡顿容忍 低(影响交流) 较高(缓冲可吸收) 架构 P2P / SFU CDN 分发 直播牺牲了延迟,换来了”百万并发”和”抗抖动”。这是权衡,不是技术限制。
第一部分:音频
声音的数字化:三步
连续的声音波形(模拟信号)
↓ ① 采样 (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、APE | MP3、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 码流
常见音频编码对比:
| 编码 | 码率(立体声) | 延迟 | 场景 |
|---|---|---|---|
| MP3 | 128-320 kbps | 高(~100ms) | 音乐存储 |
| AAC-LC | 96-256 kbps | 中(~50ms) | ★ 通用,流媒体 |
| AAC-LD | 64-128 kbps | 低(20ms) | 视频会议 |
| Opus | 6-510 kbps | 极低(5-60ms) | ★ 实时通信首选 |
| Speex | 2-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 / AVC | 2003 | 基准 | 中 | 有(但有免费阈值) |
| H.265 / HEVC | 2013 | +50% | 高 | 复杂(多个专利池) ★ |
| VP9 | 2013 | +50% | 高 | Google,免版税 |
| AV1 | 2018 | +70% | 极高 | 免版税(AOMedia) |
| H.266 / VVC | 2020 | +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 | 开源,基于 Matroska | Web 端 |
| MKV | 功能最全 | 本地存储 |
三种直播协议的对比
| RTMP | HLS | WebRTC | |
|---|---|---|---|
| 延迟 | 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万观众) | 成本 | 兼容性 |
|---|---|---|---|---|
| HLS | 5-20s | ★★★★★ CDN 完美支持 | 低 | ★★★★★ 全平台 |
| HTTP-FLV | 1-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 节的核心:架构是在约束下做决策。
小结:这一节你应该带走的东西
-
音频数字化的三步:采样(Nyquist 定理)、量化(位深)、编码(心理声学模型)。
-
视频用 YUV 而不是 RGB:人眼对亮度敏感、对色度不敏感,可以做色度子采样(4:2:0 省 50%)。
-
视频压缩的核心是消除时间冗余:I/P/B 帧 + 运动估计。这是能做到 100-500 倍压缩率的关键。
-
GOP 的权衡:长 GOP 压缩率高但 seek 慢、错误恢复慢;短 GOP 反之。直播用短 GOP。
-
HLS 延迟大的原因:切片等待 + 播放列表刷新 + 客户端缓冲。这是”流畅优先”的选择。
-
GOP 缓存是直播秒开的关键:新观众加入时先发一个完整 GOP,用一点延迟换”立即出画面”。
-
延迟和流畅度是矛盾:缓冲越大越流畅但延迟越高。直播选流畅,视频会议选延迟。
-
超低延迟的核心矛盾:CDN 的缓存复用要求”数据块化”,而低延迟要求”立即发送”。这个矛盾没有完美解法,只能按业务需求选边。
第 8 章总结
架构部分
-
架构 = 在约束下做决策。判断标准:能说出”我放弃了什么”。
-
★ 造轮子的四个判断:有现成方案吗 → 是核心竞争力吗 → 有能力维护吗 → 成本收益合理吗?
-
演进不要跳步:模块化单体 → 按需拆分 → 平台化。
-
架构师最重要的判断力:知道什么不该做。
音视频部分
-
音频:采样 → 量化 → 编码。Opus 是实时通信首选。
-
视频:YUV 4:2:0 + I/P/B 帧 + 运动估计 = 100-500 倍压缩。
-
传输:HLS(延迟高、成本低)、HTTP-FLV(折中)、WebRTC(延迟低、成本高)。
-
延迟 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 | 把复杂挪到不常发生的时刻 | 编译期 → 配置期 → 离线重建 → 增量索引 |
你现在具备的能力
- 向下追问的能力——遇到怪问题能追到底层
- 识别统一模式的能力——看到不同层次的同构性
- 权衡取舍的判断力——能说出”我放弃了什么”
- 动手造轮子的能力——内存分配器、编译器、GC、存储引擎、Raft、容器、框架
最后一句话
这份教程给你的不是”知识”,是”判断力”。
知识会过时——Go 的 GC 会改、Linux 的调度器会变、新的编码标准会出现。
但判断力不会:知道什么该做、什么不该做、为什么这么做、以及放弃了什么。
老陈:现在,去找一个真实的问题,用你学到的东西解决它。只有用过,这些才是你的。
全书完 · 约 80 万字 · 覆盖 25 门 S 级课程