MySQL với Go (Golang): Kết nối database/sql, SQLX và tối ưu Connection Pool cho microservices

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

Vấn đề thực tế: Go microservice kết nối MySQL bị chậm và timeout

Hệ thống microservices của mình chạy ổn định cho đến khi traffic tăng đột biến. Mỗi request đều tạo connection mới đến MySQL, dẫn đến latency tăng vọt và xuất hiện lỗi too many connections. Database production của mình chạy MySQL 8.0 với khoảng 50GB data — và chỉ sau khi optimize connection pool thì P99 latency mới giảm từ 800ms xuống ~120ms lúc cao điểm.

Mình mất gần nửa ngày trace log mới tìm ra nguyên nhân. Go và MySQL đều không lỗi — vấn đề thuần túy là code đang tạo connection mới cho từng request.

Phân tích nguyên nhân

1. Mỗi request mở một connection mới

Code Go đơn giản nhất để kết nối MySQL trông như thế này:

db, err := sql.Open("mysql", dsn)
defer db.Close()
// Thực hiện query rồi đóng

Gọi sql.Open() bên trong mỗi handler function, mỗi request sẽ tạo một TCP connection mới đến MySQL. 100 concurrent requests = 100 connections mới đổ vào cùng lúc — trong khi MySQL mặc định chỉ cho phép 151 connections tổng cộng.

2. Không cấu hình pool size

Singleton pattern đúng rồi nhưng quên set giới hạn — Go sẽ vô tư mở connections theo nhu cầu, không có trần. Hậu quả:

  • MySQL bị quá tải khi traffic tăng nhanh
  • Connection overhead lớn — mỗi TCP handshake + MySQL auth mất khoảng 5–10ms
  • Memory phía MySQL tăng không kiểm soát

3. Raw database/sql quá verbose

Với database/sql thuần, code scan kết quả query vào struct rất rườm rà:

rows, err := db.Query("SELECT id, name, email FROM users WHERE id = ?", id)
var user User
if rows.Next() {
    err = rows.Scan(&user.ID, &user.Name, &user.Email)
}

Struct có 10–15 columns là phải liệt kê từng field trong Scan(). Dễ bỏ sót, khó maintain khi schema thay đổi.

Các cách giải quyết

Cách 1: Dùng database/sql đúng cách (Singleton pattern)

Cài driver MySQL cho Go:

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

Tạo *sql.DB một lần duy nhất, dùng lại suốt vòng đời ứng dụng:

package database

import (
    "database/sql"
    "fmt"
    "time"
    _ "github.com/go-sql-driver/mysql"
)

var DB *sql.DB

func Init(dsn string) error {
    var err error
    DB, err = sql.Open("mysql", dsn)
    if err != nil {
        return fmt.Errorf("open mysql: %w", err)
    }

    DB.SetMaxOpenConns(25)
    DB.SetMaxIdleConns(10)
    DB.SetConnMaxLifetime(5 * time.Minute)

    return DB.Ping()
}

Trong main.go, gọi một lần rồi truyền database.DB vào các handler:

dsn := "user:password@tcp(localhost:3306)/mydb?parseTime=true&charset=utf8mb4"
if err := database.Init(dsn); err != nil {
    log.Fatal(err)
}

Connection leak không còn là vấn đề nữa — nhưng code query vẫn verbose với struct nhiều fields.

Cách 2: Dùng SQLX — database/sql nhưng thực dụng hơn

SQLX là wrapper mỏng của database/sql. Toàn bộ interface giữ nguyên, nhưng bạn có thêm StructScan, NamedQuery, và Select — những thứ mà database/sql cố tình không có để giữ thư viện chuẩn gọn.

go get github.com/jmoiron/sqlx

Thay *sql.DB bằng *sqlx.DB:

package database

import (
    "fmt"
    "time"
    "github.com/jmoiron/sqlx"
    _ "github.com/go-sql-driver/mysql"
)

var DB *sqlx.DB

func Init(dsn string) error {
    var err error
    DB, err = sqlx.Connect("mysql", dsn)
    if err != nil {
        return fmt.Errorf("connect mysql: %w", err)
    }

    DB.SetMaxOpenConns(25)
    DB.SetMaxIdleConns(10)
    DB.SetConnMaxLifetime(5 * time.Minute)
    DB.SetConnMaxIdleTime(2 * time.Minute)

    return nil
}

Query với SQLX gọn hơn nhiều — scan thẳng vào struct qua tag db:

type User struct {
    ID        int       `db:"id"`
    Name      string    `db:"name"`
    Email     string    `db:"email"`
    CreatedAt time.Time `db:"created_at"`
}

// Lấy một record
var user User
err := database.DB.Get(&user, "SELECT * FROM users WHERE id = ?", userID)

// Lấy nhiều records
var users []User
err = database.DB.Select(&users, "SELECT * FROM users WHERE active = 1 LIMIT 100")

NamedExec cho phép dùng tên field thay vì positional placeholder. INSERT 8–10 columns mà vẫn đọc được ngay:

user := User{Name: "Nguyen Van A", Email: "[email protected]"}
_, err := database.DB.NamedExec(`
    INSERT INTO users (name, email) VALUES (:name, :email)
`, user)

Cách 3: Transaction đúng cách với SQLX

Microservices thường cần transaction khi thao tác nhiều bảng cùng lúc. Pattern chuẩn dùng defer tx.Rollback():

func TransferBalance(fromID, toID int, amount float64) error {
    tx, err := database.DB.Beginx()
    if err != nil {
        return err
    }
    defer tx.Rollback() // no-op nếu đã Commit thành công

    if _, err = tx.Exec(
        "UPDATE accounts SET balance = balance - ? WHERE id = ?",
        amount, fromID,
    ); err != nil {
        return err
    }

    if _, err = tx.Exec(
        "UPDATE accounts SET balance = balance + ? WHERE id = ?",
        amount, toID,
    ); err != nil {
        return err
    }

    return tx.Commit()
}

defer tx.Rollback() là safety net: function return lỗi trước khi Commit() thì transaction tự rollback. Sau khi Commit() thành công, Rollback() gọi vào là no-op — không ảnh hưởng gì.

Tối ưu Connection Pool cho production

Hiểu 4 thông số pool

Connection pool trong Go có 4 thông số cần cấu hình đúng. Để mặc định là để Go quyết định thay bạn — và Go sẽ chọn “không giới hạn”:

DB.SetMaxOpenConns(n)      // Tổng connection tối đa (idle + active)
DB.SetMaxIdleConns(n)      // Connection giữ trong pool khi rảnh
DB.SetConnMaxLifetime(d)   // Thời gian tối đa một connection được sống
DB.SetConnMaxIdleTime(d)   // Thời gian tối đa connection ở trạng thái idle

Công thức tính pool size cho microservices

Sau nhiều lần chạy production và nhìn vào metric, mình rút ra công thức đơn giản này:

  • MaxOpenConns = số Goroutines xử lý concurrent request × 1.5 (để có buffer)
  • MaxIdleConns = 40% của MaxOpenConns (đủ để tái sử dụng, không lãng phí)
  • ConnMaxLifetime = 5–10 phút (tránh stale connection sau MySQL restart/failover)
  • ConnMaxIdleTime = 2–3 phút (giải phóng connection không dùng, giảm tải MySQL)

Service xử lý 50 concurrent requests — đây là config mình đang dùng thực tế:

func InitProductionDB(dsn string) (*sqlx.DB, error) {
    fullDSN := dsn + "?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci"
    db, err := sqlx.Connect("mysql", fullDSN)
    if err != nil {
        return nil, err
    }

    db.SetMaxOpenConns(75)               // 50 × 1.5
    db.SetMaxIdleConns(30)               // 40% của 75
    db.SetConnMaxLifetime(8 * time.Minute)
    db.SetConnMaxIdleTime(3 * time.Minute)

    return db, nil
}

Giám sát pool health với db.Stats()

Go cung cấp db.Stats() để xem trạng thái pool theo thời gian thực — không cần APM, không cần tool bên ngoài:

func LogDBStats(db *sqlx.DB) {
    s := db.Stats()
    log.Printf(
        "DB Pool — open: %d, idle: %d, in-use: %d, wait: %d, waitDuration: %s",
        s.OpenConnections, s.Idle, s.InUse, s.WaitCount, s.WaitDuration,
    )
}

WaitCount liên tục tăng → MaxOpenConns quá thấp, request đang xếp hàng chờ connection. Idle luôn bằng MaxIdleConns → đang giữ quá nhiều connection rảnh, mỗi connection MySQL chiếm khoảng 4–8MB RAM — nhân lên vài chục là thấy ngay.

Context timeout cho mọi query

Một query không có timeout có thể giữ connection trong pool suốt 30 giây, kéo theo các request sau bị queue — rồi service gọi vào cũng timeout theo dây chuyền. Luôn dùng GetContext/SelectContext:

func GetUserWithTimeout(ctx context.Context, db *sqlx.DB, id int) (*User, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    var user User
    err := db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = ?", id)
    if err != nil {
        return nil, fmt.Errorf("get user %d: %w", id, err)
    }
    return &user, nil
}

Checklist trước khi deploy production

  • Tạo *sqlx.DB một lần duy nhất, truyền vào các handler (không tạo lại trong handler)
  • Cấu hình đủ 4 thông số pool — không để mặc định
  • DSN phải có parseTime=truecharset=utf8mb4
  • Dùng GetContext/SelectContext với timeout cho mọi query
  • Transaction: defer tx.Rollback() ngay sau Beginx()
  • Monitor db.Stats() định kỳ, đặc biệt khi traffic tăng đột biến

Share: