Перейти к основному содержимому

Mock собеседование - System Design

· 37 мин. чтения

Сегодня мы разберём пример System Design собеседования, в котором интервьюер и кандидат совместно проектируют систему загрузки и обработки видео для воображаемого видеохостинга, обсуждая архитектурные решения, выбор технологий и масштабирование под растущую нагрузку. В ходе диалога демонстрируются ключевые принципы проектирования распределённых систем: декомпозиция на сервисы, использование очередей сообщений, кэширования, CDN, а также важность уточнения требований и адаптации решения под конкретные бизнес-приоритеты. Мы увидим, как один и тот же кейс можно решать по-разному, и поймём, почему в System Design нет единственного правильного ответа — важнее ход мысли, умение вести диалог и обосновывать выбор.

Вопрос 1. Что такое System Design интервью и как часто его спрашивают?

Таймкод: 00:00:21

Ответ собеседника: Правильный. System Design — это кейс, где нужно спроектировать систему с множеством вариантов решения в зависимости от нагрузки и спецификации. Во время диалога выясняется глубина опыта человека, умение изъясняться, задавать вопросы и подстраиваться под неизвестные заранее требования. Такие собеседования появились не так давно, спрашивают их не все компании, на позициях middle и выше, особенно популярны в bigtech.

Правильный ответ:

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

Что оценивается на System Design интервью:

1. Умение собирать требования

Кандидат должен сам задавать уточняющие вопросы перед началом проектирования:

  • Функциональные требования: что система должна делать
  • Нефункциональные требования: масштабируемость, доступность, задержки
  • Оценка нагрузки: DAU, QPS, объём данных, соотношение чтений и записей
  • Ограничения: бюджет, сроки, существующий стек технологий

2. Умение проектировать архитектуру

  • High-level design: основные компоненты и их взаимодействие
  • Detailed design: углубление в ключевые подсистемы
  • Выбор технологий с обоснованием
  • Проработка граничных случаев и отказоустойчивости

3. Коммуникация и адаптивность

  • Способность объяснять решения на доске или в документе
  • Реакция на фидбэк интервьюера
  • Готовность менять подход при поступлении новой информации

Как часто спрашивают:

  • BigTech (Google, Amazon, Meta, Microsoft, Apple): обязательно для позиций от middle и выше, обычно 1–2 раунда
  • Стартапы и средние компании: зависит от стадии роста — на ранних стадиях реже, при масштабировании чаще
  • Финтех и e-commerce: часто спрашивают из-за высоких требований к надёжности и производительности
  • Позиции: для junior разработчиков практически не используется, для middle — иногда, для senior и выше — стандартная часть процесса

Типичные задачи:

  • Спроектировать URL-шортенер (TinyURL)
  • Спроектировать систему чата (WhatsApp)
  • Спроектировать ленту новостей (Twitter)
  • Спроектировать систему рекомендаций
  • Спроектировать распределённое хранилище файлов (Dropbox)

Подготовка к System Design интервью требует изучения паттернов проектирования распределённых систем, понимания компромиссов между согласованностью и доступностью, а также практики в оценке нагрузки и выборе подходящих технологий для конкретных сценариев.

Вопрос 2. Чем System Design интервью отличается от реального проектирования?

Таймкод: 00:01:48

Ответ собеседника: Правильный. System Design — это воображаемая игра в проектирование, где доступны любые ресурсы и бесконечные деньги. Реальное проектирование ограничено бюджетом, историческими причинами, размером команды и компетенциями. В bigtech такие интервью сильно формализованы и к ним можно точечно подготовиться, а в маленьких компаниях — более хаотичные и зависят от проекта и должности.

Правильный ответ:

System Design интервью и реальное проектирование — это два принципиально разных процесса, которые пересекаются только в базовых инженерных принципах.

Ключевые отличия:

1. Контекст и ограничения

На собеседовании кандидат работает в идеализированных условиях: неограниченный бюджет, доступ к любым технологиям, отсутствие legacy-кода. В реальности инженер всегда ограничен:

  • Бюджетом на инфраструктуру
  • Существующим стеком технологий и интеграциями
  • Размером и компетенциями команды
  • Дедлайнами бизнеса
  • Техническим долгом, накопленным за годы

2. Временные рамки

Интервью длится 45–60 минут, за которые нужно показать мышление. Реальный проект может развиваться месяцами или годами, с итеративным улучшением архитектуры по мере поступления новой информации.

3. Процесс принятия решений

На собеседовании решения принимаются одним человеком за короткое время. В реальности:

  • Решения обсуждаются командой
  • Проводятся proof-of-concept и A/B тесты
  • Учитывается мнение стейкхолдеров
  • Документируются через RFC или design docs

4. Обратная связь и итерации

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

5. Цель процесса

  • Интервью: продемонстрировать глубину знаний, способность к абстрактному мышлению и коммуникацию
  • Реальность: создать работающую, поддерживаемую и экономически эффективную систему

Почему это важно понимать:

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

Пример:

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

Вопрос 3. На что обращать внимание при проведении System Design интервью в качестве интервьюера?

Таймкод: 00:03:28

Ответ собеседника: Правильный. Оценивается кандидат в рамках фантазии на тему совместного проекта. Важно придумать воображаемый проект, где потребуется определённая нагрузка, чтобы послушать как человек думает, мыслит вслух и представляет дизайн. Нет смысла проектировать highload-сервис, если на самом деле нагрузка 100 пользователей в секунду. У каждого проекта свой контекст.

Правильный ответ:

Проведение System Design интервью требует от интервьюера не только технической экспертизы, но и навыков фасилитации. Вот на что стоит обращать внимание:

1. Умение кандидата собирать требования

Хороший кандидат не начинает проектирование сразу, а задаёт уточняющие вопросы:

  • Какой ожидаемый DAU/MAU?
  • Какое соотношение чтений к записям?
  • Какие требования к задержкам (latency)?
  • Есть ли требования к согласованности данных?
  • Какой бюджет доступен?

Если кандидат сразу рисует архитектуру без уточнений — это красный флаг.

2. Процесс мышления, а не результат

Оценивайте как кандидат приходит к решениям:

  • Мыслит ли он вслух?
  • Рассматривает ли несколько вариантов перед выбором?
  • Может ли обосновать каждый выбор?
  • Признаёт ли компромиссы своих решений?

3. Глубина знаний в ключевых областях

Обращайте внимание на понимание:

  • Балансировки нагрузки и горизонтального масштабирования
  • Кеширования (стратегии, инвалидация, согласованность)
  • Выбора баз данных (SQL vs NoSQL, шардирование, репликация)
  • Очередей сообщений и асинхронной обработки
  • Отказоустойчивости и обработки сбоев

4. Реакция на фидбэк и направляющие вопросы

Хороший кандидат:

  • Слушает подсказки и адаптирует решение
  • Не упирается в своём первоначальном варианте
  • Может объяснить, почему изменил решение

5. Коммуникация и структурированность

  • Может ли кандидат объяснить сложные концепции простым языком?
  • Использует ли диаграммы для визуализации?
  • Структурирует ли презентацию решения?

6. Адекватность выбора технологий

Кандидат должен выбирать решения под задачу, а не использовать шаблонные ответы:

  • Не нужно проектировать систему на 1 млн RPS, если задача описывает 100 RPS
  • Выбор технологий должен быть обоснован требованиями задачи

Рекомендации для интервьюера:

  • Подготовьте задачу заранее с чёткими требованиями и возможными направлениями углубления
  • Используйте рубрику оценки (scorecard) для объективности
  • Давайте кандидату пространство для мышления, но направляйте, если он уходит в сторону
  • Документируйте наблюдения сразу после интервью для объективной оценки

Вопрос 4. Какие уточняющие вопросы по функционалу и нагрузке стоит задать перед началом проектирования системы загрузки видео?

Таймкод: 00:06:56

Ответ собеседника: Правильный. Уточнены следующие параметры: размер среднего видео — 1 ГБ, средняя скорость загрузки — 10 МБ/с, количество единовременных пользователей — 10, дневная нагрузка на загрузку — около 8,5 ТБ. Файлы успешно загруженные хранятся бессрочно, прерванные удаляются в течение 60 минут. Главная функция — собрать видео из кусочков, сохранить, обработать, незавершённые отбрасывать. Дополнительных требований по ранжированию и статистике нет. Жёстких требований по времени конвертации нет — 5-10 минут приемлемо. Главный приоритет — надёжность загрузки, чтобы ни один кусочек не потерялся.

Правильный ответ:

Перед началом проектирования системы загрузки видео необходимо уточнить два блока требований: функциональные и нефункциональные (нагрузка и качество сервиса).

Функциональные требования:

1. Процесс загрузки

  • Какой максимальный размер одного видео?
  • Поддерживается ли дозагрузка (resumable upload) при обрыве соединения?
  • Как организована загрузка — одним файлом или по частям (chunked upload)?
  • Какие форматы видео поддерживаются?

2. Обработка видео

  • Нужна ли конвертация в разные форматы и разрешения (transcoding)?
  • Какие требования к времени обработки после загрузки?
  • Нужна ли генерация превью (thumbnails)?
  • Требуется ли контент-модерация?

3. Хранение и жизненный цикл

  • Как долго хранятся успешно загруженные видео?
  • Что происходит с незавершёнными загрузками?
  • Есть ли политика удаления или архивации?

4. Дополнительный функционал

  • Нужна ли статистика просмотров?
  • Требуется ли ранжирование или рекомендательная система?
  • Нужна ли интеграция с CDN для раздачи?

Нефункциональные требования:

1. Нагрузка

  • Ожидаемое количество пользователей (DAU/MAU)?
  • Среднее и пиковое количество одновременных загрузок?
  • Средний размер загружаемого видео?
  • Суточный объём загружаемых данных?

2. Производительность

  • Приемлемое время загрузки?
  • Требования к времени обработки после загрузки?
  • Допустимая задержка при отдаче видео?

3. Надёжность

  • Какой уровень доступности требуется (SLA)?
  • Допустима ли потеря данных?
  • Как обрабатывать сбои при загрузке?

4. Приоритеты

Важно явно спросить, что является главным приоритетом:

  • Надёжность загрузки (ни один кусок не должен потеряться)
  • Скорость обработки
  • Стоимость инфраструктуры
  • Масштабируемость

Пример уточнённых требований из диалога:

  • Средний размер видео: 1 ГБ
  • Скорость загрузки: 10 МБ/с
  • Одновременных пользователей: 10
  • Суточная нагрузка: ~8,5 ТБ
  • Незавершённые загрузки удаляются через 60 минут
  • Время конвертации: 5–10 минут приемлемо
  • Главный приоритет: надёжность загрузки

Такие уточнения позволяют принять обоснованные решения по архитектуре: выбор между синхронной и асинхронной обработкой, необходимость chunked upload, выбор хранилища, стратегия обработки сбоев.

Вопрос 5. Что является главным бизнес-приоритетом сервиса загрузки видео?

Таймкод: 00:12:34

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

Правильный ответ:

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

Главный приоритет: надёжность загрузки и целостность данных

Для сервиса загрузки видео это означает:

1. Гарантия приёма всех кусочков

  • Система должна принять каждый загруженный чанк (chunk)
  • Ни один кусок не должен быть потерян при стабильном соединении
  • При обрыве соединения должна быть возможность дозагрузить только недостающие части

2. Обеспечение целостности

  • Верификация каждого чанка после загрузки (checksum)
  • Корректная сборка чанков в правильном порядке
  • Валидация итогового файла

3. Почему это главный приоритет

  • Пользователь загружает видео размером 1 ГБ — это значительные временные затраты
  • Потеря данных после загрузки 90% файла неприемлема с точки зрения UX
  • Повторная загрузка всего видео из-за потери одного чанка — плохой пользовательский опыт
  • Надёжность — это базовое требование, без которого другие функции не имеют значения

Архитектурные следствия этого приоритета:

  • Использование chunked upload с подтверждением каждого чанка
  • Хранение метаданных о загруженных частях
  • Механизм дозагрузки (resumable upload)
  • Асинхронная обработка после полной загрузки
  • Идемпотентность операций загрузки

Компромиссы, допустимые при таком приоритете:

  • Время конвертации 5–10 минут приемлемо — надёжность важнее скорости обработки
  • Можно пожертвовать дополнительным функционалом (статистика, ранжирование) ради надёжности базового сценария
  • Допустимо увеличение стоимости инфраструктуры для обеспечения отказоустойчивости

Принцип: лучше медленно и надёжно, чем быстро с потерей данных.

Вопрос 6. Как будет организовано хранение обработанного видео и что включает в себя одна единица хранения?

Таймкод: 00:12:45

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

Правильный ответ:

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

Единица хранения: папка или логическая группа файлов для одного видео

Одна единица хранения включает:

1. Варианты видео с разным битрейтом (Adaptive Bitrate Streaming)

Для поддержки адаптивного стриминга видео конвертируется в несколько вариантов:

  • 240p, 360p, 480p, 720p, 1080p, 4K
  • Каждый вариант имеет свой битрейт и размер файла
  • Видеоплеер выбирает подходящий вариант в зависимости от скорости соединения пользователя

2. Аудиодорожки

  • Разные языки (для мультиязычного контента)
  • Разное качество аудио
  • Описание для слабовидящих (audio description)

3. Сегменты для потокового вещания

Для протоколов HLS или DASH видео разбивается на короткие сегменты (обычно 2–10 секунд):

  • .ts или .mp4 сегменты
  • Индексный файл (.m3u8 для HLS, .mpd для DASH)

4. Метаданные

  • Превью (thumbnails) разных размеров
  • Субтитры в формате VTT или SRT
  • Информация о длительности, разрешении, кодеках

Выбор хранилища:

Объектное хранилище (S3, GCS, MinIO)

Это оптимальный выбор для хранения видео:

  • Масштабируемость без ограничений
  • Низкая стоимость хранения
  • Высокая доступность (99.999999999% для S3)
  • Интеграция с CDN для раздачи

Структура хранения:

bucket/
videos/
{video_id}/
metadata.json
thumbnails/
small.jpg
medium.jpg
large.jpg
audio/
ru.mp3
en.mp3
stream/
720p/
segment_001.ts
segment_002.ts
playlist.m3u8
1080p/
segment_001.ts
segment_002.ts
playlist.m3u8

Стоимость хранения:

При указанных параметрах:

  • Средний размер видео: 1 ГБ
  • Суточная нагрузка: ~8,5 ТБ
  • С учётом разных битрейтов: ~3–5 ГБ на одно видео
  • Месячный объём: ~750 ТБ – 1,25 ПБ

Важные аспекты:

  • Политика хранения: успешно загруженные видео хранятся бессрочно
  • Незавершённые загрузки удаляются через 60 минут
  • Для экономии можно использовать разные классы хранения (hot/cold/archive)
  • CDN для кэширования популярного контента и снижения нагрузки на хранилище

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

Таймкод: 00:13:44

Ответ собеседника: Правильный. Сервер-загрузчик принимает файлы по частям через HTTP, размечает большой файл на фрагменты на клиенте. Исходный файл кладётся в хранилище необработанных данных (blob-хранилище). Процесс кодировки разделён — есть планировщик задач, который ставит задачи на обработку большого файла, разбивая его на маленькие. Также есть пул ресурсов для исполнения задач. Обработанные видео хранятся локально или во внешнем blob-хранилище.

Правильный ответ:

Архитектуру системы загрузки и обработки видео стоит начинать с разделения на функциональные компоненты и определения потоков данных.

High-Level Architecture:

[Client] → [Load Balancer] → [Upload Service] → [Raw Storage]

[Message Queue]

[Scheduler/Orchestrator]

[Worker Pool]

[Processed Storage] → [CDN] → [Player]

1. Компонент загрузки (Upload Service)

  • Принимает чанки от клиента по HTTP
  • Верификация целостности каждого чанка (checksum)
  • Хранение метаданных о загруженных частях
  • Подтверждение успешной загрузки каждого чанка
type UploadService struct {
storage blob.Storage
metadata metadata.Repository
validator *ChecksumValidator
}

func (s *UploadService) UploadChunk(ctx context.Context, req ChunkRequest) error {
// Верификация чанка
if err := s.validator.Verify(req.Data, req.Checksum); err != nil {
return fmt.Errorf("checksum mismatch: %w", err)
}

// Сохранение чанка в raw storage
chunkPath := fmt.Sprintf("%s/chunks/%d", req.VideoID, req.ChunkIndex)
if err := s.storage.Put(ctx, chunkPath, req.Data); err != nil {
return fmt.Errorf("failed to store chunk: %w", err)
}

// Обновление метаданных
return s.metadata.MarkChunkUploaded(ctx, req.VideoID, req.ChunkIndex)
}

2. Хранилище необработанных данных (Raw Storage)

  • Blob-хранилище (S3, GCS, MinIO)
  • Временное хранение загруженных чанков
  • Автоматическая очистка незавершённых загрузок через 60 минут

3. Очередь сообщений (Message Queue)

  • Событие о завершении загрузки публикуется в очередь
  • Гарантия доставки сообщения
  • Возможность повторной обработки при сбое

4. Планировщик/оркестратор (Scheduler)

  • Получает событие о завершении загрузки
  • Разбивает задачу обработки на подзадачи
  • Управляет зависимостями между задачами
  • Обрабатывает сбои и повторы
type Scheduler struct {
queue queue.Queue
workers worker.Pool
storage blob.Storage
}

func (s *Scheduler) HandleUploadCompleted(ctx context.Context, event UploadCompletedEvent) error {
// Сборка чанков в единый файл
tasks := []Task{
{Type: "assemble", VideoID: event.VideoID},
{Type: "transcode_720p", VideoID: event.VideoID, DependsOn: "assemble"},
{Type: "transcode_1080p", VideoID: event.VideoID, DependsOn: "assemble"},
{Type: "generate_thumbnails", VideoID: event.VideoID, DependsOn: "assemble"},
{Type: "create_manifest", VideoID: event.VideoID, DependsOn: "transcode_720p,transcode_1080p"},
}

return s.workers.SubmitBatch(ctx, tasks)
}

5. Пул воркеров (Worker Pool)

  • Горизонтальное масштабирование
  • Каждый воркер выполняет одну задачу
  • Обработка видео — CPU-intensive операция
  • Возможность использования GPU для ускорения

6. Хранилище обработанных данных

  • Организация по папкам (одна папка = одно видео)
  • Разные битрейты, аудиодорожки, сегменты
  • Интеграция с CDN для раздачи

Принципы проектирования:

  • Разделение ответственности: загрузка и обработка — независимые процессы
  • Асинхронность: обработка происходит после загрузки, не блокируя клиента
  • Идемпотентность: повторное выполнение задачи не приводит к ошибкам
  • Отказоустойчивость: сбой одного компонента не влияет на остальные
  • Масштабируемость: каждый компонент масштабируется независимо

Вопрос 8. Какой протокол взаимодействия между клиентом и сервисом загрузки выбрать — HTTP или брокер сообщений?

Таймкод: 00:17:04

Ответ собеседника: Правильный. HTTP для взаимодействия с клиентом через POST-запросы. Для внутренней коммуникации между сервисами — очередь (брокер сообщений). Kafka имеет ограничение на размер сообщения около мегабайта, что неудобно для бинарных данных, поэтому можно рассмотреть асинхронный инструмент или написать свой. Предпочтительный вариант — сначала полностью загрузить файл, проверить целостность и только потом начинать обработку.

Правильный ответ:

Выбор протокола взаимодействия зависит от типа коммуникации: клиент-сервис или сервис-сервис.

1. Клиент ↔ Сервис загрузки: HTTP

HTTP — оптимальный выбор для взаимодействия с клиентом:

Преимущества:

  • Универсальная поддержка всеми клиентами (браузеры, мобильные приложения, desktop)
  • Простота реализации и отладки
  • Поддержка chunked transfer encoding
  • Возможность использования HTTPS для безопасности
  • Стандартные инструменты мониторинга и логирования

Реализация chunked upload:

// Endpoint для загрузки чанка
func (s *UploadService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
videoID := vars["videoID"]
chunkIndex, _ := strconv.Atoi(r.Header.Get("X-Chunk-Index"))
checksum := r.Header.Get("X-Checksum")

data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}

req := ChunkRequest{
VideoID: videoID,
ChunkIndex: chunkIndex,
Data: data,
Checksum: checksum,
}

if err := s.UploadChunk(r.Context(), req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusOK)
}

2. Сервис ↔ Сервис: Брокер сообщений

Для внутренней коммуникации используется очередь сообщений:

Почему не Kafka для бинарных данных:

  • Ограничение размера сообщения: по умолчанию 1 мб
  • Оптимизирована для потоков событий, не для передачи файлов
  • Увеличение лимита возможно, но это антипаттерн

Правильный подход:

  • Передавать только метаданные и ссылки на файлы
  • Бинарные данные хранить в blob-хранилище
// Событие о завершении загрузки
type UploadCompletedEvent struct {
VideoID string `json:"video_id"`
UserID string `json:"user_id"`
RawFilePath string `json:"raw_file_path"` // Путь в blob-хранилище
TotalSize int64 `json:"total_size"`
UploadedAt time.Time `json:"uploaded_at"`
Checksum string `json:"checksum"`
}

// Публикация события
func (s *UploadService) publishUploadCompleted(ctx context.Context, videoID string) error {
event := UploadCompletedEvent{
VideoID: videoID,
RawFilePath: fmt.Sprintf("raw/%s/video.mp4", videoID),
UploadedAt: time.Now(),
}

data, _ := json.Marshal(event)
return s.queue.Publish(ctx, "upload.completed", data)
}

Выбор брокера сообщений:

БрокерКогда использовать
RabbitMQГарантия доставки, сложная маршрутизация
KafkaВысокая пропускная способность, потоковая обработка
SQSОблачное решение, простота управления
NATSЛёгкость, низкая задержка

3. Схема взаимодействия:

[Client] --HTTP POST--> [Upload Service] --сохраняет--> [Blob Storage]
|
| --публикует событие-->
v
[Message Queue]
|
| --подписка-->
v
[Scheduler] --> [Workers] --> [Processed Storage]

Ключевой принцип: сначала полностью загрузить файл, проверить целостность, и только потом начинать обработку. Это соответствует главному приоритету — надёжности загрузки.

Вопрос 9. Когда создаётся сообщение для планировщика задач — после загрузки всех кусочков или по мере поступления?

Таймкод: 00:19:29

Ответ собеседника: Правильный. Сообщение для планировщика создаётся после загрузки всех кусочков. Если начать раньше, планировщику придётся отслеживать стадию загрузки, что не имеет смысла — всё равно придётся ждать полного файла. К тому же лучше сначала проверить целостность файла, а потом начинать обработку, чтобы не тратить ресурсы на некорректное видео.

Правильный ответ:

Сообщение для планировщика должно создаваться только после загрузки всех кусочков и проверки целостности файла.

Почему после полной загрузки:

1. Логическая завершённость

  • Планировщик отвечает за обработку полного файла
  • Неполный файл нельзя обработать корректно
  • Нет смысла запускать обработку раньше времени

2. Экономия ресурсов

  • Обработка видео — дорогостоящая CPU-intensive операция
  • Запуск обработки для повреждённого файла — трата вычислительных ресурсов
  • Лучше проверить целостность один раз перед обработкой

3. Упрощение архитектуры

  • Планировщик не должен знать о состоянии загрузки
  • Разделение ответственности: upload service отвечает за загрузку, scheduler — за обработку
  • Меньше состояний для отслеживания

Механизм определения завершения загрузки:

type UploadService struct {
metadata metadata.Repository
queue queue.Queue
storage blob.Storage
}

func (s *UploadService) UploadChunk(ctx context.Context, req ChunkRequest) error {
// Сохранение чанка
if err := s.saveChunk(ctx, req); err != nil {
return err
}

// Обновление метаданных
uploaded, err := s.metadata.MarkChunkUploaded(ctx, req.VideoID, req.ChunkIndex)
if err != nil {
return err
}

// Проверка — все ли чанки загружены?
totalChunks, err := s.metadata.GetTotalChunks(ctx, req.VideoID)
if err != nil {
return err
}

if uploaded == totalChunks {
// Все чанки загружены — проверяем целостность
if err := s.verifyIntegrity(ctx, req.VideoID); err != nil {
return err
}

// Публикуем событие для планировщик
return s.publishUploadCompleted(ctx, req.VideoID)
}

return nil
}

func (s *UploadService) verifyIntegrity(ctx context.Context, videoID string) error {
// Получаем ожидаемый checksum из метаданных
expectedChecksum, err := s.metadata.GetExpectedChecksum(ctx, videoID)
if err != nil {
return err
}

// Вычисляем checksum собранного файла
actualChecksum, err := s.storage.CalculateChecksum(ctx, fmt.Sprintf("raw/%s/video.mp4", videoID))
if err != nil {
return err
}

if expectedChecksum != actualChecksum {
// Удаляем повреждённый файл
s.storage.Delete(ctx, fmt.Sprintf("raw/%s", videoID))
return fmt.Errorf("integrity check failed for video %s", videoID)
}

return nil
}

Альтернативный подход — отдельный endpoint для завершения загрузки:

// Клиент явно сообщает о завершении загрузки
func (s *UploadService) CompleteUpload(ctx context.Context, req CompleteUploadRequest) error {
// Проверяем что все чанки загружены
uploaded, err := s.metadata.GetUploadedChunksCount(ctx, req.VideoID)
if err != nil {
return err
}

totalChunks, err := s.metadata.GetTotalChunks(ctx, req.VideoID)
if err != nil {
return err
}

if uploaded != totalChunks {
return fmt.Errorf("upload incomplete: %d/%d chunks", uploaded, totalChunks)
}

// Проверка целостности
if err := s.verifyIntegrity(ctx, req.VideoID); err != nil {
return err
}

// Публикация события
return s.publishUploadCompleted(ctx, req.VideoID)
}

Преимущества явного завершения:

  • Клиент контролирует момент завершения
  • Возможность отменить загрузку без запуска обработки
  • Явная семантика API

Итог: сообщение для планировщика создаётся только после полной загрузки и проверки целостности. Это обеспечивает надёжность и экономию ресурсов.

Вопрос 10. Как организовать процесс обработки и энкодинга видео — какие компоненты выделить?

Таймкод: 00:20:51

Ответ собеседника: Правильный. Планировщик задач отправляет задачи в очередь с разными топиками. Управление ресурсами обрабатывает задачи. Выделены отдельные обработчики, которые обращаются к blob-хранилищу за кусочками, собирают итоговый файл и кладут в хранилище обработанных файлов. Планировщик может разложить видео на видеоряд и аудиоряд, чтобы обработчики знали, что конкретно брать. Обработанные файлы хранятся в blob-хранилище.

Правильный ответ:

Процесс обработки и энкодинга видео должен быть разделён на специализированные компоненты с чёткой ответственностью.

Архитектура обработки:

[Message Queue] → [Task Scheduler] → [Queue per Task Type] → [Worker Pool] → [Blob Storage]

1. Планировщик задач (Task Scheduler)

Отвечает за:

  • Получение событий о завершении загрузки
  • Декомпозицию обработки на отдельные задачи
  • Определение зависимостей между задачами
  • Распределение задач по очередям
type TaskScheduler struct {
queues map[string]queue.Queue
}

type TaskType string

const (
TaskAssemble TaskType = "assemble"
TaskSplitVideo TaskType = "split_video"
TaskSplitAudio TaskType = "split_audio"
TaskTranscode720p TaskType = "transcode_720p"
TaskTranscode1080p TaskType = "transcode_1080p"
TaskGenerateThumbs TaskType = "generate_thumbnails"
TaskCreateManifest TaskType = "create_manifest"
)

func (s *TaskScheduler) ScheduleProcessing(ctx context.Context, videoID string) error {
tasks := []Task{
{Type: TaskAssemble, VideoID: videoID, Priority: 1},
{Type: TaskSplitVideo, VideoID: videoID, Priority: 2, DependsOn: []TaskType{TaskAssemble}},
{Type: TaskSplitAudio, VideoID: videoID, Priority: 2, DependsOn: []TaskType{TaskAssemble}},
{Type: TaskTranscode720p, VideoID: videoID, Priority: 3, DependsOn: []TaskType{TaskSplitVideo}},
{Type: TaskTranscode1080p, VideoID: videoID, Priority: 3, DependsOn: []TaskType{TaskSplitVideo}},
{Type: TaskGenerateThumbs, VideoID: videoID, Priority: 3, DependsOn: []TaskType{TaskSplitVideo}},
{Type: TaskCreateManifest, VideoID: videoID, Priority: 4, DependsOn: []TaskType{TaskTranscode720p, TaskTranscode1080p}},
}

for _, task := range tasks {
queueName := string(task.Type)
if err := s.queues[queueName].Publish(ctx, task); err != nil {
return fmt.Errorf("failed to schedule task %s: %w", task.Type, err)
}
}

return nil
}

2. Очереди задач (Task Queues)

Отдельная очередь для каждого типа задач:

  • Разные приоритеты обработки
  • Независимое масштабирование воркеров
  • Изоляция сбоев

3. Воркеры обработки (Processing Workers)

Специализированные обработчики:

Worker сборки (Assembler):

  • Собирает чанки в единый файл
  • Проверяет целостность
  • Разделяет на видео и аудио потоки
type AssemblerWorker struct {
storage blob.Storage
}

func (w *AssemblerWorker) Process(ctx context.Context, task Task) error {
videoID := task.VideoID

// Получаем все чанки
chunks, err := w.storage.ListChunks(ctx, fmt.Sprintf("raw/%s/chunks", videoID))
if err != nil {
return err
}

// Собираем файл
assembledPath := fmt.Sprintf("raw/%s/assembled.mp4", videoID)
if err := w.storage.Assemble(ctx, chunks, assembledPath); err != nil {
return err
}

return nil
}

Worker транскодинга (Transcoder):

  • Конвертирует видео в разные битрейты
  • Использует FFmpeg или аналоги
  • CPU/GPU-intensive операция
type TranscoderWorker struct {
storage blob.Storage
ffmpeg *FFmpegClient
}

func (w *TranscoderWorker) Process(ctx context.Context, task Task) error {
inputPath := fmt.Sprintf("raw/%s/video_stream.mp4", task.VideoID)

// Определяем параметры в зависимости от типа задачи
var resolution string
switch task.Type {
case TaskTranscode720p:
resolution = "1280x720"
case TaskTranscode1080p:
resolution = "1920x1080"
}

outputPath := fmt.Sprintf("processed/%s/%s/video.mp4", task.VideoID, resolution)

// Запуск FFmpeg
return w.ffmpeg.Transcode(ctx, inputPath, outputPath, TranscodeOptions{
Resolution: resolution,
Bitrate: w.getBitrate(resolution),
Codec: "h264",
})
}

Worker генерации превью (Thumbnail Generator):

  • Извлекает кадры из видео
  • Создаёт превью разных размеров
  • Генерирует спрайт для seek-бара

Worker создания манифеста (Manifest Creator):

  • Создаёт HLS/DASH манифесты
  • Объединяет информацию о всех битрейтах
  • Подготавливает метаданные для плеера

4. Пул ресурсов (Resource Manager)

Управляет выполнением задач:

  • Лимит параллельных задач
  • Приоритизация
  • Обработка сбоев и повторов
type ResourceManager struct {
workers map[TaskType][]Worker
maxWorkers int
semaphore chan struct{}
}

func (rm *ResourceManager) Start(ctx context.Context) {
for taskType, workers := range rm.workers {
for _, worker := range workers {
go rm.runWorker(ctx, taskType, worker)
}
}
}

func (rm *ResourceManager) runWorker(ctx context.Context, taskType TaskType, worker Worker) {
for {
select {
case <-ctx.Done():
return
case task := <-rm.getQueue(taskType):
rm.semaphore <- struct{}{} // Захват ресурса
err := worker.Process(ctx, task)
<-rm.semaphore // Освобождение ресурса

if err != nil {
rm.handleError(ctx, task, err)
}
}
}
}

5. Хранилище обработанных данных

  • Blob-хранилище для обработанных файлов
  • Организация по папкам
  • Интеграция с CDN

Принципы организации:

  • Разделение ответственности: каждый компонент решает свою задачу
  • Масштабируемость: независимое масштабирование воркеров
  • Отказоустойчивость: изоляция сбоев между компонентами
  • Идемпотентность: повторное выполнение задачи безопасно
  • Наблюдаемость: логирование и мониторинг каждого этапа

Вопрос 11. Какое хранилище выбрать для обработанных видео и почему?

Таймкод: 00:24:03

Ответ собеседника: Правильный. Предложено использовать специализированный сервис для хранения и раздачи видео (CDN). Облачные сервисы предпочтительны, так как при отсутствии ограничений по бюджету CDN обеспечивает быструю раздачу и репликацию данных в нескольких регионах. Собственное хранилище может быть быстрее, но требует значительных мощностей и затрат на обеспечение сохранности данных. Учитывая нагрузку 8 ТБ в день, CDN — оптимальный выбор.

Правильный ответ:

Выбор хранилища для обработанных видео зависит от нескольких факторов: объём данных, требования к доступности, география пользователей и бюджет.

Рекомендуемое решение: Облачное объектное хранилище + CDN

1. Объектное хранилище (S3, GCS, Azure Blob Storage)

Преимущества:

  • Масштабируемость без ограничений
  • Высокая доступность (99.999999999% для S3)
  • Низкая стоимость хранения
  • Встроенная репликация
  • Интеграция с CDN

Стоимость хранения:

  • S3 Standard: ~$0.023 за ГБ/месяц
  • При 8.5 ТБ/день: ~750 ТБ/месяц = ~$17,250/месяц
  • Можно использовать S3 Infrequent Access для старых видео: ~$0.0125 за ГБ/месяц

2. CDN (CloudFront, Cloud CDN, Akamai)

Преимущества:

  • Низкая задержка для пользователей по всему миру
  • Кэширование популярного контента
  • Снижение нагрузки на хранилище
  • Встроенная защита от DDoS

Архитектура хранения:

[Processed Storage (S3)] → [CDN Edge Locations] → [Users]

[Origin Server]
(fallback при cache miss)

Почему не собственное хранилище:

Собственное хранилище требует:

  • Значительных капитальных затрат на оборудование
  • Команды для обслуживания инфраструктуры
  • Обеспечения отказоустойчивости и репликации
  • Мониторинга и резервного копирования
  • Масштабирования при росте нагрузки

При нагрузке 8.5 ТБ/день:

  • Нужны десятки серверов хранения
  • Сеть с высокой пропускной способностью
  • RAID, резервирование, бэкапы
  • Это экономически нецелесообразно для большинства компаний

Оптимизация стоимости:

1. Многоуровневое хранение (Tiered Storage):

Hot Storage (S3 Standard) — последние 30 дней
↓ Автоматический переход
Warm Storage (S3 IA) — 30-180 дней
↓ Автоматический переход
Cold Storage (S3 Glacier) — старше 180 дней

2. Политика жизненного цикла:

{
"Rules": [
{
"ID": "MoveToIA",
"Status": "Enabled",
"Transitions": [
{
"Days": 30,
"StorageClass": "STANDARD_IA"
},
{
"Days": 180,
"StorageClass": "GLACIER"
}
]
}
]
}

3. CDN кэширование:

  • Популярные видео кэшируются на edge-серверах
  • Снижает количество запросов к хранилищу
  • Улучшает загрузку для пользователей

Итог: облачное хранилище + CDN — оптимальный выбор для системы с нагрузкой 8.5 ТБ/день. Это обеспечивает масштабируемость, доступность и приемлемую стоимость без необходимости управления собственной инфраструктурой хранения.

Вопрос 12. Почему CDN — лучший выбор для хранения и раздачи обработанных видео?

Таймкод: 00:25:48

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

Правильный ответ:

CDN (Content Delivery Network) является оптимальным решением для раздачи видео благодаря нескольким ключевым факторам.

1. Географическая распределённость

CDN имеет тысячи edge-серверов по всему миру:

  • Контент кэшируется максимально близко к пользователю
  • Снижение задержки (latency) с сотен миллисекунд до единиц
  • Улучшение пользовательского опыта
Без CDN:
[User in Tokyo] → 200ms → [Server in US] → [Origin Storage]

С CDN:
[User in Tokyo] → 5ms → [CDN Edge in Tokyo] (cached content)

2. Специализация на раздаче контента

CDN оптимизирован для:

  • Отдачи больших файлов (видео)
  • Потокового вещания (streaming)
  • Обработки большого количества одновременных запросов
  • Адаптивного битрейта

3. Встроенная репликация и сохранность

  • Автоматическая репликация контента между edge-серверами
  • Отказоустойчивость при выходе из строя отдельных узлов
  • Защита от DDoS-атак
  • SSL/TLS терминация

4. Экономическая эффективность

Сравнение затрат:

ПодходСтоимостьСложность
Собственный CDNОчень высокаяВысокая
Облачный CDNПредсказуемаяНизкая
Без CDNНизкая на стартеНизкая

Собственный CDN требует:

  • Аренды стоек в дата-центрах по всему миру
  • Настройки и обслуживания серверов
  • Команды инженеров для поддержки
  • Масштабирования при росте нагрузки

5. Оптимизация для видео

Современные CDN поддерживают:

  • HLS и DASH протоколы
  • Адаптивный битрейт
  • Предзагрузку контента (prefetch)
  • Геоблокировку при необходимости

Гибридный подход:

Для оптимизации затрат можно использовать комбинацию:

[Hot Content] → [CDN] → Быстрая раздача популярных видео
[Cold Content] → [Object Storage] → Дешёвое хранение редко запрашиваемых
[Archive] → [Glacier/Archive Storage] → Минимальная стоимость для старых видео

Пример конфигурации:

# CDN Configuration
cdn:
provider: cloudflare # или aws_cloudfront, akamai
origin: s3://processed-videos
caching:
ttl: 86400 # 24 часа
rules:
- pattern: "*.m3u8"
ttl: 10 # Манифесты обновляются чаще
- pattern: "*.ts"
ttl: 604800 # Сегменты кэшируются на неделю
optimization:
brotli: true
http2: true
prefetch: true

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

Вопрос 13. Как хранить метаданные о видео и зачем их отделять от бинарных данных?

Таймкод: 00:27:04

Ответ собеседника: Правильный. Предложено отдельно хранить метаданные о файле в реляционной базе данных (SQL) или в нереляционном хранилище (NoSQL, например MongoDB или key-value хранилище). Для наших вводных (10 видео одновременно, несколько тысяч записей в день) подойдёт практически любое хранилище. Преимущество отдельного хранения метаданных — их можно использовать при выводе информации на хостинге и для согласованности данных с обработанными файлами.

Правильный ответ:

Разделение метаданных и бинарных данных — это фундаментальный принцип проектирования систем хранения медиаконтента.

Зачем разделять:

1. Разные паттерны доступа

  • Метаданные: частые чтения, редкие записи, сложные запросы с фильтрацией
  • Бинарные данные: редкие записи, последовательное чтение, большие объёмы

2. Разные требования к хранилищу

  • Метаданные: нужны индексы, транзакции, сложные запросы
  • Бинарные данные: нужна высокая пропускная способность, низкая стоимость хранения

3. Оптимизация производительности

  • Загрузка метаданных из БД — миллисекунды
  • Загрузка видео из blob-хранилища — секунды
  • Разные стратегии кэширования

Структура метаданных:

CREATE TABLE videos (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
title VARCHAR(255),
description TEXT,
status VARCHAR(50) NOT NULL, -- uploading, processing, ready, failed
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
raw_file_path VARCHAR(512),
raw_file_size BIGINT,
duration INTEGER, -- в секундах
resolution VARCHAR(20),
checksum VARCHAR(64)
);

CREATE TABLE video_variants (
id UUID PRIMARY KEY,
video_id UUID REFERENCES videos(id),
resolution VARCHAR(20) NOT NULL, -- 720p, 1080p
bitrate INTEGER,
file_path VARCHAR(512) NOT NULL,
file_size BIGINT,
codec VARCHAR(50),
created_at TIMESTAMP NOT NULL
);

CREATE TABLE video_chunks (
id UUID PRIMARY KEY,
video_id UUID REFERENCES videos(id),
chunk_index INTEGER NOT NULL,
file_path VARCHAR(512) NOT NULL,
file_size BIGINT,
checksum VARCHAR(64),
uploaded_at TIMESTAMP,
UNIQUE(video_id, chunk_index)
);

CREATE TABLE processing_tasks (
id UUID PRIMARY KEY,
video_id UUID REFERENCES videos(id),
task_type VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL, -- pending, in_progress, completed, failed
worker_id VARCHAR(100),
started_at TIMESTAMP,
completed_at TIMESTAMP,
error_message TEXT,
retry_count INTEGER DEFAULT 0
);

Выбор хранилища метаданных:

Реляционная БД (PostgreSQL, MySQL):

Преимущества:

  • ACID-транзакции
  • Сложные запросы с JOIN
  • Индексы для быстрого поиска
  • Зрелые инструменты мониторинга

Подходит для:

  • Строгой согласованности данных
  • Сложных запросов отчетности
  • Транзакционных операций

NoSQL (MongoDB, DynamoDB):

Преимущества:

  • Гибкая схема
  • Горизонтальное масштабирование
  • Быстрая запись

Подходит для:

  • Быстро меняющейся схемы
  • Простых запросов по ключу
  • Высокой нагрузки на запись

Для нашей задачи:

При нагрузке 10 одновременных загрузок и нескольких тысячах записей в день подойдёт любое решение. PostgreSQL — безопасный выбор с хорошей экосистемой.

Использование метаданных:

type VideoService struct {
db *sql.DB
storage blob.Storage
}

func (s *VideoService) GetVideoInfo(ctx context.Context, videoID string) (*VideoInfo, error) {
// Быстрый запрос метаданных
var video VideoInfo
err := s.db.QueryRowContext(ctx, `
SELECT v.id, v.title, v.duration, v.status,
array_agg(vv.resolution) as resolutions
FROM videos v
LEFT JOIN video_variants vv ON v.id = vv.video_id
WHERE v.id = $1
GROUP BY v.id
`, videoID).Scan(&video.ID, &video.Title, &video.Duration,
&video.Status, &video.Resolutions)

return &video, err
}

func (s *VideoService) ListUserVideos(ctx context.Context, userID string, limit, offset int) ([]VideoInfo, error) {
// Сложный запрос с пагинацией
rows, err := s.db.QueryContext(ctx, `
SELECT id, title, duration, status, created_at
FROM videos
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`, userID, limit, offset)
// ...
}

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

Вопрос 14. Как организовать генерацию обложек и субтитров в архитектуре системы?

Таймкод: 00:30:04

Ответ собеседника: Правильный. Генерация обложек и субтитров (нейросети, внешние AI-сервисы) — это чёрный ящик, внешняя система. Предложено, чтобы планировщик задач ставил задачи на генерацию обложек и субтитров. Ресурсы (раннеры) могут быть универсальными — не привязанными к конкретному типу задач, что обеспечивает гибкость и отсутствие провалов в системе. Можно использовать абстракции вместо привязки к конкретным раннерам.

Правильный ответ:

Генерация обложек и субтитров — это специализированные задачи, которые требуют особого подхода в архитектуре.

Архитектура генерации:

[Task Scheduler] → [AI Task Queue] → [Universal Workers] → [External AI Services]

[Blob Storage]

[Metadata DB]

1. Планировщик задач

Планировщик создаёт задачи на генерацию после завершения базовой обработки:

func (s *TaskScheduler) ScheduleAIGeneration(ctx context.Context, videoID string) error {
tasks := []Task{
{
Type: TaskGenerateThumbnails,
VideoID: videoID,
Priority: 3,
DependsOn: []TaskType{TaskSplitVideo},
},
{
Type: TaskGenerateSubtitles,
VideoID: videoID,
Priority: 3,
DependsOn: []TaskType{TaskSplitAudio},
},
{
Type: TaskContentModeration,
VideoID: videoID,
Priority: 4,
DependsOn: []TaskType{TaskGenerateThumbnails},
},
}

for _, task := range tasks {
if err := s.queues["ai_tasks"].Publish(ctx, task); err != nil {
return err
}
}

return nil
}

2. Универсальные воркеры (Universal Workers)

Вместо привязки воркеров к конкретным типам задач используем абстракцию:

type TaskHandler interface {
Handle(ctx context.Context, task Task) error
}

type Worker struct {
handlers map[TaskType]TaskHandler
queue queue.Queue
}

func (w *Worker) Process(ctx context.Context, task Task) error {
handler, ok := w.handlers[task.Type]
if !ok {
return fmt.Errorf("no handler for task type: %s", task.Type)
}

return handler.Handle(ctx, task)
}

3. Обработчик генерации обложек

type ThumbnailGenerator struct {
storage blob.Storage
ffmpeg *FFmpegClient
aiService *AIImageService
}

func (g *ThumbnailGenerator) Handle(ctx context.Context, task Task) error {
videoID := task.VideoID
videoPath := fmt.Sprintf("raw/%s/video_stream.mp4", videoID)

// Извлечение кадров
timestamps := []int{10, 30, 60, 120} // секунды
var thumbnails []string

for _, ts := range timestamps {
framePath := fmt.Sprintf("temp/%s/frame_%d.jpg", videoID, ts)
if err := g.ffmpeg.ExtractFrame(ctx, videoPath, framePath, ts); err != nil {
return err
}

thumbnails = append(thumbnails, framePath)
}

// AI-генерация лучшей обложки
bestThumbnail, err := g.aiService.SelectBestThumbnail(ctx, thumbnails)
if err != nil {
// Fallback на первый кадр
bestThumbnail = thumbnails[0]
}

// Сохранение обложек разных размеров
sizes := map[string]ThumbnailSize{
"small": {Width: 320, Height: 180},
"medium": {Width: 640, Height: 360},
"large": {Width: 1280, Height: 720},
}

for name, size := range sizes {
outputPath := fmt.Sprintf("processed/%s/thumbnails/%s.jpg", videoID, name)
if err := g.ffmpeg.ResizeImage(ctx, bestThumbnail, outputPath, size); err != nil {
return err
}
}

return nil
}

4. Обработчик генерации субтитров

type SubtitleGenerator struct {
storage blob.Storage
aiService *AISpeechService
}

func (g *SubtitleGenerator) Handle(ctx context.Context, task Task) error {
videoID := task.VideoID
audioPath := fmt.Sprintf("raw/%s/audio_stream.mp4", videoID)

// Распознавание речи
languages := []string{"ru", "en"}

for _, lang := range languages {
subtitles, err := g.aiService.GenerateSubtitles(ctx, audioPath, lang)
if err != nil {
return err
}

// Сохранение в формате VTT
outputPath := fmt.Sprintf("processed/%s/subtitles/%s.vtt", videoID, lang)
if err := g.storage.Put(ctx, outputPath, []byte(subtitles.ToVTT())); err != nil {
return err
}
}

return nil
}

5. Интеграция с внешними AI-сервисами

type AIImageService struct {
client *http.Client
apiKey string
baseURL string
}

func (s *AIImageService) SelectBestThumbnail(ctx context.Context, images []string) (string, error) {
// Отправка изображений в AI-сервис
req := ThumbnailSelectionRequest{
Images: images,
Criteria: SelectionCriteria{
AvoidBlur: true,
AvoidDark: true,
PreferFaces: true,
},
}

resp, err := s.client.Post(ctx, s.baseURL+"/select-thumbnail", req)
if err != nil {
return "", err
}

return resp.SelectedImage, nil
}

Преимущества универсальных воркеров:

  • Гибкость: легко добавлять новые типы задач
  • Отсутствие провалов: если один тип задач не используется, воркеры не простаивают
  • Масштабируемость: можно масштабировать пул воркеров независимо

Обработка сбоев:

func (w *Worker) ProcessWithRetry(ctx context.Context, task Task) error {
maxRetries := 3

for attempt := 0; attempt < maxRetries; attempt++ {
err := w.Process(ctx, task)
if err == nil {
return nil
}

// Экспоненциальная задержка
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
time.Sleep(backoff)
}

return fmt.Errorf("task %s failed after %d retries", task.Type, maxRetries)
}

Итог: использование универсальных воркеров с абстракциями позволяет гибко управлять различными типами AI-задач, обеспечивая эффективное использование ресурсов и простоту расширения системы.

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

Таймкод: 00:32:30

Ответ собеседния: Правильный. Предложено использовать паттерн Outbox для фиксации момента успешной загрузки. Если файл не загрузился до конца, вместо передачи на планировщик обработки ставится задача на очистку — либо в том же месте, либо на отдельный сервис, который удалит ненужный файл. Планировщик может работать с очередями в обе стороны — знать, когда задача обработана, и инициировать удаление временных файлов.

Правильный ответ:

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

Проблема:

  • 50% загрузок могут быть незавершёнными
  • Накопление мусора в хранилище
  • Необходимость освободить место для новых загрузок

Решения:

1. TTL (Time-To-Live) на уровне хранилища

Самый простой подход — использовать встроенные возможности blob-хранилища:

# S3 Lifecycle Policy
Rules:
- ID: CleanupIncompleteUploads
Status: Enabled
Filter:
Prefix: "temp/"
Expiration:
Days: 1 # Удалять через 24 часа

2. Отдельный сервис очистки (Garbage Collector)

type GarbageCollector struct {
storage blob.Storage
metadata metadata.Repository
interval time.Duration
}

func (gc *GarbageCollector) Start(ctx context.Context) {
ticker := time.NewTicker(gc.interval)

for {
select {
case <-ctx.Done():
return
case <-ticker.C:
gc.Cleanup(ctx)
}
}
}

func (gc *GarbageCollector) Cleanup(ctx context.Context) error {
// Находим незавершённые загрузки старше 60 минут
incompleteUploads, err := gc.metadata.GetIncompleteUploads(ctx, time.Minute*60)
if err != nil {
return err
}

for _, upload := range incompleteUploads {
// Удаляем чанки
if err := gc.storage.DeletePrefix(ctx, fmt.Sprintf("temp/%s", upload.VideoID)); err != nil {
log.Error("failed to delete chunks", "videoID", upload.VideoID, "error", err)
continue
}

// Обновляем статус
if err := gc.metadata.MarkAsFailed(ctx, upload.VideoID, "upload_timeout"); err != nil {
log.Error("failed to update status", "videoID", upload.VideoID, "error", err)
}
}

return nil
}

3. Паттерн Outbox для отслеживания состояния

type UploadService struct {
metadata metadata.Repository
outbox outbox.Repository
}

func (s *UploadService) InitiateUpload(ctx context.Context, req InitiateUploadRequest) error {
// Создаём запись о загрузке
upload := Upload{
VideoID: req.VideoID,
UserID: req.UserID,
TotalChunks: req.TotalChunks,
Status: "uploading",
InitiatedAt: time.Now(),
}

if err := s.metadata.CreateUpload(ctx, upload); err != nil {
return err
}

// Создаём запись в outbox для TTL
return s.outbox.Create(ctx, OutboxEntry{
Type: "upload_timeout",
VideoID: req.VideoID,
ExecuteAt: time.Now().Add(time.Minute * 60),
})
}

func (s *UploadService) CompleteUpload(ctx context.Context, videoID string) error {
// Помечаем загрузку как завершённую
if err := s.metadata.MarkAsCompleted(ctx, videoID); err != nil {
return err
}

// Удаляем запись из outbox
return s.outbox.Delete(ctx, "upload_timeout", videoID)
}

4. Обработка таймаутов через outbox

type OutboxProcessor struct {
outbox outbox.Repository
collector *GarbageCollector
}

func (p *OutboxProcessor) ProcessTimeouts(ctx context.Context) error {
entries, err := p.outbox.GetPending(ctx, time.Now())
if err != nil {
return err
}

for _, entry := range entries {
switch entry.Type {
case "upload_timeout":
// Проверяем, завершена ли загрузка
upload, err := p.metadata.GetUpload(ctx, entry.VideoID)
if err != nil {
continue
}

if upload.Status == "uploading" {
// Загрузка не завершена — запускаем очистку
if err := p.collector.CleanupUpload(ctx, entry.VideoID); err != nil {
log.Error("cleanup failed", "videoID", entry.VideoID)
}
}
}
}

return nil
}

5. Двунаправленная коммуникация с планировщиком

type Scheduler struct {
queues map[string]queue.Queue
}

func (s *Scheduler) HandleTaskCompleted(ctx context.Context, event TaskCompletedEvent) error {
switch event.TaskType {
case TaskTranscode720p, TaskTranscode1080p, TaskGenerateThumbs:
// После завершения обработки удаляем временные файлы
return s.queues["cleanup"].Publish(ctx, Task{
Type: TaskCleanupTemp,
VideoID: event.VideoID,
})
}

return nil
}

Рекомендуемое решение:

Комбинация подходов:

  1. TTL на уровне хранилища — как safety net
  2. Garbage Collector — для активной очистки каждые 5–10 минут
  3. Outbox — для точного отслеживания таймаутов

Итог: многоуровневый подход к очистке обеспечивает надёжное удаление недозагруженных файлов без потери данных при успешных загрузках.

Вопрос 16. Как система отреагирует на внезапный рост нагрузки в 10 раз и что станет узким местом?

Таймкод: 00:34:29

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

Правильный ответ:

Внезапный рост нагрузки в 10 раз (с 10 до 100 одновременных загрузок) выявит узкие места в архитектуре.

Потенциальные узкие места:

1. Сервис загрузки (Upload Service)

Проблема:

  • Одиночный экземпляр не справится с нагрузкой
  • Ограничение на количество одновременных HTTP-соединений
  • Нехватка памяти для буферизации чанков

Решение:

# Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: upload-service
spec:
replicas: 5 # Горизонтальное масштабирование
template:
spec:
containers:
- name: upload
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2000m"
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: upload-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: upload-service
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70

2. Балансировка нагрузки

# Nginx upstream
upstream upload_servers {
least_conn; # Алгоритм наименьших соединений

server upload-1:8080 max_fails=3 fail_timeout=30s;
server upload-2:8080 max_fails=3 fail_timeout=30s;
server upload-3:8080 max_fails=3 fail_timeout=30s;

# Резервные серверы
server upload-backup-1:8080 backup;
server upload-backup-2:8080 backup;
}

server {
listen 80;

location /upload/ {
proxy_pass http://upload_servers;
proxy_request_buffering off; # Потоковая передача
client_max_body_size 0; # Без ограничений
}
}

3. Очередь сообщений

Проблема:

  • Накопление сообщений при медленной обработке
  • Увеличение времени ожидания

Решение:

// Увеличение количества консьюмеров
type ConsumerGroup struct {
consumers []queue.Consumer
}

func (cg *ConsumerGroup) Start(ctx context.Context) {
for _, consumer := range cg.consumers {
go consumer.Consume(ctx)
}
}

// Автоматическое масштабирование консьюмеров
func autoScaleConsumers(queueDepth int) int {
if queueDepth > 1000 {
return 20
} else if queueDepth > 500 {
return 10
}
return 5
}

4. Воркеры обработки

Проблема:

  • CPU-intensive операции при транскодировании
  • Ограничение на количество GPU-серверов

Решение — облачное масштабирование:

type CloudScaler struct {
cloudProvider cloud.Provider
maxInstances int
}

func (s *CloudScaler) Scale(ctx context.Context, requiredWorkers int) error {
currentWorkers, err := s.cloudProvider.GetRunningInstances(ctx, "transcode-worker")
if err != nil {
return err
}

if requiredWorkers > currentWorkers {
toAdd := min(requiredWorkers-currentWorkers, s.maxInstances-currentWorkers)
return s.cloudProvider.LaunchInstances(ctx, "transcode-worker", toAdd)
}

return nil
}

func (s *CloudScaler) ScaleDown(ctx context.Context) error {
// Удаление избыточных инстансов после спада нагрузки
return s.cloudProvider.TerminateIdleInstances(ctx, "transcode-worker", 5*time.Minute)
}

5. Хранище

Проблема:

  • Ограничение на скорость записи
  • Исчерпание inodes

Решение:

  • Использование нескольких бакетов
  • Шардирование по дате
type ShardedStorage struct {
shards []blob.Storage
}

func (s *ShardedStorage) GetShard(key string) blob.Storage {
hash := fnv.New32a()
hash.Write([]byte(key))
return s.shards[hash.Sum32()%uint32(len(s.shards))]
}

Порядок масштабирования:

1. Upload Service → Горизонтальное масштабирование + HPA
2. Load Balancer → Распределение трафика
3. Message Queue → Увеличение консьюмеров
4. Workers → Облачное масштабирование
5. Storage → Шардирование

Мониторинг и алертинг:

type LoadMonitor struct {
metrics metrics.Client
}

func (m *LoadMonitor) CheckThresholds(ctx context.Context) {
// Мониторинг глубины очереди
queueDepth := m.metrics.GetQueueDepth("upload.completed")
if queueDepth > 1000 {
m.alert("High queue depth", queueDepth)
}

// Мониторинг времени обработки
processingTime := m.metrics.GetProcessingTime("transcode")
if processingTime > 10*time.Minute {
m.alert("High processing time", processingTime)
}
}

Итког: система должна быть спроектирована с учётом горизонтального масштабирования каждого компонента. Облачные ресурсы позволяют временно увеличить мощность при пиковых нагрузках.

Вопрос 17. Какие технологии хранения стоит поменять при увеличении нагрузки с 10 до 200 одновременных пользователей (с 10 МБ/с до 200 МБ/с)?

Таймкод: 00:38:11

Ответ собеседника: Неполный. Указано, что в первую очередь нужно смотреть на базу данных и масштабировать её средствами — добавлять больше баз данных (шардирование, репликация). Также предложено рассмотреть переход от SQL к NoSQL при большом количестве записей, так как индексы в SQL страдают. Однако не раскрыты другие аспекты масштабирования.

Правильный ответ:

При 20-кратном росте нагрузки (с 10 до 200 одновременных пользователей, с ~8.5 ТБ/день до ~170 ТБ/день) необходимо пересмотреть все компоненты хранения.

1. База данных метаданных

Текущая нагрузка: ~1000 записей в день Новая нагрузка: ~20 000 записей в день

Проблемы:

  • Рост объёма данных
  • Увеличение нагрузки на запросы
  • Деградация производительности индексов

Решения:

Шардирование по user_id:

type ShardedDB struct {
shards []*sql.DB
}

func (db *ShardedDB) GetShard(userID string) *sql.DB {
hash := fnv.New32a()
hash.Write([]byte(userID))
return db.shards[hash.Sum32()%uint32(len(db.shards))]
}

func (db *ShardedDB) CreateVideo(ctx context.Context, video Video) error {
shard := db.GetShard(video.UserID)
_, err := shard.ExecContext(ctx, `
INSERT INTO videos (id, user_id, title, status, created_at)
VALUES ($1, $2, $3, $4, $5)
`, video.ID, video.UserID, video.Title, video.Status, video.CreatedAt)
return err
}

Репликация (Read Replicas):

type DatabaseCluster struct {
primary *sql.DB
replicas []*sql.DB
}

func (c *DatabaseCluster) Query(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
// Чтение из реплики
replica := c.getRandomReplica()
return replica.QueryContext(ctx, query, args...)
}

func (c *DatabaseCluster) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
// Запись в primary
return c.primary.ExecContext(ctx, query, args...)
}

Переход на NoSQL (при необходимости):

Для высоконагруженных сценариев можно использовать:

БазаКогда использовать
MongoDBГибкая схема, сложные запросы
DynamoDBПредсказуемая производительность, авто-масштабирование
CassandraВысокая нагрузка на запись, геораспределение

2. Blob-хранилище

Проблемы:

  • Увеличение объёма в 20 раз
  • Рост стоимости хранения
  • Необходимость географического распределения

Решения:

Многоуровневое хранение:

# S3 Lifecycle Rules
Rules:
- TransitionToIA:
Days: 7
- TransitionToGlacier:
Days: 30
- TransitionToDeepArchive:
Days: 180

Шардирование по бакетам:

type MultiBucketStorage struct {
buckets []blob.Storage
}

func (s *MultiBucketStorage) GetBucket(videoID string) blob.Storage {
// Распределение по бакетам на основе hash
hash := fnv.New32a()
hash.Write([]byte(videoID))
return s.buckets[hash.Sum32()%uint32(len(s.buckets))]
}

Геораспределение:

US-East: s3://videos-us-east/
EU-West: s3://videos-eu-west/
Asia: s3://videos-asia/

3. CDN

Проблемы:

  • Рост исходящего трафика
  • Увеличение стоимости CDN
  • Необходимость оптимизации кэширования

Решения:

Мульти-CDN стратегия:

cdn:
primary: cloudflare
fallback: aws_cloudfront
rules:
- region: eu
provider: cloudflare
- region: asia
provider: akamai

Оптимиция кэширования:

# Кэширование сегментов на длительный срок
location ~* \.ts$ {
expires 30d;
add_header Cache-Control "public, immutable";
}

# Манифесты — короткое кэширование
location ~* \.m3u8$ {
expires 10s;
add_header Cache-Control "public, must-revalidate";
}

4. Очереди сообщений

Проблемы:

  • Рост количества сообщений
  • Увеличение времени обработки
  • Необходимость приоритизации

Решения:

Kafka вместо RabbitMQ:

kafka:
partitions: 20 # Параллельная обработка
replication: 3
retention: 7d

Приоритетные очереди:

type PriorityQueue struct {
high queue.Queue # Платные пользователи
medium queue.Queue # Обычные пользователи
low queue.Queue # Бесплатные пользователи
}

func (q *PriorityQueue) Publish(ctx context.Context, task Task, priority Priority) error {
switch priority {
case PriorityHigh:
return q.high.Publish(ctx, task)
case PriorityMedium:
return q.medium.Publish(ctx, task)
case PriorityLow:
return q.low.Publish(ctx, task)
}
}

5. Кэширование метаданных

Добавление Redis для горячих данных:

type CachedMetadataService struct {
db *sql.DB
cache *redis.Client
}

func (s *CachedMetadataService) GetVideo(ctx context.Context, videoID string) (*Video, error) {
// Проверяем кэш
cached, err := s.cache.Get(ctx, "video:"+videoID).Result()
if err == nil {
var video Video
if json.Unmarshal([]byte(cached), &video) == nil {
return &video, nil
}
}

// Чтение из БД
video, err := s.db.GetVideo(ctx, videoID)
if err != nil {
return nil, err
}

// Сохранение в кэш
data, _ := json.Marshal(video)
s.cache.Set(ctx, "video:"+videoID, data, 5*time.Minute)

return video, nil
}

Сводная таблица изменений:

КомпонентТекущееНовоеОбоснование
БД метаданныхPostgreSQL singlePostgreSQL + Read Replicas + ShardingРост нагрузки на чтение/запись
Blob StorageSingle bucketMulti-bucket + LifecycleРост объёма, оптимизация стоимости
CDNSingle providerMulti-CDNГеораспределение, надёжность
ОчередиRabbitMQKafkaВысокая пропускная способность
КэшОтсутствуетRedisСнижение нагрузки на БД