玩轉直播系列之從 0 到 1 構建簡單直播系統(1)

一、前言

隨着5G時代的到來,音視頻行業也可能迎來一個行業的春天,直播則是新視頻行業一直以來的一個重要的產品形態,從最初的秀場直播,遊戲直播,到今年由於疫情,目前比較火的在線教育直播,帶貨直播等,各類新的直播形式則是越來越多的展示在大眾面前。

作為技術開發的我們,今天我們一起簡單的了解一下,如何快速搭建一套最簡單的直播系統,簡單地了解一下主流直播的架構模型。

二、推拉流模型

首先我們先看一張完整的直播推拉流的模型圖,我們可以很清楚地看到直播宏觀上的架構模型圖。

2.1 直播三個主要模塊

推流模塊

推流模塊主要分為音視頻數據的採集,如果是秀場類直播,可以做美顏濾鏡相關功能,用來提升直播的畫面品質和用戶體驗,最後通過編碼壓縮,降低音視頻數據的體積,最後通過流媒體傳輸協議將數據按照固定格式傳遞到RTMP服務器,這樣整個推流端的工作就完成了。

RTMP服務端模塊

傳統意義上的RTMP服務器其實可能就只有轉碼的功能,將推流端傳遞過來的數據,轉成flv等網絡格式的數據文件,方便播放端的觀看,不過目前雲商都提供了一整套的解決方案,例如清晰度轉碼,內容健康檢查,直播封面的生成,數據統計,錄製回放等功能,這也是在RTMP服務器的基礎上,進行的業務封裝,這樣才能提供一整套的解決方案。

播放端模塊

播放端的邏輯就相對比較簡單,簡而言之就是獲取拉流地址,進行音視頻的播放,不過在實際開發的過程中,播放端的業務工作量和技術優化點都是最多的,如上圖所示的首屏秒開,解碼優化,切換直播間等功能,都是需要花費大量的精力,根據業務不斷地去演進優化的。

三、搭建步驟

本入門直播簡單教程主要分為如下幾個模塊:

搭建直播服務器;

使用OBS進行推流;

直播流如何觀看;

直播間消息的實現。

3.1 搭建直播服務器

直播服務器實時地將推流端上傳的視頻流進行解析和編解碼,以用於支持rtmp、hls或httpflv等直播協議的觀看端進行觀看。

當前市面上有很多開源的直播服務器解決方案,如 livego、srs 和 nginx-rtmp ,亦或者是目前比較主流的雲解決方案,目前阿里雲,七牛雲,騰訊雲等都提供了標準的成熟的解決方案,本篇文章旨在快速地搭建一個簡單的直播,所以我們可以採用livego這個開放源代碼的方式去搭建推拉流服務器,livego 使用純 go 語言編寫,性能高且跨平台,安裝和使用非常簡單,支持常用的傳輸協議、文件格式和編碼格式,或者安裝上文所示,直接在雲商開播直播服務。

安裝 livego 主要有三種方式:1)直接下載二進制可運行文件;2)從Docker啟動;3)從源碼編譯。

docker run -p 1935:1935 -p 7001:7001 -p 7002:7002 -p 8090:8090 -d gwuhaolin/livego

其中,各個端口的含義如下:

8090:HTTP 管理訪問監聽地址

1935:RTMP 服務監聽地址

7001:HTTP-FLV 服務監聽地址

7002:HLS 服務監聽地址

3.2 使用OBS推流

OBS(Open Broadcaster Software)是一款開源免費的提供視頻錄製和直播功能的軟件,去OBS官網下載對應平台的軟件進行安裝即可。

要想推流,首先要解決的是「推什麼」的問題,也就是要明確流的來源。打開OBS,點擊新建「來源」按鈕,如下圖中第1步所示,可以看到OBS支持的來源比較豐富,有媒體源、顯示器採集、瀏覽器和窗口採集等等。此處用現有的mp4文件來進行循環推流,因此來源選擇「媒體源」,名稱用默認的就行,點擊「確定」後,設置要播放的視頻文件,然後點擊「確定」即可。

然後,要解決的就是「往哪推」的問題,也就是需要有一個可用的推流地址才行。

前面我們已經搭建好了livego直播服務器,它提供了一個默認推流地址:rtmp://localhost:1935/live,一個標準的RTMP服務器的推流URL類似這種格式:rtmp://domain/AppName/StreamName,但是要想使用該推流地址,需要有授權的 channelkey 才行。

通過訪問 //localhost:8090/control/get?room=movie 就可以獲取用於推流的 channelkey,如下所示,其中 data 字段就是此次獲取到的 channelkey。

{
    "status": 200,
    "data": "rfBd56ti2SMtYvSgD5xAV0YU99zampta7Z7S575KLkIZ9PYk"
}

到現在,推流地址和 channelkey 都有了,只需要在OBS裏面進行相關設置就可以進行推流。首先點擊「控件」的「設置」按鈕,進入設置面板。

然後,選擇「推流」選項。服務選擇「自定義」,服務器設置為:rtmp://localhost:1935/live,串流密鑰設置為前面獲取到的 channelkey:rfBd56ti2SMtYvSgD5xAV0YU99zampta7Z7S575KLkIZ9PYk 。設置好後,點擊「控件」的「開始推流」按鈕,就可以進行推流了。

一般情況下,默認的輸出配置就足以應付大多數場景了,但是要想獲得更適合自己想要的的直播效果的話,可以在「輸出」選項里設置「高級」輸出模式,對此無需求的話可以直接跳過本部分。如下圖所示,在高級輸出設置界面,可以對串流、錄像、音頻和回放緩存進行配置,其中,最重要的就是對串流的設置。編碼器軟件可以選擇 x264 和 QuickSync H.264,使用強大的 x264就可以。「重新縮放輸出」可以設置輸出的分辨率,默認使用原視頻的分辨率。

比特率(碼率)的含義是視頻經過壓縮編碼後每秒的數據量的大小,單位是 Kbps,此處 K=1000。該值越大,每秒推送的視頻數據流就越大,視頻質量也越高,但是佔用的帶寬也更多,可以根據需要進行調整,一般秀場直播常用2000~2500Kbps就可,遊戲直播可能對碼率的要求比較高一點,可以做對應的調整。

直播推流時,可以使用多種碼率控制方式,主要有CBR、ABR、VBR和CRF。

CBR(Constant Bitrate)恆定碼率,一定時間範圍內比特率基本保持恆定。使用該模式時,在視頻動態畫面較多的場景下,圖像質量會變差,而在靜態畫面較多的場景下,圖像質量又會變好。

VBR(Variable Bitrate)可變碼率,其碼率可以隨着圖像的複雜程度的不同而變化。使用該模式時,在圖像內容比較簡單的場景下,分配較少的碼率,而在圖像內容複雜的場景下,則分配較多的碼率。這樣既保證了質量,又兼顧到帶寬限制,優先考慮到圖像質量。

ABR(Average Bitrate)平均比特率,是VBR的一種插值參數。簡單場景分配較低碼率,複雜場景分配足夠碼率,這一點類似VBR。同時,一定時間內平均碼率又接近設置的目標碼率,這一點又類似CBR。可以認為ABR是CBR和VBR的折中方案。

CRF(Constant Rate Factor)恆定碼率係數。CRF值可以理解為對視頻的清晰度和流暢度期望的一個固定輸出值,即無論是在複雜場景還是在簡單場景下,都希望有一個穩定的主觀視頻質量。

關鍵幀間隔(Group of Pictures,GOP)指的是一組由一個I幀、多個P幀和B幀組成的一個幀序列。一幀就是視頻中的一個畫面,其中:

I幀(intra coded picture):最完整的畫面,自帶全部信息,無需參考其他幀即可解碼,每個GOP都是以I幀開始;

P幀(predictive coded picture):幀間預測編碼幀,需要參考前面的I幀或P幀,才能進行解碼,壓縮率較高;

B幀(bipredictive coded picture):雙向預測編碼幀,以前幀後幀作為參考幀,壓縮率最高。

對於普通視頻,加大GOP長度有利於減小視頻體積,但是在直播場景下,GOP過大會導致客戶端的首屏播放時間變長。GOP越小圖片質量越高,建議設為2秒,最長不要超過4秒。

3.3 直播流觀看

我們剛剛已經搭建完成了RTMP服務器,並且使用目前比較成熟,功能比較豐富的推流工具OBS進行推流,接下來我們就要解決如何在用戶終端進行觀看了的問題。

FLV(Flash Video)是一種網絡視頻格式,是一種流媒體格式,目前主流的一些直播網絡使用的流媒體格式比較多的都是flv,它能夠不需要安裝任何插件即可進行播放。

3.3.1 小試牛刀:使用VLC工具觀看

VLC 是一款音視頻播放器,可以播放本地媒體,也可以播放網絡上的媒體,到官網//www.videolan.org/index.zh.html 下載對應的安裝包安裝即可。

點擊「媒體」tab下的「打開網絡串流」選項,然後網絡地址設置為:rtmp://localhost:1935/live/movie ,點擊「確定」後就可以看到OBS推流的視頻啦。

使用VLC主要是方便開發同學進行觀看測試,例如觀看卡頓的問題,分辨率查看,時延問題的定位,VLC算是一個比較專業的工具,能夠方便我們去定位問題和解決問題的

3.3.2 使用flv.js進行瀏覽器端的觀看

flv.js是目標最為流行的html5的純的javascript,也是目前國內比較主流的瀏覽器終端播放flv格式的解決方案,本小節我們就使用flv.js進行簡單的播放,打開如下的網址://bilibili.github.io/flv.js/demo/


可以看到如圖所示的,將如下streamURL的輸入框輸入//127.0.0.1:7001/live/movie.flv 後,點擊switch to MediaDataSource後Load即可播放如下的畫面。

3.3.3 直播協議的簡單介紹

到目前為止,我們已經成功的搭建了RTMP小框架,了解了整個推拉流的完整過程,接下來我們就需要對與RTMP協議幾個強相關的直播網絡傳輸協議有一個入門的了解。

國內常見的直播協議有幾個:

RTMP

HLS

HTTP-FLV

HLS全稱是 HTTP Live Streaming。這是 Apple 提出的直播流協議。目前,IOS 和 高版本 Android 都支持 HLS,HLS 主要的兩塊內容是 .m3u8 文件和 .ts 播放文件。接收服務器會將接收到的視頻流進行緩存,然後緩存到一定程度後,會將這些視頻流進行編碼格式化,同時會生成一份 .m3u8 文件和其它很多的 .ts 文件,HLS的優點是跨平台性比較好,HTML5可以直接打開播放,移動端兼容性良好,缺點也是比較明顯,就是時延比較高,如果有些直播,例如互動性不高的直播,可以使用該協議,HLS網絡傳輸格式是非常適合用於點播的場景。

RTMP全稱 Real Time Messaging Protocol,即實時消息傳送協議,對於開發者來說,我們先明確RTMP是應用層協議,底層是使用的TCP傳輸協議,這邊我們知道RTMP是音視頻相關領域的協議,所以這塊使用TCP作為主要的傳輸層協議也給後續RTMP關於網絡的各種各樣的演進,留下了很多的空間,在直播行業,特別是在推流端,RTMP協議是名副其實的霸主,基本上所有主流的直播網站都是支持rtmp協議進行推流的,關於RTMP的具體協議細節,後續文章有具體的分析。

FLV(Flash Video)是 Adobe 公司推出的另一種視頻格式,是一種在網絡上傳輸的流媒體數據存儲容器格式。其格式相對簡單輕量,不需要很大的媒體頭部信息。整個 FLV 由 The FLV Header, The FLV Body 以及其它 Tag 組成。因此加載速度極快。採用 FLV 格式封裝的文件後綴為 .flv。

流媒體協議 RTMP, HTTP-FLV, HLS 簡單對比:

3.3.4 直播中的消息

在秀場直播系統中,如果說音視頻功能的實現,是給直播裝扮上了華麗的新裝外表的話,那麼直播系統中消息系統的實現,則是整個直播華麗新裝下的靈魂,如何搭建高可用的直播間消息系統,也是每一個直播系統必須要解決的問題。

在設計秀場直播的消息系統之前,我們需要簡單地梳理一下直播間的消息類型。

通知類消息例如送禮、彈幕、進場、榜單變化、等級變化等等消息。他們的特徵是通知用戶直播間的事件,營造直播間氛圍,提升用戶觀看直播的體驗。

功能類消息例如踢人、反垃圾審核、紅包、PK消息等等。這類消息的特徵是輔助直播業務開展,在流程上串聯開播端、觀看端、服務端三個角色。

我們可以從業務角度中,分析出直播間的各類消息雖然因為業務形態各式各樣,最終呈現的形式也是多彩絢麗,但是我們可以從各類的消息展現形式可以分析出,消息從開發的角度,有如下幾個特性,我們按照消息是否可丟棄,和實時性劃分,我們可以把所有的業務消息歸為如下幾類:


在直播系統中,秀場直播,帶貨直播的直播間消息信令通信是比較偏多的,主要是因為業務性質所決定的,秀場直播和帶貨直播這兩類直播的互動性相對比較強,玩法也比較多樣,按照我們上圖的分類,每一個業務的消息的可丟棄性和實時性要求都不一樣,所以在開發消息系統的時候,也需要對消息進行優先級排序,對消息分發的實時性也要有業務性能考量。

剛剛針對直播間消息實時性和不可丟棄性這兩個屬性做了業務上相關的闡述,不過對於直播消息而言,第一要素是穩定性,消息如何準確穩定地分發到指定的直播間,也是我們需要考慮的問題之一,直播消息的分發實現,從總體上說可以分為兩種實現方式,第一是依靠直播間的實時通訊(Instant Messaging),也就是我們常說的IM消息系統,第二個是依靠http短輪詢,例如客戶端每隔1秒來請求一次服務器,服務器返回這一秒內發生的增量消息信息,客戶端獲取到這些增量信息,再根據具體的消息業務類型,再進行相對業務的頁面UI渲染,這樣就可以了,從技術上說,一個是「推」模型,一個是「拉」模型,今天我們因為搭建一個簡單的直播間消息系統,我們先用一個簡單的”拉”模型進行簡單的實現。

基本實現思路:客戶端每隔一個極短的時間,例如1秒亦或者更短的時間,根據直播間的id來調用服務端的接口,輪詢該直播間發生的消息,服務端這邊我們使用redis的SortedSet的數據結構來存儲消息,其中key是直播間的房間id,score是服務器接收到該消息事件生成的時間戳,value可以簡單地直接存儲該消息序列化後的字符串,這樣可以按照時間順序地去存儲消息,並且配置過期消息的刪除邏輯,整個消息的存儲就可以簡單地搭建起來。

消息存儲用java的偽代碼所示:

 long time = new Date().getTime();
 
try {
     // redis中插入消息數據
     jedisTemplate.zadd(V_UNIQUE_ROOM_ID, time, JSON.toJSONString(roomMessage));
 
     // 按照概率性的去刪除redis中過期的消息數據
     if (probability()) {
           deleteOverTimeCache(V_UNIQUE_ROOM_ID);
        }
     } catch (Exception e) {
            log.error("message save error", e);
 }

可以看到消息存儲,如果使用redis的sortedSet進行存儲還是比較方便的,接下來我們需要處理就是redis中過期消息的刪除,因為無效的過期消息是沒有價值的(所有的消息可以做持久化存儲),redis中如果單一的key存儲的消息過多,也會導致消息的慢查,和內存的使用量不斷增大,這是我們不想看到的,這邊因為是示例代碼,所以簡單地處理一下刪除邏輯。

    private void deleteOverTimeCache(String roomId) {
 
        Long totalCount = jedisTemplate.zcard(roomId);
 
        log.info("deleteOldTimeCache size is {}", totalCount);
 
        if (totalCount < 600) {
            return;
        }
 
        // 倒序刪除過期數據
        Set<Tuple> tuples = jedisTemplate.zrangeWithScores(roomId, -601, -1);
 
        if (CollectionUtils.isNotEmpty(tuples)) {
            for (Tuple tuple : tuples) {
                // 這是第一個-600條的那個score
                double score = tuple.getScore();
                jedisTemplate.zremrangeByScore(roomId, 0d, score);
                break;
            }
        }
    }

上面的偽代碼probability()首先先做一個概率性的判斷,例如我們做百分之一的隨機判斷,判斷該次請求是否要進行消息的刪除(請注意我們刪除的邏輯是放在插入的邏輯之中的。如果每一次插入都需要判斷是否要刪除過期數據,會影響插入的性能)。如果通過概率性判斷後,我們就優先判斷某一個直播間的消息個數,如果消息個數還是比較少的話,則退出刪除邏輯,如果超過消息閥值,則按照時間倒序刪除已經過期的消息。

說完了http短輪詢消息的存儲後,我們最後再簡單地說一下客戶端消息查詢實現邏輯。客戶端通過直播間id和時間戳兩個字段來請求服務端以查詢直播間消息,其中”時間戳”是每一次服務端返回的,這個時間戳是漸進式的,當下一次客戶端來請求服務端的數據的時候,都會帶來上次服務端返回的時間戳,偽代碼如下:

   @Override
 public RoomMessage queryRoomMessages(MessageMessageReq messageMessageReq) {
 
        RoomMessage result = new RoomMessage();
 
        long timestamp = messageMessageReq.getTimestamp();
 
        Set<Tuple> tuples = null;
        if (timestamp == 0) {
            // 如果傳遞是0,說明這個客戶端終端是第一次來輪詢,我們只要返回一個最近最新的消息返回即可
            tuples = jedisTemplate.zrevrangeWithScores(UNIQUE_ROOM_ID, 0, 0);
        } else
            // 加上一毫秒,返回後續的消息,每次返回5個,防止客戶端因為低端手機原因,過多的消息渲染不出來
            tuples = jedisTemplate.zrangeByScoreWithScores(UNIQUE_ROOM_ID, timestamp + 1, System.currentTimeMillis(), 0, 5);
        }
 
        List<EachRoomMessage> eachRoomMessages = new ArrayList<>();
        long lastTimestamp = 0L;
 
        if (!CollectionUtils.isEmpty(tuples)) {
            for (Tuple tuple : tuples) {
                //最後一次循環後,會把最後一條消息產生的時間戳,返回給客戶端,這樣下次客戶端就可以拿着這個時間戳來進行查詢
                lastTimestamp = new Double(tuple.getScore()).longValue();
                eachRoomMessages.add(JSON.parseObject(tuple.getElement(), EachRoomMessage.class));
            }
        }
 
        result.setTimestamp(lastTimestamp);
        result.setEachRoomMessages(eachRoomMessages);
        return result;
    }

上述三段比較完整地代碼主要陳述了一個依賴http短輪詢這種方式快速實現的直播間的能力,這種方式是比較粗糙的,不過卻是一個很好的實現思路,目前我們線上部分業務也是根據這個輪詢的思想進行部分模塊的實現。

這樣實現的思路也有一個小坑,如果有採用該思路去實現的,可以嘗試去規避。如果Android客戶端斷網的情況下,輪詢的線程是不會停止的,例如是晚上8點整斷網的,8點01分恢復網絡的,當網絡恢復的時候,第一次輪詢就會導致服務端返回大量的消息,這邊是需要進行處理的,否則會返回過多的消息,服務端也會出現慢查,客戶端因為渲染過期的消息也會出現部分消息展示區間出現閃跳。例如公屏區可能會”發瘋”般的出現各類消息,這些可以通過客戶端和服務端的雙方約定進行規避,例如客戶端當出現網絡問題的時候,在超過5秒以上,可以把時間戳置為0,要求服務端返回最新的直播間消息即可,中間丟失掉的消息,可以在業務返回內的進行丟棄。

四、小結

本文主要是想讓大家對直播有一個初步的了解,了解直播基本的概念模型,一些基礎的概念,後續我們會深入直播具體的模塊的學習,進一步去了解直播的原理,也能夠幫助我們更好的做好直播的業務。

作者:vivo 互聯網服務器團隊-Li Guolin