Read in:
Русский

База данных разбирала свои запросы заново

После запуска кеша анонимных страниц мы снова запустили профилировщик. Рендеринг исчез с горячего пути. Треть CPU ушла в парсер SQL.

Сервер тот же: cx23, 2 vCPU, €6,49/мес на Hetzner. В профиле под нагрузкой вместо прежнего косинусного поиска стояли _yy_reduce и планировщик запросов. База данных разбирала одни и те же SQL-строки на каждый запрос и сразу выбрасывала скомпилированный результат.


Почему парсер вообще работал

trip2g использует modernc.org/sqlite — чистый Go-драйвер для SQLite. Версия, которая стояла в работе, вызывала sqlite3_prepare_v2 при каждом выполнении запроса: разобрать SQL, построить план, выполнить, выбросить скомпилированное выражение. Генерируемый слой запросов выдавал одноразовые вызовы QueryContext(sqlString, args) — скомпилированное выражение никто не держал между запросами.

Разбирать SELECT … WHERE id = ? каждый раз — одна и та же работа. Меняется только ?. Делать это на каждый запрос — чистые потери.


Исправление, которое потребовало двух шагов

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

Для промахов кеша и авторизованных читателей нужен кеш подготовленных выражений: однажды подготовить SQL, держать скомпилированное выражение, переиспользовать при каждом вызове. На словах просто. Неочевидная часть: подготовленное выражение помогает только если драйвер хранит скомпилированную форму. Старая версия драйвера не кешировала ничего. Даже переиспользование *sql.Stmt в Go приводило к повторному парсингу. Нужно было сделать два шага одновременно: обновить драйвер (до версии с кешем скомпилированных выражений на соединение) и добавить обёртку пула чтения, которая держит одно долгоживущее подготовленное выражение на строку запроса. Каждое изменение по отдельности не давало эффекта. Прыжок версии драйвера составил 16 релизов.

Одно изменение без другого — распространённая ловушка в слоёных оптимизациях. Только профилировщик надёжно показывает, сработала ли оптимизация.


Цифры

Всё измерено на cx23 (2 vCPU, €6,49/мес), нагрузка с отдельной машины через vegeta.

Анонимные читатели, попадание в кеш: пропускная способность выросла примерно с 9 000 до примерно 16 400 запросов/с, около 1,8×. Парсер SQL и планировщик исчезли из профиля. Ограничение теперь — сеть и накладные расходы HTTP-сервера.

Авторизованные читатели обходят кеш, поэтому каждый запрос проходит полную работу с базой. После кеша подготовленных выражений _yy_reduce и планировщик исчезли и из их профиля. Осталось необратимое: _sqlite3VdbeExec, обход B-дерева при выполнении запросов. До исправления поверх этого шли ещё парсинг и планирование.


Как мерили

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

Для изоляции авторизованного пути понадобился небольшой приём. Авторизованные читатели обходят кеш — именно то, что нужно было измерить: полная работа с базой без помех от попаданий в кеш. Чтобы гнать авторизованную нагрузку, мы создали короткоживущий hot-auth-token, обменяли его на сессионную cookie и запустили vegeta через эту сессию. Профиль показывает путь запросов напрямую.

Обновление драйвера требовало отдельной проверки. Прыжок на 16 релизов затрагивает достаточно внутреннего поведения, чтобы юнит-тесты не давали уверенности. Прогнали полный end-to-end suite на реальном мультиконтейнерном стеке, а не только в изоляции.


Что осталось

У авторизованных читателей есть два накладных расхода, которых нет у анонимных. Каждый ответ сжимается gzip заново — кеша для них пока нет. И каждый просмотр страницы запускает несколько служебных запросов для записи визита.

Следующие шаги: распространить кеш страниц на авторизованных читателей в default-темплейте (сервер отдаёт один и тот же HTML для анонимов и авторизованных; разница отрисовывается в браузере клиентским скриптом) и сократить служебные запросы на просмотр.

Профилировщик продолжает указывать на следующее. Так это и работает.


К чему это приводит

16 400 запросов/с на боксе за €6,49/мес: анонимные страницы из кеша теперь отдаются с пропускной способностью, характерной для статических генераторов сайтов. Заранее собранный HTML, ничего вычислять на запрос. Классические SSG (Hugo, Jekyll, Astro на GitHub Pages) добиваются этого за счёт полной пересборки сайта при каждой публикации. Изменение одной заметки — полный rebuild, иногда несколько минут CI до появления правки онлайн.

trip2g по-прежнему динамический сервер. Кеш делает его поведение на чтение статическим. При изменении заметки инвалидируется только её запись в кеше. Всё остальное остаётся тёплым. Обновлённая заметка появляется онлайн за долю секунды.

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