N+1 Effect #

N+1 adalah salah satu masalah performa yang paling sering lolos ke production tanpa terdeteksi. Tidak ada error, tidak ada pesan warning — kode berjalan dengan benar dan test hijau semua. Masalah baru muncul saat data tumbuh: endpoint yang tadinya merespons dalam 50ms tiba-tiba membutuhkan 3 detik, dan database menampilkan ratusan query identik per request di slow query log. Penyebabnya sederhana: aplikasi melakukan satu query untuk mengambil daftar data, lalu satu query lagi untuk setiap item dalam daftar itu — menghasilkan total N+1 query ketika seharusnya cukup 1 atau 2. Artikel ini membahas bagaimana N+1 terjadi, bagaimana mendeteksinya di production, tiga solusi dengan implementasi konkret di Go, dan kapan efek N+1 memang boleh dibiarkan.

Bagaimana N+1 Terjadi: Simulasi Langkah demi Langkah #

Untuk memahami masalahnya, mari ikuti alur eksekusi yang terjadi di balik kode yang terlihat wajar.

Skenario: Daftar Order beserta Nama User #

// Kode yang terlihat bersih dan logis
func GetOrderList(ctx context.Context) ([]OrderResponse, error) {
    // Query 1: ambil semua order
    orders, err := db.QueryContext(ctx,
        "SELECT id, user_id, total, status FROM orders LIMIT 20")
    // ...scan rows ke []Order

    var result []OrderResponse
    for _, order := range orders {
        // Query 2 s.d. 21: ambil user untuk SETIAP order
        var user User
        db.QueryRowContext(ctx,
            "SELECT id, name, email FROM users WHERE id = ?",
            order.UserID,
        ).Scan(&user.ID, &user.Name, &user.Email)

        result = append(result, OrderResponse{
            OrderID: order.ID,
            Total:   order.Total,
            Status:  order.Status,
            User:    user,
        })
    }
    return result, nil
}

Query yang sebenarnya dijalankan ke database:

-- Query 1: ambil daftar order
SELECT id, user_id, total, status FROM orders LIMIT 20;

-- Query 2 s.d. 21: satu per order
SELECT id, name, email FROM users WHERE id = 1;
SELECT id, name, email FROM users WHERE id = 5;
SELECT id, name, email FROM users WHERE id = 5;   -- user yang sama! duplikat
SELECT id, name, email FROM users WHERE id = 12;
SELECT id, name, email FROM users WHERE id = 3;
-- ... 15 query lagi

Total: 21 query untuk mengambil 20 order. Dengan LIMIT 20 ini terasa tidak terlalu berat. Tapi saat pagenya naik atau limitnya lebih besar:

Pertumbuhan query dengan N+1:
──────────────────────────────────────────────────────────────
  20 order   → 21 query    → ~50ms      (terasa normal)
  100 order  → 101 query   → ~250ms     (mulai terasa lambat)
  500 order  → 501 query   → ~1.200ms   (user mengeluh)
  1.000 order → 1.001 query → ~2.500ms  (timeout di beberapa client)
  Eksport all → 50.001 query → menit    (database tersedak)
──────────────────────────────────────────────────────────────
  Setiap query punya overhead: network round-trip, parsing,
  query planning, lock check, result serialization.
  1.000 query kecil jauh lebih lambat dari 1 query yang baik.
──────────────────────────────────────────────────────────────

N+1 Berlapis: Ketika Masalah Berlipat Ganda #

N+1 menjadi jauh lebih parah ketika relasi bersifat bertingkat. Bayangkan: orders → order_items → products. Setiap level memperkalikan jumlah query.

// N+1 berlapis tiga level — sangat umum di kode yang tidak diperhatikan
func GetOrdersWithDetails(ctx context.Context) ([]OrderDetail, error) {
    // Query 1: ambil orders
    orders, _ := db.QueryContext(ctx, "SELECT id, user_id FROM orders LIMIT 20")

    for _, order := range orders {  // N = 20 order
        // Query 2-21: ambil user per order
        db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", order.UserID)

        // Query 22-41: ambil items per order
        items, _ := db.QueryContext(ctx,
            "SELECT id, product_id, qty FROM order_items WHERE order_id = ?", order.ID)

        for _, item := range items {  // M = misalkan 5 item per order
            // Query 42-141: ambil product per item
            db.QueryRowContext(ctx,
                "SELECT name, price FROM products WHERE id = ?", item.ProductID)
        }
    }
}

Perhitungan query yang dijalankan:

N+1 berlapis (20 orders, rata-rata 5 items per order):
──────────────────────────────────────────────────────────────
  1 query untuk orders
  + 20 query untuk users (1 per order)
  + 20 query untuk order_items (1 per order)
  + 100 query untuk products (1 per item × 5 item × 20 order)
  ─────────────────────────────────────────────────────────────
  Total = 141 query untuk 20 order

  Jika limit 100 order (rata-rata 5 items):
  = 1 + 100 + 100 + 500 = 701 query untuk 100 order
──────────────────────────────────────────────────────────────

Tiga Solusi dengan Implementasi Go #

Solusi 1: JOIN — Satu Query untuk Semua Data #

Solusi paling efisien adalah menggabungkan data yang dibutuhkan dalam satu query dengan JOIN. Database optimizer akan menentukan cara paling efisien untuk menggabungkannya.

// ANTI-PATTERN: N+1 query
func GetOrdersN1(ctx context.Context) ([]OrderResponse, error) {
    orders, _ := db.QueryContext(ctx, "SELECT id, user_id, total FROM orders LIMIT 20")
    for _, o := range orders {
        db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", o.UserID)
        // N query tambahan
    }
    // ...
}

// BENAR: satu query dengan JOIN
func GetOrdersWithJoin(ctx context.Context, db *sqlx.DB) ([]OrderResponse, error) {
    type row struct {
        OrderID    int64   `db:"order_id"`
        Total      float64 `db:"total"`
        Status     string  `db:"status"`
        UserID     int64   `db:"user_id"`
        UserName   string  `db:"user_name"`
        UserEmail  string  `db:"user_email"`
    }

    var rows []row
    err := db.SelectContext(ctx, &rows, `
        SELECT
            o.id    AS order_id,
            o.total,
            o.status,
            u.id    AS user_id,
            u.name  AS user_name,
            u.email AS user_email
        FROM orders o
        JOIN users u ON o.user_id = u.id
        ORDER BY o.created_at DESC
        LIMIT 20
    `)
    if err != nil {
        return nil, err
    }

    result := make([]OrderResponse, len(rows))
    for i, r := range rows {
        result[i] = OrderResponse{
            OrderID: r.OrderID,
            Total:   r.Total,
            Status:  r.Status,
            User: UserInfo{
                ID:    r.UserID,
                Name:  r.UserName,
                Email: r.UserEmail,
            },
        }
    }
    return result, nil
}
// Total query: 1 — terlepas dari jumlah order

JOIN cocok ketika relasi bersifat many-to-one atau one-to-one (setiap order punya tepat satu user). Untuk relasi one-to-many (satu order punya banyak items), JOIN bisa menghasilkan baris duplikat yang perlu di-deduplikasi di aplikasi — di sinilah batch query lebih tepat.

Solusi 2: Batch Query dengan IN — Dua Query untuk Semua Data #

Untuk relasi one-to-many, pendekatan yang lebih bersih adalah mengumpulkan semua ID yang dibutuhkan, lalu fetch semuanya dalam satu query IN, dan lakukan mapping di application layer.

// BENAR: batch query dengan IN untuk relasi one-to-many
func GetOrdersWithItems(ctx context.Context, db *sqlx.DB) ([]OrderWithItems, error) {
    // Query 1: ambil semua order
    var orders []Order
    err := db.SelectContext(ctx, &orders,
        "SELECT id, user_id, total, status FROM orders ORDER BY created_at DESC LIMIT 20")
    if err != nil {
        return nil, err
    }

    if len(orders) == 0 {
        return nil, nil
    }

    // Kumpulkan semua order ID
    orderIDs := make([]int64, len(orders))
    for i, o := range orders {
        orderIDs[i] = o.ID
    }

    // Query 2: ambil SEMUA items untuk semua order sekaligus
    query, args, err := sqlx.In(
        "SELECT id, order_id, product_id, qty, price FROM order_items WHERE order_id IN (?)",
        orderIDs,
    )
    if err != nil {
        return nil, err
    }

    var items []OrderItem
    err = db.SelectContext(ctx, &items, db.Rebind(query), args...)
    if err != nil {
        return nil, err
    }

    // Mapping di application layer — O(n), tidak ada query tambahan
    itemsByOrderID := make(map[int64][]OrderItem)
    for _, item := range items {
        itemsByOrderID[item.OrderID] = append(itemsByOrderID[item.OrderID], item)
    }

    result := make([]OrderWithItems, len(orders))
    for i, order := range orders {
        result[i] = OrderWithItems{
            Order: order,
            Items: itemsByOrderID[order.ID],  // lookup O(1) dari map
        }
    }
    return result, nil
}
// Total query: 2 — terlepas dari jumlah order atau items

Pola ini bisa diperluas untuk tiga level sekaligus:

// Tiga level sekaligus: orders → items → products
// Hanya 3 query total, berapapun jumlah datanya
func GetOrdersComplete(ctx context.Context, db *sqlx.DB) ([]OrderComplete, error) {
    // Query 1: orders
    var orders []Order
    db.SelectContext(ctx, &orders, "SELECT ... FROM orders LIMIT 20")

    orderIDs := extractIDs(orders)  // helper untuk extract ID slice

    // Query 2: semua items untuk orders terpilih
    var items []OrderItem
    query, args, _ := sqlx.In("SELECT ... FROM order_items WHERE order_id IN (?)", orderIDs)
    db.SelectContext(ctx, &items, db.Rebind(query), args...)

    productIDs := extractProductIDs(items)  // kumpulkan semua product_id dari items

    // Query 3: semua products yang dibutuhkan
    var products []Product
    query, args, _ = sqlx.In("SELECT ... FROM products WHERE id IN (?)", productIDs)
    db.SelectContext(ctx, &products, db.Rebind(query), args...)

    // Mapping di application layer
    productMap := buildProductMap(products)
    itemMap := buildItemMap(items, productMap)

    return buildOrderComplete(orders, itemMap), nil
    // Total: 3 query — vs 141 query dengan N+1
}

Solusi 3: Preload via ORM #

Jika menggunakan ORM seperti GORM, gunakan Preload untuk meminta ORM melakukan batch query otomatis. Jangan gunakan lazy loading default — ia selalu menghasilkan N+1.

// ANTI-PATTERN: lazy loading di GORM — N+1 tersembunyi
var orders []Order
db.Find(&orders)  // 1 query
for _, order := range orders {
    // Setiap akses .User memicu query baru jika belum di-load
    fmt.Println(order.User.Name)  // N query — N+1!
}

// BENAR: explicit Preload — GORM akan batch query otomatis
var orders []Order
db.Preload("User").Preload("Items").Find(&orders)
// → 1 query untuk orders
// → 1 query untuk semua users (SELECT ... WHERE id IN (...))
// → 1 query untuk semua items (SELECT ... WHERE order_id IN (...))
// Total: 3 query, bukan N+1

// Atau untuk relasi bertingkat:
db.Preload("Items.Product").Find(&orders)
// → 1 query orders
// → 1 query items
// → 1 query products
// Total: 3 query tetap
Preload di ORM tidak selalu menghasilkan query yang optimal. Selalu verifikasi query yang dihasilkan dengan mengaktifkan SQL logging di development. GORM menyediakan db.Debug() untuk ini, sqlx memerlukan wrapper logging manual. Jangan percaya bahwa Preload berarti “sudah optimal” tanpa melihat query yang sebenarnya.

Cara Mendeteksi N+1 di Production #

N+1 yang sudah ada di production bisa dideteksi melalui beberapa cara, dari yang paling langsung sampai yang memerlukan instrumentasi tambahan.

Cara 1: Query Count Logging per Request #

Cara paling efektif adalah mencatat jumlah query yang dijalankan per HTTP request dan alert jika melebihi threshold.

// Middleware untuk menghitung query per request
type QueryCounter struct {
    db    *sql.DB
    count int64
}

func (qc *QueryCounter) reset() { atomic.StoreInt64(&qc.count, 0) }
func (qc *QueryCounter) increment() { atomic.AddInt64(&qc.count, 1) }
func (qc *QueryCounter) get() int64 { return atomic.LoadInt64(&qc.count) }

// Wrapper yang menghitung setiap query
func (qc *QueryCounter) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
    qc.increment()
    return qc.db.QueryContext(ctx, query, args...)
}

// Di HTTP middleware (Fiber/Go):
func QueryCountMiddleware(counter *QueryCounter) fiber.Handler {
    return func(c *fiber.Ctx) error {
        counter.reset()
        err := c.Next()

        count := counter.get()
        if count > 20 {
            // Log warning — kemungkinan ada N+1
            log.Warn("high query count",
                "path", c.Path(),
                "method", c.Method(),
                "query_count", count,
            )
        }

        // Sertakan di response header untuk debugging
        c.Set("X-Query-Count", strconv.FormatInt(count, 10))
        return err
    }
}

Cara 2: Slow Query Log + Pattern Matching #

Tanda N+1 di slow query log adalah query yang identik (hanya parameternya berbeda) muncul berkali-kali dalam satu detik yang sama:

-- Di MySQL slow query log dengan long_query_time = 0 (log semua query)
-- Tanda N+1 terlihat jelas:

-- 2026-04-18 10:30:00.123 Query: SELECT name FROM users WHERE id = 1
-- 2026-04-18 10:30:00.124 Query: SELECT name FROM users WHERE id = 5
-- 2026-04-18 10:30:00.125 Query: SELECT name FROM users WHERE id = 5   ← duplikat!
-- 2026-04-18 10:30:00.126 Query: SELECT name FROM users WHERE id = 12
-- ... (50 baris serupa dalam 200ms)

-- Cek pola duplikat query di MySQL:
-- Aktifkan general log sementara:
SET GLOBAL general_log = 'ON';
SET GLOBAL general_log_file = '/var/log/mysql/general.log';
-- Jalankan endpoint yang dicurigai, lalu matikan:
SET GLOBAL general_log = 'OFF';
-- Analisis log untuk query yang berulang

Cara 3: Deteksi di Development dengan SQL Logging #

Selama development, aktifkan logging semua query dan perhatikan pola:

// sqlx dengan custom logger
type LoggingDB struct {
    *sqlx.DB
    logger *zap.Logger
}

func (ldb *LoggingDB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
    start := time.Now()
    rows, err := ldb.DB.QueryContext(ctx, query, args...)
    ldb.logger.Debug("sql query",
        zap.String("query", query),
        zap.Any("args", args),
        zap.Duration("duration", time.Since(start)),
    )
    return rows, err
}

// Dalam test, kamu bisa menghitung query dan assert:
func TestGetOrderList_ShouldNotCauseN1(t *testing.T) {
    counter := &QueryCounter{}
    // ... jalankan GetOrderList
    assert.LessOrEqual(t, counter.get(), int64(3),
        "GetOrderList seharusnya tidak lebih dari 3 query (orders + users + items)")
}

Cara 4: Grafik Query Count di Monitoring #

Jika menggunakan APM seperti Datadog, New Relic, atau Prometheus, tambahkan metric db_query_count_per_request per endpoint. Lonjakan tajam di metric ini mengindikasikan N+1 baru yang masuk production:

// Prometheus metric
var queriesPerRequest = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_db_queries_per_request",
        Help:    "Number of DB queries executed per HTTP request",
        Buckets: []float64{1, 2, 5, 10, 20, 50, 100, 500},
    },
    []string{"method", "path"},
)

// Di akhir request middleware:
queriesPerRequest.WithLabelValues(method, path).Observe(float64(queryCount))

N+1 di Luar Konteks Database #

N+1 bukan hanya masalah database — pola yang sama bisa terjadi dengan HTTP API eksternal atau layanan internal:

// N+1 ke API eksternal — sama berbahayanya
func EnrichOrdersWithShipping(orders []Order) ([]OrderWithShipping, error) {
    var result []OrderWithShipping
    for _, order := range orders {
        // Satu HTTP call per order — N+1 ke service shipping!
        shipping, err := shippingService.GetStatus(order.TrackingNumber)
        // ...
    }
    return result, nil
}

// BENAR: batch call ke API eksternal
func EnrichOrdersWithShippingBatch(orders []Order) ([]OrderWithShipping, error) {
    trackingNumbers := extractTrackingNumbers(orders)

    // Satu call untuk semua tracking number sekaligus
    shippingMap, err := shippingService.GetStatusBatch(trackingNumbers)
    // ...

    for _, order := range orders {
        shipping := shippingMap[order.TrackingNumber]
        // mapping...
    }
}

Prinsipnya sama: jangan lakukan I/O di dalam loop jika bisa dibatch.


Kapan N+1 Masih Bisa Ditoleransi #

Tidak semua N+1 perlu diperbaiki segera. Ada konteks di mana N+1 masih acceptable:

N+1 bisa ditoleransi jika SEMUA kondisi ini terpenuhi:
──────────────────────────────────────────────────────────────
  ✓ N sangat kecil dan dibatasi secara pasti (N ≤ 5)
    → Tidak akan tumbuh seiring waktu

  ✓ Bukan di hot path / endpoint publik
    → Hanya admin tools atau background job yang jarang berjalan

  ✓ Setiap query dilayani dari cache (Redis/memory)
    → Round-trip ke database tidak benar-benar terjadi

  ✓ Relasi tidak bisa di-batch karena natural dari bisnis
    → Misalnya: setiap item butuh data real-time dari sumber berbeda
    → Dan tidak ada cara untuk meng-aggregate permintaan tersebut

N+1 TIDAK BISA ditoleransi jika:
──────────────────────────────────────────────────────────────
  ✗ N bisa tumbuh seiring data bertambah
  ✗ Endpoint ini dipanggil sering (> 10x/menit)
  ✗ Setiap query ke database yang nyata (bukan cache)
  ✗ N > 10, bahkan jika endpoint jarang dipanggil
──────────────────────────────────────────────────────────────

Anti-Pattern yang Harus Dihindari #

// ✗ Anti-pattern 1: query di dalam loop — N+1 klasik
for _, order := range orders {
    db.QueryRow("SELECT name FROM users WHERE id = ?", order.UserID)
}
// ✓ Solusi: JOIN atau batch IN

// ✗ Anti-pattern 2: lazy loading ORM tanpa Preload
db.Find(&orders)
for _, o := range orders {
    fmt.Println(o.User.Name)  // trigger query per akses
}
// ✓ Solusi: db.Preload("User").Find(&orders)

// ✗ Anti-pattern 3: batch tapi masih per-item karena loop di luar batch
for _, orderID := range orderIDs {
    // Ini masih N+1 — batch harus satu call untuk semua ID sekaligus
    db.QueryRow("SELECT * FROM items WHERE order_id = ?", orderID)
}
// ✓ Solusi: sqlx.In dengan semua ID sekaligus

// ✗ Anti-pattern 4: cache per item tapi miss rate tinggi
for _, order := range orders {
    user, ok := cache.Get(order.UserID)
    if !ok {
        // Cache miss → query per item → masih N+1 jika miss rate tinggi
        db.QueryRow("SELECT * FROM users WHERE id = ?", order.UserID)
    }
}
// ✓ Solusi untuk cache miss: batch fetch semua yang miss sekaligus

// ✗ Anti-pattern 5: N+1 ke HTTP API eksternal dalam loop
for _, item := range items {
    price, _ := pricingAPI.GetPrice(item.ProductID)  // HTTP call per item!
}
// ✓ Solusi: pricingAPI.GetPriceBatch(productIDs)  // satu call untuk semua

Checklist Review Kode untuk N+1 #

SAAT CODE REVIEW — FLAG JIKA ADA:
  □ Query database di dalam for/range loop?
  □ ORM lazy loading diakses tanpa Preload eksplisit?
  □ HTTP call ke service eksternal di dalam loop?
  □ Fungsi yang menerima satu ID tapi dipanggil dalam loop
    (seharusnya ada versi batch-nya)?

SAAT TESTING:
  □ Ada assertion untuk jumlah query per test case?
  □ SQL logging aktif dan dipantau selama development?
  □ Ada test dengan dataset yang lebih besar (>100 item)
    untuk mendeteksi pertumbuhan query?

DI PRODUCTION:
  □ Ada metric query_count_per_request per endpoint?
  □ Ada alert jika query count melebihi threshold (misal > 20)?
  □ Slow query log dianalisis secara berkala untuk pola duplikat?

Ringkasan #

  • N+1 tidak memunculkan error — ia memunculkan tagihan — kode berjalan benar tapi untuk setiap 100 order, ada 101 query ke database. Masalah baru terasa ketika data tumbuh dan traffic naik.
  • Akar masalah: I/O di dalam loop — setiap kali ada query, HTTP call, atau operasi I/O lainnya di dalam iterasi atas sebuah daftar, tanya dirimu: apakah ini bisa di-batch menjadi satu operasi?
  • Tiga solusi utama: JOIN, batch IN, atau Preload — JOIN untuk relasi many-to-one, batch IN untuk relasi one-to-many, Preload untuk ORM. Semua menghasilkan jumlah query yang konstan (tidak tumbuh dengan N).
  • N+1 berlapis memperkalikan masalah — tiga level relasi tanpa optimasi bisa menghasilkan ratusan query untuk data yang kecil. Perbaikan batch IN di setiap level mengembalikannya ke jumlah yang konstan.
  • Deteksi dini: query count logging per request — tambahkan header X-Query-Count di development dan alert di production jika melebihi threshold (misalnya > 20 query per request).
  • N+1 terjadi bukan hanya ke database — HTTP call ke API eksternal dalam loop adalah N+1 yang sama berbahayanya. Selalu cari API batch jika tersedia.
  • Preload ORM tidak selalu optimal — verifikasi SQL yang dihasilkan dengan mengaktifkan SQL logging. Preload yang salah konfigurasi bisa masih menghasilkan N+1 atau query yang tidak efisien.
  • N+1 kecil yang terkontrol masih bisa ditoleransi — jika N dijamin tidak tumbuh (selalu ≤ 5), bukan hot path, dan setiap query dilayani dari cache, N+1 kecil kadang lebih mudah dibaca daripada batch query yang kompleks.

← Sebelumnya: Optimistic Locking   Berikutnya: Database Roundtrip →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact