Оптимизация псевдостриминга FLV-видео

Один из проектов нашей компании — это сервис online-видео, аналогичный youtube. Для вещания и реализации возможностей стриминга используется замечательный веб-сервер nginx с модулем ngx_http_flv_module.

Все было хорошо, пока количество просмотров не достигло уровня, когда не только сетевые каналы серверов оказались перегружены, но и перестала справляться с запросами на чтение дисковая подсистема серверов.

Проблемы с дисковой подсистемой решили просто установкой 10-ого рейда. Но с сетью надо было что-то делать и появилась идея загружать файлы для просмотра не одним потоком, а дискретно, кусками одинакового размера. Благо во flash’e было реализовано все, что нужно. Согласно документации в объекте NetStream есть метод appendBytes() с нужным функционалом. Вот выдержка из официальной документации:

“Передает объект ByteArray в экземпляр NetStream для воспроизведения. Вызовите этот метод для объекта NetStream в режиме создания данных. Чтобы перевести объект NetStream в режим создания данных, вызовите метод NetStream.play(null) для экземпляра NetStream, созданного для объекта NetConnection, который подключен к null. Метод appendBytes() нельзя вызывать для объекта NetStream, который не находится в режиме создания данных, в противном случае выдается исключение.”

Казалось бы, берем класс Loader, прописываем заголовок Range, но нас тут поджидает мина от разработчиков Adobe. В стандартных классах Action Script, которые работают с протоколом HTTP, недоступно использование заголовка Range. Опять же, из официальных источников становится известно, что заголовок Range заблокирован в коде классов, работающих в протоколом HTTP, из соображений безопасности еще в версии плеера 9. Но радовало одно: что блокировка не нативная, а в самих классах, из чего следовало, что можно написать свой HTTP-клиент, используя базовый класс сокета Soсket. Благо в закромах google нашелся уже готовый класс HTTPClientLib. Приведенный ниже код загружает первый мегабайт видеофайла и воспроизводит его:

private var ns:NetStream;
private var video:Video;
private var meta:Object;
private var client:HttpClient;
private var filesize:Number = 0;
private var loadedBytes:Number = 0;
private var data:ByteArray = new ByteArray();
private var datadelta:Number = 1024*1024;

//Переменная содержит ссылку на видео-файл
private var file:String = %ссылка на видео файл%;

private function init():void{
   //Инициируем объект, который получит метаданные загруженного видео
   var nsClient: Object = {};
  //Собственно функция обработчик события на получение метаданных
   nsClient.onMetaData = metaDataHandler;                 
   //Объект соединения с сервером; в нашем варианте он создается с ссылкой на объект null так как мы не работаем с медиа-сервером
   var nc:NetConnection = new NetConnection();
   nc.connect(null);

   //Собственно объект, который будет управлять получением данных и воспроизведением самого видео
   ns = new NetStream(nc);
   ns.client = nsClient;
   //Событие на обработки изменения статуса
   ns.addEventListener(NetStatusEvent.NET_STATUS,netStatusHandler);
   //Обработчик ошибок сети
   ns.addEventListener(IOErrorEvent.IO_ERROR,nsIOErrorHandler);
   //Визуальный компонент который будет показывать видео
   video = new Video();
   video.attachNetStream(ns);
   // Сглаживание картинки. Необязательный параметр, но на низком качестве видео пригодится
   video.smoothing = true;
   uic.addChild(video);

   //Наш объект, который будет грузить данные вместо NetStream
   client = new HttpClient();    
   //Старт загрузки первого мегабайта видео
   loadData();
   //Включаем режим создания данных объекта NetStream
   ns.play(null);

}

private function loadData():void{
   //Объект с ссылкой на видео
   var uri:URI = new URI(file);
   //Объект, в котором можно объявить заголовки
   var request:HttpRequest = new Get();
   //maxdata показывает конечную границу диапазона загрузки
   //loadedBytes - это начальная граница диапазона
   var maxdata:Number = loadedBytes+datadelta;
   //Формируем заголовок с учетом того, что размер файла может быть не кратен нашей дельте загрузки
   if (maxdata>=filesize and filesize>0){
       request.addHeader('Range','bytes='+loadedBytes+'-');    
   } else {
       request.addHeader('Range','bytes='+loadedBytes+'-'+maxdata);
   }
   //Обработчик события на получение данных, по мере загрузки сохраняем в буфер data
   //В данном случае загружаемые данные лучше сохранять в
   //временный буфер потому что при прямой отправке в объект NetStream воспроизведение
   //начинает прыгать по временной шкале
   client.listener.onData = function(e:HttpDataEvent):void {
       var bytes:ByteArray = new ByteArray();
       bytes = e.bytes;
       bytes.position = 0;
       data.writeBytes(bytes);                    
   };

   //Обработчик события на конец загрузки файла
   client.listener.onComplete = function(e:HttpResponseEvent ):void{
       //Увеличиваем нижнюю границу на длину загруженных данных
       loadedBytes+=data.length;
       Получаем размер файла из заголовка ответа
       filesize = Number(e.response.header.getValue('Content-Length'))/1024;
       //Добавляем загруженные данные в воспроизведение
       ns.appendBytes(data);
       //Очищаем буфер
       data.clear();
      //Тригер состояния загрузки. О нем будет сказано ниже   
      inLoaded = false;
   };
   //Отправляем запрос на данные            
   client.request(uri,request);
}


Один важный момент для работы кода: объект NetStram должен быть в режиме создания данных, который включается так NetStream.play(null). Это должно быть сделано до того как поступят первые видеоданные.

Дальше по мере надобности следует подгружать оставшиеся части файла такими же кусками по 1 мегабайту. Размер в 1 мб (равен примерно 15 секундам видео) был получен экспериментально в ходе большого количества тестов и для нашей системы. Подгрузкой управляет таймер, который выполняет следующий код по событию.

if ((!inLoaded) && (ns.bufferLength <= Math.ceil(loadtime+timeDelta)) && (loadedBytes < filesize)){
       inLoaded = true;
       loadData();
}


Загрузку будем запускать, при соблюдении следующих условий:
inLoaded = false — индикатор состояния, потока true — грузятся данные, false — нет;
ns.bufferLength <= Math.ceil(loadtime+timeDelta) — в буфере воспроизведения осталось времени меньше или равно времени предыдущей загрузки(loadtime) плюс дельта для запаса (timeDelta);
loadedBytes < filesize — не достигнут конец воспроизводимого файла;

Важный момент в режиме создания данных не генерируются события NetStream.Play.Start, NetStream.Play.Stop. Поэтому надо следить за объемом загруженных данных или проигранным временем видео-ролика.

Переменная timeDelta позволяет подстроиться к качеству канала пользователя: в случае опустошения буфера воспроизведения раньше, чем закончится загрузка следующего фрагмента данных, дельта увеличивается.

Немного о перемотке

В режиме создания данных некоторые методы класса NetStream меняют свой обычный функционал. Это относится и к методу seek() — он осуществляет поиск ключевого кадра (так называемого I-кадра), расположенного ближе всего к указанной точке. В режиме создания данных после вызова метода seek класс начинает игнорировать все передаваемые методом appendBytes() данные, до вызова метода appendBytesAction(). Аргумент метода может иметь следующие значения NetStreamAppendBytesAction.RESET_BEGIN или NetStreamAppendBytesAction.RESET_SEEK, первый подразумевает наличие в свежих данных нового заголовка flv файла и обнуляет счетчики воспроизведения в классе. Вот пример кода перематывающего видео:

//Ищем в массиве навигационных точек, полученном из мета-данных видео-файла, смещения в байтах от начала файла по времени на которое кликнули на прогрессбаре воспроизведения
for (var i:Number=0;i<positions.length;i++){
          if ((positions[i]<=seekpositions)&&(positions[i+1]>=seektpositions)){
        //Смещаем наш счетчик загруженных данных на новое значение и новая загрузка начнется именно с этого места
               loadedBytes = positions[i];
               break;
           }
}
//Метод seek сбрасывает и игнорирует уже имеющиеся данные
ns.seek(seektime);
//Cообщаем классу NetStream том что мы просто переходим на новое место файла, а не начинаем проигрывать новый
ns.appendBytesAction(NetStreamAppendBytesAction.RESET_SEEK);
//Старт загрузки новых данных
loadData();


times — массив смещения по времени навигационных точек;
positions — массив смещения по байтам навигационных точек;

Пара замечаний

Одной особенностью библиотеки HTTPClientLib является то, что файл с политикой безопасности сокетов (crossdomain.xml) при кросс доменной работе запрашивается с 843 порта сервера, в отличии от стандартных объектов, которые могут подцепить его и с 80-го. Поэтому в конфигурации сервера nginx была добавлена следующая запись:

server {
       listen 843;
       server_name  localhost;
       location / {
              rewrite ^(.*)$ /crossdomain.xml;
       }
       error_page 400 /crossdomain.xml;
       location = /crossdomain.xml {
           root  /home/www-root;
           default_type  text/x-cross-domain-policy;
       }
}


Итоги

Применение плеера, который загружает видео по данной методике, позволило значительно сократить объем “лишнего — паразитного” трафика, и снизить нагрузку на потоковые сервера. В среднем на 200 000 просмотров трафик снизился на 30%. Однако, вышеизложенная методика имеет и “негативную” сторону, а именно: наличие у пользователя минимум 10.1 версии Flash Player.

Полный пример кода
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 10

    0
    Будьте аккуратны, если используете проксирование траффика в nginx: может так получиться, что в кэш попадёт файл, который запрашивали с Range заголовком, а потом он будет отдаваться клиентам.
      0
      Так получиться не может. При range запросе и включенном кэшировании если nginx не находит файла в кэше, то запрашивает с бэкенда целиком.
        0
        Эти сервера отдают только видео и кеширование там отключено, на всякий случай.
        0
        Организовывать HTTPшную отдачу, но при этом обязывать клиента ходить к нестандартному порту… Уж лучше по rtmp отдавать тогда — старт и перемотка быстрее. Да и алгоритм докачки очередной порции видео довольно странный, посмотрите как реализации html5 video в браузерах запрашивают куски видеофайла, они ориентируются по ключевым кадрам.
        Ну а про проблемы html5 video (отдача по заголовку Range:) и http pseudo streaming (через параметры GET-запроса) давно известно — невозможность нормального кэширования на промежуточных серверах и у клиента. Единственный случай, когда такие варианты работают хорошо, это в случае стримминга файлов напрямую с сервера-хранилища, тогда блочный файловый кэш справляется неплохо.
        Послушайте тов. erlyvideo, используйте HTTP Live Streaming (HLS) внутри флэша, а многие мобильные устройства его и без флэша понимают
          +1
          843 порт это стандартный порт для отдачи политики безопасности сокета при кроссдоменом доступе и и объект flash'а Socket запрашивает политику автоматически при соединении. И задача была реализовать без использования медиа-серверов тем более платных.
            0
            Для отдачи HLS совершенно необязательно использовать медиасервер, подготавливайте и храните контент в соответствующем формате, а отдается он любым HTTP-сервером как обычные файлы. Бесплатных сегментеров для HLS достаточно, а с конвертированием (ремуксом, вернее) видео в MPEG-TS отлично справляется ffmpeg. Минус там только один — на андроид 2.х придется проигрывать через флэш, т.к. нативная поддержка HLS есть только начиная с 3его андроида.
          0
          Почему не рассмотрели как вариант HDS?
            0
            Я так понимаю по данной схеме будет невозможен preload видео — когда у пользователя слабое интернет-соединение и он жмет паузу чтобы подгрузить видео для комфортного просмотра. Почему нельзя просто ограничить скорость отдачи видео в nginx?
              0
              Возможно, у нас реализован хитрый алгоритм диагностики канала и плеер автоматом переключается между режимами загрузки поток или дискретный, но это уже подпадает под корпоративную тайну и я не могу про него написать.
                0
                У нас в nginx скорость была ограничена но при большом количестве коннектов это не помогает.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое