История о том, как я загнал главную страницу форума с 88 запросов до 15, выяснил, что половину работы делал впустую один невинный аддон, и в конце снял ещё четверть серверного времени строчкой в конфиге — не сломав при этом ничего из того, что работало.
Содержание
Форум у меня работает быстро, и поводов лезть внутрь вроде бы не было. Но «быстро» — это ощущение, а я хотел цифру. У XenForo есть встроенная debug-панель: добавляешь в
Тайминги по страницам оказались разные. Статьи и темы — отличные, 9–19 запросов, трогать нечего. А вот главная под залогиненным пользователем выбивалась из ряда: 88–90 запросов. Гостевая главная при этом показывала 49 — тоже подозрительно много для страницы, которая по идее должна отдаваться из кэша почти целиком. Стало понятно, куда копать.
Я выгрузил полный список всех 88 запросов и стал читать его глазами. И почти сразу в нём проступил паттерн — один и тот же запрос, повторяющийся снова и снова:
Он шёл по три раза на каждое вложение, да ещё и повторно для тех же вложений в разных местах страницы. Я насчитал около сорока таких запросов из восьмидесяти восьми — почти половину. И каждый возвращал пустоту:
Остальные запросы оказались здоровым ядром: визитёр со своими правами, дерево узлов, маркеры прочитанного, пара выборок виджетов. Их трогать смысла нет — это та работа, которую страница обязана делать. А вот сорок пустых запросов к таблице водяных знаков — это была аномалия с конкретным источником.
Таблица
Дальше всё складывается в идеальный шторм. Шаблоны AMS запрашивают URL миниатюры по три раза на вложение — обычная версия, retina и прямая ссылка. Это уже ×3. А главная показывает одни и те же статьи сразу в двух виджетах — «Сейчас в тренде» и «Свежие статьи». Это ещё ×2. Перемножаем на десяток вложений — и вот они, сорок одинаковых запросов в пустую таблицу.
Это классическая N+1: вместо одного запроса на всё мы делаем по запросу на каждый элемент. Хрестоматийный антипаттерн, и лечится он хрестоматийно — кэшированием.
Первый соблазн — открыть файл аддона и дописать кэш прямо там. Так делать нельзя: при первом же обновлении Spolzer Watermark мои правки затрёт, и проблема вернётся молча. XenForo для таких случаев даёт штатный механизм — Class Extensions: ты объявляешь свой класс, который наследует оригинальный, и переопределяешь только нужные методы. Оригинал остаётся нетронутым, обновления ему не страшны.
У меня уже был свой служебный аддон под такие оптимизации — назову его условно Boost. В него я и добавил расширение репозитория водяных знаков. Логика двухуровневая, и обе ступени работают в пределах одного запроса (репозиторий в XenForo — singleton на запрос, так что это безопасно):
Записи (создание и удаление водяного знака) сбрасывают кэш, поэтому в рамках запроса данные всегда согласованы. И решение не разваливается на больших объёмах: кэш ограничен числом вложений на странице, а не размером таблицы.
Эффект: вместо примерно сорока запросов к водяным знакам — ноль точечных, ценой одного COUNT. Это убрало почти половину нагрузки главной разом.
Разобравшись с пользователем, я вернулся к гостю и его 49 запросам. Гипотеза была такая: реклама на сайте крутится через Siropu Ads Manager, и она помечает страницы некэшируемыми — оттого page cache гостям и не отдаётся.
Прежде чем чинить, я решил проверить, а так ли это вообще. Самый чистый способ — посмотреть, ставит ли ядро заголовок
И тут меня ждал сюрприз: второй запрос вернул
Это важный урок: инструмент измерения может искажать измеряемое. Я чуть не бросился чинить то, что и так работало, — спасла привычка сначала проверить факт, а потом действовать.
Раз page cache работает, возник логичный вопрос: а как же реклама? Если страница кэшируется целиком, откуда на ней свежие объявления и как считаются показы? Я полез в исходники Ads Manager — и обнаружил, что аддон не просто совместим с page cache, а спроектирован под него. Три находки:
Вывод неожиданный, но приятный: моя исходная гипотеза была неверна, и чинить здесь нечего. А на будущее я нашёл в том же аддоне родной механизм ленивой подгрузки отдельных рекламных позиций через AJAX — если когда-нибудь понадобится гео-таргетинг или ротация чаще, чем раз в несколько минут, позицию можно сделать «живой» поверх закэшированного HTML. Но это на потом.
Вернувшись к пользовательской главной уже после фикса водяных знаков, я снял свежий debug — и в нём, по той же схеме, проступила вторая пара N+1.
Первая — связь
Вторая оказалась интереснее и привела меня к красивому эффекту, ради которого стоит отдельный раздел.
Виджет «Сейчас в тренде» грузил статьи без связанных обложек. Поэтому шаблон карточки дотягивал обложку каждой статьи отдельным запросом — снова N+1. Это бы лечилось добавлением обложки в выборку трендинга. Но тут вступал в игру второй, неочевидный механизм.
В XenForo есть identity map: одна и та же сущность за время запроса существует в единственном экземпляре. Виджет трендинга первым загружал «голые» статьи без обложек и клал их в identity map. А следующий виджет, «Свежие статьи», запрашивал те же статьи уже со своими джойнами по обложкам — но получал из identity map уже готовые «голые» экземпляры, и его собственные джойны отбрасывались. В итоге обложки дотягивались поштучно в обоих виджетах, и N+1 удваивалась.
Решение — расширить хендлер трендинга так, чтобы он сразу грузил обложки (и
После этих двух фиксов свежий debug показал 15 запросов на пользовательской главной вместо исходных 88. И что приятно — наш страховочный COUNT по таблице избранного в трейсе даже не выполнился ни разу: хендлер трендинга теперь джойнит
Попутно я выключил у себя запись активности гостей и роботов — она мне не нужна, а базу нагружает. Но тут вылезла логическая нестыковка: раз активность гостей и роботов не пишется, то в таблице
Это я тоже закрыл расширением, привязав всё к той самой опции — при выключенной опции форум ведёт себя штатно. Когда опция включена:
Строки для модификаций шаблонов я брал байт-в-байт из мастер-шаблонов своей версии XenForo и проверял на уникальность — если будущее обновление их изменит, модификация просто не применится (это видно в админке), и ничего не сломается.
Дальше я сделал то, что должен был сделать раньше: посмотрел на структуру времени. А она такая. Запросы к базе после всех фиксов занимают 14–18 мс из общего времени страницы. Остальные ~100 мс — это чистый PHP: автозагрузка трёх с лишним сотен файлов, рендеринг шаблонов, гидрация сущностей. Дальше воевать с SQL стало бессмысленно — это битва за 13% территории. Бить надо было по PHP-рантайму.
Инструмент для этого в PHP 8.5 есть —
Тут есть две тонкости, на которых легко обжечься.
Первая: не выключайте проверку времени файлов. Типовые гайды советуют
Вторая: код аддонов в preload не кладём. Расширения XenForo наследуют динамические псевдоклассы
И главное — после preload появляется обязательный ритуал: каждое обновление кода (ядра, аддонов, PHP) требует
Сколько это дало в цифрах — отдельная история, потому что намерить правду оказалось сложнее, чем починить.
Когда основное было сделано, я открыл вкладку Network на повторном заходе — просто полюбоваться — и зацепился взглядом за восемь миниатюр статей. Каждая висела по 70–100 мс со статусом 304. Первая мысль рефлекторная: непорядок, гоним в кэш.
И снова хорошо, что я сперва присмотрелся к строчкам, а не к секундам. Статус — 304, не 200. Размер — 310 байт, не вес картинки. 304 значит «Not Modified»: браузер спрашивает «миниатюра не менялась?», сервер отвечает «нет, бери свою из кэша». Сама картинка не передаётся — те 310 байт это пустой ответ с заголовками. То есть в кэше браузера она уже лежит, пользователь видит её мгновенно. Эти 70 мс — не передача данных, а цена самого вопроса: круг до сервера плюс лёгкая работа PHP, чтобы ответить «не менялось».
Почему браузер вообще переспрашивает? Заголовки ответа:
Но я заметил путь, по которому шла миниатюра:
И снова та же развилка, что преследовала меня всю дорогу: по заголовкам казалось «фреймворк сознательно защищает вложения, не лезь», а в коде оказалось «аддон не довёл отдачу до конца». Разница между тем, что кажется снаружи, и тем, что написано внутри.
Чинится это аккуратно, но с одной важной оговоркой. Контроллер аддона перед каждой отдачей вызывает
Масштаб, как и с предыдущими мелочами, честный: выигрыш косметический. Картинки и так лежали в кэше браузера и показывались мгновенно — я убрал лишь восемь фоновых переспросов на повторных заходах да снял с PHP столько же ненужных пробуждений. Скорость, которую видит посетитель, не изменилась. Но поправить стоило — хотя бы потому, что причина была не «так задумано», а «забыли».
Я хотел честную цифру выигрыша от preload и сравнил время с ним и без него. И прежде чем получить правду, наступил подряд на три грабли измерения — каждая поучительна.
Грабля первая: внешний
Грабля вторая: localhost по HTTP. Тогда я стал бить по
Грабля третья: localhost по HTTPS без keepalive. Через
Правда вылезла только когда я снял замеры из реального браузера и выгрузил HAR. В HAR есть фаза
Вот теперь чисто. Два набора не пересекаются вообще, минимумы дают 213 против 439. Preload снимает порядка 230–260 мс — примерно половину серверного времени под админом.
Почему так много? Под админкой XenForo линкует заметно больше кода, чем под обычным посетителем, а на форуме с десятком аддонов граф классов огромный. Без preload весь этот граф разрешается заново на каждый запрос; preload разрешает иерархии наследования один раз при старте. Чем больше кодовая база и чем тяжелее путь — тем жирнее выигрыш. Админ под нагруженным форумом — это худший случай для холодной линковки, поэтому здесь эффект максимальный. Обычный посетитель в абсолюте выиграет меньше, но направление то же.
Если из всей этой истории выкинуть аддоны, запросы и preload и оставить что-то одно — я бы оставил вот это. За время работы инструмент наблюдения соврал мне дважды, причём по-разному, и каждый раз едва не увёл в сторону.
Первый раз — debug-панель. Она показала гостю 49 запросов на главной, и я уже занёс руку чинить «некэшируемую» страницу. А страница кэшировалась прекрасно — это был HIT. Просто debug привязан к моему IP, а под ним XenForo не отдаёт кэш, чтобы не закэшировать заодно и отладочную панель. То есть сам акт наблюдения отключил то, за чем я наблюдал: я мерил не страницу, а страницу-под-микроскопом, а это другой объект.
Второй раз — замер времени, и тут инструмент врал трижды подряд (про это был отдельный раздел): curl снаружи мерил мой канал до сервера, localhost ловил редирект на https, curl без keepalive переустанавливал шифрование на каждой итерации. Три попытки — три разных неправильных числа, пока HAR из браузера не дал чистую серверную фазу. А когда я следом полез проверять TLS-хендшейк, та же болезнь поджидала и там — но это уже сюжет для отдельной заметки.
Складывается простой принцип. Цифра, которую выдаёт инструмент, — это цифра про связку «объект плюс инструмент», а не про объект. Ни debug-панель, ни curl не врали в техническом смысле — они честно измеряли ровно то, что измеряли. Врал я, когда принимал их число за свойство сервера.
Дешёвая защита от этого — не верить одному измерению, а сверять его с другим, добытым принципиально иначе. Серверное время у меня в итоге пришло с трёх сторон: page time из debug-панели изнутри PHP, фаза
Это знание обошлось мне дороже по времени, чем сами фиксы. Но оно и ценнее: аддон я починил один раз, а привычку проверять цифру фактом утащу с собой в каждую следующую задачу.
Путь получился длинный, поэтому соберу всё в одну картину:
И несколько выводов, которые я забираю с собой:
И последнее, ещё раз, потому что это важно: после всех замеров не забудьте выключить
Содержание
Повод заглянуть под капот
Форум у меня работает быстро, и поводов лезть внутрь вроде бы не было. Но «быстро» — это ощущение, а я хотел цифру. У XenForo есть встроенная debug-панель: добавляешь в
config.php флаг — и внизу каждой страницы появляется сводка по времени, памяти и, главное, полный список SQL-запросов с таймингами и EXPLAIN. Включил, привязав к своему IP, открыл несколько типовых страниц и стал смотреть.⚠️ Сразу важное написал:
Тайминги по страницам оказались разные. Статьи и темы — отличные, 9–19 запросов, трогать нечего. А вот главная под залогиненным пользователем выбивалась из ряда: 88–90 запросов. Гостевая главная при этом показывала 49 — тоже подозрительно много для страницы, которая по идее должна отдаваться из кэша почти целиком. Стало понятно, куда копать.
Что показал debug: главная под пользователем
Я выгрузил полный список всех 88 запросов и стал читать его глазами. И почти сразу в нём проступил паттерн — один и тот же запрос, повторяющийся снова и снова:
Код:
SELECT * FROM xf_sp_watermark_permanent WHERE attachment_id = ?
Он шёл по три раза на каждое вложение, да ещё и повторно для тех же вложений в разных местах страницы. Я насчитал около сорока таких запросов из восьмидесяти восьми — почти половину. И каждый возвращал пустоту:
no matching row. То есть это был чистый холостой трафик к базе — мотор работал, а машина стояла.Остальные запросы оказались здоровым ядром: визитёр со своими правами, дерево узлов, маркеры прочитанного, пара выборок виджетов. Их трогать смысла нет — это та работа, которую страница обязана делать. А вот сорок пустых запросов к таблице водяных знаков — это была аномалия с конкретным источником.
Дымящийся пистолет: водяной знак и N+1
Таблица
xf_sp_watermark_permanent принадлежит аддону Spolzer Watermark — он умеет накладывать водяные знаки на вложения. Я полез в его исходники и нашёл механику целиком. Цепочка такая: метод getThumbnailUrl() на каждой картинке вызывает canServeWatermarkedAttachment(), тот — hasStoredWatermark(), а тот — getPermanentRow($id), который и лезет в базу. Никакого кэша по пути нет ни на одном уровне.Дальше всё складывается в идеальный шторм. Шаблоны AMS запрашивают URL миниатюры по три раза на вложение — обычная версия, retina и прямая ссылка. Это уже ×3. А главная показывает одни и те же статьи сразу в двух виджетах — «Сейчас в тренде» и «Свежие статьи». Это ещё ×2. Перемножаем на десяток вложений — и вот они, сорок одинаковых запросов в пустую таблицу.
Это классическая N+1: вместо одного запроса на всё мы делаем по запросу на каждый элемент. Хрестоматийный антипаттерн, и лечится он хрестоматийно — кэшированием.
Почему я правлю не аддон, а расширение
Первый соблазн — открыть файл аддона и дописать кэш прямо там. Так делать нельзя: при первом же обновлении Spolzer Watermark мои правки затрёт, и проблема вернётся молча. XenForo для таких случаев даёт штатный механизм — Class Extensions: ты объявляешь свой класс, который наследует оригинальный, и переопределяешь только нужные методы. Оригинал остаётся нетронутым, обновления ему не страшны.
У меня уже был свой служебный аддон под такие оптимизации — назову его условно Boost. В него я и добавил расширение репозитория водяных знаков. Логика двухуровневая, и обе ступени работают в пределах одного запроса (репозиторий в XenForo — singleton на запрос, так что это безопасно):
- Memo по
attachment_id. Повторные обращения к тому же вложению берут результат из памяти, в базу не ходят. Это сразу схлопывает те самые ×3 и дубли между виджетами. - Короткое замыкание по COUNT. Если в таблице постоянных водяных знаков нет ни одной строки — а у меня их ноль, постоянный режим не используется, — то точечный запрос на каждое вложение заведомо вернёт пустоту. Вместо этого делаю один
COUNT(*)на весь запрос: таблица пуста — отвечаемnullбез похода в базу.
Записи (создание и удаление водяного знака) сбрасывают кэш, поэтому в рамках запроса данные всегда согласованы. И решение не разваливается на больших объёмах: кэш ограничен числом вложений на странице, а не размером таблицы.
Эффект: вместо примерно сорока запросов к водяным знакам — ноль точечных, ценой одного COUNT. Это убрало почти половину нагрузки главной разом.
💡 На заметку написал:
Гостевой page cache: он уже работает
Разобравшись с пользователем, я вернулся к гостю и его 49 запросам. Гипотеза была такая: реклама на сайте крутится через Siropu Ads Manager, и она помечает страницы некэшируемыми — оттого page cache гостям и не отдаётся.
Прежде чем чинить, я решил проверить, а так ли это вообще. Самый чистый способ — посмотреть, ставит ли ядро заголовок
X-XF-Cache-Status при отдаче из кэша. Два запроса подряд без кук:
Bash:
curl -s -D- -o /dev/null https://sysadmin.guru/ | grep -i 'x-xf-cache\|set-cookie'
curl -s -D- -o /dev/null https://sysadmin.guru/ | grep -i 'x-xf-cache\|set-cookie'
И тут меня ждал сюрприз: второй запрос вернул
X-XF-Cache-Status: HIT. То есть гостевой page cache уже работал. Гость получает готовый HTML из кэша, и те «49 запросов», что я видел в debug, — это был артефакт самого измерения: debug-панель привязана к моему IP, а под ней страница каждый раз генерируется заново, мимо кэша. Реальный гость с улицы видит страницу из памяти и одну-две записи активности, не больше.Это важный урок: инструмент измерения может искажать измеряемое. Я чуть не бросился чинить то, что и так работало, — спасла привычка сначала проверить факт, а потом действовать.
Реклама оказалась не врагом, а союзником
Раз page cache работает, возник логичный вопрос: а как же реклама? Если страница кэшируется целиком, откуда на ней свежие объявления и как считаются показы? Я полез в исходники Ads Manager — и обнаружил, что аддон не просто совместим с page cache, а спроектирован под него. Три находки:
- Аддон подписан на событие
page_cache_idи дописывает к ключу кэша тип устройства. Страницы кэшируются раздельно для desktop, mobile и tablet — мобильному гостю не достанется десктопная вёрстка. - Показы и клики считаются на клиенте. После загрузки страницы скрипт шлёт отдельный POST на трекинг, и только этот фоновый запрос обновляет счётчики и ставит дедуп-куку. На сам HTML-ответ страницы аддон кук не вешает — поэтому ядро и признаёт страницу кэшируемой. Деньги при этом не теряются: показы считаются даже на закэшированных страницах.
- Тот единственный «рекламный» запрос, что мелькал в моём debug, на поверку выполнялся только для меня — он обёрнут в проверку
is_admin. Для гостей и обычных участников его нет вовсе.
Вывод неожиданный, но приятный: моя исходная гипотеза была неверна, и чинить здесь нечего. А на будущее я нашёл в том же аддоне родной механизм ленивой подгрузки отдельных рекламных позиций через AJAX — если когда-нибудь понадобится гео-таргетинг или ротация чаще, чем раз в несколько минут, позицию можно сделать «живой» поверх закэшированного HTML. Но это на потом.
Ещё две N+1: Featured и обложки трендинга
Вернувшись к пользовательской главной уже после фикса водяных знаков, я снял свежий debug — и в нём, по той же схеме, проступила вторая пара N+1.
Первая — связь
Featured. Шаблоны карточек статей обращаются к $article.Featured, и если эта связь не была подгружена джойном заранее, XenForo делает отдельный SELECT по xf_xa_ams_article_feature на каждую статью. Избранных статей у меня нет — таблица пуста, — так что все эти запросы снова холостые. Лечится тем же приёмом: один COUNT на запрос, и при пустой таблице связь сразу отдаётся как null без точечных выборок.Вторая оказалась интереснее и привела меня к красивому эффекту, ради которого стоит отдельный раздел.
Эффект identity map, который удваивал запросы
Виджет «Сейчас в тренде» грузил статьи без связанных обложек. Поэтому шаблон карточки дотягивал обложку каждой статьи отдельным запросом — снова N+1. Это бы лечилось добавлением обложки в выборку трендинга. Но тут вступал в игру второй, неочевидный механизм.
В XenForo есть identity map: одна и та же сущность за время запроса существует в единственном экземпляре. Виджет трендинга первым загружал «голые» статьи без обложек и клал их в identity map. А следующий виджет, «Свежие статьи», запрашивал те же статьи уже со своими джойнами по обложкам — но получал из identity map уже готовые «голые» экземпляры, и его собственные джойны отбрасывались. В итоге обложки дотягивались поштучно в обоих виджетах, и N+1 удваивалась.
Решение — расширить хендлер трендинга так, чтобы он сразу грузил обложки (и
CoverImage, и данные вложения, и Featured) тремя LEFT JOIN. Тогда в identity map попадают уже полные сущности, «Свежие статьи» переиспользуют их как есть — и десятки точечных запросов превращаются в три джойна. Каждую связь я добавляю только если она реально существует в структуре сущности — страховка на случай, если будущая версия AMS что-то переименует.💡 На заметку написал:
После этих двух фиксов свежий debug показал 15 запросов на пользовательской главной вместо исходных 88. И что приятно — наш страховочный COUNT по таблице избранного в трейсе даже не выполнился ни разу: хендлер трендинга теперь джойнит
Featured сразу, связи приходят готовыми, и до короткого замыкания дело не доходит. Оно осталось спящей подстраховкой для других страниц.Счётчики гостей и роботов, которых нет
Попутно я выключил у себя запись активности гостей и роботов — она мне не нужна, а базу нагружает. Но тут вылезла логическая нестыковка: раз активность гостей и роботов не пишется, то в таблице
xf_session_activity их нет — и счётчики «гостей онлайн», и фильтры /online/?type=guest и ?type=robot показывают бессмыслицу. Данных-то нет.Это я тоже закрыл расширением, привязав всё к той самой опции — при выключенной опции форум ведёт себя штатно. Когда опция включена:
- вкладки «Гости» и «Роботы» на странице
/online/скрываются модификацией шаблона; - прямые ссылки
?type=guestи?type=robotобъявляются невалидным фильтром на уровне репозитория — контроллер сам сбрасывает их на «Все»; - в подвале виджета «Сейчас на форуме» вместо «всего X (участников Y, гостей Z)» остаётся честное «Сейчас в сети: N».
Строки для модификаций шаблонов я брал байт-в-байт из мастер-шаблонов своей версии XenForo и проверял на уникальность — если будущее обновление их изменит, модификация просто не применится (это видно в админке), и ничего не сломается.
OPcache preload: минус четверть времени
Дальше я сделал то, что должен был сделать раньше: посмотрел на структуру времени. А она такая. Запросы к базе после всех фиксов занимают 14–18 мс из общего времени страницы. Остальные ~100 мс — это чистый PHP: автозагрузка трёх с лишним сотен файлов, рендеринг шаблонов, гидрация сущностей. Дальше воевать с SQL стало бессмысленно — это битва за 13% территории. Бить надо было по PHP-рантайму.
Инструмент для этого в PHP 8.5 есть —
opcache.preload. Идея: скомпилировать ядро движка и горячие библиотеки в общую память OPcache один раз при старте PHP-FPM, чтобы с каждого запроса исчезла возня автозагрузчика и компиляция файлов. Я написал скрипт предзагрузки, который компилирует ядро XenForo и нужные vendor-зависимости через opcache_compile_file().
INI:
opcache.preload=/usr/www/sysadmin/sg-preload.php
opcache.preload_user=www-data
opcache.memory_consumption=256
opcache.max_accelerated_files=30000
Тут есть две тонкости, на которых легко обжечься.
Первая: не выключайте проверку времени файлов. Типовые гайды советуют
opcache.validate_timestamps=0 ради скорости. Для XenForo это ловушка: движок на лету перекомпилирует шаблоны и фразы в internal_data/code_cache/, и с отключённой проверкой вы получите вечно протухшие шаблоны после каждой правки. Правильный компромисс — opcache.revalidate_freq=30: один дешёвый stat() на файл раз в полминуты, syscall-шум почти исчезает, а правки подхватываются.Вторая: код аддонов в preload не кладём. Расширения XenForo наследуют динамические псевдоклассы
XFCP_*, которых на этапе предзагрузки ещё не существует. Компиляция пройдёт, но пользы мало, а код аддонов меняется чаще ядра.И главное — после preload появляется обязательный ритуал: каждое обновление кода (ядра, аддонов, PHP) требует
systemctl restart php8.5-fpm. Обычные файлы подхватятся сами по revalidate, а предзагруженное ядро обновляется только рестартом.Сколько это дало в цифрах — отдельная история, потому что намерить правду оказалось сложнее, чем починить.
Миниатюры, которые зря переспрашивали сервер
Когда основное было сделано, я открыл вкладку Network на повторном заходе — просто полюбоваться — и зацепился взглядом за восемь миниатюр статей. Каждая висела по 70–100 мс со статусом 304. Первая мысль рефлекторная: непорядок, гоним в кэш.
И снова хорошо, что я сперва присмотрелся к строчкам, а не к секундам. Статус — 304, не 200. Размер — 310 байт, не вес картинки. 304 значит «Not Modified»: браузер спрашивает «миниатюра не менялась?», сервер отвечает «нет, бери свою из кэша». Сама картинка не передаётся — те 310 байт это пустой ответ с заголовками. То есть в кэше браузера она уже лежит, пользователь видит её мгновенно. Эти 70 мс — не передача данных, а цена самого вопроса: круг до сервера плюс лёгкая работа PHP, чтобы ответить «не менялось».
Почему браузер вообще переспрашивает? Заголовки ответа:
Код:
Cache-Control: private, no-cache, max-age=0
Expires: Thu, 19 Nov 1981 08:52:00 GMT
no-cache здесь и есть приказ «бери из кэша только переспросив», а Expires из 1981 года — древняя заглушка «протухло сорок лет назад». И тут я чуть не закрыл тему выводом, который казался очевидным: XenForo осознанно метит вложения некэшируемыми, потому что проверяет права доступа — картинка из закрытого раздела не должна осесть в кэше и утечь. Логично же. Не трогаем, это защита.Но я заметил путь, по которому шла миниатюра:
/watermark/thumb/. Это не штатный механизм вложений ядра — это аддон водяных знаков. Тот самый, с которого началась вся история про N+1. И раз заголовки ставит его код, а не ядро, я не стал гадать про права — просто открыл исходник. А там оказалось вот что: аддон честно реализовал условное кэширование — выставляет ETag, Last-Modified, отвечает 304 на совпадении. Но поверх этого остался дефолтный заголовок ядра private, no-cache, который автор просто забыл снять. Не защита, не замысел — пропущенная строчка.И снова та же развилка, что преследовала меня всю дорогу: по заголовкам казалось «фреймворк сознательно защищает вложения, не лезь», а в коде оказалось «аддон не довёл отдачу до конца». Разница между тем, что кажется снаружи, и тем, что написано внутри.
Чинится это аккуратно, но с одной важной оговоркой. Контроллер аддона перед каждой отдачей вызывает
canView() — проверку прав. Поэтому поставить public нельзя: закэшированную общим прокси картинку потом отдадут без проверки, и закрытое вложение утечёт. А вот private — можно: он разрешает кэшировать только приватному кэшу самого браузера, не прокси и не CDN. В кэш пользователя попадает лишь то, что ему и так разрешено видеть, права остаются на месте. Я заменил расширением private, no-cache, max-age=0 на private, max-age=604800 — неделя. Те самые 304 превратились в «кэш памяти», 0 мс, без переспроса.💡 На заметку написал:
Масштаб, как и с предыдущими мелочами, честный: выигрыш косметический. Картинки и так лежали в кэше браузера и показывались мгновенно — я убрал лишь восемь фоновых переспросов на повторных заходах да снял с PHP столько же ненужных пробуждений. Скорость, которую видит посетитель, не изменилась. Но поправить стоило — хотя бы потому, что причина была не «так задумано», а «забыли».
Как правильно замерять (и как я трижды ошибся)
Я хотел честную цифру выигрыша от preload и сравнил время с ним и без него. И прежде чем получить правду, наступил подряд на три грабли измерения — каждая поучительна.
Грабля первая: внешний
curl. Замер снаружи показал разницу в 183 мс. Но абсолютные числа были подозрительно большими — 510 мс против 693. Разгадка: внешний curl меряет не только работу сервера, а ещё и канал до него, и TLS-хендшейк на каждой итерации. Серверная работа тонула в транспорте.Грабля вторая: localhost по HTTP. Тогда я стал бить по
127.0.0.1 — и получил 0.5 мс. Полмиллисекунды! За такое время PHP не стартует, XenForo не грузится. Оказалось, curl http://127.0.0.1/ получал от nginx мгновенный редирект 301 на https и до PHP вообще не доходил. Я мерил скорость, с которой nginx говорит «иди на https».Грабля третья: localhost по HTTPS без keepalive. Через
--resolve я пошёл по https на петлю — и получил ~400 мс, втрое больше реального серверного времени. Через петлю время не может вырасти втрое; виноват был TLS-хендшейк, который curl делал заново на каждый из 20 запросов.Правда вылезла только когда я снял замеры из реального браузера и выгрузил HAR. В HAR есть фаза
wait — это чистое время сервера от «запрос ушёл» до «первый байт пришёл», на уже установленном keepalive-соединении, без TLS и без канала. Четыре замера с preload против четырёх без:
Код:
С preload: медиана 232 мс (диапазон 213–358)
Без preload: медиана 499 мс (диапазон 439–579)
Вот теперь чисто. Два набора не пересекаются вообще, минимумы дают 213 против 439. Preload снимает порядка 230–260 мс — примерно половину серверного времени под админом.
Почему так много? Под админкой XenForo линкует заметно больше кода, чем под обычным посетителем, а на форуме с десятком аддонов граф классов огромный. Без preload весь этот граф разрешается заново на каждый запрос; preload разрешает иерархии наследования один раз при старте. Чем больше кодовая база и чем тяжелее путь — тем жирнее выигрыш. Админ под нагруженным форумом — это худший случай для холодной линковки, поэтому здесь эффект максимальный. Обычный посетитель в абсолюте выиграет меньше, но направление то же.
💡 На заметку написал:
Чему меня научили ложные цифры
Если из всей этой истории выкинуть аддоны, запросы и preload и оставить что-то одно — я бы оставил вот это. За время работы инструмент наблюдения соврал мне дважды, причём по-разному, и каждый раз едва не увёл в сторону.
Первый раз — debug-панель. Она показала гостю 49 запросов на главной, и я уже занёс руку чинить «некэшируемую» страницу. А страница кэшировалась прекрасно — это был HIT. Просто debug привязан к моему IP, а под ним XenForo не отдаёт кэш, чтобы не закэшировать заодно и отладочную панель. То есть сам акт наблюдения отключил то, за чем я наблюдал: я мерил не страницу, а страницу-под-микроскопом, а это другой объект.
Второй раз — замер времени, и тут инструмент врал трижды подряд (про это был отдельный раздел): curl снаружи мерил мой канал до сервера, localhost ловил редирект на https, curl без keepalive переустанавливал шифрование на каждой итерации. Три попытки — три разных неправильных числа, пока HAR из браузера не дал чистую серверную фазу. А когда я следом полез проверять TLS-хендшейк, та же болезнь поджидала и там — но это уже сюжет для отдельной заметки.
Складывается простой принцип. Цифра, которую выдаёт инструмент, — это цифра про связку «объект плюс инструмент», а не про объект. Ни debug-панель, ни curl не врали в техническом смысле — они честно измеряли ровно то, что измеряли. Врал я, когда принимал их число за свойство сервера.
Дешёвая защита от этого — не верить одному измерению, а сверять его с другим, добытым принципиально иначе. Серверное время у меня в итоге пришло с трёх сторон: page time из debug-панели изнутри PHP, фаза
wait из браузерного HAR и попытки curl снаружи. Когда два метода расходятся втрое — странный не объект, врёт метод, и надо искать, который из двух. Сошлись в итоге debug и HAR, а curl с его полусекундами оказался лишним — он мерил транспорт, а не сервер.Это знание обошлось мне дороже по времени, чем сами фиксы. Но оно и ценнее: аддон я починил один раз, а привычку проверять цифру фактом утащу с собой в каждую следующую задачу.
Итоги
Путь получился длинный, поэтому соберу всё в одну картину:
- Главная под участником: 88 → 15 запросов. Три устранённых N+1 (водяные знаки, Featured, обложки трендинга) плюс починка эффекта identity map.
- Гость: отдача из page cache (HIT). Он, как выяснилось, работал и так — а Ads Manager ему не мешает, потому что считает показы на клиенте.
- Preload: минус ~230 мс серверного времени на тяжёлом пути — измерено по HAR.
- Лишние счётчики и фильтры гостей/роботов скрыты, когда их активность не пишется.
И несколько выводов, которые я забираю с собой:
- Сначала измеряй, потом чини. Я дважды чуть не бросился чинить то, что работало (гостевой кэш), и трижды получил ложные цифры из-за методики замера. Факт надо проверять, а не додумывать.
- N+1 прячется в шаблонах. Самые жирные потери были не в кривых запросах, а в невинных обращениях к связям сущностей, размноженных циклом по карточкам. Ищи повторяющиеся одинаковые запросы в debug — это первый признак.
- Правь чужой код расширением, а не напильником. Class Extensions переживают обновления; правки в файлах аддона — нет.
- Инструмент измерения искажает измеряемое. Debug-панель раздувала счётчик запросов, внешний curl — время. Это нормально, надо просто про это помнить.
- Знай, где остановиться. После preload форум упёрся в разумный потолок. Дальше лежат вещи вроде Redis вместо локального SQLite (выигрыш — единицы миллисекунд) или CDN (в рунете — отдельная боль). Гнаться за абсолютным минимумом ради цифры, которую видишь только ты в debug-панели, — плохая сделка. Сайт стал быстрым; на этом я и остановился.
И последнее, ещё раз, потому что это важно: после всех замеров не забудьте выключить
$config['debug']. С живым page cache это уже не вопрос гигиены, а вопрос того, чтобы ваша внутренняя кухня не уехала в кэш на всеобщее обозрение.