Hình dung *sql.DB như cái tủ chìa khoá của một đội xe dùng chung, chứ không phải một chiếc xe. Khi bạn gọi sql.Open, bạn chỉ mới lấy được mã mở tủ — chưa chiếc xe nào nổ máy, nên sai mật khẩu CSDL vẫn chưa lộ ra. Mỗi truy vấn là mượn một xe ra chạy, và phải trả chìa lại; quên trả thì chiếc đó biến mất khỏi đội, và khi đội hết xe thì người tiếp theo đứng chờ mãi. Ba hình ảnh đó — tủ chìa khoá, nổ thử một xe, nhớ trả chìa — giải thích gần hết những chỗ database/sql khác JDBC và hay cắn người mới. Nó 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.

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ấy mã mở tủ, chưa nổ máy xe nào. 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 — nổ thử một chiếc để chắc nó chạy:

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 — cái tủ phát chìa vô hạn. 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 chỗ trong bãi.

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 — ba người mượn xe không trả chìa, đội ba xe hết sạch.

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.

Và một cách soi nhanh sức khoẻ của cái tủ chìa khoá — 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 đó (một chiếc xe ai đó mượn mà chưa trả chìa).

Mẫu số chung

Cái pool kết nối là cùng một cỗ máy ở mọi ngôn ngữ, và ba luật của nó không đổi dù bạn viết gì.

  • Nó là pool, không phải một kết nối, và mở thì lười. Java có HikariCP, Python có pool của SQLAlchemy, Node có pool của pg/mysql2, Ruby có pool của ActiveRecord — tất cả đều là cái tủ chìa khoá, và một DataSource/engine cũng không kết nối ngay. Nên ở đâu cũng nên nổ thử một xe (validate/ping) lúc khởi động.
  • Kích thước pool là trần thông lượng cứng, và phải cap — mặc định vô hạn của database/sql là footgun vì nó giẫm đạp lên chính giới hạn của CSDL. maxLifetime phải ngắn hơn timeout của CSDL và tường lửa, ở mọi stack.
  • Một result set không đóng là rò rỉ làm cạn pool rồi treo tất cả — nên mọi ngôn ngữ đều ghép "mượn" với một "trả" bắt buộc: try-with-resources của Java, context manager của Python, client.release() của Node, defer rows.Close() của Go. Cùng một khẩu súng, cùng một cái khoá an toàn.

Và hai chỗ tầng trừu tượng rò rỉ cũng lặp lại khắp nơi: ký hiệu tham số khác nhau theo driver ($1 so với ?) — bạn không che hết được phương ngữ SQL; và NULL của SQL không vừa với null của ngôn ngữ, vì SQL dùng logic ba giá trị còn ngôn ngữ dùng hai, nên ở đâu cũng phải có một lớp bọc: *string hay NullString của Go, Optional hay wasNull của Java, None của Python. Sợi chỉ chung đáng mang theo: hãy coi pool như một đội xe khan hiếm — cap nó lại, nổ thử lúc khởi động, và luôn trả lại thứ mình mượn. Ba việc đó, ở Go hay Java hay Python, là ranh giới giữa một dịch vụ chạy êm và một dịch vụ treo cứng dưới tải mà không ai hiểu vì sao.

Ngày mai: time — Duration, Ticker, và cái bẫy múi giờ.

Bài tập làm thử

Bài 1 (đọc hiểu). Đoạn mã sau chạy với DSN sai mật khẩu. Lỗi sẽ xuất hiện ở dòng nào, và tại sao không phải dòng sql.Open?

db, err := sql.Open("postgres", "postgres://user:sai_mat_khau@host/db")
if err != nil {
	log.Fatal(err)
}
_, err = db.Exec("select 1")
if err != nil {
	log.Fatal(err) // <- lỗi lộ ra ở đây
}
Đáp án

Lỗi lộ ra ở dòng db.Exec("select 1"), không phải ở sql.Open. sql.Open không hề mở kết nối nào — nó chỉ phân tích chuỗi DSN và chuẩn bị pool, giống như "lấy mã mở tủ chìa khoá" chứ chưa "nổ máy" chiếc xe nào. Lỗi sai mật khẩu hay sai host chỉ lộ ra ở truy vấn thật đầu tiên, khi Go thực sự cố mở một kết nối tới CSDL. Đây là lý do bài viết khuyên luôn gọi db.PingContext(ctx) ngay lúc khởi động để lỗi cấu hình xuất hiện sớm, thay vì đợi tới request đầu tiên của người dùng.

Bài 2 (sửa lỗi). Đoạn mã sau có ít nhất hai lỗi khiến pool kết nối cạn kiệt hoặc bỏ sót lỗi. Tìm và sửa.

func LayDanhSach(db *sql.DB, ctx context.Context) ([]string, error) {
	rows, err := db.QueryContext(ctx, `select ten from don`)
	if err != nil {
		return nil, err
	}
	var ds []string
	for rows.Next() {
		var ten string
		rows.Scan(&ten)
		ds = append(ds, ten)
	}
	return ds, nil
}
Đáp án

Lỗi 1: thiếu defer rows.Close(). Query giữ kết nối cho tới khi Rows đóng; không đóng sẽ rò rỉ kết nối, và ba lần rò rỉ liên tiếp với MaxOpenConns nhỏ đã đủ làm cạn pool, khiến mọi thao tác sau đó chờ vô hạn.

Lỗi 2: không kiểm tra rows.Err() sau vòng lặp. rows.Next() trả về 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ì một lỗi mạng giữa chừng trông y hệt truy vấn đã xong bình thường.

func LayDanhSach(db *sql.DB, ctx context.Context) ([]string, error) {
	rows, err := db.QueryContext(ctx, `select ten from don`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var ds []string
	for rows.Next() {
		var ten string
		if err := rows.Scan(&ten); err != nil {
			return nil, err
		}
		ds = append(ds, ten)
	}
	return ds, rows.Err()
}

Bài 3 (đọc hiểu). Vì sao db.SetMaxOpenConns(0) (giá trị mặc định) được bài viết gọi là "mặc định nguy hiểm", đặc biệt khi kết hợp với cách net/http xử lý request?

Đáp án

MaxOpenConnections = 0 nghĩa là không giới hạn số kết nối mở đồng thời — "cái tủ phát chìa vô hạn". Vì net/http tạo một goroutine cho mỗi request, một đợt mười nghìn request đồng thời sẽ khiến ứng dụng xin mười nghìn kết nối tới CSDL cùng lúc — trong khi PostgreSQL mặc định chỉ cho phép khoảng 100 kết nối cùng lúc. Kết quả là CSDL bị quá tải hoặc từ chối kết nối mới, ảnh hưởng tới mọi dịch vụ khác đang dùng chung CSDL đó. Cần luôn đặt SetMaxOpenConns ở một giá trị hợp lý (khoảng gấp đôi số nhân của máy chủ CSDL rồi đo lại).

Bài 4 (vận dụng thực tế). Bạn có một cột ghi_chu trong CSDL có thể là NULL, và muốn quét nó vào một struct Go rồi trả JSON, với NULL trở thành null trong JSON đầu ra. Viết khai báo trường struct theo đúng cách bài viết khuyên.

Đáp án
type Don struct {
	GhiChu *string `json:"ghi_chu"`
}

// khi quét:
var d Don
err := db.QueryRowContext(ctx, `select ghi_chu from don where id=$1`, id).Scan(&d.GhiChu)

Bài viết khuyên dùng con trỏ (*string) thay vì sql.NullString khi có thể, vì nó gọn hơn và ghép thẳng với JSON: *string là nil sẽ tự động thành null trong JSON đầu ra, không cần thêm bước chuyển đổi nào. sql.NullString chỉ hợp khi muốn tránh cấp phát con trỏ hoặc khi kiểu đích đã là struct có sẵn.

Bài 5 (bẫy/đánh đổi). Đoạn SQL sau tồn tại ở hai phiên bản driver khác nhau. Giải thích tại sao database/sql không trừu tượng hoá được sự khác biệt này, và nêu lại nguyên tắc quan trọng hơn mà bài viết nhấn mạnh khi truyền tham số vào câu SQL.

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
Đáp án

database/sql là tầng trừu tượng chung, nhưng ký hiệu tham số ($1 hay ?) là phần đặc thù của từng driver, không phải của tầng trừu tượng — đây là chỗ mà database/sql không che giấu được sự khác biệt: đổi CSDL (ví dụ từ PostgreSQL sang MySQL) buộc phải sửa lại mọi câu lệnh SQL trong mã nguồn.

Nguyên tắc quan trọng hơn mà bài viết nhấn mạnh: luôn dùng tham số, đừng nối chuỗi trực tiếp vào câu SQL, bất kể ký hiệu tham số là gì — nối chuỗi mở cửa cho SQL injection. Và một hạn chế bổ sung: tham số như $1/? không bao giờ thay được cho tên bảng hay tên cột; với sắp xếp hay lọc động theo tên cột, phải đối chiếu với một danh sách cho phép (allowlist) thay vì tham số hoá.