Back to News/20 Masalah Database yang Bikin Senior Engineer Keringat Dingin di Production

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.

Faisal Affan
3/18/2026
20 Masalah Database yang Bikin Senior Engineer Keringat Dingin di 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 baris

Database 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 query

Solusi 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?"


Related Articles

20 Masalah Database yang Bikin Senior Engineer Keringat D... | Faisal Affan