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
	}
}

Ảnh chụp đoạn mã Go nền tối minh hoạ connection pool tự viết tái dùng tài nguyên tốn kém bằng buffered channel, pool bằng một buffered channel chứa kết nối rảnh type Pool struct conns chan con trỏ Conn kênh giữ các kết nối đang rảnh maxOpen int func NewPool max int con trỏ Pool return Pool conns make chan con trỏ Conn max maxOpen max buffered channel vừa là kho vừa lo đồng bộ không cần mutex, Get tái dùng hoặc chờ với timeout func p con trỏ Pool Get timeout time Duration con trỏ Conn error select case c bằng nhận p conns có kết nối rảnh tái dùng ngay return c nil case nhận time After timeout cạn chờ hết giờ thì lỗi return nil ErrHetHan, Put trả về pool bỏ nếu đầy func p con trỏ Pool Put c con trỏ Conn select case p conns gửi c trả về kênh cho lần sau dùng default pool đầy đóng kết nối giới hạn số mở, vì sao pool tạo kết nối DB TCP TLS tốn kém bắt tay xác thực ms pool giữ sẵn N kết nối tái dùng thay vì mở đóng liên tục giới hạn số kết nối đồng thời bảo vệ server phía sau Go database sql và http Transport đã có pool sẵn bên trong

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:

Ảnh chụp bảng kết quả đo thật nền tối pool tái dùng 3 kết nối cho 10 lần dùng timeout khi cạn nhanh hơn tạo mới cực lớn, go run cộng go test bench Go 1.23 arm64 10 core, chạy thật tái dùng không tạo thêm 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 timeout đúng 10 lần dùng chỉ với 3 kết nối Get lấy ra Put trả về xoay vòng, benchmark có pool tái dùng vs tạo mới mỗi lần có pool tái dùng chỉ Get Put 72,96 ns không pool tạo mới bắt tay 5ms 574.319 ns pool nhanh hơn hàng nghìn lần 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 tỉ lệ với chi phí tạo kết nối thật ở đây 5ms, cơ chế buffered channel vừa là kho kết nối rảnh vừa đồng bộ 0 mutex Get nhận từ channel tái dùng hoặc select timeout chờ có hạn Put gửi vào channel default nhánh bằng pool đầy thì đóng giới hạn bằng sức chứa channel chặn số kết nối mở đồng thời, cốt lõi pool buffered channel giữ kết nối rảnh tái dùng Get Put lấy ra trả về select cho timeout khi cạn tiết kiệm tránh bắt tay tốn kém mỗi lần dùng đo 73ns vs 574µs giới hạn sức chứa channel bằng trần kết nối bảo vệ server sau đánh đổi kết nối cũ chết cần health-check phải nhớ Put

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ề

  1. 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 — Get nhận từ channel, Put gửi lại, select cho timeout khi cạn.
  2. 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.
  3. 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ủa database/sql/http.Transport thay vì viết 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.