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

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

Задача и требования

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

Из бизнес-задачи мы составили следующие требования: 

  1. Фотографии в ленте должны отображаться и сортироваться по дате их создания — не по дате загрузки или дате изменения. 

  2. Галерея должна скроллиться в вебе и мобильной версии. 

  3. Лента должна синхронизироваться на мобильные устройства без блокировки её просмотра. Зайдя в приложение, пользователь сразу должен видеть свои фото без задержек.

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

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

  6. В панели навигации должно быть видно, в какой период сделано больше фото. Например, в моём Диске за 2024 год уже больше фотографий, чем за 2023-й, и эта информация должна быть легко доступна пользователю.

Также мы оценили прогнозируемые нагрузки и построили систему под следующие показатели:

А ещё подсчитали объём данных, необходимых для синхронизации. Выяснили, что потребуется больше 10 ТБ данных и больше 10 шардов PostgreSQL. 

Как устроен флоу загрузки файлов в Диск

Всё начинается с того, что пользователь загружает файл на серверы загрузки (Uploader). А Uploader отправляет в файловую систему (Disk FS) событие, что файл загружен.

Флоу загрузки
Флоу загрузки

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

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

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

Панель навигации

 Чтобы реализовать удобную панель навигации, нужно учесть несколько вещей:

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

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

  3. Нужна структура, хранящая количество фотографий за разные даты.

 Чтобы придумать такую структуру, мы рассмотрели несколько вариантов.

Вариант 1: Статистика за день

Например, 1 января у меня сделано 10 фотографий, 2 января — 5 фотографий, а 4 января не сделано ни одной.

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

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

Вариант 2: Статистика за месяц

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

Но если фотографий за один месяц окажется много, то навигация внутри такого диапазона станет сложной. 

Рассмотрим пример. Пользователь сделал за месяц около 200 000 фотографий. Если он захочет быстро перейти к определённому периоду, например к январю 2024 года, нам придётся как-то выбрать, какие фотографии отображать. Нужны ограничения — limit offset, или сложная пагинация. Этого хочется избежать, потому что производительность limit offset падает с увеличением числа элементов и на большом числе фотографий перестанет удовлетворять нашим требованиям по скорости работы.

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

Есть всеми нами любимая дата — 1 января 1970 года, к которой может быть привязано бесконечное количество фотографий. Эта дата часто появляется, когда слетает EXIF-информация, и многие инструменты с багами могут её проставлять. Не секрет, что это самая частая дата фотографий в Диске, и такие фотографии будут продолжать появляться, их количество безгранично.

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

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

Кластеры фотографий

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

  1. Каждая фотография должна попадать только в один кластер — мы хотим  показывать каждую фотографию только один раз.

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

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

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

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

Концепция

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

Нам потребуется ещё один сервис — Smartcache. Это сервис для синхронизации данных. Он нужен, чтобы раскладывать данные для клиентов и быстро их передавать. Подробнее о его устройстве расскажу в части «Синхронизация». 

После того как кластеризатор обновит кластеры, он отправит событие в Smartcache, который обновит данные и отправит клиенту уведомление о новых фотографиях в разделе «Фото».

Задачи кластеризации

Здесь есть две большие задачи, которые нужно решить. 

Инициализация кластеров. У пользователей Диска уже есть загруженные фотографии. Будет странно, если при раскатке фичи их фотографии не попадут в раздел «Фото». Поэтому нужно кластеризовать все фото. Можно сделать это любым доступным способом, например загрузить все фотографии одного пользователя в память, кластеризовать их и записать результат.

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

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

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

Устройство кластеров

 Кластеры у нас выглядят следующим образом:

Устройство кластеров
Устройство кластеров

В кластере есть метаинформация про число фотографий в нём и про даты самой первой и самой последней из них. Эти три параметра говорят, что в диапазоне с from по to было сделано ровно photosCount фотографий. Если у нас будет метаинформация по всем кластерам, то мы сможем без труда построить панель навигации способом, описанным выше. 

Внутри каждого кластера содержатся фотографии. У них много параметров, но вот основные: 

  • ID. Нужен для запроса оригинала фотографии и отображения его пользователю.

  • Ширина и высота. Нужны, чтобы фронтенд мог заранее узнать, какие фотографии с какими размерами будут отображаться, и отрисовать сетку. Так она не будет прыгать у пользователей в момент загрузки фотографий, а будет создавать плавный и гладкий UX. 

Синхронизация

Перейдём к сервису синхронизации — Smartcache. Чтобы разобраться, как он устроен, введу два новых понятия: 

  1. Snapshot (снапшот). Это слепок состояния базы данных с некоторой версией N. В нашем случае снапшот будет выглядеть как набор кластеров: есть кластер № 1 с набором фотографий, есть кластер № 2 с другим набором фотографий и так далее.  

  2. Delta (дельта). Это итеративное изменение, которое перевело снапшот из версии N в N+1. Дельта может выглядеть следующим образом: у нас был кластер № 1, в нём удалилась фотография 1 и добавилась фотография 5.

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

Дельты 

Для чего нужны дельты? Приведу пример. Скажем, у пользователя в Диске 10 000 фотографий. Нам нужно выкачать много метаинформации, чтобы отобразить пользователю весь раздел «Фото». А если он только что сделал 10 новых фото, то отправить их дельту намного проще, чем перекачивать все данные. На больших объёмах фотографий этот подход очень эффективен.  

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

Мы выбрали следующие дельты:

  • создано новое поле или объект;

  • изменено существующее поле;

  • поле удалено.

Поведение клиентов — 1

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

Рассмотрим процесс поэтапно:

  1. Всё начинается с того, что пользователи устанавливают приложение, скачивают Диск и открывают раздел «Фото».

  2. Приложение обращается к Smartсache с запросом на получение актуальных данных, так как необходимых данных у него нет.

  3. Smartcache передаёт снапшот с указанием версии и соответствующими данными.

  4. Клиент, имея снапшот версии N, запрашивает обновление, указывая текущую версию и необходимость получить актуальные данные.

  5. Smartсache присылает ему дельты от версии N до актуальной.

  6. Клиент применяет их локально и получает актуальную версию данных.

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

Такой подход в целом решает задачу, но не особо эффективно. Далее рассмотрим решение, которое подойдёт лучше.

Поведение клиентов — 2

Давайте рассмотрим несколько сценариев.

Сценарий 1:

  1. Клиент уведомит систему о желании получать уведомления об изменениях данных.

  2. Smartcache согласится отправлять пуш-уведомления при возникновении изменений.

  3. Когда данные действительно изменятся, Smartcache отправит пуш.

  4. Клиент, получив уведомление, поймёт, что появились новые данные, и выполнит тот же запрос, что и ранее, чтобы получить и применить дельты локально.

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

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

Сценарий 2.

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

Поведение клиентов
Поведение клиентов

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

Чистка дельт

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

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

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

Поэтому мы: 

  • выставляем TTL для дельт;

  • делаем ограничение по количеству дельт.

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

Прокачиваем синхронизацию

Но и это ещё не всё. Есть проблемы, которые мешают классно работать на продакшне.

Проблема 1. Сотни тысяч фотографий у пользователей. У пользователей очень много фотографий, а значит, снапшот будет очень большим. И он может измениться в процессе долгого получения метаинформации. 

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

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

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

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

Например, после конференции Highload++ появятся фотографии на Яндекс Диске. Вы нажмёте кнопку «Сохранить к себе», и вот у вас грузятся больше 2000 новых фотографий за одно нажатие кнопки. Эти фотографии нужно будет добавить, кластеризовать и отправить на ваши телефоны, если у вас включена синхронизация раздела «Фото». Появится 2000 апдейтов по одному и тому же кластеру. Такие апдейты довольно затратные, их нужно избегать. 

Решение: мы агрегируем такие обновления и применяем в одну дельту. Мы знаем, какие кластеры изменились, и можем выполнять отложенное обновление по ним. Это не сильно повлияет на пользователя, но существенно снизит нагрузку на базу и приложение.

Преимущества синхронизации и кластеризации  

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

  • Кластеризация позволяет дёшево реализовывать панель навигации. 

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

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

Поддержка фичи на клиентах

Веб

  1. Запрашиваем метаинформацию о кластерах и рисуем плашку навигации. Метаинформация о кластерах — это список кластеров, даты начала и конца в них, количество фотографий. Эта информация небольшая и весит немного: всего три поля. Мы быстро получаем её и отрисовываем панель навигации.

  2. Запрашиваем данные последнего по дате кластера и отрисовываем первые элементы. Мы знаем ширину, высоту и хеш фотографий, поэтому отрисовываем сетку и начинаем запрашивать контент. Пользователь видит свои первые фотографии.

  3. При навигации запрашиваем нужный кластер. В этом кластере все фотографии расположены рядом, так как мы их кластеризовали, и мы можем легко понять, какие из них нужно отрисовывать. Соответственно, достраиваем это место, также создаём сетку, рисуем фотографии из нужного кластера.

Мобильные устройства 

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

  2. Если метаданных по нужному диапазону нет, то работаем как в веб-версии. Мы запрашиваем метаинформацию о кластерах и фотографии с последним снапшотом, используя тот же алгоритм. Таким образом, пользователь сразу увидит свои фото. В таком сценарии автоматическая подгрузка новых фотографий не будет работать, но этот фолбэк-сценарий должен срабатывать нечасто. Основной вариант — это первый сценарий.

Как операция выглядит в итоге

И теперь мы можем объединить все части:

  1. Пользователь загружает файл на наш сервис загрузки (Uploader).

  2. Этот файл попадает в файловую систему (Disk FS).

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

  4. Кластеризатор обновляет кластеры в соответствии с теми правилами, которые мы обсудили.

  5. Информация про обновлённые кластеры отправляется в Smartcache.

  6. Smartcache обновляется, подготавливает у себя информацию для клиентов, то есть обновляется снапшот, пишет дельты, которые к нему привели.

  7. Если есть подписанные на пуши клиенты, отправляет им пуш о том, что нужно обновить данные.

Заключение и выводы 

Подход с частичной синхронизацией эффективен для больших объёмов данных. У нас много данных, но изменений данных немного. Мы пересылаем в тысячи раз меньше информации от сервера к клиенту для обновления разделов фото.

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

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

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

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