Интеграция Zend_Cache_Frontend_Page, Nginx и Memcached или 1000 запросов в секунду
// 13 декабря, 2010 // Highload, Memcached, PHP, Zend Framework
Сколько грузится ваш движок? Я имею в виду число врмя генерации скрипта? 0,5 0,6 или может быть аж целую секунду? 🙂 Наш вот срабатывает за 0,8. После последних оптимизаций (см. предыдущие статьи) стал отрабатывать за 0,5. Это примерно 2 запроса в секунду. Хотите раскажу, как довести это число до 1000*?
Что в наличии?
1. Несколько статей по кэшированию:
http://www.opennet.ru/base/net/nginx_memcached.txt.html
http://habrahabr.ru/blogs/nginx/76315/
http://habrahabr.ru/blogs/hi/72539/
http://habrahabr.ru/blogs/hi/79876/
http://dklab.ru/chicken/nablas/56.html
2. Из ПО: Zend Framework, Nginx, Memcached, Mysql.
Что будем кэшировать?
Картинки в и всю статику в одной из прошлых заметок мы уже поместили на флешку, осталась динамика, а именно, вывод php-движка на Zend Framework и некоторых php-утилит.
HTTP-запросы могут приходить от зарегистрированных пользователей, или от гостей. Информацию зарегенных пользователей кэшировать не будем, из-за большой мороки с инвалидацией кэша.
*Значит будем будем кэшировать запросы только от гостей, и только GET-запросы.
Куда и как кэшировать?
Тут, собственно, альтернатив нет. Memcache, на данный момент самый быстрый, так что будем кэшировать в него. Сохранять данные в кэш будет ZF(PHP), а читать из него — Nginx. Благодаря этому, нам удастся вообще избежать обращения к PHP при выдаче страниц гостем, избежать файловых операций (т.к. страничка находится в памяти), что по идее должно дать нам большой припрост производительности.
Проблемы
Но не всё так просто, есть несколько проблем.
1. Для кэширования HTML-вывода будем использовать Zend_Cache_Frontend_Page. Всё в нём хорошо, за исключением того, что в кэш он кладет не чистую HTML-страничку, а JSON массив, состоящий из странички и заголовков.
$array = array( 'data' => $data, 'headers' => $storedHeaders ); $this->save($array, null, $this->_activeOptions['tags'], $this->_activeOptions['specific_lifetime'],
Nginx же при чтении просто вытаскивает элемент из кэша и выдает юзеру. А юзеру не очень то понравится, какой-то JSON. Я уж не говорю про поисковых ботов.
2. Второй компонент Zend_Cache_Backend_Memcache тоже добавляет в данные свою информацию, а именно lifetime и текущее время.
$result = @$this->_memcache->set($id, array($data, time(), $lifetime), $flag, $lifetime);
В итоге в кэш идёт многомерный JSON-массив.
3. Для составления ключа кэша Zend_Cache_Frontend_Page ипользует md5() от кучи параметров.
protected function _makeId() { $tmp = $_SERVER['REQUEST_URI']; $array = explode('?', $tmp, 2); $tmp = $array[0]; foreach (array('Get', 'Post', 'Session', 'Files', 'Cookie') as $arrayName) { $tmp2 = $this->_makePartialId($arrayName, $this->_activeOptions['cache_with_' . strtolower($arrayName) . '_variables'], $this->_activeOptions['make_id_with_' . strtolower($arrayName) . '_variables']); if ($tmp2===false) { return false; } $tmp = $tmp . $tmp2; } return md5($tmp); }
А Nginx просто не сможет собрать такой ключ. Ну на самом деле сможет, но для этого надо перекомпилировать его с поддержкой perl-модуля ngx_http_perl_module. И после этого вставить такую вот процедуру в конфиг:
http { perl_set $md5_uri 'sub { use Digest::MD5 qw(md5_base64); my $r = shift; my $uri=$r->uri; my $args=$r->args; if ($args){ $uri=$uri."?".$args; } return md5_base64($uri); } ';
Также вам понадобится модуль для Perl Digest::MD5. Скажу так, у меня не получилось перекомпилить nginx с поддержкой этого модуля. если у вас получится — пишите в комментах.
Второй вариант решения проблемы — это формировать в Zend Framework нормальные ключи кэша, которые понимает NGINX. Казалось бы, — вот оно решение берем ключ вида:
$cacheKey = ‘nginx_’.$_SERVER[‘HTTP_HOST’].$_SERVER[‘DOCUMENT_URI’].’?’.$_SERVER[‘QUERY_STRING’];
и всё готово. А нет. Ключи со слешами в Zend Framework не разрешены, срабатывает валидация в функции _validateIdOrTag() см. закомменченный кусок.
protected static function _validateIdOrTag($string) { if (!is_string($string)) { Zend_Cache::throwException('Invalid id or tag : must be a string'); } if (substr($string, 0, 9) == 'internal-') { Zend_Cache::throwException('"internal-*" ids or tags are reserved'); } /* if (!preg_match('~^[a-zA-Z0-9_]+$~D', $string)) { Zend_Cache::throwException("Invalid id or tag '$string' : must use only [a-zA-Z0-9_]"); } */ }
Придётся её вырубить, чтобы не портила всю малину.
Решение
Мы сделаем свой кэш — с преферансом и куртизанками, подумал я… и сделал! Для этого пришлось создать/изменить следующие компоненты:
1. ZendExtra_Cache_Frontend_Page extends Zend_Cache_Frontend_Page
— сделал нормальный ключ кэша, который понимает Nginx
— убрал проверку ключа кэша, чтобы проходили слеши
— переделал сохранение и чтение кэша, чтобы записывались чистые данные, без заголовков
— перенес некоторые функции, которые делали статические вызовы функции валидации
2. ZendExtra_Cache_Backend_RawMemcached extends Zend_Cache_Backend_Memcached
— сделал, чтобы бекэнд сохранял чисты данные, без lifetime и time() внутри них. Выключил сериализацию.
— сделал чтение данных из кэша без десериазизации
3. ZendExtra_Controller_Plugin_PageCache
— плагин запускает кэширование, только для гостей
4. Прописал новые компоненты в файле загрузки (Bootstrap.php), чтобы грузились мои отнаследованные компоненты, а не стандартные.
Сорцы
ZendExtra_Cache_Frontend_Page
_cancel) { return $data; } $contentType = null; $storedHeaders = array(); $headersList = headers_list(); foreach($this->_specificOptions['memorize_headers'] as $key=>$headerName) { foreach ($headersList as $headerSent) { $tmp = explode(':', $headerSent); $headerSentName = trim(array_shift($tmp)); if (strtolower($headerName) == strtolower($headerSentName)) { $headerSentValue = trim(implode(':', $tmp)); $storedHeaders[] = array($headerSentName, $headerSentValue); } } } /* $array = array( 'data' => $data, 'headers' => $storedHeaders ); */ $array = $data; $this->save($array, null, $this->_activeOptions['tags'], $this->_activeOptions['specific_lifetime'], $this->_activeOptions['priority']); return $data; } /** * Убираем лишнюю проверку из Zend_Cache_Core::_validateIdOrTag() * чтобы в качестве ключа кэша мог выступать url */ public function load($id, $doNotTestCacheValidity = false, $doNotUnserialize = false) { if (!$this->_options['caching']) { return false; } $id = $this->_id($id); // cache id may need prefix $this->_lastId = $id; self::_validateIdOrTag($id); $data = $this->_backend->load($id, $doNotTestCacheValidity); if ($data===false) { // no cache available return false; } if ((!$doNotUnserialize) && $this->_options['automatic_serialization']) { // we need to unserialize before sending the result return unserialize($data); } return $data; } /** * Здесь идёт вызов self::_validateIdOrTag, * так что функцию тоде необьходимо отнаследовать. */ public function test($id) { if (!$this->_options['caching']) { return false; } $id = $this->_id($id); // cache id may need prefix self::_validateIdOrTag($id); $this->_lastId = $id; return $this->_backend->test($id); } /** * Сохраняем html-код странички в кэш напрямую, без сериализации. */ public function save($data, $id = null, $tags = array(), $specificLifetime = false, $priority = 8) { if (!$this->_options['caching']) { return true; } if ($id === null) { $id = $this->_lastId; } else { $id = $this->_id($id); } self::_validateIdOrTag($id); self::_validateTagsArray($tags); if ($this->_options['automatic_serialization']) { // we need to serialize datas before storing them // Don't Serialize (by Tok) //$data = serialize($data); } else { if (!is_string($data)) { Zend_Cache::throwException("Datas must be string or set automatic_serialization = true"); } } // automatic cleaning if ($this->_options['automatic_cleaning_factor'] > 0) { $rand = rand(1, $this->_options['automatic_cleaning_factor']); if ($rand==1) { if ($this->_extendedBackend) { // New way if ($this->_backendCapabilities['automatic_cleaning']) { $this->clean(Zend_Cache::CLEANING_MODE_OLD); } else { $this->_log('Zend_Cache_Core::save() / automatic cleaning is not available/necessary with this backend'); } } else { // Deprecated way (will be removed in next major version) if (method_exists($this->_backend, 'isAutomaticCleaningAvailable') && ($this->_backend->isAutomaticCleaningAvailable())) { $this->clean(Zend_Cache::CLEANING_MODE_OLD); } else { $this->_log('Zend_Cache_Core::save() / automatic cleaning is not available/necessary with this backend'); } } } } if ($this->_options['ignore_user_abort']) { $abort = ignore_user_abort(true); } if (($this->_extendedBackend) && ($this->_backendCapabilities['priority'])) { $result = $this->_backend->save($data, $id, $tags, $specificLifetime, $priority); } else { $result = $this->_backend->save($data, $id, $tags, $specificLifetime); } if ($this->_options['ignore_user_abort']) { ignore_user_abort($abort); } if (!$result) { // maybe the cache is corrupted, so we remove it ! if ($this->_options['logging']) { $this->_log("Zend_Cache_Core::save() : impossible to save cache (id=$id)"); } $this->remove($id); return false; } if ($this->_options['write_control']) { $data2 = $this->_backend->load($id, true); if ($data!=$data2) { $this->_log('Zend_Cache_Core::save() / write_control : written and read data do not match'); $this->_backend->remove($id); return false; } } return true; } /** * Здесь идёт вызов self::_validateIdOrTag, * так что функцию тоде необьходимо отнаследовать. */ public function remove($id) { if (!$this->_options['caching']) { return true; } $id = $this->_id($id); // cache id may need prefix self::_validateIdOrTag($id); return $this->_backend->remove($id); } /** * Здесь идёт вызов self::_validateTagsArray() -> self::_validateIdOrTag, * так что функцию тоде необьходимо отнаследовать. */ public function clean($mode = 'all', $tags = array()) { if (!$this->_options['caching']) { return true; } if (!in_array($mode, array(Zend_Cache::CLEANING_MODE_ALL, Zend_Cache::CLEANING_MODE_OLD, Zend_Cache::CLEANING_MODE_MATCHING_TAG, Zend_Cache::CLEANING_MODE_NOT_MATCHING_TAG, Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG))) { Zend_Cache::throwException('Invalid cleaning mode'); } self::_validateTagsArray($tags); return $this->_backend->clean($mode, $tags); } /** * Убираем лишнюю проверку из Zend_Cache_Core::_validateIdOrTag() * чтобы в качестве ключа кэша мог выступать url */ protected static function _validateIdOrTag($string) { if (!is_string($string)) { Zend_Cache::throwException('Invalid id or tag : must be a string'); } if (substr($string, 0, 9) == 'internal-') { Zend_Cache::throwException('"internal-*" ids or tags are reserved'); } /* if (!preg_match('~^[a-zA-Z0-9_]+$~D', $string)) { Zend_Cache::throwException("Invalid id or tag '$string' : must use only [a-zA-Z0-9_]"); } */ } /** * Здесь идёт вызов self::_validateIdOrTag, * так что функцию тоде необьходимо отнаследовать. */ protected static function _validateTagsArray($tags) { if (!is_array($tags)) { Zend_Cache::throwException('Invalid tags array : must be an array'); } foreach($tags as $tag) { self::_validateIdOrTag($tag); } reset($tags); } }
ZendExtra_Cache_Backend_RawMemcached
_memcache->get($id); /* if (is_array($tmp) && isset($tmp[0])) { return $tmp[0]; }*/ if($tmp) return $tmp; return false; } /** * Проверка существования сырых данных */ public function test($id) { $tmp = $this->_memcache->get($id); /* if (is_array($tmp)) { return $tmp[1]; } */ if($tmp) return $tmp; return false; } /** * Сохранение сырых данных без сериализации */ public function save($data, $id, $tags = array(), $specificLifetime = false) { $lifetime = $this->getLifetime($specificLifetime); if ($this->_options['compression']) { $flag = MEMCACHE_COMPRESSED; } else { $flag = 0; } // ZF-8856: using set because add needs a second request if item already exists //$result = @$this->_memcache->set($id, array((string)$data)); $result = @$this->_memcache->set($id, $data, $flag, $lifetime); if (count($tags) > 0) { $this->_log(self::TAGS_UNSUPPORTED_BY_SAVE_OF_MEMCACHED_BACKEND); } return $result; } /** * Т.к. lifetime теперь не хранится в data, то нельзя его узнать при get-запросе * к memcached, как следствие, touch() работать не сможет. */ public function touch($id, $extraLifetime) { /* if ($this->_options['compression']) { $flag = MEMCACHE_COMPRESSED; } else { $flag = 0; } $tmp = $this->_memcache->get($id); if (is_array($tmp)) { $data = $tmp[0]; $mtime = $tmp[1]; if (!isset($tmp[2])) { // because this record is only with 1.7 release // if old cache records are still there... return false; } $lifetime = $tmp[2]; $newLifetime = $lifetime - (time() - $mtime) + $extraLifetime; if ($newLifetime <=0) { return false; } // #ZF-5702 : we try replace() first becase set() seems to be slower if (!($result = $this->_memcache->replace($id, array($data, time(), $newLifetime), $flag, $newLifetime))) { $result = $this->_memcache->set($id, array($data, time(), $newLifetime), $flag, $newLifetime); } return $result; } */ return false; } } ?>
ZendExtra_Controller_Plugin_PageCache
_cacheFrontend = $frontend; } /** * Запуск страничного кэша. * Метод, срабатывающий при завершении маршрутизации, когда маршрут найден. * @param Zend_Controller_Request_Abstract $request */ public function routeShutdown(Zend_Controller_Request_Abstract $request) { $options = Zend_Registry::get('appconfig'); $cacheManagerOptions = $options['resources']['cachemanager']['options']; // Только если нет сессии if($cacheManagerOptions['enablePageCache'] == true && !Zend_Session::sessionExists()) $this->_cacheFrontend->start(); } }
Bootstrap.php
... protected function _initCache() { ... $pagesCache = Zend_Cache::factory( new ZendExtra_Cache_Frontend_Page, new ZendExtra_Cache_Backend_RawMemcached, $pagesCacheOptions, $memcachedBackendOptions ); ... } ...
Конфиг Nginx
# Запросы утилиты (кэшируются) location /utils/someUtil.php { set $memcached_key 'nginx_$host$uri?$args'; memcached_pass 127.0.0.1:11211; error_page 404 502 504 405 = @phpscripts; } # Запросы непосредственно .php-файлов, например утилит или index.php (не кэшируются) location ~ \.php$ { include /etc/nginx/fastcgi_params; fastcgi_param SCRIPT_FILENAME $root_path/$fastcgi_script_name; fastcgi_param DOCUMENT_ROOT $root_path; # этот параметр нужен несмотря на root в секции server fastcgi_pass php-fpm; } # Копия предыдущего для internal переадресации location @phpscripts { include /etc/nginx/fastcgi_params; fastcgi_param SCRIPT_FILENAME $root_path/$fastcgi_script_name; fastcgi_param DOCUMENT_ROOT $root_path; # этот параметр нужен несмотря на root в секции server fastcgi_pass php-fpm; } # Остальные запросы также идут на PHP-FPM, если $uri не существует (через memcache) location / { add_header Content-Type "text/html"; root $root_path; # Проверки для кэширования set $cachable 1; # не кэшировать пост if ($request_method = POST ) { set $cachable 0; break; } # авторизованные if ($http_cookie ~ "PHPSESSID") { set $cachable 0; break; } if ($cachable = 1) { set $memcached_key 'nginx_$host$uri?$args'; memcached_pass 127.0.0.1:11211; } error_page 404 502 504 405 = @php; } # Веб-приложение location @php { include /etc/nginx/fastcgi_params; fastcgi_param SCRIPT_FILENAME $root_path/index.php; fastcgi_pass php-fpm; } }
Тестирование и бенчмарки
Тестирование проводилось на горчячем кэше (страница уже в памяти). Тестовый стенд: Sony VAIO VGN-SR11MR Core2Duo 2,26 4Gb Ram.
andrey@vaio:~$ ab -c 10 -n 100 http://site.com/ru/?a=1 Server Software: nginx/0.7.65 Server Hostname: site.com Server Port: 80 Document Path: /ru/?a=1 Document Length: 227140 bytes Concurrency Level: 10 Time taken for tests: 0.099 seconds Complete requests: 100 Failed requests: 0 Write errors: 0 Total transferred: 22731200 bytes HTML transferred: 22714000 bytes Requests per second: 1006.24 [#/sec] (mean) Time per request: 9.938 [ms] (mean) Time per request: 0.994 [ms] (mean, across all concurrent requests) Transfer rate: 223369.26 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.2 0 1 Processing: 3 9 2.8 9 18 Waiting: 1 7 2.3 8 13 Total: 3 10 2.7 10 18 Percentage of the requests served within a certain time (ms) 50% 10 66% 10 75% 11 80% 11 90% 12 95% 16 98% 18 99% 18 100% 18 (longest request) andrey@vaio:~$
Результаты
Время обработки запроса в среднем: 10 мс., или 1006 запросов в секунду. Думаю это замечательный результат! 🙂 Оптимизация прошла успешно!
Исходники
Для тех кто хочет пощупать у себя этот метод, выкладываю исходники классов, упомянутых в этой статье. nginx_memcached_zf
UPD. При формировании ключа и наличии русских значений переменных, кэширование на работает. Необходимо формировать ключ с учетом кодировки строки запроса:
ZendExtra_Cache_Frontend_Page.php
/** * Создание ключа кэша аналогичного ключу nginx'а */ protected function _makeId() { /** * Если нет - формируем ключ как nginx * set $memcached_key 'nginx_$host$uri?$args'; */ $query_string = urlencode($_SERVER['QUERY_STRING']); $query_string = str_replace("%3D", "=", $query_string); // раскодируем обратно символ "=" $query_string = str_replace("%26", "&", $query_string); // раскодируем обратно символ "&" $query_string = str_replace("%2C", ",", $query_string); // раскодируем обратно символ "," $cacheKey = 'nginx_'.$_SERVER['HTTP_HOST'].$_SERVER['DOCUMENT_URI'].'?'.$query_string; return $cacheKey; }
UPD2 Также по умолчанию не ставится lifetime. Для этого надо добавить фукнцию:
ZendExta_Cache_Frontend_Page
/** * Устанавливает время жизни кэша */ public function setLifetime($lifetime) { $this->_specificOptions['default_options']['specific_lifetime'] = $lifetime; }
и в бутстрапе делать присовение lifetime:
$pagesCache = Zend_Cache::factory( new ZendExtra_Cache_Frontend_Page, new ZendExtra_Cache_Backend_RawMemcached, $pagesCacheOptions, $memcachedBackendOptions ); $pagesCache->setLifetime($configPageCacheOptions['lifetime']);
Архив не правил.
Спасибо!
Если вам помогла статья, или вы хотите поддержать мои исследования и блог - вот лучший способ сделать это:
Отличное решение, оценил 🙂
Подскажите, а как же быть с динамическими частями страницы?
Например, баннеры, либо для авторизованных пользователей другой блок вместо формы авторизации?
Можно ли как-то настроить nginx, чтобы он опирался на сессии?
Шпасибо)
Да, можно. На хабре как-то пробегала статья по поводу блочного кэширования с nginx+ssi. Вот ссылка: http://habrahabr.ru/blogs/hi/109050/
Сотня тысяч хитов в сутки, при пользовательской активности на протяжении 8 бизнес-часов — это 3-4 запроса в секунду. Вы действительно этим гордитесь?
Речь идёт о времени отдачи страницы. И да, я этим горжусь в тех условиях (оборудование, канал и т.д.), что у меня на тот момент были. А в чём собственно проблема? Вот, например данные для Яндекса.
Мои показатели лучше, чем у Яндекса (у него 91мс, у меня 10мс, у него 107 запросов в секунду, у меня 1006). Таки да, есть чем гордиться.