Интеграция Zend_Cache_Frontend_Page, Nginx и Memcached или 1000 запросов в секунду
Сколько грузится ваш движок? Я имею в виду число врмя генерации скрипта? 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 массив, состоящий из странички и заголовков.
1 2 3 4 5 |
$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 и текущее время.
1 |
$result = @$this->_memcache->set($id, array($data, time(), $lifetime), $flag, $lifetime); |
В итоге в кэш идёт многомерный JSON-массив.
3. Для составления ключа кэша Zend_Cache_Frontend_Page ипользует md5() от кучи параметров.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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. И после этого вставить такую вот процедуру в конфиг:
1 2 3 4 5 6 7 8 9 10 11 12 |
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() см. закомменченный кусок.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
_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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
_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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
_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
1 2 3 4 5 6 7 8 9 10 11 12 13 |
... protected function _initCache() { ... $pagesCache = Zend_Cache::factory( new ZendExtra_Cache_Frontend_Page, new ZendExtra_Cache_Backend_RawMemcached, $pagesCacheOptions, $memcachedBackendOptions ); ... } ... |
Конфиг Nginx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
# Запросы утилиты (кэшируются) 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
/** * Создание ключа кэша аналогичного ключу 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
1 2 3 4 5 6 7 |
/** * Устанавливает время жизни кэша */ public function setLifetime($lifetime) { $this->_specificOptions['default_options']['specific_lifetime'] = $lifetime; } |
и в бутстрапе делать присовение lifetime:
1 2 3 4 5 6 7 |
$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). Таки да, есть чем гордиться.