Видеоразбор этой задачи на русском языке можно посмотреть здесь - https://www.youtube.com/watch?v=Zw5A33rTlL0


Проектирование Dropbox

Постановка задачи

☁️ Что такое Dropbox?

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

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

Основные требования

  1. Пользователи могут загрузить файл с любого устройства.

  2. Пользователи могут скачать файл с любого устройства.

  3. Пользователи могут делиться файлами с другими пользователями и просматривать файлы, которыми поделились с ними.

  4. Файлы синхронизируются между устройствами.

За рамками задачи

  • Пользователи могут редактировать файлы

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

Стоит отметить, что существуют задачи System Design, касающиеся самого хранилища больших бинарных (blob) объектов. Это выходит за рамки данной задачи, но вы можете самостоятельно изучить этот вопрос, чтобы понять, как работает и как устроено объектное хранилище.

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

  1. Система должна обладать высокой доступностью (приоритет доступности над согласованностью данных).

  2. Система должна поддерживать файлы размером до 50 ГБ.

  3. Система должна быть безопасной и надежной. Должна существовать возможность восстанавливать файлы в случае их потери или повреждения.

  4. Система должна обеспечивать максимально быструю загрузку, скачивание и синхронизацию.

За рамками задачи

  • Система должна иметь ограничение на объем, доступный каждому пользователю

  • Система должна поддерживать версионирование файлов

  • Система должна сканировать файлы на наличие вирусов и вредоносных программ

Вот как это может выглядеть на доске:

Многие кандидаты испытывают трудности с компромиссом, связанным с теоремой CAP, при решении этой задачи. Помните, что приоритет согласованности над доступностью вы отдаете только в том случае, если каждое чтение должно получать самую последнюю запись, иначе система неработоспособна. Например, в приложении для торговли акциями, если пользователь покупает акции T в Москве, а затем другой пользователь немедленно пытается купить акции T в Мехико, вам необходимо убедиться, что первая транзакция была реплицирована в Мехико, прежде чем вы сможете продолжить. Однако для файловой системы, такой как Dropbox, допустимо, если пользователь в Москве загружает файл, а пользователь в Мехико не может его увидеть в течение нескольких секунд.

Подготовка

Планирование подхода

Прежде чем переходить к проектированию системы, важно на секунду остановиться и продумать стратегию. К счастью, для “продуктовых” задач план обычно простой: последовательно собирать дизайн, проходя по функциональным требованиям одно за другим. Так вы сохраните фокус и не утонете в деталях.

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

Проектирование API

Начнем с определения основных сущностей, это поможет спроектировать API. Пока не обязательно знать каждое поле или колонку, но если у вас уже есть представление о том, что там будет, можно это записать.

В случае Dropbox основные сущности предельно просты:

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

  2. FileMetadata: метаданные, связанные с файлом. Они включают такую ​​информацию, как имя файла, размер, MIME-тип и пользователь, загрузивший его.

  3. User: пользователь нашей системы.

В реальном интервью короткого списка, как выше, часто достаточно. Главное - проговорить сущности с интервьюером и убедиться, что вы оба одинаково их понимаете.

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

Для загрузки файла у нас может быть эндпоинт примерно такого вида:

POST /files
{
  File,
  FileMetadata
}

Для скачивания файла мы можем использовать эндпоинт:

GET /files/{fileId} -> File & FileMetadata

Имейте в виду, что ваш API может меняться или развиваться по мере проектирования. В данном случае API для загрузки и скачивания значительно эволюционируют, поскольку мы взвешиваем компромиссы различных подходов в нашем high-level дизайне (подробнее об этом позже). Вы можете заранее сообщить об этом интервьюеру, сказав: “Я собираюсь описать несколько простых API эндпоинтов, но, возможно, вернусь к ним и улучшу их по мере того, как мы будем углубляться в проектирование”.

Для обмена файлами мы можем использовать следующий эндпоинт:

POST /files/{fileId}/share
{
  User[] // Пользователи, с которыми поделились
}

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

GET /files/{fileId}/changes -> FileMetadata[]

В каждом из этих запросов информация о пользователе передается в заголовках (через session token или auth token). Это распространенный паттерн, так мы можем обеспечивать аутентификацию/авторизацию и безопасность. Не стоит передавать пользовательские данные в теле запроса: в этом случае их можно легко подделать.

Высокоуровневый дизайн

1. Пользователи могут загружать файлы с любого устройства

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

  1. Где мы храним содержимое файла (бинарные данные)?

  2. Где мы храним метаданные файла?

Для метаданных мы можем использовать NoSQL-базу данных, например DynamoDB. DynamoDB - это полностью управляемая NoSQL-база данных, предоставляемая AWS. Наши метаданные слабо структурированы, с небольшим количеством связей, а основной шаблон запроса - получение файлов по пользователю. Это делает DynamoDB хорошим выбором, но не слишком зацикливайтесь на правильном выборе на собеседовании. В действительности, SQL-база данных, такая как PostgreSQL, подошла бы для этого случая не хуже.

Наша схема будет представлять собой простой документ и может быть примерно такой:

{
  "id": "12",
  "name": "file.txt",
  "size": 2000,
  "mime_type": "text/plain",
  "uploaded_by": "user1"
}

Что касается способа хранения самого файла, у нас есть несколько вариантов. Давайте рассмотрим компромиссы каждого из них.

Плохое решение: Загрузка на наш сервер

Подход

Самый простой подход - загружать файлы непосредственно на наш бэкенд-сервер (назовем его файловый сервис) и хранить их там. Наш запрос POST /files будет принимать файл и метаданные, а затем сохранять файл в локальной файловой системе сервера, а метаданные в нашей базе данных. Это разумный подход для небольшого приложения, но он плохо масштабируется и ненадежен.

Проблемы

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

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

Хорошее решение: Сохраняем в объектное хранилище

Подход

Более эффективным подходом является хранение файла в объектном хранилище, таком как Amazon S3 или Google Cloud Storage. Когда пользователь загружает файл на наш бэкенд, мы можем отправить его в объектное хранилище и сохранить метаданные в нашей базе данных. Мы можем хранить (в теории) неограниченное количество файлов в объектном хранилище, поскольку оно само позаботится о масштабировании. Это также более надежно. Если наш сервер выйдет из строя, мы не потеряем доступ к нашим файлам. Мы также можем воспользоваться такими функциями объектного хранилища, как политики жизненного цикла для автоматического удаления старых файлов и версионирование для отслеживания изменений файлов при необходимости (хотя это выходит за рамки данной задачи).

Проблемы

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

Во-вторых, такой подход (как показано выше) требует от нас технически дважды загрузить файл - один раз на наш бэкенд и один раз в объектное хранилище. Это избыточно. Мы можем решить эту проблему, позволив пользователю загружать файл непосредственно в объектное хранилище.

Отличное решение: Загрузка напрямую в объектное хранилище

Подход

Наилучший подход - загружать файл непосредственно в объектное хранилище с клиентской стороны. Это быстрее и дешевле, чем предварительная загрузка файла в наш бэкенд. Мы можем использовать предварительно подписанный URL-адрес (presigned URL), который пользователь сможет использовать для прямой загрузки файла в объектное хранилище. После загрузки файла объектное хранилище отправит уведомление в наш бэкенд, чтобы мы могли сохранить метаданные.

Предварительно подписанные URL-адреса - это URL-адреса, которые предоставляют пользователю разрешение на загрузку файла в определенное место в объектном хранилище. Мы можем сгенерировать такой URL-адрес и отправить его пользователю, когда он захочет загрузить файл. Таким образом, если изначально наш API для загрузки представлял собой POST-запрос к /files, то теперь это будет трехэтапный процесс:

  1. Запрос предварительно подписанного URL-адреса (который генерируется с помощью S3 SDK), сохранение метаданных файла в нашей базе данных со статусом “uploading”.

POST /files/presigned-url -> PresignedUrl
{
  FileMetadata
}
  1. Используем предварительно подписанный URL-адрес для загрузки файла в объектное хранилище непосредственно с клиентской стороны. Это осуществляется посредством PUT-запроса, где файл является телом запроса.

  2. После загрузки файла объектное хранилище отправит уведомление на наш бэкенд с помощью S3 Notifications . Затем наш бэкенд обновит метаданные файла в нашей базе данных, присвоив ему статус “uploaded”.

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

2. Пользователи могут скачать файл с любого устройства

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

Плохое решение: Скачивание через наш сервер

Подход

Частый подход, который предлагают кандидаты, - это скачать файл один раз из объектного хранилища на наш сервер, а затем еще раз с нашего сервера на клиентский компьютер.

Проблемы

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

Хорошее решение: Скачивание c объектного хранилища

Подход

Более оптимальный подход - позволить пользователю скачать файл непосредственно из объектного хранилища, с помощью предварительно подписанного URL-адреса. Как и при загрузке файлов, предварительно подписанный URL-адрес предоставит пользователю разрешение на загрузку файла из определенного места в объектном хранилище в течение ограниченного времени.

  1. Запрос предварительно подписанного URL-адреса для скачивания файла.

GET /files/{fileId}/presigned-url -> PresignedUrl
  1. Используем предварительно подписанный URL-адрес для скачивания файла из объектного хранилища непосредственно на клиентское устройство.

Проблемы

Хотя это почти оптимальный вариант, основным ограничением является то, что он все еще может быть медленным для большой, географически распределенной базы пользователей. Объектное хранилище расположено в одном регионе, поэтому пользователи, находящиеся далеко от этого региона, будут сталкиваться с более медленной скоростью загрузки. Мы можем решить эту проблему, используя сеть доставки контента (CDN) для кэширования файла ближе к пользователю.

Отличное решение: Скачивание c CDN

Подход

Наилучший подход - использование сети доставки контента (Content Delivery Network, CDN) для кэширования файла ближе к пользователю. CDN - это сеть серверов, распределенных по всему миру, которые кэшируют файлы и предоставляют их пользователям с ближайшего к ним сервера. Это уменьшает задержку и ускоряет время загрузки.

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

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

Проблемы

CDN-сети относительно дороги. Для решения этой проблемы обычно используют стратегический подход к тому, какие файлы кэшируются и как долго. Можно использовать заголовок управления кэшем, чтобы указать, как долго файл должен кэшироваться в CDN. Также можно использовать механизм аннулирования кэша для удаления файлов из CDN при их обновлении или удалении. Таким образом, кэшируются только часто используемые файлы, и мы не тратим деньги на кэширование файлов, к которым обращаются редко.

3. Пользователи могут делиться файлами с другими пользователями

Для завершения функциональных требований нам необходимо обеспечить поддержку обмена файлами с другими пользователями. Мы реализуем это аналогично Google Drive, где вам нужно всего лишь ввести адрес электронной почты пользователя, с которым вы хотите поделиться файлом. Можно предположить, что пользователи уже авторизованы.

Главный вопрос на собеседовании здесь - как сделать этот процесс быстрым и эффективным. Давайте разберемся.

Плохое решение: Список доступа в метаданных

Подход

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

{
  "id": "12",
  "name": "file.txt",
  "size": 2000,
  "mime_type": "text/plain",
  "uploaded_by": "user1",
  "sharelist": ["user2", "user3"]
}

Проблемы

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

Хорошее решение: Кеширование списка доступа

Подход

Более эффективный подход заключается в том, чтобы, помимо sharelist в метаданных, кэшировать список, отображающий обратную зависимость. Это будет сопоставление любого конкретного пользователя со списком файлов, которыми с ним поделились. Таким образом, когда пользователь открывает наш сайт, мы можем быстро получить список файлов, которыми с ним поделились, найдя его user_id в нашем кэше sharedFiles.

Наша запись в кэше будет представлять собой простую пару ключ-значение, примерно такую:

user1:["file1", "file2"]

Проблемы

Нам необходимо синхронизировать список sharedFiles со списком sharelist в метаданных файла. Лучший способ решить эту проблему - хранить сопоставление пользователей и файлов в той же базе данных и обновлять как sharelist, так и sharedFiles в рамках одной транзакции.

Отличное решение: Отдельная таблица для списка доступа

Подход

Другой подход заключается в полной нормализации данных. Он предполагает создание новой таблицы, в которой user_id сопоставляется с file_id, где file_id идентификатор файла, которым поделились с данным пользователем. Таким образом, когда пользователь открывает наш сайт, мы можем быстро получить список предоставленных ему файлов, запросив таблицу SharedFiles и найдя все файлы с user_id этого пользователя.

Новая таблица SharedFiles будет выглядеть следующим образом:

| user_id (PK) | file_id (SK) |
|--------------|--------------|
| user1        | file1        |
| user1        | file2        |
| user2        | file3        |

В этой конфигурации нам больше не нужен sharelist в метаданных. Мы можем просто запросить таблицу SharedFiles для получения всех файлов, у которых user_id совпадает с идентификатором пользователя, отправившего запрос, что устраняет необходимость синхронизации списка sharelist со списком sharedFiles.

Проблемы

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

4. Файлы синхронизируются между устройствами

Наконец, нам нужно убедиться, что файлы автоматически синхронизируются между различными устройствами. В общих чертах это работает за счет хранения копии определенного файла на каждом клиентском устройстве (локально), а также в удаленном хранилище (т.е., в “облаке”). Таким образом, нам нужно синхронизировать файлы в двух направлениях:

  1. Локально -> Удаленно

  2. Удаленно -> Локально

Локально -> Удаленно

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

Для этого нам нужен агент синхронизации на стороне клиента, который:

  1. Отслеживает изменения в локальной папке Dropbox, используя события файловой системы, специфичные для операционной системы (например, FileSystemWatcher в Windows или FSEvents в macOS).

  2. При обнаружении изменений агент ставит измененный файл в очередь на отправку.

  3. Затем агент использует наш API для загрузки файлов, чтобы отправить изменения на сервер вместе с обновленными метаданными.

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

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

Удаленно -> Локально

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

Существует два основных подхода, которые мы могли бы использовать:

  1. Опрос (Polling): клиент периодически спрашивает у сервера: “Что-нибудь изменилось с момента последней синхронизации?” Сервер обращается к базе данных, чтобы проверить, есть ли у каких-либо файлов, за которыми следит пользователь, метка времени updated_at, более новая, чем время последней синхронизации. Это простой метод, но он может медленно обнаруживать изменения и расходовать ресурсы, если ничего не изменилось.

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

Для Dropbox можно использовать гибридный подход. Мы можем разделить файлы на две категории:

Новые файлы: файлы, которые были недавно отредактированы (в течение последних нескольких часов). Для них мы поддерживаем соединение WebSocket, чтобы обеспечить синхронизацию практически в реальном времени.

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

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

Итоговый дизайн

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

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

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

Балансировщик нагрузки и API-шлюз: отвечает за маршрутизацию запросов к соответствующему серверу и обработку таких операций, как завершение SSL-соединения, ограничение скорости и проверка запросов.

Файловый сервис: отвечает за запись в базу данных метаданных файлов, а также за генерацию предварительно подписанных URL-адресов с использованием SDK S3. Он фактически не обрабатывает загрузку или скачивание файлов. Это всего лишь посредник между клиентом и S3.

База данных метаданных: хранит такие данные, как имя файла, размер, MIME-тип и пользователь, загрузивший файл. Здесь же хранится таблица общих файлов, которая сопоставляет файлы с пользователями, имеющими к ним доступ. Мы используем эту таблицу для обеспечения соблюдения прав доступа при попытке пользователя скачать файл.

S3: здесь фактически хранятся файлы. Мы загружаем и скачиваем файлы напрямую в S3 и из S3, используя предварительно подписанные URL-адреса, которые получаем от файлового сервера.

CDN: кэширует файлы вблизи пользователя для уменьшения задержки.

Потенциальные погружения в детали

1. Как поддержать большие файлы?

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

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

  2. Возобновляемая загрузка: пользователи должны иметь возможность приостанавливать и возобновлять загрузку. В случае потери интернет-соединения или закрытия браузера они должны иметь возможность продолжить с того места, где остановились, вместо того, чтобы повторно загружать 49 ГБ, которые, возможно, уже были загружены до прерывания.

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

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

  • Таймауты: веб-серверы и клиенты обычно имеют настройки таймаутов, чтобы предотвратить бесконечное ожидание ответа. Один POST запрос с файлом размером 50 ГБ может легко превысить эти таймауты. На самом деле, это может быть подходящим моментом для быстрых подсчетов на собеседовании. Если у нас есть файл размером 50 ГБ и интернет-соединение со скоростью 100 Мбит/с, сколько времени потребуется для загрузки файла? 50 ГБ * 8 бит/байт / 100 Мбит/с = 4000 секунд, тогда 4000 секунд / 60 секунд/минута / 60 минут/час = 1,11 часа. Это очень долгое время ожидания без ответа от сервера.

  • Ограничения браузера и сервера: в большинстве случаев загрузка файла размером 50 ГБ с помощью одного POST запроса невозможна в принципе из-за ограничений, которые браузеры и веб-серверы часто устанавливают на размер тела запроса. Хотя веб-серверы, такие как Apache и NGINX, могут быть настроены на прием больших объемов данных, большинство современных сервисов, таких как Amazon API Gateway, имеют жесткие ограничения, которые намного ниже и не могут быть увеличены. В случае с Amazon API Gateway, это всего 10 МБ.

  • Сетевые сбои: большие файлы более подвержены сетевым сбоям. Если пользователь загружает файл размером 50 ГБ, и его интернет-соединение обрывается, ему придется начинать загрузку заново.

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

Для решения этих проблем мы можем использовать метод, называемый “разбивкой на части” (chunking), чтобы разбить файл на более мелкие фрагменты и загружать их по одному (или параллельно, в зависимости от пропускной способности сети). Распространенная ошибка, которую допускают кандидаты, - это разбивка файла на части на сервере, в чем фактически нет смысла, поскольку для этого все равно загружается весь файл целиком. Поэтому разбивка должна выполняться на стороне клиента. Обычно мы разбиваем файл на фрагменты размером 5-10 МБ, но это можно скорректировать в зависимости от условий сети и размера файла.

При работе с фрагментами нам довольно просто показать пользователю индикатор выполнения. Мы можем просто отслеживать прогресс каждого фрагмента и обновлять индикатор выполнения по мере успешной загрузки фрагментов.

Следующий вопрос: как мы будем обрабатывать возобновляемые загрузки? Нам нужно отслеживать, какие фрагменты были загружены, а какие нет. Мы можем сделать это, сохраняя состояние загрузки в базе данных, а именно в нашей таблице FileMetadata. Давайте обновим схему FileMetadata , добавив поле chunks.

{
  "id": "12",
  "name": "file.txt",
  "size": 2000,
  "mimeType": "text/plain",
  "uploadedBy": "user1",
  "status": "uploading",
  "chunks": [
    {
      "id": "chunk1",
      "status": "uploaded"
    },
    {
      "id": "chunk2",
      "status": "uploading"
    },
    {
      "id": "chunk3",
      "status": "not-uploaded"
    }
  ]
}

Когда пользователь возобновляет загрузку, мы можем проверить поле “chunks” , чтобы увидеть, какие фрагменты уже загружены, а какие нет. Затем мы можем начать загрузку тех фрагментов, которые еще не были загружены. Таким образом, пользователю не придется начинать загрузку заново, если он потеряет интернет-соединение или закроет браузер.

Но как нам обеспечить синхронизацию поля chunks с фактически загруженными фрагментами файла?

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

Хорошее решение: Обновление через PATCH запрос

Подход

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

  1. Клиент берет файл, разбивает его на фрагменты и загружает эти фрагменты непосредственно в S3.

  2. S3 отправляет сообщение об успешной загрузке каждого фрагмента.

  3. В случае успеха клиент отправляет PATCH запрос на наш бэкенд для обновления поля chunks в таблице FileMetadata.

PATCH /files/{fileId}/chunks
{
  "chunks": [
    {
      "id": "chunk1",
      "status": "uploaded"
    },
  ]
}

Проблемы

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

Отличное решение: Проверка фрагментов на сервере

Подход

Более эффективным подходом является реализация серверной проверки загрузки фрагментов с использованием ETags. Уведомления о событиях S3 не срабатывают для отдельных фрагментов загрузки, a только когда весь объект завершен. Поэтому нам необходимо использовать непосредственно API многокомпонентной загрузки S3 (S3 Multipart Upload API).

Каждый фрагмент получает ETag после успешной загрузки, который клиент может включить в PATCH запрос к нашему бэкенду. Затем наш бэкенд может проверить эти ETag, вызвав ListParts API в S3, что обеспечивает эффективный способ проверки нескольких фрагментов одновременно. Такой подход обеспечивает баланс между удобством использования и целостностью данных - мы принимаем обновления от клиента для отслеживания прогресса в реальном времени, чтобы предоставлять немедленную обратную связь, но периодически проверяем статус фрагмента на стороне сервера, прежде чем пометить весь файл как “uploaded”.

Доверяй, но проверяй.

Далее поговорим о том, как однозначно идентифицировать файл и его фрагмент. Когда вы пытаетесь возобновить загрузку, первый вопрос, который следует задать, это: (1) Пытались ли мы загрузить этот файл раньше? и (2) Если да, то какие фрагменты уже загружены? Чтобы ответить на первый вопрос, мы не можем наивно полагаться на имя файла. Это связано с тем, что два разных пользователя (или даже один и тот же пользователь) могут загружать файлы с одинаковым именем. Вместо этого нам нужно полагаться на уникальный идентификатор, полученный из содержимого файла. Это называется отпечатком (fingerprint).

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

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

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

  1. Клиент разбивает файл на части размером 5-10 МБ и вычисляет отпечаток для каждой части. Он также вычисляет отпечаток для всего файла, который станет идентификатором файла (fileId).

  2. Клиент отправляет GET запрос для получения FileMetadata с заданным fileId (отпечатком), чтобы проверить, существует ли он уже - в этом случае мы сможем возобновить загрузку.

  3. Если файл не существует, клиент отправляет POST запрос для инициирования загрузки (multipart upload). Бэкенд вызывает S3 API CreateMultipartUpload, чтобы получить uploadId, генерирует предварительно подписанные URL-адреса для каждой части, сохраняет метаданные файла в таблице FileMetadata со статусом “uploading” и возвращает uploadId вместе с предварительно подписанными URL-адресами для каждого фрагмента.

  4. Затем клиент загружает каждый фрагмент в S3, используя соответствующий предварительно подписанный URL-адрес (для каждой части требуется свой собственный предварительно подписанный URL-адрес с идентификатором загрузки uploadId и номером части partNumber). После загрузки каждого фрагмента клиент отправляет PATCH запрос в наш бэкенд со статусом фрагмента и ETag. Затем наш бэкенд может проверить загрузку фрагментов с помощью S3 API ListParts, прежде чем обновить поле chunks в таблице FileMetadata, и помечает фрагмент как “uploaded”.

  5. Как только все фрагменты в нашем массиве фрагментов будут помечены как “uploaded”, бэкенд обновляет таблицу FileMetadata и помечает весь файл как “uploaded”.

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

Описанный нами подход не нов, на самом деле, эта проблема уже решена поставщиками облачных хранилищ, такими как Amazon S3. У них есть функция Multipart Upload, которая позволяет загружать большие объекты по частям. Это именно то, что мы только что описали. Клиент разбивает файл на части и загружает каждую часть в S3. Затем S3 объединяет части в один объект. Они даже предоставляют удобный JavaScript SDK, который будет обрабатывать всю разбивку на части и загрузку за вас.

При загрузке нескольких частей в S3 уведомления о событиях срабатывают только после завершения всей загрузки (когда все части собраны), а не при загрузке отдельных частей. Для отслеживания прогресса загрузки отдельных частей необходимо использовать API ListParts в S3, который возвращает все загруженные части с их ETags для текущей загрузки.

На практике вы будете полагаться на этот API при проектировании таких систем, как Dropbox. Однако, скорее всего, на собеседовании вы не сможете просто сказать: “Я бы использовал S3 Multipart Upload API”, не сумев объяснить, как он работает и как бы вы сами его реализовали, если бы это потребовалось. Тем не менее, сообщить интервьюеру о том что вы знаете про существующее готовое решение - хорошая идея, поскольку это демонстрирует практический опыт.

2. Как можно максимально ускорить загрузку, скачивание и синхронизацию данных?

Мы уже обсудили несколько способов ускорения загрузки и скачивания, но есть еще кое-что, что можно сделать. Напомним, для скачивания мы использовали CDN для кэширования файла ближе к пользователю. Это позволило сократить расстояние, которое файл должен преодолевать до пользователя, уменьшив задержку и ускорив время скачивания. Для загрузки, помимо удобства возобновления, значительную роль в ускорении процесса играет разбиение на части. Хотя пропускная способность фиксирована, мы можем использовать разбиение, чтобы максимально эффективно использовать имеющуюся пропускную способность. Отправляя несколько фрагментов параллельно и используя адаптивные размеры фрагментов в зависимости от состояния сети, мы можем максимально использовать доступную пропускную способность. Аналогичный подход с разбиением на части можно использовать для синхронизации файлов - при изменении файла мы можем определить, какие части изменились, и синхронизировать только эти части, а не весь файл целиком, что значительно ускоряет синхронизацию.

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

Однако нам нужно разумно подходить к вопросу сжатия. Сжатие полезно только в том случае, если выигрыш в скорости от передачи меньшего количества байтов перевешивает время, необходимое для сжатия и распаковки файла. Для некоторых типов файлов, особенно медиафайлов, таких как изображения и видео, коэффициент сжатия настолько низок, что время, затрачиваемое на сжатие и распаковку файла, не оправдывает себя. Если вы прямо сейчас возьмете файл .png и сожмете его, вам повезет, если размер файла уменьшится более чем на несколько процентов - поэтому это не стоит того. С другой стороны, для текстовых файлов коэффициент сжатия намного выше, и в зависимости от условий сети это вполне может быть выгодно. Текстовый файл размером 5 ГБ может быть сжат до 1 ГБ или даже меньше в зависимости от содержимого.

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

Алгоритмы сжатия

Существует ряд алгоритмов сжатия файлов. Наиболее распространенными являются Gzip, Brotli и Zstandard. Каждый из этих алгоритмов имеет свои компромиссы с точки зрения степени сжатия и скорости. Gzip является наиболее распространенным и поддерживается всеми современными браузерами. Brotli - более новый алгоритм с более высокой степенью сжатия, чем Gzip, но он поддерживается не всеми браузерами. Zstandard - самый новый алгоритм с самой высокой степенью сжатия и скоростью, но он также поддерживается не всеми браузерами. Вам нужно будет выбрать алгоритм, исходя из ваших конкретных условий.

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

3. Как можно обеспечить безопасность файлов?

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

  1. Шифрование при передаче: конечно, для большинства кандидатов это очевидно. Мы должны использовать HTTPS для шифрования данных при их передаче между клиентом и сервером. Это стандартная практика, поддерживаемая всеми современными браузерами.

  2. Шифрование при хранении: мы также должны шифровать файлы, когда они хранятся в S3. Это встроенная функция S3, и ее легко включить. Когда файл загружается в S3, мы можем указать, что он должен быть зашифрован. Затем S3 шифрует файл с помощью уникального ключа и сохранит ключ отдельно от файла. Таким образом, даже если кто-то получит доступ к файлу, он не сможет расшифровать его без ключа. Подробнее о шифровании в S3 можно узнать здесь.

  3. Контроль доступа: наш список общего доступа (sharelist) или отдельная таблица/кэш общего доступа - это наш базовый ACL (Access Control List, список контроля доступа). Как обсуждалось ранее, мы гарантируем, что предоставляем ссылки для скачивания только авторизованным пользователям.

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

Здесь снова вступают в игру подписанные URL-адреса, о которых мы говорили ранее. Когда пользователь запрашивает ссылку для скачивания, мы генерируем подписанный URL-адрес, действительный только в течение короткого периода времени (например, 5 минут). Затем этот подписанный URL-адрес отправляется пользователю, который может использовать его для загрузки файла. Стоит отметить, что подписанные URL-адреса являются токенами “на предъявителя” (bearer token) - любой, у кого есть действительный, непросроченный URL-адрес, может загрузить файл. Короткий срок действия ограничивает уязвимость, но не полностью предотвращает распространение. Для более строгих сценариев безопасности можно добавить дополнительные ограничения, такие как привязка к IP-адресу, или потребовать использования подписанного URL-адреса в сочетании с аутентификационными файлами cookie.

Подписанные URL-адреса также работают с современными CDN, такими как CloudFront, и являются функцией S3. Вот как это работает:

  1. Генерация: на сервере генерируется подписанный URL-адрес, включающий подпись, которая обычно содержит путь к URL-адресу, метку времени истечения срока действия и, возможно, другие ограничения (например, IP-адрес). В случае CloudFront эта подпись создается с использованием закрытого ключа поставщика контента.

  2. Распространение: подписанный URL-адрес распространяется авторизованному пользователю, который может использовать его для прямого доступа к указанному ресурсу из CDN.

  3. Проверка подписи: когда CDN получает запрос с подписанным URL-адресом, он проверяет подпись, используя соответствующий открытый ключ (зарегистрированный в CloudFront), проверяет метку времени истечения срока действия и любые другие ограничения. Если подпись действительна и срок действия URL-адреса не истек, CDN предоставляет запрошенный контент. В противном случае доступ запрещается.

Что ожидается на каждом уровне?

Хорошо, мы обсудили много всего. Возникает резонный вопрос: “сколько из этого реально ожидается от меня на интервью?” Разберем по уровням.

Middle

Ширина vs глубина: от Middle кандидата чаще ожидается ширина кругозора и знаний (примерно 80% vs 20%). Вы должны собрать понятный высокоуровневый дизайн, закрывающий все функциональные требования, но многие компоненты могут оставаться абстракциями, которые вы проработали и обсудили с интервьюером на поверхностном уровне.

Проверка базовых знаний: интервьюер будет прощупывать базу, чтобы удостовериться, что вы понимаете, что делает каждый компонент. Например, добавив API Gateway, ожидайте вопрос “что он делает” и “как работает”.

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

Задача Dropbox: от Middle кандидата ожидается четко определенный API и модель данных, а также высокоуровневый дизайн, который функционально покрывает все процессы загрузки, скачивания и обмена файлами. Не ожидается, что кандидаты сразу будут знать о предварительно подписанных URL-адресах или о прямой загрузке/скачке в/из S3, или сразу предложат разбиение на части. Однако после уточняющих вопросов, таких как: “Вы сейчас загружаете файл дважды, как этого избежать?” или “Как можно показать прогресс пользователя, позволяя ему возобновить загрузку?”, они смогут проанализировать проблему и прийти к решению коммуницируя с интервьюером.

Senior

Глубина экспертизы: от Senior кандидата ожидания смещаются к глубине - примерно 60% ширины и 40% глубины. Нужно уметь уходить в детали там, где у вас есть практический опыт. Крайне важно продемонстрировать глубокое понимание ключевых концепций и технологий, имеющих отношение к поставленной задаче.

Продвинутый дизайн системы: вы должны быть знакомы с современными принципами проектирования систем. Например, знать, как использовать объектное хранилище или как использовать CDN для более быстрой загрузки.

Аргументация решений: вы должны уметь ясно объяснять плюсы/минусы архитектурных решений и их влияние на масштабирование, производительность и поддерживаемость, проговаривая компромиссы.

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

Задача Dropbox: от Senior кандидатов ожидается, что они быстро пройдут начальный этап проектирования, чтобы затем подробно обсудить, как обрабатывать загрузку больших файлов. Хотя это и не является обязательным требованием, многие кандидаты имеют опыт работы с загрузкой файлов и могут рассказать о некоторых API (например, о S3 Multipart Upload API) и принципах их работы.

Staff+

Акцент на глубину: от Staff+ кандидата ожидается глубокий разбор нюансов - примерно 40% ширины и 60% глубины. Важна демонстрация того, что, даже если вы не решали именно эту задачу раньше, вы решали достаточно похожих задач в реальном мире, чтобы уверенно спроектировать решение, опираясь на опыт.

Интервьюер понимает, что вы знаете основы (REST, нормализация данных и т. п.), так что вы можете быстро пройти это на high-level дизайне и перейти к самому интересному.

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

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

Решение проблем: ожидаются сильные навыки решения проблем с учетом факторов масштабирования, производительности, надежности и поддерживаемости.

Задача Dropbox: от Staff+ кандидата ожидается высокое качество решений по сложным задачам, которые обсуждались выше. Сильные кандидаты глубоко разбирают каждую тему, от них также ожидается четкое понимание компромиссов между различными решениями и способность ясно их сформулировать.


Разборы задач по System Design


Комментарии (5)


  1. Dhwtj
    14.05.2026 14:10

    Без понимания экономики сервиса это всё пук в лужу.

    Как увидел CDN и прочие потенциально дорогие решения сразу засомневался.

    Также критично для экономики срок хранения файлов.

    Не впадайте в архитектурный азарт, поставьте реальные цели и считайте каждую копейку


    1. pavsenin Автор
      14.05.2026 14:10

      Здравствуйте, спасибо за комментарий!

      Я не знаю, что вам ответить кроме цитаты из статьи относящейся к CDN:

      Проблемы

      CDN-сети относительно дороги. Для решения этой проблемы обычно используют стратегический подход к тому, какие файлы кэшируются и как долго. Можно использовать заголовок управления кэшем, чтобы указать, как долго файл должен кэшироваться в CDN. Также можно использовать механизм аннулирования кэша для удаления файлов из CDN при их обновлении или удалении. Таким образом, кэшируются только часто используемые файлы, и мы не тратим деньги на кэширование файлов, к которым обращаются редко.


      1. Dhwtj
        14.05.2026 14:10

        Вот, думаю, что для разработчика и для архитектора требования на собеседовании совсем разные.

        Реактивный дизайн (решения всплывают по ходу, требования додумываются) на собеседовании большая ошибка, я считаю. Для разработчика ок, для архитектора нет.

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

        А для архитектора не хватает

        1. Оценки масштаба (количества пользователей всего и активных, размер хранилища, RPS, трафик) - без них решения берутся с потолка

        2. Ранжирование сценариев по частоте - что оптимизируем в первую очередь

        3. Ключевые архитектурные решения до коробочек: блочная модель/чанки, иммутабельность, разделение metadata/content, модель конфликтов

        4. Экономика/себестоимость - дедуплиеация, tiering, лимиты как следствие экономики

        Продвинутый дизайн системы: вы должны быть знакомы с современными принципами проектирования систем. Например, знать, как использовать объектное хранилище или как использовать CDN для более быстрой загрузки.

        Нет, не должен))) 80% архитектурных ошибок отсеиваются на уровне здравого смысла (смотри выше), до технологий может и не дойти если задумка плохая


        1. pavsenin Автор
          14.05.2026 14:10

          Вы правы, требования зависят от роли, на которую вы претендуете. В конце данной статьи подробно рассказываются примерные требования для Middle, Senior и Staff кандидатов (не буду цитировать, чтобы не перепечатывать в комментарии всю статью).

          Конечно в разных компаниях (и даже у разных интервьюеров) они могут отличаться. Если у вас есть возможность узнать детали того как проходит System Design интервью именно в вашу целевую компанию - я всегда рекомендую кандидатам так делать. Это хорошо и для кандидата: он будет лучше готов, для него меньше неожиданностей. И для интервьюера: не нужно тратить время на объяснения, интервьюер за часовое интервью успевает собрать все сигналы по областям компетенций, которые необходимы для роли. Конечно в рамках того, что в принципе возможно собрать за час (но тут интервьюер находится в рамках процесса найма, установленных в компании).

          Что касается функциональных, нефункциональных требований, структуры интервью, шаблонных и нешаблонных решений и их обоснований - это большая тема для обсуждения, ее невозможно рассказать в комментариях.

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


        1. pavsenin Автор
          14.05.2026 14:10

          А для архитектора не хватает

          1. Оценки масштаба (количества пользователей всего и активных, размер хранилища, RPS, трафик) - без них решения берутся с потолка

          Здесь я с вами соглашусь лишь частично. Умение все это считать - требование для Middle кандидата. Неопытные кандидаты очень часто тратят на оценки слишком много времени, игнорируя большую часть результатов этих оценок. Staff+ кандидаты не тратят время на оценки, если они не имеют прямого непосредственного влияния на конкретную часть дизайна, которая сейчас рассматривается. Если прямо сейчас кандидат обсуждает нужно ли шардировать базу - самое время сделать оценки масштаба для принятия этого решения, это разумно. Но если кандидат просто посмотрел 10+ шаблонных видео на ютуб, в которых все так делают в начале интервью и поэтому он сделает так же - чтобы выглядеть "как архитектур" - это бесполезно, опытный интервьюер легко это считает.