吐血乾貨,直播首屏耗時400ms以下的優化實踐

  • 2020 年 3 月 17 日
  • 筆記

導讀:
直播行業的競爭越來越激烈,進過18年這波洗牌後,已經度過了蠻荒暴力期,剩下的都是在不斷追求體驗。最近在幫做直播優化首開,通過多種方案並行,把首開降到500ms以下,希望能對大家有借鑒。

背景:
基於FFmpeg的ijkplayer,最新版本0.88版本。

拉流協議基於http-flv,http-flv更穩定些,中國大部分直播公司基本都是使用http-flv了,從我們實際數據來看,http-flv確實稍微更快些。但是考慮到會有rtmp源,這塊也加了些優化。

IP直通車

簡單理解就是,把域名替換成IP。比如https://www.baidu.com/,你可以直接換成14.215.177.39,這樣做的目的是,省去了DNS解析的耗時,尤其在網路不好時,訪問域名,域名要去解析,再給你返回。不僅僅有時間解析過長的問題,還有小運營商DNS劫持的問題。一般就是在啟動應用時,就開始對拉流的域名進行預解析好,存到本地,然後在真正拉流時,直接用就行。典型的案列,就是很多人使用HTTPDNS,這個github上也有開源,可以自行去研究下。

需要注意的是,這種方案在使用 HTTPS 時,是會失敗的。因為 HTTPS 在證書驗證的過程,會出現 domain 不匹配導致 SSL/TLS 握手不成功。

服務端 GOP 快取

除了客戶端業務側的優化外,我們還可以從流媒體伺服器側進行優化。我們都知道直播流中的影像幀分為:I 幀、P 幀、B 幀,其中只有 I 幀是能不依賴其他幀獨立完成解碼的,這就意味著當播放器接收到 I 幀它能馬上渲染出來,而接收到 P 幀、B 幀則需要等待依賴的幀而不能立即完成解碼和渲染,這個期間就是「黑屏」了。

所以,在伺服器端可以通過快取 GOP(在 H.264 中,GOP 是封閉的,是以 I 幀開頭的一組影像幀序列),保證播放端在接入直播時能先獲取到 I 幀馬上渲染出畫面來,從而優化首屏載入的體驗。

這裡有一個 IDR 幀的概念需要講一下,所有的 IDR 幀都是 I 幀,但是並不是所有 I 幀都是 IDR 幀,IDR 幀是 I 幀的子集。I 幀嚴格定義是幀內編碼幀,由於是一個全幀壓縮編碼幀,通常用 I 幀表示「關鍵幀」。IDR 是基於 I 幀的一個擴展,帶了控制邏輯,IDR 影像都是 I 幀影像,當解碼器解碼到 IDR 影像時,會立即將參考幀隊列清空,將已解碼的數據全部輸出或拋棄。重新查找參數集,開始一個新的序列。這樣如果前一個序列出現重大錯誤,在這裡可以獲得重新同步的機會。IDR 影像之後的影像永遠不會使用 IDR 之前的影像的數據來解碼。在 H.264 編碼中,GOP 是封閉式的,一個 GOP 的第一幀都是 IDR 幀。

推流端設置

一般播放器需要拿到一個完整的GOP,才能記性播放。GOP是在推流端可以設置,比如下面這個圖,是我dump一個流,看到的GOP情況。GOP大小是50,推流過來的fps設置是25,也就是1s內會顯示25個Frame,50個Frame,剛好直播設置GOP 2S,但是直播一般fps不用設置這麼高,可以隨便dump任何一家直播公司的推流,設置fps在15-18之間就夠了。

播放器相關耗時

當set一個源給播放器後,播放器需要open這個流,然後和服務端建立長連接,然後demux,codec,最後渲染。我們可以按照播放器的四大塊,依次優化

  • 數據請求耗時
  • 解復用耗時
  • 解碼耗時
  • 渲染出圖耗時

數據請求

這裡就是網路和協議相關。無論是http-flv,還是rtmp,都主要是基於tcp的,所以一定會有tcp三次握手,同時打開tcp.c分析。需要加日誌在一些方法中,如下tcp_open方法。是已經改動過的

/* return non zero if error */  static int tcp_open(URLContext *h, const char *uri, int flags)  {      av_log(NULL, AV_LOG_INFO, "tcp_open begin");      ...省略部分程式碼      if (!dns_entry) {  #ifdef HAVE_PTHREADS          av_log(h, AV_LOG_INFO, "ijk_tcp_getaddrinfo_nonblock begin.n");          ret = ijk_tcp_getaddrinfo_nonblock(hostname, portstr, &hints, &ai, s->addrinfo_timeout, &h->interrupt_callback, s->addrinfo_one_by_one);          av_log(h, AV_LOG_INFO, "ijk_tcp_getaddrinfo_nonblock end.n");  #else          if (s->addrinfo_timeout > 0)              av_log(h, AV_LOG_WARNING, "Ignore addrinfo_timeout without pthreads support.n");          av_log(h, AV_LOG_INFO, "getaddrinfo begin.n");          if (!hostname[0])              ret = getaddrinfo(NULL, portstr, &hints, &ai);          else              ret = getaddrinfo(hostname, portstr, &hints, &ai);          av_log(h, AV_LOG_INFO, "getaddrinfo end.n");  #endif            if (ret) {              av_log(h, AV_LOG_ERROR,                  "Failed to resolve hostname %s: %sn",                  hostname, gai_strerror(ret));              return AVERROR(EIO);          }            cur_ai = ai;      } else {          av_log(NULL, AV_LOG_INFO, "Hit DNS cache hostname = %sn", hostname);          cur_ai = dns_entry->res;      }     restart:  #if HAVE_STRUCT_SOCKADDR_IN6      // workaround for IOS9 getaddrinfo in IPv6 only network use hardcode IPv4 address can not resolve port number.      if (cur_ai->ai_family == AF_INET6){          struct sockaddr_in6 * sockaddr_v6 = (struct sockaddr_in6 *)cur_ai->ai_addr;          if (!sockaddr_v6->sin6_port){              sockaddr_v6->sin6_port = htons(port);          }      }  #endif        fd = ff_socket(cur_ai->ai_family,                     cur_ai->ai_socktype,                     cur_ai->ai_protocol);      if (fd < 0) {          ret = ff_neterrno();          goto fail;      }        /* Set the socket's send or receive buffer sizes, if specified.         If unspecified or setting fails, system default is used. */      if (s->recv_buffer_size > 0) {          setsockopt (fd, SOL_SOCKET, SO_RCVBUF, &s->recv_buffer_size, sizeof (s->recv_buffer_size));      }      if (s->send_buffer_size > 0) {          setsockopt (fd, SOL_SOCKET, SO_SNDBUF, &s->send_buffer_size, sizeof (s->send_buffer_size));      }        if (s->listen == 2) {          // multi-client          if ((ret = ff_listen(fd, cur_ai->ai_addr, cur_ai->ai_addrlen)) < 0)              goto fail1;      } else if (s->listen == 1) {          // single client          if ((ret = ff_listen_bind(fd, cur_ai->ai_addr, cur_ai->ai_addrlen,                                    s->listen_timeout, h)) < 0)              goto fail1;          // Socket descriptor already closed here. Safe to overwrite to client one.          fd = ret;      } else {          ret = av_application_on_tcp_will_open(s->app_ctx);          if (ret) {              av_log(NULL, AV_LOG_WARNING, "terminated by application in AVAPP_CTRL_WILL_TCP_OPEN");              goto fail1;          }            if ((ret = ff_listen_connect(fd, cur_ai->ai_addr, cur_ai->ai_addrlen,                                       s->open_timeout / 1000, h, !!cur_ai->ai_next)) < 0) {              if (av_application_on_tcp_did_open(s->app_ctx, ret, fd, &control))                  goto fail1;              if (ret == AVERROR_EXIT)                  goto fail1;              else                  goto fail;          } else {              ret = av_application_on_tcp_did_open(s->app_ctx, 0, fd, &control);              if (ret) {                  av_log(NULL, AV_LOG_WARNING, "terminated by application in AVAPP_CTRL_DID_TCP_OPEN");                  goto fail1;              } else if (!dns_entry && strcmp(control.ip, hostname_bak)) {                  add_dns_cache_entry(hostname_bak, cur_ai, s->dns_cache_timeout);                  av_log(NULL, AV_LOG_INFO, "Add dns cache hostname = %s, ip = %sn", hostname_bak , control.ip);              }          }      }        h->is_streamed = 1;      s->fd = fd;        if (dns_entry) {          release_dns_cache_reference(hostname_bak, &dns_entry);      } else {          freeaddrinfo(ai);      }      av_log(NULL, AV_LOG_INFO, "tcp_open end");      return 0;      // 省略部分程式碼  }

改動地方主要是hints.ai_family = AF_INET;,原來是 hints.ai_family = AF_UNSPEC;,原來設計是一個兼容IPv4和IPv6的配置,如果修改成AF_INET,那麼就不會有AAAA的查詢包了。如果只有IPv4的請求,就可以改成AF_INET。當然有IPv6,這裡就不要動了。這麼看是否有,可以通過抓包工具看。

接著分析,我們發現tcp_read函數是個阻塞式的,會非常耗時,我們又不能設置短一點中斷時間,因為短了的話,造成讀取不到數據,就中斷,後續播放就直接失敗了,這裡只能讓它等。不過還是優化的點時下面部分

static int tcp_read(URLContext *h, uint8_t *buf, int size)  {      av_log(NULL, AV_LOG_INFO, "tcp_read begin %dn", size);      TCPContext *s = h->priv_data;      int ret;        if (!(h->flags & AVIO_FLAG_NONBLOCK)) {          ret = ff_network_wait_fd_timeout(s->fd, 0, h->rw_timeout, &h->interrupt_callback);          if (ret)              return ret;      }      ret = recv(s->fd, buf, size, 0);      if (ret == 0)          return AVERROR_EOF;      //if (ret > 0)      //    av_application_did_io_tcp_read(s->app_ctx, (void*)h, ret);      av_log(NULL, AV_LOG_INFO, "tcp_read end %dn", ret);      return ret < 0 ? ff_neterrno() : ret;  }

我們可以把上面兩行注釋掉,因為在ff_network_wait_fd_timeout等回來後,數據可以放到buf中,下面av_application_did_io_tcp_read就沒必要去執行了。原來每次ret>0,都會執行av_application_did_io_tcp_read這個函數。

解復用耗時

在日誌中發現,數據請求到後,進行音影片分離時,首先需要匹配對應demuxer,其中ffmpeg的av_find_input_format和avformat_find_stream_info非常耗時,前者簡單理解就是打開某中請求到數據,後者就是探測流的一些資訊,做一些樣本檢測,讀取一定長度的碼流數據,來分析碼流的基本資訊,為影片中各個媒體流的 AVStream 結構體填充好相應的數據。這個函數中做了查找合適的解碼器、打開解碼器、讀取一定的音影片幀數據、嘗試解碼音影片幀等工作,基本上完成了解碼的整個流程。這時一個同步調用,在不清楚影片數據的格式又要做到較好的兼容性時,這個過程是比較耗時的,從而會影響到播放器首屏秒開。這兩個函數調用都在ff_ffplay.c的read_thread函數中:

    if (ffp->iformat_name) {          av_log(ffp, AV_LOG_INFO, "av_find_input_format noraml begin");          is->iformat = av_find_input_format(ffp->iformat_name);          av_log(ffp, AV_LOG_INFO, "av_find_input_format normal end");      }      else if (av_stristart(is->filename, "rtmp", NULL)) {          av_log(ffp, AV_LOG_INFO, "av_find_input_format rtmp begin");          is->iformat = av_find_input_format("flv");          av_log(ffp, AV_LOG_INFO, "av_find_input_format rtmp end");          ic->probesize = 4096;          ic->max_analyze_duration = 2000000;          ic->flags |= AVFMT_FLAG_NOBUFFER;      }      av_log(ffp, AV_LOG_INFO, "avformat_open_input begin");      err = avformat_open_input(&ic, is->filename, is->iformat, &ffp->format_opts);      av_log(ffp, AV_LOG_INFO, "avformat_open_input end");      if (err < 0) {          print_error(is->filename, err);          ret = -1;          goto fail;      }      ffp_notify_msg1(ffp, FFP_MSG_OPEN_INPUT);        if (scan_all_pmts_set)          av_dict_set(&ffp->format_opts, "scan_all_pmts", NULL, AV_DICT_MATCH_CASE);        if ((t = av_dict_get(ffp->format_opts, "", NULL, AV_DICT_IGNORE_SUFFIX))) {          av_log(NULL, AV_LOG_ERROR, "Option %s not found.n", t->key);  #ifdef FFP_MERGE          ret = AVERROR_OPTION_NOT_FOUND;          goto fail;  #endif      }      is->ic = ic;        if (ffp->genpts)          ic->flags |= AVFMT_FLAG_GENPTS;        av_format_inject_global_side_data(ic);        if (ffp->find_stream_info) {          AVDictionary **opts = setup_find_stream_info_opts(ic, ffp->codec_opts);          int orig_nb_streams = ic->nb_streams;            do {              if (av_stristart(is->filename, "data:", NULL) && orig_nb_streams > 0) {                  for (i = 0; i < orig_nb_streams; i++) {                      if (!ic->streams[i] || !ic->streams[i]->codecpar || ic->streams[i]->codecpar->profile == FF_PROFILE_UNKNOWN) {                          break;                      }                  }                    if (i == orig_nb_streams) {                      break;                  }              }              ic->probesize=100*1024;              ic->max_analyze_duration=5*AV_TIME_BASE;              ic->fps_probe_size=0;              av_log(ffp, AV_LOG_INFO, "avformat_find_stream_info begin");              err = avformat_find_stream_info(ic, opts);              av_log(ffp, AV_LOG_INFO, "avformat_find_stream_info end");          } while(0);          ffp_notify_msg1(ffp, FFP_MSG_FIND_STREAM_INFO);

最終改的如上,主要是對rtmp增加了,指定format為『flv』,以及樣本大小。
同時在外部可以通過設置 probesize 和 analyzeduration 兩個參數來控制該函數讀取的數據量大小和分析時長為比較小的值來降低 avformat_find_stream_info的耗時,從而優化播放器首屏秒開。但是,需要注意的是這兩個參數設置過小時,可能會造成預讀數據不足,無法解析出碼流資訊,從而導致播放失敗、無音頻或無影片的情況。所以,在服務端對影片格式進行標準化轉碼,從而確定影片格式,進而再去推算 avformat_find_stream_info分析碼流資訊所兼容的最小的probesize和 analyzeduration,就能在保證播放成功率的情況下最大限度地區優化首屏秒開。

在 FFmpeg 中的 utils.c 文件中的函數實現中有一行程式碼是 int fps_analyze_framecount = 20;,這行程式碼的大概用處是,如果外部沒有額外設置這個值,那麼 avformat_find_stream_info 需要獲取至少 20 幀影片數據,這對於首屏來說耗時就比較長了,一般都要 1s 左右。而且直播還有實時性的需求,所以沒必要至少取 20 幀。將這個值初始化為2,看看效果。

/* check if one codec still needs to be handled */          for (i = 0; i < ic->nb_streams; i++) {              int fps_analyze_framecount = 2;                st = ic->streams[i];              if (!has_codec_parameters(st, NULL))                  break;                if (ic->metadata) {                  AVDictionaryEntry *t = av_dict_get(ic->metadata, "skip-calc-frame-rate", NULL, AV_DICT_MATCH_CASE);                  if (t) {                      int fps_flag = (int) strtol(t->value, NULL, 10);                      if (!st->r_frame_rate.num && st->avg_frame_rate.num > 0 && st->avg_frame_rate.den > 0 && fps_flag > 0) {                          int avg_fps = st->avg_frame_rate.num / st->avg_frame_rate.den;                          if (avg_fps > 0 && avg_fps <= 120) {                              st->r_frame_rate.num = st->avg_frame_rate.num;                              st->r_frame_rate.den = st->avg_frame_rate.den;                          }                      }                  }              }

這樣,avformat_find_stream_info 的耗時就可以縮減到 100ms 以內。

最後就是解碼耗時和渲染出圖耗時,這塊優化空間很少,大頭都在前面。

有人開始拋出問題了,你這個起播快是快,但是後面網路不好,卡頓怎麼辦?直播中會引起卡頓,主要是網路有抖動的時候,沒有足夠的數據來播放,ijkplayer會激發其緩衝機制,主要是有幾個宏控制

  • DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS:網路差時首次去喚醒read_thread函數去讀取數據。
  • DEFAULT_NEXT_HIGH_WATER_MARK_IN_MS:第二次去喚醒read_thread函數去讀取數據。
  • DEFAULT_LAST_HIGH_WATER_MARK_IN_MS這個宏的意思是最後的機會去喚醒read_thread函數去讀取數據。

可以設置DEFAULT_LAST_HIGH_WATER_MARK_IN_MS為1 * 1000,也即緩衝1秒後開始通知緩衝完成去讀取數據,默認是5秒,如果過大,會讓用戶等太久,那麼每次讀取的bytes也可以少些。可以設置DEFAULT_HIGH_WATER_MARK_IN_BYTES小一些,設置為30 * 1024,默認是256 * 1024。把BUFFERING_CHECK_PER_MILLISECONDS設置為50,默認是500

#define DEFAULT_HIGH_WATER_MARK_IN_BYTES        (30 * 1024)    #define DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS     (100)  #define DEFAULT_NEXT_HIGH_WATER_MARK_IN_MS      (1 * 1000)  #define DEFAULT_LAST_HIGH_WATER_MARK_IN_MS      (1 * 1000)    #define BUFFERING_CHECK_PER_BYTES               (512)  #define BUFFERING_CHECK_PER_MILLISECONDS        (50)

可以看下這些宏使用的地方

inline static void ffp_reset_demux_cache_control(FFDemuxCacheControl *dcc)  {      dcc->min_frames                = DEFAULT_MIN_FRAMES;      dcc->max_buffer_size           = MAX_QUEUE_SIZE;      dcc->high_water_mark_in_bytes  = DEFAULT_HIGH_WATER_MARK_IN_BYTES;        dcc->first_high_water_mark_in_ms    = DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS;      dcc->next_high_water_mark_in_ms     = DEFAULT_NEXT_HIGH_WATER_MARK_IN_MS;      dcc->last_high_water_mark_in_ms     = DEFAULT_LAST_HIGH_WATER_MARK_IN_MS;      dcc->current_high_water_mark_in_ms  = DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS;  }

最後優化的點,是設置一些參數值,也能優化一部分,實際上很多直播用軟體用低解析度240,甚至360,來達到秒開,可以可以作為一個減少耗時點來展開的,因為解析度越低,數據量越少,首開越快。

mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "opensles", 0);  mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 1);  mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 1);    mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "http-detect-range-support", 0);  mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer");  mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "flush_packets", 1);  mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "max_delay", 0);    mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_CODEC, "skip_loop_filter", 48);    mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 0);  mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "max-buffer-size", 4 * 1024);  mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "min-frames", 50);  mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probsize", "1024");  mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", "100");  mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "dns_cache_clear", 1);  //靜音  //mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "an", 1);  //重連模式,如果中途伺服器斷開了連接,讓它重新連接  mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect", 1);

以上完了後,就可以看下測試數據,解析度在540p以下基本秒開,在4G網路下測試:

1、河北衛視直播源,測試10組,平均下來300ms。一組數據386ms,如下:

11-17 14:17:46.659 9896 10147 D IJKMEDIA: IjkMediaPlayer_native_setup
11-17 14:17:46.663 9896 10147 V IJKMEDIA: setDataSource: path http://weblive.hebtv.com/live/hbws_bq/index.m3u8
11-17 14:17:46.666 9896 10177 I FFMPEG : [FFPlayer @ 0xe070d400] avformat_open_input begin
11-17 14:17:46.841 9896 10177 I FFMPEG : [FFPlayer @ 0xe070d400] avformat_open_input end
11-17 14:17:46.841 9896 10177 I FFMPEG : [FFPlayer @ 0xe070d400] avformat_find_stream_info begin
11-17 14:17:46.894 9896 10177 I FFMPEG : [FFPlayer @ 0xe070d400] avformat_find_stream_info end
11-17 14:17:47.045 9896 10191 D IJKMEDIA: Video: first frame decoded
11-17 14:17:47.046 9896 10175 D IJKMEDIA: FFP_MSG_VIDEO_DECODED_START:

2、映客直播秀場源,測試10組,平均下來400ms。一組數據418ms,如下:

11-17 14:21:32.908 11464 11788 D IJKMEDIA: IjkMediaPlayer_native_setup
11-17 14:21:32.952 11464 11788 V IJKMEDIA: setDataSource: path http://14.215.100.45/hw.pull.inke.cn/live/1542433669916866_0_ud.flv?ikDnsOp=1001&ikHost=hw&ikOp=0&codecInfo=8192&ikLog=1&ikSyncBeta=1&dpSrc=6&push_host=trans.push.cls.inke.cn&ikMinBuf=2900&ikMaxBuf=3600&ikSlowRate=0.9&ikFastRate=1.1
11-17 14:21:32.996 11464 11818 I FFMPEG : [FFPlayer @ 0xc2575c00] avformat_open_input begin
11-17 14:21:33.161 11464 11818 I FFMPEG : [FFPlayer @ 0xc2575c00] avformat_open_input end
11-17 14:21:33.326 11464 11829 D IJKMEDIA: Video: first frame decoded

3、熊貓直播遊戲直播源,測試10組,平均下來350ms。一組數據373ms,如下:

11-17 14:29:17.615 15801 16053 D IJKMEDIA: IjkMediaPlayer_native_setup
11-17 14:29:17.645 15801 16053 V IJKMEDIA: setDataSource: path http://flv-live-qn.xingxiu.panda.tv/panda-xingxiu/dc7eb0c2e78c96646591aae3a20b0686.flv
11-17 14:29:17.649 15801 16079 I FFMPEG : [FFPlayer @ 0xeb5ef000] avformat_open_input begin
11-17 14:29:17.731 15801 16079 I FFMPEG : [FFPlayer @ 0xeb5ef000] avformat_open_input end
11-17 14:29:17.988 15801 16090 D IJKMEDIA: Video: first frame decoded
在这里插入图片描述
歡迎關注我的微信公眾號「碼農突圍」,分享Python、Java、大數據、機器學習、人工智慧等技術,關注碼農技術提升•職場突圍•思維躍遷,20萬+碼農成長充電第一站,陪有夢想的你一起成長。

Tags: