Все мы любим простые решения. Есть мнение, что мы так ценим религию, тренинги по личностному росту и поддаёмся разводам потому, что
Исходные требования
Суть была в следующем: наш проект Сars Mail.Ru имеет множество объявлений, к каждому из которых привязаны несколько фоток. Фотки могут загружаться пользователями вручную, а могут автоматически скачиваться краулером с партнёрских сайтов и прицепляться к объявлениям. При этом сами фотки довольно большие (до 10 Мб), и их почти всегда по несколько штук на объявление. Сами фотки хранятся на нескольких синхронных DAV-aх, нарезаются в несколько размеров, могут снабжаться watermark-ами. Т.е. процесс обработки одной фотографии (crop-resize-split-upload) весьма затратен и требует времени и ресурсов (CPU, диски, сетка).
Почти идеальная для нас архитектура должна минимизировать использование этих ресурсов и уметь решать следующие задачи:
- уметь хранить несколько фотографий в разных размерах для одного объявления
- хранить фотку в единственном экземпляре, даже если она привязана к нескольким объявлениям
- не выполнять лишних действий при заливке фотографии, которая уже есть в базе, например, если пользователь залил фотку, потом ушел с формы, а потом снова попал на форму и снова залил ту же фотку, не выполнять crop-resize-split-upload, а использовать то, что сделано 5 минут назад
- не скачивать лишний раз фотку с одного и того же URL, если мы качали ее недавно
- не оставлять мусора на диске, если пользователь загрузил фото и ушел с сайта, так и не разместив объявление
- максимально ускорить удаление плюс добавление большого количества объявлений, избавив его от загрузки и удаления больших массивов фотографий
- сделать чистку хранилища от сгнивших фоток максимально быстрой
Если вы писали вертикальный поиск или импортируете от партнеров много сущностей с привязанными картинками, то ситуация вам несомненно знакома.
Решение в лоб
Прямое решение достаточно традиционно. При подаче объявления вручную делаем POST форму с <input type=«file» ...>
, пользователь отправляет 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
Теперь любая запись в таблице соответствует существующей, обработанной фотке, но некоторые из них не привязаны ни к одному объявлению и используются в отложенном режиме либо удаляются сборщиком мусора. Обработка фоток отдельно, связь с объявлениями — отдельно.
Для этого мы реализовали нижеописанные сценарии:
1. Юзерское добавление объявления
Сама форма добавления объявления не содержит <input type=«file»...>
и не обязана использовать POST для отправки данных. Фотки в этой форме являются просто скрытыми полями, в которые после предзаливки пользователем фотографий будут записаны соответствующие id фоток. Для заливки фоток используется отдельный ajax url, в который пользователь просто передает файл фотки, а в ответ получает image_id
:
<a href="#" data-photo-num="1" class="photo_upload">Загрузить фото 1</a>
<input type="hidden" name="photo1" value="">
<a href="#" data-photo-num="2" class="photo_upload">Загрузить фото 2</a>
<input type="hidden" name="photo2" value="">
<script type="text/javascript">
$(document).ready(function() {
$(".photo_upload").click(function() {
// открыть загрузчик картинок
// отправить файл на URL /pre-upload-photo/
// в случае успеха, в ответе должен вернуться image_id
var image_id = pre_upload_result.image_id;
var num = $(this).attr("data-photo-num");
$("input[name="photo" + num + "]").val(image_id);
return false;
});
});
</script>
Внутри 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:
<input type="hidden" name="photo1" value="0cc175b9c0f1b6a831c399e269772661">
<input type="hidden" name="photo2" value="92eb5ffee6ae2fec3ad71c777531578f">
При добавлении собственно объявления пользователь отправляет на бэкенд пары:
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-еры.
2. Краулер фотографий
Краулер партнерских фоток действует в другой последовательности, но смысл похож. Т.к. у него самое большое время занимает выкачка фотки с сайта партнёра, вместо 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 не понадобится — мы используем текущую запись, продублировав её либо обновив.
3. Удаление объявления
DELETE FROM Offers WHERE id=?
Всё. ON DELETE SET NULL сбросит offer_id у всех фотографий данного объявления, а через N дней за ними явится скрипт чистки фоток и отправит туда, куда отправляются все ненужные фотографии. Собственно, процедура удаления объявления вообще ничего не знает ни про какие фотки, и это прекрасно.
4. Чистка сгнивших фоток
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-дневное «окно» фоток, которые, возможно, понадобятся.
5. Чистка после чистки
Вы, конечно, не поверите, но у нас были баги! Да-да, те, самые, из-за которых всё работает немного не так, как задумывалось. Поэтому, чтобы быть готовыми ко всему, вооружитесь также версией чистильщика, которая чистит диск честно, начиная с файлов. Проблема нашего прошлого чистильщика в том, что он не видел файлы, которые есть на диске, но которых нет в базе. Такая ситуация может возникнуть, например, при добавлении новых размеров нарезки или изменении алгоритма шардирования. Я хотел бы предостеречь вас от некоторых ошибок при запуске его под нагрузкой.
Собственно, задача ясна — удалить с диска все файлы, записей о которых нет в таблице картинок. У нас есть шарды, которые позволяют провести это процесс внятными порциями, без необходимости загружать в память всю базу картинок или весь список файлов с диска. Поэтому просто делаем всё порциями, ограничиваясь каждый раз отдельным шардом.
Первый подход: получаем из базы список картинок, потом бежим по диску, берем каждый файл, проверяем, есть ли он в этом списке, и если нет — удаляем его. Второй: берем список файлов, бежим по нему, проверяем каждый на наличие в базе и удаляем, если его там нет.
У первого подхода есть проблема: если новые файлы будут добавлены в промежутке между выборкой из базы и получением списка файлов, чистильщик снесёт их, породив совсем плохую проблему, когда запись в базе есть, а файла на диске нет. Поэтому мы предпочли второй подход. Мы получаем листинг файлов шарда в память, бежим по нему и удаляем те из них, которых нет в базе. Если в процессе работы будут добавлены новые файлы, наш скрипт их просто не увидит. Могу покаяться, мы этот скрипт запускали несколько раз. Искренне вам этого НЕ желаю.
Небольшие дополнения к вышеприведённым алгоритмам Как вы заметили, мы используем различные атрибуты (url_hash
и body_hash
) для определения уникальности фотки. В принципе, можно их совместить в один, и затем использовать его в качестве image_id
— тогда код станет проще и надёжней. Дело в том, что у нас есть еще некоторая логика работы с фотками, которая требует оба этих хэша по отдельности, да и пришли мы к этой схеме через несколько итераций, поддерживая совместимость с предыдущим хранилищем. Поэтому я привел здесь именно вариант с отдельными хэшами. Но смысл технологии эти детали не меняют — мы отцепили логику загрузки и удаления фотографии от её связывания с проектными сущностями и ввели временной лаг между этими событиями.
При разработке советую не полениться и влепить в каждую ветку кода подробный debug, а также написать тест. Он окупится, т.к. логика привязки картинок к основным сущностям проекта является критически важной для вертикальных проектов. А ошибки в этом коде стоят дорого, т.к. можно порубать пользовательский контент, нагенерировать кучу мусора, незаметно зря жрать ресуры и т.д.
Заключение
После внедрения всего этого хозяйства на проекте мы получили следующий профит:
- предзагрузка юзерских фоток теперь имеет «кэш» (в течение N дней мы никогда дважды не crop-resize-split-upload одну и ту же фотку)
- отсутствуют временные файлы (или memcached какой-нибудь) при предзагрузке юзерских фоток (за исключением тех, которые требуются для crop-resize-split-upload)
- кэш скачивания для краулера фоток реализован тем же кодом, что и пользовательская загрузка
- чистка сгнивших фоток производится очень быстро
- код удаления объявлений ничего не знает про картинки и работает крайне быстро
- переход на новые схемы хранения, ресайзы и прочее теперь намного проще, т.к. код не дублируется
Простых вам решений!
Автор: BoogerWooger