Перейти к основному содержимому
ИнженерияMar 28, 2026

Deep EVM #23: Отладка производительности — когда чтения из БД убивают латентность

OS
Open Soft Team

Engineering Team

Почему база данных — главный источник латентности

В типичном веб-приложении на Rust/Axum 80-95% времени ответа приходится на операции с базой данных. Ваш Rust-код исполняется за микросекунды, но один неоптимальный SQL-запрос может добавить сотни миллисекунд. Когда пользователи жалуются на медленность — ищите проблему в базе данных.

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

Проблема N+1 запросов

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

// ПЛОХО: N+1 запросов
let articles = sqlx::query_as!(Article, "SELECT * FROM articles LIMIT 20")
    .fetch_all(&pool).await?;

for article in &articles {
    let author = sqlx::query_as!(Author,
        "SELECT * FROM authors WHERE id = $1", article.author_id
    ).fetch_one(&pool).await?;
    // ... используем author
}

Результат: 1 + 20 = 21 запрос вместо одного. На латентности сети 1мс каждый — это 21мс только на round-trip’ы.

Решение: JOIN

// ХОРОШО: 1 запрос
let articles_with_authors = sqlx::query_as!(
    ArticleWithAuthor,
    r#"SELECT a.*, au.name as author_name
       FROM articles a
       JOIN authors au ON a.author_id = au.id
       LIMIT 20"#
).fetch_all(&pool).await?;

Решение: batch-загрузка

Когда JOIN неуместен (например, разные таблицы для разных типов):

let author_ids: Vec<Uuid> = articles.iter()
    .map(|a| a.author_id).collect();

let authors = sqlx::query_as!(Author,
    "SELECT * FROM authors WHERE id = ANY($1)",
    &author_ids
).fetch_all(&pool).await?;

let author_map: HashMap<Uuid, Author> = authors
    .into_iter()
    .map(|a| (a.id, a))
    .collect();

2 запроса вместо 21.

Отсутствие индексов

Вторая по частоте проблема — отсутствие нужных индексов. PostgreSQL выполняет sequential scan по всей таблице вместо index scan.

Диагностика с EXPLAIN ANALYZE

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM articles WHERE slug = 'my-article';

Если видите Seq Scan на большой таблице — нужен индекс:

CREATE INDEX CONCURRENTLY idx_articles_slug ON articles (slug);

Составные индексы

Для запросов с несколькими условиями:

-- Запрос
SELECT * FROM articles
WHERE category_id = $1 AND published = true
ORDER BY published_at DESC;

-- Индекс
CREATE INDEX idx_articles_cat_pub
ON articles (category_id, published_at DESC)
WHERE published = true;

Частичный индекс (WHERE published = true) ещё эффективнее — индексирует только опубликованные статьи.

Пул соединений: настройка и подводные камни

sqlx по умолчанию создаёт пул с 10 соединениями. Для нагруженных приложений этого может быть мало:

let pool = PgPoolOptions::new()
    .max_connections(50)
    .min_connections(5)
    .acquire_timeout(Duration::from_secs(3))
    .idle_timeout(Duration::from_secs(600))
    .max_lifetime(Duration::from_secs(1800))
    .connect(&database_url)
    .await?;

Ключевые параметры:

  • max_connections — не больше, чем max_connections PostgreSQL минус резерв
  • acquire_timeout — сколько ждать свободного соединения
  • idle_timeout — когда закрывать неиспользуемые соединения

Формула для max_connections

Общее правило: max_connections = (количество ядер CPU) * 2 + эффективное количество дисков. Для SSD-систем с 4 ядрами — 10-20 соединений обычно оптимально.

Медленные запросы: pg_stat_statements

Включите расширение pg_stat_statements для отслеживания медленных запросов:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Топ-10 медленных запросов по общему времени
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Кэширование результатов

Для часто запрашиваемых и редко меняющихся данных используйте in-memory кэш:

use moka::future::Cache;

let cache: Cache<String, Article> = Cache::builder()
    .max_capacity(1000)
    .time_to_live(Duration::from_secs(300))
    .build();

async fn get_article(
    pool: &PgPool,
    cache: &Cache<String, Article>,
    slug: &str,
) -> Result<Article> {
    if let Some(article) = cache.get(slug) {
        return Ok(article);
    }
    let article = sqlx::query_as!(Article,
        "SELECT * FROM articles WHERE slug = $1", slug
    ).fetch_one(pool).await?;
    cache.insert(slug.to_string(), article.clone()).await;
    Ok(article)
}

moka — это высокопроизводительный конкурентный кэш для Rust, вдохновлённый Caffeine из Java.

Инструментирование с tracing

Добавьте tracing для всех SQL-запросов:

let pool = PgPoolOptions::new()
    .after_connect(|conn, _meta| {
        Box::pin(async move {
            conn.execute("SET application_name = 'my-app'")
                .await?;
            Ok(())
        })
    })
    .connect(&database_url)
    .await?;

Используйте sqlx::query! с tracing для автоматического логирования времени выполнения каждого запроса.

Чеклист производительности БД

  1. Нет ли N+1 запросов? Используйте JOIN или batch-загрузку.
  2. Есть ли индексы для всех WHERE/ORDER BY? Проверьте с EXPLAIN ANALYZE.
  3. Правильно ли настроен пул соединений? Не больше, чем PostgreSQL может обработать.
  4. Включён ли pg_stat_statements? Отслеживайте медленные запросы.
  5. Нужно ли кэширование? moka для in-process, Redis для распределённого.
  6. Есть ли tracing? Измеряйте каждый запрос.

Заключение

Оптимизация производительности базы данных — это не одноразовое мероприятие, а непрерывный процесс. Инструментируйте запросы, мониторьте латентность, анализируйте explain-планы. В 90% случаев проблема — это отсутствие индекса или N+1 запросов. Эти проблемы легко обнаружить и легко исправить, если у вас есть правильные инструменты.