Kodeloka
  1. Kodeloka
  2. Kelas Golang
  3. Materi

Domain 18 · 2 bab

Database Go Modern

190. pgx - Driver PostgreSQL Berkinerja Tinggi

Memakai pgx secara native beserta connection pool, batch query, COPY untuk sisipan massal, dan penanganan error PostgreSQL yang spesifik.

Kenapa pgx, Bukan database/sql

database/sql adalah antarmuka umum yang harus melayani semua database, sehingga fitur khas PostgreSQL tidak terjangkau. pgx berbicara langsung dengan protokol PostgreSQL.

Kemampuandatabase/sql + lib/pqpgx native
Prepared statement cacheTerbatasOtomatis & efisien
Tipe PostgreSQL (array, JSONB, range)Perlu pembungkus manualDidukung langsung
Protokol binerTidakYa — parsing lebih cepat
COPY untuk sisipan massalTidak nyamanBawaan
LISTEN/NOTIFYTidakYa
Batch queryTidakYa — satu round-trip
Detail error PostgreSQLTerbatasLengkap (pgconn.PgError)

Connection Pool

import "github.com/jackc/pgx/v5/pgxpool"

func buatPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
    cfg, err := pgxpool.ParseConfig(dsn)
    if err != nil {
        return nil, err
    }

    cfg.MaxConns        = 25
    cfg.MinConns        = 5
    cfg.MaxConnLifetime = 30 * time.Minute
    cfg.MaxConnIdleTime = 5 * time.Minute
    cfg.HealthCheckPeriod = 1 * time.Minute

    pool, err := pgxpool.NewWithConfig(ctx, cfg)
    if err != nil {
        return nil, err
    }
    if err := pool.Ping(ctx); err != nil {
        return nil, fmt.Errorf("ping database: %w", err)
    }
    return pool, nil
}
⚠️ MaxConns harus dihitung, bukan ditebak

PostgreSQL punya batas max_connections (bawaan sekitar 100). Bila 10 pod masing-masing membuka 25 koneksi, itu 250 koneksi — melebihi batas, dan pod yang datang belakangan akan gagal terhubung.

Hitung dengan: MaxConns × jumlah_replika ≤ max_connections − cadangan_admin. Untuk jumlah pod yang besar, gunakan PgBouncer sebagai penggabung koneksi. Perlu diingat bahwa PgBouncer dalam mode transaction pooling tidak mendukung prepared statement bernama — pada kasus itu pgx harus dikonfigurasi dengan QueryExecModeSimpleProtocol.

Kueri

// Satu baris
var p Pesanan
err := pool.QueryRow(ctx,
    "SELECT id, total, status FROM pesanan WHERE id = $1", id,
).Scan(&p.ID, &p.Total, &p.Status)

if errors.Is(err, pgx.ErrNoRows) {
    return nil, ErrTidakDitemukan
}

// Banyak baris dengan pemindaian otomatis ke struct
baris, err := pool.Query(ctx,
    "SELECT id, total, status FROM pesanan WHERE pengguna_id = $1", uid)
if err != nil {
    return nil, err
}
pesanan, err := pgx.CollectRows(baris, pgx.RowToStructByName[Pesanan])

// Tipe PostgreSQL yang ditangani langsung
var tag []string                    // text[]
var meta map[string]any             // jsonb
err = pool.QueryRow(ctx,
    "SELECT tag, metadata FROM produk WHERE id = $1", pid,
).Scan(&tag, &meta)

Menangani Error PostgreSQL secara Spesifik

_, err := pool.Exec(ctx,
    "INSERT INTO pengguna (email) VALUES ($1)", email)

var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
    switch pgErr.Code {
    case "23505":   // unique_violation
        return ErrEmailSudahDipakai
    case "23503":   // foreign_key_violation
        return ErrReferensiTidakSah
    case "23514":   // check_violation
        return ErrMelanggarAturan
    case "40001":   // serialization_failure
        return ErrCobaLagi     // aman untuk diulang
    case "40P01":   // deadlock_detected
        return ErrCobaLagi
    }
}
📌 Andalkan constraint database, bukan pemeriksaan sebelumnya

Pola "SELECT dulu untuk cek apakah email sudah ada, baru INSERT" mengandung balapan: dua permintaan bersamaan bisa sama-sama lolos pemeriksaan lalu sama-sama menyisipkan. Cara yang benar adalah langsung INSERT dan menangani kode 23505. Constraint database adalah satu-satunya penjamin keunikan yang benar-benar atomik.

Transaksi

func (r *Repo) BuatPesanan(ctx context.Context, p Pesanan) error {
    tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{
        IsoLevel: pgx.ReadCommitted,
    })
    if err != nil {
        return err
    }
    // Aman dipanggil setelah Commit -- akan diabaikan
    defer tx.Rollback(ctx)

    var idPesanan string
    err = tx.QueryRow(ctx,
        "INSERT INTO pesanan (pengguna_id, total) VALUES ($1,$2) RETURNING id",
        p.PenggunaID, p.Total,
    ).Scan(&idPesanan)
    if err != nil {
        return fmt.Errorf("sisipkan pesanan: %w", err)
    }

    for _, it := range p.Item {
        _, err = tx.Exec(ctx,
            "UPDATE stok SET jumlah = jumlah - $1 WHERE produk_id = $2 AND jumlah >= $1",
            it.Jumlah, it.ProdukID)
        if err != nil {
            return fmt.Errorf("kurangi stok: %w", err)
        }
    }

    return tx.Commit(ctx)
}
🚫 Jangan pernah memanggil API eksternal di dalam transaksi

Transaksi menahan kunci baris. Memanggil gerbang pembayaran yang butuh 3 detik di dalam transaksi berarti kunci tertahan 3 detik, dan permintaan lain yang menyentuh baris itu ikut mengantre. Di bawah beban, ini berubah menjadi deadlock dan koneksi habis. Pola yang benar: commit dulu dengan status MENUNGGU, panggil API di luar transaksi, lalu perbarui status di transaksi kedua.

Batch — Satu Round-Trip untuk Banyak Kueri

batch := &pgx.Batch{}
for _, it := range item {
    batch.Queue("INSERT INTO item_pesanan (pesanan_id, produk_id, jumlah) VALUES ($1,$2,$3)",
        idPesanan, it.ProdukID, it.Jumlah)
}

hasil := tx.SendBatch(ctx, batch)
defer hasil.Close()

for range item {
    if _, err := hasil.Exec(); err != nil {
        return err
    }
}
💡 Latensi jaringan sering menjadi biang keladi sebenarnya

Seratus INSERT terpisah dengan latensi jaringan 1 ms berarti 100 ms hanya untuk perjalanan bolak-balik, sebelum database mengerjakan apa pun. Batch mengirimkan semuanya dalam satu perjalanan. Untuk sisipan yang sangat banyak, COPY bahkan jauh lebih cepat lagi.

COPY untuk Sisipan Massal

jumlah, err := pool.CopyFrom(ctx,
    pgx.Identifier{"item_pesanan"},
    []string{"pesanan_id", "produk_id", "jumlah"},
    pgx.CopyFromSlice(len(item), func(i int) ([]any, error) {
        return []any{idPesanan, item[i].ProdukID, item[i].Jumlah}, nil
    }),
)
// Untuk 100 ribu baris, COPY bisa puluhan kali lebih cepat
// daripada INSERT satu per satu.

Yang Perlu Dipantau

stat := pool.Stat()
slog.Info("statistik pool",
    "total",          stat.TotalConns(),
    "menganggur",     stat.IdleConns(),
    "digunakan",      stat.AcquiredConns(),
    "menunggu",       stat.EmptyAcquireCount(),
    "durasi_tunggu",  stat.AcquireDuration())

Angka EmptyAcquireCount yang terus naik berarti aplikasi sering kehabisan koneksi dan harus menunggu. Penyebabnya bisa pool terlalu kecil, atau — yang lebih sering — ada kueri lambat yang menahan koneksi terlalu lama. Periksa kueri lambatnya lebih dulu sebelum menaikkan MaxConns.

Contoh program

package main

import (
	"errors"
	"fmt"
)

// Simulasi error PostgreSQL
type PgError struct {
	Code    string
	Message string
	Detail  string
}

func (e *PgError) Error() string { return fmt.Sprintf("pg %s: %s", e.Code, e.Message) }

var (
	ErrEmailSudahDipakai  = errors.New("email sudah terdaftar")
	ErrReferensiTidakSah  = errors.New("referensi tidak sah")
	ErrCobaLagi           = errors.New("bentrok sementara, coba lagi")
)

func terjemahkan(err error) error {
	var pgErr *PgError
	if !errors.As(err, &pgErr) {
		return err
	}
	switch pgErr.Code {
	case "23505":
		return ErrEmailSudahDipakai
	case "23503":
		return ErrReferensiTidakSah
	case "40001", "40P01":
		return ErrCobaLagi
	}
	return err
}

func main() {
	kasus := []error{
		&PgError{Code: "23505", Message: "duplicate key value violates unique constraint", Detail: "Key (email)=([email protected]) already exists."},
		&PgError{Code: "23503", Message: "insert or update violates foreign key constraint"},
		&PgError{Code: "40P01", Message: "deadlock detected"},
		&PgError{Code: "42P01", Message: "relation \"pesananx\" does not exist"},
	}

	for _, err := range kasus {
		hasil := terjemahkan(err)
		dapatDiulang := errors.Is(hasil, ErrCobaLagi)
		fmt.Printf("%-8s -> %-32v (boleh diulang: %v)\n",
			err.(*PgError).Code, hasil, dapatDiulang)
	}
}

Praktik dan kuis bab ini di kelas interaktif →

191. sqlc - SQL Aman Tipe Tanpa ORM

Membangkitkan kode Go yang aman tipe langsung dari kueri SQL, memadukan kendali penuh atas SQL dengan pemeriksaan compiler.

Tiga Pendekatan Akses Data

SQL mentahORM (GORM)sqlc
Kendali atas SQLPenuhTerbatasPenuh
Aman tipeTidakSebagianYa, saat kompilasi
Salah ketik kolom terdeteksiSaat runtimeSaat runtimeSaat generate
Risiko N+1Terlihat jelasTersembunyiTerlihat jelas
Kurva belajarSQL sajaAPI ORM yang besarSQL saja
Kode boilerplateBanyakSedikitNol — dibangkitkan
💡 Gagasan inti sqlc

Anda menulis SQL biasa. sqlc terhubung ke skema database Anda, memeriksa kueri tersebut valid, lalu membangkitkan fungsi Go beserta struct-nya. Salah mengetik nama kolom akan gagal saat pembangkitan kode, bukan saat kode sudah berjalan di produksi.

Struktur Proyek

proyek/
├── sqlc.yaml
├── db/
│   ├── skema/
│   │   └── 001_awal.sql          <-- definisi tabel
│   ├── kueri/
│   │   └── pesanan.sql           <-- kueri yang Anda tulis
│   └── gen/                      <-- hasil bangkitan, JANGAN disunting
│       ├── db.go
│       ├── models.go
│       └── pesanan.sql.go
sqlc.yaml
version: "2"
sql:
  - engine: "postgresql"
    schema: "db/skema"
    queries: "db/kueri"
    gen:
      go:
        package: "gen"
        out: "db/gen"
        sql_package: "pgx/v5"
        emit_json_tags: true
        emit_pointers_for_null_types: true
        emit_interface: true          # untuk memudahkan pembuatan mock

Menulis Kueri

db/kueri/pesanan.sql
-- name: AmbilPesanan :one
SELECT * FROM pesanan WHERE id = $1;

-- name: DaftarPesananPengguna :many
SELECT * FROM pesanan
WHERE pengguna_id = $1
ORDER BY dibuat_pada DESC
LIMIT $2 OFFSET $3;

-- name: BuatPesanan :one
INSERT INTO pesanan (pengguna_id, total, status)
VALUES ($1, $2, $3)
RETURNING *;

-- name: PerbaruiStatus :exec
UPDATE pesanan SET status = $2, diubah_pada = now()
WHERE id = $1;

-- name: HitungPerStatus :many
SELECT status, COUNT(*) AS jumlah
FROM pesanan
WHERE dibuat_pada >= $1
GROUP BY status;

-- name: PesananDenganItem :many
SELECT
  p.id, p.total, p.status,
  i.produk_id, i.jumlah, i.harga
FROM pesanan p
JOIN item_pesanan i ON i.pesanan_id = p.id
WHERE p.pengguna_id = $1;

Komentar penanda menentukan bentuk fungsi yang dibangkitkan: :one mengembalikan satu baris, :many mengembalikan slice, :exec tanpa nilai balik data, dan :copyfrom memakai COPY untuk sisipan massal.

Kode yang Dibangkitkan

// db/gen/pesanan.sql.go -- dibangkitkan otomatis

type BuatPesananParams struct {
    PenggunaID int64       `json:"pengguna_id"`
    Total      int64       `json:"total"`
    Status     StatusPesanan `json:"status"`
}

func (q *Queries) BuatPesanan(ctx context.Context, arg BuatPesananParams) (Pesanan, error) {
    baris := q.db.QueryRow(ctx, buatPesanan, arg.PenggunaID, arg.Total, arg.Status)
    var i Pesanan
    err := baris.Scan(&i.ID, &i.PenggunaID, &i.Total, &i.Status, &i.DibuatPada)
    return i, err
}
Pemakaian di aplikasi
kueri := gen.New(pool)

p, err := kueri.BuatPesanan(ctx, gen.BuatPesananParams{
    PenggunaID: 55,
    Total:      250000,
    Status:     gen.StatusPesananMenunggu,
})
// p bertipe gen.Pesanan -- sepenuhnya aman tipe.
// Salah ketik nama field ditangkap compiler.

Transaksi

func (s *Layanan) BuatPesananLengkap(ctx context.Context, req Request) error {
    tx, err := s.pool.Begin(ctx)
    if err != nil {
        return err
    }
    defer tx.Rollback(ctx)

    // Ikat Queries ke transaksi ini
    q := s.kueri.WithTx(tx)

    p, err := q.BuatPesanan(ctx, gen.BuatPesananParams{...})
    if err != nil {
        return err
    }

    for _, it := range req.Item {
        if err := q.TambahItem(ctx, gen.TambahItemParams{
            PesananID: p.ID,
            ProdukID:  it.ProdukID,
            Jumlah:    it.Jumlah,
        }); err != nil {
            return err
        }
    }

    return tx.Commit(ctx)
}

Batasan sqlc yang Perlu Diketahui

⚠️ Kueri dinamis bukan wilayah sqlc

sqlc membangkitkan kode dari SQL statis. Filter yang jumlahnya berubah-ubah sesuai masukan pengguna — misalnya halaman pencarian dengan sepuluh filter opsional — tidak bisa ditangani langsung. Ada dua jalan keluar:

-- 1. Parameter opsional dengan pola sqlc.narg
-- name: CariPesanan :many
SELECT * FROM pesanan
WHERE (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
  AND (sqlc.narg('min_total')::bigint IS NULL OR total >= sqlc.narg('min_total'));

-- 2. Untuk yang benar-benar dinamis, pakai query builder
--    (squirrel, atau pgx langsung) khusus untuk kasus itu saja.
📌 Perpaduan yang sangat baik: sqlc + goose/atlas

sqlc membaca skema dari berkas SQL, jadi ia menyatu mulus dengan alat migrasi seperti goose atau Atlas. Alurnya menjadi: tulis migrasi → jalankan sqlc generate → kompilasi. Bila sebuah migrasi menghapus kolom yang masih dipakai kueri, kompilasi langsung gagal — sebelum kode itu pernah menyentuh produksi.

goose -dir db/skema postgres "$DSN" up
sqlc generate
sqlc vet          # periksa kueri terhadap aturan yang Anda tetapkan
go build ./...

Bahaya N+1 yang Disembunyikan ORM

// Dengan ORM -- terlihat tak berbahaya, padahal 101 kueri
var pesanan []Pesanan
db.Find(&pesanan)                    // 1 kueri
for _, p := range pesanan {
    db.Model(&p).Related(&p.Item)    // 100 kueri lagi!
}

// Dengan sqlc -- Anda MELIHAT SQL-nya, jadi masalahnya kentara
// name: PesananDenganItem :many
SELECT p.*, i.* FROM pesanan p
JOIN item_pesanan i ON i.pesanan_id = p.id
WHERE p.pengguna_id = $1;            // 1 kueri saja
💡 Ini keunggulan sqlc yang paling sering diremehkan

Karena setiap kueri tertulis eksplisit di dalam berkas .sql, masalah kinerja terlihat saat code review, bukan ditemukan lewat profiling produksi berbulan-bulan kemudian. ORM menyembunyikan biaya di balik pemanggilan method yang terlihat sederhana; sqlc menampilkannya apa adanya.

Contoh program

package main

import "fmt"

// Simulasi struct & fungsi hasil bangkitan sqlc
type StatusPesanan string

const (
	StatusMenunggu StatusPesanan = "MENUNGGU"
	StatusDibayar  StatusPesanan = "DIBAYAR"
)

type Pesanan struct {
	ID         string        `json:"id"`
	PenggunaID int64         `json:"pengguna_id"`
	Total      int64         `json:"total"`
	Status     StatusPesanan `json:"status"`
}

type BuatPesananParams struct {
	PenggunaID int64
	Total      int64
	Status     StatusPesanan
}

type Queries struct{ data map[string]Pesanan; urut []string }

func (q *Queries) BuatPesanan(arg BuatPesananParams) (Pesanan, error) {
	id := fmt.Sprintf("p-%04d", len(q.urut)+1001)
	p := Pesanan{ID: id, PenggunaID: arg.PenggunaID, Total: arg.Total, Status: arg.Status}
	q.data[id] = p
	q.urut = append(q.urut, id)
	return p, nil
}

func (q *Queries) DaftarPesananPengguna(penggunaID int64) ([]Pesanan, error) {
	var hasil []Pesanan
	for _, id := range q.urut {
		if p := q.data[id]; p.PenggunaID == penggunaID {
			hasil = append(hasil, p)
		}
	}
	return hasil, nil
}

func main() {
	q := &Queries{data: map[string]Pesanan{}}

	q.BuatPesanan(BuatPesananParams{PenggunaID: 55, Total: 250000, Status: StatusMenunggu})
	q.BuatPesanan(BuatPesananParams{PenggunaID: 55, Total: 89000, Status: StatusDibayar})
	q.BuatPesanan(BuatPesananParams{PenggunaID: 71, Total: 45000, Status: StatusMenunggu})

	daftar, _ := q.DaftarPesananPengguna(55)
	fmt.Println("Pesanan milik pengguna 55:")
	for _, p := range daftar {
		fmt.Printf("  %s  Rp%-8d  %s\n", p.ID, p.Total, p.Status)
	}

	fmt.Println("\nSemua field aman tipe -- salah ketik ditangkap compiler.")
}

Praktik dan kuis bab ini di kelas interaktif →