Jika API Rust dengan sqlx mulai lambat saat data membesar, penyebab yang paling sering muncul bukan karena Rust kurang cepat, melainkan karena pola akses database yang buruk. Dua sumber masalah yang umum adalah N+1 query dan filter/sort tanpa index yang sesuai. Gejalanya biasanya terlihat di produksi: latensi p95/p99 naik, koneksi database cepat habis, throughput turun, dan endpoint yang tadinya aman mulai timeout.

Artikel ini membahas audit N+1 query dan index untuk aplikasi Rust dengan sqlx secara praktis. Kita akan melihat contoh skema dan query yang buruk, cara mengidentifikasi bottleneck dengan log query, metrik latensi, serta EXPLAIN/EXPLAIN ANALYZE, lalu melakukan refactor ke query yang lebih efisien dan mendesain index yang relevan. Fokusnya adalah langkah yang bisa langsung diterapkan pada API backend menengah, bukan penjelasan dangkal.

Gejala di Produksi: Kapan Harus Curiga ke N+1 Query dan Index

Sebelum mengubah kode, kenali pola gejalanya. N+1 query dan index yang tidak tepat sering menimbulkan sinyal berikut:

  • Latensi endpoint naik seiring jumlah item per halaman. Misalnya daftar order dengan 20 item masih cepat, tetapi 100 item menjadi sangat lambat.
  • Jumlah query per request melonjak. Satu request API yang seharusnya mengeksekusi 2-5 query ternyata mengirim puluhan atau ratusan query.
  • CPU database tinggi atau I/O naik saat traffic normal, terutama pada endpoint baca.
  • Pool koneksi sqlx penuh, sehingga request lain ikut menunggu meskipun logika aplikasinya ringan.
  • Query lambat muncul hanya setelah data tumbuh. Saat tabel masih kecil, full scan terasa baik-baik saja; setelah jutaan baris, performa runtuh.

Pola pentingnya adalah ini: jika latensi naik proporsional terhadap jumlah item hasil, curigai N+1. Jika latensi naik karena filter atau sorting tertentu, curigai desain index.

Contoh Kasus: Endpoint Order yang Melambat

Misalkan API perlu menampilkan daftar order terbaru milik tenant tertentu, berikut nama customer dan jumlah item dalam setiap order. Skema sederhananya:

-- orders(id, tenant_id, customer_id, status, created_at, total_amount)
-- customers(id, tenant_id, name, email)
-- order_items(id, order_id, sku, qty, price)

Implementasi awal sering tampak masuk akal, tetapi menyimpan masalah besar:

use sqlx::{PgPool, FromRow};

#[derive(FromRow)]
struct OrderRow {
    id: i64,
    customer_id: i64,
    status: String,
    created_at: chrono::DateTime<chrono::Utc>,
    total_amount: rust_decimal::Decimal,
}

#[derive(serde::Serialize)]
struct OrderResponse {
    id: i64,
    status: String,
    created_at: chrono::DateTime<chrono::Utc>,
    total_amount: rust_decimal::Decimal,
    customer_name: String,
    item_count: i64,
}

pub async fn list_orders_bad(
    pool: &PgPool,
    tenant_id: i64,
    limit: i64,
    offset: i64,
) -> Result<Vec<OrderResponse>, sqlx::Error> {
    let orders = sqlx::query_as::<_, OrderRow>(
        r#"
        SELECT id, customer_id, status, created_at, total_amount
        FROM orders
        WHERE tenant_id = $1
        ORDER BY created_at DESC
        LIMIT $2 OFFSET $3
        "#,
    )
    .bind(tenant_id)
    .bind(limit)
    .bind(offset)
    .fetch_all(pool)
    .await?;

    let mut result = Vec::with_capacity(orders.len());

    for order in orders {
        let customer_name: String = sqlx::query_scalar(
            "SELECT name FROM customers WHERE id = $1"
        )
        .bind(order.customer_id)
        .fetch_one(pool)
        .await?;

        let item_count: i64 = sqlx::query_scalar(
            "SELECT COUNT(*) FROM order_items WHERE order_id = $1"
        )
        .bind(order.id)
        .fetch_one(pool)
        .await?;

        result.push(OrderResponse {
            id: order.id,
            status: order.status,
            created_at: order.created_at,
            total_amount: order.total_amount,
            customer_name,
            item_count,
        });
    }

    Ok(result)
}

Masalahnya jelas: untuk 50 order, fungsi di atas menjalankan 1 query awal + 50 query customer + 50 query item_count. Inilah N+1 query. Walaupun setiap query individual terlihat kecil, akumulasi round-trip, parsing, lock ringan, dan antrean koneksi akan terasa berat di produksi.

Cara Audit: Jangan Menebak, Ukur

1. Hitung jumlah query per request

Langkah pertama adalah memastikan apakah sebuah endpoint benar-benar melakukan terlalu banyak query. Pada aplikasi yang memakai tracing atau logger serupa, aktifkan logging SQL secukupnya di lingkungan staging atau produksi terbatas. Tujuannya bukan mencetak semua query sepanjang waktu, tetapi menghubungkan request tertentu dengan jumlah query dan durasinya.

Prinsip pengamatan yang berguna:

  • Catat request id atau trace id.
  • Catat durasi total endpoint.
  • Catat jumlah query yang dieksekusi selama request.
  • Catat query paling lambat dan frekuensinya.

Jika satu request daftar order mengeksekusi puluhan query serupa dengan parameter berbeda, hampir pasti ada N+1.

2. Pantau metrik latensi yang tepat

Rata-rata latensi sering menipu. Untuk audit performa API, fokus pada:

  • p50: baseline pengalaman umum.
  • p95/p99: ekor distribusi yang biasanya paling terdampak saat DB mulai tertekan.
  • Error rate: timeout atau kegagalan akibat pool/koneksi.
  • Pool saturation: berapa lama operasi menunggu koneksi.

Jika p95/p99 naik saat traffic stabil, kemungkinan besar ada query yang makin mahal atau jumlah query per request bertambah.

3. Gunakan EXPLAIN atau EXPLAIN ANALYZE

Setelah kandidat query ditemukan, jalankan EXPLAIN untuk melihat rencana eksekusi, lalu EXPLAIN ANALYZE di lingkungan aman untuk melihat eksekusi nyata. Fokus pada sinyal berikut:

  • Seq Scan pada tabel besar saat seharusnya bisa pakai index.
  • Rows removed by filter yang tinggi, artinya DB membaca terlalu banyak baris yang akhirnya dibuang.
  • Sort mahal karena tidak ada index yang cocok untuk urutan hasil.
  • Nested loop yang membengkak karena join atau subquery tidak efisien.

Contoh query awal untuk halaman order:

EXPLAIN ANALYZE
SELECT id, customer_id, status, created_at, total_amount
FROM orders
WHERE tenant_id = 42
ORDER BY created_at DESC
LIMIT 50 OFFSET 5000;

Jika hasil rencana menunjukkan scan besar lalu sort, ada dua kemungkinan utama: index untuk filter dan sort tidak sesuai, atau OFFSET sudah terlalu dalam sehingga database tetap harus melewati banyak baris.

Catatan: EXPLAIN ANALYZE menjalankan query sungguhan. Hindari pada query tulis atau query berat di produksi tanpa pengamanan yang jelas.

Refactor N+1 Query: Join dan Batch Fetch

Perbaikan paling umum adalah mengubah banyak query kecil menjadi satu query gabungan, atau sedikit query batch. Pilihannya bergantung pada bentuk data dan biaya duplikasi hasil.

Pendekatan 1: Gabungkan dengan JOIN dan agregasi

Untuk kasus order, kita bisa mengambil data customer dan jumlah item dalam satu query:

use sqlx::{PgPool, FromRow};

#[derive(FromRow, serde::Serialize)]
struct OrderResponse {
    id: i64,
    status: String,
    created_at: chrono::DateTime<chrono::Utc>,
    total_amount: rust_decimal::Decimal,
    customer_name: String,
    item_count: i64,
}

pub async fn list_orders_joined(
    pool: &PgPool,
    tenant_id: i64,
    limit: i64,
    offset: i64,
) -> Result<Vec<OrderResponse>, sqlx::Error> {
    let rows = sqlx::query_as::<_, OrderResponse>(
        r#"
        SELECT
            o.id,
            o.status,
            o.created_at,
            o.total_amount,
            c.name AS customer_name,
            COALESCE(COUNT(oi.id), 0) AS item_count
        FROM orders o
        JOIN customers c
          ON c.id = o.customer_id
         AND c.tenant_id = o.tenant_id
        LEFT JOIN order_items oi
          ON oi.order_id = o.id
        WHERE o.tenant_id = $1
        GROUP BY o.id, o.status, o.created_at, o.total_amount, c.name
        ORDER BY o.created_at DESC
        LIMIT $2 OFFSET $3
        "#,
    )
    .bind(tenant_id)
    .bind(limit)
    .bind(offset)
    .fetch_all(pool)
    .await?;

    Ok(rows)
}

Mengapa ini lebih baik? Karena database dirancang untuk melakukan join, agregasi, dan filtering di sisi server dengan rencana eksekusi yang teroptimasi. Anda mengurangi round-trip aplikasi-ke-database secara drastis.

Namun ada trade-off:

  • Query menjadi lebih kompleks dan lebih sensitif terhadap index yang benar.
  • COUNT di join bisa mahal jika order_items sangat besar dan halaman data besar.
  • Jika Anda ikut mengambil relasi one-to-many lengkap, hasil join bisa menggandakan baris dan memperbesar payload.

Pendekatan 2: Batch fetch untuk relasi yang tidak cocok di-join langsung

Kadang lebih aman memakai 2-3 query batch daripada satu join besar, terutama jika Anda ingin memuat relasi tambahan tanpa menduplikasi data secara berlebihan.

use std::collections::HashMap;
use sqlx::{PgPool, FromRow};

#[derive(FromRow)]
struct OrderRow {
    id: i64,
    customer_id: i64,
    status: String,
    created_at: chrono::DateTime<chrono::Utc>,
    total_amount: rust_decimal::Decimal,
}

#[derive(FromRow)]
struct CustomerRow {
    id: i64,
    name: String,
}

#[derive(FromRow)]
struct ItemCountRow {
    order_id: i64,
    item_count: i64,
}

#[derive(serde::Serialize)]
struct OrderResponse {
    id: i64,
    status: String,
    created_at: chrono::DateTime<chrono::Utc>,
    total_amount: rust_decimal::Decimal,
    customer_name: String,
    item_count: i64,
}

pub async fn list_orders_batched(
    pool: &PgPool,
    tenant_id: i64,
    limit: i64,
    offset: i64,
) -> Result<Vec<OrderResponse>, sqlx::Error> {
    let orders = sqlx::query_as::<_, OrderRow>(
        r#"
        SELECT id, customer_id, status, created_at, total_amount
        FROM orders
        WHERE tenant_id = $1
        ORDER BY created_at DESC
        LIMIT $2 OFFSET $3
        "#,
    )
    .bind(tenant_id)
    .bind(limit)
    .bind(offset)
    .fetch_all(pool)
    .await?;

    let customer_ids: Vec<i64> = orders.iter().map(|o| o.customer_id).collect();
    let order_ids: Vec<i64> = orders.iter().map(|o| o.id).collect();

    let customers = sqlx::query_as::<_, CustomerRow>(
        r#"
        SELECT id, name
        FROM customers
        WHERE id = ANY($1)
        "#,
    )
    .bind(&customer_ids)
    .fetch_all(pool)
    .await?;

    let item_counts = sqlx::query_as::<_, ItemCountRow>(
        r#"
        SELECT order_id, COUNT(*) AS item_count
        FROM order_items
        WHERE order_id = ANY($1)
        GROUP BY order_id
        "#,
    )
    .bind(&order_ids)
    .fetch_all(pool)
    .await?;

    let customer_map: HashMap<i64, String> = customers
        .into_iter()
        .map(|c| (c.id, c.name))
        .collect();

    let item_count_map: HashMap<i64, i64> = item_counts
        .into_iter()
        .map(|r| (r.order_id, r.item_count))
        .collect();

    let result = orders
        .into_iter()
        .map(|o| OrderResponse {
            id: o.id,
            status: o.status,
            created_at: o.created_at,
            total_amount: o.total_amount,
            customer_name: customer_map.get(&o.customer_id).cloned().unwrap_or_default(),
            item_count: item_count_map.get(&o.id).copied().unwrap_or(0),
        })
        .collect();

    Ok(result)
}

Pola ini tetap menghilangkan N+1, tetapi lebih mudah dikendalikan. Anda menjalankan tiga query tetap, bukan 1 + N + N. Ini sering menjadi kompromi yang sangat baik untuk API produksi.

Kapan memilih JOIN, kapan batch?

  • Pilih JOIN jika relasi sederhana, hasil tidak menggandakan data secara berlebihan, dan Anda ingin pemrosesan dilakukan penuh di database.
  • Pilih batch jika relasi banyak, struktur respons kompleks, atau join besar berisiko menghasilkan data duplikat dan memori berlebih.

Desain Index yang Relevan untuk Query API

Setelah N+1 diperbaiki, bottleneck berikutnya biasanya adalah query utama daftar data: filter tenant, status, rentang waktu, dan sorting berdasarkan created_at. Menambahkan index secara asal tidak cukup; index harus cocok dengan pola query nyata.

Contoh query yang membutuhkan index tepat

SELECT id, customer_id, status, created_at, total_amount
FROM orders
WHERE tenant_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;

Untuk query seperti ini, index terpisah di tenant_id, status, dan created_at belum tentu optimal. Database sering membutuhkan composite index yang mengikuti pola filter dan sorting.

Contoh migration SQL:

-- Migration: tambah index untuk list order per tenant dan status
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_tenant_status_created_at
ON orders (tenant_id, status, created_at DESC);

-- Migration: bantu agregasi atau lookup order_items per order
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_order_items_order_id
ON order_items (order_id);

-- Migration: bantu join customer dalam konteks tenant
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_customers_tenant_id_id
ON customers (tenant_id, id);

Mengapa susunan kolom penting?

  • Kolom filter yang paling konsisten dipakai biasanya ditempatkan di depan.
  • Jika query memfilter tenant_id dan status lalu mengurutkan created_at, maka index (tenant_id, status, created_at) lebih masuk akal daripada index acak.
  • Index ini membantu database menemukan subset data yang relevan dan membaca dalam urutan yang sudah sesuai, sehingga biaya sort turun atau hilang.

Kesalahan umum dalam desain index

  • Terlalu banyak index tunggal, tetapi tidak ada composite index yang cocok dengan query penting.
  • Urutan kolom salah, sehingga filter utama tetap tidak efisien.
  • Index untuk query yang jarang dipakai, tetapi query panas diabaikan.
  • Melupakan multi-tenant pattern. Pada aplikasi multi-tenant, tenant_id sering perlu menjadi bagian awal index karena hampir semua query memfilternya.

Trade-off: index mempercepat baca, memperlambat tulis

Setiap index baru menambah biaya saat INSERT, UPDATE, dan DELETE. Database harus memelihara struktur index tambahan. Karena itu, jangan menambah index hanya karena “mungkin berguna”. Tambahkan index yang terbukti membantu query panas dan ukur dampaknya.

Pertimbangan praktis:

  • Jika endpoint baca jauh lebih dominan daripada tulis, index sering layak ditambah.
  • Jika tabel menerima write sangat tinggi, setiap index tambahan harus benar-benar dibenarkan.
  • Jika ada index yang tidak pernah terpakai, evaluasi untuk dihapus agar biaya write dan storage turun.

Membaca EXPLAIN/EXPLAIN ANALYZE dengan Fokus yang Benar

Anda tidak perlu menjadi ahli query planner untuk audit dasar. Cukup fokus pada pertanyaan berikut:

  1. Apakah database membaca terlalu banyak baris? Jika ya, index atau filter perlu diperbaiki.
  2. Apakah ada sort besar? Jika ya, mungkin perlu composite index yang mendukung urutan hasil.
  3. Apakah join tertentu meledak biayanya? Jika ya, cek index pada kolom join dan cek apakah hasil join terlalu besar.
  4. Apakah OFFSET membuat biaya naik drastis? Jika ya, pertimbangkan keyset pagination.

Contoh interpretasi sederhana:

  • Seq Scan on orders pada tabel besar untuk query tenant + status biasanya tanda bahwa index belum cocok.
  • Sort Method dengan penggunaan memori besar menandakan urutan hasil tidak bisa diambil langsung dari index.
  • Actual rows jauh lebih kecil daripada baris yang dibaca berarti banyak pekerjaan terbuang.

Offset Pagination vs Keyset Pagination

Banyak API melambat bukan hanya karena N+1, tetapi juga karena tetap memakai OFFSET saat data sudah besar. Query seperti ini terlihat sederhana:

SELECT id, created_at, status
FROM orders
WHERE tenant_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 10000;

Masalahnya, semakin besar offset, semakin banyak baris yang harus dilewati. Walaupun index membantu, database tetap perlu melangkahi banyak data sebelum mencapai halaman yang diminta.

Kapan perlu keyset pagination

Pertimbangkan keyset pagination jika:

  • Data terus bertambah dan pengguna sering mengakses halaman dalam.
  • Urutan hasil stabil, misalnya berdasarkan created_at dan id.
  • Anda ingin latensi halaman berikutnya tetap konsisten.

Contoh query keyset:

SELECT id, customer_id, status, created_at, total_amount
FROM orders
WHERE tenant_id = $1
  AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 50;

Untuk query ini, index yang relevan biasanya mengikuti filter tenant dan urutan keyset:

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_tenant_created_id
ON orders (tenant_id, created_at DESC, id DESC);

Trade-off keyset pagination:

  • Lebih cepat dan stabil untuk halaman lanjut.
  • Tidak cocok untuk lompat langsung ke halaman 200 seperti model offset tradisional.
  • Perlu desain token cursor yang benar di layer API.

Verifikasi Hasil: Sebelum dan Sesudah

Perubahan performa harus dibuktikan. Setelah refactor query dan penambahan index, lakukan verifikasi minimal pada tiga level:

1. Jumlah query per request

Pastikan endpoint yang tadinya menjalankan 101 query kini turun menjadi 1-3 query. Ini metrik paling cepat untuk membuktikan N+1 sudah hilang.

2. Rencana eksekusi query

Bandingkan output EXPLAIN ANALYZE sebelum dan sesudah. Anda ingin melihat:

  • Lebih sedikit baris yang dibaca.
  • Biaya sort turun atau hilang.
  • Join menggunakan index yang masuk akal.

3. Metrik API

Verifikasi pada p50, p95, p99, waktu tunggu pool koneksi, dan timeout. Jangan hanya melihat “terasa lebih cepat” dari satu percobaan lokal.

Jika memungkinkan, uji dengan volume data yang realistis. Banyak query tampak baik di laptop karena data sedikit, tetapi gagal total saat cardinality naik di produksi.

Contoh Checklist Audit untuk Rust sqlx

Gunakan checklist ini saat mengaudit endpoint yang melambat:

  • Apakah satu request mengeksekusi query dalam loop?
  • Apakah jumlah query per request bertambah sesuai jumlah item hasil?
  • Apakah query utama memakai OFFSET besar?
  • Apakah filter paling umum sudah memiliki composite index yang sesuai?
  • Apakah kolom join memiliki index?
  • Apakah urutan ORDER BY selaras dengan urutan kolom index?
  • Apakah EXPLAIN ANALYZE menunjukkan Seq Scan atau sort mahal pada tabel besar?
  • Apakah ada index yang ditambahkan tetapi tidak membantu query panas?
  • Apakah write throughput memburuk setelah penambahan index?
  • Apakah hasil sesudah perbaikan sudah diverifikasi dengan p95/p99, bukan hanya rata-rata?

Kesalahan yang Sering Terjadi Saat Memperbaiki Performa

1. Menambah cache sebelum menghilangkan N+1

Cache bisa menyamarkan masalah, tetapi pola query buruk tetap ada. Saat cache miss meningkat atau invalidation rumit, masalah inti kembali muncul.

2. Menambah index tanpa melihat query planner

Index baru tidak otomatis dipakai. Tanpa melihat rencana eksekusi, Anda bisa menambah biaya write tanpa manfaat nyata.

3. Mengoptimasi query sekunder, bukan query terpanas

Prioritaskan endpoint dengan traffic dan dampak bisnis tinggi. Audit performa harus berbasis data, bukan intuisi.

4. Mengabaikan bentuk data respons

Satu query besar bukan selalu solusi terbaik. Jika join menghasilkan ledakan duplikasi baris, batch fetch bisa lebih efisien dan lebih mudah dipelihara.

Penutup

Dalam banyak kasus, API Rust dengan sqlx yang melambat bukan masalah runtime, melainkan masalah pola query dan desain index. Audit yang efektif dimulai dari gejala produksi: jumlah query per request, p95/p99, saturasi pool koneksi, lalu dibuktikan dengan EXPLAIN/EXPLAIN ANALYZE. Dari sana, perbaikannya biasanya jelas: hilangkan N+1 query dengan join atau batch fetch, tambahkan index yang sesuai dengan filter dan sorting nyata, lalu pertimbangkan keyset pagination jika offset mulai mahal.

Jika Anda hanya mengambil satu prinsip dari artikel ini, gunakan ini: ukur dulu, refactor query kedua, index ketiga, verifikasi terakhir. Dengan urutan itu, optimasi pada aplikasi Rust sqlx akan lebih tepat sasaran dan tidak berubah menjadi tebakan mahal.