История о том, как я загнал главную страницу форума с 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.phpopcache.preload_user=www-dataopcache.memory_consumption=256opcache.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=0Expires: 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 -lsudo -u www-data php cmd.php xf:job-run
Самопроверка шаблона перед заливкой. Если правил templates.xml руками — поиск пустых вызовов фразы, которые ломают импорт «неожиданным содержимым»:
grep "phrase(' ')" templates.xmlgrep "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 это уже не вопрос гигиены, а вопрос того, чтобы ваша внутренняя кухня не уехала в кэш на всеобщее обозрение.
ссылка на оригинал статьи https://habr.com/ru/articles/1050602/