Жил да был у меня сервис некий высоконагрузочный достаточно. Жил он себе на веб-сервере nginx, с бэк-эндом под PHP-FastCGI. Все было хорошо, но нагрузка росла не по дням, а по часам. Тогда был сделан небольшой апгрейд, суть которого была в сохранении отрендереных страниц на сервере memcached (с помощью PECL-расширения Memcached), а затем — извлечении их оттуда и передаче клиенту. И было это хорошо, так как контента генерируется сервисом до хрена и больше, но почти весь он зависит только от запроса и не зависит ни от какой аутентификации, сессии и прочей ерунды. Так что схема работы сервиса стала такой:
1. Скрипт index.php при запуске проверяет наличие соотв. ключа в memcached, и если он есть — возвращает клиенту и прекращает работу. В противном случае в обычном порядке генерируется страница, которая после отдачи клиенту сохраняется в memcached:
<?php _initCache(); $key = _generateKey(); $text = $cacheObject->get($key); if ($text !== FALSE && $text !== null){ echo $text; exit(); } //Генерируем страницу echo $text; $cacheObject->set($key, $text); function _initCache(){ global $cacheObject; $cacheObject = new Memcached(); $cacheObject->addServer('localhost', 11211); $cacheObject->setOption(Memcached::OPT_COMPRESSION, true); $cacheObject->setOption(Memcached::OPT_PREFIX_KEY, 'MyProject::'); } function _generateKey(){ //... } ?>
2. Любое изменение данных с помощью скрипта admin.php приводит просто к стиранию соотв. записей из memcached ($cacheObject->delete($key)), так что при следующем обращении к ним они будут немедлено сгенерированы и сохранены заново.
Казалось бы, все хорошо. Но проблема в том, что при каждом запросе к index.php (а их много, ОЧЕНЬ много) — дергается PHP. Пусть он под FastCGI, а не под mod_php, но все равно это — потенциальный затык по многим причинам. А почему бы nginx не отдавать страницы напрямую из memcached? Он ведь это умеет!
Действительно, умеет, но — только если сохраненная в memcached страница не сжата. Можно, конечно, убрать установку OPT_COMPRESSION в true, и пользоваться прямой связью nginx и memcached. Но тогда, во-первых, памяти memcached будет кушать в 4 раза больше примерно; а во-вторых — при каждом запросе данных оттуда они будут заново сжиматься для отдачи клиенту (что, конечно, лучше, чем отдавать их несжатыми, но все равно как-то довольно глупо, коли можно их однократно сжатыми хранить).
В результате тщательных поисков и экспериментов был обнаружен крайне старый и заброшенный патч к nginx (еще к версии 0.5.35), который позволяет последнему отдавать страницы напрямую из memcached, если они уже сжаты. Более того, если клиент не поддерживает сжатие — то данные на лету распаковываются перед отдачей ему (веб-клиентов, не поддерживающих сжатие, сейчас очень мало, а распаковка — существенно более дешевая операция, чем упаковка). Я этот патч портировал на последнюю версию nginx (0.8.6), слегка почистил и подправил, настроил nginx:
location ^~ /index.php { access_log /var/log/nginx/access_log main_cached; default_type text/html; memcached_gzip_flag 2; gzip on; gunzip on; set $memcached_key "TSites::$host::$uri"; memcached_pass localhost:11211; error_page 404 502 504 = @fcgi; } location @fcgi { internal; access_log /var/log/nginx/access_log main; fastcgi_pass backend; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME /var/www$fastcgi_script_name; include /etc/nginx/fastcgi_params; }
Здесь
memcached_gzip_flag 2 — устанавливает бит флагов memcached, используемый для указания, что содержимое упаковано;
gzip on — включает упаковку ответа для тех данных, что не были сжаты заранее; а
gunzip on — включает автоматическую распаковку заранее упакованных данных, если клиент не сообщил, что упаковку поддерживает.
default_type text/html — необходима, чтобы nginx, лишенный возможности определить mime-type по расширению файла, вернул его правильно, а не application/octet-stream.
Еще можно обратить внимание на директивы access_log. Я вопсользовался дополнительным форматом лога main_cached, чтобы в тех случаях, когда ответ был возвращен из memcached — в лог была добавлена строка "CACHE", что полезно для анализа работы кэша:
log_format main '$host || $remote_addr || $time_local || "$request" || $status || $bytes_sent || "$http_referer" || "$http_user_agent" || $gzip_ratio'; log_format main_cached '$host || $remote_addr || $time_local || "$request" || $status || $bytes_sent || "$http_referer" || "$http_user_agent" || CACHE';
И все стало хорошо. Почти. Если бы я пользовался не PECL:Memcached для сохранения данных, то на этом можно было бы сию сагу закончить — во всяком случае, для меня она бы закончилась на этом. Но я пользовался именно этим расширением, которое, при всем своем удобстве и быстродействии, оказалось не без недостатков конкретно в моем случае. Оказалось, что данное расширение использует для сжатия данных функции ZLib compress/uncompress, которые упаковывают данные в контейнер не GZip, а ZLib, что соответствует не Content:Encoding: gzip, а Content-Encoding: deflate. Разумеется, можно было бы возвращать эти данные с соотв. заголовком, но беда в том, что на настоящий момент nginx заточен только под gzip-сжатие, и объем изменений, которые пришлось бы внести, был бы слишком велик. В частности, если бы эти изменения не были приняты автором nginx для включения в основную ветку разработки, то осуществление слияния изменений при каждом обновлении nginx было бы чрезмерно геморройным процессом. Поэтому решено было доработать PECL:Memcached, чтобы тот умел хранить данные в формате gzip.
Ситуация дополнительно усугубилась тем, что, исследовав проблему, я обнаружил, что весь код ZLib заточен именно под deflate-формат, а не gzip, который поддерживается только функциями работы с файлами. В принципе это, может быть, и правильно — контейнер gzip оптимизирован для сохранения данных о файлах, их атрибутах и путях, а для хранения данных в памяти это не нужно, так что deflate получается на какую-то крохотную частицу меньше, а его упаковка/распаковка — на такую же крохотную частицу, но быстрее. Но все равно я не видел никаких причин, почему бы не оставить это решение пользователю, тем более что низкоуровневые функции ZLib устроены так, что это было совсем нетрудно сделать. В результате, чтобы не переносить здоровый кусок кода ZLib внутрь Memcached, что было бы неправильно, я вынужден был сперва сбилдить патч к ZLib :)
Итак, что получилось в результате:
- Патч к ZLib (базируется на текущей версии 1.2.3). Полностью прозрачно совместим с основной веткой ZLib, добавляет две и нтерфейсные функции compress3 и uncompress3, отличающиеся от compress2/uncompress наличием дополнительного параметра write_gzip/read_gzip, который, будучи ненулевым, указывает на необходимость работать с контейнером gzip. Патч уже выслан авторам ZLib и, по предварительной информации, будет включен в основную ветку девелопмента (функции, возможно, будут переименованы).
- Патч к Memcached (базируется на текущей версии 1.0.0). Полностью совместим с предыдущими версиями. Патч уже выслан автору PECL:Memcached и, по предварительной информации, будет включен в основную ветку девелопмента. Основные изменения:
- Собственно то, ради чего делался предыдущий патч: gzip-сжатие. Добавлена дополнительная опция OPT_COMP_GZIP, при установке которой в true данные сохраняются в memcached в формате gzip (default: false). При этом во флаги в дополнение к флагу MEMC_VAL_COMPRESSED добавляется флаг MEMC_VAL_GZIP, так что при извлечении данных они могут быть автоматически распакованы независимо от того, каким методом их упаковали при сохранении.
- Кроме того, для обеспечения лучшей совместимости с другими memcached-клиентами изменена работа с флагами memcached: флаг MEMC_VAL_COMPRESSED перемещен в бит 1, где он расположен у большинства клиентов, а флаги, в которых сохраняется тип данных, перемещены с битов 0-3 на биты 8-11, которые большинством клиентов не используются.
- Уровень сжатия изменен с дефолтного (6) на максимальный (9). Разницы по быстродействию я не заметил, а вот по размеру сжатых данных — есть.
- Ну и до кучи, поправлен мелкий и редкий, но возможный баг с сохранением флагов.
- Ну и, наконец, собственно патч к nginx, ради которого все и затеяно. Это было самой сложной и самой рискованной частью работы, и хотя я постарался выполнить и проверить ее настолько тщательно, насколько мог — я не могу ничего гарантировать. Патч основан на последнем билде, 0.8.6. Он был выложен в mail-list по nginx и в настоящее время изучается сообществом, включая автора сервера. Если по нему будут какие-то замечания, исправления итд — я об этом обязательно немедленно напишу в журнале. Внесены следующие изменения:
- Добавлены параметры конфигурации memcached_gzip_flag и gunzip. об их значении можно прочесть здесь.
- Для упрощения отладки изменен файл core/nginx.h так, что изменилась сообщаемая сервером строка версии: вместо "nginx/0.8.6" пишется "nginx+gzmemc/0.8.6". Это изменение, разумеется, не несет никакой функциональной нагрузки и было сделано только чтобы проще было отслеживать, какие сервера пропатчены. Вы можете смело дропнуть этот файл.
- Все изменения, непосредственно служащие поддержке выдачи сжатого контента из memcached, находятся в файлах http\ngx_http_core_module.h, http\ngx_http_core_module.c, http\ngx_http_request.h и http\modules\ngx_http_memcached_module.c.
Эта часть тщательнейшим образом протестирована, и ее достаточно, чтобы работало все, кроме опции gunzip. Вы можете собрать nginx, дропнув из патча файл http\modules\ngx_http_gzip_filter_module и выставить опцию gunzip в off. Однако при этом клиенты, не поддерживающие gzip-сжатие, не будут получать выигрыша от кэширования. Если в вашем случае таких клиентов — существенная часть, то вам необходим также и пропатченый модуль gzip_filter, который, собственно, и представлял наибольшую сложность как в разработке, так и в тестировании, и за полноценную работоспособность которого я, увы, пока что отвечать не могу.
В планах, после полноценной проверки и отладки того, что есть — прозрачная и полноценная поддержка deflate-сжатия как в модуле memcached, там и в модуле gzip_filter. Кроме того — поддержка сжатия deflate в PHP-модуле ZLib, что необходимо, например, для полноценной работы моей контрольной панели к memcached.