Дело было вечером... Сидел я, развлекался переписыванием фронта маркетплейса с React на Preact с Brotli и нативным CSS, чтобы протестировать максимальную оптимизацию. В попытках добиться предельной производительности и скорости решил провести эксперимент по переписыванию бэка на Rust с сжатием БД в Redis — но это совсем другая история. В общем, эксперименты привели меня к идее сделать SSR на Rust, и по бенчмаркам вышел на 95,000+ RPS на M4. Это уже само по себе неплохо, ниже расскажу подробнее.

Архитектура Rusty-SSR

Rust позволяет более гибко управлять потоками и памятью. В основе Rusty-SSR — пул V8 изолятов, привязка потоков к ядрам и многоуровневый кэш.

1. Пул V8 изолятов для многопоточности

Вместо отдельных процессов используются легковесные V8 изоляты в одном процессе Rust, по одному на поток.

// Инициализация пула
let pool = V8Pool::new(V8PoolConfig {
    num_threads: num_cpus::get(), // Используем все ядра
    queue_capacity: 512,          // Очередь для backpressure
    ..Default::default()
});

Это позволяет избежать блокировок: если один изолят занят, другие продолжают работу.

2. Привязка потоков к ядрам (Thread Pinning)

Контекстные переключения снижают производительность. Чтобы минимизировать их, каждый поток привязывается к конкретному ядру.

// В рабочем потоке
if let Some(core_id) = cores.get(idx) {
    if core_affinity::set_for_current(*core_id) {
        tracing::debug!("Worker {} pinned to core {:?}", id, core_id.id);
    }
}

Это помогает держать кэш процессора (L1/L2) в актуальном состоянии. В облаке эффект может варьироваться, поэтому рекомендуется проверять на профайлинге.

3. Многоуровневый кэш

Кэширование минимизирует рендеринг. Вместо простой HashMap с блокировками реализован двухуровневый подход:

  • Hot Cache (L1): Thread-local, для быстрого доступа без синхронизации.

  • Cold Cache (L2): DashMap для общего доступа между потоками.

Размер кэша задается в элементах (страницах), TTL — в секундах (например, cache_ttl_secs(300)). Метрики доступны через engine.cache_metrics() (hit-rate, hot/cold hits и т.д.).

Префетчинг данных

Для ускорения использованы SSE-инструкции для предварительной загрузки данных в кэш CPU. Это как подогреть кофе заранее, чтобы не ждать.

#[repr(align(64))]  // Выравнивание по кэш-линии
pub struct HotCache {
    ultra_hot: [Option<HotEntry>; 8],
    hot_map: HashMap<u64, HotEntry>,
    // ...
}

Внутренняя структура Hot Cache

Hot Cache разделен на ultra-hot массив (8 элементов, для сверхбыстрого доступа) и HashMap (128 элементов). Записи промотируются по LRU-принципу.

let html: Arc<str> = Arc::from(rendered_html.as_str());
cache.insert(url, Arc::clone(&html));  // Клонируется только Arc

Zero-Copy с Arc

HTML хранится как Arc<str>, чтобы избежать копирования между потоками.

let html: Arc<str> = Arc::from(rendered_html.as_str());
cache.insert(url, Arc::clone(&html));  // Клонируется только Arc

Это экономит память для больших страниц.

Оптимизация DashMap

Cold Cache использует DashMap с 128 шардами для снижения contention при многопоточности. Тестирование показало +19% к пропускной способности по сравнению с дефолтными 16 шардами.

Шарды

Пропускная способность

Улучшение

16 (default)

51M elem/s

baseline

32

57M elem/s

+12%

64

59M elem/s

+16%

128

60.6M elem/s

+19%

256

60.3M elem/s

+18%

Надежность и готовность к продакшену

  • Очередь с таймаутом (request_timeout) предотвращает блокировки.

  • Обработка ошибок при загрузке бандла.

  • Полная очистка кэша, включая thread-local.

Бенчмарки

Тесты на Apple M4 (10 ядер), с использованием wrk --latency -t10 -c400/1000 -d30s на loopback, демо-HTML из репозитория, прогретый кэш.

Метрика

Значение

Комментарий

Throughput

95,363 req/s

Высокая пропускная способность

Latency p50

0.46 ms

Средняя задержка

Latency p99

4.60 ms

Под нагрузкой

В данный момент использую всю эту историю на своем портфолио https://portfolio-production-b677.up.railway.app/. Оно пока сырое и преимущественно под десктоп, но как бенчмарк тоже можно использовать — в нем сложный контент с анимациями и Three.js, но загрузка чрезвычайно быстрая. Портфолио на самом дешевом Redis-пакете.

В реальных сценариях производительность зависит от сети, базы данных и браузера. Но даже небольшое улучшение может снизить затраты на инфраструктуру, а это полезно как минимум для экологии :)

В реальных сценариях производительность зависит от сети, базы данных и браузера. Но даже небольое улучшение может снизить затраты на инфраструктуру а это полезно как минимум для экологии )

Заключение

Rust предлагает инструменты для эффективных веб-серверов. Это мой опыт, который может быть полезен. Код открыт под MIT. Если пробуете, делитесь в комментариях — интересно услышать фидбек.

Ссылки:

Комментарии (3)


  1. Dhwtj
    10.12.2025 14:40

    use axum::{routing::get, Router};
    
    #[tokio::main]
    async fn main() {
        let app = Router::new().route("/", get(|| async { "hello" }));
        let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
        axum::serve(listener, app).await.unwrap();
    }

    Функционал одинаковый. Но мой быстрее


  1. Dhwtj
    10.12.2025 14:40

    Вы не обижайтесь, если что

    Просто вы написали бенчмарки на статическом HTML. А самое быстрое решение я привёл. Возможно, ваше решение быстрее для каких-то других случаев.


    1. babaiiika Автор
      10.12.2025 14:40

      да, я не обижаюсь ).

      Твой пример классный, спасибо. Hello-world на Axum даст ещё больше RPS, но мои бенчи гонялись на полном SSR через V8: полный HTML (демо/портфолиом навароченое/) с прогретым двухуровневым кешем, wrk --latency -t10 -c400/1000 -d30s