Интеграция Zend_Cache_Frontend_Page, Nginx и Memcached или 1000 запросов в секунду

// Декабрь 13th, 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://highload.com.ua/index.php/2010/04/06/nginx-memcached-ssi-кеширование-страниц-и-блоков-partials/

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']);

Архив не правил.

Share

Спасибо!


Если вам помогла статья, или вы хотите поддержать мои исследования и блог - вот лучший способ сделать это:


4 Responses to “Интеграция Zend_Cache_Frontend_Page, Nginx и Memcached или 1000 запросов в секунду”

  1. Отличное решение, оценил :)

    Подскажите, а как же быть с динамическими частями страницы?
    Например, баннеры, либо для авторизованных пользователей другой блок вместо формы авторизации?

    Можно ли как-то настроить nginx, чтобы он опирался на сессии?

  2. cl-service.com:

    Сотня тысяч хитов в сутки, при пользовательской активности на протяжении 8 бизнес-часов — это 3-4 запроса в секунду. Вы действительно этим гордитесь?

    • google.com Андрей Токарчук:

      Речь идёт о времени отдачи страницы. И да, я этим горжусь в тех условиях (оборудование, канал и т.д.), что у меня на тот момент были. А в чём собственно проблема? Вот, например данные для Яндекса.

      andrey@u330:~$ ab -c 10 -n 100 http://ya.ru/
      This is ApacheBench, Version 2.3 < $Revision: 1604373 $>
      Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
      Licensed to The Apache Software Foundation, http://www.apache.org/
      
      Benchmarking ya.ru (be patient).....done
      
      
      Server Software:        nginx
      Server Hostname:        ya.ru
      Server Port:            80
      
      Document Path:          /
      Document Length:        9668 bytes
      
      Concurrency Level:      10
      Time taken for tests:   0.932 seconds
      Complete requests:      100
      Failed requests:        94
         (Connect: 0, Receive: 0, Length: 94, Exceptions: 0)
      Total transferred:      1025775 bytes
      HTML transferred:       967884 bytes
      Requests per second:    107.28 [#/sec] (mean)
      Time per request:       93.216 [ms] (mean)
      Time per request:       9.322 [ms] (mean, across all concurrent requests)
      Transfer rate:          1074.63 [Kbytes/sec] received
      
      Connection Times (ms)
                    min  mean[+/-sd] median   max
      Connect:        7   27   8.5     27      47
      Processing:    35   65  13.0     66      93
      Waiting:       29   62  14.6     64      93
      Total:         60   92  15.4     91     136
      
      Percentage of the requests served within a certain time (ms)
        50%     91
        66%     98
        75%    102
        80%    104
        90%    110
        95%    119
        98%    131
        99%    136
       100%    136 (longest request)
      

      Мои показатели лучше, чем у Яндекса (у него 91мс, у меня 10мс, у него 107 запросов в секунду, у меня 1006). Таки да, есть чем гордиться.

Комментировать