20 Masalah Database yang Bikin Senior Engineer Keringat Dingin di Production
N+1 cuma puncak gunung es. Di bawahnya ada Cartesian Explosion, Lost Update, God Table, Replication Lag, ORM Trap, dan 15 masalah lain yang jauh lebih berbahaya — lengkap dengan study case nyata dan solusi Golang.

- 20 Masalah Database yang Bikin Senior Engineer Keringat Dingin di Production
- Peta Masalah Database
- BAGIAN 1: Query Latency Problems
- 1. Cartesian Product Explosion (Cross Join Tersembunyi)
- 2. Unbounded Query (Query Tanpa LIMIT)
- 3. Missing Index Trap (Sequential Scan Tersembunyi)
- 4. Over-fetching (SELECT * Syndrome)
- 5. Implicit Type Casting (Index Invalidation)
- 6. Long-Running Transaction Lock
- BAGIAN 2: Write-side Problems
- 7. Write Amplification (Index Overhead)
- 8. Hot Spot / Write Contention
- 9. Upsert Race Condition
- BAGIAN 3: Data Consistency Problems
- 10. Dirty Read / Phantom Read (Isolation Level Salah)
- 11. Lost Update
- 12. Read-Your-Writes Violation (Replication Lag)
- BAGIAN 4: Schema Design Problems
- 13. God Table (Tabel dengan 200+ Kolom)
- 14. EAV Anti-pattern (Entity-Attribute-Value)
- 15. Premature Normalization (Join Hell)
- BAGIAN 5: Operational Problems
- 16. Connection Pool Exhaustion
- 17. Table Bloat (Dead Tuples Menumpuk)
- 18. Migration Lock (ALTER TABLE di Tabel Besar)
- 19. ORM Lazy Loading Trap
- 20. ORM Generated Query Explosion
- Checklist Sebelum Deploy ke Production
20 Masalah Database yang Bikin Senior Engineer Keringat Dingin di Production
"Setiap kali sistem production tiba-tiba lambat tanpa sebab, database selalu jadi tersangka utama — dan hampir selalu bersalah."
TL;DR
N+1 query itu ibarat jerawat — mudah terlihat, semua orang tahu cara ngobatin. Yang berbahaya adalah penyakit dalam yang tidak terdeteksi sampai sistem kolaps di production. Artikel ini membahas 20 masalah database dari 5 kategori berbeda, masing-masing dengan study case nyata dan solusi praktis di Golang.
Peta Masalah Database
Masalah database tidak hanya soal query lambat. Ada 5 kategori besar yang perlu dipahami:
⚡ Query Latency
Masalah pada cara query ditulis dan dieksekusi
✍️ Write-side
Masalah pada operasi tulis dan konkurensi
🔒 Data Consistency
Masalah integritas dan isolasi data
🏗️ Schema Design
Masalah desain struktur tabel
🔧 Operational
Masalah infrastruktur dan ORM
BAGIAN 1: Query Latency Problems
1. Cartesian Product Explosion (Cross Join Tersembunyi)
Apa Masalahnya?
Terjadi ketika dua tabel di-JOIN tanpa kondisi yang tepat. Hasilnya: perkalian jumlah baris dari kedua tabel yang bisa mencapai jutaan baris dalam hitungan detik.
Study Case
Startup e-commerce dengan products (50.000 baris) dan tags (200 baris):
-- Terlihat innocent, tapi ini bom waktu
SELECT p.*, t.*
FROM products p, tags t
WHERE p.category = 'electronics';
-- Hasil: 50.000 × 200 = 10.000.000 barisDatabase server crash dalam 3 menit setelah fitur diluncurkan.
Solusi di Golang
// ❌ Berbahaya: implicit cross join
func GetProductsWithTagsBad(db *sql.DB) ([]ProductTag, error) {
rows, err := db.Query(`
SELECT p.id, p.name, t.name as tag_name
FROM products p, tags t
WHERE p.category = 'electronics'
`)
return scanRows(rows, err)
}
// ✅ Benar: explicit INNER JOIN dengan kondisi
func GetProductsWithTagsGood(db *sqlx.DB, category string, limit, offset int) ([]Product, error) {
var products []Product
err := db.Select(&products, `
SELECT p.id, p.name, p.price
FROM products p
INNER JOIN product_tags pt ON pt.product_id = p.id
INNER JOIN tags t ON t.id = pt.tag_id
WHERE p.category = $1
ORDER BY p.id
LIMIT $2 OFFSET $3
`, category, limit, offset)
if err != nil {
return nil, fmt.Errorf("get products: %w", err)
}
productIDs := extractIDs(products)
tags, err := getTagsByProductIDs(db, productIDs)
if err != nil {
return nil, err
}
return mergeProductTags(products, tags), nil
}Rule of Thumb
Setiap kali nulis JOIN tanpa explicit ON, selalu tanya: "Berapa baris maksimum yang bisa dikembalikan?" Kalau tidak bisa jawab dengan pasti — ada masalah.
2. Unbounded Query (Query Tanpa LIMIT)
Apa Masalahnya?
Query tanpa LIMIT mengambil semua data yang cocok. Di development dengan 100 baris, tidak terasa. Di production dengan 5 juta baris? Gigabytes of memory hilang seketika.
Study Case
Platform konten digital, 3 juta artikel. Fitur "export semua artikel" berjalan normal selama 2 tahun. Di tahun ke-3, satu klik tombol export menyebabkan downtime 45 menit karena server OOM.
Solusi di Golang
// ✅ Cursor-based streaming untuk export data besar
func ExportArticlesCursor(ctx context.Context, db *sql.DB, batchSize int) (<-chan Article, <-chan error) {
articles := make(chan Article, batchSize)
errc := make(chan error, 1)
go func() {
defer close(articles)
defer close(errc)
lastID := 0
for {
batch, err := fetchBatch(ctx, db, lastID, batchSize)
if err != nil {
errc <- err
return
}
if len(batch) == 0 {
return
}
for _, article := range batch {
select {
case articles <- article:
case <-ctx.Done():
errc <- ctx.Err()
return
}
}
lastID = batch[len(batch)-1].ID
}
}()
return articles, errc
}
func fetchBatch(ctx context.Context, db *sql.DB, afterID, limit int) ([]Article, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, title, content, status, created_at
FROM articles
WHERE status = 'published' AND id > $1
ORDER BY id ASC
LIMIT $2
`, afterID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanArticles(rows)
}3. Missing Index Trap (Sequential Scan Tersembunyi)
Apa Masalahnya?
Query filter pada kolom tanpa index memaksa database membaca setiap baris. Ini O(n) — makin besar tabel, makin lambat secara linear.
Study Case
Fintech dengan tabel transactions (50 juta baris). Query riwayat transaksi tanpa index: 8 detik. Setelah index: 12ms. Perbedaan 666x.
Solusi di Golang
// ✅ Deteksi missing index dengan EXPLAIN ANALYZE
func AnalyzeQuery(ctx context.Context, db *sql.DB, query string, args ...interface{}) error {
explainQuery := "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) " + query
var result []byte
if err := db.QueryRowContext(ctx, explainQuery, args...).Scan(&result); err != nil {
return err
}
if strings.Contains(string(result), "Seq Scan") {
log.Warn().Str("query", query).Msg("Sequential scan detected — consider adding index")
}
return nil
}
// ✅ Index yang tepat untuk pola query umum
/*
-- Composite index untuk query transaksi per user
CREATE INDEX CONCURRENTLY idx_transactions_user_created
ON transactions(user_id, created_at DESC);
-- Partial index untuk status tertentu saja
CREATE INDEX CONCURRENTLY idx_transactions_pending
ON transactions(created_at) WHERE status = 'pending';
*/
// ✅ Query memanfaatkan composite index dengan cursor pagination
func GetUserTransactions(ctx context.Context, db *sql.DB, userID, limit int, cursor *time.Time) ([]Transaction, error) {
if cursor == nil {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, amount, status, created_at
FROM transactions
WHERE user_id = $1
ORDER BY created_at DESC LIMIT $2
`, userID, limit)
defer rows.Close()
return scanTransactions(rows, err)
}
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, amount, status, created_at
FROM transactions
WHERE user_id = $1 AND created_at < $2
ORDER BY created_at DESC LIMIT $3
`, userID, cursor, limit)
defer rows.Close()
return scanTransactions(rows, err)
}Tools untuk Deteksi
Aktifkan pg_stat_statements di PostgreSQL untuk melihat query paling lambat di production. Integrasikan OpenTelemetry di Golang untuk auto-trace setiap query dengan durasinya.
4. Over-fetching (SELECT * Syndrome)
Apa Masalahnya?
SELECT * mengambil semua kolom termasuk yang besar (BLOB, JSONB, TEXT panjang) yang tidak dibutuhkan. Network I/O dan memory usage membengkak sia-sia.
Study Case
API /users/list untuk dropdown nama. Tabel users punya kolom profile_photo (BYTEA, ~2MB) dan settings (JSONB, ~50KB). Untuk 100 user: 205MB ditransfer dari DB — padahal yang dibutuhkan hanya id + name = ~2KB.
Solusi di Golang
// ❌ Anti-pattern: ambil semua kolom
func GetAllUsersBad(db *sqlx.DB) ([]User, error) {
var users []User
return users, db.Select(&users, "SELECT * FROM users")
}
// ✅ Gunakan DTO spesifik per use case
type UserListItem struct {
ID int `db:"id"`
Name string `db:"name"`
}
func GetUserListForDropdown(db *sqlx.DB) ([]UserListItem, error) {
var users []UserListItem
return users, db.Select(&users, `
SELECT id, name FROM users
WHERE active = true ORDER BY name LIMIT 500
`)
}5. Implicit Type Casting (Index Invalidation)
Apa Masalahnya?
Tipe parameter yang tidak cocok dengan tipe kolom memaksa database melakukan casting — dan index pada kolom tersebut menjadi tidak terpakai.
Study Case
Kolom phone_number bertipe VARCHAR dengan index. Developer kirim int64 sebagai parameter. PostgreSQL harus cast semua nilai ke integer — query berubah dari 1ms menjadi 8 detik.
Solusi di Golang
// ❌ Salah: tipe tidak cocok dengan kolom VARCHAR
func GetUserByPhoneBad(db *sql.DB, phone int64) (*User, error) {
row := db.QueryRow("SELECT id, name FROM users WHERE phone_number = $1", phone)
var u User
return &u, row.Scan(&u.ID, &u.Name)
}
// ✅ Benar: gunakan string sesuai tipe kolom
type PhoneNumber string
func GetUserByPhoneGood(db *sql.DB, phone PhoneNumber) (*User, error) {
row := db.QueryRow("SELECT id, name FROM users WHERE phone_number = $1", string(phone))
var u User
return &u, row.Scan(&u.ID, &u.Name)
}Pro Tip
WHERE created_at::date = '2026-01-01' sama bahayanya. Casting di WHERE clause selalu menginvalidasi index. Gunakan: WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02'.
6. Long-Running Transaction Lock
Apa Masalahnya?
Transaksi yang lama memblokir semua operasi lain pada baris yang sama. Di sistem concurrent, ini menciptakan antrian yang terus bertumbuh hingga timeout massal.
Study Case
Platform ticketing konser. Transaksi beli tiket memanggil external API (2 detik) + payment gateway (3 detik) di dalam satu transaksi database. Hasilnya: deadlock massal, server tidak responsif dalam 10 menit.
Solusi di Golang
// ❌ Anti-pattern: external call di dalam transaksi DB
func BuyTicketBad(ctx context.Context, db *sql.DB, userID, ticketID int) error {
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
tx.ExecContext(ctx, "UPDATE tickets SET status='reserved' WHERE id=$1", ticketID)
validateUserWithExternalAPI(userID) // ❌ 2 detik lock!
processPayment(userID, ticketID) // ❌ 3 detik lock!
return tx.Commit()
}
// ✅ Pisahkan: validasi → lock singkat → payment di luar transaksi
func BuyTicketGood(ctx context.Context, db *sql.DB, userID, ticketID int) error {
// Step 1: Validasi SEBELUM buka transaksi
if err := validateUserWithExternalAPI(userID); err != nil {
return fmt.Errorf("user validation: %w", err)
}
// Step 2: Transaksi DB singkat — hanya operasi DB
reservationID, err := reserveTicketSkipLocked(ctx, db, userID, ticketID)
if err != nil {
return fmt.Errorf("reserve ticket: %w", err)
}
// Step 3: Payment SETELAH transaksi DB selesai
if err := processPayment(userID, reservationID); err != nil {
_ = cancelReservation(ctx, db, reservationID) // compensating transaction
return fmt.Errorf("process payment: %w", err)
}
return confirmTicket(ctx, db, reservationID)
}
// ✅ SELECT FOR UPDATE SKIP LOCKED untuk high-concurrency
func reserveTicketSkipLocked(ctx context.Context, db *sql.DB, userID, ticketID int) (int, error) {
var id int
err := db.QueryRowContext(ctx, `
WITH locked AS (
SELECT id FROM tickets
WHERE id = $1 AND status = 'available'
FOR UPDATE SKIP LOCKED LIMIT 1
)
UPDATE tickets SET status = 'reserved', reserved_by = $2, reserved_at = NOW()
WHERE id IN (SELECT id FROM locked)
RETURNING id
`, ticketID, userID).Scan(&id)
if err == sql.ErrNoRows {
return 0, ErrTicketNotAvailable
}
return id, err
}BAGIAN 2: Write-side Problems
7. Write Amplification (Index Overhead)
Apa Masalahnya?
Setiap operasi INSERT atau UPDATE tidak hanya menulis ke tabel — tapi juga ke setiap index yang ada di tabel itu. Tabel dengan 15 index bisa menghasilkan 16 write operations untuk 1 INSERT.
Study Case
Tabel events di sistem analytics dengan 20 index (hasil bertahun-tahun "berjaga-jaga"). Throughput INSERT awalnya 10.000/detik. Setelah audit dan hapus 12 index yang tidak terpakai: 47.000/detik — hampir 5x lebih cepat.
Solusi di Golang
// ✅ Audit index yang tidak terpakai di PostgreSQL
const unusedIndexQuery = `
SELECT
schemaname,
tablename,
indexname,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE 'pg_%'
ORDER BY pg_relation_size(indexrelid) DESC;
`
func AuditUnusedIndexes(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, unusedIndexQuery)
if err != nil {
return err
}
defer rows.Close()
log.Info().Msg("=== Unused Indexes (candidates for removal) ===")
for rows.Next() {
var schema, table, index, size string
var scans int64
rows.Scan(&schema, &table, &index, &scans, &size)
log.Info().Str("index", index).Str("table", table).Str("size", size).Msg("unused index")
}
return rows.Err()
}
// ✅ Untuk bulk insert, gunakan COPY protocol via pgx (jauh lebih cepat)
func BulkInsertEvents(ctx context.Context, pool *pgxpool.Pool, events []Event) error {
_, err := pool.CopyFrom(
ctx,
pgx.Identifier{"events"},
[]string{"user_id", "event_type", "payload", "created_at"},
pgx.CopyFromSlice(len(events), func(i int) ([]interface{}, error) {
return []interface{}{
events[i].UserID,
events[i].Type,
events[i].Payload,
events[i].CreatedAt,
}, nil
}),
)
return err
}8. Hot Spot / Write Contention
Apa Masalahnya?
Ribuan goroutine menulis ke baris atau tabel yang sama secara bersamaan. Database harus men-serialize semua write ini satu per satu — menjadi bottleneck tunggal.
Study Case
Sistem leaderboard game online. Kolom total_score di tabel global_stats di-update setiap kali ada pemain menyelesaikan misi. Saat 5.000 pemain aktif bersamaan, tabel ini menjadi hot spot — p99 latency naik ke 12 detik.
Solusi di Golang
// ❌ Anti-pattern: update langsung ke satu baris
func UpdateGlobalScoreBad(ctx context.Context, db *sql.DB, delta int) error {
_, err := db.ExecContext(ctx, `
UPDATE global_stats SET total_score = total_score + $1
`, delta)
return err
}
// ✅ Solusi 1: Write buffer — kumpulkan update, flush berkala
type ScoreBuffer struct {
mu sync.Mutex
delta int
}
var buffer = &ScoreBuffer{}
func AddScore(delta int) {
buffer.mu.Lock()
buffer.delta += delta
buffer.mu.Unlock()
}
func FlushScoreBuffer(ctx context.Context, db *sql.DB) error {
buffer.mu.Lock()
delta := buffer.delta
buffer.delta = 0
buffer.mu.Unlock()
if delta == 0 {
return nil
}
_, err := db.ExecContext(ctx, `
UPDATE global_stats SET total_score = total_score + $1
`, delta)
return err
}
// ✅ Solusi 2: Sharded counter — pecah satu baris jadi N baris
func UpdateShardedScore(ctx context.Context, db *sql.DB, userID, delta int) error {
shardID := userID % 16 // 16 shards
_, err := db.ExecContext(ctx, `
INSERT INTO score_shards (shard_id, delta)
VALUES ($1, $2)
`, shardID, delta)
return err
}
func GetTotalScore(ctx context.Context, db *sql.DB) (int64, error) {
var total int64
err := db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(delta), 0) FROM score_shards
`).Scan(&total)
return total, err
}9. Upsert Race Condition
Apa Masalahnya?
Dua goroutine melakukan "check then insert" secara bersamaan. Keduanya mengecek "data belum ada" pada saat yang sama — lalu keduanya INSERT — dan salah satu gagal dengan duplicate key error atau, lebih buruk, data duplikat masuk tanpa error.
Study Case
Sistem notifikasi. Dua event tiba bersamaan untuk user yang sama. Keduanya cek WHERE user_id = X → tidak ada → keduanya INSERT. Hasil: user menerima notifikasi duplikat dua kali.
Solusi di Golang
// ❌ Anti-pattern: check-then-insert tanpa atomicity
func CreateUserProfileBad(ctx context.Context, db *sql.DB, userID int) error {
var count int
db.QueryRowContext(ctx, "SELECT COUNT(*) FROM profiles WHERE user_id = $1", userID).Scan(&count)
// Race condition window: goroutine lain bisa insert di sini!
if count == 0 {
_, err := db.ExecContext(ctx, "INSERT INTO profiles (user_id) VALUES ($1)", userID)
return err
}
return nil
}
// ✅ Gunakan INSERT ... ON CONFLICT (atomic upsert)
func UpsertUserProfile(ctx context.Context, db *sql.DB, userID int, data ProfileData) error {
_, err := db.ExecContext(ctx, `
INSERT INTO profiles (user_id, bio, avatar_url, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id) DO UPDATE SET
bio = EXCLUDED.bio,
avatar_url = EXCLUDED.avatar_url,
updated_at = NOW()
`, userID, data.Bio, data.AvatarURL)
return err
}
// ✅ Atau gunakan INSERT ... ON CONFLICT DO NOTHING untuk idempotent insert
func EnsureUserExists(ctx context.Context, db *sql.DB, userID int) error {
_, err := db.ExecContext(ctx, `
INSERT INTO users (id, created_at)
VALUES ($1, NOW())
ON CONFLICT (id) DO NOTHING
`, userID)
return err
}BAGIAN 3: Data Consistency Problems
10. Dirty Read / Phantom Read (Isolation Level Salah)
Apa Masalahnya?
Isolation level yang terlalu rendah memungkinkan transaksi membaca data yang belum di-commit (dirty read) atau data baru yang muncul di tengah transaksi yang sama (phantom read).
Study Case
Sistem akuntansi. Laporan keuangan dibuat dengan isolation level READ UNCOMMITTED. Report menampilkan saldo yang tidak pernah ada — karena membaca transaksi yang kemudian di-rollback.
Solusi di Golang
// ✅ Set isolation level yang tepat sesuai kebutuhan
func GenerateFinancialReport(ctx context.Context, db *sql.DB) (*Report, error) {
// Snapshot isolation: semua baca dari snapshot yang konsisten
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead, // atau LevelSerializable untuk kritis
ReadOnly: true,
})
if err != nil {
return nil, err
}
defer tx.Rollback()
// Semua query di sini membaca dari snapshot waktu tx dimulai
report, err := buildReport(ctx, tx)
if err != nil {
return nil, err
}
return report, tx.Commit()
}
/*
Panduan isolation level:
- READ COMMITTED (default PostgreSQL): cocok untuk operasi normal
- REPEATABLE READ: cocok untuk laporan yang butuh konsistensi dalam satu transaksi
- SERIALIZABLE: cocok untuk operasi finansial kritis, tapi lebih lambat
*/11. Lost Update
Apa Masalahnya?
Dua transaksi membaca nilai yang sama, keduanya memodifikasi, dan yang terakhir commit menimpa perubahan transaksi pertama. Perubahan pertama hilang begitu saja.
Study Case
Dua admin mengedit profil user yang sama di waktu bersamaan. Admin A mengubah email, Admin B mengubah nama. Yang terakhir save menimpa perubahan yang pertama — salah satu perubahan hilang tanpa jejak.
Solusi di Golang
// ✅ Optimistic locking dengan version/updated_at column
type UserProfile struct {
ID int `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
Version int `db:"version"` // increment setiap update
UpdatedAt time.Time `db:"updated_at"`
}
func UpdateUserProfileOptimistic(ctx context.Context, db *sql.DB, profile UserProfile) error {
result, err := db.ExecContext(ctx, `
UPDATE users
SET name = $1, email = $2, version = version + 1, updated_at = NOW()
WHERE id = $3 AND version = $4 -- hanya update jika version cocok
`, profile.Name, profile.Email, profile.ID, profile.Version)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
// Versi tidak cocok — ada yang sudah update duluan
return ErrConflict
}
return nil
}
// ✅ Pessimistic locking dengan SELECT FOR UPDATE
func UpdateUserProfilePessimistic(ctx context.Context, db *sql.DB, userID int, fn func(*UserProfile) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var profile UserProfile
err = tx.QueryRowContext(ctx, `
SELECT id, name, email, version FROM users WHERE id = $1 FOR UPDATE
`, userID).Scan(&profile.ID, &profile.Name, &profile.Email, &profile.Version)
if err != nil {
return err
}
if err := fn(&profile); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
UPDATE users SET name = $1, email = $2, version = version + 1 WHERE id = $3
`, profile.Name, profile.Email, profile.ID)
if err != nil {
return err
}
return tx.Commit()
}12. Read-Your-Writes Violation (Replication Lag)
Apa Masalahnya?
Aplikasi menulis ke primary tapi membaca dari replica yang belum sync. User melakukan aksi, lalu melihat data seolah aksi tersebut tidak terjadi — padahal sudah tersimpan.
Study Case
Social media. User post tweet, langsung redirect ke profile. Tapi tweet tidak muncul di profile karena dibaca dari replica yang lag 2 detik. User mengira post gagal, post ulang — tweet duplikat.
Solusi di Golang
// ✅ Sticky session: setelah write, baca dari primary untuk beberapa waktu
type DBRouter struct {
primary *sql.DB
replicas []*sql.DB
// map userID → deadline untuk baca dari primary
stickyMu sync.RWMutex
sticky map[int]time.Time
}
func (r *DBRouter) AfterWrite(userID int) {
r.stickyMu.Lock()
r.sticky[userID] = time.Now().Add(5 * time.Second)
r.stickyMu.Unlock()
}
func (r *DBRouter) ReadDB(userID int) *sql.DB {
r.stickyMu.RLock()
deadline, ok := r.sticky[userID]
r.stickyMu.RUnlock()
if ok && time.Now().Before(deadline) {
return r.primary // baca dari primary
}
return r.replicas[userID%len(r.replicas)] // baca dari replica
}
// ✅ Atau gunakan token-based consistency
func CreatePost(ctx context.Context, db *DBRouter, userID int, content string) (string, error) {
postID, err := insertPost(ctx, db.primary, userID, content)
if err != nil {
return "", err
}
// Tandai bahwa user ini butuh read-your-writes untuk 5 detik ke depan
db.AfterWrite(userID)
return postID, nil
}BAGIAN 4: Schema Design Problems
13. God Table (Tabel dengan 200+ Kolom)
Apa Masalahnya?
Semua data dari berbagai entitas dicampur dalam satu tabel raksasa. Query apapun jadi lambat karena row size yang besar. ALTER TABLE menjadi operasi yang mengerikan.
Study Case
Platform SaaS dengan tabel users yang memiliki 240 kolom — mulai dari data personal, preferensi UI, billing info, analytics flags, sampai feature flags. Query SELECT id, name FROM users tetap harus membaca row 8KB penuh dari disk.
Solusi di Golang
// ❌ Anti-pattern: God table
// users(id, name, email, phone, address, city, country,
// stripe_customer_id, plan_type, billing_cycle,
// ui_theme, ui_language, notification_prefs,
// last_login, login_count, ...240 kolom lainnya)
// ✅ Decompose berdasarkan domain
/*
users(id, name, email, created_at) -- core identity
user_billing(user_id, stripe_id, plan, cycle) -- billing domain
user_preferences(user_id, theme, language, notif_settings) -- preferences
user_activity(user_id, last_login, login_count) -- analytics
*/
// ✅ Di Golang, fetch hanya domain yang dibutuhkan
type UserCore struct {
ID int `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
}
type UserBilling struct {
UserID int `db:"user_id"`
StripeID string `db:"stripe_id"`
Plan string `db:"plan"`
}
func GetUserWithBilling(ctx context.Context, db *sqlx.DB, userID int) (*UserCore, *UserBilling, error) {
var core UserCore
var billing UserBilling
// Dua query kecil > satu query dari God table
errCh := make(chan error, 2)
go func() { errCh <- db.GetContext(ctx, &core, "SELECT id, name, email FROM users WHERE id=$1", userID) }()
go func() { errCh <- db.GetContext(ctx, &billing, "SELECT user_id, stripe_id, plan FROM user_billing WHERE user_id=$1", userID) }()
for i := 0; i < 2; i++ {
if err := <-errCh; err != nil && err != sql.ErrNoRows {
return nil, nil, err
}
}
return &core, &billing, nil
}14. EAV Anti-pattern (Entity-Attribute-Value)
Apa Masalahnya?
Desain "satu tabel untuk semua atribut dinamis" dengan struktur (entity_id, attribute_name, value). Terlihat fleksibel, tapi query untuk mendapatkan semua atribut satu entitas memerlukan N join atau N query.
Study Case
CMS dengan tabel page_attributes(page_id, key, value). Query untuk render satu halaman dengan 30 atribut membutuhkan 30 baris yang harus di-pivot di aplikasi. Dengan 1.000 halaman di-list sekaligus: 30.000 baris ditransfer.
Solusi di Golang
// ❌ Anti-pattern EAV
// page_attributes: page_id=1, key='title', value='Hello'
// page_attributes: page_id=1, key='slug', value='hello'
// page_attributes: page_id=1, key='published', value='true'
// ✅ Solusi 1: Gunakan kolom biasa untuk atribut yang sudah diketahui
/*
CREATE TABLE pages (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
published BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
*/
// ✅ Solusi 2: JSONB untuk atribut yang benar-benar dinamis
/*
CREATE TABLE pages (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
metadata JSONB DEFAULT '{}' -- untuk atribut dinamis
);
CREATE INDEX idx_pages_metadata ON pages USING GIN(metadata);
*/
func GetPageWithMetadata(ctx context.Context, db *sql.DB, slug string) (*Page, error) {
var page Page
var metadataJSON []byte
err := db.QueryRowContext(ctx, `
SELECT id, title, slug, metadata FROM pages WHERE slug = $1
`, slug).Scan(&page.ID, &page.Title, &page.Slug, &metadataJSON)
if err != nil {
return nil, err
}
json.Unmarshal(metadataJSON, &page.Metadata)
return &page, nil
}
// ✅ Query JSONB dengan index
func GetPagesByTag(ctx context.Context, db *sql.DB, tag string) ([]Page, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, title, slug
FROM pages
WHERE metadata @> $1::jsonb -- GIN index dipakai!
`, fmt.Sprintf(`{"tags": ["%s"]}`, tag))
defer rows.Close()
return scanPages(rows, err)
}15. Premature Normalization (Join Hell)
Apa Masalahnya?
Over-normalization memecah data yang sering dibaca bersamaan ke terlalu banyak tabel. Query sederhana membutuhkan 7-10 JOIN yang menyulitkan query planner mengoptimasi.
Study Case
E-commerce dengan query tampilkan order: JOIN ke orders, order_items, products, categories, users, addresses, payment_methods, shipping_options — 8 JOIN untuk satu halaman. Query plan: 45ms. Setelah denormalize beberapa field: 3ms.
Solusi di Golang
// ✅ Denormalize field yang sering dibaca bersamaan
/*
-- Daripada join ke products setiap kali, simpan snapshot saat order dibuat
ALTER TABLE order_items ADD COLUMN product_name TEXT;
ALTER TABLE order_items ADD COLUMN product_price NUMERIC(10,2);
-- Isi saat insert
INSERT INTO order_items (order_id, product_id, product_name, product_price, qty)
SELECT $1, p.id, p.name, p.price, $2
FROM products p WHERE p.id = $3;
*/
// ✅ Di Golang, buat DTO yang flat untuk kebutuhan display
type OrderDisplayItem struct {
ItemID int `db:"item_id"`
ProductName string `db:"product_name"` // denormalized
ProductPrice float64 `db:"product_price"` // denormalized
Qty int `db:"qty"`
Subtotal float64 `db:"subtotal"`
}
func GetOrderItems(ctx context.Context, db *sql.DB, orderID int) ([]OrderDisplayItem, error) {
rows, err := db.QueryContext(ctx, `
SELECT id as item_id, product_name, product_price, qty,
product_price * qty AS subtotal
FROM order_items
WHERE order_id = $1
`, orderID)
// Tidak ada JOIN! Data sudah tersimpan saat order dibuat.
defer rows.Close()
return scanOrderItems(rows, err)
}BAGIAN 5: Operational Problems
16. Connection Pool Exhaustion
Apa Masalahnya?
rows.Close() yang terlupakan atau transaksi yang tidak di-rollback membuat koneksi tidak dikembalikan ke pool. Pool habis, semua request berikutnya blocking menunggu koneksi tersedia.
Solusi di Golang
// ✅ Konfigurasi pool yang tepat
func NewDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(2 * time.Minute)
return db, nil
}
// ✅ Pola yang benar: defer rows.Close() SEGERA setelah cek error
func GetCategories(ctx context.Context, db *sql.DB) ([]Category, error) {
rows, err := db.QueryContext(ctx, "SELECT id, name FROM categories")
if err != nil {
return nil, err
}
defer rows.Close() // ← SELALU di sini
var categories []Category
for rows.Next() {
var c Category
if err := rows.Scan(&c.ID, &c.Name); err != nil {
return nil, err
}
categories = append(categories, c)
}
return categories, rows.Err()
}
// ✅ Monitor pool health
func MonitorDBPool(db *sql.DB) {
stats := db.Stats()
if float64(stats.InUse)/float64(stats.MaxOpenConnections) > 0.8 {
log.Warn().
Int("in_use", stats.InUse).
Int64("wait_count", stats.WaitCount).
Msg("DB pool usage > 80% — consider increasing MaxOpenConns or optimizing queries")
}
}17. Table Bloat (Dead Tuples Menumpuk)
Apa Masalahnya?
Di PostgreSQL, UPDATE dan DELETE tidak langsung menghapus data — mereka membuat dead tuples yang masih memakan ruang di disk. Tanpa VACUUM yang tepat, tabel bisa 10x lebih besar dari seharusnya, dan semua query jadi lambat karena harus membaca lebih banyak halaman disk.
Study Case
Tabel sessions di aplikasi web dengan DELETE jutaan baris per hari. Setelah 6 bulan, tabel yang seharusnya 100MB sudah berukuran 8GB. Query pada tabel ini butuh 5 detik karena sequential scan membaca 8GB data.
Solusi di Golang
// ✅ Deteksi table bloat
const bloatQuery = `
SELECT
tablename,
pg_size_pretty(pg_total_relation_size(tablename::regclass)) AS total_size,
pg_size_pretty(pg_relation_size(tablename::regclass)) AS table_size,
n_dead_tup,
n_live_tup,
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
`
func CheckTableBloat(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, bloatQuery)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var table, totalSize, tableSize string
var deadTup, liveTup int64
var deadPct float64
rows.Scan(&table, &totalSize, &tableSize, &deadTup, &liveTup, &deadPct)
if deadPct > 20 {
log.Warn().
Str("table", table).
Float64("dead_pct", deadPct).
Str("total_size", totalSize).
Msg("Table bloat detected — consider VACUUM ANALYZE")
}
}
return nil
}
// ✅ Untuk tabel high-delete, gunakan partitioning daripada DELETE
/*
-- Partisi tabel sessions per minggu
CREATE TABLE sessions (
id UUID DEFAULT gen_random_uuid(),
user_id INT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (expires_at);
-- Drop partisi lama = instant cleanup tanpa bloat
DROP TABLE sessions_2026_w01;
*/18. Migration Lock (ALTER TABLE di Tabel Besar)
Apa Masalahnya?
ALTER TABLE di PostgreSQL mengambil Access Exclusive Lock — memblokir semua baca dan tulis. Di tabel dengan 100 juta baris, ini bisa berlangsung menit hingga jam.
Study Case
Tim engineering menambah kolom nullable ke tabel orders (80 juta baris) saat traffic tinggi. Migration berjalan 8 menit. Selama itu, semua request yang menyentuh tabel orders timeout. Incident P1.
Solusi di Golang
// ✅ Gunakan migrasi bertahap untuk zero-downtime
/*
Step 1: Tambah kolom nullable TANPA default (instant di PostgreSQL 11+)
ALTER TABLE orders ADD COLUMN notes TEXT;
Step 2: Backfill data secara bertahap (tidak lock tabel)
UPDATE orders SET notes = '' WHERE id BETWEEN 1 AND 100000 AND notes IS NULL;
UPDATE orders SET notes = '' WHERE id BETWEEN 100001 AND 200000 AND notes IS NULL;
-- dst, dalam batch kecil
Step 3: Set default dan NOT NULL setelah semua baris terisi
ALTER TABLE orders ALTER COLUMN notes SET DEFAULT '';
ALTER TABLE orders ALTER COLUMN notes SET NOT NULL;
*/
// ✅ Di Golang: jalankan backfill sebagai background job
func BackfillColumnInBatches(ctx context.Context, db *sql.DB, batchSize int) error {
var minID, maxID int
db.QueryRowContext(ctx, "SELECT MIN(id), MAX(id) FROM orders WHERE notes IS NULL").
Scan(&minID, &maxID)
for start := minID; start <= maxID; start += batchSize {
end := start + batchSize - 1
_, err := db.ExecContext(ctx, `
UPDATE orders SET notes = ''
WHERE id BETWEEN $1 AND $2 AND notes IS NULL
`, start, end)
if err != nil {
return fmt.Errorf("backfill batch %d-%d: %w", start, end, err)
}
// Jeda kecil untuk memberi napas ke database
time.Sleep(10 * time.Millisecond)
}
return nil
}19. ORM Lazy Loading Trap
Apa Masalahnya?
ORM menggunakan lazy loading: relasi tidak di-fetch sampai di-akses pertama kali. Di loop, ini menghasilkan N+1 yang tersembunyi — tidak terlihat dari kode, tapi terjadi di balik layar.
Study Case
GORM di Go. Developer menulis:
var orders []Order
db.Find(&orders) // 1 query
for _, o := range orders {
fmt.Println(o.User.Name) // ← GORM fetch user satu per satu!
}
// Total: 1 + N querySolusi di Golang
// ❌ Lazy loading trap dengan GORM
func GetOrdersWithUserBad(db *gorm.DB) ([]Order, error) {
var orders []Order
db.Find(&orders)
for _, o := range orders {
_ = o.User.Name // Trigger query per order!
}
return orders, nil
}
// ✅ Gunakan Preload (eager loading) di GORM
func GetOrdersWithUserGood(db *gorm.DB) ([]Order, error) {
var orders []Order
err := db.Preload("User").Preload("Items").Find(&orders).Error
// GORM generate: SELECT * FROM orders; SELECT * FROM users WHERE id IN (...);
return orders, err
}
// ✅ Atau lebih baik: tulis raw query dengan JOIN untuk kontrol penuh
func GetOrdersWithUserRaw(ctx context.Context, db *sqlx.DB, limit int) ([]OrderWithUser, error) {
var result []OrderWithUser
err := db.SelectContext(ctx, &result, `
SELECT o.id, o.total, o.status,
u.id as user_id, u.name as user_name, u.email as user_email
FROM orders o
INNER JOIN users u ON u.id = o.user_id
WHERE o.status != 'cancelled'
ORDER BY o.created_at DESC
LIMIT $1
`, limit)
return result, err
}20. ORM Generated Query Explosion
Apa Masalahnya?
ORM kadang generate query yang jauh lebih kompleks dari yang diperlukan — subquery bertingkat, kondisi yang redundan, atau SELECT berulang untuk hal yang sama.
Study Case
Aplikasi dengan GORM. Query "ambil user aktif dengan order terbaru" menggunakan method chaining GORM yang menghasilkan query dengan 3 level subquery nested dan waktu eksekusi 800ms. Ditulis ulang sebagai raw SQL: 12ms.
Solusi di Golang
// ❌ GORM method chaining yang generate query buruk
func GetActiveUsersWithLatestOrderBad(db *gorm.DB) ([]User, error) {
var users []User
db.Where("active = ?", true).
Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at desc")
}).
Find(&users)
return users, nil
// Generated: nested subquery yang tidak optimal
}
// ✅ Raw SQL untuk query kompleks — transparan dan bisa di-EXPLAIN
const activeUsersWithLatestOrderSQL = `
SELECT DISTINCT ON (u.id)
u.id, u.name, u.email,
o.id as latest_order_id,
o.total as latest_order_total,
o.created_at as latest_order_at
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.active = true
ORDER BY u.id, o.created_at DESC NULLS LAST
LIMIT $1
`
type UserWithLatestOrder struct {
ID int `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
LatestOrderID *int `db:"latest_order_id"`
LatestOrderTotal *float64 `db:"latest_order_total"`
LatestOrderAt *time.Time `db:"latest_order_at"`
}
func GetActiveUsersWithLatestOrderGood(ctx context.Context, db *sqlx.DB, limit int) ([]UserWithLatestOrder, error) {
var result []UserWithLatestOrder
err := db.SelectContext(ctx, &result, activeUsersWithLatestOrderSQL, limit)
return result, err
}Checklist Sebelum Deploy ke Production
⚡ Query Latency
Tidak ada Cartesian JOIN, semua query ada LIMIT, EXPLAIN ANALYZE sudah dijalankan, tipe parameter cocok dengan kolom, transaksi singkat.
✍️ Write-side
Index yang tidak terpakai sudah dihapus, bulk insert pakai COPY protocol, counter pakai sharding atau buffer, upsert pakai ON CONFLICT.
🔒 Consistency
Isolation level sesuai kebutuhan, optimistic/pessimistic locking untuk concurrent update, read-your-writes ditangani untuk replica.
🏗️ Schema
Tidak ada God Table, EAV diganti JSONB, denormalize field yang sering dibaca bersamaan, migration pakai zero-downtime pattern.
🔧 Operational
rows.Close() selalu di-defer, pool terkonfigurasi dan dimonitor, VACUUM berjalan rutin, ORM menggunakan eager loading.
Penutup
Database problems bukan hanya soal query lambat. Ini tentang memahami seluruh lifecycle data — dari bagaimana data ditulis, disimpan, dibaca, sampai bagaimana sistem berperilaku saat concurrent. Setiap masalah di atas punya satu kesamaan: tidak terlihat di development, baru meledak di production.
Senjata terbaik: EXPLAIN ANALYZE sebelum deploy, pg_stat_statements di production, OpenTelemetry tracing di Golang, dan code review yang selalu bertanya — "Ini akan seperti apa saat 1 juta baris dan 10.000 concurrent users?"