История о том, как я загнал главную страницу форума с 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 на запрос, так что это безопасно):
Memo по
attachment_id. Повторные обращения к тому же вложению берут результат из памяти, в базу не ходят. Это сразу схлопывает те самые ×3 и дубли между виджетами.Короткое замыкание по 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()); } }
Три вещи, которые я понял на граблях, пока его доводил:
Каталоги
tests/examplesнадо исключать. Их классы наследуютPHPUnit\Framework\TestCase, которого на проде нет, — preload завалит журнал FPM предупреждениями «Can’t preload unlinked class». На работу не влияет, но мусорит.Каталог
league/flysystemнадо включать. XF гоняет через него всю файловую абстракцию (data://,internal-data://), это горячий код.Предупреждения «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 — ядро движка в общей памяти.
И несколько выводов, которые я забираю с собой:
Сначала измеряй, потом чини. Я дважды чуть не бросился чинить то, что работало (гостевой кэш), и трижды получил ложные цифры из-за методики замера. Факт надо проверять, а не додумывать.
N+1 прячется в шаблонах. Самые жирные потери были не в кривых запросах, а в невинных обращениях к связям сущностей, размноженных циклом по карточкам. Ищи повторяющиеся одинаковые запросы в debug — это первый признак.
Правь чужой код расширением, а не напильником. Class Extensions переживают обновления; правки в файлах аддона — нет.
Инструмент измерения искажает измеряемое. Debug-панель раздувала счётчик запросов, внешний curl — время. Это нормально, надо просто про это помнить.
Знай, где остановиться. После preload форум упёрся в разумный потолок. Дальше лежат вещи вроде Redis вместо локального SQLite (выигрыш — единицы миллисекунд) или CDN (в рунете — отдельная боль). Гнаться за абсолютным минимумом ради цифры, которую видишь только ты в debug-панели, — плохая сделка. Сайт стал быстрым; на этом я и остановился.
И последнее, ещё раз, потому что это важно: после всех замеров не забудьте выключить $config['debug']. С живым page cache это уже не вопрос гигиены, а вопрос того, чтобы ваша внутренняя кухня не уехала в кэш на всеобщее обозрение.
