
凌晨 2:07,我蹲在香港柴湾机房的第 3 排,盯着 Grafana 上那条刺眼的红线——“首帧等待 > 2.5s”。618 大促临近,商品页里嵌的直播窗口在高并发下频繁被客服投诉“开播要等好久”,而我们的目标是把首帧等待压到 < 1s(4G 网络典型场景)。传统 HLS 的 6s 切片和 3 段缓冲注定拉不开差距,于是我们决定在香港的 Ubuntu 服务器上 上 LL‑HLS(Low‑Latency HLS),并把优化做到骨子里:从采集、编码、打包、分发到播放器参数,一环不放过。
这篇文章是我当晚—其实是两晚—的完整实操手记,包含方案选型、硬件参数、OS 与内核调优、Docker 化部署、打包器(packager)与前置网关配置、播放器侧低延迟策略、监控指标、压测方法,以及我们真正踩过的坑和现场的解决方案。文末有数据对比表,你可以把它对着自己的环境改完就上。
目标与约束
目标
- 面向移动端观众(4G/5G + Wi‑Fi 混合),把**首帧等待(First Frame)**控制在 0.8~1.2s 区间;
- 直播带货场景,要求稳定性优于绝对极限延迟;
- 以 LL‑HLS(CMAF fMP4 + #EXT‑X‑PART) 为主,兼容常规 HLS 作为 fallback;
- 尽量利用香港机房的跨境链路优势,降低内地观众首包时延;
- 全链路可观测,可快速回滚。
约束
- 预算内优先使用开源组件;
- 不强依赖定制 CDN 特性(但可对接常规 CDN);
- 运维团队熟悉 Ubuntu(22.04 LTS)。
拓扑与组件选型
架构蓝图(文字版)
Studio/主播端(OBS/SRT/RTMP) → Ingress 网关(SRT/RTMP) → 打包器/Origin(OvenMediaEngine, CMAF LL‑HLS) → 前置 TLS/H2/H3 网关(Caddy) → CDN/直连观众 → 播放器(Safari 原生 / hls.js 低延迟)
为什么这样选:
- OvenMediaEngine(OME) 原生支持 LL‑HLS(苹果标准,非早期的 LHLS),支持 CMAF/fMP4、部分级切片(parts)与 preload‑hint,开源、文档清晰、落地成本低;
- Caddy 作为前置 HTTPS/H2/H3 反向代理,配置简洁,原生 HTTP/3(QUIC)支持,TLS 证书自动续签;
- SRT 作为采集入口更稳;RTMP 作为兼容路径保留;
- Docker‑Compose 管理部署,回滚简单;
- tmpfs 存放 LL‑HLS 短时切片,避免 NVMe 小块写放大。
硬件与网络参数(我们的生产档位)
| 角色 | 机型/CPU | 内存 | 磁盘 | 网卡 | 备注 |
|---|---|---|---|---|---|
| Ingress(SRT/RTMP) | AMD EPYC 7443P(24C) | 64GB | NVMe 1TB | 10GbE x1 | 可与 Origin 合并 |
| Origin/Packager(OME) | Intel Xeon Silver 4314(16C) | 64GB | NVMe 1TB + tmpfs 4GB | 10GbE x1 | LL‑HLS 切片驻内存 |
| Edge/TLS 网关(Caddy) | Intel Xeon Gold 6130(16C) | 32GB | SATA SSD 480GB | 10GbE x1 | 可水平扩展 |
- 上行:香港本地 10G → 运营商混合 BGP;
- 典型并发:2~3 万同时在线观看,每路 ABR 3~4 档。
Ubuntu 基础与内核调优
适配 Ubuntu 22.04 LTS(5.15+ 内核)。
1)系统与时区
sudo timedatectl set-timezone Asia/Hong_Kong
sudo apt update && sudo apt -y upgrade
sudo apt -y install jq git curl htop iftop net-tools ethtool chrony
2)文件描述符与进程限制
cat <<'EOF' | sudo tee /etc/security/limits.d/streaming.conf
* soft nofile 1048576
* hard nofile 1048576
* soft nproc 65535
* hard nproc 65535
EOF
在 /etc/systemd/system.conf 与 /etc/systemd/user.conf 中设置:
DefaultLimitNOFILE=1048576
DefaultLimitNPROC=65535
3)网络栈(BBR + 队列 + 缓冲)
cat <<'EOF' | sudo tee /etc/sysctl.d/99-llhls.conf
net.core.somaxconn=4096
net.core.netdev_max_backlog=250000
net.ipv4.tcp_max_syn_backlog=8192
net.ipv4.tcp_fin_timeout=15
net.ipv4.tcp_tw_reuse=1
net.ipv4.tcp_syncookies=1
net.ipv4.ip_local_port_range=1024 65535
net.ipv4.tcp_rmem=4096 1048576 8388608
net.ipv4.tcp_wmem=4096 1048576 8388608
net.core.rmem_max=268435456
net.core.wmem_max=268435456
net.ipv4.tcp_congestion_control=bbr
net.ipv4.tcp_ecn=1
net.ipv4.tcp_mtu_probing=1
EOF
sudo sysctl --system
4)LL‑HLS 切片驻内存(tmpfs)
sudo mkdir -p /data/llhls
echo 'tmpfs /data/llhls tmpfs rw,size=4g,mode=0755,uid=www-data,gid=www-data 0 0' | sudo tee -a /etc/fstab
sudo mount -a && df -h | grep /data/llhls
Docker 化部署:OME + Caddy(HTTP/2 / HTTP/3)
1)目录结构
/opt/llhls/
├─ docker-compose.yml
├─ ome/
│ ├─ Server.xml
│ └─ VHost.xml
└─ caddy/
└─ Caddyfile
2)docker-compose.yml
version: "3.8"
services:
ome:
image: airensoft/ovenmediaengine:latest
container_name: ome
network_mode: host
volumes:
- ./ome/Server.xml:/opt/ovenmediaengine/bin/origin_conf/Server.xml:ro
- ./ome/VHost.xml:/opt/ovenmediaengine/bin/origin_conf/VHost.xml:ro
- /data/llhls:/data/llhls
restart: always
caddy:
image: caddy:2
container_name: caddy
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC)
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
depends_on:
- ome
restart: always
说明:network_mode: host 让 OME 无需额外端口映射即可暴露 SRT/RTMP/HTTP 端口;Caddy 仅负责 80/443 与 H2/H3 终止与转发。
3)OME 的 Server.xml(核心开关)
OME 的配置项在不同版本名字可能略有差异,以下为我们稳定可用的一套。聚焦:LL‑HLS 打开、CMAF、segment/part 时长、preload‑hint 与 hold‑back。
<?xml version="1.0" encoding="UTF-8"?>
<Server>
<Bind>
<Publishers>
<Rtmp>
<Port>1935</Port>
</Rtmp>
<Srt>
<Port>10080</Port>
<Latency>80</Latency> <!-- ms -->
</Srt>
</Publishers>
<Origin>
<Http>
<Port>8080</Port>
<WorkerCount>8</WorkerCount>
<KeepAliveTimeout>15</KeepAliveTimeout>
<ChunkedTransfer>true</ChunkedTransfer>
</Http>
</Origin>
</Bind>
</Server>
4)OME 的 VHost.xml(应用级配置 + ABR)
<?xml version="1.0" encoding="UTF-8"?>
<VirtualHosts>
<VirtualHost>
<Name>default_vhost</Name>
<Applications>
<Application>
<Name>live</Name>
<Type>live</Type>
<Sources>
<Rtmp/>
<Srt/>
</Sources>
<Outputs>
<StreamProfiles>
<!-- 1080p 主档 -->
<StreamProfile>
<Name>1080p</Name>
<Video>
<Codec>h264</Codec>
<Width>1920</Width>
<Height>1080</Height>
<Framerate>30</Framerate>
<Bitrate>4500000</Bitrate>
<Gop>60</Gop> <!-- 2s @30fps -->
<Preset>veryfast</Preset>
<Tune>zerolatency</Tune>
</Video>
<Audio>
<Codec>aac</Codec>
<Bitrate>128000</Bitrate>
<Samplerate>48000</Samplerate>
<Channels>2</Channels>
</Audio>
</StreamProfile>
<!-- 720p 次档 -->
<StreamProfile>
<Name>720p</Name>
<Video>
<Codec>h264</Codec>
<Width>1280</Width>
<Height>720</Height>
<Framerate>30</Framerate>
<Bitrate>2500000</Bitrate>
<Gop>60</Gop>
<Preset>veryfast</Preset>
<Tune>zerolatency</Tune>
</Video>
<Audio>
<Codec>aac</Codec>
<Bitrate>96000</Bitrate>
</Audio>
</StreamProfile>
<!-- 480p 保障档 -->
<StreamProfile>
<Name>480p</Name>
<Video>
<Codec>h264</Codec>
<Width>854</Width>
<Height>480</Height>
<Framerate>30</Framerate>
<Bitrate>1200000</Bitrate>
<Gop>60</Gop>
<Preset>veryfast</Preset>
<Tune>zerolatency</Tune>
</Video>
<Audio>
<Codec>aac</Codec>
<Bitrate>64000</Bitrate>
</Audio>
</StreamProfile>
</StreamProfiles>
<Hls>
<LowLatency>true</LowLatency>
<Cmaf>true</Cmaf>
<SegmentDuration>2</SegmentDuration> <!-- 秒 -->
<PartDuration>0.333</PartDuration> <!-- 秒,约 3 个 parts / s -->
<PartHoldBack>1.0</PartHoldBack> <!-- 建议 3 * PartDuration -->
<PlaylistHoldBack>3.0</PlaylistHoldBack> <!-- Safari 容差更稳 -->
<PreloadHint>true</PreloadHint>
<DeleteThreshold>8</DeleteThreshold> <!-- 保留最近 8 段 -->
<StorageRoot>/data/llhls</StorageRoot>
<Path>/live</Path>
<MasterPlaylistVariant>true</MasterPlaylistVariant>
<UseBlockingPlaylist>true</UseBlockingPlaylist>
</Hls>
</Outputs>
</Application>
</Applications>
</VirtualHost>
</VirtualHosts>
关键点:GOP=2s 与 SegmentDuration=2s 对齐,PartDuration≈0.333s,从而每段约 6 个 parts;打开 PreloadHint 与 UseBlockingPlaylist,让客户端能在播放列表阻塞等待新 part。
5)Caddyfile(HTTPS / HTTP/2 / HTTP/3 反代 OME)
live.example.com {
encode zstd gzip
@llhls path /live/*
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Access-Control-Allow-Origin *
Access-Control-Allow-Headers *
Access-Control-Allow-Methods GET,HEAD,OPTIONS
}
# 反向代理到 OME Origin(8080)
reverse_proxy @llhls 127.0.0.1:8080 {
transport http {
versions h2 h2c 1.1
read_buffer 64MB
keepalive 15s
dial_timeout 2s
read_timeout 30s # 足够容纳阻塞 playlist 的长轮询
write_timeout 30s
}
header_up X-Forwarded-Proto {scheme}
}
# 其他静态资源
file_server
}
启动:
cd /opt/llhls && sudo docker compose up -d
sudo docker ps
采集与推流(OBS / FFmpeg / SRT)
OBS 关键参数
- 输出:x264,keyint = 60(30fps 时等于 2s),bframes=0,tune=zerolatency;
- 码率控制:CBR,1080p 4.5Mbps / 720p 2.5Mbps / 480p 1.2Mbps;
- 音频:AAC 48kHz 128kbps;
- 推流协议:优先 SRT(Listener 模式),退化走 RTMP。
FFmpeg 推流示例(SRT)
ffmpeg -re -i input.mp4 \
-c:v libx264 -preset veryfast -tune zerolatency -g 60 -keyint_min 60 -sc_threshold 0 -b:v 4500k -maxrate 4800k -bufsize 9000k \
-c:a aac -b:a 128k -ar 48000 -ac 2 \
-f mpegts "srt://your.omo.host:10080?mode=caller&latency=80&pkt_size=1316&streamid=#!::r=live/stream1"
说明:streamid 的 r=live/stream1 对应 OME Application/Path;latency=80ms 可按网络质量上调到 120~160ms。
RTMP 备份推流
ffmpeg -re -i input.mp4 \
-c:v libx264 -preset veryfast -tune zerolatency -g 60 -keyint_min 60 -sc_threshold 0 -b:v 4500k -maxrate 4800k -bufsize 9000k \
-c:a aac -b:a 128k -ar 48000 -ac 2 \
-f flv rtmp://your.ome.host:1935/live/stream1
播放器端:Safari 与 hls.js 的低延迟参数
1)Safari(iOS/macOS)
原生支持 LL‑HLS。只要播放列表正确包含 #EXT-X-PART、#EXT-X-PRELOAD-HINT 且 HoldBack 合理,Safari 会拉低时移窗口。测试地址:
https://live.example.com/live/stream1/playlist.m3u8
2)hls.js(Chromium/Android)
<video id="v" playsinline muted autoplay controls></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
(async () => {
const url = 'https://live.example.com/live/stream1/playlist.m3u8';
if (Hls.isSupported()) {
const hls = new Hls({
lowLatencyMode: true,
backBufferLength: 90,
liveSyncDuration: 1.0, // 目标与直播边缘的距离(秒)
liveMaxLatencyDuration: 2.5,
fragLoadPolicy: { default: { maxTimeToFirstByteMs: 1200 } }
});
hls.loadSource(url);
hls.attachMedia(document.getElementById('v'));
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.audioTrack = 0);
} else if (document.getElementById('v').canPlayType('application/vnd.apple.mpegurl')) {
document.getElementById('v').src = url;
}
})();
</script>
我们的关键参数表(可直接复用)
| 模块 | 参数 | 值 | 说明 |
| 编码 | GOP | 60(2s@30fps) | 与 Segment 对齐,避免拆片跨 I‑frame |
| 编码 | B‑frames | 0 | 降低解码缓冲不确定性 |
| 打包 | SegmentDuration | 2s | 低延迟常用档 |
| 打包 | PartDuration | 0.333s | 约 3 parts/s(也可 0.2~0.5) |
| 打包 | PartHoldBack | 1.0s | Safari 更稳;过小易卡 |
| 打包 | PlaylistHoldBack | 3.0s | 控制播放点距离 live edge |
| 网络 | BBR | 开启 | 长肥管道拥塞恢复快 |
| 代理 | H2/H3 | 开启 | 首包与队头阻塞优化 |
| 存储 | 切片目录 | tmpfs | 避免小片频繁写 NVMe |
端到端验证与观测
1)播放列表应包含的关键行
#EXTM3U
#EXT-X-VERSION:9
#EXT-X-TARGETDURATION:2
#EXT-X-PART-INF:PART-TARGET=0.333
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.0
#EXT-X-MAP:URI="init.mp4"
#EXTINF:2.000,
seg-001.m4s
#EXT-X-PART:DURATION=0.333,URI="seg-002.part0.m4s"
#EXT-X-PART:DURATION=0.333,URI="seg-002.part1.m4s"
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="seg-002.part2.m4s"
2)首帧时间测法(简易)
- 计时从点击播放到 video.playing 事件;
- Android/Chromium 用 hls.js 的 Hls.Events.FRAG_CHANGED 对齐;
- 重复 50 次,去极值取中位数。
3)我们在线上采到的对比数据
| 场景 | Segment/Part | 首帧 P50 | 首帧 P95 | 注释 |
| 传统 HLS | 6s / 无 | 3.8s | 6.2s | 三段缓冲,移动网抖动明显 |
| LL‑HLS v1 | 2s / 0.5s | 1.2s | 2.0s | 适配快,稳态 OK |
| LL‑HLS v2 | 2s / 0.333s | 0.9s | 1.6s | 上线版本 |
| LL‑HLS v2 + H3 | 2s / 0.333s | 0.85s | 1.5s | HTTP/3 有小幅收益 |
常见坑与现场修复
OBS 默认 keyint 太大:很多主播端保留 250(约 8.3s@30fps),导致 GOP 与 2s Segment 不对齐,首帧抖到 2~4s。
修复:强制 keyint=60、sc_threshold=0,并在 OME 侧拒绝不合规流(或者动态转码)。
CDN 对分块响应不友好:某些 CDN/代理会缓存或切断分块传输,坏掉 CAN-BLOCK-RELOAD 的阻塞长轮询。
修复:对 LL‑HLS 路径 绕过 CDN 缓存 或设 Cache-Control: no-transform;必要时直连 Caddy。
PartHoldBack 取值过小:极端网络下播放点紧贴 live edge,频繁 rebuffer。
修复:把 PART-HOLD-BACK 拉到 3*PartDuration 以上(如 1.0s),PlaylistHoldBack 适度增大。
NVMe 写放大:LL‑HLS 小文件多,NVMe 垃圾回收频繁,抖延迟。
修复:切片目录放 tmpfs,异步落盘归档或仅保留最近 N 段。
HTTP/2 代理超时:阻塞 playlist 的请求被默认 5~10s 超时切断,客户端误判卡顿。
修复:前置代理(Caddy/Nginx)提高 read_timeout 至 ≥30s。
安卓硬解码差异:部分老机型对 fMP4 初始化段敏感,首帧比 iOS 慢。
修复:hls.js 开 lowLatencyMode,ABR 初始选低一档,首帧后再上行。
跨境 DNS 调度:内地用户被调度到海外 PoP,首包慢。
修复:香港与内地分别做 Dual‑stack 与 GeoDNS,必要时配 CN 友好 CDN。
压测与回归
h2load:模拟并发拉流
h2load -n 10000 -c 200 -m 100 \
https://live.example.com/live/stream1/playlist.m3u8
观察 2xx 率、TTFB 分布;同时配合 iftop 与 Caddy access log。
curl 观察阻塞刷新
curl -I https://live.example.com/live/stream1/playlist.m3u8
# 反复请求,确认 CDN 未缓存,且响应时间随 part 产出波动(长轮询特征)
Prometheus 指标(建议埋点)
- OME:每路流的 segment_build_ms、part_build_ms、playlist_block_ms;
- Caddy:H2/H3 连接数与请求时长直方;
- 业务:首帧 ttff_ms、卡顿率、切清晰度次数。
ABR 梯度与带宽预算(直播带货推荐)
| 档位 | 分辨率 | 码率 | 峰值 | 主用场景 |
| 1080p | 1920×1080@30 | 4.5 Mbps | 4.8 Mbps | 大屏/良好 Wi‑Fi |
| 720p | 1280×720@30 | 2.5 Mbps | 2.8 Mbps | 主流 4G |
| 480p | 854×480@30 | 1.2 Mbps | 1.4 Mbps | 边缘网络 |
- 音频统一 64~128kbps;
- hls.js 初始锁定 480p,首帧后 2s 内根据缓冲 & RTT 再升级。
回滚与灰度
- docker compose -f docker-compose.yml up -d 可版本化;
- 对 live.example.com/live/* 先按 UA 灰度(部分 App 内核先吃),逐步扩大;
- 发现端到端首帧异常:一键切回传统 HLS(OME LowLatency=false),保留观众体验。
凌晨 3:41,红线终于从 2.xs 坠回 0.9s,我和同事在机房通道里对了个拳。直播间的弹幕刷起来更快了,“上车!”“补货!”像瀑布一样。那一刻我明白,低延迟不是某个参数的魔法,而是一条链路上无数小细节的诚实协作——合适的 GOP、稳定的打包、不过度的缓存、刚刚好的 hold‑back,以及每一次超时配置的秒表。希望这份手记能让你少踩几个坑,把那条红线也按下去。
附:排障速查表
| 症状 | 可能原因 | 快速检查 | 解决方案 |
| 首帧 > 2s | GOP 与 Segment 不对齐 | mediainfo 看 gop |
统一 gop=60 与 Segment=2s |
| 播放卡顿 | HoldBack 太小 | 看 M3U8 的 SERVER-CONTROL |
提高 PART-HOLD-BACK 到 1.0s |
| iOS 正常/安卓慢 | hls.js 未低延迟 | 控制台看配置 | lowLatencyMode: true + 初始低档 |
| CDN 味道不对 | 缓存了分块 | curl -I 看缓存头 |
绕过缓存或 no-transform |
| NVMe 抖动 | 小文件写放大 | iostat -x 1 |
切片放 tmpfs |
| 断流重连慢 | 代理超时 | 代理日志 408/499 | 增大 read_timeout,开启 H2/H3 |
有问题欢迎按本文结构逐段核对;大部分问题都能在“参数表 + 速查表”里找到。