Простые решения. Прокачиваем картинки
Все мы любим простые решения. Есть мнение, что мы так ценим религию, тренинги по личностному росту и поддаёмся разводам потому, что мозг с большим удовольствием принимает простые решения вместо сложных, щедро награждая нас дофамином. В этой статье я расскажу о таком решении на одном из наших проектов. В нём нет ничего сложного, ничего особенно остроумного, но оно надежно работает, относительно просто реализуется и решает множество задач сразу. Очень надеюсь, что оно принесёт вам практическую пользу или натолкнёт на идею дальнейшего развития вашего проекта.
Суть была в следующем: наш проект С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
Внутри 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) кэш скачивания для краулера фоток реализован тем же кодом, что и пользовательская загрузка чистка сгнивших фоток производится очень быстро код удаления объявлений ничего не знает про картинки и работает крайне быстро переход на новые схемы хранения, ресайзы и прочее теперь намного проще, т.к. код не дублируется Простых вам решений!