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ằng MaxOpenConns để 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.

Ảnh chụp đoạn mã Go nền tối minh hoạ tinh chỉnh connection pool của database/sql trong Go 3 nút quyết định, sql.DB không phải một kết nối nó là một pool sai lầm phổ biến tưởng sql.DB là 1 kết nối mở đóng liên tục thực ra sql.DB là pool kết nối an toàn cho nhiều goroutine tự mở tái dùng đóng kết nối mở 1 lần dùng suốt vòng đời app db bằng sql Open pgx dsn không mở kết nối ngay chỉ tạo pool, ba nút tinh chỉnh db SetMaxOpenConns n tối đa kết nối mở cùng lúc vượt thì goroutine chờ blocking tới khi có slot db SetMaxIdleConns n giữ bao nhiêu kết nối rảnh để tái dùng thấp thì đóng mở lại nhiều tốn handshake db SetConnMaxLifetime d tuổi thọ tối đa 1 kết nối rồi thay mới tránh kết nối cũ chết sau failover DB LB, đo bằng chính db Stats st bằng 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ờ chỉ báo pool nhỏ WaitCount WaitDuration cao bằng pool quá nhỏ so với tải đồng thời, tải thử 50 goroutine chạy 1000 truy vấn for i nhỏ hơn songSong 50 goroutine đồng thời go func for j nhỏ hơn moiGoroutine mỗi con 20 truy vấn row bằng db QueryRowContext ctx SELECT 1 row Scan mỗi truy vấn gia lập tốn 5ms

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=50 khớ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.

Ảnh chụp bảng kết quả đo thật nền tối tinh chỉnh connection pool database sql trong Go chạy bằng go run Go 1.23 arm64 50 goroutine nhân 20 truy vấn bằng 1000 mỗi truy vấn 5ms, MaxOpenConns ảnh hưởng thông lượng và thời gian chờ MaxOpenConns 1 thời gian 5.762s throughput 174 tv mỗi s WaitCount 997 WaitDuration 4m11s, MaxOpenConns 2 thời gian 2.869s throughput 349 tv mỗi s WaitCount 997 WaitDuration 2m01s, MaxOpenConns 5 thời gian 1.147s throughput 872 tv mỗi s WaitCount 993 WaitDuration 45.2s, MaxOpenConns 10 thời gian 590ms throughput 1696 tv mỗi s WaitCount 975 WaitDuration 20.3s, MaxOpenConns 25 thời gian 254ms throughput 3930 tv mỗi s WaitCount 946 WaitDuration 5.25s, MaxOpenConns 50 thời gian 118ms throughput 8497 tv mỗi s WaitCount 0 WaitDuration 0s, pool 1 mọi truy vấn nối đuôi 174 tv mỗi s chờ tổng 4 phút pool tăng gấp đôi throughput gần gấp đôi song song hơn pool 50 khớp 50 goroutine không ai phải chờ WaitCount 0 1000 truy vấn chia 118ms mỗi con 20 nhân 5ms bằng 100ms gần mức lý tưởng, cốt lõi sql.DB là pool không phải 1 kết nối mở 1 lần dùng cả đời app MaxOpen trần kết nối đồng thời vượt thì goroutine chờ slot MaxIdle số kết nối rảnh giữ lại để tái dùng thấp mở lại tốn MaxLifetime thay kết nối cũ định kỳ tránh kết nối chết sau failover db Stats WaitCount WaitDuration cao bằng pool quá nhỏ so với tải đánh đổi pool lớn không luôn tốt DB có trần kết nối riêng mỗi kết nối tốn RAM phía DB đặt vượt sức DB là tự bắn chân

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ề

  1. sql.DB là 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/Close mỗi request là vứt bỏ lợi ích pool và trả giá bắt tay kết nối mỗi lần.
  2. MaxOpenConns quyế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ùng db.Stats() với WaitCount/WaitDuration để biết pool có quá nhỏ không.
  3. 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.

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.