Database Roundtrip #

Bayangkan sebuah API endpoint yang membutuhkan 8 detik untuk merespons. Kamu buka dashboard database: CPU 15%, memory aman, tidak ada slow query di log — semua query selesai dalam 2-5ms. Lantas dari mana 8 detik itu berasal? Jawabannya sering ada di database roundtrip: endpoint itu menjalankan 400 query kecil secara berurutan, dan masing-masing query memerlukan satu siklus penuh komunikasi aplikasi-database yang masing-masing butuh 1-2ms. Hasilnya: 400 × 2ms = 800ms hanya dari overhead komunikasi, belum termasuk waktu eksekusi query itu sendiri. Database roundtrip adalah biaya yang tidak terlihat di EXPLAIN plan dan tidak muncul di slow query log, tapi akumulasinya sangat nyata di latency aplikasi. Artikel ini membahas anatomi biaya satu roundtrip, mengapa banyak query kecil bisa lebih mahal dari satu query besar, dan teknik-teknik konkret untuk mengurangi jumlah roundtrip tanpa mengorbankan kejelasan kode.

Anatomi Satu Database Roundtrip #

Setiap kali aplikasi mengirim query ke database, ada serangkaian langkah yang terjadi sebelum hasilnya kembali ke aplikasi. Masing-masing langkah punya biaya waktu yang kecil tapi nyata.

Anatomi satu database roundtrip (query sederhana: SELECT name FROM users WHERE id = 42):
──────────────────────────────────────────────────────────────────────────────────────
  Sisi Aplikasi:
    1. Aplikasi serialisasi query + parameter ke wire protocol (MySQL/PostgreSQL)
    2. Kirim ke socket TCP
    3. Tunggu respons (blocking)

  Sisi Network:
    4. Paket perjalanan melalui network stack kernel
    5. NIC → kernel buffer → TCP stack
    Latency: 0.1–2ms (lokal VPC), 10–50ms (cross-region)

  Sisi Database:
    6. Database terima paket, demarshal dari wire protocol
    7. Parse SQL → tokenize → parse tree
    8. Query planner: cek cache plan, buat execution plan
    9. Akuisisi lock internal
    10. Eksekusi: index lookup, baca data page, filter
    11. Serialisasi result ke wire protocol
    12. Kirim respons via TCP

  Sisi Aplikasi (setelah terima respons):
    13. Deserialize wire protocol ke struct Go
    14. Release koneksi ke pool
──────────────────────────────────────────────────────────────────────────────────────
  Overhead langkah 1-4, 6-9, 11-14 terjadi untuk SETIAP query,
  bahkan jika query hanya membaca 1 baris.
  Untuk query yang mengembalikan 1 baris dalam 0.1ms,
  total overhead komunikasi bisa 10-20× lebih besar dari query itu sendiri.
──────────────────────────────────────────────────────────────────────────────────────

Ini mengapa 1.000 query yang masing-masing 1ms bisa jauh lebih lambat dari 1 query yang 50ms — karena yang pertama menanggung overhead komunikasi 1.000 kali, yang kedua hanya sekali.


Mengukur Overhead Roundtrip Secara Nyata #

Sebelum mengoptimasi, penting untuk mengukur berapa besar overhead roundtrip yang sebenarnya terjadi di sistem kamu. Ini membantu menjustifikasi effort optimasi.

// Benchmark sederhana untuk mengukur overhead roundtrip di Go
func BenchmarkRoundtripOverhead(b *testing.B) {
    db, _ := sql.Open("mysql", dsn)

    b.Run("single_query_1000x", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            // 1000 roundtrip terpisah
            for j := 0; j < 1000; j++ {
                var name string
                db.QueryRow("SELECT name FROM users WHERE id = ?", j%100+1).Scan(&name)
            }
        }
    })

    b.Run("batch_query_once", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            // 1 roundtrip dengan IN clause untuk 1000 ID
            ids := make([]interface{}, 1000)
            for j := range ids { ids[j] = j%100 + 1 }
            placeholders := strings.Repeat("?,", 999) + "?"
            rows, _ := db.Query("SELECT name FROM users WHERE id IN ("+placeholders+")", ids...)
            for rows.Next() {
                var name string
                rows.Scan(&name)
            }
            rows.Close()
        }
    })
}

// Hasil tipikal (MySQL, same host):
// BenchmarkRoundtripOverhead/single_query_1000x   →  ~1.800ms per op
// BenchmarkRoundtripOverhead/batch_query_once      →  ~   12ms per op
// Rasio: 150× lebih lambat karena overhead roundtrip

Di production, kamu bisa mengukur roundtrip overhead dengan cara sederhana: catat waktu sebelum dan sesudah setiap query, lalu bandingkan total waktu DB dengan total waktu request. Selisihnya adalah overhead non-query (network, protocol, connection pool).

// Middleware sederhana untuk mengukur rasio waktu DB vs total request
type DBTimer struct {
    totalDBTime   int64  // nanoseconds, atomic
    queryCount    int64
}

func (t *DBTimer) TimeQuery(fn func() error) error {
    start := time.Now()
    err := fn()
    elapsed := time.Since(start).Nanoseconds()
    atomic.AddInt64(&t.totalDBTime, elapsed)
    atomic.AddInt64(&t.queryCount, 1)
    return err
}

// Di akhir request:
// DB time: totalDBTime
// Query count: queryCount
// Roundtrip overhead per query = (totalDBTime / queryCount) - avg_query_exec_time

Lima Teknik Mengurangi Roundtrip #

Teknik 1: Batching dengan IN Clause #

Pola paling sederhana dan paling impactful: ganti serial query satu per satu dengan satu query yang mengambil semua data sekaligus.

// ANTI-PATTERN: satu roundtrip per user — N roundtrip total
func getUserNames(ctx context.Context, userIDs []int64) (map[int64]string, error) {
    result := make(map[int64]string)
    for _, id := range userIDs {
        var name string
        // Setiap iterasi = satu roundtrip
        err := db.QueryRowContext(ctx,
            "SELECT name FROM users WHERE id = ?", id,
        ).Scan(&name)
        if err != nil {
            return nil, err
        }
        result[id] = name
    }
    return result, nil
}
// Untuk 100 user: 100 roundtrip × 2ms overhead = 200ms hanya dari overhead

// BENAR: satu roundtrip untuk semua user
func getUserNamesBatch(ctx context.Context, db *sqlx.DB, userIDs []int64) (map[int64]string, error) {
    if len(userIDs) == 0 {
        return nil, nil
    }

    // sqlx.In menghasilkan query dengan placeholder yang benar
    query, args, err := sqlx.In(
        "SELECT id, name FROM users WHERE id IN (?)", userIDs)
    if err != nil {
        return nil, err
    }

    type row struct {
        ID   int64  `db:"id"`
        Name string `db:"name"`
    }
    var rows []row
    if err := db.SelectContext(ctx, &rows, db.Rebind(query), args...); err != nil {
        return nil, err
    }

    result := make(map[int64]string, len(rows))
    for _, r := range rows {
        result[r.ID] = r.Name
    }
    return result, nil
}
// Untuk 100 user: 1 roundtrip × 2ms overhead = 2ms — 100× lebih efisien

Teknik 2: Multi-Statement dalam Satu Koneksi #

MySQL mendukung multiple statements dalam satu query string (dengan flag multiStatements=true). Ini memungkinkan beberapa operasi dikirim dalam satu roundtrip.

// DSN dengan multiStatements enabled
dsn := "user:pass@tcp(host)/db?multiStatements=true"
db, _ := sql.Open("mysql", dsn)

// Satu roundtrip untuk dua operasi UPDATE sekaligus
result, err := db.ExecContext(ctx, `
    UPDATE orders SET status = 'processing' WHERE id = ?;
    INSERT INTO order_logs (order_id, status, created_at)
    VALUES (?, 'processing', NOW());
`, orderID, orderID)

// Tanpa multiStatements: 2 roundtrip
// Dengan multiStatements: 1 roundtrip
multiStatements=true membawa risiko keamanan jika query dibangun dari input user — SQL injection bisa menyisipkan statement tambahan. Gunakan hanya untuk query internal yang sudah diketahui strukturnya, selalu gunakan prepared statement atau parameter binding, dan jangan pernah interpolate input user ke dalam multi-statement query.

Teknik 3: CTE (Common Table Expression) untuk Menggabungkan Logika #

CTE memungkinkan penulisan query yang kompleks dan bertingkat dalam satu statement, menggantikan beberapa roundtrip dengan satu query yang lebih panjang.

-- ANTI-PATTERN: tiga roundtrip terpisah untuk satu kebutuhan bisnis
-- Roundtrip 1: ambil user aktif bulan ini
SELECT id FROM users WHERE last_login >= DATE_SUB(NOW(), INTERVAL 30 DAY);

-- Roundtrip 2: ambil order user-user tersebut
SELECT user_id, COUNT(*) as order_count
FROM orders WHERE user_id IN (/* hasil roundtrip 1 */);

-- Roundtrip 3: ambil produk yang paling banyak dibeli
SELECT product_id, COUNT(*) as buy_count
FROM order_items WHERE order_id IN (/* hasil roundtrip 2 */);

-- BENAR: satu roundtrip dengan CTE
WITH active_users AS (
    SELECT id
    FROM users
    WHERE last_login >= DATE_SUB(NOW(), INTERVAL 30 DAY)
),
user_orders AS (
    SELECT o.user_id, o.id AS order_id, COUNT(*) OVER (PARTITION BY o.user_id) AS order_count
    FROM orders o
    JOIN active_users au ON o.user_id = au.id
),
popular_items AS (
    SELECT oi.product_id, COUNT(*) AS buy_count
    FROM order_items oi
    JOIN user_orders uo ON oi.order_id = uo.order_id
    GROUP BY oi.product_id
)
SELECT p.id, p.name, pi.buy_count
FROM popular_items pi
JOIN products p ON pi.product_id = p.id
ORDER BY pi.buy_count DESC
LIMIT 10;
-- Satu roundtrip, berapapun kompleksitasnya

CTE juga berguna untuk operasi write yang membutuhkan data read terlebih dahulu:

-- Satu roundtrip untuk read-then-write dengan CTE di PostgreSQL
WITH eligible_orders AS (
    SELECT id
    FROM orders
    WHERE status = 'pending'
      AND created_at < NOW() - INTERVAL '24 hours'
)
UPDATE orders
SET status = 'expired'
WHERE id IN (SELECT id FROM eligible_orders)
RETURNING id, status;
-- Tanpa CTE: SELECT dulu (roundtrip 1), lalu UPDATE (roundtrip 2)
-- Dengan CTE: satu roundtrip, atomis

Teknik 4: Pipeline Query Paralel #

Untuk query yang independen satu sama lain (tidak ada ketergantungan data), jalankan secara paralel alih-alih berurutan. Ini tidak mengurangi jumlah roundtrip, tapi mengurangi waktu tunggu karena semua roundtrip terjadi serentak.

// ANTI-PATTERN: query berurutan — total waktu = sum semua query
func GetDashboardData(ctx context.Context, userID int64) (*Dashboard, error) {
    // Roundtrip 1: profil user (5ms)
    user, err := getUser(ctx, userID)
    if err != nil { return nil, err }

    // Roundtrip 2: order terbaru (8ms)
    orders, err := getRecentOrders(ctx, userID)
    if err != nil { return nil, err }

    // Roundtrip 3: notifikasi belum dibaca (3ms)
    notifications, err := getUnreadNotifications(ctx, userID)
    if err != nil { return nil, err }

    // Total: 5 + 8 + 3 = 16ms overhead sequential
    return &Dashboard{User: user, Orders: orders, Notifications: notifications}, nil
}

// BENAR: query paralel — total waktu = max semua query
func GetDashboardDataParallel(ctx context.Context, userID int64) (*Dashboard, error) {
    var (
        user          *User
        orders        []Order
        notifications []Notification
        userErr, ordersErr, notifErr error
    )

    var wg sync.WaitGroup
    wg.Add(3)

    go func() {
        defer wg.Done()
        user, userErr = getUser(ctx, userID)       // 5ms
    }()

    go func() {
        defer wg.Done()
        orders, ordersErr = getRecentOrders(ctx, userID)   // 8ms
    }()

    go func() {
        defer wg.Done()
        notifications, notifErr = getUnreadNotifications(ctx, userID)  // 3ms
    }()

    wg.Wait()

    // Cek semua error
    if err := errors.Join(userErr, ordersErr, notifErr); err != nil {
        return nil, err
    }

    // Total: max(5, 8, 3) = 8ms — bukan 16ms
    return &Dashboard{User: user, Orders: orders, Notifications: notifications}, nil
}

Perhatikan bahwa parallel query menggunakan lebih banyak koneksi dari connection pool secara bersamaan. Jika pool size kecil dan konkurensi tinggi, ini bisa menjadi bottleneck baru. Selalu pertimbangkan ukuran connection pool saat menerapkan pola ini.

Teknik 5: Prepared Statement untuk Query Berulang #

Prepared statement memindahkan proses parsing dan planning ke satu kali saja di awal, sehingga eksekusi berikutnya lebih cepat karena melewati langkah parse dan plan.

// Tanpa prepared statement: parse + plan setiap kali
for _, id := range orderIDs {
    db.QueryRowContext(ctx, "SELECT total, status FROM orders WHERE id = ?", id)
    // Parse + plan + execute per iterasi
}

// Dengan prepared statement: parse + plan hanya sekali
stmt, err := db.PrepareContext(ctx, "SELECT total, status FROM orders WHERE id = ?")
if err != nil {
    return err
}
defer stmt.Close()

for _, id := range orderIDs {
    stmt.QueryRowContext(ctx, id)
    // Hanya execute — parse dan plan sudah di-cache
}
// Untuk 1000 iterasi: hemat ~999 parse + plan overhead

Di Go dengan database/sql, prepared statement juga aman untuk concurrent use dari multiple goroutine — driver akan menangani per-connection statement caching secara otomatis.


Hubungan Roundtrip dengan Connection Pool #

Database roundtrip dan connection pool saling berkaitan erat. Setiap roundtrip memerlukan koneksi aktif dari pool selama durasi query. Terlalu banyak roundtrip bersamaan bisa menguras pool.

Interaksi roundtrip dan connection pool:
──────────────────────────────────────────────────────────────
  Pool size: 20 koneksi

  Skenario A: 20 concurrent request, masing-masing 5 query berurutan
    → Setiap request butuh 1 koneksi selama 5 roundtrip
    → Pool bisa melayani semua (20 koneksi, masing-masing 1 request)

  Skenario B: 20 concurrent request, masing-masing 1 query tapi
              digabung dengan teknik paralel (4 goroutine per request)
    → Setiap request butuh 4 koneksi sekaligus
    → 20 request × 4 koneksi = 80 koneksi dibutuhkan
    → Pool hanya punya 20 → 60 goroutine menunggu koneksi!
    → Total waktu bisa lebih lambat dari sequential meskipun per-request lebih cepat

  Kesimpulan:
    Parallel query mengurangi latency per-request TAPI menambah
    tekanan pada connection pool. Sesuaikan pool size atau batasi
    derajat paralelisme berdasarkan kapasitas pool.
──────────────────────────────────────────────────────────────

Konfigurasi connection pool yang direkomendasikan untuk sistem dengan roundtrip optimization:

db.SetMaxOpenConns(25)     // maksimum koneksi ke database
db.SetMaxIdleConns(10)     // koneksi idle yang dipertahankan
db.SetConnMaxLifetime(5 * time.Minute)  // rotasi koneksi untuk menghindari stale
db.SetConnMaxIdleTime(1 * time.Minute)  // tutup koneksi idle yang terlalu lama

Pola Anti-Pattern yang Sering Ditemukan #

// ✗ Anti-pattern 1: roundtrip untuk data yang bisa dihitung di query
// Roundtrip 1: ambil semua order
orders, _ := db.QueryContext(ctx, "SELECT id, total FROM orders WHERE user_id = ?", userID)
// Aplikasi menghitung total sendiri:
var grandTotal float64
for _, o := range orders { grandTotal += o.Total }

// ✓ Solusi: hitung di database, satu roundtrip saja
var grandTotal float64
db.QueryRowContext(ctx,
    "SELECT COALESCE(SUM(total), 0) FROM orders WHERE user_id = ?", userID,
).Scan(&grandTotal)

// ✗ Anti-pattern 2: roundtrip sequential untuk data yang bisa dijoin
// Roundtrip 1:
var userCity string
db.QueryRowContext(ctx, "SELECT city FROM users WHERE id = ?", userID).Scan(&userCity)
// Roundtrip 2: (butuh userCity dari roundtrip 1)
db.QueryRowContext(ctx,
    "SELECT COUNT(*) FROM orders WHERE city = ?", userCity)

// ✓ Solusi: subquery atau JOIN menghilangkan ketergantungan sequential
db.QueryRowContext(ctx, `
    SELECT COUNT(*)
    FROM orders o
    JOIN users u ON u.id = ?
    WHERE o.city = u.city
`, userID)

// ✗ Anti-pattern 3: roundtrip untuk validasi yang bisa dilakukan di INSERT
// Roundtrip 1: cek apakah email sudah ada
var count int
db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE email = ?", email).Scan(&count)
if count > 0 { return ErrEmailExists }
// Roundtrip 2: insert jika tidak ada
db.ExecContext(ctx, "INSERT INTO users (email, name) VALUES (?, ?)", email, name)

// ✓ Solusi: gunakan INSERT ... ON DUPLICATE KEY (MySQL)
// atau INSERT ... ON CONFLICT (PostgreSQL) — satu roundtrip, atomis
result, err := db.ExecContext(ctx, `
    INSERT INTO users (email, name)
    VALUES (?, ?)
    ON DUPLICATE KEY UPDATE id = id  -- no-op, tapi kita bisa deteksi
`, email, name)
affected, _ := result.RowsAffected()
if affected == 0 { return ErrEmailExists }

// ✗ Anti-pattern 4: roundtrip untuk update yang bisa dilakukan conditional
// Roundtrip 1: baca nilai current
var currentStock int
db.QueryRowContext(ctx, "SELECT stock FROM products WHERE id = ?", productID).Scan(&currentStock)
if currentStock < qty { return ErrInsufficientStock }
// Roundtrip 2: update
db.ExecContext(ctx, "UPDATE products SET stock = stock - ? WHERE id = ?", qty, productID)

// ✓ Solusi: update conditional dalam satu roundtrip, cek affected rows
result, _ := db.ExecContext(ctx, `
    UPDATE products
    SET stock = stock - ?
    WHERE id = ? AND stock >= ?
`, qty, productID, qty)
if affected, _ := result.RowsAffected(); affected == 0 {
    return ErrInsufficientStock
}

Cara Mendiagnosis Roundtrip Berlebih di Production #

Ketika API lambat tapi database terlihat sehat, ikuti langkah diagnosis ini:

Langkah diagnosis roundtrip overhead:
──────────────────────────────────────────────────────────────
  1. Aktifkan query logging dengan timestamp nanosecond
     → Lihat apakah ada banyak query yang identik atau sangat mirip

  2. Hitung total query per request
     → Lebih dari 20 query per request → perlu investigasi

  3. Ukur ratio: (total_db_time / total_request_time)
     → Jika > 80% waktu request dihabiskan di DB → periksa query count
     → Jika < 20% tapi request tetap lambat → ada bottleneck lain

  4. Cek apakah query terjadi sequential atau paralel
     → Timestamp query yang overlap → paralel (baik)
     → Timestamp query yang berurutan tanpa overlap → sequential (bisa diparalel)

  5. Cari pola query yang identik dalam satu request
     → Query "SELECT ... WHERE id = 1", "SELECT ... WHERE id = 2", dst.
     → Ini N+1 / roundtrip berlebih yang paling jelas
──────────────────────────────────────────────────────────────

Di Go, cara praktis untuk memonitor ini menggunakan sql.DB.Stats():

// Jalankan setelah request selesai untuk melihat pool health
stats := db.Stats()
log.Info("db pool stats",
    "open_connections", stats.OpenConnections,
    "in_use",           stats.InUse,
    "idle",             stats.Idle,
    "wait_count",       stats.WaitCount,      // berapa kali goroutine menunggu koneksi
    "wait_duration",    stats.WaitDuration,   // total waktu tunggu koneksi
)

// WaitCount tinggi → pool size perlu dinaikkan atau query count perlu dikurangi
// WaitDuration tinggi → ada goroutine yang menahan koneksi terlalu lama

Checklist Audit Roundtrip #

REVIEW KODE:
  □ Ada query di dalam loop (for/range) yang bisa di-batch dengan IN?
  □ Ada beberapa query sequential yang bisa digabung dengan JOIN atau CTE?
  □ Ada read-then-write yang bisa digabung dengan INSERT ON CONFLICT
    atau UPDATE WHERE ... RETURNING?
  □ Ada query independen yang bisa dijalankan paralel?
  □ Ada validasi (EXISTS/COUNT) yang bisa digabung ke operasi INSERT/UPDATE?

REVIEW KONFIGURASI:
  □ Prepared statement dipakai untuk query berulang dalam satu scope?
  □ Connection pool size sesuai dengan expected concurrent request?
  □ MaxIdleConns tidak terlalu kecil (menyebabkan koneksi baru terus dibuat)?

MONITORING:
  □ Ada metric query_count_per_request per endpoint?
  □ Ada metric db.Stats().WaitCount yang dipantau?
  □ Total waktu DB vs total waktu request diukur dan dibandingkan?

Ringkasan #

  • Setiap roundtrip punya biaya tetap — network traversal, TCP stack, wire protocol parsing, query planning, lock acquisition. Untuk query sederhana, biaya tetap ini bisa 10-20× lebih besar dari waktu eksekusi query itu sendiri.
  • 1.000 query kecil bisa jauh lebih lambat dari 1 query besar — meski total data yang dibaca sama. Overhead per-roundtrip terakumulasi menjadi ratusan milidetik tanpa ada query “lambat” sama sekali.
  • Batching dengan IN adalah cara paling mudah dan paling impactful — ganti loop query per-ID dengan satu query WHERE id IN (...). Ini bisa mengurangi 100 roundtrip menjadi 1.
  • CTE memungkinkan logika bertingkat dalam satu roundtrip — operasi read-then-write, penghitungan berlapis, dan filter berantai bisa ditulis sebagai satu statement SQL yang dikirim dalam satu roundtrip.
  • Parallel query mengurangi waktu tunggu, bukan jumlah roundtrip — tiga query independen yang dijalankan paralel membutuhkan waktu max(t1, t2, t3) bukan t1+t2+t3. Tapi butuh lebih banyak koneksi dari pool secara bersamaan.
  • INSERT ON CONFLICT dan UPDATE WHERE menggabungkan read + write menjadi satu roundtrip — validasi uniqueness atau cek kondisi sebelum write sering bisa dieliminasi dengan pola atomis ini.
  • Connection pool dan roundtrip saling mempengaruhi — parallel query butuh lebih banyak koneksi bersamaan. Jika pool size tidak cukup, goroutine menunggu koneksi dan keuntungan paralel hilang.
  • Diagnosis: hitung query per request, ukur WaitDuration — API lambat dengan database sehat hampir selalu berarti terlalu banyak roundtrip atau koneksi yang tidak dikelola dengan baik.

← Sebelumnya: N+1 Effect   Berikutnya: Bulk CUD Operation →

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