Продолжаем ускорять блог на WordPress - PHP7, ESI

В своей предыдущей статье по оптимизации сайта на WordPress я рассказал об очень эффективном подходе к оптимизации за счёт кэширования страниц. В результате чего для незалогиненных пользователей время ожидания страницы клиентом (исключая время на установление TLS-сессии) сократилось с 820 мс до 30 мс (этот и все последующие замеры проводились с сервера, расположенного в том же городе, что и мой VDS), что, согласитесь, является отличным показателем. Однако, для залогиненных пользователей генерация страницы происходила по-прежнему долго — в среднем 770 мс на сервере. В этой части я расскажу о том, как я сократил это время до 65 мс, при этом полностью сохранив работоспособность пользовательского функционала.

Целью этой и предыдущей статей является моё желание показать возможность оптимизации сайтов не только на WordPress, а вообще любого веб-приложения. Поэтому я использую такое количество инструментов, и так детально разбираю их конфигурацию. Если же Вам просто нужно ускорить WordPress — установите плагин WP Super Cache. Если Вас, как и меня, интересуют технологии, позволяющие оптимизировать любой сайт, а также Вам интересно, что стоит учитывать при разработке веб-приложений, рассчитанных на высокие нагрузки — прошу под кат, но только после прочтения первой части — дорабатывать я буду ту же систему.

Отключаем лишнее

Рекомендации в данном разделе многим могут показаться банальными, но обещаю вам, чем дальше мы будем продвигаться, тем интереснее будут пункты. Данный пункт привёл к значительному ускорению работы сайта, и я считаю, что это должно быть здесь.

Итак, среднее время генерации динамической страницы для залогиненного пользователя — 770 мс. Время генерации измерялось путём добавления в исходник index.php строчек:

<?php
    $t = microtime(1);
    //содержимое index.php
    echo '<!--Сгенерировано за: ' . (microtime(1) - $t) . 's' . '-->';
?>

Далее я провёл предварительный стресс-тест: 50 пользователей одновременно обращаются к динамически генерируемой странице, как только загрузка страницы завершена — тут же идёт следующий запрос.

Максимальное время ответа в данном случае составило 45.721 секунды.

Следуем распространённому совету, в кругах оптимизаторов WordPress, а именно — отключаем ненужные плагины. В моём блоге практически в каждой статье приводятся листинги кода/конфигурации. Для этих целей я использовал плагин Crayon Syntax Highlighter, рекомендуемый чуть ли не каждым блогером как лучшее средство для подсветки синтаксиса в листингах. Однако, сама подсветка мне была не столь уж и нужна, главное — аккуратно оформленное окошечко со скролл-баром с поддержкой табуляции. В целях тестирования попробовал отключить этот плагин. Результат — вместо 770 мс генерация страницы происходила в среднем за 170 мс, что в 4.5 раза быстрее. Однако, листинги теперь не были оформлены — текст статей сливался с кодом. Далее в поисках замены я наткнулся на плагин wp-syntax, который имел не такой богатый функционал как Crayon Syntax Highlighter, но со своей главной задачей — подсветкой синтаксиса, справлялся на ура. Среднее время генерации страницы с этим плагином составило в среднем 235 мс, что, в общем-то, приемлемо. Здесь я повторно провёл стресс-тест. Результаты следующие:

Максимальное время ответа теперь составило 9.897 секунды. Разница колоссальная.

Однако, как я уже говорил выше, подсветка для меня была не так уж и важна. Поэтому я решил удалить и этот плагин тоже, а вместо этого в стилях темы для тега <pre> (в него заключались листинги кода в вышеописанных плагинах) прописать следующие стили:

pre {
    max-height: 700px;
    margin-bottom: 20px;
    overflow: auto;
    background: #f0f0f0;
    padding: 10px;
    -webkit-box-shadow: 0px 0px 3px 0px rgba(0,0,0,0.75);
    -moz-box-shadow: 0px 0px 3px 0px rgba(0,0,0,0.75);
    box-shadow: 0px 0px 3px 0px rgba(0,0,0,0.75);
}

Листинги приняли следующий вид:

Этого мне было достаточно. Время генерации страницы на сервере — 170 мс. Проводим стресс-тест:

Максимальное время ответа теперь 7.002 секунды. Разница ощутима, оставляю этот вариант.

Далее я убрал из футера 2 виджета — последние комментарии и облако тегов. За счёт уменьшения количества запросов к БД среднее время генерации страницы на сервере сократилось до 155 мс. Результаты стресс-теста потерял, но максимальное время ответа составляло примерно 6.5 секунды.

Выбор движка таблиц в MariaDB

В комментариях к предыдущей статье мне указали на то, что использование MyISAM в качестве движка таблиц в СУБД MariaDB необоснованно. Согласен, исправляюсь. Выбирать буду между MyISAM, Aria, XtraDB.

Конфигурация my.cnf в случае с MyISAM и Aria будет одинаковой, и не отличается от конфигурации, приведённой в первой статье. Однако, для Aria следует использовать директиву default-storage-engine=Aria.

Для XtraDB следует задать следующие директивы (привожу только то, что изменил по сравнению с конфигурацией в предыдущей статье):

key_buffer = 32M #Уменьшаем буфер для ключей таблиц MyISAM и Aria
#skip-innodb #Включаем обратно движок XtraDB 
#default-storage-engine=MyISAM #По-умолчанию теперь будет XtraDB
innodb_buffer_pool_size = 128M #Размер буфера для кэширования данных и индексов XtraDB
innodb_flush_method = "O_DIRECT" #Выбираем более надёжный метод сброса данных из памяти на диск

В качестве критерия выбора я сначала хотел использовать время генерации страниц, однако все типы движков при простом запросе страницы показывали одни и те же результаты, поэтому было решено произвести стресс-тесты с нагрузкой в 100 пользователей. Мой тестовый аккаунт не позволял запускать стресс-тесты с количеством пользователей более 50, однако, как было выяснено опытным путём, было возможно запускать несколько тестов из разных вкладок браузера одновременно, чем я и воспользовался. Проверяем MyISAM. Пользователей: 100 (в другой вкладке чуть раньше запущен такой же тест):

Максимальное время ответа: 17.473 секунды.

Проверяем Aria. Пользователей: 100 (в другой вкладке чуть раньше запущен такой же тест):

Максимальное время ответа: 14.223 секунды. При этом более плавное изменение времени ответа.

Проверяем XtraDB. Пользователей: 100 (в другой вкладке чуть раньше запущен такой же тест):

Максимальное время ответа: 14.355 секунды.

Так как результаты на XtraDB и на Aria оказались почти одинаковыми, оставив при этом позади MyISAM, я решил пойти дальше и увеличивать число запросов, пока сайт на ляжет. На каком движке выдержит большую нагрузку — тот и выберу.

Снова тестируем Aria. Пользователей: 150 (в других двух вкладках чуть раньше запущены такие же тесты):

Упало на 148 пользователях. Время ответа: 19.311 секунды.

Тестируем XtraDB. Пользователей: 150 (в других двух вкладках чуть раньше запущены такие же тесты):

Упало на 150 пользователях. Время ответа: 20.963 секунды. В целом, результаты на Aria и XtraDB почти одинаковые. Но я решил выбрать XtraDB, потому что в дальнейшем хочу реализовать запрос данных для страниц со статьями с помощью NoSQL-решения HandlerSocket, работающее только с InnoDB/XtraDB. В данный момент существует расширение, компилируемое для PHP (php-handlersocket) и библиотека, написанная на PHP (HSPHP). Расширение я успел попробовать совместно с PHP5.6. С его помощью я сделал выборку из БД по PRIMARY KEY примерно в 2.5 раза быстрее, чем с помощью SQL-запроса. Но под PHP7 оно компилироваться отказывается (а следующим шагом у нас будет именно переход на новую версию). Что касается библиотеки HSPHP — замерив время получения выборки с её помощью и с помощью SQL-запроса (с учётом времени на подключение библиотеки), я выяснил, что при одиночном запросе в БД прироста она не даёт. Поэтому от её использования я отказался и теперь жду версии расширения, совместимой с PHP7.

Установка и настройка PHP7 + php-fpm

3 марта состоялся релиз новой версии PHP7.0.4. Её и будем ставить. Для начала удалим предыдущую версию PHP и php-fpm:

apt-get purge php5 php5-fpm

Скачиваем и распаковываем архив с исходниками:

mkdir /usr/local/src/php7-build
cd /usr/local/src/php7-build
curl -O http://se1.php.net/get/php-7.0.4.tar.bz2/from/this/mirror
tar jxf php-7.0.4.tar.bz2
cd php-7.0.4/

Устанавливаем всё необходимое для компиляции:

apt-get install build-essential libfcgi-dev libfcgi0ldbl libjpeg62-turbo-dbg libmcrypt-dev libssl-dev libc-client2007e libc-client2007e-dev libxml2-dev libbz2-dev libcurl4-openssl-dev libjpeg-dev libpng12-dev libfreetype6-dev libkrb5-dev libpq-dev libxml2-dev libxslt1-dev

Создаём симлинк, иначе не сможем скомпилить php с поддержкой imap.

ln -s /usr/lib/libc-client.a /usr/lib/x86_64-linux-gnu/libc-client.a

Создаём папку для установки:

mkdir /opt/php-7.0.4

Конфигурируем:

./configure --prefix=/opt/php-7.0.4 --with-pdo-pgsql --with-zlib-dir --with-freetype-dir --enable-mbstring --with-libxml-dir=/usr --enable-soap --enable-calendar --with-curl --with-mcrypt --with-zlib --with-gd --with-pgsql --disable-rpath --enable-inline-optimization --with-bz2 --with-zlib --enable-sockets --enable-sysvsem --enable-sysvshm --enable-pcntl --enable-mbregex --enable-exif --enable-bcmath --with-mhash --enable-zip --with-pcre-regex --with-pdo-mysql --with-mysqli --with-mysql-sock=/var/run/mysqld/mysqld.sock --with-jpeg-dir=/usr --with-png-dir=/usr --enable-gd-native-ttf --with-openssl --with-fpm-user=www-data --with-fpm-group=www-data --with-libdir=/lib/x86_64-linux-gnu --enable-ftp --with-imap --with-imap-ssl --with-kerberos --with-gettext --with-xmlrpc --with-xsl --enable-opcache --enable-fpm

Компилируем и устанавливаем (если у Вас VDS с теми же характеристиками, как у меня, можете смело идти пить кофе):

make
make install

Копируем конфиги:

cp /usr/local/src/php7-build/php-7.0.4/php.ini-production /opt/php-7.0.4/lib/php.ini
cp /opt/php-7.0.4/etc/php-fpm.conf.default /opt/php-7.0.4/etc/php-fpm.conf
cp /opt/php-7.0.4/etc/php-fpm.d/www.conf.default /opt/php-7.0.4/etc/php-fpm.d/www.conf

В файле /opt/php-7.0.4/etc/php-fpm.conf раскомментируем следующие строки:

pid = run/php-fpm.pid
events.mechanism = epoll

В файле /opt/php-7.0.4/etc/php-fpm.d/www.conf задаются те же настройки для воркеров, что и в первой статье. Директиву listen = 127.0.0.1:9000 Заменяем следующими строками:

listen = /var/run/php7.0.4-fpm.sock
listen.owner = www-data
listen.group = www-data

Соответственно, в настройках nginx’а в файле /etc/nginx/conf.d/backend.conf стоит указать директиву:

fastcgi_pass unix:/var/run/php7.0.4-fpm.sock;

При этом необходимо увеличить максимальное количество разрешенных подключений к Unix-сокету в системе, добавив в конец файла /etc/sysctl.conf директиву:

net.core.somaxconn = 65535

После этого необходимо перечитать конфиг:

sysctl -p /etc/sysctl.conf

В файле /opt/php-7.0.4/lib/php.ini задаём те же настройки, как и в первой статье. Для включения OPcache помимо этого добавляем строку:

zend_extension=opcache.so

Создаём симлинки для PHP и утилит:

ln -s /opt/php-7.0.4/bin/pear /bin/pear
ln -s /opt/php-7.0.4/bin/peardev /bin/peardev
ln -s /opt/php-7.0.4/bin/pecl /bin/pecl
ln -s /opt/php-7.0.4/bin/phar /bin/phar
ln -s /opt/php-7.0.4/bin/phar.phar /bin/phar.phar
ln -s /opt/php-7.0.4/bin/php /bin/php
ln -s /opt/php-7.0.4/bin/php-cgi /bin/php-cgi
ln -s /opt/php-7.0.4/bin/php-config /bin/php-config
ln -s /opt/php-7.0.4/bin/phpdbg /bin/phpdbg
ln -s /opt/php-7.0.4/bin/phpize /bin/phpize

Теперь создаем файл юнита systemd /etc/systemd/system/php-7.0.4-fpm.service:

[Unit]
Description=The PHP 7 FastCGI Process Manager
After=network.target

[Service]
Type=simple
PIDFile=/opt/php-7.0.4/var/run/php-fpm.pid
ExecStart=/opt/php-7.0.4/sbin/php-fpm --nodaemonize --fpm-config /opt/php-7.0.4/etc/php-fpm.conf
ExecReload=/bin/kill -USR2 $MAINPID

[Install]
WantedBy=multi-user.target

Активируем сервис и перезапускаем systemd:

systemctl enable php-7.0.4-fpm.service
systemctl daemon-reload

Осталось только запустить php-fpm:

systemctl start php-7.0.4-fpm.service

Проверяем скорость генерации страницы на сервере: 45 мс. По сравнению с предыдущим шагом производительность выросла в 3.4 раза. Проводим нагрузочное тестирование. 150 пользователей оказалось недостаточно, чтобы сайт упал. Открываем ещё одну вкладку с тестом. На 179 пользователях проскакивает ошибка (не полный крах), останавливаю тест. Максимальное время ответа: 8.966 секунды. В общем, переходите на PHP7.

Настройка Edge Side Includes (ESI) в Varnish

ESI — это язык для включения фрагментов веб-страниц в другие страницы. Это позволяет использовать кэшированные страницы, с использованием в них динамических элементов, сократив за счёт этого время генерации. Это аналог SSI в nginx.

В WordPress я решил это использовать для реализации следующей схемы: если пользователь залогинен, то генерировать на бэкенде для него только header, содержащий верхнее пользовательское меню. Для страниц с записями также динамически генерировать блок с комментариями. Всё остальное берётся из кэша. Также для сохранения высокой скорости отдачи для незалогиненных пользователей для каждой странички генерируется статический header и в последующем используется из кэша.

Так как теперь всё, что ниже header’а кэшируется, определить скорость генерации страницы с помощью PHP не получится. Для этого я использовал сервис Pingdom Tools, позволяющий оценить скорость загрузки сайта в целом, так и каждого документа. Выбрал я его по причине того, что у них возможно запустить тестирование с сервера, расположенного в Амстердаме, где и расположен мой VDS. За счёт этого время на установление соединения было в районе 20 мс и время генерации страницы после отправки запросу серверу при нескольких подряд отправленных запросах практически не менялось (+/- 5 мс). Перед активацией ESI я замерил скорость загрузки сайта с помощью этого сервиса. Вот результаты:

Сайт полностью загрузился за 360 мс, а время генерации самой страницы составило 135 мс. Приступим к настройке.

Немного изменим файл конфигурации VCL /etc/varnish/default.vcl, приведенный в первой части.

После блока в секции vcl_backend_response, описывающего работу со статическими файлами

# Для статических файлов, которые отдаёт бэкенд...
{
...
}

Добавляем следующий блок:

# Включаем обработку ESI для всех запросов, в URL которых отсутствует слово dynamic, можно сделать что-то более уникальное, так как такой запрос вполне уже может существовать
if (!(bereq.url ~ "dynamic")) {
    set beresp.do_esi = true;
}

Включение обработки будет включено только для тех случаев, для которых в секции vcl_recv не указан запрет обработки с помощью директивы set req.esi = false;

Блок кода в секции vcl_recv, отправляющий на бэкенд залогиненного пользователя

if (req.http.Cookie ~ "wordpress_" || req.http.Cookie ~ "comment_") {
    return (pass);
}

заменяем на блок:

# Для незалогиненных пользователей отключаем ESI. 
if (!(req.http.Cookie ~ "wordpress_" || req.http.Cookie ~ "comment_")) {
    set req.esi = false;
}

А в самое начало секции vcl_recv помещаем следующий блок:

# Пропускать запросы, содержащие слово dynamic
if (req.url ~ "dynamic") {
    return (pass);
}

Теперь нужно заставить WordPress работать нужным нам образом. Но для начала расскажу о тегах, используемых в ESI, позволяющих сформировать 2 разные страницы в браузере, в зависимости от того, включена обработка ESI или нет.

  • Тэг комментария <!—esi … —> — при условии включенной обработки ESI эти комментарии убираются, а то, что было ими закомментировано, при этом выполняется. Если же ESI отключено, то в браузере клиента данный участок кода будет интерпретирован как простой HTML-комментарий и будет проигнорирован;
  • <esi:include src=»адрес/страницы»/> — если включено ESI, будет произведен инклуд этой страницы в данном месте. Если ESI выключено, будет передано в браузер в исходном виде. Поэтому заключается в вышеописанный тэг комментария;
  • <esi:remove> … </esi:remove> — если включено ESI, будет произведено удаление данного участка кода. Если ESI выключено, будет передано в браузер в исходном виде. Поэтому так же заключается в вышеописанный тэг комментария.

Итак, настраиваем WordPress. Код верхней пользовательской панели генерируется, как правило, в файле header.php. Однако в моём случае часть стилей была перемещена в footer.php в целях ускорения загрузки страницы и генерировалась при вызове функции wp_footer(). Первым делом я перенёс вызов этой функции в файл header.php, поместив после открывающего тэга <body> код:

<?php wp_footer(); ?>

и убрал его в файле footer.php.

Теперь отредактируем файл темы index.php. Вместо кода

<?php get_header(); ?>

напишем:

<?php 
    $beforeHeader = '?';
    if(isset($_GET['dynamic-header']) || isset($_GET['static-header'])) 
    {
        get_header(); 
        die();
    }
    elseif ($_SERVER['QUERY_STRING'] != '')
    {
        $beforeHeader = '&';
    }
?>
<!--esi <esi:include src="<?php echo $_SERVER['REQUEST_URI'] . $beforeHeader; ?>dynamic-header"/> 
<esi:remove> -->
    <?php echo file_get_contents('http://php.zone'.$_SERVER['REQUEST_URI'] . $beforeHeader .'static-header'); ?>
<!--esi </esi:remove> -->

Если не поняли, поясню, как это работает. В переменной $beforeHeader лежит символ, который будет поставлен перед параметром в URL. Если строка запроса пуста, добавляем после адреса вопросительный знак и параметр, если нет — символ ‘&‘ и параметр. Если методом GET была установлена переменная dynamic-header или static-header, то выдать заголовок и умереть. Иначе, в случае включенного ESI (а для нас это означает, что перед нами залогиненный пользователь), запросить текущую страницу с добавлением ?dynamic-header. Так как в URL содержится слово dynamic, Varnish направит запрос на бэкенд. Затем будет загружена текущая страница с ?static-header на конце при помощи функции file_get_contents(), при этом никакие cookies от пользователя не дойдут до этой страницы и будет сформирован статический заголовок, предназначенный для гостей, который ввиду отсутствия в URL слова dynamic отправится в кэш.
В случае, если ESI отключено (зашёл гость), инклуда динамического заголовка не произойдёт, и будет отправлен закомментированный код. Теги <esi:remove> и </esi:remove> так же будут отправлены закомментированными, а код статического заголовка будет подгружен из кэша, либо сгенерируется и сохранится в кэш, если обращение к странице произошло впервые.

Производим аналогичную замену кода <?php get_header(); ?> во всех страницах темы, где он имеется.

Осталось только реализовать динамическую работу с комментариями на страницах записей. Для этого открываем шаблон single.php и ищем функцию, отвечающую за выдачу кода комментариев. В моём случае это функция comments_template(); и в начало кода помимо уже добавленного нами пишем:

<?php
if(isset($_GET['dynamic-comments'])) 
{
    comments_template( '', true );
    die();
}

А вместо вызова функции

<?php comments_template( '', true ); ?>

Пишем:

<!--esi <esi:include src="<?php echo $_SERVER['REQUEST_URI'] . $beforeHeader; ?>dynamic-comments"/>
<esi:remove> -->
    <?php echo file_get_contents('https://php.zone'.$_SERVER['REQUEST_URI'] . $beforeHeader .'dynamic-comments'); ?>
<!--esi </esi:remove> -->

Работает всё точно так же как и в случае выше, только комментарии всегда будут выдаваться динамически, потому что если кто-то добавит комментарий, PURGE-запрос будет отправлен только на страницу, но не на page?static-comments и комменты для незалогиненных не обновятся, а так всё работает.

Перезапускаем varnish:

service varnish restart

Замеряем скорость загрузки сайта с включённым ESI:
Общее время загрузки сайта — 289 мс. Время генерации страницы составило при этом 65 мс. Таким образом, с помощью ESI мы ускорили генерацию страницы для пользователя чуть больше чем в 2 раза.

После этого я провёл финальное нагрузочное тестирование. Пользователей: 300 (в других пяти вкладках чуть раньше запущены такие же тесты):

Сервер упал при нагрузке в 276 залогиненных пользователей. Время ответа при этом составило 7.155 секунды.

Эффективное сжатие

В комментариях к предыдущей статье мне указали на то, что сжатие на выходе nginx со степенью сжатия 9 будет давать лишнюю нагрузку на процессор, при этом файлы сожмутся сильнее примерно на 5 процентов, по сравнению со степенью сжатия 1, дающей гораздо меньшую нагрузку на процессор.

Собственно, пришла мысль для сжатия статических файлов (.css, .js) использовать 9 степень и сжимать их на стороне бэкенда, после чего они будут попадать в кэш Varnish’а и уже без последующей нагрузки на процессор за счёт операции сжатия передаваться пользователю, а саму генерируемую страничку сжимать на фронтенде со степенью сжатия 1.

Таким образом и место в кэше сэкономим, и отдавать пользователям будем более мелкие файлы.

Для этого нужно в конфиге фронтенда nginx /etc/nginx/conf.d/frontend.conf прописать следующие директивы:

gzip on;
gzip_comp_level 1;
gzip_min_length 512;
gzip_buffers 8 64k;
gzip_types text/plain;
gzip_proxied any;

А в конфиге бэкенда /etc/nginx/conf.d/backend.conf:

gzip on;
gzip_comp_level 9;
gzip_min_length 512;
gzip_buffers 8 64k;
gzip_types text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript image/svg+xml;
gzip_proxied any;

Никаких ощутимых результатов в скорости загрузки мне не дало, но размеры статических файлов всё же поуменьшились на несколько Кб. На супер-нагруженной системе это даст свои плоды.

Заключение
Помимо всего перечисленного можно дождаться выхода memcache для PHP7 и кэшировать запросы к БД с помощью плагина W3 Total Cache или какого-либо подобного. Также хотелось бы увидеть расширение php-handlersocket для PHP7, попробовать использовать для получения материалов по ID. Скорость доступа к этим данным должна возрасти раза в 2 точно. Большую часть советов, которые были даны в комментариях к прошлой статье выполнил. Спасибо большое комментаторам, благодаря Вам удалось разогнать сайт так, как я и не мечтал. Благодарю за прочтение.

loader
Комментарии
К этому посту больше нельзя оставлять новые комментарии
Логические задачи с собеседований