Почему живой опрос ломает очевидное решение
Очевидная реализация — эндпоинт, который страница дёргает каждые несколько секунд. Он работает на тестах с пятью людьми и разваливается в проде, потому что живая аудитория синхронизирована так, как обычный веб-трафик не бывает никогда.
Все смотрят один и тот же момент. Ведущий открывает вопрос — и тридцать тысяч браузеров запрашивают его в одну и ту же секунду. Всем нужен одинаковый ответ, то есть вы выполняете один и тот же запрос тридцать тысяч раз и возвращаете одни и те же байты. Потом они продолжают спрашивать каждые несколько секунд до конца эфира, изменилось что-то или нет.
Привычный выход — 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?
Обычно да, но главная экономия — операционная. Нет сокетного слоя, который надо масштабировать, нет состояния соединений, нет шторма переподключений при обрыве мобильной сети. Затраты на источник следуют за частотой изменения опроса, а не за числом зрителей.