Connection pooling & keep-alive — vì sao app cần pool
Mỗi kết nối mới tốn 1 RTT TCP + 1 RTT TLS trước byte đầu tiên. HTTP keep-alive tái dùng kết nối; connection pool giữ sẵn rổ kết nối ESTABLISHED. Tune pool size sai — quá nhỏ thì request xếp hàng, quá lớn thì server quá tải.
TL;DR: Ở bài 01 bạn đã thấy TCP handshake tốn 1 RTT trước byte HTTP đầu tiên — cộng thêm 1 RTT nữa cho TLS nếu là HTTPS. Nếu app mở kết nối mới cho mỗi request, mỗi request gánh 1-2 RTT thuần bắt tay trước khi server bắt đầu xử lý. HTTP keep-alive (Connection: keep-alive) giải quyết ở tầng HTTP/1.1: tái dùng một kết nối TCP cho nhiều request tuần tự, trả chi phí handshake một lần. Connection pool đi xa hơn: duy trì sẵn một rổ kết nối ESTABLISHED để mượn ngay, không phải chờ bắt tay. Tune pool size quan trọng — quá nhỏ thì request xếp hàng chờ kết nối; quá lớn thì server bị quá tải và tốn tài nguyên vô ích. Pool cũng giảm trực tiếp lượng TIME_WAIT tích luỹ vì ít kết nối được mở/đóng hơn.
1. Analogy — Xe taxi vs đặt xe chung
Hình dung bạn cần đi từ văn phòng tới kho hàng nhiều lần trong ngày:
- Gọi taxi mới mỗi lần (kết nối mới mỗi request): mỗi chuyến phải chờ taxi đến, trình bày điểm đến, trả tiền và ra xe — chi phí cố định cho mỗi chuyến dù chuyến chỉ 5 phút.
- Giữ taxi chờ giữa các chuyến (keep-alive): trả taxi chờ, đi lại không cần gọi mới — nhưng chỉ một taxi, các chuyến vẫn phải nối tiếp nhau.
- Đội xe riêng thường trực (connection pool): đội 10 xe sẵn sàng trong bãi, cần đi thì lấy ngay, về trả lại — nhiều người đi song song được.
| Taxi | TCP |
|---|---|
| Chờ taxi đến | TCP handshake (1 RTT) |
| Trình bày điểm đến | TLS handshake (1 RTT) |
| Taxi chờ giữa các chuyến | HTTP keep-alive — giữ kết nối mở |
| Đội xe thường trực | Connection pool — rổ kết nối ESTABLISHED |
| Xe nhàn rỗi lâu bị cho nghỉ | Idle timeout — đóng kết nối không dùng |
Keep-alive = 1 kết nối, nhiều request tuần tự, trả handshake 1 lần. Connection pool = nhiều kết nối giữ sẵn, nhiều request song song, mỗi kết nối trả handshake 1 lần. Pool = keep-alive mở rộng cho concurrent requests.
2. Chi phí thực sự của mỗi kết nối mới
Trước khi byte HTTP đầu tiên rời máy, một kết nối HTTPS mới phải đi qua:
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: Ket noi moi — 2 RTT truoc byte HTTP dau tien
C->>S: SYN
S-->>C: SYN-ACK
C->>S: ACK
Note over C,S: TCP ESTABLISHED (1 RTT)
C->>S: ClientHello (TLS)
S-->>C: ServerHello + Certificate
C->>S: Finished
Note over C,S: TLS ESTABLISHED (1 RTT)
C->>S: GET /api/users HTTP/1.1
S-->>C: 200 OK + data
Note over C,S: Request thuc su (1 RTT nua)Với RTT 50ms (nội địa), mỗi kết nối HTTPS mới tốn 150ms trước khi nhận được byte data đầu tiên — 2 RTT bắt tay + 1 RTT request. Với 100 API calls/giây, bạn lãng phí 10 000ms = 10 giây thuần bắt tay mỗi giây.
3. HTTP keep-alive — tái dùng một kết nối
HTTP keep-alive (còn gọi là persistent connection) là cơ chế HTTP/1.1 giữ kết nối TCP mở sau khi response xong, để request tiếp theo dùng lại mà không cần handshake mới.
HTTP/1.1 bật keep-alive mặc định (ngược với HTTP/1.0 phải thêm Connection: keep-alive header). Để tắt, client hoặc server gửi Connection: close.
# Request 1
GET /api/users HTTP/1.1
Host: api.example.com
Connection: keep-alive <- giu ket noi mo
# Server tra loi — ket noi van mo
HTTP/1.1 200 OK
Content-Length: 1234
Keep-Alive: timeout=30, max=100 <- giu toi da 30s, 100 request
# Request 2 — tren cung ket noi TCP, khong can bat tay lai
GET /api/orders HTTP/1.1
Host: api.example.com
Server có thể chỉ định giới hạn: timeout=30 (đóng kết nối sau 30s idle) và max=100 (tối đa 100 request trên kết nối này).
Hạn chế của keep-alive HTTP/1.1: request vẫn phải tuần tự (request 2 phải chờ response 1 xong mới gửi — trừ khi dùng HTTP pipelining ít được hỗ trợ). HTTP/2 giải quyết điều này bằng multiplexing — nhiều request song song trên 1 kết nối.
4. Connection pool — rổ kết nối ESTABLISHED
Connection pool duy trì sẵn một tập kết nối đã ESTABLISHED, sẵn sàng cho bất kỳ thread nào trong app mượn và trả lại.
flowchart LR
subgraph Pool ["Connection Pool (size=10)"]
direction TB
C1["Conn 1 — IDLE"]
C2["Conn 2 — BUSY"]
C3["Conn 3 — IDLE"]
C4["... Conn 10"]
end
T1["Thread A"] -->|"borrow"| C2
C2 -->|"return sau request"| Pool
T2["Thread B"] -->|"borrow"| C1
C1 -->|"return"| Pool
Pool -->|"idle > timeout: dong"| X["CLOSED"]
Pool -->|"size < min: mo moi"| NEW["New Conn"]Vòng đời một kết nối trong pool:
- Init: pool tạo
minPoolSizekết nối khi khởi động. - Mượn (borrow): thread request kết nối → pool giao kết nối idle.
- Sử dụng: thread thực hiện request trên kết nối.
- Trả (return): thread trả kết nối về pool (không
close()). - Idle timeout: nếu kết nối ngồi idle quá
idleTimeout, pool đóng nó để không lãng phí tài nguyên server. - Validation: pool thỉnh thoảng ping kết nối idle để kiểm tra còn sống (server có thể đã đóng sau idle timeout của phía nó).
5. Tune pool size — không phải càng lớn càng tốt
Đây là phần quan trọng nhất mà nhiều dev bỏ qua.
Pool quá nhỏ:
App có 200 concurrent threads
Pool size = 5
→ 195 threads xếp hàng chờ kết nối
→ Request timeout vì chờ quá lâu
→ Symptom: "server chậm" — nhưng server thực ra đang nhàn!
Pool quá lớn:
Pool size = 500 kết nối tới DB
DB PostgreSQL max_connections = 100
→ 500 kết nối vượt quá max → DB từ chối kết nối
→ Hoặc: 500 kết nối ESTABLISHED ngốn RAM server
→ Symptom: DB OOM hoặc connection refused
Công thức sơ bộ (HikariCP wiki — "About Pool Sizing"):
pool_size = Tp * (Tc / Tr)
Trong đó:
Tp= số thread tối đa của pool thread appTc= thời gian 1 request (bao gồm network round-trip)Tr= thời gian DB thực sự xử lý (CPU time)
Với DB: Tp = 10 threads, Tc = 100ms (bao gồm 80ms network), Tr = 20ms:
pool_size = 10 * (100 / 20) = 50
Công thức trên là điểm khởi đầu, không phải chân lý. Đo thực tế với load test là cách duy nhất tin cậy. HikariCP khuyến nghị bắt đầu từ connections = (core_count * 2) + effective_spindle_count cho DB pool — thường rất nhỏ, khoảng 10-20.
6. HikariCP — JDBC pool thực tế
HikariCP là JDBC connection pool phổ biến nhất cho Java (mặc định trong Spring Boot). Cấu hình cơ bản:
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost/mydb");
config.setUsername("app");
config.setPassword("secret");
// Pool sizing
config.setMaximumPoolSize(10); // toi da 10 ket noi
config.setMinimumIdle(5); // giu san 5 ket noi idle
config.setIdleTimeout(600_000); // dong idle sau 10 phut (ms)
config.setConnectionTimeout(30_000); // timeout cho ngu nhan ket noi tu pool (ms)
config.setMaxLifetime(1_800_000); // toi da 30 phut/ket noi (tranh firewall reset)
HikariDataSource ds = new HikariDataSource(config);
Trong application.properties (Spring Boot):
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.max-lifetime=1800000
maxLifetime quan trọng: nhiều firewall và cloud load balancer reset TCP connection sau 30-60 phút idle. Nếu không set maxLifetime, pool có thể giữ connection đã chết, dẫn tới lỗi khi mượn.
7. HTTP client pool — OkHttp / Apache HttpClient
Pool không chỉ cho DB — HTTP client cũng cần pool khi gọi external service.
OkHttp (phổ biến trong Java/Android):
OkHttpClient client = new OkHttpClient.Builder()
.connectionPool(new ConnectionPool(
10, // max 10 ket noi idle
5, // giu toi da 5 phut
TimeUnit.MINUTES
))
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
// Dung chung 1 instance toan app — KHONG tao moi moi request
Lỗi phổ biến: tạo OkHttpClient mới mỗi request — mỗi instance có pool riêng, không tái dùng được kết nối.
8. Pool cạn — triệu chứng và chẩn đoán
Pool cạn (exhausted) là khi tất cả kết nối trong pool đang bận và không có kết nối mới nào có thể tạo (đã đạt maxPoolSize). Các thread xếp hàng chờ trong connectionTimeout ms rồi ném exception.
Triệu chứng trong log:
HikariPool-1 - Connection is not available, request timed out after 30000ms
Hoặc với custom DB pool:
Unable to acquire JDBC Connection; nested exception:
com.zaxxer.hikari.pool.HikariPool$PoolInitializationException
Chẩn đoán — HikariCP metrics:
// Trong code — HikariPoolMXBean
HikariPoolMXBean poolMXBean = hikariDataSource.getHikariPoolMXBean();
int active = poolMXBean.getActiveConnections(); // dang su dung
int idle = poolMXBean.getIdleConnections(); // san sang
int waiting = poolMXBean.getThreadsAwaitingConnection(); // dang cho
int total = poolMXBean.getTotalConnections(); // tong
Nếu waiting > 0 thường xuyên → pool size quá nhỏ hoặc có connection leak (kết nối mượn nhưng không trả về pool — xem Pitfall bên dưới).
9. Pool giảm TIME_WAIT — liên hệ bài 04
Mỗi kết nối TCP đóng để lại TIME_WAIT ~60 giây (xem bài 04 — TIME_WAIT & hết port). Pool giảm TIME_WAIT bằng cách giảm số lần mở/đóng kết nối:
- Không pool: 1 000 request/giây × 1 kết nối mới = 1 000 TIME_WAIT tạo/giây → sau 60s có 60 000 TIME_WAIT.
- Pool size 20: tối đa 20 kết nối tồn tại đồng thời → gần như 0 TIME_WAIT mới (kết nối được tái dùng, không đóng giữa request).
10. Pitfall — hiểu nhầm và lỗi thường gặp
❌ Nhầm 1: "Pool càng lớn thì càng tốt — thêm tài nguyên cho chắc."
✅ Pool quá lớn gây hại: (1) DB/server backend có giới hạn max_connections; (2) mỗi kết nối idle vẫn tốn RAM ở server; (3) nhiều kết nối đồng thời gây context-switch và lock contention ở DB. HikariCP và các benchmark thực tế thường cho thấy pool nhỏ hơn (10-20) cho throughput cao hơn pool lớn (100+) với cùng số CPU core.
❌ Nhầm 2: "Request timeout là vì server chậm."
✅ Nếu timeout xảy ra ngay từ đầu request (không phải cuối), kiểm tra waiting connections trong pool metrics. Pool cạn gây request treo trước khi đến server — server hoàn toàn bình thường trong khi client đang chờ kết nối từ pool.
❌ Nhầm 3: "Connection leak là hiếm gặp, chỉ xảy ra với code tệ."
✅ Leak xảy ra khi kết nối mượn từ pool không được trả về — thường do exception không được bắt đúng cách, hoặc close() không nằm trong finally / try-with-resources. Hệ quả: pool dần cạn dù server backend ổn, và CLOSE_WAIT tích luỹ vì socket bị giữ mở. Luôn dùng try-with-resources với Connection:
// DUNG — try-with-resources dam bao close() duoc goi
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT ...")) {
ResultSet rs = ps.executeQuery();
// ...
} // conn.close() goi tu dong — tra ve pool
11. Liên hệ các bài khác
- Bài 01 — TCP 3-way handshake: handshake tốn 1 RTT — đây là chi phí mà keep-alive và pool tiết kiệm. Hiểu rõ handshake giúp bạn định lượng được lợi ích của pool.
- Bài 04 — TIME_WAIT & hết port: pool giảm số kết nối mở/đóng → giảm TIME_WAIT tích luỹ → giảm nguy cơ cạn ephemeral port trên proxy tải cao.
- Module 2 bài 04 — HTTP/2 multiplexing: HTTP/2 đẩy xa hơn keep-alive — một kết nối TCP cho nhiều request song song (multiplexing), giảm nhu cầu pool lớn cho HTTP calls.
12. 📚 Deep Dive — tài liệu gốc
Đọc khi muốn đi tới gốc:
- RFC 9112 — HTTP/1.1 — mục 9.3 "Persistence" định nghĩa keep-alive trong HTTP/1.1; mục 9.6 giải thích cách xác định khi nào một message kết thúc để tái dùng kết nối an toàn.
- HikariCP — About Pool Sizing — phân tích thực nghiệm tại sao pool nhỏ thường tốt hơn pool lớn, kèm công thức và ví dụ PostgreSQL.
- OkHttp — Connections — tài liệu về ConnectionPool và cách OkHttp tái dùng HTTP/1.1 và HTTP/2 connections.
13. Tóm tắt
- Mỗi kết nối TCP mới tốn 1 RTT bắt tay; HTTPS thêm 1 RTT TLS — tổng 2 RTT trước byte HTTP đầu tiên.
- HTTP keep-alive (mặc định trong HTTP/1.1) giữ kết nối mở sau response, tái dùng cho request tiếp theo — trả chi phí handshake một lần, request tuần tự.
- Connection pool duy trì rổ kết nối
ESTABLISHEDđể mượn ngay — nhiều thread dùng song song, không cần bắt tay mỗi request. - Pool quá nhỏ → request xếp hàng chờ kết nối → timeout; pool quá lớn → server backend quá tải + tốn RAM vô ích.
- Điểm khởi đầu tune:
connections = (core_count * 2) + effective_spindle_count(HikariCP), đo thực tế bằng load test. - Pool giảm TIME_WAIT vì ít kết nối mở/đóng hơn — liên hệ trực tiếp với bài 04.
- Connection leak (không trả kết nối về pool) gây pool cạn dần — luôn dùng
try-with-resources.
14. Tự kiểm tra
Q1Vì sao mỗi kết nối HTTPS mới tốn tới 2 RTT trước khi server có thể xử lý request?▸
Q2HTTP keep-alive giải quyết vấn đề gì so với mở kết nối mới mỗi request? Hạn chế của nó là gì?▸
Q3Connection pool quá lớn có thể gây hại như thế nào? Kể 2 hệ quả cụ thể.▸
- Vượt max_connections của DB/server backend: PostgreSQL mặc định
max_connections=100. Nếu pool 500 kết nối tới cùng DB, 400 kết nối bị từ chối hoặc DB crash vì OOM. Mỗi PostgreSQL connection tốn ~5-10MB RAM worker process. - Lock contention và context-switch: nhiều connection đồng thời tranh nhau lock ở DB (row-level lock, table lock). Benchmark thực tế (HikariCP wiki) cho thấy pool lớn có throughput thấp hơn pool nhỏ trên cùng số CPU core vì overhead scheduling.
Q4Bạn thấy log 'Connection is not available, request timed out after 30000ms'. Đây là vấn đề gì và hướng kiểm tra đầu tiên là gì?▸
activeConnections, idleConnections, threadsAwaitingConnection — nếu waiting > 0 thường xuyên thì pool nhỏ quá; (2) kiểm tra connection leak: có kết nối nào mượn nhưng không trả về pool không? Tìm code path thiếu try-with-resources hoặc thiếu close() trong finally. Nếu waiting cao nhưng không có leak, tăng maximumPoolSize hoặc giảm thời gian xử lý mỗi request.Q5Vì sao connection pool giảm TIME_WAIT tích luỹ trên hệ thống?▸
TIME_WAIT ~60 giây (xem bài 04). Không dùng pool: mỗi HTTP request mở kết nối mới → đóng sau response → tạo 1 TIME_WAIT. Với 1 000 request/giây → 60 000 TIME_WAIT sau 60 giây. Dùng pool: kết nối được tái dùng — không đóng sau mỗi request mà trả về pool. Chỉ khi pool đóng kết nối do idle timeout hoặc max lifetime mới tạo TIME_WAIT. Pool size 20 → tối đa vài chục TIME_WAIT thay vì hàng chục nghìn.Q6Vì sao nên dùng try-with-resources khi làm việc với Connection từ pool? Điều gì xảy ra nếu không dùng?▸
try-with-resources đảm bảo connection.close() được gọi dù code có ném exception — close() với pool connection không thực sự đóng TCP socket mà trả kết nối về pool.
Nếu không dùng và có exception xảy ra trước close(): kết nối bị leak — pool không bao giờ nhận lại, activeConnections tăng dần cho đến khi đạt maxPoolSize, mọi request tiếp theo bị timeout. Đây là lỗi "pool cạn dù server ổn" — rất khó debug nếu không có metrics pool.
Q7App Java của bạn gọi external HTTP API. Bạn nên tạo OkHttpClient một lần hay mỗi request một instance? Vì sao?▸
OkHttpClient instance có ConnectionPool riêng — tạo mới mỗi request đồng nghĩa với: (1) không tái dùng được kết nối nào (mỗi request phải bắt tay lại); (2) leak thread pool và connection pool nếu không shutdown() đúng cách; (3) tốn thêm bộ nhớ cho mỗi instance. Một OkHttpClient singleton với ConnectionPool tuỳ chỉnh cho phép nhiều thread tái dùng chung kết nối — đúng mục đích pool.Bài tiếp theo: Lỗi mạng thường gặp — đọc error message đúng chỗ
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên