AxumSeniorExperience

Представьте, endpoint на Axum (Rust) иногда отвечает за секунды вместо миллисекунд. Как вы будете искать причину?

Иногда медленный endpoint — сигнал периодической проблемы: connection pool exhaustion, slow query на определённых данных, блокирующий код или GC-пауза. Алгоритм: воспроизвести, добавить трейсинг по слоям, найти outlier, изолировать.

Почему «иногда» — самый сложный сценарий

Постоянно медленный endpoint диагностировать просто. «Иногда медленный» означает, что причина периодическая: высокая нагрузка, определённые данные, внешняя зависимость или состояние гонки. Алгоритм диагностики отличается.

Шаг 1 — собрать данные без изменения кода

Сначала убедитесь, что у вас есть histogramm latency (p50/p95/p99), а не только average. Average скрывает outliers. Включите TraceLayer с логированием времени ответа:

use tower_http::trace::{TraceLayer, DefaultMakeSpan, DefaultOnResponse};
use tracing::Level;

let app = Router::new()
    .route("/api/items", get(list_items))
    .layer(
        TraceLayer::new_for_http()
            .make_span_with(DefaultMakeSpan::new().level(Level::INFO))
            .on_response(DefaultOnResponse::new().level(Level::INFO))
    );
// Теперь каждый запрос логирует: method, path, status, latency

Шаг 2 — выдвинуть гипотезы

Типичные причины периодических задержек в Axum-сервисах:

  • Connection pool exhaustion — все соединения с БД заняты, хендлер ждёт освобождения.
  • Slow query на определённых данных — запрос без индекса быстр на маленьких таблицах, медленен после роста данных.
  • Блокирующий код в async — CPU-heavy операция или sync IO блокирует Tokio worker thread.
  • Внешний HTTP-запрос без timeout — сторонний API периодически медленно отвечает.
  • Memory pressure / allocator — jemalloc vs system allocator заметен при частых аллокациях.

Шаг 3 — инструментировать хендлер

use axum::{extract::State, Json};
use std::sync::Arc;
use tracing::instrument;

#[instrument(skip(state), fields(user_id))] // создаёт span для каждого вызова
async fn list_items(
    State(state): State<Arc<AppState>>,
) -> Json<Vec<Item>> {
    let t0 = std::time::Instant::now();
    
    let pool_size = state.db.size();    // сколько соединений используется
    let idle = state.db.num_idle();     // сколько свободных
    tracing::debug!(pool_size, idle, "db pool stats");

    let items = state.db.fetch_items().await;  // <-- здесь может быть задержка
    tracing::info!(elapsed_ms = t0.elapsed().as_millis(), "db query done");

    Json(items.unwrap())
}

Шаг 4 — проверить connection pool

// sqlx: настройка pool с таймаутами и мониторингом
let pool = sqlx::postgres::PgPoolOptions::new()
    .max_connections(20)
    .acquire_timeout(std::time::Duration::from_secs(3)) // не ждать вечно
    .connect(&database_url)
    .await?;

// В хендлере — логировать статус пула при медленных запросах
if pool.size() == pool.options().get_max_connections() {
    tracing::warn!("connection pool exhausted");
}

Шаг 5 — проверить внешние зависимости

// Всегда используйте timeout для внешних HTTP-запросов
use std::time::Duration;

let client = reqwest::Client::builder()
    .timeout(Duration::from_secs(2))  // без этого один медленный upstream = зависший хендлер
    .build()?;

let resp = client.get("https://api.example.com/data").send().await?;

Шаг 6 — load test для воспроизведения

Используйте drill или oha для нагрузочного теста: запустите 100 concurrent requests и посмотрите, при каком уровне concurrency появляются outliers. Это укажет на ресурс под давлением.

# oha — простой HTTP benchmarking tool на Rust
oha -n 1000 -c 50 http://localhost:3000/api/items
# Смотрим: p99 latency и distribution графиком

Подводные камни

  • Average latency скрывает проблему: всегда смотрите p99/p999 — один медленный запрос из 100 не виден в average, но убивает пользовательский опыт.
  • Tokio runtime не имеет вытесняющей многозадачности: одна задача, захватившая CPU на 100ms, задерживает все остальные на том же thread — профилируйте с tokio-console.
  • sqlx acquire_timeout по умолчанию 30 секунд: если не переопределить, исчерпание пула выглядит как очень медленный ответ, а не ошибка.
  • reqwest без timeout: отсутствие таймаута на внешний запрос — самая частая причина периодических зависаний в production.
  • Трейсинг добавляет overhead: включение подробного трейсинга в production может само по себе замедлить сервис — используйте sampling (например, 1% запросов).
  • Не коррелировать с деплоями: периодическая деградация после деплоя — первый признак регрессии производительности; сравните метрики до и после.
  • Игнорировать allocator: Rust по умолчанию использует system allocator; для высоконагруженных сервисов jemalloc может дать 10–20% прирост и снизить latency spikes.
  • Flame graph только в idle: запускайте профилировщик под нагрузкой, иначе hot path не проявится — используйте cargo-flamegraph с параллельными нагрузочными тестами.

What hurts your answer

  • Сразу обвинять Axum (Rust), не проверив соседние слои системы
  • Чинить симптом без минимального воспроизведения и evidence
  • Не учитывать версии, конфигурацию, окружение и recent changes

What they're listening for

  • Умеет локализовать проблему вокруг Axum (Rust)
  • Двигается от симптома к гипотезам и проверкам
  • Отличает баг инструмента от ошибки использования или окружения

Related topics