Read in:
Русский

Перемерили через четыре месяца: что держит, а что поехало

О чём это: в июне мы опубликовали цифры производительности. За четыре месяца в trip2g появились note queries, федерация, MCP на официальном SDK и десяток мелочей на пути рендера. Мы перемерили всё заново и включили профилировщик на каждом эндпоинте. Здесь — что подтвердилось, что сломалось и что уже починено.

Опубликованная цифра остаётся правдой ровно для того пути, который мерили. Всё вокруг него за это время сдвинулось.

Как мерили

Три независимых прогона, каждый в своей облачной песочнице на 4 vCPU, в прод-режиме (DEV=false):

  1. Стресс по типам запросов на собственной документации (908 заметок): страницы, статика, поиск, GraphQL, MCP, sitemap, чтение во время записи.
  2. Note queries и все MCP-инструменты, как их вызывает агент, плюс 2 000 синтетических заметок с разным frontmatter.
  3. Отдача страниц и синхронизация на 10 000 заметок, A/B: июньский коммит 8552a649 против текущего main на одной и той же машине.

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

Что держит

Кеш анонимных страниц. Около 8 500 запросов в секунду при 54 % CPU сервера — в потолок упирается генератор, а не trip2g. Это та же цифра, что в июне.

Маленькие страницы и статика. Маленькая страница — около 47 000 rps при p99 19 мс на 200 одновременных клиентах, и это тоже потолок генератора. Без заголовка Accept-Encoding: gzip тот же клиент распаковывал каждый ответ сам и упирался в 7 500 rps — ещё одна ловушка измерения, теперь четвёртая.

MCP-инструменты отвечают быстро и корректно, авторизация по ролям работает. Запись через синхронизацию чтение не блокирует на маленьком хранилище: пропускная способность падает на 25–30 %, ошибок нет.

Что оказалось не тем, чем казалось

Синхронизация на большом хранилище. Одна изменённая заметка в хранилище на 10 000 реалистичных заметок синхронизировалась около 3–5 секунд, память после загрузки — 5–7 ГБ. В июньской статье — десятки миллисекунд и 1 ГБ. Первая версия этого текста называла это регрессией. Это не она.

Мы перемерили июньский коммит и октябрьский main одним и тем же способом. На методике из июньской статьи — синтетических заметках по 500 байт — октябрьский код повторяет опубликованные цифры. На реалистичных заметках (в среднем ~9 КБ, со ссылками, встраиваниями, таблицами) июньский код ровно так же медленный: 4,7 секунды на одну заметку против 4,8 в октябре, 7,4 ГБ против 7,6. Опубликованная цифра была честной для синтетики и ничего не говорила о настоящем хранилище.

Причина медленности — модель перезагрузки: каждая синхронизация заново рендерит все заметки. Кеш заметок экономит разбор, но не рендер. Поэтому цена одной заметки растёт с объёмом всего хранилища. Это не дефект, который можно починить одной строкой, а архитектурное свойство, и страница производительности теперь говорит о нём прямо.

Настоящая регрессия нашлась одна. С августа хранилище, где хотя бы у одной заметки есть route: на кастомный домен, на каждой перезагрузке рендерило каждую заметку ещё раз на каждый такой хост. В документации trip2g такая заметка есть — демо мультидоменности. Рендер под хосты занимал 57 % CPU перезагрузки, а его HTML — в 2,7 раза больше памяти, чем основной. Исправление: под хост заранее рендерятся только заметки с собственным маршрутом на нём, остальные — при первом запросе на этот хост, один раз на снимок.

Чтение во время синхронизации. В одном из прогонов на 200 запросах в секунду, пока синхронизировались 100 заметок, медианная задержка чтения выросла до 2–6 секунд против 2 мс на июньском коде. Часть разницы объясняется рендером под хосты: июньский прогон шёл на документации без мультидоменного демо. Остальное — общее для обеих версий: каждая перезагрузка сбрасывает кеш страниц, и читатели разом идут в полный рендер, который конкурирует за CPU с самой перезагрузкой.

Полный рендер подорожал вдвое. Главная причина нашлась в профиле с первого взгляда: на каждый запрос шаблон обходил все заметки, чтобы найти RSS-ленты для <link rel="alternate">. Это 42 % всех аллокаций на пути рендера. Список лент меняется только при загрузке заметок, теперь он там и считается: 0,9 мс на рендер → 2 наносекунды.

Где тратилось время зря

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

tools/list в MCP. Статичный ответ на 15 КБ — около 250 rps на четырёх ядрах. На каждый запрос строился сервер MCP SDK со всем каталогом инструментов, SDK дважды разбирал JSON-схему каждого инструмента и потом ещё раз медленно её маршалил, а мы после этого дважды разбирали готовый ответ, чтобы убедиться, что в нём нет ошибки. Исправление: каталог, который видит вызывающий, собирается в сервер один раз и переиспользуется. 1,15 мс → 0,33 мс на запрос, аллокаций в 12 раз меньше, ответ на проводе не изменился ни на байт.

Sitemap. 88 % CPU этого эндпоинта уходило на gzip одних и тех же байт. Sitemap и так генерируется при загрузке заметок. Исправление: там же его и сжимать, а на запрос отдавать готовые байты.

Лог. Заметка с лентой ссылалась на лейаут rss, которого не было в хранилище документации. На каждый запрос к ленте в лог писалось предупреждение со списком всех лейаутов — 1,1 млн строк и 560 МБ за один прогон. А сама лента отдавала пустой ответ со статусом 200. Исправление: предупреждение один раз при загрузке, у самой заметки, и никакого пустого 200.

Поиск. 100–700 rps на четырёх ядрах, и около 70 % CPU — подсветка совпадений. Для каждого из 20 результатов bleve заново достаёт из индекса документ целиком, а тело заметки хранится в индексе дважды. Больше половины CPU в итоге уходит на сборщик мусора. Позиции совпадений bleve и так возвращает, поэтому подсветку мы теперь строим сами по этим позициям и отдаём не HTML с <mark>, а структурой: отрезки текста с пометкой «совпадение». Фронтенду больше не нужно выковыривать <mark> регуляркой, тела заметок из индекса ушли, поиск стал примерно вдвое быстрее.

Note queries. Каждый вызов заново сериализовал в JSON все доступные читателю заметки — 20 % CPU запроса, ещё 66 % — сам Jsonnet. Исправление: держать сериализованные заметки до следующей загрузки, отдельно для каждого класса доступа.

Что было по-настоящему опасно

Один note query мог уронить весь процесс. Программа на Jsonnet с хвостовой рекурсией, глубину которой задаёт параметр посетителя, обходила ограничение глубины стека интерпретатора, и Go падал с переполнением стека — не паникой, которую можно перехватить, а фатальной ошибкой. Запрос для этого должен уже лежать в хранилище, но заметки у нас пишут и агенты. Обычная рекурсия ловилась лимитом и возвращала ошибку, а эта — нет.

Тяжёлый гостевой GraphQL-запрос — пять поисков с полным HTML каждого результата — отдавал 1,5 МБ и держал 43 rps. Лимит сложности его не видел: поиск стоил столько же, сколько любое другое поле. Исправление: поиск стоит 10, а документ в результате поиска — ещё 5.

Статус на 20 октября

Вошло в main: tools/list в MCP, sitemap, лента и цена поиска в GraphQL, обход лент при загрузке, своя подсветка поиска сегментами, защита от падения на note query, кеш JSON заметок для note queries. В pull request: ленивый рендер под кастомные домены.

Когда всё войдёт в релиз, перемерим ещё раз.

Что из этого следует

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

«Один раз при загрузке» — главный приём этого движка. trip2g быстр, потому что рендерит при записи, а не при чтении. Почти все находки этого прогона — места, где про это забыли: ленты, sitemap, каталог MCP, JSON заметок для запросов. Каждое по отдельности выглядело безобидно.

Профиль важнее бенчмарка. Бенчмарк говорит, что медленно. Профиль говорит, что именно делается зря, — и чаще всего это одна строчка, которую можно перенести на этап загрузки.