MySQL #

Go tidak menyertakan driver database spesifik di standard library — melainkan menyediakan database/sql sebagai interface abstraksi yang seragam. Semua operasi database (query, exec, transaction) dilakukan melalui database/sql, sementara driver spesifik (MySQL, PostgreSQL, SQLite) di-register di balik layar. Ini berarti kode bisnis kamu hampir identik apapun database yang dipakai — hanya connection string dan driver yang berbeda. Untuk MySQL, driver yang paling umum dan matang adalah github.com/go-sql-driver/mysql.

Instalasi #

go get github.com/go-sql-driver/mysql

Koneksi ke MySQL #

import (
    "database/sql"
    "fmt"
    "log"
    _ "github.com/go-sql-driver/mysql"  // blank import: register driver
)

func main() {
    // Format DSN: user:password@protocol(host:port)/dbname?param=value
    dsn := "root:password@tcp(localhost:3306)/tokoonline?parseTime=true&loc=Asia%2FJakarta"

    db, err := sql.Open("mysql", dsn)
    if err != nil {
        log.Fatal("sql.Open gagal:", err)
    }
    defer db.Close()

    // sql.Open TIDAK membuka koneksi — hanya validasi DSN
    // Gunakan Ping untuk memverifikasi koneksi aktif
    if err := db.Ping(); err != nil {
        log.Fatal("Ping gagal:", err)
    }
    fmt.Println("Terhubung ke MySQL!")
}

Parameter DSN Penting #

parseTime=true        → scan kolom DATETIME/TIMESTAMP ke time.Time (WAJIB)
loc=Asia%2FJakarta    → timezone untuk interpretasi waktu
charset=utf8mb4       → support emoji dan karakter Unicode penuh
timeout=10s           → connection timeout
readTimeout=30s       → read timeout per query
writeTimeout=30s      → write timeout per query
multiStatements=true  → izinkan beberapa statement dalam satu Exec

Connection Pool — Konfigurasi yang Benar #

database/sql mengelola pool koneksi secara otomatis. Konfigurasi pool sangat penting untuk performa:

func openDB(dsn string) (*sql.DB, error) {
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return nil, err
    }

    // Jumlah koneksi terbuka maksimum ke server
    // Sesuaikan dengan max_connections di MySQL (default 151)
    db.SetMaxOpenConns(25)

    // Koneksi idle yang dipertahankan di pool
    // Sebaiknya sama atau lebih kecil dari MaxOpenConns
    db.SetMaxIdleConns(25)

    // Maksimum umur koneksi (paksa buat koneksi baru setelah ini)
    // Berguna untuk menghindari koneksi stale karena firewall/proxy
    db.SetConnMaxLifetime(5 * time.Minute)

    // Maksimum waktu koneksi boleh idle di pool (Go 1.15+)
    db.SetConnMaxIdleTime(1 * time.Minute)

    // Verifikasi pool berfungsi
    if err := db.Ping(); err != nil {
        return nil, fmt.Errorf("ping database: %w", err)
    }

    return db, nil
}
Jangan tutup *sql.DB setelah setiap query. db adalah pool koneksi yang seharusnya dibuat sekali dan dipakai selama aplikasi berjalan. Panggil db.Close() hanya saat aplikasi berhenti (misalnya di defer di main()).

Query — Membaca Data #

QueryRowContext — Satu Baris #

func getProductByID(ctx context.Context, db *sql.DB, id int) (*Product, error) {
    query := `
        SELECT id, name, price, stock, category, created_at
        FROM products
        WHERE id = ?
    `
    row := db.QueryRowContext(ctx, query, id)

    var p Product
    err := row.Scan(
        &p.ID,
        &p.Name,
        &p.Price,
        &p.Stock,
        &p.Category,
        &p.CreatedAt,
    )
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrNotFound  // produk tidak ditemukan
        }
        return nil, fmt.Errorf("scan product: %w", err)
    }
    return &p, nil
}

QueryContext — Banyak Baris #

func listProducts(ctx context.Context, db *sql.DB, category string) ([]*Product, error) {
    query := `
        SELECT id, name, price, stock, category
        FROM products
        WHERE category = ?
        ORDER BY name ASC
    `
    rows, err := db.QueryContext(ctx, query, category)
    if err != nil {
        return nil, fmt.Errorf("query products: %w", err)
    }
    defer rows.Close()  // WAJIB: tutup rows untuk kembalikan koneksi ke pool

    var products []*Product
    for rows.Next() {
        var p Product
        if err := rows.Scan(
            &p.ID, &p.Name, &p.Price, &p.Stock, &p.Category,
        ); err != nil {
            return nil, fmt.Errorf("scan row: %w", err)
        }
        products = append(products, &p)
    }

    // Cek error setelah iterasi selesai
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("rows error: %w", err)
    }

    return products, nil
}
defer rows.Close() wajib dipanggil. Jika rows tidak ditutup, koneksi tidak dikembalikan ke pool dan pool bisa habis. Panggil segera setelah QueryContext — bahkan jika belum iterasi sama sekali.

Exec — Menulis Data #

ExecContext untuk INSERT, UPDATE, DELETE — tidak mengembalikan rows:

// INSERT
func createProduct(ctx context.Context, db *sql.DB, p *Product) (int64, error) {
    query := `
        INSERT INTO products (name, price, stock, category, created_at)
        VALUES (?, ?, ?, ?, NOW())
    `
    result, err := db.ExecContext(ctx, query,
        p.Name, p.Price, p.Stock, p.Category,
    )
    if err != nil {
        return 0, fmt.Errorf("insert product: %w", err)
    }

    // Ambil ID yang baru dibuat
    id, err := result.LastInsertId()
    if err != nil {
        return 0, fmt.Errorf("last insert id: %w", err)
    }
    return id, nil
}

// UPDATE
func updateProduct(ctx context.Context, db *sql.DB, p *Product) error {
    query := `
        UPDATE products
        SET name = ?, price = ?, stock = ?, category = ?
        WHERE id = ?
    `
    result, err := db.ExecContext(ctx, query,
        p.Name, p.Price, p.Stock, p.Category, p.ID,
    )
    if err != nil {
        return fmt.Errorf("update product: %w", err)
    }

    // Cek apakah ada baris yang terpengaruh
    rows, _ := result.RowsAffected()
    if rows == 0 {
        return ErrNotFound
    }
    return nil
}

// DELETE
func deleteProduct(ctx context.Context, db *sql.DB, id int) error {
    result, err := db.ExecContext(ctx,
        "DELETE FROM products WHERE id = ?", id,
    )
    if err != nil {
        return fmt.Errorf("delete product: %w", err)
    }
    rows, _ := result.RowsAffected()
    if rows == 0 {
        return ErrNotFound
    }
    return nil
}

Prepared Statement #

Prepared statement diparse sekali oleh server, bisa dieksekusi berkali-kali dengan parameter berbeda — lebih aman dari SQL injection dan lebih efisien untuk query berulang:

// Buat statement sekali, pakai berkali-kali
func bulkUpdateStock(ctx context.Context, db *sql.DB, updates []StockUpdate) error {
    stmt, err := db.PrepareContext(ctx,
        "UPDATE products SET stock = ? WHERE id = ?",
    )
    if err != nil {
        return fmt.Errorf("prepare statement: %w", err)
    }
    defer stmt.Close()  // kembalikan statement ke pool

    for _, u := range updates {
        if _, err := stmt.ExecContext(ctx, u.NewStock, u.ProductID); err != nil {
            return fmt.Errorf("update stok produk %d: %w", u.ProductID, err)
        }
    }
    return nil
}

Transaksi #

Transaksi memastikan sekelompok operasi berjalan secara atomik — semua berhasil atau semua dibatalkan:

func transferStock(ctx context.Context, db *sql.DB, fromID, toID, qty int) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    // Defer rollback — tidak berbahaya jika commit sudah berhasil
    defer tx.Rollback()

    // Kurangi stok dari produk asal
    result, err := tx.ExecContext(ctx,
        "UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?",
        qty, fromID, qty,
    )
    if err != nil {
        return fmt.Errorf("kurangi stok: %w", err)
    }
    if rows, _ := result.RowsAffected(); rows == 0 {
        return errors.New("stok tidak mencukupi")
    }

    // Tambah stok ke produk tujuan
    if _, err := tx.ExecContext(ctx,
        "UPDATE products SET stock = stock + ? WHERE id = ?",
        qty, toID,
    ); err != nil {
        return fmt.Errorf("tambah stok: %w", err)
    }

    // Catat log transfer
    if _, err := tx.ExecContext(ctx,
        "INSERT INTO stock_transfers (from_id, to_id, qty, created_at) VALUES (?, ?, ?, NOW())",
        fromID, toID, qty,
    ); err != nil {
        return fmt.Errorf("catat transfer: %w", err)
    }

    // Commit — jika berhasil, defer Rollback tidak berpengaruh
    return tx.Commit()
}

Null Values #

MySQL memungkinkan kolom bernilai NULL. Gunakan tipe sql.Null* untuk menanganinya:

type Product struct {
    ID          int
    Name        string
    Description sql.NullString  // bisa NULL
    Price       float64
    DeletedAt   sql.NullTime    // soft delete, bisa NULL
    Weight      sql.NullFloat64 // bisa NULL
}

// Scan dengan null values
var p Product
err := row.Scan(
    &p.ID,
    &p.Name,
    &p.Description,  // auto-handle NULL
    &p.Price,
    &p.DeletedAt,
    &p.Weight,
)

// Akses nilai
if p.Description.Valid {
    fmt.Println("Deskripsi:", p.Description.String)
} else {
    fmt.Println("Tidak ada deskripsi")
}

// Insert dengan null
description := sql.NullString{}  // NULL
if desc := "Laptop gaming"; desc != "" {
    description = sql.NullString{String: desc, Valid: true}
}
db.ExecContext(ctx,
    "INSERT INTO products (name, description) VALUES (?, ?)",
    "Laptop", description,
)

Batch Insert #

Untuk insert banyak baris sekaligus, hindari loop satu per satu:

func batchInsertProducts(ctx context.Context, db *sql.DB, products []Product) error {
    if len(products) == 0 {
        return nil
    }

    // Bangun query dengan banyak placeholder
    // INSERT INTO products (name, price, stock) VALUES (?, ?, ?), (?, ?, ?), ...
    valueStrings := make([]string, len(products))
    valueArgs := make([]interface{}, 0, len(products)*3)

    for i, p := range products {
        valueStrings[i] = "(?, ?, ?)"
        valueArgs = append(valueArgs, p.Name, p.Price, p.Stock)
    }

    query := fmt.Sprintf(
        "INSERT INTO products (name, price, stock) VALUES %s",
        strings.Join(valueStrings, ","),
    )

    _, err := db.ExecContext(ctx, query, valueArgs...)
    if err != nil {
        return fmt.Errorf("batch insert: %w", err)
    }
    return nil
}

Contoh Program Lengkap — Repository Pattern #

package main

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
    "log"
    "strings"
    "time"

    _ "github.com/go-sql-driver/mysql"
)

var ErrNotFound = errors.New("data tidak ditemukan")

type Product struct {
    ID        int
    Name      string
    Price     float64
    Stock     int
    Category  string
    CreatedAt time.Time
}

// ProductRepository — interface untuk abstraksi
type ProductRepository interface {
    Create(ctx context.Context, p *Product) (int64, error)
    FindByID(ctx context.Context, id int) (*Product, error)
    FindByCategory(ctx context.Context, category string) ([]*Product, error)
    Update(ctx context.Context, p *Product) error
    Delete(ctx context.Context, id int) error
    Search(ctx context.Context, keyword string, limit int) ([]*Product, error)
}

// mysqlProductRepo — implementasi MySQL
type mysqlProductRepo struct {
    db *sql.DB
}

func NewProductRepository(db *sql.DB) ProductRepository {
    return &mysqlProductRepo{db: db}
}

func (r *mysqlProductRepo) Create(ctx context.Context, p *Product) (int64, error) {
    result, err := r.db.ExecContext(ctx, `
        INSERT INTO products (name, price, stock, category, created_at)
        VALUES (?, ?, ?, ?, NOW())
    `, p.Name, p.Price, p.Stock, p.Category)
    if err != nil {
        return 0, fmt.Errorf("create product: %w", err)
    }
    return result.LastInsertId()
}

func (r *mysqlProductRepo) FindByID(ctx context.Context, id int) (*Product, error) {
    var p Product
    err := r.db.QueryRowContext(ctx, `
        SELECT id, name, price, stock, category, created_at
        FROM products WHERE id = ?
    `, id).Scan(&p.ID, &p.Name, &p.Price, &p.Stock, &p.Category, &p.CreatedAt)

    if errors.Is(err, sql.ErrNoRows) {
        return nil, ErrNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("find product %d: %w", id, err)
    }
    return &p, nil
}

func (r *mysqlProductRepo) FindByCategory(ctx context.Context, category string) ([]*Product, error) {
    rows, err := r.db.QueryContext(ctx, `
        SELECT id, name, price, stock, category, created_at
        FROM products WHERE category = ? ORDER BY name
    `, category)
    if err != nil {
        return nil, fmt.Errorf("list products: %w", err)
    }
    defer rows.Close()

    var products []*Product
    for rows.Next() {
        var p Product
        if err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.Stock,
            &p.Category, &p.CreatedAt); err != nil {
            return nil, fmt.Errorf("scan product: %w", err)
        }
        products = append(products, &p)
    }
    return products, rows.Err()
}

func (r *mysqlProductRepo) Update(ctx context.Context, p *Product) error {
    res, err := r.db.ExecContext(ctx, `
        UPDATE products SET name=?, price=?, stock=?, category=?
        WHERE id=?
    `, p.Name, p.Price, p.Stock, p.Category, p.ID)
    if err != nil {
        return fmt.Errorf("update product: %w", err)
    }
    if n, _ := res.RowsAffected(); n == 0 {
        return ErrNotFound
    }
    return nil
}

func (r *mysqlProductRepo) Delete(ctx context.Context, id int) error {
    res, err := r.db.ExecContext(ctx,
        "DELETE FROM products WHERE id = ?", id)
    if err != nil {
        return fmt.Errorf("delete product: %w", err)
    }
    if n, _ := res.RowsAffected(); n == 0 {
        return ErrNotFound
    }
    return nil
}

func (r *mysqlProductRepo) Search(ctx context.Context, keyword string, limit int) ([]*Product, error) {
    rows, err := r.db.QueryContext(ctx, `
        SELECT id, name, price, stock, category, created_at
        FROM products
        WHERE name LIKE ? OR category LIKE ?
        ORDER BY name LIMIT ?
    `, "%"+keyword+"%", "%"+keyword+"%", limit)
    if err != nil {
        return nil, fmt.Errorf("search products: %w", err)
    }
    defer rows.Close()

    var products []*Product
    for rows.Next() {
        var p Product
        if err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.Stock,
            &p.Category, &p.CreatedAt); err != nil {
            return nil, err
        }
        products = append(products, &p)
    }
    return products, rows.Err()
}

// DDL untuk setup tabel
const createTableSQL = `
CREATE TABLE IF NOT EXISTS products (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    name       VARCHAR(200) NOT NULL,
    price      DECIMAL(15,2) NOT NULL DEFAULT 0,
    stock      INT NOT NULL DEFAULT 0,
    category   VARCHAR(100) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_category (category),
    INDEX idx_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`

func main() {
    dsn := "root:password@tcp(localhost:3306)/tokoonline?parseTime=true"

    db, err := sql.Open("mysql", dsn)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(25)
    db.SetConnMaxLifetime(5 * time.Minute)

    if err := db.Ping(); err != nil {
        log.Fatal("Koneksi gagal:", err)
    }

    // Setup tabel
    if _, err := db.Exec(createTableSQL); err != nil {
        log.Fatal("Create table:", err)
    }

    ctx := context.Background()
    repo := NewProductRepository(db)

    // Buat produk
    products := []Product{
        {Name: "Laptop Pro 14", Price: 15_000_000, Stock: 10, Category: "elektronik"},
        {Name: "Mouse Wireless", Price: 350_000, Stock: 50, Category: "elektronik"},
        {Name: "Keyboard Mech", Price: 1_500_000, Stock: 25, Category: "elektronik"},
        {Name: "Kaos Polos", Price: 85_000, Stock: 100, Category: "fashion"},
        {Name: "Celana Chino", Price: 250_000, Stock: 60, Category: "fashion"},
    }

    fmt.Println("=== Insert Produk ===")
    ids := make([]int64, 0)
    for _, p := range products {
        p := p
        id, err := repo.Create(ctx, &p)
        if err != nil {
            log.Printf("Gagal insert %s: %v", p.Name, err)
            continue
        }
        ids = append(ids, id)
        fmt.Printf("  [%d] %s — Rp%.0f\n", id, p.Name, p.Price)
    }

    // Cari satu produk
    fmt.Println("\n=== FindByID ===")
    if len(ids) > 0 {
        p, err := repo.FindByID(ctx, int(ids[0]))
        if err != nil {
            log.Println("FindByID error:", err)
        } else {
            fmt.Printf("  ID=%d, %s, Rp%.0f, Stok=%d\n",
                p.ID, p.Name, p.Price, p.Stock)
        }
    }

    // Daftar per kategori
    fmt.Println("\n=== FindByCategory: elektronik ===")
    elektronik, _ := repo.FindByCategory(ctx, "elektronik")
    for _, p := range elektronik {
        fmt.Printf("  [%d] %-20s Rp%10.0f  stok=%d\n",
            p.ID, p.Name, p.Price, p.Stock)
    }

    // Update
    fmt.Println("\n=== Update ===")
    if len(ids) > 0 {
        err := repo.Update(ctx, &Product{
            ID: int(ids[0]), Name: "Laptop Pro 14 (New)",
            Price: 14_500_000, Stock: 8, Category: "elektronik",
        })
        if err != nil {
            log.Println("Update error:", err)
        } else {
            fmt.Println("  Update berhasil")
        }
    }

    // Search
    fmt.Println("\n=== Search: 'laptop' ===")
    results, _ := repo.Search(ctx, "laptop", 10)
    for _, p := range results {
        fmt.Printf("  [%d] %s — Rp%.0f\n", p.ID, p.Name, p.Price)
    }

    // Delete
    if len(ids) > 0 {
        fmt.Println("\n=== Delete ===")
        err := repo.Delete(ctx, int(ids[len(ids)-1]))
        if err != nil {
            log.Println("Delete error:", err)
        } else {
            fmt.Printf("  Produk ID %d dihapus\n", ids[len(ids)-1])
        }
    }

    // Statistik pool
    stats := db.Stats()
    fmt.Printf("\n=== Pool Stats ===\n")
    fmt.Printf("  Open connections  : %d\n", stats.OpenConnections)
    fmt.Printf("  In use            : %d\n", stats.InUse)
    fmt.Printf("  Idle              : %d\n", stats.Idle)
    fmt.Printf("  Wait count        : %d\n", stats.WaitCount)

    _ = strings.Join  // suppress import
}

Ringkasan #

  • database/sql adalah abstraksi standar; driver di-import dengan blank import _ "github.com/go-sql-driver/mysql".
  • DSN wajib mengandung parseTime=true agar kolom DATETIME/TIMESTAMP otomatis di-scan ke time.Time.
  • Connection pool: SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime wajib dikonfigurasi — default tidak membatasi koneksi.
  • defer rows.Close() segera setelah QueryContext — jika tidak, koneksi tidak kembali ke pool.
  • sql.ErrNoRows dari QueryRow.Scan berarti baris tidak ditemukan — bukan error fatal.
  • result.RowsAffected() untuk UPDATE/DELETE — cek apakah ada baris yang benar-benar terpengaruh.
  • Prepared statement untuk query yang dieksekusi berulang kali — lebih aman dan efisien.
  • Transaksi dengan defer tx.Rollback() — aman karena Rollback setelah Commit tidak berbahaya.
  • sql.NullString, sql.NullTime untuk kolom yang bisa NULL — cek field Valid sebelum mengakses nilainya.
  • Batch insert dengan satu query multi-values jauh lebih cepat daripada loop INSERT satu per satu.

← Sebelumnya: YAML   Berikutnya: MSSQL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact