database/sql là tầng trừu tượng chung, driver là gói riêng. Bài này về những chỗ nó khác JDBC.
sql.Open không kết nối
db, err := sql.Open("postgres", dsn)
Open chỉ kiểm chuỗi DSN
Ping OK
Open không mở kết nối nào. Nó chỉ phân tích DSN và chuẩn bị pool. Lỗi sai mật khẩu hay sai host chỉ lộ ra ở truy vấn đầu tiên.
Nên luôn Ping lúc khởi động:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
log.Fatalf("không kết nối được CSDL: %v", err)
}
Chết lúc khởi động tốt hơn nhiều so với chết ở request đầu tiên của người dùng.
Và *sql.DB là pool, không phải một kết nối. Tạo một lần cho cả ứng dụng, an toàn với goroutine. Đừng Open trong mỗi handler.
Pool mặc định không giới hạn
MaxOpenConnections = 0 (0 = KHÔNG giới hạn)
Đây là mặc định nguy hiểm. Với net/http tạo một goroutine cho mỗi request (bài 46), mười nghìn request đồng thời sẽ xin mười nghìn kết nối — và PostgreSQL mặc định chỉ cho 100.
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
ConnMaxLifetime phải ngắn hơn giới hạn của CSDL và của mọi tường lửa trên đường — nếu không bạn gặp lỗi "connection reset" ngẫu nhiên, đúng bài học ở bài 66 sê-ri Java.
Cách chọn kích thước cũng giống bài đó: bắt đầu ở khoảng hai lần số nhân của máy chủ CSDL, rồi đo.
Rò rỉ kết nối, tái hiện bằng ba dòng
db.SetMaxOpenConns(3)
for i := 0; i < 3; i++ {
db.QueryContext(ctx, `select 1`) // KHÔNG đóng Rows
}
db.PingContext(ctx)
Ping -> context deadline exceeded (chờ 300ms)
Stats: InUse=3 Idle=0 WaitCount=1
Ba truy vấn, pool cạn sạch, mọi thao tác sau đó chờ vô hạn.
Query giữ kết nối cho tới khi Rows đóng. Đây là khác biệt lớn nhất với JDBC theo nghĩa dễ mắc lỗi:
rows, err := db.QueryContext(ctx, q)
if err != nil { return err }
defer rows.Close() // BẮT BUỘC
for rows.Next() {
if err := rows.Scan(&a, &b); err != nil { return err }
}
return rows.Err() // CŨNG bắt buộc
rows.Err() là dòng hay quên nhất. rows.Next() trả false cho cả hai trường hợp: hết dòng, và có lỗi giữa chừng. Không kiểm Err() thì lỗi mạng trông y hệt truy vấn xong.
Duyệt hết bằng rows.Next() thì Rows tự đóng — nhưng break giữa chừng hay return sớm thì không. Nên defer rows.Close() luôn luôn.
db.Stats() là công cụ chẩn đoán: WaitCount tăng đều nghĩa là pool quá nhỏ hoặc có rò rỉ.
QueryRow và ErrNoRows
var ma string
err := db.QueryRowContext(ctx, `select ma from don where id=$1`, id).Scan(&ma)
if errors.Is(err, sql.ErrNoRows) {
return nil, &LoiKhongTim{Ma: id}
}
errors.Is(err, sql.ErrNoRows) = true
QueryRow không cần đóng — Scan tự lo. Và không có dòng nào là một error, không phải nil.
Đây là chỗ nên đổi sang lỗi của miền nghiệp vụ, đúng như bài 28 đã nói — tầng trên không cần biết database/sql tồn tại.
NULL không quét vào string được
Scan(&string) -> converting NULL to string is unsupported
Scan(&sql.NullString) -> Valid=false String=""
Scan(&*string) -> <nil>
Ba lựa chọn, và tôi khuyên cái thứ ba: con trỏ. Nó gọn hơn sql.NullString và ghép thẳng với JSON — *string nil thành null, đúng như bài 45 đã nói.
sql.NullString hợp khi bạn muốn tránh cấp phát con trỏ, hoặc khi kiểu đích đã là struct có sẵn.
Cách thứ tư là xử lý ngay trong SQL: coalesce(ghichu, ''). Đơn giản nhất khi giá trị mặc định có nghĩa.
Tham số dùng ký hiệu của driver
db.Exec(`insert into don(ma,tien) values ($1,$2)`, ma, tien) // PostgreSQL
db.Exec(`insert into don(ma,tien) values (?,?)`, ma, tien) // MySQL, SQLite
Ký hiệu khác nhau theo driver — đây là chỗ database/sql không trừu tượng hoá được. Đổi CSDL là phải sửa mọi câu lệnh.
Nhưng quan trọng hơn: luôn dùng tham số, đừng nối chuỗi. Bài 65 sê-ri Java đã đo một chuỗi 28 ký tự xoá cả một bảng. Nguyên tắc y hệt trong Go.
Và $1 không thay được tên bảng hay tên cột — với sắp xếp động, phải đối chiếu danh sách cho phép.
Giao dịch
tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback() // an toàn: no-op nếu đã Commit
if _, err := tx.ExecContext(ctx, ...); err != nil { return err }
return tx.Commit()
sau Rollback: 0 dòng
defer tx.Rollback() là mẫu chuẩn: gọi sau Commit không gây lỗi thật (trả sql.ErrTxDone), nên bạn được bảo vệ ở mọi đường thoát.
Chú ý tx chiếm một kết nối suốt thời gian sống. Giao dịch dài là kết nối bị giam lâu — đừng gọi API bên ngoài khi đang mở giao dịch.
Có nên dùng ORM
database/sql khá dài dòng — quét từng cột bằng tay.
sqlx là bước nhẹ nhàng nhất: giữ nguyên database/sql, thêm StructScan để ánh xạ vào struct. Với tôi đây là điểm cân bằng tốt nhất.
sqlc sinh mã Go từ câu SQL bạn viết — kiểu an toàn, không phản chiếu, và SQL vẫn là SQL.
gorm đầy đủ nhất nhưng che nhiều thứ, và bài toán N+1 quay lại đúng như ở JPA.
Cộng đồng Go nghiêng hẳn về "viết SQL thật". Với dịch vụ vừa, sqlx hoặc sqlc gần như luôn là lựa chọn tôi khuyên.
Thử ba mươi giây
Thêm vào endpoint sức khoẻ:
s := db.Stats()
fmt.Fprintf(w, "open=%d inuse=%d idle=%d wait=%d", s.OpenConnections, s.InUse, s.Idle, s.WaitCount)
WaitCount tăng đều theo thời gian nghĩa là pool đang cạn — hoặc quá nhỏ, hoặc có Rows không được đóng ở đâu đó.
Ngày mai: time — Duration, Ticker, và cái bẫy múi giờ.