平時看直播大家一般比較關心視頻質量和直播延遲,可能在有些場景對直播延遲要求沒那么高,但是在有互動的直播中直播延遲是非常重要的,不然每次互動都有幾十秒延遲,這是不能接受的。
上圖中展示了不同直播方案的延遲,可以發現延遲最低的是 WebRTC,不過 WebRTC 一般不能支持大規模用戶同時觀看直播。這篇文章將介紹圖中的 Low-Latency HLS 直播方案,可以看到它是在 3 秒左右延遲的位置。
HLS
HLS 是蘋果公司在 2009 年提出的基于 HTTP 的流媒體傳輸協議。
在最開始蘋果推薦一個 HLS 視頻片段時長是 10 秒(現在推薦 6 秒),在倒數第三個視頻片段開始播放,如果按照這個推薦配置,用 HLS 開直播的延遲將在 30 秒往上,也就是上方延遲圖中的最高延遲位置。
要想降低延遲,一個非常簡單的方法就是直接縮短一個視頻片段的時長,比如將一個視頻片段縮短成 3 秒,使用這中非常短的視頻片段,直播延遲將可以降低到 10 秒左右。
當然 10 秒左右延遲還是挺高的,于是就有人想出了一個社區低延遲方案,它被稱為 LHLS。再后來蘋果推出了官方的低延遲解決方案,它被稱為 LLHLS。下面將詳細介紹這兩種方案。
LHLS
LHLS 也被稱為 CL-HLS,它并不是標準規范,而是社區驅動的 HLS 低延遲方案,最早是由 Periscope 團隊在 2017 年發布一篇博客 Introducing LHLS Media Streaming 提出這個概念。后面由 hls.js 與一些流媒體廠商一起合作,規范這一方案 Low-latency HLS Streaming 。
實現原理
LHLS 是怎么實現低延遲直播的呢?大家可以看下面這張圖,其中一個視頻片段是 8 秒。
現在一共生成了 3 個視頻片段,第 4 個視頻片段已生成 3 秒,由于一個視頻片段只有完全生成才能被下載。所以我們有下面這幾種不同的方法來播放這個播放列表。
- 最簡單的方法當然是從第 1 個分片開始播放,這樣延遲是 27 秒(3 * 8 + 3)。
- 第二種方案是我從最后一個分片開始播放,這樣延遲是 11 秒。
- 或者我們等待 5 秒,讓第 4 個分片生成再播放,這樣延遲是 8 秒。
可以發現上面這 3 個方案延遲都挺高,第三個方案延遲稍微低一點但是起播延遲卻太高了。
LHLS 方案是將一個視頻片段細分成一個個很小的 Chunk,無需等待一整個視頻片段生成,每生成一個 Chunk 它就會被下載到播放器緩存起來。上圖中最后一種方法就是將一個分片分成一個個 1 秒小 Chunk,這樣我們就得到了 3 秒延遲的直播。
具體到實際實現中 LHLS 是使用 HTTP/1.1 的 Chunked transfer encoding 功能,播放器會保持與服務器的連接,每當服務器生成一個 Chunk 就會直接傳遞給播放器,直到一個視頻片段全部傳輸完畢才會斷開連接。另外 HTTP 的這個功能大部分 CDN 都支持。
社區方案的一個主要問題是它不好做 ABR 自適應碼率切換,因為與服務器的連接是長連接,客戶端不好估算出當前用戶的網絡帶寬,為了解決這個問題一般會用一個測試文件去測試當前網速。
規范詳情
社區規范中一共引入兩個自定義標簽 EXT-X-PREFETCH 和
EXT-X-PREFETCH-DISCONTINUITY。 EXT-X-PREFETCH-DISCONTINUITY 和 EXT-X-DISCONTINUITY 功能一樣,只不過 EXT-X-PREFETCH 上方不能放置 EXT-X-DISCONTINUITY,要把它變成 EXT-X-PREFETCH-DISCONTINUITY。
該規范完全兼容 HLS 標準規范,對于支持這一規范的播放器可以選擇使用它們來低延遲直播,對于不支持播放器會忽略這些標簽,變成高延遲直播。
下面是一個 LHLS M3U8 文件例子。
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:2
#EXT-X-PROGRAM-DATE-TIME:2018-09-05T20:59:08.531Z
#EXTINF:2.000
https://foo.com/bar/1.ts
#EXT-X-PROGRAM-DATE-TIME:2018-09-05T21:59:10.531Z
#EXTINF:2.000
https://foo.com/bar/5.ts
#EXT-X-PREFETCH:https://foo.com/bar/6.ts
#EXT-X-PREFETCH:https://foo.com/bar/7.ts
我們可以發現除了最后兩行,這和普通 M3U8 文件沒有任何區別。一個 LHLS M3U8 最少包含一個并且不超過兩個 EXT-X-PREFETCH 標簽。EXT-X-PREFETCH 標簽后面跟隨著一個 URL,它是還沒有生成的分片。
支持 LHLS 的播放器會直接發送兩個 HTTP 請求,去請求 6.ts 和 7.ts,服務器會維持這兩個請求,并不斷發送 Chunk。
可能有同學要問,如果 6.ts 連接還沒斷開,但是 7.ts 連接收到數據了怎么辦?這時候播放器就要內部保持這些數據,直到前一個請求完成。
Twitch 的直播延遲
那么誰在使用 LHLS 低延遲方案呢?上面提到的最早提出這個方案的 Periscope 團隊,它們后面被 Twitter 收購,再然后就被關停了。
不過國外非常出名的直播平臺 Twitch 正在使用該方案。它并沒有按照社區規范來實現,而是加入了一些自定義的東西,比如它把 EXT-X-PREFETCH 換成了 EXT-X-TWITCH-PREFETCH,而且 EXT-X-DISCONTINUITY 可以直接應用在 EXT-X-TWITCH-PREFETCH 上。
那么 Twitch 的直播延遲是多少呢?我決定自己去開個直播間試試。
然而在第一步注冊賬號就卡住了。
通過 OBS 推流后,進入自己的直播間,可以看到我推的 1080P 直播,延遲是 5 秒左右。
LL-HLS
LL-HLS 表示是蘋果官方版的低延遲方案,它也被稱為 ALHLS。在 2019 年 WWDC 上蘋果介紹了他們官方的 HLS 低延遲解決方案,蘋果發布的低延遲方案并沒有借鑒社區低延遲方案的成果,而是重新設計了一套低延遲方案。蘋果的目標是 1 到 2 秒低延遲,支持大規模用戶的直播,并且可以完全向下兼容。
看了蘋果的方案后,大家情緒都不穩定了,因為蘋果的方案中提到需要 HTTP2 的 push 功能,但是這個功能大部分的 CDN 都沒有實現,并且這個功能和傳統方案有很大的差別,實現起來也非常頭疼。到最后蘋果終于決定將 HTTP2 push 功能移出了規范,加入 EXT-X-PRELOAD-HINT 標簽代替該功能。
LLHLS 方案相比 LHLS 復雜度大大的提高,LLHLS 中一共加入了 5 大修改,分別是 Partial Segment、請求長連接、增量更新、預加載和快速碼率切換,下面將詳細介紹這些功能。
另外由于蘋果推出了官方 HLS 低延規規范,于是社區立馬拋棄了社區規范,hls.js 也刪除了相關代碼,去實現 LLHLS 規范。
Partial Media Segment
LLHLS 將一個視頻片段再細分稱為小分段,一個視頻片段由多個小分段組成。原先需要等待一個視頻片段完全被生成才能下載,比如一個片段是 6 秒種,客戶端就需要等待 6 秒這個分片被生成才能下載它。
現在服務端將一個片段分成多個小分段,比如一個小分段是 200 毫秒,那么一個視頻片段包含 30 個小分段,客戶端只需等待 200 毫秒就可以一個個下載這些小分段。
可以發現這種方式和社區方案非常相似,社區方案是將一個視頻分段分成一個個小 Chunk,通過 HTTP/1.1 的 Chunked transfer encoding 功能下載到客戶端。而 LLHLS 是將一個視頻片段分成一個個小分段,通過普通 HTTP 請求去下載這些小分段。
與小分段相關的標簽有 EXT-X-PART-INF 和 EXT-X-PART 兩個標簽。
EXT-X-PART-INF
EXT-X-PART-INF 提供了播放列表中小分段的信息,如果播放列表中存在 EXT-X-PART 標簽,那么必須提供這個標簽。
這個標簽只有一個必傳屬性 PART-TARGET,它的值是浮點數,單位是秒。和 EXT-X-TARGETDURATION 標簽類型,這個屬性表示的是小分段的目標時長。
EXT-X-PART
EXT-X-PART 標簽與 EXTINF 相似,它是用來聲明一個小分段,它一共有 5 個屬性。
- URI 小分段的資源鏈接。
- DURATION 小分段時長。
- INDEPENDENT 如果小分段中包含關鍵幀,可以將這個字段設置為 YES。
- BYTERANGE 如果要使用 HTTP Range 請求,可以使用該屬性,它的值與 EXT-X-BYTERANGE 標簽一樣。
- GAP 如果這個小分段不可使用,可以將這個屬性設置為 YES。
需要注意,如果該標簽包含了 GAP=YES 屬性,那么客戶端就不應該去請求這個資源,客戶端需要自己解決如何跳過這個 gap,蘋果播放器的做法是延長上一幀的播放時長。
下面是一個完整 LLHLS 播放列表的例子。
#EXTM3U
#EXT-X-TARGETDURATION:4
#EXT-X-VERSION:6
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.0,CAN-SKIP-UNTIL=12.0
#EXT-X-PART-INF:PART-TARGET=0.33334
#EXT-X-MEDIA-SEQUENCE:266
#EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:13:36.106Z
#EXT-X-MAP:URI="init.mp4"
#EXTINF:4.00008,
fileSequence266.mp4
#EXTINF:4.00008,
fileSequence267.mp4
#EXTINF:4.00008,
fileSequence268.mp4
#EXTINF:4.00008,
fileSequence269.mp4
#EXTINF:4.00008,
fileSequence270.mp4
#EXT-X-PART:DURATION=0.33334,URI="filePart271.0.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart271.1.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart271.2.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart271.3.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart271.4.mp4",INDEPENDENT=YES
#EXT-X-PART:DURATION=0.33334,URI="filePart271.5.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart271.6.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart271.7.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart271.8.mp4",INDEPENDENT=YES
#EXT-X-PART:DURATION=0.33334,URI="filePart271.9.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart271.10.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart271.11.mp4"
#EXTINF:4.00008,
fileSequence271.mp4
#EXT-X-PROGRAM-DATE-TIME:2019-02-14T02:14:00.106Z
#EXT-X-PART:DURATION=0.33334,URI="filePart272.a.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart272.b.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart272.c.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart272.d.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart272.e.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart272.f.mp4",INDEPENDENT=YES
#EXT-X-PART:DURATION=0.33334,URI="filePart272.g.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart272.h.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart272.i.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart272.j.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart272.k.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart272.l.mp4"
#EXTINF:4.00008,
fileSequence272.mp4
#EXT-X-PART:DURATION=0.33334,URI="filePart273.0.mp4",INDEPENDENT=YES
#EXT-X-PART:DURATION=0.33334,URI="filePart273.1.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart273.2.mp4"
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart273.3.mp4"
#EXT-X-RENDITION-REPORT:URI="../1M/waitForMSN.php",LAST-MSN=273,LAST-PART=2
#EXT-X-RENDITION-REPORT:URI="../4M/waitForMSN.php",LAST-MSN=273,LAST-PART=1
可以發現 LLHLS 播放列表中有非常多的 Part 小分段,為了防止生成太多的小分段,服務端將會定期清理老的小分段。
條件請求
之前請求 HLS 播放列表都是客戶端發起一個普通的 HTTP GET 請求,然后服務器返回一個 m3u8 文件。
現在 LLHLS 允許在請求播放列表時添加查詢條件。服務器是否支持這些功能,是通過 EXT-X-SERVER-CONTROL 標簽設置,該標簽后面跟著一個屬性列表,來指明服務器支持哪些條件查詢。
目前 LLHLS 一共支持了 3 個查詢參數,分別是 _HLS_msn 、 _HLS_part 和 _HLS_skip。通過它們可以實現不同的功能,具體參數含義將在下方詳細介紹。
請求長連接
在 HLS 直播中,我們需要頻繁的去請求播放列表文件去查看是否有新的視頻片段被添加,這樣非常的浪費時間和資源。在 LLHLS 中服務器可以保持這個連接不斷開,直到客戶端需要的片段被生成才完成請求。
服務器支持這一功能,需要 EXT-X-SERVER-CONTROL 標簽中的 CAN-BLOCK-RELOAD 屬性為 YES。
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES
要告訴服務器何時才完成請求,需要用到 _HLS_msn 和 _HLS_part 兩個查詢條件。如果只需要服務器在生成下一個視頻片段時才完成請求可以發送下面這個請求。
https://llhls.com/playlist.m3u8?_HLS_msn={下一個片段的 Media Sequence Number}
_HLS_msn 用來控制服務器播放列表包含了指定片段或指定片段之后的片段才返回請求,_HLS_part 控制服務器播放列表包含了指定片段的哪個小分段才返回請求,小分段的下標是從 0 開始,比如一個視頻片段是 6 秒,一個小分段是 1 秒,那么這個視頻片段一共由下標 0 到 5 的小分段組成。
https://llhls.com/playlist.m3u8?_HLS_msn={下一個片段的 Media Sequence Number}&_HLS_part={小分段下標}
需要注意 _HLS_msn 可以單獨使用, _HLS_part 必須和 _HLS_msn 一起使用,否則服務器將會返回 400 錯誤。當 _HLS_msn 超過最新生成片段太多服務器也會返回 400 錯誤。
如果播放列表包含 EXT-X-ENDLIST,服務器將會忽略 _HLS_part 和 _HLS_msn 兩個參數。
播放列表增量更新
在 HLS 直播中,我們每次刷新播放列表都會包含一些我們已經知道的老片段信息。比如第一次請求返回 0、1 和 2 這三個片段信息,第二次刷新返回 1、2 和 3 這新的片段信息,可以發現 1 和 2 我們是知道的,其實無需再包含在播放列表中。
LLHLS 提供了播放列表增量更新功能,我們可以告訴服務器可以跳過哪些片段,不用將它包含在播放列表中,從而減少傳輸損耗。
要支持增量更新功能,需要 EXT-X-SERVER-CONTROL 標簽中包含 CAN-SKIP-UNTIL 屬性。還可以包含必須與 CAN-SKIP-UNTIL 一起使用的 CAN-SKIP-DATERANGES 屬性,它表示是否可以跳過老的 EXT-X-DATERANGE 標簽。
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=12.0,CAN-SKIP-DATERANGES=YES
CAN-SKIP-UNTIL 屬性的值是十進制浮點數,單位是秒,這個值至少是目標時長的 6 倍。它表示跳過分段的邊界。
要發起一個播放列表增量更新請求,需要包含 _HLS_skip 查詢參數。
https://llhls.com/playlist.m3u8?_HLS_skip={YES或v2}
_HLS_skip 的值是 YES 或 v2。YES 表示跳過老的片段。v2 表示跳過老的片段和老的 EXT-X-DATERANGE 標簽(需要服務器返回 CAN-SKIP-DATERANGES=YES)。
需要注意當客戶端沒有一個完整的播放列表或當前播放列表太久沒更新超過一半的可跳過邊界時應該使用全量查詢而不是增量查詢。
當一個播放列表是增量更新時,播放列表中會包含一個 EXT-X-SKIP 標簽,這個標簽只有兩個屬性, SKIPPED-SEGMENTS 表示跳過視頻片段數量和
RECENTLY-REMOVED-DATERANGES 表示跳過了哪些 DATERANGE id。
下面是一個增量更新的播放列表例子。
#EXTM3U
#EXT-X-TARGETDURATION:4
#EXT-X-VERSION:9
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.0,CAN-SKIP-UNTIL=12.0
#EXT-X-PART-INF:PART-TARGET=0.33334
#EXT-X-MEDIA-SEQUENCE:266
#EXT-X-SKIP:SKIPPED-SEGMENTS=3
#EXTINF:4.00008,
fileSequence269.mp4
#EXTINF:4.00008,
fileSequence270.mp4
#EXTINF:4.00008,
fileSequence271.mp4
#EXTINF:4.00008,
fileSequence272.mp4
#EXT-X-PART:DURATION=0.33334,URI="filePart273.0.mp4",INDEPENDENT=YES
#EXT-X-PART:DURATION=0.33334,URI="filePart273.1.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart273.2.mp4"
#EXT-X-PART:DURATION=0.33334,URI="filePart273.3.mp4"
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="filePart273.4.mp4"
#EXT-X-RENDITION-REPORT:URI="../1M/waitForMSN.php",LAST-MSN=273,LAST-PART=3
#EXT-X-RENDITION-REPORT:URI="../4M/waitForMSN.php",LAST-MSN=273,LAST-PART=3
可以發現上面這個例子中跳過了 3 個視頻片段,跳過的視頻片段的 msn 分別是 266、267 和 268。
片段預加載
LLHLS 中還有視頻片段預加載功能,它表示一個視頻片段還沒被創建,但是客戶端去請求它。這個功能與社區方案的 EXT-X-PREFETCH 非常相似。
與該功能相關的標簽是 EXT-X-PRELOAD-HINT,它后面跟一個屬性列表,一共有 4 個屬性。
- TYPE 屬性有兩個值,PART 表示是小分段,MAP 表示是媒體初始部分(與 EXT-X-MAP 相似)。
- URI 資源的 url。
- BYTERANGE-START 如果是一個資源的一部分,這個屬性用來指定開始部分。
- BYTERANGE-LENGTH 這個表示資源的字節長度,與 BYTERANGE-START 配合使用。
當客戶端碰到這個標簽時,可以選擇是否直接請求這個資源,服務器會和上面請求長連接中一樣維持這個請求,直到整個資源數據可用時才返回資源。當然也有可能直接返回 404。
快速碼率切換
使用 HLS 的一個優勢是可以自適應碼率切換,根據當前網速、屏幕大小等信息選擇最適合用戶的當前環境碼率的流。在 LLHLS 中蘋果提供了一種可以快速切換碼率的功能。
服務器通過 EXT-X-RENDITION-REPORT 標簽,將主播放列表中與當前流相關的其他碼率的流條件到當前 Media 類型的播放列表中,這個標簽一共有 3 個屬性。
- URI 與當前流相關的其他碼率流的鏈接。
- LAST-MSN 這個流中最后一個視頻片段的視頻編號。
- LAST-PART 這個流中最后一個小分段的下標。
每個視頻流的 LAST-MSN 和 LAST-PART 可能不一樣,EXT-X-RENDITION-REPORT 標簽提供了這些信息,我們就不用去請求那些比較落后的流,這樣可以減少很多不必要的請求。
直播從哪兒開始播放
客戶端面對一個播放列表,應該從哪兒開始播放呢?離主播位置越遠延遲就越高,離主播當前位置越近 Buffer 有太少,容易引起播放卡頓。
上面介紹的 EXT-X-SERVER-CONTROL 標簽可以解決這個問題,這個標簽還有兩個屬性 HOLD-BACK 和 PART-HOLD-BACK,這兩個屬性是服務器推薦的直播開始位置。
- HOLD-BACK 的值是一個浮點數秒數,代表服務器推薦的離播放列表末尾最小距離,它應該最小是 3 個視頻片段目標時長。
- PART-HOLD-BACK 的值是一個浮點數秒數,代表服務器推薦的離播放列表末尾最小距離,它最小是 2 倍的 Part 小分段的目標時長。推薦是 Part 目標時長的 3 倍。
當存在 PART-HOLD-BACK 屬性時,客戶端應該忽略 HOLD-BACK 屬性。如果播放列表包含 EXT-X-PART-INF 標簽,則必須要有 PART-HOLD-BACK 屬性。
如何獲取最新播放列表 CDN Tune-in
CDN 一般會有緩存,那么如何獲取最新版本的播放列表呢?蘋果給出了一個解決方案。
- 首先發送一個不包含 _HLS_msn 和 _HLS_part 查詢參數的請求。
- 記錄這次請求的接收時間和 Age 響應頭。如果沒有 Age 響應頭那么這次請求應該就是最新的版本。
- 設置變量 goalDuration 去匹配 Age 響應頭,如果 Part 目標時長小于 1 秒則 goalDuration 加 1 秒。
- 如果 Age 響應頭大于或等于 Part 目標時長,則設置 currentGoal 等于 goalDuration 加上現在到第一次響應的時間。
- 利用片段目標時長和 Part 目標時長,去估算服務器應該加了多少片段和小分段到播放列表了。
- 利用估算出來的值去發送帶有 _HLS_msn 和 _HLS_part 查詢條件的請求,就可以獲得最新版本的播放列表了。
當然也可以實現自己的算法來獲取最新版本的播放列表,比如 hls.js 中是這樣計算 currentGoal 的。
currentGoal = Math.min(cdnAge - partTarget, targetDuration * 1.5)
segments = Math.floor(currentGoal / targetDuration)
parts = Math.round((currentGoal % targetDuration) / partTarget)
它在目標時長的 1.5 倍和 Age 響應頭與 Part 目標延遲之間差值取最小值,計算出 currentGoal。然后通過 currentGoal 計算出 _HLS_msn 和 _HLS_part 兩個查詢條件的參數。
推薦時長設置
蘋果推薦的一個視頻片段的時長是 6 秒鐘,一個 Part 小分段時長推薦設置為 1 秒鐘,GOP 推薦設置為 1 到 2 秒。推薦最少在有 3 個 Part 目標時長位置開始播放。
1.7 秒的直播延遲
由于 LLHLS 相對還比較新,我還不知道哪個直播平臺有使用,不過那些流媒體服務廠商都實現了 LLHLS。
在 Wowza 的低延遲解決方案中就包括蘋果低延遲 HLS 解決方案,剛好他們官網演示視頻中就有展示直播延遲。
演示視頻中將 Part 小分段時長設置為 0.4 秒,PART-HOLD-BACK 設置為 0.8 秒。然后使用支持 LLHLS 的 THEOplayer 來播放直播流。可以發現是只有 1.7 秒的延遲。
總結
本文介紹了兩種 HLS 直播方案,LHLS 社區方案和 LLHLS 官方方案,它們都可以提供不錯的低延遲直播。要推薦的話當然是官方的 LLHLS 方案,因為它的功能比較多,而且蘋果的設備都會去支持它,官方也會不斷維護擴展這個方案。另外在設置直播延遲時也要考慮到具體的使用場景,越低的延遲當然越好,但是它也會導致越低的緩存,容易造成直播卡頓。






