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

в 10:31, , рубрики: mail.ru, web-разработка, Алгоритмы, Блог компании Mail.Ru Group, Веб-разработка, хранение данных

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

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

Исходные требования

Суть была в следующем: наш проект С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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js