Pull to refresh
VK
Building the Internet

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

Reading time9 min
Views22K


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

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


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


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

Простых вам решений!
Tags:
Hubs:
Total votes 33: ↑28 and ↓5+23
Comments25

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен