A detailed backend reference

Menguasai Scaling & Performa

Performance engineering adalah disiplin memahami bagaimana sistem berperilaku di bawah beban, di mana bottleneck bersembunyi, dan bagaimana berpikir jernih saat semuanya tampak berantakan. Bab ini membangun intuisi lapis demi lapis, dari definisi mentah "cepat" lewat pengukuran latensi, tuning database, strategi caching, dan dua mode fundamental scaling. Semuanya berpijak pada engineering backend praktis.


Versi Sederhana

01

Apa Maksudnya "Cepat"?

Saat pengguna mengklik tombol di web app Anda, rentetan peristiwa menyala: browser mengemas request HTTP, request itu menyeberangi internet ke server Anda, server memprosesnya (mungkin memukul database, memanggil API eksternal, atau mengirim email), dan akhirnya server menyusun respons, biasanya JSON, yang bepergian kembali melewati internet. Browser lalu mem-parse respons dan merender hasilnya di layar.

Total waktu yang berlalu antara klik dan render akhir itulah yang pengguna persepsikan sebagai kecepatan. Saat seseorang bilang "aplikasi Anda lambat", mereka menggambarkan durasi end-to-end ini, bahkan jika mereka tidak pernah mendengar kata latensi.

User Browser HTTP Request Server Process Logic Query Database External API JSON Response TOTAL LATENCY
Fig 1 / Request lifecycle: every millisecond adds up

Performa bukan kualitas samar yang subjektif. Ia bisa, dan harus, diekspresikan dalam angka. Tiga metrik fundamentalnya latensi (berapa lama satu request), throughput (berapa banyak request per satuan waktu), dan utilisasi (berapa persen kapasitas yang terpakai). Bersama-sama, mereka membentuk kosakata performance engineering.


02

Selami Latensi

Latensi adalah waktu yang berlalu dari momen request dikirim sampai momen respons diterima. Ia metrik performa yang paling terlihat pengguna, angka yang pelanggan Anda rasakan di ujung jari.

Latensi bukan satu angka

Kesalahan umum memperlakukan latensi sebagai satu nilai tetap seperti "API kami merespons dalam 200ms". Kenyataannya, latensi bervariasi dari request ke request karena banyak faktor dunia nyata:

Cache hit vs. miss: request yang dilayani CDN atau cache dalam memori seperti Redis bisa kembali dalam 5ms, sementara request yang sama memukul database dingin makan 200ms. Beban server: saat server Anda idle, ia langsung mengambil tiap request; saat memproses 50 request serentak, kedatangan baru menunggu di antrean. Kondisi jaringan: kehilangan paket, perubahan rute, dan jarak geografis semuanya memperkenalkan variabilitas. Kompleksitas request: mengambil profil pengguna lebih cepat daripada membuat laporan analitik bulanan.

Why averages lie

Bayangkan 1.000 request: 990 selesai dalam 50ms dan 10 makan 5.000ms. Rata-ratanya ±100ms, yang terdengar sehat. Tapi 10 request outlier itu mewakili pengguna nyata yang menatap loading spinner selama 5 detik. Di 1 juta request/hari, itu 10.000 pengalaman pengguna buruk per hari, sepenuhnya tak terlihat oleh rata-rata.

Karena rata-rata menyerap variasi, ia hampir sia-sia untuk analisis performa. Yang kita butuhkan adalah cara membicarakan distribusi latensi, yang membawa kita ke persentil.


03

Persentil: P50, P90, P99

Persentil memberi tahu Anda ambang latensi di mana persentase request tertentu berada di bawahnya. Tiga persentil yang akan Anda temui terus-menerus:

PersentilMaknaContoh
P50 (median) 50% request lebih cepat dari nilai ini P50 = 80ms -> separuh pengguna Anda melihat <=80ms
P90 90% lebih cepat; 10% pengguna mengalami ini atau lebih buruk P90 = 400ms -> 1 dari 10 pengguna menunggu 400ms+
P99 99% lebih cepat; 1% pengguna mengalami ini atau lebih buruk P99 = 2 detik -> 1 dari 100 pengguna menunggu 2 detik+
Latency -> Requests P50 P90 P99 80ms 400ms 2000ms
Fig 2 / Latency distribution with percentile markers

Kenapa P99 dan P95 paling penting

Engineer backend fokus pada latensi ekor (P95/P99) karena dua alasan. Pertama, request yang mendarat di ekor biasanya yang paling kompleks, mereka melibatkan logika bisnis terberat, join terbanyak, atau panggilan antar-layanan terbanyak. Kedua, request ini sering mewakili pengguna Anda yang paling bernilai: seseorang yang membeli, membuat laporan, atau menyelesaikan alur checkout, persis alur kerja tempat hang 5 detik kehilangan pendapatan dan kepercayaan.

Mengukur persentil di Go

Library standar Go tidak mengirim kalkulator persentil, tapi Anda bisa menghitungnya dari irisan tersortir atau memakai library. Ini implementasi sederhana dari nol:

Go
package main

import (
    "fmt"
    "math"
    "sort"
    "time"
)

// Percentile returns the p-th percentile from a slice of durations.
// p must be between 0 and 100.
func Percentile(latencies []time.Duration, p float64) time.Duration {
    if len(latencies) == 0 {
        return 0
    }
    sorted := make([]time.Duration, len(latencies))
    copy(sorted, latencies)
    sort.Slice(sorted, func(i, j int) bool {
        return sorted[i] < sorted[j]
    })

    rank := (p / 100.0) * float64(len(sorted)-1)
    idx  := int(math.Ceil(rank))
    return sorted[idx]
}

func main() {
    // Simulated latency samples
    samples := []time.Duration{
        12 * time.Millisecond, 15 * time.Millisecond,
        22 * time.Millisecond, 48 * time.Millisecond,
        95 * time.Millisecond, 110 * time.Millisecond,
        340 * time.Millisecond, 890 * time.Millisecond,
        1200 * time.Millisecond, 4800 * time.Millisecond,
    }

    fmt.Printf("P50:  %v\n", Percentile(samples, 50))
    fmt.Printf("P90:  %v\n", Percentile(samples, 90))
    fmt.Printf("P99:  %v\n", Percentile(samples, 99))
}

Mengukur persentil di Python

Python
import numpy as np

# Simulated latency samples (in milliseconds)
samples = [12, 15, 22, 48, 95, 110, 340, 890, 1200, 4800]

print(f"P50:  {np.percentile(samples, 50):.0f}ms")
print(f"P90:  {np.percentile(samples, 90):.0f}ms")
print(f"P99:  {np.percentile(samples, 99):.0f}ms")
print(f"P99.9:{np.percentile(samples, 99.9):.0f}ms")
Reference: Prometheus, Histograms & Summaries, the industry standard for collecting percentile data in production.

04

Throughput

Sementara latensi menggambarkan durasi satu request, throughput mengukur berapa banyak request yang bisa ditangani sistem Anda dalam jendela waktu tertentu, biasanya diekspresikan sebagai request per detik (RPS) atau request per menit (RPM).

Kedua metrik saling terkait dalam tapi tidak dengan cara linier sederhana. Sistem Anda mungkin menunjukkan latensi sangat baik di 10 RPS, katakanlah 50ms per request. Tapi di 5.000 RPS, latensi bisa melonjak ke 2 detik. Memahami throughput bersamaan latensi membuat Anda bisa menjawab pertanyaan operasional kritis: Bisakah kita bertahan lonjakan lalu lintas Black Friday? Berapa banyak pengguna serentak yang bisa kita dukung sebelum butuh resource lagi? Apa yang terjadi jika kita di-feature podcast populer besok?

Throughput = Requests Handled / Time Period

Kurva latensi: throughput

Saat throughput naik, latensi naik perlahan dulu tapi kemudian dramatis, ia mengikuti kurva, bukan garis. Di throughput rendah, server kebanyakan idle dan tiap request dilayani seketika. Saat throughput memanjat menuju kapasitas, request mulai mengantre dan bersaing untuk CPU, memori, dan I/O. Mendekati saturasi, latensi meledak eksponensial.

Throughput (RPS) -> Latency -> Expected (linear) Actual Safe operating zone (60-80%) Danger zone
Fig 3 / Latency grows exponentially as throughput approaches capacity

Hubungan ini diatur teori antrean: khususnya model antrean M/M/1 dari riset operasi. Saat utililisasi rho mendekati 1,0, waktu tunggu rata-rata cenderung menuju tak hingga:

W = S / (1 - rho)   where S = service time, rho = utilization
Reference: MDN, Web Performance, Mozilla's comprehensive guide to front-to-back performance measurement.

05

Utilisasi & Antrean

Utilisasi adalah persentase kapasitas total sistem Anda yang aktif terpakai. Di 0% sistem idle; di 100% setiap resource tersaturasi dan tidak ada yang baru bisa diproses.

Analogi toko es krim

Bayangkan toko es krim dengan satu pekerja. Minggu malam yang sepi, Anda masuk, memesan, dan dilayani seketika, utililisasi rendah, latensi rendah. Selasa siang, ada antrean panjang. Pekerja menyiapkan tiap cone di kecepatan yang persis sama, tapi waktu tunggu Anda meroket karena Anda harus menunggu semua orang di depan Anda. Throughput pekerja tidak berubah. Latensi yang Anda rasakan yang berubah.

Sistem backend berperilaku identik: saat utililisasi CPU dan memori rendah, request dilayani seketika. Saat lebih banyak request tiba, tiap request menunggu request di depannya selesai, membentuk antrean. Semakin tinggi utililisasi, semakin panjang antrean, dan semakin lama tiap request menunggu.

Analogi jalan tol

Anggap jalan tol di tingkat kapasitas berbeda. Di 50%, lalu lintas mengalir mulus dan mobil menjaga kecepatan. Di 80%, pindah jalur butuh lebih hati-hati dan kecepatan mulai turun. Di 90%, sistem jadi kacau, kadang mengalir, kadang macet. Setiap injakan rem menciptakan efek riak. Di 100%, kemacetan total: tidak ada yang bergerak.

Hubungan eksponensial

Hubungan antara utililisasi dan latensi tidak linier. Kita secara intuitif mengharapkan menggandakan utililisasi menggandakan latensi, tapi kenyataannya jauh lebih buruk. Latensi tetap relatif datar sampai ±70% utililisasi, lalu melengkung tajam ke atas, dan mendekati 100% hampir vertikal, mendekati tak hingga.

Key Takeaway

Anda tidak akan pernah bisa menjalankan sistem di 100% utililisasi dan mengharapkannya berkinerja. Sistem produksi biasanya menarget utilisasi 60-80%, menyisakan 20-40% sebagai ruang kepala untuk ledakan lalu lintas. Lalu lintas bursty secara alami, ia tiba dalam gelombang, bukan tingkat tetap, dan penyangga Anda yang menyerap lonjakan itu.


06

Menemukan Bottleneck

Saat sistem Anda lambat, sesuatu yang spesifik yang menyebabkan kelambatan. Sesuatu itu disebut bottleneck. Disiplin paling kritis dalam performance engineering adalah mengidentifikasi bottleneck sebenarnya sebelum menerapkan perbaikan, bukan menebak.

Jebakan melompat ke solusi

Saat engineer menjumpai API lambat, instingnya meraih obat standar buku: tambah caching Redis, upgrade Postgres, lempar lebih banyak server ke sana. Kadang Anda beruntung. Tapi lebih sering, Anda menghabiskan berhari-hari atau berminggu-minggu mengimplementasikan solusi untuk masalah yang sebenarnya tidak Anda miliki, sementara bottleneck sebenarnya duduk tak tersentuh.

Contoh dunia nyata

Anggap endpoint GET /products/:id yang terasa lambat. Reaksi lutut: database pasti masalahnya. Anda menghabiskan seminggu membangun lapisan caching Redis. Anda deploy. API masih lambat.

Sekarang Anda menambah pengukuran waktu di seluruh kode dan menemukan: query database makan 10ms, pencarian Redis baru makan 5ms, tapi panggilan logging sinkron ke layanan Elasticsearch jarak jauh makan 500ms, memblokir request sambil menunggu respons jaringan. Database tidak pernah jadi masalahnya.

The golden rule

Jangan pernah menebak. Selalu ukur. Bottleneck tersembunyi umum meliputi: logging sinkron ke layanan jarak jauh, serialisasi JSON/XML payload besar, panggilan API eksternal dalam loop, payload respons besar yang menyebabkan transmisi jaringan lambat, dan middleware yang berjalan di setiap request.

Middleware timing di Go

Ini middleware praktis yang mencatat timing per-request, supaya Anda bisa mulai mengidentifikasi endpoint lambat:

Go
package middleware

import (
    "log"
    "net/http"
    "time"
)

// TimingMiddleware logs the duration of every HTTP request.
func TimingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // Wrap the ResponseWriter to capture the status code
        rw := &statusWriter{ResponseWriter: w, status: 200}
        next.ServeHTTP(rw, r)

        duration := time.Since(start)
        log.Printf(
            "method=%s path=%s status=%d duration=%v",
            r.Method, r.URL.Path, rw.status, duration,
        )
    })
}

type statusWriter struct {
    http.ResponseWriter
    status int
}

func (w *statusWriter) WriteHeader(code int) {
    w.status = code
    w.ResponseWriter.WriteHeader(code)
}

Decorator timing di Python (FastAPI)

Python
import time
import logging
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware

logger = logging.getLogger("perf")

class TimingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        start = time.perf_counter()
        response = await call_next(request)
        duration_ms = (time.perf_counter() - start) * 1000

        logger.info(
            f"method={request.method} path={request.url.path} "
            f"status={response.status_code} duration={duration_ms:.1f}ms"
        )
        return response

07

Profiling & Flame Graph

Profiling adalah praktik mengukur persis di mana aplikasi Anda menghabiskan waktunya. Profiler menempel ke aplikasi yang berjalan, mengambil sampel call stack pada interval teratur, dan mencatat fungsi mana yang mengeksekusi, berapa lama berjalan, dan berapa banyak CPU atau memori yang mereka konsumsi.

Cara kerja profiler

CPU profiler (seperti pprof bawaan Go atau cProfile Python) secara berkala menginterupsi program dan menangkap snapshot call stack saat ini. Setelah ribuan sampel, gambaran statistik muncul menunjukkan fungsi mana yang mendominasi waktu eksekusi.

Flame graph

Keluaran profiler mentah bisa mencantumkan ribuan fungsi, masing-masing menyumbang sebagian waktu total. Flame graph membuat data ini visual: tiap fungsi adalah bar horizontal, bar lebih lebar menunjukkan lebih banyak waktu, dan fungsi yang dipanggil fungsi lain ditumpuk vertikal. Satu lirikan ke flame graph memberi tahu Anda di mana hotspot aplikasi Anda.

main.handleRequest(), 100% parseJSON 8% auth 5% db.QueryProducts 12% logging.SendRemote 70% <- BOTTLENECK net.http.Post 55% serialize 15% tcp.Dial 40%
Fig 4 / Flame graph: wider bars = more time; the logging call dominates

CPU profiling di Go dengan pprof

Go
import (
    "net/http"
    _ "net/http/pprof"  // side-effect import: registers /debug/pprof/
)

func main() {
    // Your normal app setup...
    // pprof endpoints are now accessible at /debug/pprof/
    // Collect a 30-second CPU profile:
    //   go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
    //   (pprof) web   <- opens flame graph in browser
    http.ListenAndServe(":6060", nil)
}

CPU profiling di Python

Python
import cProfile
import pstats

# Profile a function and print the top 20 slowest calls
with cProfile.Profile() as pr:
    handle_request()  # your function under test

stats = pstats.Stats(pr)
stats.sort_stats("cumulative")
stats.print_stats(20)

# For flame graphs, use py-spy (external tool):
#   pip install py-spy
#   py-spy record -o profile.svg -- python app.py

Bottleneck CPU-bound vs I/O-bound

Profiler unggul menemukan bottleneck CPU-bound, fungsi yang mengonsumsi siklus komputasi berlebihan (pemrosesan gambar, inferensi machine learning, matematika berat). Tapi, kebanyakan aplikasi backend I/O-bound: waktunya dihabiskan menunggu respons database, panggilan API eksternal, pembacaan file, atau acknowledgment message queue. CPU profiler kurang efektif di sini karena CPU tidak bekerja, ia menunggu. Untuk kerja I/O-bound, Anda butuh distributed tracing.


08

Distributed Tracing

Distributed tracing mengikuti satu request saat mengalir melewati seluruh sistem Anda, lintas layanan, database, cache, dan API eksternal, mencatat timestamp di setiap batas. Keluarannya trace: pohon "span", masing-masing mewakili operasi ber-timing.

Tempat profiler menunjukkan apa yang dilakukan CPU, trace menunjukkan di mana jam berdetak. Misalnya, trace GET /products/5 mungkin mengungkap: 2ms di logika API Anda, 800ms di query database, dan 50ms di serialisasi. Kini Anda tahu persis ke mana fokus.

0ms 200ms 400ms 600ms 800ms Request 852ms total Auth 5ms DB Query 800ms <- BOTTLENECK Serialize 42ms Response 5ms
Fig 5 / Distributed trace: the DB query dominates this request's timeline

Tool tracing populer meliputi Jaeger, Zipkin, dan OpenTelemetry (standar yang muncul yang menyatukan tracing, metrik, dan logging). OpenTelemetry menyediakan SDK untuk Go, Python, Java, dan kebanyakan bahasa utama.

Reference: OpenTelemetry Documentation, the vendor-neutral standard for distributed tracing, metrics, and logs.

09

Masalah Query N+1

Masalah query N+1 salah satu anti-pola performa paling terkenal di engineering backend. Ia terjadi saat Anda membuat 1 query untuk mengambil daftar N item, lalu N query tambahan untuk mengambil data terkait untuk tiap item, total N+1 round trip database.

Cara ia terjadi

Bayangkan aplikasi blog. Untuk merender beranda, Anda butuh 20 post blog dengan nama penulis tiap post. Pendekatan naif: query semua 20 post, lalu loop dan buat query terpisah untuk penulis tiap post. Itu 21 query database. Untuk 1.000 post, 1.001 query. Bahkan di 5ms per query, 1.001 query makan lebih dari 5 detik.

N+1 PATTERN (BAD) SELECT * FROM posts (1 query) SELECT author WHERE id=1 SELECT author WHERE id=2 ... (x 20 more) 21 queries = 105ms BULK FETCH (GOOD) SELECT * FROM posts (1 query) SELECT * FROM users WHERE id IN (...) 2 queries = 10ms 10x faster by eliminating the loop
Fig 6 / N+1 queries vs. bulk fetch: same data, vastly different performance

Perbaikannya: batch fetching

Alih-alih query dalam loop, kumpulkan semua ID yang Anda butuhkan dan ambil dalam satu query memakai WHERE id IN (...). Hasilnya: selalu 2 query berapa pun jumlah item, satu untuk daftarnya, satu untuk data terkait.

Go: menyelesaikan N+1 dengan batch query

Go
func GetPostsWithAuthors(ctx context.Context, db *sql.DB) ([]PostWithAuthor, error) {
    // Query 1: fetch all posts
    posts, err := queryPosts(ctx, db)
    if err != nil {
        return nil, err
    }

    // Collect unique author IDs
    authorIDSet := make(map[int64]struct{})
    for _, p := range posts {
        authorIDSet[p.AuthorID] = struct{}{}
    }
    authorIDs := make([]int64, 0, len(authorIDSet))
    for id := range authorIDSet {
        authorIDs = append(authorIDs, id)
    }

    // Query 2: fetch ALL authors in one batch
    authors, err := queryAuthorsByIDs(ctx, db, authorIDs)
    if err != nil {
        return nil, err
    }

    // Build lookup map and merge
    authorMap := make(map[int64]Author)
    for _, a := range authors {
        authorMap[a.ID] = a
    }

    result := make([]PostWithAuthor, len(posts))
    for i, p := range posts {
        result[i] = PostWithAuthor{Post: p, Author: authorMap[p.AuthorID]}
    }
    return result, nil
}

Python (Django): selectrelated & prefetchrelated

Python
# BAD, N+1 queries (Django ORM)
posts = Post.objects.all()
for post in posts:
    print(post.author.name)  # Each access fires a separate query!

# GOOD, 1 query with JOIN (ForeignKey)
posts = Post.objects.select_related("author").all()

# GOOD, 2 queries (ManyToMany or reverse FK)
posts = Post.objects.prefetch_related("tags").all()

Setiap ORM modern menyediakan primitif bulk-fetch: Django punya selectrelated / prefetchrelated, Ruby on Rails punya includes, Prisma dan Drizzle menawarkan leftJoin dan pemuatan relasi. Jika Anda menulis SQL mentah, jawabannya JOIN. Pola yang harus dihindari loop apa pun yang menembakkan query per iterasi.

Pro tip

Kebanyakan ORM modern punya mode "cetak SQL yang dihasilkan". Aktifkan selama pengembangan untuk menangkap pola N+1 sebelum mencapai produksi. Di Django: settings.LOGGING dengan logger django.db.backends. Di GORM Go: db.Debug().


10

Index & Query Plan

Sumber paling umum masalah performa database adalah ketiadaan index yang tepat. Index struktur data, biasanya B-tree, yang memelihara salinan tersortir dari nilai di satu atau lebih kolom, beserta penunjuk kembali ke baris penuh. Ia mengubah sequential scan lambat (memeriksa setiap baris) menjadi pencarian cepat.

Analogi perpustakaan

Bayangkan perpustakaan dengan sejuta buku tapi tanpa katalog. Menemukan semua buku karya John Green menuntut berjalan ke setiap rak, memeriksa setiap buku, full table scan. Sekarang tambah katalog tersortir berdasarkan nama penulis dengan lokasi rak. Anda mencari "Green, John," menemukan tiga referensi rak, berjalan langsung ke rak tersebut, dan kembali dalam hitungan menit alih-alih hari. Katalog itu index-nya.

Sequential scan vs. index scan

Tanpa index, database Anda melakukan sequential scan (juga disebut full table scan), ia membaca setiap baris untuk memeriksa klausa WHERE. Untuk sejuta baris, ini bisa makan beberapa detik. Dengan index B-tree, database melakukan pencarian logaritmik, menemukan baris yang cocok dalam beberapa milidetik apa pun ukuran tabelnya.

Sequential scan: O(n) -> Index scan: O(log n)

Membuat index

SQL
-- Basic single-column index
CREATE INDEX idx_posts_author_id ON posts (author_id);

-- Composite index: order matters!
-- This helps queries filtering by user_id, or user_id + created_at
-- It does NOT help queries filtering ONLY by created_at
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);

-- Covering index: all needed data lives IN the index
-- The query never touches the main table at all
CREATE INDEX idx_departments_name ON departments (name) INCLUDE (id);

Index Komposit vs. Covering

Composite Index
  • Meng-index beberapa kolom sekaligus
  • Mengoptimalkan query yang memfilter pada kolom itu
  • Urutan kolom penting: aturan awalan paling kiri
  • Index pada (A, B) membantu WHERE A=... dan WHERE A=... AND B=... tapi bukan WHERE B=... sendirian
Covering Index
  • Menyertakan semua kolom yang dibutuhkan query
  • Database melayani data sepenuhnya dari index
  • Menghilangkan "heap lookup" (kembali ke tabel)
  • Memakai klausa INCLUDE di Postgres untuk menambah kolom non-key

Biaya index

Index tidak gratis. Setiap index mengonsumsi ruang disk (dan idealnya muat di memori). Lebih kritis, setiap INSERT, UPDATE, atau DELETE harus memperbarui semua index terkait. Jika Anda membuta meng-index setiap kolom, operasi tulis Anda melambat dramatis. Pedomannya: index kolom yang muncul di klausa WHERE, JOIN, dan ORDER BY dari query paling sering Anda, dan biarkan sisanya.

EXPLAIN ANALYZE: membaca query plan

Untuk menemukan apakah query Anda memakai index, Postgres menyediakan perintah EXPLAIN ANALYZE. Ia menjalankan query dan menampilkan rencana eksekusi persis, tabel mana yang dipindai, index mana yang dipakai, dan berapa lama tiap langkah.

SQL
EXPLAIN ANALYZE
SELECT p.*, u.name AS author_name
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.published = true
ORDER BY p.created_at DESC
LIMIT 20;

-- Look for "Seq Scan" in the output, that means no index is being used.
-- After adding an index, re-run and confirm it shows "Index Scan".
Reference: PostgreSQL, Using EXPLAIN, official documentation on reading query execution plans.

11

Connection Pooling

Membangun koneksi database mahal. Setiap koneksi baru melibatkan three-way handshake TCP, autentikasi, negosiasi enkripsi, setup state sesi, dan alokasi memori di server database (biasanya beberapa MB per koneksi). Jika aplikasi Anda membuka koneksi baru untuk setiap query dan langsung menutupnya, Anda membayar biaya ini di setiap request.

Dua masalahnya

Masalah 1: Overhead latensi. Setiap setup koneksi menambah puluhan milidetik overhead, waktu terbuang sebelum query apa pun berjalan. Masalah 2: Kehabisan koneksi. Database punya batas keras koneksi serentak (Postgres default ±100, sering dikonfigurasi 300-500). Selama lonjakan lalu lintas, backend Anda bisa menghabiskan batas koneksi dan men-crash database.

Connection pooling

Connection pool memelihara set koneksi yang sudah didirikan dan bisa dipakai ulang. Alih-alih membuat koneksi baru untuk tiap query, aplikasi Anda meminjam koneksi dari pool, memakainya, dan mengembalikannya. Koneksi tetap terbuka untuk dipakai ulang request berikutnya. Ini menghilangkan overhead latensi dan risiko kehabisan koneksi.

Server 1 Server 2 Server 3 EXTERNAL POOL (e.g. PgBouncer) max: 250 conns PostgreSQL max: 300
Fig 7 / External connection pool (PgBouncer) sits between servers and database

Pooling internal vs. eksternal

Internal Pool
  • Pool hidup di dalam tiap instance server
  • Tiap instance memelihara koneksinya sendiri
  • Setup sederhana; kebanyakan driver DB menyertakannya
  • Risiko: 3 instance x 150 koneksi = 450, yang bisa melebihi batas DB 300
External Pool
  • Proses mandiri (PgBouncer, PgCat)
  • Semua instance server berbagi satu pool
  • Total koneksi ke DB dikendalikan terpusat
  • Dipilih untuk sistem produksi yang di-scaling horizontal

Connection pooling di Go

Go
import "database/sql"

db, err := sql.Open("postgres", connStr)
if err != nil {
    log.Fatal(err)
}

// Configure the built-in connection pool
db.SetMaxOpenConns(25)                  // max open connections to the DB
db.SetMaxIdleConns(10)                  // max idle connections kept ready
db.SetConnMaxLifetime(5 * time.Minute)  // recycle connections after 5 min
db.SetConnMaxIdleTime(1 * time.Minute)  // close idle connections after 1 min

Connection pooling di Python (SQLAlchemy)

Python
from sqlalchemy import create_engine

engine = create_engine(
    "postgresql://user:pass@host/db",
    pool_size=20,          # number of persistent connections
    max_overflow=10,       # extra connections allowed during spikes
    pool_timeout=30,       # seconds to wait for a free connection
    pool_recycle=1800,     # recycle connections every 30 minutes
    pool_pre_ping=True,    # test connection health before use
)
Reference: PgBouncer, lightweight connection pooler for PostgreSQL, the de facto standard for production Postgres deployments.

12

Fundamental Caching

Caching adalah praktik menyimpan hasil operasi mahal di lapisan penyimpanan lebih cepat supaya request berikutnya untuk data yang sama bisa dilayani tanpa mengulang kerja mahal. Gagasannya indah sederhana: hitung sekali, layani banyak.

Dalam praktik, Anda menaruh cache (biasanya Redis, Memcached, atau Valkey) di depan database. Saat request tiba, Anda cek cache dulu. Pada cache hit, Anda mengembalikan data ter-cache seketika, sering di bawah 5ms. Pada cache miss, Anda query database, menyimpan hasilnya di cache, lalu mengembalikannya. Satu langkah bisa mengurangi latensi yang dirasakan dari 800ms ke 50ms.

Caching lokal vs. terdistribusi

Local Cache
  • Memori dalam proses (dictionary/map)
  • Akses tercepat mungkin: 1-3ms
  • Privat ke tiap instance server
  • Masalah: inkonsistensi cache lintas instance
Distributed Cache
  • Layanan eksternal (Redis, Memcached, Valkey)
  • Dibagikan lintas semua instance server
  • Tanpa inkonsistensi antar server
  • Kekurangan: round trip jaringan menambah ±5-50ms

Caching bertingkat

Banyak sistem produksi memakai keduanya: cache lokal kecil untuk data "terpanas" (item paling sering diakses) yang didukung cache terdistribusi lebih besar. Request memeriksa cache lokal dulu (sub-milidetik), lalu jatuh ke cache terdistribusi (beberapa milidetik), dan hanya memukul database saat miss penuh.

Caching Redis sederhana di Go

Go
import (
    "context"
    "encoding/json"
    "time"
    "github.com/redis/go-redis/v9"
)

var rdb = redis.NewClient(&redis.Options{Addr: "localhost:6379"})

func GetProduct(ctx context.Context, id string) (*Product, error) {
    cacheKey := "product:" + id

    // 1. Try cache first
    cached, err := rdb.Get(ctx, cacheKey).Result()
    if err == nil {
        var p Product
        json.Unmarshal([]byte(cached), &p)
        return &p, nil  // Cache HIT, ~2ms
    }

    // 2. Cache miss, query database
    p, err := queryProductFromDB(ctx, id)
    if err != nil {
        return nil, err
    }

    // 3. Store in cache with TTL
    data, _ := json.Marshal(p)
    rdb.Set(ctx, cacheKey, data, 10*time.Minute)

    return p, nil
}

Caching Redis sederhana di Python

Python
import json, redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

def get_product(product_id: str) -> dict:
    cache_key = f"product:{product_id}"

    # 1. Try cache
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)  # Cache HIT

    # 2. Cache miss, query database
    product = query_product_from_db(product_id)

    # 3. Store with 10-minute TTL
    r.setex(cache_key, 600, json.dumps(product))
    return product

13

Invalidasi Cache

Ada pepatah terkenal di ilmu komputer: "Hanya ada dua masalah sulit dalam pemrograman: menamai hal, invalidasi cache, dan off-by-one error." Invalidasi cache, menjaga data ter-cache sinkron dengan sumber kebenaran, benar-benar salah satu masalah tersulit di sistem terdistribusi.

Kedaluwarsa berbasis waktu (TTL)

Pendekatan paling sederhana: set Time To Live (TTL) di setiap entri cache. Setelah TTL kedaluwarsa, entri otomatis dihapus. Request berikutnya memicu query database segar. Tantangannya memilih TTL yang tepat, terlalu pendek dan Anda kehilangan sebagian besar manfaat caching; terlalu panjang dan pengguna melihat data basi.

Invalidasi berbasis peristiwa

Alih-alih menunggu timer, Anda secara eksplisit menghapus atau memperbarui entri cache kapan pun data yang mendasarinya berubah. Misalnya, saat pengguna memperbarui profilnya, handler pembaruan juga menghapus entri profil ter-cache. Request baca berikutnya akan mengambil data segar dari database dan mengisi ulang cache.

Keuntungannya: data selalu segar. Risikonya: jika Anda lupa meng-invalidate di bahkan satu jalur kode yang memodifikasi data, Anda menyajikan hasil basi. Ini menuntut disiplin dan review kode menyeluruh.


14

Pola Caching

Pola caching menentukan kapan dan bagaimana data masuk cache. Tiga pola utama mendominasi engineering backend:

1. Cache-Aside (Lazy Loading)

Pola paling umum. Kode aplikasi Anda mengelola cache secara eksplisit: cek cache -> saat miss, query DB -> simpan hasil di cache -> kembalikan. Saat tulis, invalidate atau hapus entri cache.

Cache-Aside Flow

Baca: Cek cache -> hit? Kembalikan. Miss? Query DB -> tulis ke cache -> kembalikan. Tulis: Update DB -> hapus entri cache (baca berikutnya mengisi ulang).

2. Write-Through

Setiap operasi tulis memperbarui keduanya cache dan database secara bersamaan. Baca selalu menemukan data segar di cache, tidak ada cache miss setelah tulis pertama. Komprominya: latensi tulis naik karena Anda menulis ke dua sistem sebelum mengembalikan respons sukses.

3. Write-Behind (Write-Back)

Tulis hanya memperbarui cache dan langsung mengembalikan. Pembaruan database terjadi asinkron di latar. Ini memberi latensi tulis tercepat tapi memperkenalkan risiko: jika cache gagal sebelum tulis asinkron selesai, data hilang. Cache dan database mungkin untuk sementara tidak konsisten.

PolaKecepatan BacaKecepatan TulisKonsistensiKompleksitas
Cache-AsideCepat (setelah miss pertama)NormalBaik (invalidasi eksplisit)Rendah
Write-ThroughSelalu cepatLebih lambat (tulis ganda)KuatSedang
Write-BehindSelalu cepatTercepatEventual (risiko kehilangan)Tinggi

15

Cache Hit Rate

Cache hit rate mengukur berapa persen request yang berhasil dilayani dari cache alih-alih jatuh ke database. Hit rate 90% berarti 9 dari 10 request dilayani dari cache; hanya 10% menyentuh database. Hit rate 20% berarti lapisan caching Anda nyaris tidak bekerja.

Cache Hit Rate = Cache Hits / (Cache Hits + Cache Misses) x 100%

Faktor yang memengaruhi hit rate

Durasi TTL: TTL lebih panjang menjaga item di cache lebih lama, menaikkan hit rate tapi berisiko basi. Ukuran cache: Cache lebih besar memegang lebih banyak entri, secara alami menaikkan probabilitas hit. Pola akses data: Jika lalu lintas Anda sangat terkonsentrasi pada set kecil item "panas" (distribusi power-law), caching sangat efektif. Jika lalu lintas tersebar merata di jutaan key unik, hit rate akan lebih rendah. Kebijakan eviction: Algoritma seperti LRU (Least Recently Used) dan LFU (Least Frequently Used) menentukan entri mana yang di-evict saat cache penuh, memilih yang tepat tergantung pola akses Anda.

Target hit rates

Untuk kebanyakan aplikasi web, hit rate 70-95% dianggap sehat. Di bawah 50% menandakan ketidakcocokan fundamental antara strategi caching Anda dan pola akses aktual, saatnya memeriksa ulang TTL, key cache, dan kebijakan eviction Anda.


16

Vertical Scaling

Vertical scaling (juga disebut "scaling up") berarti mengganti server Anda dengan mesin lebih kuat, lebih banyak core CPU, lebih banyak RAM, penyimpanan lebih cepat, network card lebih baik. Kode Anda tidak berubah. Arsitektur Anda tidak berubah. Anda cukup meng-upgrade perangkat kerasnya.

Kenapa ia menarik

Vertical scaling bentuk scaling paling sederhana. Server dengan 2x core CPU menangani sekitar 2x request. Server dengan 2x RAM bisa menampung 2x data ter-cache. Anda menghindari seluruh kategori kompleksitas sistem terdistribusi: tanpa load balancer, tanpa sinkronisasi state, tanpa penanganan partisi jaringan. Secara ekonomi, satu mesin dengan 2x daya sering lebih murah daripada dua mesin setengah ukurannya, dan Anda melewati overhead operasional mengelola armada.

Batas keras vertical scaling

Plafon perangkat keras: Penyedia cloud menawarkan mesin sampai ukuran tertentu. Begitu Anda memaksimalkan instance terbesar yang tersedia (mis. 96 core, 384 GB RAM), Anda sama sekali tidak bisa scaling lebih jauh. Single point of failure: Satu mesin berarti satu domain kegagalan. Jika ia crash, seluruh layanan Anda offline. Tanpa distribusi geografis: Satu server di satu pusat data berarti pengguna di sisi lain dunia mengalami latensi tinggi, fisika memberlakukan lantai kecepatan cahaya untuk round trip lintas benua (±100ms AS<->India).

2 cores 4 GB 50 GB $50/mo -> 8 cores 32 GB 500 GB SSD $320/mo -> 32 cores 96 GB RAM 2 TB NVMe 10 Gbps NIC $2,400/mo HARDWARE CEILING
Fig 8 / Vertical scaling: bigger machines, same architecture, hard ceiling

17

Horizontal Scaling

Horizontal scaling (juga disebut "scaling out") mengambil pendekatan berlawanan: alih-alih membuat satu mesin lebih besar, Anda menambah lebih banyak mesin berukuran serupa yang bekerja bersama menangani beban.

Keuntungan

Tanpa plafon keras: Butuh kapasitas lebih? Tambah server. Tidak ada batas berapa banyak instance yang bisa Anda jalankan. Redundansi: Jika satu server gagal, yang lain terus melayani lalu lintas, kegagalan diserap, bukan katastrofal. Distribusi geografis: Anda bisa menaruh server di banyak pusat data di seluruh dunia, merutekan pengguna ke yang terdekat untuk latensi minimal.

Biaya kompleksitas

Horizontal scaling memperkenalkan kategori masalah yang sepenuhnya baru, masalah sistem terdistribusi:

Distribusi request: Bagaimana Anda memutuskan server mana yang menangani tiap request? Anda butuh load balancer, komponen baru di infrastruktur Anda. Sinkronisasi state: Jika user A memperbarui profilnya di Server 1, bagaimana Server 2 mengetahuinya? Partisi jaringan: Apa yang terjadi saat jaringan yang menghubungkan server Anda gagal? Server bisa membuat keputusan yang bertentangan. Health checking: Bagaimana Anda mendeteksi saat server mati, dan bagaimana Anda mengalihkan lalu lintas darinya dan kembali saat ia pulih?

Semua masalah ini punya solusi, load balancer, database bersama, algoritma konsensus, health check, tapi tiap solusi menambah kompleksitas dan butuh rekayasa hati-hati. Sistem terdistribusi tidak menghilangkan masalah; mereka mentransformasi satu set masalah menjadi set yang berbeda, dan Anda harus mengevaluasi set masalah mana yang lebih bisa dikelola untuk situasi Anda.

Vertical Scaling
  • Sederhana, tanpa perubahan arsitektur
  • Tanpa kompleksitas sistem terdistribusi
  • Efisien secara ekonomi di skala kecil
  • Plafon perangkat keras yang keras
  • Single point of failure
  • Tanpa distribusi geografis
Horizontal Scaling
  • Tanpa plafon kapasitas
  • Redundansi bawaan
  • Distribusi geografis mungkin
  • Butuh load balancing
  • Sinkronisasi state dibutuhkan
  • Kompleks secara operasional

18

Load Balancing

Begitu Anda punya lebih dari satu server, Anda butuh mekanisme mendistribusikan request masuk di antara mereka. Mekanisme itu load balancer. Ia duduk di depan server Anda, menerima semua lalu lintas masuk, dan meneruskan tiap request ke salah satu instance backend yang tersedia berdasarkan algoritma terpilih.

Algoritma umum

Round Robin: Request didistribusikan berurutan, server 1, server 2, server 3, ulang. Sederhana dan merata. Least Connections: Tiap request pergi ke server yang saat ini menangani koneksi aktif paling sedikit. Lebih baik untuk request dengan durasi bervariasi. IP Hash: Alamat IP klien menentukan server mana yang menerima request, memastikan klien yang sama selalu memukul server yang sama (berguna untuk sesi affinity). Weighted Round Robin: Server dengan kapasitas lebih menerima request proporsional lebih banyak.

Users 10K RPS LOAD BALANCER Nginx / HAProxy ALB / Traefik Server 1 US-East Server 2 EU-West Server 3 AP-South ~3.3K RPS ~3.3K RPS ~3.3K RPS
Fig 9 / Load balancer distributes traffic across geographically distributed servers

Load balancer dalam praktik (config Nginx)

Nginx
upstream backend {
    # Least connections algorithm
    least_conn;

    server 10.0.1.10:8080 weight=3;   # stronger machine
    server 10.0.1.11:8080 weight=2;
    server 10.0.1.12:8080 weight=2;
    server 10.0.1.13:8080 backup;     # only used if others are down
}

server {
    listen 443 ssl;
    server_name api.example.com;

    location / {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Health check

Load balancer secara berkala mengirim probe health check (request HTTP ke endpoint /health) ke tiap backend. Jika server gagal merespons, load balancer berhenti merutekan lalu lintas ke sana. Saat server pulih, lalu lintas dilanjutkan. Ini mekanisme yang menyediakan manfaat redundansi dari horizontal scaling.

Endpoint health sederhana di Go

Go
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    // Check database connectivity
    if err := db.PingContext(r.Context()); err != nil {
        w.WriteHeader(http.StatusServiceUnavailable)
        json.NewEncoder(w).Encode(map[string]string{
            "status": "unhealthy",
            "reason": "database unreachable",
        })
        return
    }
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{
        "status": "healthy",
    })
})

Sum

Ringkasan & Prinsip Kunci

Performance engineering bukan tentang menghafal checklist teknik. Ia tentang membangun model mental bagaimana sistem berperilaku di bawah beban dan menerapkan tool yang tepat di waktu yang tepat. Ini prinsip intinya:

1. Ukur, jangan menebak. Jangan pernah berasumsi di mana bottleneck-nya. Pakai middleware timing, distributed tracing, profiler, dan EXPLAIN ANALYZE untuk mengidentifikasi masalah sebenarnya sebelum berinvestasi pada solusi.

2. Berpikir dalam persentil, bukan rata-rata. P50 memberi tahu pengalaman khas. P99 memberi tahu pengalaman terburuk, dan ia sering mewakili pengguna Anda yang paling bernilai melakukan operasi paling kompleks.

3. Hormati kurva utililisasi. Latensi tumbuh eksponensial saat utililisasi mendekati 100%. Sisakan ruang kepala untuk ledakan lalu lintas. Target utililisasi 60-80% di produksi.

4. Perbaiki database dulu. Hilangkan query N+1, tambah index yang tepat (tapi jangan terlalu banyak), dan pakai connection pooling. Optimasi tingkat rendah ini sering memberi keuntungan terbesar.

5. Cache secara strategis. Pahami pola akses Anda. Pilih pola caching yang tepat (cache-aside, write-through, write-behind). Pantau cache hit rate Anda. Ingat invalidasi cache benar-benar sulit, hormati itu.

6. Scale secara tepat. Mulai dengan vertical scaling, lebih sederhana dan lebih murah. Pindah ke horizontal scaling saat mencapai batas perangkat keras, butuh redundansi, atau butuh distribusi geografis. Pahami bahwa horizontal scaling mentransformasi masalah Anda alih-alih menghilangkannya.

^ Back to top