Кеширование тяжелых запросов (на примере memcache)

В этой статье рассмотрим проблемы, которые могут возникать при кеширования тяжелых запросов. Под тяжелыми запросами следует понимать не только медленные, но и ресурсоемкие запросы (например, обращение к внешним XML источникам с последующей обработкой). Наиболее стандартные ситуации - это тяжелые SQL выборки на страницах с агрегационной информацией (популярные видео ролики, лучше фотки, самые активные пользователи и т.п.). На первый взгляд все просто - кешируем на час..два и забываем о этих запросах на долгое время. Какие проблемы могут возникнуть в ходе увеличения нагрузок?
Истечение срока хранения объекта в кеше
Когда срок хранения объекта в кеше исчерпан, система делает повторный запрос на получение данных из источника. В этот момент могут наступить проблемы. Допустим время запроса - 7 секунд. В пиковые часы работы в течении этого времени может поступить несколько таких запросов (или десятков и даже сотен!). Поскольку новое значение еще не сгенерировано и не закешировано, все эти запросы уйдут в источник (допустим, MySQL). Это значит, что Ваша СУБД будет выполнять не один тяжелый запрос, а несколько. Не следует недооценивать эту ситуацию. На моем личном опыте подобные проблемы укладывали проект connect.ua в некторые пики на 10…20 минут (а количество тяжелых запросов, проходивших мимо кеша достигало нескольких сотен).
Решение
Сама проблема очень простая, а следовательно и решение выглядит достаточно просто.
Существует определенный подход для “более умного” кеширования, чтобы избежать подобных ситуаций. В чем он заключается:
- Для выбранного запроса создается два объекта в кеше. Один имеет стандартный TTL (ttl1) (тот, который выбираете Вы на основе логики приложения). Второй имеет TTL = ttl1 + query_time*5 (query_time - максимальное время выполнения тяжелого запроса, на 5 умножаем для уверенности)
- При запросе проверяем первый кеш, и если объект есть - выдаем значение
- Если при запросе в первом кеше пусто, сразу делаем выборку второго кеша и складываем ее результат в первый. Только после этого делаем запрос к источнику (СУБД) и перезаписываем объект в первом кеше. В этом случае время, когда значение в основном кеше будет отсутствовать будет стремиться к нулю, и все последующие запросы достанут значение из кеша.
Для наглядности решения - пример на PHP:
function get_populer_video()
{
# Ключи для кеширования
$cache_key = 'popular_video';
$cache_key_backup = 'popular_video_backup';
# Время жизни основного и запасного объектов
$ttl = 60;
$ttl_backup = $ttl + 20; # учитывая, что время запроса не превышает 3...4 секунд
# Если мы не нашли данных в основном кеше, то выполняем логику внутри
if ( ( $data = my_mem_cache::get($cache_key) ) === false )
{
# Сначала устанавливаем основной кеш из запасного
my_mem_cache::set( $cache_key, my_mem_cache::get($cache_key_backup), $ttl );
# Получаем новые данные
$data = database::get_all( 'SELECT * FROM very_big_table WHERE complex_clause = 25 LIMIT 25' );
# Устанавливаем основной и запасной кеши с соотв. временем жизни
my_mem_cache::set( $cache_key, $data, $ttl );
my_mem_cache::set( $cache_key_backup, $data, $ttl_backup );
}
return $data;
}
Следует отметить…
Подобные вещи никогда не следует делать заранее. Это пример практики оптимизации, которую следует внедрять только тогда, когда вы вплотную подошли к проблеме. Отлавливать и прогнозировать тяжелые запросы можно с помощью различных утилит, например mk-query-digest для MySQL


Денис, может я что-то упускаю, но чем это кеширование лучше встроенного MySQL?
Привет.
Как бы это сказать…. сложно все это )
Есть более простое решение.
Когда делаем выборку и видим что там лежит просроченный кеш, мы продлеваем время жизни у этой записи в кеше. Однако при этом мы полагаем что из кеша вернулось false - это значит, что мы обработаем запрос по полной программе, без кеша, и в конце положим новые данные в кеш. Второй аналогичный запрос который прийдет во время выполнения нашего первого запроса, возьмет данные из кеша как буд-то они не просроченные (ведь мы у него уже продлили ttl - благо memcached атомарный).
Удобно это делать в проксирующем классе (посредник между вашим кодом и build-in функциями memcache в языке программирования)
Дэн, а ты можешь сделать версию для печати? Я бы с удовольствием просвещался в аццкой трубе московского метрополитена =)
@Лёша
Привет, встроенное в MySQL кеширование работает так: произошла вставка или обновление (удаление) - кеш сбрасывается. Т.о. на динамичных таблицах этот кеш сбрасывается постоянно, что делает его не эффективным.
@Дмитрий Горбенко
Будет здорово, если поделитесь примером!
Дмитрий, спасибо!
Интересное предложение
Насколько я понимаю, управление временем жизни Вы предлагаете перенести на обертку (т.е. не пользоваться функционалом ttl мемкеша)?
@aazon
Ок, поищу плагин
@Den Golotyuk
именно, перенести на него.
пример ? Да, его можно найти здесь: http://code.google.com/p/ads-engine/source/browse/trunk/lib/classes/class.DataCache.php
смотреть на функцию Get(), а кокнретнее, на код начинающийся с коментария
//check lifetime
@Дмитрий Горбенко
Спасибо, Очень полезно!
@Дмитрий Горбенко
А що, коли self::$Memcache->get($key) поверне false? Тобто запит об’єкту прийде після того, як об’єкта уже не буде в мемкеші, коли time() > $lifetime*2 ?
Єдине, що спадає на думку, це робити Get() з періодичністю в $lifetime =)
@aazon
Сделал, иконка принтера сверху справа (при просмотре поста)
на самом деле тяжелые запросы можно кэшировать 3 вариантами:
- крон
- при обновление данных админом/юзером + крон
- запускать паука по страницам
хранить клон-кеш не имеет смысла!
и опять таки проверку валидный/не валидный кеш можно делать через установку своих флагов при изменении данных
еще по поводу хранения клона кеша: почитайте как работает мусорщик мемкеша при заполнении памяти! поскольку клон-кеш запрашиваться не будет - есть вероятность что он может пропасть.
Спасибо!
@tarasov
Спасибо! Несколько комментариев:
1. Крон - интересная идея, нужно только учесть оверхед на управляемость (для каждого запроса нужно иметь либо свою крон задачу, либо иметь гибкий механизм запуска периодичных задач плюс необходимо будет каждый такой запрос выносить в этот самый крон). Но самое неприятное это то, что придется генерировать все возможные запросы, т.к. не понятно, какие действительно будут запрашиваться, а какие нет.
2. По поводу мусорщика. Мусорщик запускается только тогда, когда мемкешу не хватает памяти для объектов. А до этого лучше вообще не доводить - это уже показатель нехватки ресурсов.
3. Про паука не совсем понял, интересно было бы подробнее узнать
@anonymous
проще говоря, “что если ключа вообще не будет в базе - как здесь исключить пик нагрузки по идентичному запросу”.
в этом случае используется следующий подход в работе функции Get() (сразу предупрежу, эта логика не присутствует в коде на который я дал ссылку) - узнав о том что такого ключа вовсе нет, вы тут же устанавливаете его в состояние “-1″. То есть, делаете set(key, -1, 10); После установки возвращаете наверх значение false - что заставляет код создать ключ.
Иные же запросы, которые придут по время создания ключа, прочтут из кеша значение -1. При получении такого значения из кеша они должны начать ждать результатов работы первого запроса используя функцию WaitingForCache() - я добавил эту функцию в класс приведенный по ссылке выше.
Так как всегда используется класс-обертка, и так как этот класс в memcache ложит всегда массивы, то чтение из кеша значение -1 не приводит к ситуации с вопросом “а может -1 означает не текущий процесс создания ключа, а реальное значение ключа”.
Вы просите почему эта функция сейчас не используется ? ответ один - у нас ключи в кеше обновляются до того, как кеш их удалит. Да и процедуру холодного старта мы проводим грамотно - так что ситуации когда сложный ключ отсутствует - практически никогда нет. А если и получается - этим можно пренебречь.
@tarasov
фраза “на самом деле” звучит как будто иного выбора нету… “как жаль” (C)
обновлять ключи принудительно - а вам действительно интересно создавать систему, которая жестко зависит от внимания девелоперов к ключам и точности выполнения кроном задач точно в срок, и которая не может жить самостоятельно своей жизнью ?
извините, но подбирая правильные ttl для ключей и создавая правильный код, можно не использовать принудительную генерацию ключей.
@Дмитрий Горбенко
Дмитрий, обратите внимание, что в этом случае при медленных запросах, клиентам придется ждать относительно долго генерации страницы.
@Den Golotyuk
Денис, да ты прав, но чтобы избежать этого мы делаем холодный старт системе, и сервер входит встрой подготовленным к нагрузке.
Сделать холодный старт не так уж и сложно, нежели делать крон-задачи и поддерживать их. Для этого могут подойти созданные юнит-тесты.
@Дмитрий Горбенко
Старт системы не связан с прогоранием объекта в кеше. В случае постановки клиентов на ожидание генерации запроса, им придется ждать время, которое этот запрос будет обрабатываться. Зависимо от посещаемости и времени суток, это число может быть велико.
А каким образом Вы “обновляете данные в кеше до их прогорания”?
извини, не приходят уведомления на имейл и не могу сразу отписываться.
>>обновлять ключи принудительно - а вам действительно интересно создавать >>систему, которая жестко зависит от внимания девелоперов к ключам и >>точности выполнения кроном задач точно в срок, и которая не может жить >>самостоятельно своей жизнью ?
это на случай, если запуск некоторых тяжелых запросов
разберем на примере:
есть страница на которой есть тяжелый запрос. юзер заходит на страницу и если нету кэша выполняет запрос и кэширует его. все просто.
(сразу скажу, если страница одна из посещаемых и проект нагруженный - одновременных запросов будет очень много)
+ значит нужно перегенирировать запрос до того как он заекспайрится.
+ на всякий случай перусмотрим падение базы на какое-то разумное время. возьмем для примера 15 минут.
= создаем кэш с временем жизни например 10 минут + 15 минут на перегенерацию и возможное падение.
= запускаем крон каждые 10 минут, с проверкой если база упала - не писать в кэш ничего. в итоге получаем только 1 выполнение запроса + страницу, которая проживет еще какое-то время без базы.
to Дмитрий Горбенко, с чем из вышеизложеного Вы не согласны?
to Den Golotyuk, по поводу мусорщика:
довольно сложно расчитать все ключи. если проект большой запросто может произойти заполнение всего кэша.
по поводу паука:
например у вас есть около 20-50 страниц с некоторыми тяжелыми запросами на них и предположим, что запросов таких много, или они могут менятся.
создаем крон, который будет проходит по этим страницам и передавать им какой-то ключ на перегенерацию запросов раз, скажем, в 10 минут. в итоге мы получаем одного юзера который перегенерирует запросы и множество который пользуются данными из кэша.
Ден, если не раскрыл суть - напиши, а я пойду пока за кофе
@Den Golotyuk
Денис, ты не внимательно читаешь мои посты.
5 Декабрь 2009 в 21:22 я писал: “Вы просите почему эта функция сейчас не используется ? ответ один - у нас ключи в кеше обновляются до того, как кеш их удалит.”
Также, 5 Декабрь 2009 в 21:33 я дополнил эту тему: “подбирая правильные ttl для ключей и создавая правильный код, можно не использовать принудительную генерацию ключей.”
@Дмитрий Горбенко
Дмитрий, спасибо! Интересно было бы узнать такой момент:
Какой средний оверхед дополнительных данных в кеше в Вашей системе? Т.е. какой средний размер объекта, и какой средний размер мета данных (пользовательский ttl и т.п.).
@tarasov
Большое спасибо, мысли понятны! А как Вы указываете на какие страницы натравливать паука. Я правильно понимаю, что этот список постоянно изменяется?
@tarasov
не согласен только с тем, что вы на плечи кеша переклдываете часть проблем связанных с базой (вот эти самые 15-ть минут). хотя это ваше решение, если нельзя добиться стабильности базы. если часто падает база - да, кеш поможет - почему бы и нет.
вы пишите “в итоге получаем только 1 выполнение запроса” (я отбросил часть про падение базы) - ок. но в моем подходе тоже одно выполнение запроса ))
как бы я старался показать что юзание крона может быть немного лишним именно в моей ситуации.
в моем подходе вы в одном месте описываете спец. логику работы с memcache, и больше не думаете про expire ключей. максимум о чем вы думаете - так это про ttl ключа в каждом конкретном месте. НО - это при условии, которое я описал в посте от “7 Декабрь 2009 в 13:52″
давайте закругляться ) ибо ваше решение у меня работать не будет, а моё (если не будет тех особенностей, описанных в посте от “7 Декабрь 2009 в 13:52″) - тоже у вас работать не будет.
А мог бы автор глянуть на такое мое предложение? правда написано на python для Django. Но думаю алгоритм будет понятен
http://www.lyabah.com/index.php/2009/12/11/smart-cache/
@Дмитрий Горбенко
тяжело разговаривать с человеком, который не внимательно читает комментарии других пользователей и пытается на них отвечать. Да и еще упрекает в невнимательности других.
1) жизнь ключа очень важна! не нужно засорять кеш
2) перегенерация ключа до его експайра хороша тем, что если кеша не будет - не произойдет вызов перегенерации одновременно от N юзеров (при учете, что генерация не выставляет флаги и не говорит другим подождать + много хитов)
@Den Golotyuk
большинство списков будет состоять из главной + главных страниц разделов (~30% запросов попадает именно на главную страницу)
Денис. Столкнулся с такой проблемой. Мне нужно оптимизировать сайт, который не справляется с нагрузкой. При добавлении данных в memcache они, вне зависимости от заданного срока хранения, пропадают через несколько секунд. Memcache работает через tcp. Может Вы сталкивались с таким его неадекватным поведением и сможете посоветовать в каком направлении нужно смотреть.
@Alex
Судя по всему для мемкеша выделено слишком мало памяти и он вынужден выталкивать из кеша объекты по причине ее нехватки.
Посмотрите на статистику:
Убедитесь, что:
evictions имеет значение близкое к нулю. Если значение велико, то проверьте limit_maxbytes (возможно оно слишком мало).