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.
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.
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.
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.
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.
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:
| Persentil | Makna | Contoh |
|---|---|---|
| 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+ |
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")
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?
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.
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:
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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().
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.
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)membantuWHERE A=...danWHERE A=... AND B=...tapi bukanWHERE B=...sendirian
Covering Index
- Menyertakan semua kolom yang dibutuhkan query
- Database melayani data sepenuhnya dari index
- Menghilangkan "heap lookup" (kembali ke tabel)
- Memakai klausa
INCLUDEdi 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".
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.
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
)
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
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.
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.
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.
| Pola | Kecepatan Baca | Kecepatan Tulis | Konsistensi | Kompleksitas |
|---|---|---|---|---|
| Cache-Aside | Cepat (setelah miss pertama) | Normal | Baik (invalidasi eksplisit) | Rendah |
| Write-Through | Selalu cepat | Lebih lambat (tulis ganda) | Kuat | Sedang |
| Write-Behind | Selalu cepat | Tercepat | Eventual (risiko kehilangan) | Tinggi |
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.
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.
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.
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).
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
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.
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",
})
})
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.