TCP, HTTP & Web cho Backend/Connection pooling & keep-alive — vì sao app cần pool
7/29
Bài 7 / 29~20 phútTCP & UDP Deep DiveMiễn phí lượt xem

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.
TaxiTCP
Chờ taxi đếnTCP handshake (1 RTT)
Trình bày điểm đếnTLS handshake (1 RTT)
Taxi chờ giữa các chuyếnHTTP keep-alive — giữ kết nối mở
Đội xe thường trựcConnection 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
💡 Cách nhớ

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.

Chi phí theo kịch bản — RTT 50ms
Kết nối HTTP mới mỗi request50ms TCP + 50ms HTTP = 100ms/req
Kết nối HTTPS mới mỗi request50ms TCP + 50ms TLS + 50ms HTTP = 150ms/req
Keep-alive / Pool (kết nối sẵn)0ms bắt tay + 50ms HTTP = 50ms/req

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:

  1. Init: pool tạo minPoolSize kết nối khi khởi động.
  2. Mượn (borrow): thread request kết nối → pool giao kết nối idle.
  3. Sử dụng: thread thực hiện request trên kết nối.
  4. Trả (return): thread trả kết nối về pool (không close()).
  5. 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.
  6. 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 app
  • Tc = 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
⚠️ Quy tắc ngón tay cái — không phải con số tuyệt đối

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

📚 RFC & 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

Tự kiểm tra
Q1
Vì 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?
Kết nối HTTPS cần hai handshake nối tiếp: (1) TCP 3-way handshake (SYN/SYN-ACK/ACK) tốn 1 RTT để thiết lập kết nối transport — không có byte HTTP nào được gửi trong giai đoạn này; (2) TLS handshake (ClientHello/ServerHello/...) tốn thêm 1 RTT để thoả thuận mã hoá và xác thực certificate — chạy bên trong kết nối TCP vừa mở. Chỉ sau cả hai handshake hoàn tất, request HTTP đầu tiên mới được gửi (thêm 1 RTT nữa). Tổng 3 RTT cho request HTTPS đầu tiên trên kết nối mới.
Q2
HTTP 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ì?
Giải quyết: keep-alive giữ kết nối TCP (và TLS) mở sau khi response xong, request tiếp theo dùng lại kết nối đó mà không cần handshake — trả chi phí 1-2 RTT một lần cho nhiều request. Hạn chế: trong HTTP/1.1, request vẫn phải tuần tự — request thứ 2 phải chờ response thứ 1 xong (nếu không dùng pipelining, vốn ít được hỗ trợ). Nếu app cần 10 concurrent requests, keep-alive đơn lẻ không giúp — cần pool 10 kết nối hoặc HTTP/2 multiplexing.
Q3
Connection pool quá lớn có thể gây hại như thế nào? Kể 2 hệ quả cụ thể.
  1. 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.
  2. 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.
Q4
Bạ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ì?
Đây là dấu hiệu pool cạn (exhausted) — tất cả kết nối trong pool đang bận và timeout đợi kết nối rảnh. Hướng kiểm tra: (1) đọc metrics pool: 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.
Q5
Vì sao connection pool giảm TIME_WAIT tích luỹ trên hệ thống?
Mỗi lần đóng kết nối TCP, phía chủ động đóng rơi vào 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.
Q6
Vì 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.

Q7
App 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?
Tạo một lần duy nhất (singleton) và tái dùng toàn app. Mỗi 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

Đặt 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

Bài tiếp theo

ECONNREFUSED, ECONNRESET, ETIMEDOUT — đọc đúng nguyên nhân