Loading...

Кейс

Опросы в прямом эфире при масштабе: приём «кэш и пурж»

Опрос в прямом эфире — худший из возможных сценариев чтения: все зрители хотят один и тот же ответ в одну и ту же секунду, а как только ведущий открывает новый вопрос, все спрашивают заново. Опрос бэкенда по таймеру этого не выдерживает. Вот как мы обеспечиваем опросы в yayında.tv и взаимодействия внутри плеера Videoonly примерно одним запросом к источнику на каждое изменение — без чего-то экзотичнее кэшируемого URL и вызова пурж.

9 Средний уровень Updated

Опросы в прямом эфире при масштабе: приём «кэш и пурж»

Почему живой опрос ломает очевидное решение

Очевидная реализация — эндпоинт, который страница дёргает каждые несколько секунд. Он работает на тестах с пятью людьми и разваливается в проде, потому что живая аудитория синхронизирована так, как обычный веб-трафик не бывает никогда.

Все смотрят один и тот же момент. Ведущий открывает вопрос — и тридцать тысяч браузеров запрашивают его в одну и ту же секунду. Всем нужен одинаковый ответ, то есть вы выполняете один и тот же запрос тридцать тысяч раз и возвращаете одни и те же байты. Потом они продолжают спрашивать каждые несколько секунд до конца эфира, изменилось что-то или нет.

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

Есть более простой вариант — и именно он у нас в проде.

Приём: один кэшируемый URL, пурж при изменении

Состояние опроса маленькое, одинаковое для всех и меняется редко — несколько раз за эфир. Это ровно та форма, ради которой и создан CDN.

Поэтому опубликуйте его как обычный кэшируемый JSON-URL. Зрители запрашивают этот URL, и отвечает ближайший edge. Ваш источник видит первый запрос в каждой локации и практически ничего после, сколько бы людей ни смотрело.

Когда ведущий открывает, закрывает или редактирует вопрос, бэкенд пуржит этот единственный URL. Следующий запрос промахивается мимо кэша, один раз забирает новое состояние, и все последующие зрители снова обслуживаются из кэша.

Интересное свойство — что происходит при росте аудитории: ничего. Десять тысяч зрителей или сто тысяч, источник всё равно видит один запрос на изменение на локацию. Затраты следуют за тем, как часто меняется опрос, а не за тем, сколько людей смотрит.

Как это выглядит в yayında.tv

yayında.tv — наша платформа прямых трансляций, и весь механизм именно такой.

На чтении — один эндпоинт, который отдаёт активный опрос с вопросами и вариантами в JSON и обслуживается через CDN. Он шлёт разрешающие CORS-заголовки, чтобы плеер мог забрать данные с любого домена, куда встроена трансляция. URL несёт хеш, выведенный из имени канала и серверного секрета: это мешает тривиальному перебору и при этом не ломает кэшируемость — URL стабилен для конкретного канала, то есть это настоящий ключ кэша, а не ключ на пользователя.

На записи — несколько строк в контроллере, который запускает или останавливает опрос: собрать тот же URL, вызвать API пуржа, готово. Ни очереди, ни рассылки, ни сокетного слоя — рассылку делает CDN.

The two halves
// READ — what every viewer hits, cached at the edge
GET https://cdn.example.tv/{channel}/survey?s={hash}

{
  "status": "success",
  "survey": {
    "id": 42,
    "questions": [
      { "text": "Who takes the penalty?",
        "answers": [{"id": 1, "text": "..."}, {"id": 2, "text": "..."}] }
    ]
  }
}

// WRITE — the broadcaster opens the poll; the backend purges that one URL
cdnctl purge --account <account_uuid> \
             --path "/{channel}/survey?s={hash}" --type exact

// or straight from the app, on the same event that flips the poll live

И внутри плеера: Videoonly

Videoonly применяет ту же идею внутри видеоплеера. Взаимодействия в плеере — слой с вопросом, карточка спонсора, призыв к действию, привязанный к моменту эфира — это одна и та же по форме задача: небольшой кусок состояния, нужный всем зрителям и меняющийся тогда, когда оператор решил его поменять.

Поэтому плеер забирает конфигурацию слоёв с кэшируемого URL, а не опрашивает API по таймеру. Когда кто-то планирует или снимает слой, этот URL пуржится. Плеер подхватывает изменение при следующем запросе, а API вообще не находится на горячем пути.

Та же логика работает для всего с такими свойствами: табло счёта, строка «сейчас в эфире», фиче-флаги для живого события, обратный отсчёт, который можно прервать.

Как не испортить детали

Несколько вещей определяют, будет ли это гладко или мучительно на практике.

**Держите URL стабильным.** Всё, что уникально для пользователя, в пути или query — идентификатор сессии, антикэш-таймстамп — даёт каждому зрителю собственный ключ кэша, и вы снова бьёте по источнику за всех. Персонализации место во втором, отдельном запросе, а не в общем.

**Ставьте TTL, с которым вы всё равно готовы жить.** Быстрым изменение делает пурж, но конечный TTL — ваша страховка на день, когда вызов пуржа не сработает. Достаточно короткий, чтобы пропущенный пурж был заминкой, и достаточно длинный, чтобы источник оставался в покое.

**Пуржите ровно тот URL, который отдаёте.** Если кэшируемый объект — `?s=abc`, а вы пуржите путь без query, ничего не инвалидируется и старый опрос остаётся на экране. Это самый частый способ, которым приём тихо ломается.

**Разделяйте чтение и запись.** Зрители читают кэшируемый URL; голоса идут на обычный, некэшируемый эндпоинт. Записи — малая доля от чтений: зритель голосует один раз и читает постоянно, так что путь записи может оставаться обычным.

**Не кладите секреты в хеш URL.** Это обфускация, а не авторизация: достаточно, чтобы отсечь случайный перебор, и это должно оставаться совместимым с кэшем. Всё, что действительно нуждается в защите, не место в общем кэшируемом объекте.

Когда всё-таки брать WebSocket

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

Он подходит, когда состояние общее для всех, маленькое и меняется в человеческом темпе: опросы, слои, табло, флаги. Он не подходит, когда каждому зрителю нужны свои данные, когда обновления непрерывны, а не эпизодичны, или когда нужна доставка быстрее секунды с гарантиями порядка. Живой чат — очевидный контрпример: индивидуальный, постоянный и чувствительный к задержкам. Там нужен сокетный слой.

Честная формулировка такова: большинство «реалтайм»-функций на странице трансляции вовсе не реалтайм. Это эпизодические изменения общего состояния, и кэшируемый URL плюс пурж решают их с долей от количества движущихся частей.

Частые вопросы

Как быстро зрители видят изменение?

Настолько быстро, насколько распространится пурж, плюс следующий запрос клиента. На практике это одна-две секунды — с запасом для эфира, ведь ведущий как раз говорит поверх перехода.

Что если вызов пуржа не сработает?

Для этого и нужен TTL. Неудачный пурж означает, что изменение вступит в силу по истечении объекта, а не сразу. Держите TTL достаточно коротким, чтобы это была задержка, а не сбой, и логируйте неудачные пуржи, чтобы заметить закономерность.

Работает ли это с query string в URL?

Да, если CDN включает query string в ключ кэша и вы пуржите ровно тот URL, который отдаёте. Пуржить только путь, когда объект закэширован с query string, — классическая ошибка.

Куда идут голоса?

На обычный некэшируемый эндпоинт. Кэшируется только общее чтение. Голоса — крошечная доля трафика, потому что каждый зритель голосует один раз, а читает постоянно.

Это дешевле WebSocket?

Обычно да, но главная экономия — операционная. Нет сокетного слоя, который надо масштабировать, нет состояния соединений, нет шторма переподключений при обрыве мобильной сети. Затраты на источник следуют за частотой изменения опроса, а не за числом зрителей.