История о том, как я загнал главную страницу форума с 88 запросов до 15, выяснил, что половину работы делал впустую один невинный аддон, и в конце снял ещё четверть серверного времени строчкой в конфиге — не сломав при этом ничего из того, что работало. А заодно — полная документация на стек из четырёх своих расширений и preload, на которых форум сейчас и держится.

Содержание

Повод заглянуть под капот

Форум у меня работает быстро, и поводов лезть внутрь вроде бы не было. Но «быстро» — это ощущение, а я хотел цифру. У XenForo есть встроенная debug-панель: добавляешь в config.php флаг — и внизу каждой страницы появляется сводка по времени, памяти и, главное, полный список SQL-запросов с таймингами и EXPLAIN. Включил, привязав к своему IP, открыл несколько типовых страниц и стал смотреть.

⚠️ Сразу важное. Debug, привязанный к IP, кажется безобидным — но если у вас работает гостевой page cache (а к концу этой статьи он будет работать), есть тонкий момент. Ядро сохраняет в кэш то, что отрендерило. Если страницу для кэша сгенерирует ваш заход с debug — панель со всеми SQL-запросами и путями сервера ляжет в кэш и будет отдаваться всем подряд несколько минут. Поэтому правило железное: померили — выключили $config['debug']['enabled'], и только потом тестируем кэш.

Тайминги по страницам оказались разные. Статьи и темы — отличные, 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 на запрос, так что это безопасно):

  1. Memo по attachment_id. Повторные обращения к тому же вложению берут результат из памяти, в базу не ходят. Это сразу схлопывает те самые ×3 и дубли между виджетами.

  2. Короткое замыкание по COUNT. Если в таблице постоянных водяных знаков нет ни одной строки — а у меня их ноль, постоянный режим не используется, — то точечный запрос на каждое вложение заведомо вернёт пустоту. Вместо этого делаю один COUNT(*) на весь запрос: таблица пуста — отвечаем null без похода в базу.

Записи (создание и удаление водяного знака) сбрасывают кэш, поэтому в рамках запроса данные всегда согласованы. И решение не разваливается на больших объёмах: кэш ограничен числом вложений на странице, а не размером таблицы.

Эффект: вместо примерно сорока запросов к водяным знакам — ноль точечных, ценой одного COUNT. Это убрало почти половину нагрузки главной разом.

💡 На заметку. Прежде чем переопределять чужой метод расширением, полезно свериться, как именно фреймворк резолвит этот класс — проходит ли он вообще через систему расширений. В XenForo репозитории создаются через Manager::getRepository(), а тот прогоняет имя класса через extendClass(). Значит, репозиторий аддона расширяется штатно, как и любой свой.

Гостевой page cache: он уже работает

Разобравшись с пользователем, я вернулся к гостю и его 49 запросам. Гипотеза была такая: реклама на сайте крутится через Siropu Ads Manager, и она помечает страницы некэшируемыми — оттого page cache гостям и не отдаётся.

Прежде чем чинить, я решил проверить, а так ли это вообще. Самый чистый способ — посмотреть, ставит ли ядро заголовок X-XF-Cache-Status при отдаче из кэша. Два запроса подряд без кук:

curl -s -D- -o /dev/null https://example.com/ | grep -i 'x-xf-cache\|set-cookie'
curl -s -D- -o /dev/null https://example.com/ | grep -i 'x-xf-cache\|set-cookie'

И тут меня ждал сюрприз: второй запрос вернул X-XF-Cache-Status: HIT. То есть гостевой page cache уже работал. Гость получает готовый HTML из кэша, и те «49 запросов», что я видел в debug, — это был артефакт самого измерения: debug-панель привязана к моему IP, а под ней страница каждый раз генерируется заново, мимо кэша. Реальный гость с улицы видит страницу из памяти и одну-две записи активности, не больше.

Работает этот кэш не сам по себе — его обеспечивает один из моих аддонов, SG SQLite Cache, который держит готовый HTML гостевых страниц в оперативной памяти. Подробно про него — в разделе про стек ниже; пока достаточно знать, что HIT берётся именно оттуда.

Это важный урок: инструмент измерения может искажать измеряемое. Я чуть не бросился чинить то, что и так работало, — спасла привычка сначала проверить факт, а потом действовать.

Реклама оказалась не врагом, а союзником

Раз 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 что-то переименует.

💡 На заметку. Identity map — палка о двух концах. Она экономит запросы, переиспользуя сущности, но если первый, кто загрузил сущность, поскупился на связи — все последующие потребители унаследуют его скупость. Поэтому грузить связи выгоднее всего там, где сущность попадает в работу первой.

После этих двух фиксов свежий 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(). Сам скрипт разберу ниже отдельным разделом, а пока — конфиг:

opcache.preload=/usr/www/example/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 мс, без переспроса.

💡 На заметку. Разница между public и private в Cache-Control — это ровно граница между «ускорил» и «открыл приватные данные». public разрешает кэшировать кому угодно по пути, включая прокси и CDN; private — только конечному браузеру. На любом маршруте, который проверяет права доступа, public обходит эту проверку для всех, кто получит файл из общего кэша. Если сомневаетесь — private.

Масштаб, как и с предыдущими мелочами, честный: выигрыш косметический. Картинки и так лежали в кэше браузера и показывались мгновенно — я убрал лишь восемь фоновых переспросов на повторных заходах да снял с PHP столько же ненужных пробуждений. Скорость, которую видит посетитель, не изменилась. Но поправить стоило — хотя бы потому, что причина была не «так задумано», а «забыли».

Весь стек разом: что в итоге работает

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

  • SG Boost — срезает лишние запросы к базе: устранённые N+1 водяных знаков и AMS, плюс опции против холостых счётчиков активности и просмотров.

  • SG SQLite Cache — держит кэш в оперативке: реестр, сессии, скомпилированный CSS и гостевой page cache в /dev/shm. Это он отдаёт гостю готовый HTML — тот самый HIT.

  • SG Sphinx Search — уносит поиск с MySQL на отдельный демон Manticore, разгружая основную базу.

  • SG Root URLs — чистые адреса контента прямо от корня сайта, без префиксов секций.

  • OPcache preload — компилирует ядро движка в общую память при старте PHP-FPM.

Чтобы было видно, как слои складываются, проследим два запроса. Гость заходит на главную: SQLite Cache отдаёт готовый HTML из оперативки — MySQL не трогается вовсе, рендера нет, поиск не при делах. Участник открывает ту же главную: page cache ему не положен (страница персональная), поэтому в дело вступают остальные — Boost уже срезал N+1 в запросах, preload убрал компиляцию ядра из горячего пути, а если участник пойдёт искать — поиск уйдёт на Manticore, не нагружая базу, которая в этот момент отдаёт ему страницу. Дальше — каждый компонент подробно.

SG Boost: из чего собрался аддон

Boost — мой служебный аддон, куда я складываю расширения, оптимизирующие ядро и чужие аддоны. К версии 1.0.4 в нём собралось два вида содержимого: опции (то, чем можно управлять, всё по умолчанию выключено — чтобы аддон не менял поведение форума без спроса) и безусловные расширения (работают всегда, чинят N+1, про которые шла речь выше).

Опции (по умолчанию выключены):

  • sgBoostSkipGuestActivity — не записывать активность гостей и роботов в xf_session_activity. Включает заодно автоскрытие их счётчиков и фильтров (см. раздел про счётчики выше).

  • sgBoostMemberActivityInterval — троттлинг записи активности участников: не чаще раза в N секунд на пользователя, вместо записи на каждый клик.

  • sgBoostDisableThreadViewLog — не писать инкремент счётчика просмотров темы (INSERT на каждое открытие темы каждым участником).

  • sgBoostDisableAttachmentViewLog — то же для счётчика просмотров вложений.

Безусловные расширения (восемь классов):

  • Spolzer\Watermark\Repository\WatermarkRepository — memo по attachment_id плюс короткое замыкание по COUNT при пустой таблице; флаг «таблица пуста» вынесен в межзапросный кэш с инвалидацией на запись.

  • Spolzer\Watermark\Pub\View\Watermark\Image — замена no-cache на private, max-age на отдаче миниатюр (раздел про 304).

  • XenAddons\AMS\Entity\ArticleItem и ArticleFeature — короткое замыкание связи Featured при пустой таблице избранного, с инвалидацией.

  • XenAddons\AMS\TrendingContent\ArticleHandler — догрузка обложек и Featured тремя JOIN в выборке трендинга (устранение identity-map-эффекта).

  • XF\Repository\SessionActivityRepository, ThreadRepository, AttachmentRepository — точки, через которые реализованы перечисленные опции.

Разделение на «опции выключены, расширения работают» осознанное: исправления N+1 — это чистая победа без побочек, их можно включать всем и всегда. А вот отключение счётчиков просмотров или троттлинг активности — это уже редакторское решение (нужна тебе метрика просмотров или нет), поэтому оно за галочкой.

SG SQLite Cache: кэш в оперативной памяти

Это тот аддон, благодаря которому гостевой page cache из раздела выше вообще существует. XenForo умеет складывать в кэш-провайдер реестр (настройки, маршруты, права — читается на каждом запросе), сессии, скомпилированный CSS и готовый HTML гостевых страниц. Вопрос — куда складывать. Штатно это либо файлы на диске (медленно), либо Redis/Memcached (отдельный демон, который надо ставить и держать). Я сделал третий вариант: кэш-провайдер на SQLite, а файл базы лежит в /dev/shm — это tmpfs, файловая система в оперативной памяти. Получается «SQLite в RAM»: скорость памяти, транзакционность SQLite, и никакого отдельного демона.

Схема перенесена с проверенной реализации старого проекта: таблица cache_name PK / cache_expire_time / cache_value, запись через REPLACE INTO, и набор PRAGMA, который и делает всю скорость:

PRAGMA journal_mode = WAL;          -- конкурентный доступ воркеров без блокировок
PRAGMA synchronous = NORMAL;       -- в tmpfs fsync не нужен
PRAGMA busy_timeout = 5000;        -- ждать блокировку, а не падать
PRAGMA mmap_size = 256M;
PRAGMA temp_store = MEMORY;

Плюс retry при SQLITE_BUSY/LOCKED, ленивое создание таблицы и сборка мусора просроченных записей (вероятностная на записи + почасовой cron). Подключается всё в config.php — отдельным контекстом на каждый вид данных:

$config['cache']['enabled']  = true;
$config['cache']['provider'] = 'SG\\SqliteCache\\Adapter';
$config['cache']['config']   = ['name' => 'global'];   // реестр

// сессии в RAM вместо xf_session в MySQL
$config['cache']['context']['sessions'] = [
    'provider' => 'SG\\SqliteCache\\Adapter',
    'config'   => ['name' => 'sessions'],
];
// скомпилированный CSS
$config['cache']['context']['css'] = [
    'provider' => 'SG\\SqliteCache\\Adapter',
    'config'   => ['name' => 'css'],
];
// полностраничный кэш для гостей — тот самый HIT
$config['cache']['context']['page'] = [
    'provider' => 'SG\\SqliteCache\\Adapter',
    'config'   => ['name' => 'page'],
];
$config['pageCache']['enabled']  = true;
$config['pageCache']['lifetime'] = 300;

Каждый контекст — свой файл в /dev/shm/xf-cache/ (global.sqlite, sessions.sqlite и т.д.), создаётся автоматически. Зачем SQLite, а не просто файлы в tmpfs: один файл вместо тысяч мелких, WAL для конкурентного доступа десятков воркеров, и транзакции вместо гонок на запись.

⚠️ Важный нюанс. /dev/shm живёт в оперативке и очищается при перезагрузке сервера. Это не баг, а природа кэша: после ребута реестр и CSS пересоберутся при первом обращении, сессии пропадут (пользователи переавторизуются по cookie «запомнить меня»), page cache прогреется сам трафиком. Откат тоже бесплатный — закомментировал блок в config.php, и XenForo вернулся к работе без кэша, аддон можно даже не удалять. Контроль занятой памяти — ls -lh /dev/shm/xf-cache/: реестр и CSS это единицы мегабайт, page cache зависит от трафика и lifetime.

SG Sphinx Search: поиск мимо MySQL

Штатный поиск XenForo работает по таблице xf_search_index через MySQL fulltext. На большом форуме это тяжело: поиск конкурирует за ту же базу, что отдаёт страницы, и релевантность у MySQL fulltext посредственная. Я вынес поиск на отдельный демон по протоколу SphinxQL (это MySQL-протокол на порту 9306) — поддерживаются Manticore Search 25+ (рекомендую: открытый форк команды Sphinx, есть APT-репозиторий) и Sphinx 3.x для тех, кому нужен именно он.

Ключевое в реализации — документы пушатся демону в реальном времени при сохранении контента. Никаких indexer, дельта-индексов и cron-переиндексации: сохранил пост — он уже в индексе. Метаданные поисковых хендлеров кодируются токенами md<key>_<value> в поле metadata — это байт-в-байт схема штатного MySqlFt, поэтому поисковые хендлеры любых аддонов (статьи и комментарии AMS, тикеты, теги) работают без адаптации.

RT-таблица создаётся в Manticore автоматически при первой записи, с настройками под русско-английский форум: морфология stem_enru, index_exact_words, min_infix_len=2 для wildcard и автодополнения. И один параметр, который стоил мне отладки и которым стоит поделиться отдельно:

💡 Грабля. charset_table = 'non_cjk,U+005F'. По умолчанию Manticore не считает подчёркивание частью слова — оно для него разделитель. А на IT-форуме подчёркивание повсюду: имена функций mysqlquery, переменные innodb_buffer_pool_size, опции конфигов. Без этого фикса поиск по php_fpm разваливал запрос на «php» и «fpm» по отдельности. U+005F — это и есть код подчёркивания, добавленный в таблицу символов как буквенный.

Аддон спроектирован так, чтобы быть безопасным: пустой хост демона в настройках = поиск работает штатно через MySQL (это и выключатель, и страховка). Демон недоступен при поиске — ошибка в журнал, поиск возвращает пусто, форум не падает. Демон недоступен при сохранении — контент сохраняется как обычно, проблема индексации только логируется. После успешной перестройки индекса старую таблицу MySQL можно опустошить — TRUNCATE TABLE xf_search_index — и освободить место.

SG Root URLs: адреса от корня сайта

Этот компонент — единственный в стеке не про скорость, а про вид адресов, но раз документирую — пусть будет полным. XenForo строит URL с префиксами секций: /threads/slug.123/, /forums/slug.45/, /ams/articles/slug.67/. Я убираю префиксы — контент живёт по адресам прямо от корня: /slug.123/.

У задачи две стороны, и обе закрыты расширением публичного роутера:

  • Исходящие ссылки (построение URL): билдер маршрутов отдаёт адреса без префикса для тем, форумов, статей и категорий AMS. Префикс может быть и составным (например ams/categories) — это учтено.

  • Входящие запросы (роутинг): slug.id от корня распознаётся по базе — что это, статья, категория AMS, тема или форум, — и передаётся штатному маршруту. Контроллеры XenForo сами делают 301 на канонический адрес, поэтому старые ссылки с префиксами не ломаются.

Логика включается только для публичного роутера (через событие router_public_setup) — админка, API и установщик не затрагиваются. Коллизии разрешаются по id-части slug.id: id уникален в пределах типа контента, а порядок проверки типов задан явно. Внутри роутера стоят кэши разрешённых путей в рамках запроса, чтобы повторный роутинг (например, retry со слешем на конце) не бил в базу второй раз.

Скрипт sg-preload.php: разбор

Выше я описал preload концептуально, тут — сам скрипт. Подход — opcache_compile_file(), а не require: компиляция не исполняет код и не требует разрешённых зависимостей на старте, классы линкуются при первом использовании, но уже из памяти.

<?php
$root = '/usr/www/example/httpdocs';
$dirs = [
    $root . '/src/XF',                       // ядро движка
    $root . '/src/vendor/composer',
    $root . '/src/vendor/symfony/cache',
    $root . '/src/vendor/guzzlehttp',
    $root . '/src/vendor/league',            // flysystem — файловая абстракция XF
    // ... остальной горячий vendor
];

foreach ($dirs as $dir) {
    if (!is_dir($dir)) continue;
    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
    );
    foreach ($it as $file) {
        if ($file->getExtension() !== 'php') continue;
        // тесты и примеры наследуют PHPUnit, которого на проде нет
        if (preg_match('~/(tests?|examples?)/~i', $file->getPathname())) continue;
        @opcache_compile_file($file->getPathname());
    }
}

Три вещи, которые я понял на граблях, пока его доводил:

  1. Каталоги tests/examples надо исключать. Их классы наследуют PHPUnit\Framework\TestCase, которого на проде нет, — preload завалит журнал FPM предупреждениями «Can’t preload unlinked class». На работу не влияет, но мусорит.

  2. Каталог league/flysystem надо включать. XF гоняет через него всю файловую абстракцию (data://, internal-data://), это горячий код.

  3. Предупреждения «Can’t preload unlinked class» по адаптерам symfony/cache — это норма. Те классы ссылаются на PHP-расширения (Memcached, Couchbase), которых нет; они скомпилированы, но линкуются лениво и фактически не используются никогда.

Проверить, что ядро действительно в общей памяти, надо изнутри FPM (CLI показывает свой отдельный opcache) — см. команды в следующем разделе.

Инструменты, которыми я мерил

За всю эту работу набрался инструментарий проверок — собираю его здесь как готовый справочник. Все команды реальные, ими я и пользовался.

Работает ли гостевой page cache. Два запроса без кук подряд — на втором должен быть HIT:

curl -s -D- -o /dev/null https://example/ | grep -i 'x-xf-cache\|set-cookie'

Если на первом ответе видно set-cookie с сессией — что-то пишет в сессию каждому гостю и ломает кэш; ищи причину.

Серверное время (TTFB), медиана из 20 замеров. Снаружи — но помни, что в это число входят сеть и TLS:

for i in $(seq 20); do
  curl -s -o /dev/null -w '%{time_starttransfer}\n' https://example.com/
done | sort -n | sed -n '10p'

Изнутри сервера, без своего канала, но с TLS — через --resolve на петлю (с прогревом из трёх запросов перед циклом):

curl -s -o /dev/null -w '%{time_starttransfer}\n' \
  --resolve example.com:443:127.0.0.1 https://example.com/

Активен ли preload и что в нём. Проверка флага в работающем FPM и статистика общей памяти (через временный скрипт с opcache_get_status(), запрошенный по HTTP, чтобы попасть в процесс FPM, а не CLI):

php-fpm8.5 -i | grep 'opcache.preload\b'
# во временном opstat.php:  var_export(opcache_get_status(false)['preload_statistics']);

В preload_statistics должны быть сотни классов и функций — это и есть предзагруженное ядро. Скрипт после проверки сразу удалить.

Состояние TLS-сертификата и хендшейка. Тип ключа, протокол, обмен ключами:

echo | openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null \
  | grep -iE 'Protocol|Cipher|Server Temp Key'

Здоровый современный набор — ECDSA-ключ, X25519, TLSv1.3. Чистое время одного хендшейка (без накладных curl):

time (echo | openssl s_client -connect 127.0.0.1:443 -servername example.com >/dev/null 2>&1)

Прогрев кэша миниатюр водяного знака. Сколько файлов в кэше и принудительный прогон фоновых задач, чтобы первый дорогой рендер не достался живому посетителю:

find internal_data/watermark/cache -type f | wc -l
sudo -u www-data php cmd.php xf:job-run

Самопроверка шаблона перед заливкой. Если правил templates.xml руками — поиск пустых вызовов фразы, которые ломают импорт «неожиданным содержимым»:

grep "phrase(' ')" templates.xml
grep "phrase('')" templates.xml

💡 Общий принцип. Меряй изнутри. Почти все мои ошибки в замерах (про них — следующие два раздела) сводились к тому, что инструмент снаружи прихватывал то, что я мерить не собирался: сеть, TLS, редиректы. Чем ближе точка замера к самому процессу — петля вместо интернета, FPM вместо CLI, фаза wait в браузерном HAR вместо общего времени — тем честнее цифра.

Как правильно замерять (и как я трижды ошибся)

Я хотел честную цифру выигрыша от 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, а про методику: убирайте из измерения всё, что не измеряете. Сеть, TLS, редиректы — каждый из них умеет подмешать сотни миллисекунд и увести вывод в сторону. HAR из браузера с его раздельными фазами wait и receive оказался честнее любого curl.

Чему меня научили ложные цифры

Если из всей этой истории выкинуть аддоны, запросы и 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) — готовый HTML из оперативки, а Ads Manager ему не мешает, потому что считает показы на клиенте.

  • Preload: минус ~230 мс серверного времени на тяжёлом пути — измерено по HAR.

  • Лишние счётчики и фильтры гостей/роботов скрыты, когда их активность не пишется.

А вот стек, на котором форум держится постоянно, — четыре своих аддона и preload, каждый по своему слою:

  • SG Boost — срез N+1 и опции против холостых счётчиков.

  • SG SQLite Cache — реестр, сессии, CSS и гостевой page cache в /dev/shm, без Redis/Memcached.

  • SG Sphinx Search — поиск на Manticore мимо MySQL, документы в индекс в реальном времени.

  • SG Root URLs — чистые адреса контента от корня сайта.

  • OPcache preload — ядро движка в общей памяти.

И несколько выводов, которые я забираю с собой:

  1. Сначала измеряй, потом чини. Я дважды чуть не бросился чинить то, что работало (гостевой кэш), и трижды получил ложные цифры из-за методики замера. Факт надо проверять, а не додумывать.

  2. N+1 прячется в шаблонах. Самые жирные потери были не в кривых запросах, а в невинных обращениях к связям сущностей, размноженных циклом по карточкам. Ищи повторяющиеся одинаковые запросы в debug — это первый признак.

  3. Правь чужой код расширением, а не напильником. Class Extensions переживают обновления; правки в файлах аддона — нет.

  4. Инструмент измерения искажает измеряемое. Debug-панель раздувала счётчик запросов, внешний curl — время. Это нормально, надо просто про это помнить.

  5. Знай, где остановиться. После preload форум упёрся в разумный потолок. Дальше лежат вещи вроде Redis вместо локального SQLite (выигрыш — единицы миллисекунд) или CDN (в рунете — отдельная боль). Гнаться за абсолютным минимумом ради цифры, которую видишь только ты в debug-панели, — плохая сделка. Сайт стал быстрым; на этом я и остановился.

И последнее, ещё раз, потому что это важно: после всех замеров не забудьте выключить $config['debug']. С живым page cache это уже не вопрос гигиены, а вопрос того, чтобы ваша внутренняя кухня не уехала в кэш на всеобщее обозрение.