HLS低延迟直播(LL-HLS)原理与落地实践
分类:技术与进阶
前阵子想给朋友搞个小范围的直播,就喊了几个哥们一起看比赛。一开始用的 RTMP + Flash 那一套,结果现在浏览器都抛弃 Flash 了,还要装插件特别麻烦。后来切到 HLS,兼容性倒是好了,但延迟直接飙到十几秒,群里的消息跟画面差了半个进球,体验简直炸裂。于是开始研究怎么给 HLS 减肥,就摸到了 LL-HLS(低延迟 HLS)。
说实话,LL-HLS 刚出来的时候我挺不以为然的,感觉苹果又在画饼。但真上手试了之后发现,延迟确实能干到 3-5 秒,跟之前 RTMP 差不多,而且完全基于 HTTP 协议,不用跑奇怪的端口,CDN 也好配合。聊一下我折腾下来的一些理解和踩的坑吧。
LL-HLS 核心思路:把片段再切小
传统 HLS 延迟高是因为数据按 ts 或 m4s 片段组织,一个片段一般 6 秒,加上播放器缓存,延迟轻松 10-15 秒。LL-HLS 的骚操作是把每个片段进一步切成更小的 Partial Segment(分块),比如 0.5 秒一块。m3u8 里多了一个 #EXT-X-PART 标签来标记这些分块,并且头部会提前渲染,让播放器在片段还没完全生成的时候就开始下载小块。
还有一个关键的是 Preload Hint(预加载提示)。服务器会在 m3u8 里加一个特殊的 #EXT-X-PRELOAD-HINT,告诉播放器“那块还没结束的数据我马上就能给你”,播放器收到后就持续用 HTTP 长轮询或者分块传输的方式去拉这块数据,只要服务器新的 chunk 来了,马上就能拿到更新。这就把之前的等待时间压缩到毫秒级。
另外苹果还推荐用 HTTP/2 推送或者说多路复用,不过我自己实测下来,只要分块够小,HTTP/1.1 的长轮询也能跑得动,就是连接数会多一点。
落地实践:从服务端到播放器一路都是坑
原理看着不太复杂,但真上手搭还是有好多地方要照顾。
服务端:我们常用的 Nginx 自带的 nginx-rtmp-module 虽然能切片出 HLS,但对 LL-HLS 的支持很有限(旧版本基本不支持)。我后来换了 Nginx 官方提供的 HLS 模块打了补丁,或者直接用开源方案比如 lal(一款大牛写的 Go 流媒体服务器),它原生支持 LL-HLS。也可以用 ffmpeg 加 -hls_init_time 0.5 和 -hls_playlist_type event 来推流,但 ffmpeg 的 LL-HLS 功能还在完善中,有时候分块时间不对。
分块大小与延迟平衡:Partial Segment 时长设太小(比如 0.2s)会大大增加服务端的计算压力和网络开销,延迟是下来了但卡顿就上去了。我试过 0.5s 是个平衡点,配合预加载提示,整体延迟能控制在 3-4 秒,普通用户几乎感觉不到。这个参数要在服务器端的切片引擎里配好,比如 lal 里设置 fragment_duration = 1000(毫秒),然后 part_duration = 500。
客户端支持:这个最坑。Safari 和 iOS 的 HLS 原生支持 LL-HLS,开箱即用。但其他浏览器就得靠第三方播放器。hls.js 从 1.0 开始加入了 LL-HLS 支持,但版本升级过程中经常有些 bug,比如遇到 #EXT-X-PRELOAD-HINT 会直接不解析。我测试的时候经常用 M3U8Player(https://m3u8player.link/)这个在线播放器,扔个 LL-HLS 的 m3u8 链接进去就能看到延迟表现,也方便给朋友们验证,算是一个偷懒小工具。
还有个坑是时间同步。LL-HLS 很依赖服务器和客户端的时钟同步,因为预加载提示里携带了 SN(序号)和 PART 信息,如果时间偏差太大,播放器会误判哪个 block 是最新的,导致重复请求或者永远追不上进度。我自己的环境下直接用 NTP 锁住服务器时间,客户端反正一般都是浏览器,自然同步问题不大。
缓存的坑:CDN 缓存如果不关掉的话,分块会被缓存,播放器拿到的永远是最旧的那一块,延迟又爆回十几秒。所以我专门搞了一套 API 自动给 m3u8 文件名加随机参数,并在 CDN 上设置不缓存 m3u8 和最近几秒的 ts/m4s 文件。这一点很容易被忽略。
总结(其实也没啥好总结的)
折腾了一圈之后,LL-HLS 确实能让 HLS 的延迟降到可用水平,但部署成本比普通 HLS 高出一截。不过好处是兼容性依旧好,而且现在各大云服务商也都开始支持了。如果你也在搞直播又不想用 WebRTC 那套复杂的信令,LL-HLS 是个值得试的方向。接下来我准备在服务器上搞个自动化脚本,一键生成 LL-HLS 流,这样以后再拉朋友看比赛就不用听他们骂延迟了。