golang

HLS Lazy Muxing 2.0 Как срезать 80% CPU и не остановить запись архива

  • среда, 12 августа 2026 г. в 00:00:21
https://habr.com/ru/articles/1069188/

Привет, хабарчане! Это третья статья про разработку RUSEON-core, Zero-Copy сервера потокового видео для AI-платформ и Edge-видеоинфраструктуры. В предыдущих статьях (первая и вторая), были разобраны моменты с оптимизацией раздачи потоков при больших одновременных нагрузках, а также узкие места ОС в контексте потоковой записи на диск. В этой же статье, я хочу рассказать, о другой более специфичной проблеме – HLS-мультиплексировании.

Если кто то, когда-либо занимался видеопотоками поверх интернета, т.е использовал классический медиасервер работающий по принципу RTSP to HLS (или ему подобные протоколы) тогда он знает какие проблемы возникают. Для примера возьмем двух гигантов, Flussonic и Wowza. В документации Flussonic`a есть четкие аппаратные характеристики при которых ЦП начнет долбиться в сотку: 250 камер с битрейтом 2 Мбит/c, на Xeon E3-1230v5 3.4 GHz + 32 GB RAM. У Wowza же четких примеров нет, но если немного «экстраполировать», то у нее на том же железе около 350-400 камер с битрейтом 2 Мбит/c, загружают процессор на сотку. Давайте внесу оговорку: я понимаю, что указанное железо довольно старое. Но 80% малого и среднего бизнеса на +/- таком работают, я имею ввиду тех, кто не пользуется облачной инфраструктурой или не арендует железо в ДЦ. Так вот, казалось бы, ну такая вот нагрузка, что с ней поделать? Проблема в том, что в моменте эти потоки никто не смотрит. Условный «охранник» залипает в телефоне, пьет кофе – а сервак 24/7 продолжает нарезать сырые кадры в HLS-сегменты и писать архив, грея серверную. Банальное расходование ресурсов в пустую, когда они не нужны. А в наш век стоимости на железо – стоит беспокоиться о таких вещах.

Решение на поверхности, и многие о нем знают – режим On Demand (по запросам). Нет зрителей – тушим камеру.  Есть зритель – отдаем HLS-поток. Казалось бы, все идеально, вопрос решен, расходимся. Но тут вылезает главная проблема классического On Demand - как только зрителей нет, соответственно камера потушена, запись архива не ведется. Для любой системы безопасности – это новомодный «ред флаг».

В разработке RUSEON Core, долго бодался с этой архитектурной вилкой, ведь главная задача этого проекта – минимум потребления ресурсов, за максимум профита. В итоге была придумана фича которую я называю Lazy Muxing 2.0 (как звучит то, а?). Суть проста: ядро работает нон стоп, архив пишется, а засыпает только самая тяжелая часть – HLS муксер. Сейчас, постараюсь объяснить, как это было реализовано, и какие подводные камни можно обнаружить.

Low-Copy подход

Классический пайплайн видеосервера – это монолит. Но как я уже писал в прошлых статьях – архитектурно RUSEON распилен на независимые куски. Это не микросервисы, и не попытка разнести все по отдельным процессам. Речь именно об изоляции модулей внутри одного процесса, т.е не один модуль не должен тянуть за собой другой и уж тем более влиять на его жизненный цикл.

Если посмотреть под капот (ring.go), то видно, что крутится изолированная горутина, которая непрерывно тащит RTSP. Кадры складываются в кольцевой буффер, а вот дальше используется как раз-таки low-copy подход. Запись на диск (recorder.go), это простой подписчик на этот самый буфер. Он читает указатели на память и сбрасывает байты на диск. Все. Ему глубоко плевать смотрит кто-то видел или нет. Архив пишется 24/7.

// internal/recorder/recorder.go (упрощенно)

func (r *Recorder) run() {

    // Подписываемся на RingBuffer (Data Plane)

    reader := r.ringBuffer.NewReader()

    defer reader.Close()

    for {

        // Читаем указатель на кадр, не копируя сами байты (Low-Copy)

        frame := reader.Read()

        if frame == nil {

            break

        }

        // Скидываем байты NAL-юнитов на диск

        r.writeToDisk(frame)

    }

}

А вот тяжелый HLS-муксер (muxer.go), не является обязательной частью пайплайна, он тупо висит как «ленивый плагин» и подключается к тому же буфферу только тогда – когда есть потребитель. Нет HTTP-запросов к плейлисту? Муксера не существует в памяти. Получается некая аксиома – Архив – постоянный, HLS – временный. Именно это разграничение, позволяет не тратить ресурсы железа на создание и работу HLS-потока для камеры – которую в данный момент никто не смотрит.

А в чем разница между тем что сделано в том же Flussonic?

Тут честно встает вопрос о том, что: разве это что-то принципиально новое? Нет. Например FLussonic – там давно реализовано разделение между приемом потока, DVR и его доставкой.

В доке по флуссонику они описывают свой подход как just-in-time packaging: сервер принимает поток один раз и может сразу на лету резать его в разные форматы — HLS, DASH, RTMP и т.д. При этом DVR (это их архив) является отдельным модулем, которая нон стопом пишет поток.

То есть в целом у них примерно такая схема:


                 ┌──────────────► DVR / Archive

                 │

Camera ──► Ingest

                 │

                 └──────────────► JIT Packaging

                                      │

                                      ├── HLS

                                      ├── DASH

                                      └── другие протоколы

Но здесь есть важный нюанс с термином On-demand. В Flussonic ondemand в первую очередь относится к жизненному циклу самого входного потока: если зрителей нет, источник тушится, а при появлении запроса поток снова запускается. Тут скорее используется механизм экономии на ingest и трафике.

В нашей реализации подход несколько иной. Поток и запись никогда не зависят от наличия зрителя. RTSP-поток принимается всегда, ring buffer работает, а recorder пишет архив 24/7. On-demand применяется только к тяжелейшему участку пути — HLS-упаковка.

Тоесть наша схема примерно такая:

Нет зрителя

     ↓

не создавать HLS muxer

     ↓

ingest работает

     ↓

archive работает

Исходя из этого, правильнее сказать что у Flussonic On-demand управляет жизненным циклом источника (RTSP-соединения), а Lazy Muxing в нашей реализации — это исключительно управление жизненным циклом тяжелого пайпалайна (HLS-упаковкой), при этом источник (RTSP) остается горячим и активным для записи.

При этом сам принцип у Flussonic, конечно, гораздо обширнее нашей реализации: это полноценная система доставки одного источника в несколько выходных протоколов. У нас же гораздо более узкая задача — не держать HLS-мультиплексирование для камер, которые никто сейчас не смотрит. Это позволяет жестко припаять жизненный цикл муксера, к наличию реального HLS-зрителя.

Опоздавшие зрители и старт с низкой задержкой

Еще одна проблема On-demand, это его относительно долгое «оживление». Клиент жмет play, ну либо включен auto-play но он только открыл браузер/плеер и…. смотрит на крутящейся лоадер 5-15 секунд. Почему так? Нюанс в самом протоколе HLS. HLS-сегмент обязан начинаться с I-кадра (ключевой кадр). Тогда получается, что муксер просыпается и тупо ждет, пока камера не пришлет новый ключевой кадр. Если тот же GOP настроен на камере на 2-3-4-5 секунд, клиент будет ждать 2-3-4-5 секунд + время на буфферизацию самого плеера. В самых «запущенных» случаях, общее время от первого клика до появления картинки – может быть близким к 30 секундам. Никто не любит задержки, у нас вон на подходе HTTP/3 одним из преимуществ которого является уменьшение задержки.

Что было сделано чтобы это обойти? Ленивый муксер при старте вообще не ждет новых кадров. Он делает дамп RingBuffer, т.к буфер кольцевой (держим около 30 кадров в нем) – то там гарантированно валяется предыдущий ключевой кадр. Муксер мгновенно вытаскивает этот исторический GOP, склеивает первый. ts сегмент и отдает его плееру. Зритель же получает картинку почти без задержек (буфферизация плеера никуда не делась).

// internal/hls/muxer.go

func (m *Muxer) run() {

    // Получаем канал, куда RingBuffer СРАЗУ выкинет исторический GOP

    reader := m.ringBuffer.NewReader()

    defer reader.Close()
 
    for {

        frame := reader.Read()


        if currentBuf == nil {

            // Игнорируем мусор, ждем первый исторический I-Frame из дампа

            if !frame.IsKeyFrame {

                continue

            }
            // Мгновенно формируем первый .ts сегмент!
          
            tsWriter = mpegts.NewWriter(currentBuf, tracks)

        }

        // ...

    }

}

Нюанс тут в том, что видео начинается на секунду раньше реального времени, но для зрителя появление картинки почти моментальное.

Сразу скажу, что есть ограничения. Если у вас камеры (обычно это совсем дешевые китайские PTZ) выдают ключевой кадр раз в 10 секунд или не дай бог больше – то держать такой жирный буфер в ОЗУ, самоубийство по памяти. У нас буфер оттюнен на небольшой объем кадров и частые ключевые кадры. Мы не пытаемся компенсировать «плохую» настройку камеры, бесконечным увеличением буффера.

Как понять, что зритель ушел или мини-Watchdog

C WebRTC все понятно, сокет отвалился, зрителя нет. А вот с HLS все несколько сложнее. Это простой HTTP. Плеер просто дергает файл .m3u8 раз в пару секунд. Поначалу была идея прикрутить хитрую аналитику по TCP-сессиям, но все же было решено не усложнять. Зачем изобретать велосипед, если можно обойтись и без него.

Был реализован очень простой, дубовый и топорный Watchdog. Просто оставлю код:

// internal/stream/stream.go

// 1. Вызывается при каждом HTTP-запросе к .m3u8 плейлисту

func (s Stream) WakeUpHLSMuxer() hls.Muxer {

    s.muxerMu.Lock()

    defer s.muxerMu.Unlock()

    s.lastHLSRequest = time.Now() // Обновляем таймер

    // Если Muxer спал - поднимаем его

    if s.hlsMuxer == nil {

        s.hlsMuxer = hls.NewMuxer(s.ID, s.ringBuffer)

    }

    return s.hlsMuxer

}

// 2. Горутина-сторож (Watchdog), работающая в фоне

func (s *Stream) lazyHLSWatchdog() {

    ticker := time.NewTicker(1 * time.Minute)

    for range ticker.C {

        s.muxerMu.Lock()

        // Если тишина больше 60 секунд — мягко убиваем муксер

        if s.hlsMuxer != nil && time.Since(s.lastHLSRequest) > 60*time.Second {

            s.hlsMuxer.Stop()

            s.hlsMuxer = nil // Освобождаем память и CPU

        }

        s.muxerMu.Unlock()

    }

}

Профит в цифрах

Слова – это круто, но давайте смотреть на метрики. Много крутых фич на бумаге, а в проде появляются утечки горутин. Для тестов была написана утилита, которая симулирует 100 камер на 30 FPS. 3000 кадров в секунду. Сравнивали 2 сценария:

1. 100 активных HLS-зрителей + рекордер

2. 100 спящих HLS-зрителей, работает только рекордер

График памяти во втором случае – идеальная пила. Средняя линия – 1.3 ГБ. Что происходит с CPU? Он падает почти в 5 раз. Ядро спокойно переживает 3к FPS для непрерывного архива, вообще не напрягаясь на мультиплексирование. Спящие зрители, реально высвобождают ресурсы сервера, а не просто висят мертвым грузом. По сути вы получаете плотность потоков как у голого RTSP-релея, но с удобством веб плееров и полноценной записью в архив.

========================================

RUSEON Core Capacity Test

Cameras: 100 | Viewers: 100 (Спящие) | Duration: 60s

========================================

[*] Starting cameras and pipelines...

[*] Load test running...

Memory: 1056 MB (Alloc) | GC Pauses: 10

Memory: 1660 MB (Alloc) | GC Pauses: 11

Memory: 1241 MB (Alloc) | GC Pauses: 12  <-- Очистка GC (Идеальная пила)

...

Memory: 2471 MB (Alloc) | GC Pauses: 14

Memory: 1355 MB (Alloc) | GC Pauses: 15

========================================

RESULTS

Frames Ingested: 181 800

Average Ingest FPS: 3026.68

Final Memory Alloc: 1517 MB (Базовая линия)

======================================== 

Исходники: https://github.com/RUSEGAL/ruseon-core