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.DBmộ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=truevàcharset=utf8mb4 - Dùng
GetContext/SelectContextvới timeout cho mọi query - Transaction:
defer tx.Rollback()ngay sauBeginx() - Monitor
db.Stats()định kỳ, đặc biệt khi traffic tăng đột biến

