Read in:
Русский

Производительность

Коротко

  • Синхронизация (повседневная запись): одна изменённая заметка пушится за десятки миллисекунд даже на хранилище в 10 000 заметок; первый полный импорт 10 000 заметок — ~14 с на одном ядре.
  • Отдача (горячий путь чтения): одна небольшая нода (Hetzner cpx32, 4 vCPU) отдаёт ~4 000 реальных страниц документации в секунду со 100 % успеха и p99 ~30 ms.
  • Память: ~80 МБ на 100 заметок, ~1 ГБ на 10 000.
  • Кеш анонимных страниц: бюджетный сервер за €6,49/мес (Hetzner cx23, 2 vCPU) держит ~8 500 зап/с gzip-страниц 10 минут подряд: 0 ошибок, 0 % CPU steal, ≈ €0,0003 за миллион запросов.

Получить честную цифру отдачи оказалось на удивление сложно — три ловушки замера, каждая стоит примерно 2×:

  1. Dev-режим пересчитывает хэш каждого ассета на каждый запрос (~2× медленнее) → мерять на DEV=false.
  2. Генератор нагрузки на той же машине ворует CPU у сервера (~2× медленнее) → запускать с отдельной машины.
  3. Холодный кеш / один URL льстит результату → бить по многим реальным страницам с прогретым кешем.

Наш наивный первый замер был ~4× пессимистичен. Цифры ниже — после учёта всех трёх. Дальше — детали и графики.


Насколько быстра синхронизация хранилища и до какого размера оно может вырасти, прежде чем вы это почувствуете? Мы измерили мутацию pushNotes — именно её вызывает плагин Obsidian и CLI при каждой синхронизации — на хранилищах от 10 до 10 000 заметок.

Графики ниже — живые блоки datachart, которые читают CSV с сырыми результатами прямо из этого хранилища. Та же функция доступна и вам для ваших данных.

Методика

  • Каждый прогон — свежий сервер с пустой базой SQLite, ограниченный 1 или 4 ядрами CPU (taskset + GOMAXPROCS).
  • Синтетические заметки ~500 байт: frontmatter, заголовки, wikilink, список, блок кода.
  • Первая загрузка — все N заметок одним вызовом pushNotes (одна транзакция записи).
  • 1 заметка — типичная синхронизация: одна изменённая заметка в хранилище из N заметок (медиана из 3).
  • 10% хранилища — пакетная синхронизация: N/10 изменённых заметок одним вызовом (медиана из 3).
  • Память — RSS процесса сервера после прогона; пик — из /proc.
  • Скрипт бенчмарка: scripts/bench-pushnotes.sh в репозитории.

Важный контекст: пока сервер обрабатывает push, он держит блокировку записи базы — остальные писатели (фоновые задачи, другие push'и) ждут в очереди. Поэтому «время push» здесь — это ещё и «сколько база заблокирована».

Первая загрузка: всё хранилище одним вызовом

Рост почти линейный: ~1,2 с на 1 000 заметок на одном ядре, ~14 с на 10 000. Четыре ядра сокращают время примерно вдвое: рендеринг заметок параллелится, запись в базу — нет.

Инкрементальная синхронизация: повседневный случай

Это то, что вы чувствуете каждый день: отредактировали заметку — плагин её отправил. Даже при 10 000 заметок push одной заметки занимает ~0,4 с на одном ядре и ~0,2 с на четырёх. Рендерер кеширует неизменённые заметки, поэтому стоимость растёт с размером хранилища (переиндексация), а не с объёмом изменений: push 1 000 изменённых заметок (10% хранилища в 10 000) занимает ~2,3 с на одном ядре — далеко не 1 000 × стоимость одной заметки.

Память

Сервер держит отрендеренные заметки в памяти: ~80 МБ на сотню заметок, ~180 МБ на тысячу, ~1 ГБ на десять тысяч. Учитывайте это при выборе хостинга — VPS с 1 ГБ памяти спокойно обслуживает хранилища до нескольких тысяч заметок.

Память с векторным поиском

При включённом векторном поиске (семантическое сходство, на основе модели эмбеддингов — например, bge-m3) сервер строит in-memory индекс поверх кеша заметок. Индекс хранит один вектор на заметку: bge-m3 даёт 1024-мерные float32-векторы, то есть каждая заметка занимает 4 КБ.

Хранилище RSS без вектора (1я / 4я) RSS с вектором (1я / 4я) Overhead
10 67 / 68 МБ 70 / 69 МБ ~2 МБ
100 82 / 79 МБ 84 / 84 МБ ~5 МБ
1 000 176 / 136 МБ 205 / 179 МБ +29–43 МБ
10 000 1050 / 837 МБ 964 / 875 МБ +38 МБ

На маленьких хранилищах векторный индекс незаметен (40 КБ на 10 заметок). Он становится ощутимым от 1 000 заметок: +30–45 МБ. На 10 000 заметок добавляется ~40 МБ к базовому кешу — соответствует теоретическому расчёту 1 024 измерения × 4 байта × N заметок.

Сырые цифры

Хранилище Первая загрузка 1я / 4я 1 заметка 1я / 4я 10% пакетом 1я / 4я Пик RSS 1я / 4я
10 40 / 8 мс 4 / 2 мс 4 / 2 мс 68 / 69 МБ
100 113 / 81 мс 6 / 8 мс 13 / 10 мс 83 / 80 МБ
1 000 1,2 / 0,5 с 39 / 22 мс 96 / 62 мс 182 / 149 МБ
10 000 13,8 / 6,8 с 382 / 224 мс 2,3 / 0,7 с 1186 / 902 МБ

Замеры 2026-06-11 на 12-ядерной dev-машине ARM64 с ограничением через taskset, SQLite в режиме WAL, один процесс сервера. Ваши абсолютные цифры будут другими; форма кривых — нет.

Почему рендер страниц такой быстрый

Цифры выше — про операции записи. Чтение страниц — другая история: p50 времени ответа 3–6 мс при любом размере хранилища, даже под 10 одновременными пользователями.

Это не случайность — это архитектурный выбор. trip2g находится между статическим генератором и традиционной CMS:

  • Статический генератор (Gatsby, Hugo, Eleventy): строит HTML-файлы на диск при деплое. Чтение мгновенное — просто отдаёт файл. Но каждое изменение контента требует полной пересборки — минуты для больших сайтов, без живого редактирования.
  • Традиционная CMS (WordPress, Ghost): рендерит каждую страницу на каждый запрос — ходит в базу, подтягивает связи, прогоняет шаблоны. Гибко и живо, но каждый читатель нагружает базу данных.
  • trip2g: рендерит HTML при записи (во время pushNotes) и хранит результат в памяти. Чтение отдаёт уже готовый HTML прямо из хэш-таблицы — без базы данных, без шаблонизатора на горячем пути. Стоимость рендера платится один раз при изменении контента и размазывается на всех читателей.

Держать тысячи отрендеренных страниц в оперативной памяти оказалось удивительно дёшево. Синтетическая заметка в 500 байт markdown превращается в ~800 байт HTML; 10 000 заметок — это примерно 8 МБ контента. Остальное из ~1 ГБ RSS при таком масштабе — Go runtime, WAL-буферы SQLite и поисковые индексы, а не кеш страниц. VPS за $5/мес с 1 ГБ памяти без проблем обслуживает хранилища в несколько тысяч заметок, не обращаясь к диску при чтении.

Есть куда двигаться дальше — сейчас архитектура рендерит заметки по отдельности, а финальную страницу (лейаут, инъекции) собирает на каждом запросе. Пре-рендер готовых страниц снизит задержку ещё сильнее. Но даже без этого trip2g уже на порядки быстрее традиционных CMS на аналогичном железе.

Измерено на реальном контенте

Мы залили эту самую документацию — 35 реальных страниц (набор /en/user: кастомный HTML внутри default-темплейта) — на Hetzner cpx32 (4 vCPU AMD EPYC-Genoa, 8 ГБ) и нагрузили их vegeta с отдельной машины (чтобы генератор не воровал CPU у сервера), round-robin по всем страницам (~52 КБ каждая), в production-режиме с прогретым кешем:

Нагрузка Успех p50 p95 p99
1 000 зап/с 100 % 2.7 ms 3.9 ms 10 ms
2 000 зап/с 100 % 1.4 ms 3.3 ms 12 ms
3 000 зап/с 100 % 1.8 ms 6.2 ms 16 ms
4 000 зап/с 100 % 2.6 ms 13 ms 32 ms
5 000 зап/с 100 % 9.1 ms 74 ms 169 ms
6 000 зап/с 100 % 241 ms 645 ms 898 ms

Каждый запрос успешен (100 % 200) вплоть до 6 000 зап/с — упирается в задержку, не в ошибки. Одна 4-ядерная нода уверенно отдаёт ~4 000 реальных страниц в секунду с p99 ~30 ms; колено — около 4 000–5 000 зап/с, дальше задержка уходит в сотни мс (нода упирается в CPU). Это потолок одной ноды на таком железе и точка, где окупается вторая нода (или read-реплика — см. zerodowntime / litestream). Замерено 2026-06-22.

Как замерить честно. Запускайте с DEV=false (dev-режим пересчитывает хэши ассетов на каждый запрос, ~2× медленнее), генератор нагрузки — с отдельной машины (на той же ворует ~2× CPU), и бейте по многим реальным страницам с прогретым кешем. Пропуск любого пункта занизил наш первый прогон в ~4×.

Основную стоимость запроса на этом потолке дают два пункта: gzip-сжатие каждого ответа и несколько DB-запросов на запрос в render-пути (проверка доступа, встроенные Telegram-ссылки, HTML-инъекции, трекинг просмотров). Оба кэшируемы — страницы статичны между записями — так что есть явный запас, чтобы будущим проходом оптимизации поднять потолок в разы. (Эта оптимизация запланирована, но ещё не сделана.)

Статика и проверка на bandwidth

Мы отдали те же 35 страниц как обычные файлы из nginx на той же ноде, чтобы оценить накладные расходы движка против голой раздачи статики. Сюрприз: при ~52 КБ на страницу и nginx, и trip2g упираются в один диапазон в несколько тысяч зап/с — стена там это полоса сети (52 КБ × 5 000 ≈ 2 Гбит/с) плюс gzip-на-запрос, а не render-путь trip2g. То есть на этом железе и размере страниц trip2g уже идёт вровень с голой раздачей файлов; стоимость движка на запрос (сборка layout + служебные DB-запросы) станет доминирующим ограничением только на мелких страницах или быстрой сети. Чтобы чисто разместить trip2g на спектре от статики до CMS-на-базе (WordPress), нужен более чистый сетап (мелкие страницы / простое железо) — отмечено как будущая работа.

На какой маленькой ноде это крутится

Мы развернули trip2g на нескольких дешёвых ВМ и нагрузили каждую с отдельной машины (чтобы генератор не крал CPU у сервера), в production-режиме (DEV=false):

Нода vCPU / RAM ~Цена Держит на 100 % Рестарт под нагрузкой
Hetzner cpx32 4 / 8 ГБ ~€10/мес ~4 000 зап/с (реальные 52 КБ doc-страницы)
Hetzner cpx22 2 / 4 ГБ ~€6/мес ≥4 000 зап/с (/ лендинг ~43 КБ — не насыщено) 0 дропов
DigitalOcean basic 1 / 512 МБ $4/мес ~500 rps* 0 дропов

*Одно ядро держит ~500 rps страницы / (~43 КБ) на 100 % с p99 < 50 мс; колено ~600–700 rps, за ~900 задержка разваливается. Замерено с генератором в том же US-East (~23 мс) — первый прогон с генератором в Германии дал обманчивые «1000 rps», но тот трансатлантический канал 86 мс просто throttлил генератор, и дроплет не получал реальную нагрузку. Цены — list-прайс провайдеров.

Примечание о замерах: цифры этой таблицы получены до кеша анонимных страниц (2026-06-29), то есть показывают пропускную способность без него. Внутри таблицы строки Hetzner сняты после более ранних оптимизаций записи, строка DigitalOcean — до них. Пропускную способность с кешем на сопоставимом железе смотрите в разделе ниже.

Важнее сырых цифр два вывода.

Деплой не теряет ничего. Рестарт под живым трафиком — systemctl restart, пока летели запросы — уронил ноль соединений и на 2-ядерной Hetzner-ноде, и на $4-дроплете (20 000 / 20 000 и 12 000 / 12 000 ответов 200, без отказов). Слушающий сокет переживает рестарт через systemd socket activation: in-flight запросы ждут в очереди ядра пару сотен миллисекунд вместо отказа. Один сервер, без балансировщика, без простоя — см. zerodowntime.

Объектное хранилище не нужно. С локальным бэкендом (--storage-backend=local) trip2g держит ассеты и бэкапы на диске самого сервера — ни MinIO, ни S3. На $4-дроплете забутился, заняв ~57 МБ RAM. Самой дешёвой ВМ достаточно для настоящего сайта.

Что это значит для вас

  • Хранилища до ~1 000 заметок: всё мгновенно — синхронизация занимает десятки миллисекунд.
  • ~10 000 заметок на маленьком сервере: повседневные синхронизации по-прежнему меньше секунды, но первая загрузка всего хранилища занимает ~14 с на одном ядре. На это время база заблокирована для других писателей — большие хранилища лучше импортировать в тихие часы.
  • Ядра ускоряют рендеринг, а не блокировку: 4-ядерный сервер вдвое сокращает большие загрузки. Но блокировка держится весь push в любом случае, поэтому много одновременных писателей выигрывают меньше, чем обещают сырые цифры.

Кеш анонимных страниц: 8 500 зап/с на машине за €6,49/мес

Архитектура кеша заметок выше отрендеривает HTML заранее и держит его в памяти, но gzip и несколько DB-запросов по-прежнему выполняются на каждый запрос. Оба эти расхода теперь устранены для анонимных читателей вторым слоем — кешем анонимных страниц: готовый gzip-ированный HTML хранится по ключу (путь, хост, версия заметки, конфиг, язык). Попадание в кеш — копирование байт из памяти. Рендер и gzip не запускаются.

Эффект: страница документации в default-темплейте раньше стоила ~28 мс/запрос (94% CPU уходило на синхронный per-request поиск «похожих заметок» — cosineSimilarity по всем эмбеддингам). Jet-лендинг стоил ~0,9 мс/запрос. После прогрева кеша обе страницы отдаются с одинаковой скоростью — ~9 000 зап/с. Кеш уравнивает дешёвые и дорогие страницы.

Бенчмарк: 2026-06-29, Hetzner shared-vCPU

Машина vCPU / RAM Цена
cx23 (тестируемый сервер) 2 / 3,8 ГБ €6,49/мес
cx33 (генератор нагрузки) 4 / 7,7 ГБ €8,99/мес

Атака с cx33 по сети (разные машины).

12-секундный всплеск (максимальный темп): ~9 000 зап/с, CPU сервера ~78%, steal 0%.

10-минутный прогон на максимальном темпе:

Метрика Значение
Всего запросов 5 134 192
Пропускная способность 8 557 зап/с
Vs. всплеск −5%
p50 25,8 мс
p99 86 мс
Максимум 323 мс
Ошибок 0
CPU steal 0% весь прогон

Пять миллионов запросов, десять минут, ноль ошибок.

На графике ниже видно, как растёт задержка по мере приближения к насыщению.

p50 держится ниже 1 мс вплоть до ~6 000 зап/с. Колено — около 7 000–8 000 зап/с: p50 прыгает с 1,2 мс до 146 мс. Вблизи 9 000 зап/с машина насыщается. Success-rate оставался 100% на всём диапазоне: под перегрузкой сервер деградирует по задержке, не отказывая в обслуживании.

Стоимость: 8 557 зап/с ≈ 739 млн запросов в сутки на машине за €6,49/мес — ≈ €0,0003 за миллион запросов, или около €0,29 за миллиард. Для любого реального трафика стоимость одного запроса к обслуживанию округляется до нуля.

Сравнение до/после (12-ядерная dev-машина, одна страница): страница документации в default-темплейте ускорилась с 421 до 8 504 зап/с после прогрева кеша — рост в 20 раз. CPU при попадании в кеш упал с ~95% до ~0%.

Три ловушки замера

Два альтернативных стенда дают заниженные или нечестные числа.

Атака с той же машины (loopback). Запуск vegeta на том же cx23 дал только ~5 400 зап/с — генератор нагрузки конкурировал за два ядра с сервером. CPU сервера не упирался в 100%; мы замеряли потолок атакующего, а не сервера. Межмашинная цифра — реальный потолок.

Слабый атакующий, сильный сервер. Запуск trip2g на cx33 с атакой с cx23 показал лишь ~44–45% CPU на cx33. Слабый cx23 не смог насытить его нагрузкой. Это ограничение атакующего, не измерение сервера.

steal как пробник throttling. Shared vCPU можно ограничить при превышении fair-use. Сигнал — метрика steal. За весь 10-минутный прогон steal держался на 0% — throttling не было. Для нагрузок, которые работают на максимуме 24/7, подходит dedicated-vCPU без риска ограничений. Для типичного пикового трафика shared достаточно.

Что кеш покрывает

Кеш анонимных страниц работает только для неаутентифицированных читателей. Авторизованные пользователи его обходят. Причина: авторизованный читатель может быть администратором (видит элементы управления редактирования), подписчиком (у него другой доступ к материалам) или иметь иной персональный контекст; отдать ему кешированную анонимную страницу значит рискнуть показать не то. Есть нюанс, специфичный для default-темплейта: разница между видом анонима и авторизованного (кнопка входа, значок редактирования) рендерится клиентским скриптом в браузере. HTML с сервера для обоих одинаков. Технически кеш можно было бы распространить на авторизованных пользователей в default-темплейте без риска. Но не для кастомных лейаутов и не для заметок за пейволлом. Текущий дизайн выбирает простое и надёжное: любой авторизованный запрос обходит кеш. Синхронный поиск «похожих заметок» (~28 мс) по-прежнему выполняется на каждый cache miss и каждый запрос авторизованного пользователя. Следующий шаг — вычислять «похожие заметки» один раз при перезагрузке заметки, убирая O(N)-работу с пути чтения. О том, как мы к этому пришли — в эссе Кеш страниц и неожиданное узкое место.