Deep EVM #23: Отладка производительности — когда чтения из БД убивают латентность
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_connectionsPostgreSQL минус резерв - 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 для автоматического логирования времени выполнения каждого запроса.
Чеклист производительности БД
- Нет ли N+1 запросов? Используйте JOIN или batch-загрузку.
- Есть ли индексы для всех WHERE/ORDER BY? Проверьте с EXPLAIN ANALYZE.
- Правильно ли настроен пул соединений? Не больше, чем PostgreSQL может обработать.
- Включён ли pg_stat_statements? Отслеживайте медленные запросы.
- Нужно ли кэширование? moka для in-process, Redis для распределённого.
- Есть ли tracing? Измеряйте каждый запрос.
Заключение
Оптимизация производительности базы данных — это не одноразовое мероприятие, а непрерывный процесс. Инструментируйте запросы, мониторьте латентность, анализируйте explain-планы. В 90% случаев проблема — это отсутствие индекса или N+1 запросов. Эти проблемы легко обнаружить и легко исправить, если у вас есть правильные инструменты.