Русский
Производительность
Коротко
- Синхронизация (повседневная запись): одна изменённая заметка пушится за десятки миллисекунд даже на хранилище в 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×:
- Dev-режим пересчитывает хэш каждого ассета на каждый запрос (~2× медленнее) → мерять на
DEV=false. - Генератор нагрузки на той же машине ворует CPU у сервера (~2× медленнее) → запускать с отдельной машины.
- Холодный кеш / один 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)-работу с пути чтения. О том, как мы к этому пришли — в эссе Кеш страниц и неожиданное узкое место.