Ở bài về keep-alive ta thấy tái dùng một kết nối né được thuế bắt tay. Nhưng một client thật không chỉ giữ một kết nối — nó giữ cả một bể (pool) kết nối ấm tới máy chủ, và một câu hỏi thực tế nảy sinh: bể đó nên to bao nhiêu? Bài này đo trực tiếp ảnh hưởng của kích thước bể kết nối tới hiệu năng, và vấp đúng cái giới hạn khiến "to hơn" ngừng có nghĩa.
Bể kết nối là gì
Một bể kết nối giữ sẵn một số kết nối keep-alive tới máy chủ và tái dùng chúng cho các request thay vì mở mới mỗi lần. Kích thước bể — số kết nối tối đa mở cùng lúc — là một tham số quan trọng, vì với HTTP/1.1 mỗi kết nối chỉ làm một request tại một thời điểm. Nghĩa là kích thước bể chính là mức song song phía client: một bể N kết nối cho phép tối đa N request chạy đồng thời, còn các request vượt quá phải xếp hàng chờ một kết nối rảnh.
Tôi đo bằng cách bắn 30 request đồng thời qua một bể có kích thước thay đổi, tới một backend mất 40 mili giây xử lý mỗi request, trên đường có RTT ~55 ms.
Đo: kích thước bể là mức song song
Với một backend rộng rãi (xử lý được rất nhiều request đồng thời):
| Pool size | Thời gian cho 30 request |
|---|---|
| 1 | 2581 ms |
| 2 | 1307 ms |
| 4 | 707 ms |
| 8 | 380 ms |
| 16 | 227 ms |
| 30 | 146 ms |
Con số cực rõ: mỗi lần tăng gấp đôi kích thước bể, thời gian giảm gần một nửa. Bể 1 kết nối buộc cả 30 request nối đuôi nhau — 30 × (40 ms + RTT) ≈ 2581 ms, hoàn toàn tuần tự dù kết nối được tái dùng. Bể 30 kết nối cho cả 30 request chạy song song, xong trong 146 ms — nhanh gấp 18 lần. Đây là điểm khác biệt then chốt với keep-alive đơn thuần: tái dùng một kết nối né được bắt tay nhưng vẫn tuần tự; một bể kết nối mới mở khóa song song. Kích thước bể là ngân sách song song của bạn.
Một lần tôi đo hớ: to hơn không phải lúc nào cũng nhanh hơn
Nhìn bảng trên — cứ gấp đôi bể là giảm nửa thời gian — tôi định chốt một quy tắc gọn: "bể càng to càng nhanh, cứ tăng lên hết cỡ". Nhưng bảng đó đo với một backend rộng rãi, xử lý bao nhiêu request đồng thời cũng được. Backend thật thì có giới hạn.
Tôi đo lại với một backend chỉ xử lý được 4 request đồng thời (mô phỏng một dịch vụ có số worker giới hạn, hay một cơ sở dữ liệu có số kết nối tối đa):
| Pool size | Backend rộng | Backend hẹp (4 đồng thời) |
|---|---|---|
| 1 | 2581 ms | 2583 ms |
| 4 | 707 ms | 687 ms |
| 8 | 380 ms | 436 ms |
| 16 | 227 ms | 440 ms |
| 30 | 146 ms | 436 ms |
Cột bên phải kể một câu chuyện khác. Tăng bể từ 1 lên 8 vẫn giúp (2583 xuống 436 ms), nhưng từ 8 lên 16 rồi 30 thì chững lại hẳn ở ~436 ms — thêm kết nối không giúp gì nữa. Lý do: backend chỉ nuốt được 4 request cùng lúc, nên khi bể đã đủ lớn để luôn có 4 request đang chạy, các kết nối dư chỉ ngồi xếp hàng chờ ở backend chứ không làm được gì thêm. Một bể 30 kết nối đấu với một backend 4-đồng-thời thực chất là 30 kết nối làm công việc của khoảng 8 — 22 kết nối thừa chỉ tốn tài nguyên (tải backend, cổng tạm như bài cổng và socket, bộ nhớ) mà không nhanh thêm một mili giây.
Bài học đo lường: lợi ích của một tài nguyên phải được đo tới khi nó chững lại, mới thấy điểm dừng. Nếu tôi chỉ đo trên backend rộng rãi và thấy đường cong giảm đều, tôi đã kết luận sai rằng "cứ tăng bể là tốt". Chỉ khi đo với backend có giới hạn thật mới lộ ra rằng kích thước bể tối ưu bị chặn bởi sức của backend, không phải bởi "to nhất có thể". Đo một xu hướng trong khoảng hẹp rồi ngoại suy vô hạn là cách chắc chắn để đi quá điểm dừng mà không biết.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là chỉnh kích thước bể theo sức của backend, không phải theo trực giác "càng nhiều càng tốt". Bể quá nhỏ thì request xếp hàng chờ kết nối ngay ở phía client — một nút thắt tự tạo, dễ bị bỏ sót vì backend trông vẫn rảnh. Bể quá to thì mở quá nhiều kết nối tới backend, có thể làm backend quá tải (mỗi kết nối là một chút tài nguyên ở phía nó), đốt cổng tạm, và không nhanh hơn chút nào. Điểm ngọt nằm quanh mức song song mà backend thật sự phục vụ được.
Hệ quả thứ hai là cùng một quy luật đúng cho bể kết nối cơ sở dữ liệu, nơi nó còn quan trọng hơn. Một cơ sở dữ liệu thường có giới hạn số kết nối (ví dụ PostgreSQL mặc định 100), và mỗi kết nối tốn bộ nhớ đáng kể ở phía nó. Đặt bể kết nối ứng dụng lớn hơn số worker mà DB xử lý hiệu quả không những vô ích mà còn có hại — các truy vấn dư chỉ tranh nhau tài nguyên DB. Kích thước bể DB tối ưu thường nhỏ đến bất ngờ, đúng bằng số truy vấn DB có thể chạy song song mà không giẫm chân nhau.
Hệ quả thứ ba là một cách nhìn tổng quát: mức song song cần thiết ≈ thông lượng × độ trễ (định luật Little). Nếu backend xử lý mỗi request trong 40 ms và bạn muốn 100 request/giây, bạn chỉ cần khoảng 100 × 0,04 = 4 kết nối song song — thêm nữa là thừa. Con số mang theo: kích thước bể kết nối là mức song song của client; tăng nó giảm thời gian tuyến tính cho tới khi đụng sức của backend, rồi chững hẳn — nên chỉnh bể bằng sức backend, không phải bằng con số to nhất bạn dám đặt. Một bể đúng cỡ vừa nhanh vừa tiết kiệm; một bể quá cỡ chỉ tốn tài nguyên mà không mua thêm tốc độ.
Hai chi tiết dễ quên: pool theo host, và chờ pool
Hai điều thực tế đáng nhớ khi dùng bể kết nối. Thứ nhất, bể thường tính theo từng host: giới hạn max_connections_per_host áp riêng cho mỗi máy chủ, và một client gọi nhiều dịch vụ khác nhau giữ một bể riêng cho mỗi host. Nhiều thư viện có cả hai giới hạn — tổng số kết nối và số mỗi host — và cái nhỏ hơn thắng; đặt tổng lớn mà quên nới per-host thì vẫn nghẽn ở mức per-host.
Thứ hai, khi bể đã đầy và một request phải chờ kết nối rảnh, cái chờ đó cũng có thời gian chờ (pool timeout) riêng, tách biệt với timeout kết nối và timeout đọc. Một sự cố khó lần: dưới tải cao, request thất bại không phải vì backend chậm mà vì chúng chờ quá lâu để lấy được một kết nối từ bể rồi bị hủy — lỗi trông giống timeout mạng nhưng gốc rễ là bể quá nhỏ so với tải. Khi thấy timeout tăng vọt lúc cao điểm, hãy kiểm cả thời gian chờ-lấy-kết-nối, không chỉ thời gian gọi backend.
Thử ba mươi giây
Trong thư viện HTTP client bạn dùng, tìm tham số kích thước bể (thường tên max_connections, pool_maxsize, maxSockets, hay tương tự) và xem mặc định là bao nhiêu — nhiều thư viện mặc định khá nhỏ (10 hay thậm chí 2 mỗi host), có thể là nút thắt bí mật nếu bạn gọi song song nhiều. Thử một tải song song với bể mặc định rồi với bể lớn hơn, đo tổng thời gian: nếu tăng bể làm nhanh lên, bạn đang bị giới hạn ở client; nếu không đổi, backend (hoặc mạng) mới là nút thắt — và đó chính là điểm dừng mà bài này đo.