Прості рішення. Прокачуємо картинки



Всі ми любимо прості рішення. Є думка, що ми так цінуємо релігію, тренінги з особистісного росту і піддаємося розлучень тому, що мозок з великим задоволенням приймає прості рішення замість складних, щедро нагороджуючи нас дофаміном. У цій статті я розповім про таке рішення на одному з наших проектів. У ньому немає нічого складного, нічого особливо дотепного, але вона надійно працює, відносно просто реалізується і вирішує безліч завдань відразу. Дуже сподіваюсь, що воно принесе вам практичну користь чи наштовхне на ідею подальшого розвитку вашого проекту.

Вихідні вимоги
Суть була в наступному: наш проект Сars Mail.Uk має безліч оголошень, до кожного з яких прив'язані кілька фоток. Фотки можуть завантажуватися користувачами вручну, а може автоматично завантажуються краулером з партнерських сайтів і причіпляти до оголошень. При цьому самі фотки досить великі (до 10 Мб), і їх майже завжди по кілька штук на оголошення. Самі фотки зберігаються на декількох синхронних DAV-ах, нарізати на кілька розмірів, можуть забезпечуватися 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: unsigned int 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: unsigned int 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)
  • кеш скачування для краулера фоток реалізований тим же кодом, що і користувацька завантаження
  • чистка згнилих фоток проводиться дуже швидко
  • код видалення оголошень нічого не знає про картинки і працює дуже швидко
  • перехід на нові схеми зберігання, ресайзы та інше тепер набагато простіше, т. к. код не дублюється
Простих вам рішень!

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.