Tạo một kết nối — tới database, một service qua TLS, một broker — là thao tác tốn kém: bắt tay TCP, thương lượng TLS, xác thực, tất cả mất vài mili giây. Nếu mỗi request mở một kết nối mới rồi đóng, chi phí đó nhân lên khủng khiếp. Connection pool giải bài này: giữ sẵn một số kết nối và tái dùng chúng. Trong Go, mẫu này đặc biệt đẹp vì buffered channel vừa làm kho chứa vừa lo đồng bộ mà không cần mutex. Bài này dựng một pool từ đầu chạy được, đo thật lợi ích, và chỉ ra các cạm bẫy.
Pool là một buffered channel
Ý tưởng cốt lõi: một buffered channel chứa các kết nối đang rảnh. Lấy kết nối là nhận từ channel; trả về là gửi vào channel. Channel tự lo phần đồng bộ giữa các goroutine:
type Pool struct {
conns chan *Conn // kênh giữ các kết nối rảnh
maxOpen int
}
func NewPool(max int) *Pool {
return &Pool{conns: make(chan *Conn, max), maxOpen: max}
}
Sức chứa của channel (max) chính là giới hạn số kết nối — không thể có nhiều hơn max kết nối trong pool. Đây cũng là cách bảo vệ server phía sau khỏi bị quá tải kết nối.
Get và Put: lấy ra, trả về, chờ có hạn
Get lấy một kết nối rảnh từ channel; nếu cạn, nó chờ nhưng có timeout để không kẹt vô hạn:
func (p *Pool) Get(timeout time.Duration) (*Conn, error) {
select {
case c := <-p.conns: // có kết nối rảnh -> tái dùng ngay
return c, nil
case <-time.After(timeout): // cạn -> chờ, hết giờ thì báo lỗi
return nil, ErrHetHan
}
}
Put trả kết nối về pool; nếu pool đầy (hiếm, khi trả nhiều hơn lấy), nhánh default bỏ kết nối thừa:
func (p *Pool) Put(c *Conn) {
select {
case p.conns <- c: // trả về kênh cho lần sau dùng
default: // pool đầy -> đóng kết nối
}
}

Hình 1: Pool là một buffered channel chứa kết nối rảnh (channel vừa là kho vừa đồng bộ, không cần mutex). Get nhận từ channel hoặc select timeout khi cạn; Put gửi về channel, nhánh default bỏ nếu đầy — sức chứa channel là trần kết nối.
Đo thật: tái dùng và tốc độ
Chạy thật (Go 1.23) với kết nối giả lập tốn 5ms để tạo:
Nạp sẵn 3 kết nối, soLanTao = 3
Sau 10 lần Get/Put, soLanTao = 3 (không tạo thêm)
Get khi pool cạn: hết hạn chờ kết nối
10 lần dùng chỉ cần 3 kết nối — pool xoay vòng chúng, không tạo thêm cái nào (soLanTao giữ nguyên 3). Khi pool cạn và không ai trả về, Get chờ rồi trả lỗi timeout đúng như thiết kế. Benchmark tái dùng qua pool so với tạo mới mỗi lần:

Hình 2: Pool tái dùng 3 kết nối cho 10 lần dùng (soLanTao giữ 3), timeout đúng khi cạn. Benchmark: có pool (chỉ Get/Put) 72,96 ns so với tạo mới mỗi lần (bắt tay 5ms) 574.319 ns — pool nhanh hơn hàng nghìn lần.
- có pool (tái dùng): 72,96 ns/op.
- không pool (tạo mới, bắt tay 5ms): 574.319 ns/op.
Pool nhanh hơn hàng nghìn lần vì Get/Put chỉ là thao tác channel (~73 ns), còn tạo mới trả cái giá bắt tay 5ms mỗi lần. Khoảng cách này tỉ lệ thuận với chi phí tạo kết nối thật — kết nối càng đắt (TLS, xa về địa lý), pool càng đáng giá.
Đánh đổi cần cân nhắc
Kết nối cũ có thể "chết" — cần kiểm tra sức khỏe. Một kết nối nằm trong pool lâu có thể bị server phía sau đóng, firewall ngắt, hay timeout. Nếu Get trả về một kết nối đã chết, request thất bại. Pool sản xuất cần: kiểm tra sức khỏe khi lấy ra (ping nhẹ), hoặc đặt max lifetime (đóng kết nối quá tuổi), hoặc thử lại với kết nối khác khi gặp lỗi. Pool đơn giản trong bài chưa có phần này.
Phải nhớ Put — quên là rò rỉ. Nếu code lấy kết nối bằng Get nhưng quên Put (do lỗi, panic, hay đường thoát sớm), kết nối đó biến mất khỏi pool vĩnh viễn — pool cạn dần rồi treo ở timeout. Luôn defer pool.Put(conn) ngay sau Get, và cân nhắc cơ chế bọc (một Release() an toàn) để khó quên. Đây là lỗi phổ biến nhất với pool tự viết.
Đừng viết lại pool nếu Go đã có. database/sql có connection pool đầy đủ bên trong (chỉnh bằng SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime), và http.Transport pool kết nối HTTP tự động. Với các trường hợp đó, dùng cái có sẵn — chúng đã xử lý health check, lifetime, và đồng thời kỹ càng. Viết pool tự chỉ khi bạn quản lý một loại tài nguyên tùy biến (kết nối tới hệ thống riêng, object đắt tiền) mà thư viện chuẩn không lo.
Ba ý mang về
- Connection pool dựng gọn bằng buffered channel trong Go: channel vừa là kho chứa kết nối rảnh vừa lo đồng bộ giữa goroutine (không cần mutex), và sức chứa channel chính là giới hạn số kết nối —
Getnhận từ channel,Putgửi lại,selectcho timeout khi cạn. - Pool tiết kiệm cực lớn khi kết nối tốn kém tạo: đo thật tái dùng 3 kết nối cho 10 lần dùng (không tạo thêm), và benchmark pool 72,96 ns so với tạo mới mỗi lần 574.319 ns (bắt tay 5ms) — khoảng cách tỉ lệ với chi phí tạo kết nối thật.
- Cạm bẫy: kết nối chết và quên Put: pool sản xuất cần health-check hoặc max lifetime cho kết nối cũ, và luôn
defer Putđể tránh rò rỉ — nhưng với DB và HTTP, dùng pool có sẵn củadatabase/sql/http.Transportthay vì viết lại.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho code:
func (p *Pool) Get(timeout time.Duration) (*Conn, error) {
select {
case c := <-p.conns:
return c, nil
case <-time.After(timeout):
return nil, ErrHetHan
}
}
Nếu p.conns đang rỗng (không có kết nối rảnh) và không ai Put trong vòng timeout, điều gì xảy ra? Vì sao dùng select với hai case thay vì chỉ c := <-p.conns?
Đáp án
Nếu p.conns rỗng và không có ai Put trong thời gian timeout, case <-time.After(timeout) sẽ được chọn trước và Get trả về nil, ErrHetHan. Dùng select với hai case (nhận từ channel HOẶC timeout) thay vì chỉ <-p.conns để tránh việc gọi Get bị chờ vô hạn khi pool cạn kiệt — timeout đảm bảo hàm luôn trả về trong một khoảng thời gian giới hạn, dù thành công hay thất bại.
Bài 2 (sửa lỗi). Đoạn mã sử dụng pool sau có nguy cơ rò rỉ kết nối nếu xuLy(conn) panic:
conn, err := pool.Get(2 * time.Second)
if err != nil { return err }
xuLy(conn)
pool.Put(conn)
Sửa lại để tránh rò rỉ.
Đáp án
conn, err := pool.Get(2 * time.Second)
if err != nil { return err }
defer pool.Put(conn) // Put luôn chạy dù xuLy panic hay return sớm
xuLy(conn)
Nếu xuLy(conn) panic hoặc có đường thoát sớm (return giữa chừng), pool.Put(conn) đặt cuối hàm gốc sẽ không bao giờ chạy, khiến kết nối đó biến mất khỏi pool vĩnh viễn. Dùng defer pool.Put(conn) ngay sau khi Get thành công đảm bảo Put luôn được gọi bất kể điều gì xảy ra sau đó — đúng lời khuyên "luôn defer pool.Put(conn) ngay sau Get" trong bài viết.
Bài 3 (vận dụng). Viết hàm Put cho Pool sao cho nếu channel conns đã đầy (trường hợp hiếm khi trả về nhiều hơn số đã lấy), kết nối thừa được đóng thay vì làm Put bị block.
Đáp án
func (p *Pool) Put(c *Conn) {
select {
case p.conns <- c: // còn chỗ -> trả về kênh cho lần sau
default:
c.Close() // pool đầy -> đóng kết nối thừa, không block
}
}
select với nhánh default khiến Put không bao giờ block: nếu channel còn chỗ, gửi kết nối vào; nếu đầy, rơi ngay vào default để đóng kết nối thừa. Đây chính là cơ chế bài viết mô tả: "nếu pool đầy (hiếm, khi trả nhiều hơn lấy), nhánh default bỏ kết nối thừa".
Bài 4 (bẫy/đánh đổi). Đo thật cho thấy pool tái dùng (Get/Put, 72,96 ns) nhanh hơn tạo mới mỗi lần (574.319 ns, giả lập bắt tay 5ms) hàng nghìn lần. Nếu chi phí tạo kết nối thật chỉ là 0,01 ms (rất rẻ, ví dụ kết nối trong cùng máy, không TLS) thay vì 5ms, khoảng cách hiệu năng giữa hai cách có còn lớn như vậy không? Giải thích theo nguyên tắc bài viết nêu.
Đáp án
Khoảng cách sẽ thu hẹp đáng kể, dù pool vẫn nhanh hơn. Bài viết nói rõ: "khoảng cách này tỉ lệ thuận với chi phí tạo kết nối thật — kết nối càng đắt (TLS, xa về địa lý), pool càng đáng giá". Với chi phí tạo kết nối rất rẻ (0,01 ms so với 5ms), phần tiết kiệm được từ việc tái dùng giảm tương ứng — pool vẫn có lợi (vì Get/Put chỉ ~73 ns), nhưng lợi ích tương đối không còn ở mức "hàng nghìn lần" như khi chi phí tạo kết nối là 5ms. Điều này gợi ý: pool đáng viết nhất khi tài nguyên quản lý thực sự tốn kém để tạo (TCP/TLS qua mạng, xác thực), không phải mọi loại tài nguyên.
Bài 5 (đọc hiểu/đánh đổi). Bài viết cảnh báo "kết nối cũ có thể chết — cần kiểm tra sức khỏe" và pool trong bài "chưa có phần này". Mô tả một kịch bản cụ thể khi pool đơn giản (không health-check) sẽ khiến request thất bại, và hai giải pháp bài viết gợi ý.
Đáp án
Kịch bản: một kết nối nằm rảnh trong pool một thời gian dài, trong lúc đó server phía sau đóng nó (do timeout không hoạt động phía server) hoặc một firewall/NAT ngắt kết nối. Khi Get trả kết nối này ra và code cố dùng nó (gửi request), thao tác sẽ thất bại vì kết nối đã chết — dù pool "tưởng" nó vẫn còn rảnh và hợp lệ. Hai giải pháp bài viết gợi ý: (1) kiểm tra sức khỏe khi lấy ra khỏi pool (ping nhẹ trước khi trả về cho người gọi), hoặc (2) đặt max lifetime — tự động đóng và loại bỏ kết nối đã tồn tại quá lâu khỏi pool, hoặc thử lại với một kết nối khác khi gặp lỗi.
Phần sau ta xét một mẫu bảo vệ hệ thống khỏi lỗi lan truyền khi service phía sau hỏng: Phần sau dựng một circuit breaker trong Go — tự ngắt lời gọi tới service đang lỗi để tránh dồn tải, và tự thử lại khi nó hồi phục.