Кропотливая оптимизация PHP-приложений (рассматриваю PHP5, но большинство справедливо и для 4-й ветки)

Не то чтобы “очень классная статья”, но начинающим будет полезна (взято здесь http://www.habrahabr.ru/blog/webdev/19129.html)

Когда во сне снится “ой а если сервера не хватит…”

Для начала, Доброй Ночи. Пишу что-то полезное вроде впервые (если не считать разного рода полу-тестов в моём блоге). Человек я допытливый до жути, неожиданно в голову пришло, что могу помочь сэкономить кому-то много времени ;).

В общем когда на PHP создаются достаточно большие проекты (>100000 строк кода) желание сделать “правильно” то, что было сделано давно грозит повергнуть всё в хаос. По крайней мере для новых программистов, которые могут прийти в компанию через неделю, месяц, год… Решение - четкая систематизация с самого начала и установление жестких архитектурных правил. Для себя я решил - не используя фреймворки писать буду только “Hello World”-сайты. Не мудрствуя лукаво когда подумал о фреймворках полистал, почитал, но решил отдаться-таки зенду с его ZendFramework. Добротный он, хотя и изменений я в нём для себя сделал огромное количество.

В таком решении на ряду со всеми возможными плюсами и удобством неожиданно встаёт вопрос-стена: теперь у меня бизнес логика занимает, наверное, где-то вовсе 1-2% от времени исполнения всей программы. Плата за удобство и ООП (или “удобство ООП”? Наверное даже просто “удобство” или просто “ООП” - это почти одно и то же ;)) - огромное количество сопутствующего и управляющего кода.

В общем когда я делал новый проект - была цель - не менее 50 запросов в секунду на захудалом Celeron 2.6GHz. Т.е. около 0.02сек на запрос, включая mysql и так далее тому подобное. За время создания проекта я его умудрялся разгонять в несколько раз какими-то улучшениями. Какими? Налейте чашечку кофе - и добро пожаловать в мир мудрого девелопинга :) Сразу скажу - получилось.

Оптимизация от А до Я. Рецепт супчика от MockSoul :)

Этап 0. Готовимся

Окружение? Моя наилюбимейшая схема:

  1. LigHTTPd. Под линуксом. Со включенным sys-epoll;
  2. PHP5. Через FastCGI. PHP должен быть собран с поддержкой CGI, sharedmem (или threads, лучше sharedmem - а и то и другое сразу не скомпилится ;)). Дикий пример с чем я собираю пхп:

    ./configure’ ‘–prefix=/usr/lib/php5′ ‘–host=i686-pc-linux-gnu’ ‘–mandir=/usr/lib/php5/man’ ‘–infodir=/usr/lib/php5/info’ ‘–sysconfdir=/etc’ ‘–cache-file=./config.cache’ ‘–disable-cli’ ‘–enable-cgi’ ‘–enable-fastcgi’ ‘–disable-discard-path’ ‘–disable-force-cgi-redirect’ ‘–with-config-file-path=/etc/php/cgi-php5′ ‘–with-config-file-scan-dir=/etc/php/cgi-php5/ext-active’ ‘–without-pear’ ‘–disable-bcmath’ ‘–with-bz2′ ‘–disable-calendar’ ‘–disable-ctype’ ‘–without-curl’ ‘–without-curlwrappers’ ‘–disable-dbase’ ‘–disable-exif’ ‘–without-fbsql’ ‘–without-fdftk’ ‘–disable-filter’ ‘–disable-ftp’ ‘–with-gettext’ ‘–without-gmp’ ‘–disable-hash’ ‘–disable-ipv6′ ‘–disable-json’ ‘–without-kerberos’ ‘–enable-mbstring’ ‘–with-mcrypt’ ‘–without-mhash’ ‘–without-msql’ ‘–without-mssql’ ‘–with-ncurses’ ‘–with-openssl’ ‘–with-openssl-dir=/usr’ ‘–disable-pcntl’ ‘–without-pgsql’ ‘–without-pspell’ ‘–without-recode’ ‘–disable-simplexml’ ‘–enable-shmop’ ‘–with-snmp’ ‘–disable-soap’ ‘–enable-sockets’ ‘–without-sybase’ ‘–without-sybase-ct’ ‘–disable-sysvmsg’ ‘–disable-sysvsem’ ‘–disable-sysvshm’ ‘–with-tidy’ ‘–disable-tokenizer’ ‘–disable-wddx’ ‘–disable-xmlreader’ ‘–disable-xmlwriter’ ‘–without-xmlrpc’ ‘–without-xsl’ ‘–disable-zip’ ‘–with-zlib’ ‘–disable-debug’ ‘–enable-dba’ ‘–without-cdb’ ‘–without-db4′ ‘–without-flatfile’ ‘–with-gdbm’ ‘–without-inifile’ ‘–without-qdbm’ ‘–with-freetype-dir=/usr’ ‘–with-t1lib=/usr’ ‘–disable-gd-jis-conv’ ‘–with-jpeg-dir=/usr’ ‘–with-png-dir=/usr’ ‘–without-xpm-dir’ ‘–with-gd’ ‘–with-ldap’ ‘–without-ldap-sasl’ ‘–with-mysql=/usr’ ‘–with-mysql-sock=/var/run/mysqld/mysqld.sock’ ‘–without-mysqli’ ‘–without-pdo-dblib’ ‘–with-pdo-mysql=/usr’ ‘–without-pdo-odbc’ ‘–without-pdo-pgsql’ ‘–without-pdo-sqlite’ ‘–with-readline’ ‘–without-libedit’ ‘–with-mm’ ‘–without-sqlite’

    Грамотно прикручиваем к lighttpd, а не абы как:

    fastcgi.server = (     ".php" => (         "localhost" => (             "socket"          => "/tmp/php5-gmru-sandbox-mocksoul-lighttpd.sock" [#1],             "bin-path"        => "/usr/lib/php5/bin/php-cgi -c " + "/path/to/application/config/php_config_dir" [#2],             "min-procs"       => 1 [#3],             "max-procs"       => 1 [#3],             "bin-environment" => (                 "PHP_FCGI_CHILDREN" => "32" [#4],                 "PHP_FCGI_MAX_REQUESTS" => "3200" [#5]             )         )     ) )

    ([#1], [#2], … - так буду ссылаться на комментарии к коду. Если хотите взять код - такие пометки надо будет стереть. Ниже в коде буду придерживаться такой же схемы)

    • [#1] - unix-сокеты много шустрее чем tcp-сокеты. Так что используйте их только если в TCP нет серьёзной необходимости (или, хаха, под Windows :))
    • [#2] - тут я просто показал пример как можно конфиг пхп прикручивать к разному хосту (через -c указываем на папку с php.ini)
    • [#3] - min-procs и max-procs ДОЛЖНЫ БЫТЬ = 1!! Почему? Потому что далее я скажу про кеширование байткода. Кеш будет нелогичен при кол-ве процессов пхп более 1
    • [#4] - магический танец. Просим php запустить 32 потока в одном процессе для обработки запросов от lighttpd. Важно: если поставить, например, 10 и все 10 будут заняты каким-то диким 10-секундно-выполняющимся-скриптом - lighttpd будет отдавать 500 ошибку! Т.е. количество потоков не увеличивается в реалтайме - ставьте 32, 64 или, даже, 128 (работает это как threadpool)
    • [#5] - просим убить поток и создать новый через энное количество запросов. На всякий случай, ведь php не идеален :).
  3. Opcode Cacher. Или кешер байткода. Или “что за дибилизм - парсить одни и те же файлы при каждом запросе?!”. Очень (ОЧЕНЬ!) рекомендую APC (Alternative PHP Cache) который лежит в PECL. Можно так же eAccelerator или даже ZendOptimizer. Вкусы разные бывают.. Но при выборе между eAccelerator и APC - я рекомендую APC. Почему? Да хотя бы за возможность положить что угодно в shmem сегмент :). Ниже расскажу.

Этап 1. Пишем

Сначала пишем. Пишем и крутим в голове мысли о том как что-то сделать более разумным и быстрым сразу. Чтобы потом не отвлекаться (вообще это наверное совершенно естественное желание любого уважающего себя программиста %))

Моменты на которые сразу нужно обращать внимание:

  1. Вам, наверное, почти не нужно будет использовать require и include. В основном - require_once и include_once.
  2. Для итерации по массивам, их изменению и фильтрации - учимся использовать array_* функции в пхп. Особенно лямбда-функции:
    <?php          $arr = array('that', 'is', 'this'); array_walk($arr, create_function('&$v,$k', '$v = $v . " yeah";'); print_r($arr);  // outputs: // Array // ( //   [0] => that yeah //   [1] => is yeah //   [2] => this yeah // )  // А вы бы сделали это циклом? Ай-ай-ай...  ?>
  3. Передача переменной по ссылке (например $a=1; call_func(&$a)) - не влияет на быстродействие. Передача массивов по ссылке - влияет чуть-чуть. Передача классов - влияет очень. Я это к тому - что не передавайте ничего по ссылке надеясь ускорить программу. Передавайте по ссылкам только когда вам это _действительно_ нужно
  4. Делайте классы статическими если можно. Т.е. если для работы класса закрытая инстанция в общем-то и не нужна.
  5. Комментировать можно сколько хочется - кешер байткода все равно комментарии игнорирует. На быстродействие это влияет.. хм.. на 0.000001% :)
  6. Избегайте глубоких рекурсий. Стандартную задачу - взять список файлов включая поддирректории можно сделать и без рекурсии вовсе =)
  7. Прочитайте грамотные доки. Документацию того же ZendFramework - там много чего полезного даже тем кто фреймворк не использует и использовать не собирается
  8. Старайтесь делить код на логические блоки. Так, чтобы можно было взять 10-20 строчек подряд и сказать - вот тут я делаю ТОЛЬКО ЭТО. Взять другие 10-20 - и сказать а тут я делаю ТОЛЬКО ДРУГОЕ. Кол-во строчек которые надо брать, конечно, зависит от вас. Но лучше чтобы блоки были не более чем по 30-40 строк. Разбивайте программу и любой блог на инициализацию, настройку, работу, сохранение результата (в переменную скажем). При чём тут скорость? Через полгода поймёте ;).
  9. О том “Может сделать мне $a = “some $v inline” или $a = “some” . $v . “var” даже думать не стоит. Лично я (имхо) нахожу абсолютно дибильным вставку переменных прямо в строки. Лучшая читаемость:
    • $var = ’some’ . $in . ‘li’ . $ne . ‘ variable’;
    • $var = sprintf(’some %sli%s variable’, $in, $li);
  10. Используйте константы для того что никогда не меняется. Они парсятся в самом начале и лежат вообще в другом куске памяти чем обычные переменные. Конструкции вида $str = ’some’ . STR_CONSTANT и выглядят к тому же лучше. Особо грамотно - перенос строки. Обзывают его по-разному, я же люблю NL (NewLine) или CRLF(CarretReturnLineFeed)
  11. Не забывайте что foreach может и не делать копию массива :)
    foreach ($arr as $key => &$val) { ... }
  12. Как это ни парадоксально но вот такой момент меня в пхп совсем убивает: is_null() - придумана идиотом. if (null === $var) или if ($var === null) быстрее чем if (is_null($var))… дибилизм. Не используйте is_null() :)
  13. Регулярные выражения, работу со строками с помощью str_* функций и прочее оставляю на вашей совести как выходящее за рамки этой и без того раздутой статьи :)

Этап 2. Размышляем о возможных тратах времени

Так.. вот написали вы чего-нибудь. А теперь давайте посмотрим что обычно отнимает достаточно дофига времени без вашей бизнес-логики:

  1. Коннект к БД
  2. Обработка тонны require_once и include_once
  3. Сами запросы к БД
  4. Где-то храним конфиг и парсим его каждый раз? Используем модели БД и инициализируем их каждый раз? Вообще посмотрите как много одинакового мы делаем каждый запрос!!
  5. Что-то делаем с файловой системой? А зачем? Лично я думаю что можно чуть ли не любой проект написать с вообще отсутствующим IO (конечно, кроме того что будет использовать БД и тп). Не нужно ничего хранить в файловой системе. Мелкое. Большое (какой-нибудь гиговый проиндексированный файл) - нужно

Это я все отсортировал по важности. А теперь по порядку по каждому ненасытному моменту:

Коннект к БД

Всё просто - если владеете сервером - используйте постоянные подключения! PDO_MYSQL, MYSQL - все это умеют )

Обработка тонны require_once и include_once

Вот тут начинается веселье =). Для начала я взял посмотрел сколько файлов у меня включаются при ЛЮБОМ запросе в ZendFramework. Оказалось - чуть менее 300 (!!!!). Если не использовать байткод кешер - это будет вообще ненормально долгая процедура.

Решение “влоб” нашлось само собой - запихать всё это в один файл. Встал вопрос - а как узнать что у нас всегда инклудится - а что иногда? Вообще размышлять в тот момент особо времени не было поэтому и этот аспект я решил “влоб” )

Дикий результат - http://www.mocksoul.ru/pub/dev/mkzend.phps

Там:

  • Насколько часто обращение к файлу - смотрим через APC кеш по статистике
  • Рисуем табличку
  • Изменяем зенд автоматом :). Типа вырезаем все require_once, комментарии, открывающие и закрывающие пхп теги, лишние пробелы… издеваемся короче :) Смотрите исходник
  • Сохраняем получившеся гигантский скрипт в файлик… )

Скрипт абсолютно нестабилен и заточен под один проект. Запускать надо через браузер, чтобы APC отработал. Просто как пример. У вас работать не будет со 100% вероятностью =).

Как оказалось - 300 файлов парсились 2 сек, из байткешера вытаскивались за 0.3 сек, а сгенерированый суперфайл большой парсится 0.7сек а из кеша вытягивается за 0.003сек. Проект сразу разогнался почти в 3 раза :). Маньячная оптимизация, однако. Метод подходит для production-сервера, т.к. девелопить библиотеки которые из другого файла грузятся - невозможно.

Запросы к БД

Пройдите экскурс в ДБА и начните, наконец, использовать MYSQL_QUERY_CACHE. В my.cnf пишем query_cache_size = 100M. За кешем следим путём show status like ‘qcache%’. Ещё очень плотно читаем доки MySQL относительно Query Cache

Хватит делать одно и тоже - кешируйте!

Прочитали конфиг? Распарсили? Получили готовенький массив? Ну и зачем парсить его снова? ) У вас же есть - shared memory под рукой в виде APC! :) Невероятно быстрая скорость работы.. Храните в нём все что только можно - конфигурацию, собранные объекты, результаты запросов а-ля “describe table” (это прерогатива Zend_Db_Table_*). Из кеша данные берутся с невообразимой скоростью - 0.000001с где-то. В памяти, если не дублировать ничего, можно сохранить просто дофига данных. Помните, что 1 гиг - это огромная куча возможной информации. Не используйте IO в файловую систему для этого - лучше память. В зависимости от вашей квалификации - от 10 до 100% прироста скорости. Смотрите ниже про APD ;)

Зачем вам ФС?

Используйте ФС как хранитель чего угодно, только если это не влазит в память. Даже если пишите лог или статистику запросов - ложите в APC! И сохраняйте, скажем, каждые 5 минут на винт.

Этап 3. Устали размышлять о тратах времени. Хотим график перед глазами!

Это для меня оказалось весьма ценным открытием. В общем пошаговый гид:

  1. Нам нужен PECL APD (Advanced PHP Debugger)
  2. Конфигурим dumpdir для apd в конфиге. Что-то вроде:
    zend_extension=/usr/lib/php5/lib/php/extensions/no-debug-non-zts-20060613/apd.so apd.dumpdir="/tmp/php-apd-dump"
  3. В самом главном файлике пишем в сааамом верху apd_set_pprof_trace();, тем самым включая дамп профилера
  4. Делаем 1-100 запросов на сервер. Каждый раз будет сохранятся новый файлик в нашей /tmp/php-apd-dump
  5. Теперь мы можем смотреть результаты профилера либо прямо в консоли - вместе с apd идёт скриптик pprofp
  6. А ещё можем сделать супервещь - преобразовать в более унифицированный формат :). С APD кроме pprofp есть ещё pprof2calltree. Она преобразует дампы профилера в формат, понимаемый cachegrind и KCacheGrind в частности. Полученный файлик открываем в kcachegrind - и рукоплещем от удовольствия.

В целом - обычный такой профилер получается. Вот только для PHP я раньше такого не делал ;)

Этап 4. Проверяем

Проверять скорость простыми запросами на 1 урл при помощи ab или ab2 - глупо.

Более логичный вариант - сделать список всех (или не всех ;)) урлов, положить в текстовый файлик, взять Siege и тестить. Во время теста следить за TPS (TransactionsPerSecond) на винты (например при помощи iostat из пакета sysstat), следить за загрузкой процессоров, смотреть чтобы в конце не было ответов сервера отличных от 2хх.

Зачем это всё

Так сильно пытаться все ускорить нужно когда проект разрастается. Увеличение быстродействия на 10% на 1 сервере даёт прирост в скорости равный 10%. А если у вас уже 10 серверов - то 10%-ое увеличение быстродействие будет равно добавлению ещё одного 11-го сервера. Т.е. +100% в пересчете на 1 сервер. Это много. Это деньги. И это более высокий порог входа для конкурентов ;).

1 comment so far

  1. Morozov December 22, 2007 16:08

    Замечательная статья. Вам срочноо нужно на первую страницу Гугла :)

    Меня несколько смущает, что по сути два одинаковых выражения (”is_null($var)” и “null === $var“) работают по-разному в плане быстродействия. Судя по всему, первый вариант работает дольше только потому, что там происходит вызов функции. Сразу же возникает идея для разработчиков PHP, почему бы при разбора кода (или создании байт-кода) тупо не заменять вызов функции на сравнение — эдакая оптимизация на стороне интерпретатора? Правда, я не особо в курвсе, как оно там внутри устроено.

    BTW: не получилось залогиниться по OpenID :(

Leave a comment

Please be polite and on topic. Your e-mail will never be published.

You must be logged in to post a comment.