Một trong những hiểu lầm phổ biến nhất về Go: nghĩ sql.DB là một kết nối tới database, rồi mở-đóng nó cho mỗi request. Thực ra sql.DB là một pool kết nối — an toàn cho nhiều goroutine, tự mở, tái dùng, và đóng kết nối bên dưới. Bạn Open nó một lần lúc khởi động và dùng suốt vòng đời ứng dụng.
Và vì nó là pool, nó có các nút tinh chỉnh — ba nút cụ thể — quyết định trực tiếp thông lượng và độ trễ của toàn bộ tầng dữ liệu. Đặt sai: hoặc ứng dụng nghẽn cổ chai (goroutine xếp hàng chờ kết nối), hoặc nó mở quá nhiều kết nối và giết chết chính database. Bài này giải thích ba nút, và đo thật ảnh hưởng của chúng bằng chính công cụ Go cung cấp: db.Stats().
sql.DB là một pool, không phải một kết nối
db, _ := sql.Open("pgx", dsn) // không mở kết nối ngay, chỉ tạo pool
sql.Open thậm chí không mở kết nối nào — nó chỉ dựng cấu trúc pool. Kết nối thật được mở lười (lazy) khi truy vấn đầu tiên cần. Sau đó pool quản lý vòng đời: khi một goroutine cần chạy truy vấn, nó mượn một kết nối từ pool, dùng xong trả lại để goroutine khác tái dùng. Đây là lý do bạn không bao giờ nên Open/Close cho mỗi request — làm vậy là vứt bỏ toàn bộ lợi ích của pool và trả cái giá bắt tay kết nối mỗi lần.
Ba nút tinh chỉnh
db.SetMaxOpenConns(n) // TỐI ĐA kết nối mở cùng lúc
db.SetMaxIdleConns(n) // giữ bao nhiêu kết nối rảnh để TÁI DÙNG
db.SetConnMaxLifetime(d) // tuổi thọ tối đa 1 kết nối rồi thay mới
MaxOpenConns: trần số kết nối mở đồng thời. Đây là nút quan trọng nhất. Nếu tất cả kết nối đang bận và một goroutine cần thêm, nó chờ (blocking) cho tới khi có một kết nối được trả về. Đặt quá thấp → nghẽn; đặt quá cao → giết DB.MaxIdleConns: số kết nối rảnh giữ lại trong pool để tái dùng. Nếu bằng 0 hoặc quá thấp, pool đóng kết nối ngay sau khi dùng xong và phải mở lại (tốn handshake TCP + TLS + xác thực) cho lần sau. Thường đặt bằngMaxOpenConnsđể tái dùng tối đa.ConnMaxLifetime: tuổi thọ tối đa của một kết nối trước khi pool đóng và thay mới. Cần để tránh giữ kết nối cũ đã chết sau khi database failover, load balancer đổi backend, hoặc DB đóng kết nối phía nó.
Đo bằng chính db.Stats()
Go cho bạn một cửa sổ nhìn vào pool qua db.Stats():
st := db.Stats()
st.OpenConnections // đang mở tổng cộng
st.InUse // đang bận chạy truy vấn
st.Idle // rảnh, sẵn tái dùng
st.WaitCount // SỐ LẦN goroutine phải chờ 1 kết nối
st.WaitDuration // TỔNG thời gian đã chờ
Hai trường vàng để chẩn đoán là WaitCount và WaitDuration: chúng cho biết goroutine đã phải xếp hàng chờ kết nối bao nhiêu lần và tổng bao lâu. Nếu chúng cao, pool của bạn quá nhỏ so với tải đồng thời.

Hình 1: sql.DB là một pool kết nối, mở một lần dùng cả đời app; ba nút MaxOpenConns, MaxIdleConns, ConnMaxLifetime điều khiển pool; và db.Stats() phơi bày WaitCount/WaitDuration để chẩn đoán pool quá nhỏ.
Đo thật: throughput theo MaxOpenConns
Để đo sạch ảnh hưởng của pool (không lẫn nhiễu mạng thật), tôi dùng một driver database/sql giả lập, trong đó mỗi truy vấn "tốn" 5ms — mô phỏng trọn đi-về mạng cộng xử lý ở DB. Rồi cho 50 goroutine chạy đồng thời, mỗi con 20 truy vấn (tổng 1000 truy vấn), với các MaxOpenConns khác nhau:
MaxOpenConns thời gian throughput WaitCount WaitDuration
1 5.762s 174 tv/s 997 4m11s
2 2.869s 349 tv/s 997 2m01s
5 1.147s 872 tv/s 993 45.2s
10 590ms 1696 tv/s 975 20.3s
25 254ms 3930 tv/s 946 5.25s
50 118ms 8497 tv/s 0 0s
Câu chuyện rất rõ:
MaxOpenConns=1: mọi truy vấn nối đuôi qua một kết nối duy nhất — chỉ 174 truy vấn/giây, và tổng thời gian goroutine phải chờ là 4 phút 11 giây (997 lần chờ). Đây là cổ chai điển hình khi pool quá nhỏ.- Tăng gấp đôi pool → throughput gần gấp đôi: 1→2 cho 174→349, 2→5 cho 349→872. Càng nhiều kết nối, càng nhiều truy vấn chạy song song.
MaxOpenConns=50khớp đúng 50 goroutine →WaitCount=0: không goroutine nào phải chờ, throughput đạt 8497 tv/s. 1000 truy vấn xong trong 118ms; vì mỗi goroutine chạy 20 truy vấn × 5ms = 100ms tuần tự, 118ms là gần mức lý tưởng lý thuyết.
WaitCount và WaitDuration giảm dần rồi về 0 chính là tín hiệu định lượng cho biết pool đã đủ lớn cho mức đồng thời này.

Hình 2: Đo thật cùng 1000 truy vấn với MaxOpenConns tăng dần — throughput đi từ 174 tv/s (pool=1, tổng chờ 4 phút) lên 8497 tv/s (pool=50), và WaitCount/WaitDuration giảm về 0 khi pool khớp mức đồng thời 50 goroutine.
Đánh đổi cần cân nhắc
Pool lớn không phải luôn tốt hơn — đây là cái bẫy nguy hiểm nhất. Nhìn bảng trên, bạn có thể kết luận "cứ đặt MaxOpenConns thật cao". Sai. Database có trần kết nối riêng của nó (PostgreSQL mặc định max_connections=100), và mỗi kết nối tốn bộ nhớ đáng kể phía DB (PostgreSQL mỗi kết nối là một process). Nếu bạn có 10 bản sao ứng dụng, mỗi bản đặt MaxOpenConns=50, bạn yêu cầu 500 kết nối — vượt xa trần 100, và DB từ chối kết nối hoặc sụp vì cạn bộ nhớ. Tổng kết nối từ tất cả client phải nằm trong sức chịu của DB.
Điểm tối ưu phụ thuộc tải, không có con số vàng. Pool tốt nhất khớp với mức đồng thời thực và sức của DB, không phải một số cố định. Với DB nhanh (truy vấn micro-giây), pool nhỏ đã đủ vì kết nối được trả lại rất nhanh. Với DB chậm hoặc truy vấn nặng, cần pool lớn hơn — nhưng vẫn bị chặn bởi trần DB. Đo bằng db.Stats() dưới tải thật rồi chỉnh, đừng đoán.
Cân nhắc PgBouncer khi có nhiều bản sao ứng dụng. Khi số client nhân lên khiến tổng kết nối vượt sức DB, giải pháp là một connection pooler ở giữa (PgBouncer, pgpool) gộp kết nối từ nhiều app vào một số ít kết nối thật tới DB. Lúc đó MaxOpenConns phía Go trỏ vào pooler chứ không phải DB trực tiếp, và bài toán trần kết nối chuyển sang tầng pooler.
Ba ý mang về
sql.DBlà một pool, không phải một kết nối: mở một lần lúc khởi động, dùng cả đời app —Open/Closemỗi request là vứt bỏ lợi ích pool và trả giá bắt tay kết nối mỗi lần.MaxOpenConnsquyết định thông lượng dưới tải đồng thời: đo thật cùng 1000 truy vấn, throughput đi từ 174 tv/s (pool=1, chờ tổng 4 phút) lên 8497 tv/s (pool=50, WaitCount=0) — dùngdb.Stats()với WaitCount/WaitDuration để biết pool có quá nhỏ không.- Pool lớn không luôn tốt: database có trần kết nối riêng và mỗi kết nối tốn RAM phía DB — tổng kết nối từ mọi bản sao ứng dụng phải nằm trong sức DB, nếu không dùng PgBouncer; luôn đo dưới tải thật thay vì đoán một con số vàng.
Bài tập làm thử
Bài 1 (đọc hiểu). Giải thích vì sao đoạn mã sau bị coi là một sai lầm phổ biến, dựa trên bản chất của sql.DB:
func xuLy(dsn string) {
db, _ := sql.Open("pgx", dsn)
defer db.Close()
db.QueryRowContext(context.Background(), "SELECT 1")
}
nếu hàm xuLy này được gọi cho mỗi request đến.
Đáp án
sql.Db không phải một kết nối đơn mà là một pool kết nối, được thiết kế để mở một lần lúc khởi động và dùng suốt vòng đời ứng dụng. Gọi sql.Open/db.Close() cho mỗi request là vứt bỏ toàn bộ lợi ích của pool (tái dùng kết nối) và trả giá bắt tay kết nối (handshake TCP/TLS/xác thực) mỗi lần — đúng như bài nhấn mạnh: "bạn không bao giờ nên Open/Close cho mỗi request".
Bài 2 (sửa lỗi/chẩn đoán). Một dịch vụ báo cáo độ trễ cao dưới tải đồng thời. Bạn kiểm tra db.Stats() và thấy:
st := db.Stats()
fmt.Println(st.WaitCount, st.WaitDuration)
// in ra: 5000 12m30s
Đây là dấu hiệu của vấn đề gì, và hướng khắc phục đầu tiên nên thử là gì?
Đáp án
WaitCount và WaitDuration cao là tín hiệu định lượng cho thấy pool kết nối quá nhỏ so với tải đồng thời: nhiều goroutine phải xếp hàng chờ một kết nối được trả về, tổng cộng đã chờ 12 phút 30 giây. Hướng khắc phục đầu tiên: tăng MaxOpenConns (như bài đo, tăng pool từ 1 lên 50 kéo throughput từ 174 lên 8497 tv/s và WaitCount về 0) — nhưng phải kiểm tra trần kết nối của DB trước khi tăng.
Bài 3 (vận dụng thực tế). Bạn triển khai 10 bản sao của cùng một ứng dụng, mỗi bản đặt db.SetMaxOpenConns(50), kết nối tới một PostgreSQL có max_connections=100 mặc định. Điều gì sẽ xảy ra, và cách khắc phục nào được bài đề xuất?
Đáp án
Tổng kết nối yêu cầu tối đa là 10 × 50 = 500, vượt xa trần 100 của PostgreSQL — DB sẽ từ chối kết nối hoặc sụp vì cạn bộ nhớ (mỗi kết nối PostgreSQL là một process riêng, tốn RAM). Đây chính là cái bẫy "pool lớn không phải luôn tốt hơn" mà bài cảnh báo. Khắc phục: giảm MaxOpenConns mỗi bản sao sao cho tổng nằm trong sức chịu của DB, hoặc dùng một connection pooler ở giữa như PgBouncer để gộp kết nối từ nhiều app vào một số ít kết nối thật tới DB.
Bài 4 (bẫy/đánh đổi). Vì sao bài khuyên nên đặt MaxIdleConns gần bằng MaxOpenConns, và điều gì xảy ra nếu MaxIdleConns quá thấp?
Đáp án
Nếu MaxIdleConns quá thấp (hoặc bằng 0), pool sẽ đóng kết nối ngay sau khi dùng xong thay vì giữ lại để tái dùng, buộc phải mở lại kết nối mới cho lần sau — tốn chi phí bắt tay (handshake TCP + TLS + xác thực) lặp đi lặp lại. Đặt MaxIdleConns gần bằng MaxOpenConns giúp tối đa hóa việc tái dùng kết nối rảnh, tránh lãng phí này.
Bài 5 (đọc hiểu số liệu). Từ bảng đo: MaxOpenConns=5 cho throughput 872 tv/s, MaxOpenConns=10 cho 1696 tv/s. Có phải throughput luôn tăng đúng tỉ lệ tuyến tính khi tăng gấp đôi pool? Dựa vào toàn bộ bảng số liệu, hãy nhận xét xu hướng tăng khi pool tiến gần tới 50 (số goroutine đồng thời).
Đáp án
Gần đúng tuyến tính ở vùng pool nhỏ (872→1696 khi 5→10, gần gấp đôi), nhưng xu hướng chững lại khi pool tiến gần 50: từ 25 lên 50 (gấp đôi) throughput chỉ tăng từ 3930 lên 8497 (khoảng 2,16 lần — vẫn gần gấp đôi thực ra), nhưng quan trọng hơn là khi MaxOpenConns=50 khớp đúng số goroutine đồng thời (50), WaitCount về đúng 0 — nghĩa là đã đạt mức bão hòa: tăng pool thêm nữa (ví dụ lên 100) sẽ không giúp gì thêm vì không còn goroutine nào phải chờ, do tải đồng thời tối đa chỉ là 50 goroutine.
Phần sau ta xét một cách viết truy vấn SQL an toàn kiểu trong Go: sqlc sinh code Go từ SQL — bắt lỗi truy vấn lúc biên dịch thay vì lúc chạy, và so với ORM cùng viết tay.