Почему живой опрос ломает очевидное решение
Очевидная реализация — эндпоинт, который страница дёргает каждые несколько секунд. Он работает на тестах с пятью людьми и разваливается в проде, потому что живая аудитория синхронизирована так, как обычный веб-трафик не бывает никогда.
Все смотрят один и тот же момент. Ведущий открывает вопрос — и тридцать тысяч браузеров запрашивают его в одну и ту же секунду. Всем нужен одинаковый ответ, то есть вы выполняете один и тот же запрос тридцать тысяч раз и возвращаете одни и те же байты. Потом они продолжают спрашивать каждые несколько секунд до конца эфира, изменилось что-то или нет.
Привычный выход — WebSocket: держать соединение на каждого зрителя и пушить. Это работает, но вы взяли на себя состояние соединений, масштабирование сокетного слоя, штормы переподключений при сбоях мобильной сети и запасной путь для клиентов, которые не могут держать соединение.
Есть более простой вариант — и именно он у нас в проде.
Приём: один кэшируемый URL, пурж при изменении
Состояние опроса маленькое, одинаковое для всех и меняется редко — несколько раз за эфир. Это ровно та форма, ради которой и создан CDN.
Поэтому опубликуйте его как обычный кэшируемый JSON-URL. Зрители запрашивают этот URL, и отвечает ближайший edge. Ваш источник видит первый запрос в каждой локации и практически ничего после, сколько бы людей ни смотрело.
Когда ведущий открывает, закрывает или редактирует вопрос, бэкенд пуржит этот единственный URL. Следующий запрос промахивается мимо кэша, один раз забирает новое состояние, и все последующие зрители снова обслуживаются из кэша.
Интересное свойство — что происходит при росте аудитории: ничего. Десять тысяч зрителей или сто тысяч, источник всё равно видит один запрос на изменение на локацию. Затраты следуют за тем, как часто меняется опрос, а не за тем, сколько людей смотрит.
Как это выглядит в yayında.tv
yayında.tv — наша платформа прямых трансляций, и весь механизм именно такой.
На чтении — один эндпоинт, который отдаёт активный опрос с вопросами и вариантами в JSON и обслуживается через CDN. Он шлёт разрешающие CORS-заголовки, чтобы плеер мог забрать данные с любого домена, куда встроена трансляция. URL несёт хеш, выведенный из имени канала и серверного секрета: это мешает тривиальному перебору и при этом не ломает кэшируемость — URL стабилен для конкретного канала, то есть это настоящий ключ кэша, а не ключ на пользователя.
На записи — несколько строк в контроллере, который запускает или останавливает опрос: собрать тот же URL, вызвать API пуржа, готово. Ни очереди, ни рассылки, ни сокетного слоя — рассылку делает CDN.
// 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?
Обычно да, но главная экономия — операционная. Нет сокетного слоя, который надо масштабировать, нет состояния соединений, нет шторма переподключений при обрыве мобильной сети. Затраты на источник следуют за частотой изменения опроса, а не за числом зрителей.