company_banner

Миграция фотографий или ещё одна очередь на MySQL

    Недавно мы писали о том, как перед нами впервые встала задача крупномасштабной миграции данных пользователей между дата-центрами и о том как мы ее решили.
    В этот раз мы подробнее остановимся на том, каким образом осуществлялась миграция фотографий пользователей и какие структуры данных использовались для ограничения создаваемой нагрузки на сервера с фотографиями.
    Ежедневно пользователи Badoo загружают примерно 3 миллиона фотографий. Для их хранения мы выделили специальный кластер серверов, занимающихся также изменением размеров, наложением «водяных знаков», импортом фотографий из других социальных сетей и прочими манипуляциями с файлами.
    Все машины этого кластера можно условно разделить на три группы. Первая ― это серверы, отвечающие за быструю отдачу фотографий пользователям (можно сказать, собственная реализация CDN). В контексте миграции эти серверы нам не будут интересны. Вторая группа ― это хранилища с дисками, на которых, собственно, и находятся все фотографии. И третья группа ― это серверы, предоставляющие интерфейс ко второй группе, условно назовём их фотосерверами. На них по оптоволокну смонтированы дисковые массивы хранилищ, на эти же машины происходит загрузка фотографий и здесь же работают все скрипты, выполняющие какие-либо операции с файлами.
    Таким образом, для PHP-кода совершенно неважно, на каком именно диске какого хранилища находится фотография. Все, что нужно сделать, это перенести фотографии пользователя с одного фотосервера на другой и обновить эту информацию в базе данных и некоторых демонах. Здесь важно отметить, что все фотографии пользователя всегда находятся на одном фотосервере.

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


    Суммарный объем всех фотографий, когда-либо загруженных нашими пользователями, составляет примерно 600 Тб. В это число входят как оригиналы фотографий, так и набор фотографий с измененными размерами, необходимый для отображения в том или ином случае.
    Грубая оценка показывает, что если 190 миллионов пользователей загрузили 600 ТБ данных, то 1,5 миллиона пользователей из Тайланда (самой крупной из перенесённых между дата-центрами стран) загрузили 4,7 ТБ. Пропускная способность канала между нашими дата-центрами на на момент миграции составляла 200 Мбит/с. Путем нехитрых вычислений мы получаем 55 часов на перенесение всех фотографий пользователей из Тайланда. Естественно, этот канал уже частично занят другими данными, постоянно циркулирующими между дата-центрами, и в действительности понадобится больше, чем 55 часов. А наша цель ― успеть за 8 часов.
    Можно было бы перенести только оригиналы фотографий и сделать для них «ресайзы» на новом фотосервере, но это привело бы к нежелательному росту нагрузки на процессоры. Поэтому мы решили перенести фотографии заранее, рассчитывая, что пользователи не успеют загрузить много новых фотографий за время, необходимое на перенесение фотографий всей страны.
    То есть сначала мы просто копируем уже существующие фотографии, а во время миграции всех данных пользователя (когда сайт для него недоступен и невозможно что-то изменить в своих фотографиях) проверяем, были ли какие-то изменения после переноса фотографий. Если изменения были, то копируем фотографии еще раз (вернее, сделаем rsync), и только после этого обновим данные в базах и демонах, чтобы фотографии пользователя стали показываться и загружаться на новом фотосервере.
    Как показала практика, наши ожидания оправдались, и делать rsync второй раз пришлось для очень незначительного процента пользователей.
    Еще одним ограничением для нас являлась производительность дисков хранилищ. Мы выяснили, что даже имея канал в сотни терабит, мы не сможем использовать его на полную мощность, поскольку на фотосерверы постоянно загружаются фотографии, с ними производятся различные операции. Кроме того, наш CDN «читает» эти фотографии с дисков, и дополнительная нагрузка на чтение может существенно замедлить повседневные операции. То есть интенсивность миграции фотографий должна быть искусственно ограничена.

    Реализация


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

    CREATE TABLE MigrationPhoto (
     user_id     INT PRIMARY KEY,
     updated     TIMESTAMP,
     photoserver VARCHAR(255), # фотосервер c фотографиями пользователя
     script_name VARCHAR(255), # имя процесса, обрабатывающего пользователя
     done        TINYINT(1),   # 1 если фотографии были перенесены, иначе 0
     KEY photoserver (photoserver),
     KEY script_name (script_name)
    )
    

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

    INSERT INTO MigrationPhoto (user_id,photoserver) VALUES (00000000, 'photoserver1')
    

    Ограничение на суммарное число процессов легко достигается с помощью расширения pcntl и не представляет большого интереса, поэтому далее будем рассматривать один процесс, занимающийся переносом.
    Нам нужно обеспечить конкретное число процессов на один фотосервер. Сначала разберемся, пользователи с каких фотосерверов вообще есть в очереди. Чтобы не делать каждый раз SELECT photoserver, COUNT(*) FROM MigrationPhoto, заведем отдельную таблицу:

    CREATE TABLE MigrationPhotoCounters (
     photoserver VARCHAR(255) PRIMARY KEY,
     users       INT
    )
    

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

    INSERT INTO MigrationPhotoCounters (photoserver, users) VALUES ('photoserver1', 1) ON DUPLICATE KEY UPDATE users = users + VALUES(users)
    

    Либо после заполнения MigrationPhoto сделаем так:

    INSERT INTO MigrationPhotoCounters (bphotos_server, users) VALUES (SELECT photoserver, COUNT(*) AS users FROM MigrationPhoto)
    

    Имея такую таблицу, при запуске каждого процесса будем делать

    SELECT photoserver FROM MigrationPhotoCounters WHERE users>0 ORDER BY RAND()
    

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

    $processNumber = null;
    foreach ($photoservers as $serverName) {
        for ($i = 1; $i <= PROCESSES_PER_SERVER; $i++) {
               $lock = executeQuery("SELECT GET_LOCK('migration" . $serverName . '_' . $processNumber . "', 0)");
               if ($lock === '1') {
                       $processNumber = $i;
                   break;
               }
        }
        if ($processNumber) {
               $serverName = $serverName;
               $scriptName = 'migration' . $serverName . '_' . $processNumber;
               break;
        }
    }
    

    Таким образом, мы перебираем в двух вложенных циклах все фотосерверы и номера процессов для них. Если выполнены все итерации внутреннего цикла и переменная $processNumber не определена, значит, для данного фотосервера достигнут лимит количества процессов. Если выполнены все итерации внешнего цикла, значит, такой лимит достигнут для всех фотосерверов, на которых еще имеются пользователи, подлежащие переносу.
    Допустим, мы выбрали фотосервер photoserver1, и это второй процесс для него, то есть идентификатор процесса будет $scriptName = 'migration_photoserver1_2'.
    Перед тем как двигаться дальше, вернем в общую очередь тех пользователей, которые по тем или иным причинам остались помеченными выбранным нами идентификатором процесса ($scriptName) при предыдущих запусках:

    UPDATE MIgrationPhoto SET script_name = NULL WHERE done = 0 AND script_name = 'migration_photoserver1_2'
    

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

    UPDATE SET MigrationPhoto script_name='migration_photoserver1_2' WHERE photoserver='photoserver1' AND done = 0 AND script_name IS NULL LIMIT 100;
    

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

    SELECT * FROM MigrationPhoto WHERE script_name='migration_photoserver1_2' AND done=0;
    

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

    UPDATE MigrationPhoto SET updated=NOW() WHERE user_id = 00000000
    

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

    BEGIN;
    UPDATE MigrationPhotoCounters SET users = users - 1 WHERE photoserver = 'photoserver1';
    UPDATE MigrationPhoto SET done = 1 WHERE user_id = 00000000;
    COMMIT;
    

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

    UPDATE MigrationPhoto SET script_name = NULL WHERE user_id IN (<failed_ids>)
    

    Завершаем процесс:

    SELECT RELEASE_LOCK('migration_photoserver1_2')
    

    Казалось бы, на этом можно закончить. Но у нашей схемы есть принципиальный недостаток.
    Предположим, что запустился процесс, у которого $scriptName='migration_photoserver1_10', при этом PROCESSES_PER_SERVER=10. И этот процесс упал, не вернув взятых им пользователей в очередь. Для того чтобы эти пользователи снова были выбраны, либо снова должен запуститься процесс с таким же $scriptName, либо кто-то должен выставить этим пользователям в базе script_name=NULL. Запуска процесса с таким же $scriptName может больше и не случиться.
    Например, у нас 100 фотосерверов в MigrationPhotoCounters, ограничение на суммарное число процессов ― 50, ограничение на число процессов на один ― 10, тогда очевидно, что если в какой-то момент на один фотосервер пришлось 10 процессов, то в дальнейшем этот фотосервер может получать только по одному процессу. Поэтому напишем еще один скрипт, который, допустим, один раз в минуту будет устанавливать script_name=NULL для тех пользователей, процессы которых сейчас не запущены:

    foreach ($photoservers as $serverName) {
        for ($i = 1; $i <= PROCESSES_PER_SERVER; $i++) {
               $lock = executeQuery("SELECT GET_LOCK('migration" . $serverName . '_' . $processNumber . "', 0)");
               if ($lock === '1') {
                       executeQuery("UPDATE MigrationPhoto SET script_name = NULL WHERE done = 0 AND script_name = 'migration" . $serverName . '_' . $processNumber . "'");
               }
        }
    }
    

    Теперь, даже в случае падения процесса, его пользователи станут доступны для обработки другим процессам. Кроме того, это позволит менять ограничение числа процессов на один фотосервер «на лету».
    Когда процесс будет завершен и начнется миграция всех остальных данных пользователя, достаточно лишь проверить, что фотографии пользователя не менялись со времени, указанного в поле updated таблицы MigrationPhoto. А если менялись ― то просто повторить rsync. Это не займет много времени, так как практически никто из пользователей не меняет все свои фотографии за 2-3 суток.
    В итоге у нас было 63 фотосервера, с которых мы читали фотографии, и 30 серверов, на которые писали. Все это происходило силами 80 процессов, с ограничением не более 3 процессов на один фотосервер. При таких ограничениях трафик составлял 150 Мбит/с. Перенесение фотографий для пользователей из Тайланда заняло чуть менее трех суток. Учитывая объем данных, мы получили отличный результат.

    Заключение


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

    Антон Степаненко, Team Lead, PHP-разработчик
    Badoo
    381,33
    Big Dating
    Поделиться публикацией

    Похожие публикации

    Комментарии 17

      +10
      А не проще поставить, например, RabbitMQ (или любую другую реализацию очереди) в котором это все уже есть? И работает быстрее? И по стандартному протоколу, который поддерживается кучей решений, библиотек, фреймворков итд итп?

      И квотирование организовать опять же стандартным алгоритмом на уровне обработчиков, вообще не трогая базу лишний раз (http://en.wikipedia.org/wiki/Leaky_bucket)?
        +9
        RabbitMQ — это новая сущность, которую нужно устанавливать, настраивать и поддерживать. MySQL у нас уже есть, мы умеем его готовить. Как мы писали, миграция фотографий — это одна из частей всей миграции пользователей. То есть в базе хранится много других данных, которые хочется обрабатывать транзакционно. Вынеся часть данных в RabbitMQ мы такую возможность потеряем. Что касается скорости — нас вполне устроили полученные результаты, и если говорить именно о задаче миграции фотографий, то здесь MySQL не являлся узким местом.
          +1
          А вы вместо него написали свою очередь, и ее тоже нужно устанавливать, настраивать и поддерживать. Тем более что инструмент этот не навсегда, а только для обслуживания переезда. Я взглянул на RabbitMQ, там действительно все очень просто и с подробным туториалом. У вашего подхода я вижу только один плюс — есть о чем рассказать в посте
            +4
            Не могу не высказаться в поддержку rabbitmq любят у вас там гвозди закручивать отвёртками...
            Настраивать там на самом деле нечего (у меня очереди по 2.7млн сообщений обслуживались установленным из стандартного репозитория Ubuntu стандартным же rabbitmq, в конфиг вообще не заглядывал).
            Падающие воркеры прекрасно обрабатываются за счёт отложенного ACK (консьюмер забрал сообщение из очереди а потом закрыл коннект (умер) не прислав ACK — сообщение возвращается обратно в очередь).
            Можно для каждого фотосервера создать отдельную очередь и следить по длинне очереди за прогрессом. Можно воркеров подключать к нескольким очередям, можно перекидывать между ними.

            Насчёт транзакционности не знаю — в посте об этом не было сказано. На мой взгляд проблема надуманная, но вам виднее наверное.
            +8
            Возможно это прозвучит немного странно, но по опыту работы с высоконагруженными проектами (и серверами в нескольких странах), очень часто свой странный и нелогичный местами костыль работает быстрее и надежнее, чем специализированное ПО, не говоря уже о возможностях контроля и гибкости (при условии если есть понимающий человек конечно же).

            Впервые это ощутил при настройке nginx, который дико валился по инструкциям (аля кол-во воркеров от числа ядер или как то так), но уже 2 года отлично работает со странным конфигом и ни разу не оказался узким местом. Самое узкое место на данный момент — готовый фреймворк, которые очень хвалят по всему интернету, а в реальности он тупо жрет ресурсы и оптимизации почти не поддается. Для примера — есть 2 скрипта для проверки авторизации, логика 1 к 1 у обоих, но первый написан с использованием фреймворка и на равном месте иногда до 23 секунд (да да! 23 секунды, почти половина минуты) повисает, другой напрямую без всяких оберток, ООП и т.д. дергает базу и стабильно работает с предсказуемым временем не более 0.01 секунды.
              0
              А что за фреймворк, если не секрет, чтобы не напороться на грабли.
                +1
                Если на проекте не куча народу постоянно и трафик не 1-2 Гбит/с, то не стоит переживать за название и можно использовать любой.
                Как бы меня не заминусовали любители этого чуда… В моем случае CodeInginter (наследие прошлого девелопера) то и дело подсовывает сюрпризы, которые в начале проекта никак себя не проявляли и вообще намека не было на будущие проблемы. В итоге времени на переписывание и оптимизацию уходит больше, чем на новые фичи, но скоро надеюсь полностью отвяжемся от этого ужаса и будет чистый php и python.
                зы: не стоит писать что CI фигня, а %framework% лучше, они все хорошие, но начинают тупит при увеличении нагрузки и в итоге то тут, то там приходится этот самый фреймворк допиливать.
                  0
                  Действительно, очень странно. Там же просто нечему тормозить :-) Обычно php-код не является узким местом и все его проблемы решаются дополнительными серверами. Конечно, так и хочется Вам посоветовать другой, гораздо более производительный фреймворк, который наверно, сможет помочь, но не буду :-) Потому что настоящий high-load лучше действительно писать с нуля.
                    +2
                    Мне кажется сложно изначально всё просчитать и сразу же написать идеальный код, в моем случае уже был проект на CI, его надо было доводить до ума (в итоге от старого варианта остались почти только названия таблиц в базе). Пока юзеров и данных было мало, всё шло отлично, теперь то тут, то там переписываю архитектуру, убираю/упрощаю код, но как не крути, отдельные/самостоятельные части, написанные на чистом php почти не сбоют, всё что использует фреймворк — иногда тупит на ровном месте.

                    Наверное для highload профи это не новость (привет КО), но для себя сделал несколько заметок по мере переписывания старого кода и решения разных проблем:
                    0) не решать проблему, пока её нет — в частности не заниматься оптимизацией, пока не стало 100% ясно где узкое место
                    1) под всё что можно — очереди, выполняюся они быстро, но при резком (а это не часто) увеличении нагрузки сервер не загнется
                    2) кеш на 1-3 секунды в redis (или аналоги) офигенно снимает нагрузку со всего, если конечно система не критична к актуальным данным
                    3) свою работу с сессиями с учетом нужд и особенностей проекта не связаную никак с дисками (т.е. только в памяти всё)
                    4) любые сложные участки разбивать на куски (см п.1), и хорошо бы разбивать по серверам
                    5) куча избыточных данных в таблицах, чтобы как можно меньше джойнить и вообще стараться делать запросы не сложнее SELECT item1, item2 FROM table WHERE key=123;
                    6) уводить на сторону клиента (JS) всё что можно, в идеале php должен заниматься только проверкой кеша, запросами к базе и отдачей этих данных клиенту
                    7) использовать консольные утилиты в Linux по максимому
                    8) не писать больше ничего подобного с использованием фреймворков как на стороне сервера, так и не стороне клиента — да, это сложнее, дольше и геморойнее, но это в итоге дает хороший прирост производительности

                    Ну и слушать советы и сразу принимать на веру что-то не стоит (к этому сообщению это так же имеет отношение), т.к. всё сильно зависит от проекта. Более того — почти все советы выше вредны обычным сайтам — профита никакого, а гемороя с написанием/поддержкой море.
                      +1
                      Забыл еще один пункт… Начните проект с написания API и сами же его сразу используйте, проект будет легко разнести по серверам, оптимизация кода становится проще, да и когда то всеравно надо будет писать клиент для мобильных платформ.
                0
                по-моему это прописная истина: хочешь скорость, забудь про фреймворки
                  +3
                  Слабо верится. Фреймворк конечно замедляет выполнение процесса, но 23 секунды — это из области фантастики. Скорее всего где-то в фреймворке что-то не так используется, где-то неявные лишние вызовы чего-то, запись в папку с миллионом файлов или sleep(23) от предыдущего недовольного сотрудника
                +5
                Примерно год назад переносил данные между датацентрами (около 20ТБ) на разных континентах, аналогично сперва перекинул всё что есть, т.к. юзеры много не зальют, заняло правда около месяца, т.к. сервера еще CDN и мелкие php демоны, мучающие файлы, убирающие дубликаты, ресайзеры/превьюшиники и т.д.
                Файлы в таблице хранились с индексом сервера, т.е. после переноса всех файлов, запустил rsync*, чтобы постоянно стартовал и копировал и когда убедился что файлы на новом месте, то сделал update поля с индексом сервера, потом дожлася когда закончаться все загрузки со старого сервера и почистил хранилище.
                * rsync напрямую работал медленее, чем если примонтировать хранилище через sshfs (самый быстрый вариант оказался от 23 МБайта/с и больше) + rsync, копировал не все сразу, а отдельно файлы до 100мб и отдельно больше 100мб, иначе копирование иногда затыкалось по непонятной причине или просто очень медленно шло.
                  +13
                  любопытства ради спрошу, а несколько вместительных hdd + самолет не рассматривали? затем догнать изменения по сети уже и переключить балансеры (или что у вас направляет юзера на фотосервер), и сеть бы не нагружалась лишний раз
                    0
                    Так не интересно )
                    +6
                    как же у вас все хорошо и одновременно очень плохо
                      +1
                      А у нас очереди постоянных копирований (не разово, а день за днем, за годом год) мигрируют как раз на Redis с транзакционностью через встроенный lua.
                      Percona не очень себя хорошо повела на очередях из сотен тысяч файлов, когда источников и получателей (машин) сотни.

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

                      Самое читаемое