Mock собеседование - System Design
Сегодня мы разберём пример 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
}
Рекомендуемое решение:
Комбинация подходов:
- TTL на уровне хранилища — как safety net
- Garbage Collector — для активной очистки каждые 5–10 минут
- 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 single | PostgreSQL + Read Replicas + Sharding | Рост нагрузки на чтение/запись |
| Blob Storage | Single bucket | Multi-bucket + Lifecycle | Рост объёма, оптимизация стоимости |
| CDN | Single provider | Multi-CDN | Геораспределение, надёжность |
| Очереди | RabbitMQ | Kafka | Высокая пропускная способность |
| Кэш | Отсутствует | Redis | Снижение нагрузки на БД |
