...

пятница, 30 января 2015 г.

Простые решения. Прокачиваем картинки


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





Суть была в следующем: наш проект Сars Mail.Ru имеет множество объявлений, к каждому из которых привязаны несколько фоток. Фотки могут загружаться пользователями вручную, а могут автоматически скачиваться краулером с партнёрских сайтов и прицепляться к объявлениям. При этом сами фотки довольно большие (до 10 Мб), и их почти всегда по несколько штук на объявление. Сами фотки хранятся на нескольких синхронных DAV-aх, нарезаются в несколько размеров, могут снабжаться watermark-ами. Т.е. процесс обработки одной фотографии (crop-resize-split-upload) весьма затратен и требует времени и ресурсов (CPU, диски, сетка).

Почти идеальная для нас архитектура должна минимизировать использование этих ресурсов и уметь решать следующие задачи:



  • уметь хранить несколько фотографий в разных размерах для одного объявления

  • хранить фотку в единственном экземпляре, даже если она привязана к нескольким объявлениям

  • не выполнять лишних действий при заливке фотографии, которая уже есть в базе, например, если пользователь залил фотку, потом ушел с формы, а потом снова попал на форму и снова залил ту же фотку, не выполнять crop-resize-split-upload, а использовать то, что сделано 5 минут назад

  • не скачивать лишний раз фотку с одного и того же URL, если мы качали ее недавно

  • не оставлять мусора на диске, если пользователь загрузил фото и ушел с сайта, так и не разместив объявление

  • максимально ускорить удаление плюс добавление большого количества объявлений, избавив его от загрузки и удаления больших массивов фотографий

  • сделать чистку хранилища от сгнивших фоток максимально быстрой




Если вы писали вертикальный поиск или импортируете от партнеров много сущностей с привязанными картинками, то ситуация вам несомненно знакома.

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

Перечислим некоторые недостатки этого решения.



  • Долгое добавление объявления (если не используется предзагрузчик).

  • Необходимо реализовывать отдельный механизм для предзаливки фоток в форме подачи объявления.

  • Предзагрузка пользователем фотки (с выводом preview) и реальное добавление фотки на проект (crop-resize-split-upload) — это разные алгоритмы, и успех первого не означает успех второго.

  • Долгое удаление объявления — при удалении надо удалить все связанные фотки с диска-DAV-а.

  • Суммарные последствия этих минусов, в полной мере проявляющие себя на больших объёмах и при распараллеливании импорта.




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

CREATE TABLE Images (
image_id: char(32) PRIMARY KEY, -- id фотки, из которого формируется урл, шарды, префикс для нарезки нескольких размеров и т.д.
offer_id: int unsigned NOT NULL FOREIGN KEY REFERENCES offers_table(id) ON DELETE RESTRICT, -- обязательная ссылка на объявление для данной фотки
url_hash: char(32) NULL, -- md5 от урла, с которого картинку скачали
body_hash: char(32) NULL, -- md5 от тела фотки
num: tinyint unsigned NOT NULL, -- порядковый номер фотки в объявлении
last_update: timestamp NOT NULL, -- время последнего изменения записи
);




Как я и обещал, решение очень простое — мы просто позволили существовать фоткам, не привязанным к объявлениям, а записям о них — дублироваться. Т.е. просто сделали необязательным внешний ключ offer_id, и убрали UNIQUE с image_id. Вот так:

image_id: char(32), -- теперь image_id неуникален, и может дублироваться
offer_id: int unsigned NULL FOREIGN KEY REFERENCES Offers(id) ON DELETE SET NULL




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

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

Сама форма добавления объявления не содержит и не обязана использовать POST для отправки данных. Фотки в этой форме являются просто скрытыми полями, в которые после предзаливки пользователем фотографий будут записаны соответствующие id фоток. Для заливки фоток используется отдельный ajax url, в который пользователь просто передает файл фотки, а в ответ получает image_id:

Загрузить фото 1


Загрузить фото 2





Внутри URL /pre-upload-photo/ (в который мы отправляем файл фотки) происходит следующее:



Получаем тело файла фотографии и считаем this_body_hash(md5 от тела фотки);

Ищем в таблице записи с body_hash == this_body_hash;

IF (такие записи существуют) {
Обновляем last_update у этих записей;
Выбираем одну из них либо создаём новую запись с пустым offer_id и тем же image_id;
} ELSE {
Делаем crop-resize-spilt-upload;
Добавляем запись с данным body_hash, свежим last_update, пустым offer_id и новым image_id;
}

Возвращаем image_id выбранной записи;




Теперь, заливая каждую фотку, юзер получает в ответ image_id, который кладется в соответствующий данной фотке input:







При добавлении собственно объявления пользователь отправляет на бэкенд пары:

photo1: 0cc175b9c0f1b6a831c399e269772661
photo2: 92eb5ffee6ae2fec3ad71c777531578f
photo3: 4a8a08f09d37b73795649038408b5f33


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



offer_id: NULL, num: 0, image_id: 4a8a08f09d37b73795649038408b5f33
offer_id: 1234, num: 3, image_id: 92eb5ffee6ae2fec3ad71c777531578f
offer_id: 1234, num: 1, image_id: 0cc175b9c0f1b6a831c399e269772661


Логику перераспределения порядка фотографий я опущу, а то так вам совсем не останется работы.


Итак, если я пользователь и у меня есть всего десять фоток, то, сколько бы я ни размещал объявлений, ни переставлял местами фотографии, и т.п., кроме одноразового crop-resize-split-upload никаких манипуляций над моими фотками выполнено не будет. Только скачивание, подсчет хэша и манипуляции над строками в таблице. Также не забудем rate-limit на URL предзагрузки фоток — чтобы нас не затопили злые DoS-еры.




Краулер партнерских фоток действует в другой последовательности, но смысл похож. Т.к. у него самое большое время занимает выкачка фотки с сайта партнёра, вместо body_hash используется url_hash (md5 от URL фотки). Таким образом, при помощи той же самой таблицы и той же схемы реализуется кэш скачивания фоток. Т.е. если мы качали фотку в течение N последних дней, независимо от того, использовали мы её или нет, мы не будем второй раз ходить за ней и делать crop-resize-split-upload.

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



Посчитать this_url_hash от входного URL;

Получить список фоток из таблицы, у которых url_hash == this_url_hash;

IF (таких нет) {
# новая фотка
Сделать crop-resize-spilt-upload;
Добавить в таблицу новую запись c нужным offer_id, url_hash, num, last_update;
} ELSIF (существует такая запись, но с другим, непустым offer_id) {
# фотка уже есть, но привязана к другому объявлению
Добавить в таблицу новую запись c тем же image_id, но нашим offer_id, url_hash, num и last_update;
} ELSE {
# есть запись о непривязанной, но уже залитой фотке, используем её
в найденной записи обновить offer_id или num, а также last_update;
}




Таким образом, следующее скачивание по этому URL не понадобится — мы используем текущую запись, продублировав её либо обновив.

DELETE FROM Offers WHERE id=?




Всё. ON DELETE SET NULL сбросит offer_id у всех фотографий данного объявления, а через N дней за ними явится скрипт чистки фоток и отправит туда, куда отправляются все ненужные фотографии. Собственно, процедура удаления объявления вообще ничего не знает ни про какие фотки, и это прекрасно.

SELECT image_id FROM ImagesTable i WHERE offer_id IS NULL AND (last_update + INTERVAL ? DAY) < NOW();




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

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


Таким образом, если юзер предзалил фотку, она будет валяться в базе и на дисках еще две недели, пока не придет чистильщик и не снесёт её. В течение этого времени, если пользователь вернётся на проект, заливка этих фоток для нас будет существенно легче (несколько простых манипуляций с записями в базе вместо crop-resize-split-upload). Фактически мы держим на проекте N-дневное «окно» фоток, которые, возможно, понадобятся.




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

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


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


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


Небольшие дополнения к вышеприведённым алгоритмам Как вы заметили, мы используем различные атрибуты (url_hash и body_hash) для определения уникальности фотки. В принципе, можно их совместить в один, и затем использовать его в качестве image_id — тогда код станет проще и надёжней. Дело в том, что у нас есть еще некоторая логика работы с фотками, которая требует оба этих хэша по отдельности, да и пришли мы к этой схеме через несколько итераций, поддерживая совместимость с предыдущим хранилищем. Поэтому я привел здесь именно вариант с отдельными хэшами. Но смысл технологии эти детали не меняют — мы отцепили логику загрузки и удаления фотографии от её связывания с проектными сущностями и ввели временной лаг между этими событиями.


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




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

  • предзагрузка юзерских фоток теперь имеет «кэш» (в течение N дней мы никогда дважды не crop-resize-split-upload одну и ту же фотку)

  • отсутствуют временные файлы (или memcached какой-нибудь) при предзагрузке юзерских фоток (за исключением тех, которые требуются для crop-resize-split-upload)

  • кэш скачивания для краулера фоток реализован тем же кодом, что и пользовательская загрузка

  • чистка сгнивших фоток производится очень быстро

  • код удаления объявлений ничего не знает про картинки и работает крайне быстро

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




Простых вам решений!

Recommended article: Chomsky: We Are All – Fill in the Blank.

This entry passed through the Full-Text RSS service - if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий