Легенда про срібної кулі або як я шукав логічні помилки кешування і отримання даних

Відмова від претензій: не використовуйте наведені нижче патчі на продакшені; користуйтеся спеціально підготовленими тестовими майданчиками.

* * *
Коли я був маленький, то мріяв стати чарівником. Коли трохи підріс, вибрав найбільш близьку до чаклунства професію — став програмістом. У процесі розробки будь-яких веб-додатків різного ступеня складності виявилося, що тішаться не тільки цікавість і любов до експериментів, але й просте людське марнославство.

Ох вже це солодке відчуття, коли ти приходиш весь у білому, ворушиш пальцями над клавіатурою тиждень або дві (можливо, годину або день) — і вирішуєш якусь проблему, з якою багато людей довго і безуспішно боролися.

Вступна
Історія почалася з того, що мене попросили провести докладний аудит досить популярного інтернет-магазину. Усі додаток крутилося на двох балансировщиках, кількох бэкэндах і двох серверах БД. Навантаження — 1-4 тисячі запитів в хвилину. Стек близький до класичного: PHP(-fpm), Mysql, Memcached, Sphinx, Nginx. Іноді обставини складалися так, що вся система вставала колом, при цьому прямої кореляції з навантаженням не було. І навіть з викладенням нового коду (і відповідними перезапуску демонів) — не завжди.

Приємно, що, схоже, розробники читали різні корисні статті, коли створювали це все. Потім, правда, потихеньку люди змінювалися, і поточна команда в основному пиляла новий функціонал, а періодичними падіннями займалася за залишковим принципом (тобто поклали болт: «швидко підняте не вважається впало»).

Ретроспектива (забігаючи вперед)
Коли проект вже завершився, мені самому здається дивним, що я так довго порпався: проблемний метод миготів у перших звітах New Relic, а помітити запит до зовнішнього сховища даних всередині циклу можна було відразу.

У підсумку мені знадобилося кілька десятків годин на читання коду і експерименти, щоб «відчути» проект і виловити нарешті проблему.

Переписати, налагодити і підготувати патч — півдня, може, трохи більше. Сподіваюся, коли-небудь я зможу з гордістю сказати щось на кшталт «ніхто б не зміг знайти цей косяк швидше». Але поки пишу нотатки, щоб дозволити колегам набивати менше шишок:)

Дослідження
Основний корінь проблем (насправді, немає) я знайшов досить швидко. З моменту запуску магазину таблиця товарів розрослася до більш ніж 10ГБ, і крутилася з певними труднощами. Особливо коли на ній оновлювалися індекси. З табличкою замовлень було щось подібне, але до неї не було масових звернень. При цьому бэкофис сайту на Magento виключав хоч який-небудь шардінг. Та частина, на яку припадала основна навантаження, написана на Yii, і в ній мені і потрібно було щось розкопати з допомогою New Relic і нецензурних виразів.

Першим ділом, звичайно, я пробігся по ТОП-20 найбільш «времяемких» контролерів. Перевірив, як там з кешуванням (майже скрізь все було), перевірив, що кеші працюють. Заодно пройшовся по коду автоматичним аналізатором, знайшов кілька помилок на кшталт «в PHP так не можна», та й тільки.

Поки розробники доробляли кешування в тих місцях, де його не вистачало, я продовжував копирсатися в додатку. Намагався накидати балансувальник SQL-запитів для Magento, витратив купу часу, занепав духом, кинув.

У якийсь момент прийшло в голову здорове рішення. Якщо кешування скрізь є, «гарячий старт» взагалі не вимагає запитів до бази, то, може бути, кешей занадто багато? Ключі різні, дані однакові? Побічно на цю думку мене навела статистика — для одного запиту веб-сторінки триста memcached-get — забагато, явно є простір для оптимізації.

Провести дослідження по використанню мемкешей виявилося не так вже й складно. Головне — не робіть так на продакшені. Все зламати — пара дрібниць. Будемо патчити ядро Yii.

Експеримент
У класі memcached потрібно додати змінну, припустимо, $debagger. Якщо виклик йде через Singleton або що-небудь подібне — необов'язково навіть оголошувати змінну статичною.

Потім у методі get() додаємо статистику.

public function get($id)
{
if (isset($this->debugger['ids_count'][$id])) {
$this->debugger['ids_count'][$id]++;
if (
$this->debugger['ids_count'][$id] > 10
&& !isset($this->debugger['much'][$id])
) {
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
$this->debugger['much'][$id] = $this->debugger['ids_count'][$id];
$this->debugger['much'][$id] = $backtrace;
}
} else {
$this->debugger['ids_count'][$id] = 0;
}
if (isset($this->debugger['all'])) {
$this->debugger['all']++;
} else {
$this->debugger['all'] = 0;
}
if(($value=$this->getValue($this->generateUniqueKey($id)))!==false)
{
$data=$this->autoSerialize ? $this->unserializeValue($value) : $value;
if(!$this->autoSerialize || (is_array($data) && (!($data[1] instanceof ICacheDependency) || !$data[1]->getHasChanged ())))
{
Yii::trace('Serving "'.$id.'" from cache','system.caching.'.get_class($this));
if (isset($this->debugger['success'][$id])) {
$this->debugger['success'][$id]++;
} else {
$this->debugger['success'][$id] = 0;
}
return $this->autoSerialize ? $data[0] : $data;
}
}
if (isset($this->debugger['fail'][$id])) {
$this->debugger['fail'][$id]++;
} else {
$this->debugger['fail'][$id] = 0;
}
return false;
}


Ми вважаємо загальна кількість унікальних ключів (ids_count). Якщо ключ не унікальний — рахуємо, скільки разів він викликається. Потім фіксуємо попадання і промахи. І, нарешті, для найбільш популярних ключів фіксуємо трейс, щоб знайти кінці.

Де-небудь в кінці сторінки всю цю змінну можна вивести. Аккуратисты, звичайно, покладуть це все в логфайл і будуть дивитися туди.

Я пройшовся по основним сторінок, зібрав логи і поліз дивитися. Дивно — але знайшлося кілька методів, які по 20 разів запитували одні і ті ж дані. Переробив — та кількість запитів впало приблизно в півтора-два рази, що, правда, не сильно позначилася на продуктивності (закономірно).

Не прокатали
Копаємо більше, дальше і глибше… Я почав підозрювати (насправді, були скарги на падіння), що є проблеми з використанням Sphinx. Додаток спілкувалося і з ним, і з Mysql через HandlerSocket, тому New Relic їх у своїй статистиці не поділяв — цим довелося займатися самому.

Застосувавши вищевикладений спосіб збору статистики, я побачив фантастичні 600 запитів до Sphinx на головній сторінці при «холодному старті», і жодного — при «гарячому». Правда, всі запити різні. Записав в логи сам запит і отримав щось типу:
select id1, id2 from table where cat_id IN (N); N -- -- ціле число.


Тут я почав щось підозрювати. Заглянув в код. І, звичайно, побачив правильний кеш (правильно), в разі відсутності якого виконувався foreach, в якому робився запит до Sphinx (неправильно).

Сфінкс швидкий і класний. Але він не може (і не повинен) працювати з такою ж інтенсивністю, як мемкеш. У нього інший принцип роботи і, прямо скажемо, інші завдання. Не треба так.

Переписав весь метод на використання одного великого запиту замість пачки маленьких. Запит став виглядати ось так:
select id1, id2 from table where cat_id IN (X1, X2, ... XN);

Перевірив, що все працює. Із завмиранням серця дочекався викладки.

Прокатали
Загальний приріст продуктивності, звичайно, вийшов не кратним. Може, 10%, може, трохи більше. Але наскільки легше стали даватися скиди Memcache! 600 простих і швидких запитів перетворилися в один складний і довгий. Але він все одно робився в два рази швидше! Зате сервер почав дихати на повні груди і перестав падати при кожній викладки.

Ось на цих графіках вертикальними лініями відзначена викладка коду на сервери (зазначу, що за нашою порадою код стали викладати не одночасно, а з невеликим інтервалом). Рваний графік запитів відразу перетворився на плавну лінію. Мінімальний час відпрацювання сторінки зросла, але зате екстремуми зникли зовсім. І, головне, число запитів стало стабільно низьким.

image

image

Учасники подій назнімали ще красивих картинок в New Relic, намалювали оптимістичний звіт і з задоволенням пропили солідний гонорар.

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

0 коментарів

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