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.
| Kemampuan | database/sql + lib/pq | pgx native |
|---|---|---|
| Prepared statement cache | Terbatas | Otomatis & efisien |
| Tipe PostgreSQL (array, JSONB, range) | Perlu pembungkus manual | Didukung langsung |
| Protokol biner | Tidak | Ya — parsing lebih cepat |
| COPY untuk sisipan massal | Tidak nyaman | Bawaan |
| LISTEN/NOTIFY | Tidak | Ya |
| Batch query | Tidak | Ya — satu round-trip |
| Detail error PostgreSQL | Terbatas | Lengkap (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
}
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
}
}
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)
}
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
}
}
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)
}
}
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 mentah | ORM (GORM) | sqlc | |
|---|---|---|---|
| Kendali atas SQL | Penuh | Terbatas | Penuh |
| Aman tipe | Tidak | Sebagian | Ya, saat kompilasi |
| Salah ketik kolom terdeteksi | Saat runtime | Saat runtime | Saat generate |
| Risiko N+1 | Terlihat jelas | Tersembunyi | Terlihat jelas |
| Kurva belajar | SQL saja | API ORM yang besar | SQL saja |
| Kode boilerplate | Banyak | Sedikit | Nol — dibangkitkan |
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
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
-- 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
}
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
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.
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
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.")
}