Quay lại bài viết
4 thg 5, 2026
16 min read

Connection Pool: Tại sao mở kết nối database mỗi request là một sự lãng phí

Bạn đang chạy production ngon lành — rồi đột nhiên flash sale đổ về, dashboard báo đỏ: FATAL: too many connections for role "app_user". CPU database spike 100%, response time từ 50ms nhảy lên 5 giây, rồi timeout hàng loạt.

Phản xạ đầu tiên: tăng max_connections lên gấp đôi. Nhưng thực tế, càng tăng càng tệ — database giờ phải quản lý hàng trăm connection, mỗi connection ngốn bộ nhớ, tranh nhau CPU, rồi tất cả cùng chậm.

Vấn đề thật sự không phải database yếu. Vấn đề là mỗi HTTP request đang mở một kết nối mới đến database, dùng cho đúng 1 câu query, rồi đóng lại — và cái giá phải trả cho “mở + đóng” đó đắt hơn rất nhiều so với bạn nghĩ.

Bài viết này sẽ mổ xẻ chính xác chuyện gì xảy ra khi bạn mở và đóng một kết nối database, tại sao nó đắt, và cách connection pool giải quyết từng vấn đề — từ local pool với pg trong Node.js đến global pool với AWS RDS Proxy.


1. Không có connection pool thì chuyện gì xảy ra?

Hình dung mỗi khi user gửi một HTTP request đến server, application sẽ:

  1. Mở một TCP connection đến database
  2. Thực hiện TLS handshake để mã hoá kênh truyền
  3. Gửi thông tin xác thực (username/password)
  4. Database tạo backend process riêng, cấp phát bộ nhớ (RAM), thiết lập session state
  5. Chạy câu query (thường chỉ mất vài milliseconds)
  6. Dọn dẹp session, giải phóng bộ nhớ
  7. Đóng kết nối TCP

Tất cả bước 1–4 và 6–7 là overhead — chúng không liên quan gì đến câu query, nhưng phải thực hiện mỗi lần có request mới.

Request 1 → [TCP] → [TLS] → [Auth] → [Alloc] → [Query 2ms] → [Cleanup] → [Close] Request 2 → [TCP] → [TLS] → [Auth] → [Alloc] → [Query 2ms] → [Cleanup] → [Close] Request 3 → [TCP] → [TLS] → [Auth] → [Alloc] → [Query 2ms] → [Cleanup] → [Close] ... Request N → [TCP] → [TLS] → [Auth] → [Alloc] → [Query 2ms] → [Cleanup] → [Close]

Giống như bạn gọi điện cho ngân hàng: mỗi lần phải bấm số, chờ kết nối, xác minh danh tính qua 3 câu hỏi bảo mật… chỉ để hỏi một câu. Cúp máy. Rồi gọi lại, xác minh lại, hỏi câu tiếp theo. Lặp lại 1000 lần.

Và đây là vấn đề thật sự: PostgreSQL mặc định chỉ cho phép 100 connections đồng thời (max_connections = 100). Request thứ 101 sẽ nhận ngay lỗi FATAL: too many connections. Khi mỗi request tạo connection riêng và giữ nó qua cả quá trình setup dài dòng, 100 slot đó hết rất nhanh.

Lưu ý: Con số 100 chỉ là default của PostgreSQL. Trong thực tế, max_connections thay đổi tùy theo cấu hình hardware của database. AWS RDS chẳng hạn, set max_connections tự động theo công thức LEAST({DBInstanceClassMemory/9531392}, 5000) — nghĩa là tỉ lệ với RAM của instance:

Instance classRAMmax_connections (RDS PostgreSQL default)
db.t3.micro1 GB~85
db.t3.medium4 GB~410
db.r5.large16 GB~1700
db.r5.4xlarge128 GB~5000 (capped)

Nhưng đừng vội mừng vì instance to có nhiều slot — như Section 3 sẽ chỉ ra, càng nhiều connection không đồng nghĩa với throughput cao hơn. Chi phí context switching, memory, lock contention đều scale theo số connection. Mục tiêu của connection pool không phải “dùng hết max_connections” mà là dùng số connection tối ưu cho workload.

max_connections tỉ lệ với RAM, một hotfix nhanh khi production bị FATAL: too many connectionsvertical scale: nâng instance từ db.t3.medium (4GB, ~410 connections) lên db.r5.large (16GB, ~1700 connections). Vài cú click trên AWS Console, sau khi reboot là có thêm slot ngay.

Nhưng ngay sau khi scale xong, bạn sẽ thấy hệ quả đến rất nhanh:

Vertical scale là mua thời gian, không phải lời giải. Lời giải là dừng việc tạo/đóng connection cho mỗi request — và đó chính là việc connection pool làm.


2. Chi phí ẩn của mỗi kết nối: Trước và sau câu query

Hãy zoom vào từng bước overhead để hiểu tại sao chúng đắt đến vậy.

2.1. Chi phí thiết lập kết nối (Connection Setup)

TCP 3-way handshake

Trước khi gửi bất kỳ dữ liệu nào, client và database phải thiết lập kết nối TCP qua 3 bước:

Chi phí: 1.5 RTT. Nếu application chạy ở ap-southeast-1 (Singapore) và database ở us-east-1 (Virginia) với latency ~200ms/RTT, bạn đã mất 300ms chỉ để thiết lập kết nối TCP. Ngay cả trong cùng region, cross-AZ latency ~1-2ms/RTT cũng cộng dồn nhanh khi nhân với hàng nghìn request.

TLS negotiation

Hầu hết production database đều bật TLS (SSL) để mã hóa dữ liệu trên đường truyền. Sau khi TCP connection sẵn sàng, client và database phải trao đổi certificate, thống nhất cipher suite, và tạo session key:

Đây không chỉ tốn thời gian mà còn tốn CPU — cả hai bên phải thực hiện phép toán mã hóa bất đối xứng (asymmetric cryptography) để trao đổi key.

Authentication

PostgreSQL mặc định dùng SCRAM-SHA-256 — một giao thức xác thực hiện đại và an toàn. Quá trình bao gồm:

  1. Client gửi username
  2. Server phản hồi với saltiteration count (mặc định 4096)
  3. Client tính toán proof bằng PBKDF2 với 4096 iterations
  4. Server xác minh proof

Chi phí: ít nhất 1 RTT thêm, cộng thêm CPU time đáng kể cho PBKDF2 hashing. Trên một database instance nhỏ (ví dụ db.t3.medium), khi 500 connections cùng xác thực đồng thời, CPU sẽ dành phần lớn thời gian cho crypto thay vì xử lý query.

Memory allocation

PostgreSQL sử dụng mô hình process-per-connection: mỗi kết nối mới, PostgreSQL fork một backend process hoàn toàn mới. Process này cần cấp phát bộ nhớ riêng:

Thành phầnDung lượngMục đích
work_mem4MB (default)Bộ nhớ cho sort, hash operations
temp_buffers8MB (default)Bộ nhớ cho temporary tables
Catalog cache~1-2MBCache metadata bảng, index
Plan cache~1-2MBCache execution plan
Stack + overhead~0.5-1MBProcess stack và kernel overhead
Tổng~5-10MBPer connection

Nghe có vẻ ít, nhưng nhân lên: 200 connections = 1-2GB chỉ cho connection overhead, chưa tính shared buffers hay dữ liệu thật sự.

Session state initialization

Sau khi xác thực thành công, PostgreSQL còn phải:

2.2. Chi phí dọn dẹp (Connection Teardown)

Khi request xong và application đóng kết nối, database phải:

2.3. Tổng chi phí

BướcChi phí thời gianChi phí tài nguyên
TCP handshake1.5 RTTSocket, file descriptor
TLS 1.31 RTTCPU cho crypto
Authentication1+ RTTCPU cho PBKDF2
Memory allocation~5-10MB RAM
Session initCPU time
Teardown2 RTTPort bị giữ 60s (TIME_WAIT)
Tổng setup + teardown5.5+ RTTRAM + CPU + port

Câu query của bạn chạy trong 2ms. Nhưng setup + teardown bao quanh nó tốn 150-200ms (cross-AZ) hoặc hàng giây (cross-region). Bạn đang trả 99% overhead cho 1% công việc thật sự.


3. Khi hàng nghìn kết nối cùng đổ về

Những chi phí ở Section 2 là cho một kết nối. Khi traffic spike và hàng nghìn request đổ về đồng thời, các chi phí này cộng hưởng tạo ra hiệu ứng domino:

3.1. Port exhaustion

Mỗi kết nối TCP sử dụng một ephemeral port (phạm vi 32768–60999 trên Linux, tổng cộng khoảng 28,000 ports). Khi đóng kết nối, port không giải phóng ngay mà nằm trong TIME_WAIT 60 giây.

Làm phép tính: nếu application tạo 500 connections/giây, mỗi port bị giữ 60s → cần 30,000 ports — vượt quá tổng số ephemeral ports. Kết quả: lỗi EADDRNOTAVAIL (Cannot assign requested address). Application hoàn toàn không thể mở thêm kết nối nào nữa.

3.2. Memory pressure

3.3. CPU bị đốt cho handshake

Dưới traffic spike, CPU database không dành cho việc xử lý query — mà bận:

Khi 1000 connections cùng thiết lập đồng thời, CPU gần như 100% là crypto, query thật sự phải xếp hàng chờ.

3.4. Context switching

PostgreSQL dùng mô hình process-per-connection: mỗi connection là một OS process độc lập. Database instance thường chỉ có vài CPU cores (4, 8, 16…), nhưng khi có hàng trăm connections, kernel phải liên tục chuyển CPU qua lại giữa các process để mỗi process được chạy một lượt.

Mỗi lần context switch, kernel phải:

Một context switch trên Linux tốn khoảng 1–10 microseconds đo theo wall clock, nhưng chi phí thật sự (cache miss sau switch) có thể lên đến vài chục microseconds. Khi có 500 connections active, kernel có thể switch hàng chục nghìn lần/giây — CPU dành phần lớn thời gian cho việc chuyển ngữ cảnh thay vì thực thi query.

Đây là lý do tại sao tăng max_connections không tỉ lệ thuận với throughput: vượt qua một ngưỡng nào đó (thường là 2-4 lần số CPU cores), throughput giảm do context switching overhead vượt qua lợi ích của việc xử lý song song.

3.5. Connection limit

PostgreSQL có giới hạn cứng max_connections (default 100). Tăng lên 300, 500? Mỗi connection tốn resource, nên:

3.6. File descriptor exhaustion

Mỗi connection chiếm 1 file descriptor. Default ulimit -n trên nhiều Linux distribution là 1024. Cộng thêm file descriptors cho data files, WAL, logs — 1024 hết rất nhanh.

Tổng hợp

Vấn đềNgưỡngTriệu chứng
Port exhaustion~28K ports / TIME_WAIT 60sEADDRNOTAVAIL
Memory pressure~5-10MB per connectionOOM, swapping, crash
CPU saturationTLS + SCRAM per connection%sys CPU cao, query chậm
Context switching> 2-4× CPU coresThroughput giảm, latency tăng
Connection limitmax_connections (default 100)FATAL: too many connections
File descriptor limitulimit -n (default 1024)Too many open files

Tất cả 5 vấn đề trên đều đến từ cùng một nguyên nhân gốc: tạo và hủy kết nối liên tục cho mỗi request.


4. Connection Pool giải quyết vấn đề như thế nào

Ý tưởng của connection pool rất đơn giản: tạo kết nối một lần, dùng lại nhiều lần.

Thay vì mỗi request tự mở kết nối, xác thực, rồi đóng — application giữ sẵn một nhóm (pool) kết nối đã được thiết lập đầy đủ (TCP + TLS + Auth xong hết). Khi request cần query database, nó mượn một kết nối từ pool, dùng xong thì trả lại — không đóng, không tạo mới.

Mapping vấn đề → giải pháp

Vấn đề (Section 3)Connection Pool giải quyết
TCP+TLS+Auth mỗi requestChi phí setup chỉ trả 1 lần khi pool khởi tạo
Port exhaustionSố connection cố định, không tạo/đóng liên tục → không có TIME_WAIT
Memory pressurePool có max — giới hạn cứng số connection → bộ nhớ bounded
CPU burn cho handshakeKhông lặp lại handshake → CPU dành cho query
max_connections hitPool max nhỏ hơn nhiều so với max_connections → luôn đủ slot
Connection churnConnection được reuse, không bị destroy/recreate

Vòng đời của connection trong pool

  1. Creation — Pool tạo min connections khi khởi động. TCP + TLS + Auth hoàn tất cho tất cả.
  2. Checkout — Request mượn connection. Nếu có idle connection → lấy ngay. Nếu hết idle nhưng chưa đạt max → tạo mới. Nếu đạt max → xếp hàng chờ.
  3. Release — Request xong, trả connection về pool. Pool reset session state (rollback nếu có transaction dở, clear temp settings).
  4. Validation — Pool kiểm tra connection còn sống không trước khi cho mượn (chạy SELECT 1 hoặc ping).
  5. Cleanup — Connection idle quá lâu (idleTimeout) → pool đóng bớt, giữ lại tối thiểu min connections.

Các tham số cấu hình quan trọng

Tham sốÝ nghĩaGiá trị ví dụ
minSố connection giữ sẵn khi idle5
maxSố connection tối đa dưới tải cao20
idleTimeoutConnection idle quá lâu → đóng30 giây
connectionTimeoutChờ tối đa để lấy connection từ pool2 giây
validationCách kiểm tra connection còn sốngSELECT 1 trước khi checkout

5. Local Pool: pg Pool trong Node.js

Khi application chỉ chạy một instance duy nhất, local pool là giải pháp đơn giản và hiệu quả nhất. Thư viện pg (node-postgres) cung cấp sẵn đối tượng Pool với đầy đủ tính năng.

5.1. Cấu hình

import { Pool } from 'pg' const pool = new Pool({ host: 'your-db-host.rds.amazonaws.com', port: 5432, database: 'myapp', user: 'app_user', password: process.env.DB_PASSWORD, ssl: { rejectUnauthorized: true }, // Pool sizing min: 5, // Giữ sẵn 5 connections khi idle max: 20, // Tối đa 20 connections dưới tải cao // Timeouts idleTimeoutMillis: 30000, // Connection idle > 30s → đóng, trả về OS connectionTimeoutMillis: 2000, // Chờ tối đa 2s để lấy connection từ pool // Cho phép process exit khi pool rỗng allowExitOnIdle: true, })

Giải thích các giá trị:

5.2. Sử dụng trong code

Pattern 1: pool.query() — Pool tự động checkout, chạy query, rồi release. Phù hợp cho single query.

async function getUser(id: number) { const { rows } = await pool.query( 'SELECT * FROM users WHERE id = $1', [id] ) return rows[0] }

Pattern 2: Manual checkout — Khi cần chạy nhiều query trong một transaction, bạn phải giữ cùng một connection.

async function transferMoney(from: number, to: number, amount: number) { const client = await pool.connect() try { await client.query('BEGIN') await client.query( 'UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, from] ) await client.query( 'UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, to] ) await client.query('COMMIT') } catch (e) { await client.query('ROLLBACK') throw e } finally { client.release() } }

Lưu ý quan trọng: Luôn gọi client.release() trong finally block. Nếu quên release, connection bị “leak” — pool nghĩ nó đang bận nhưng thực tế không ai dùng. Leak đủ nhiều, pool cạn kiệt, toàn bộ application bị block.

Local pool hoạt động tốt khi application chạy một instance. Nhưng khi bạn scale ra nhiều instances thì sao?

10 instances × max=20 = 200 connections đến database. Auto-scale lên 50 instances? 1000 connections. PostgreSQL max_connections = 100 thì chưa đến instance thứ 6 đã hết slot.


6. Global Pool: AWS RDS Proxy — Khi local pool không đủ

6.1. Vấn đề: N instances × local pool

Mỗi application instance giữ local pool riêng. Các pool này không biết nhau — mỗi pool tự quản lý connections độc lập.

Vấn đề trở nên nghiêm trọng hơn với:

Giảm max pool của mỗi instance? Được, nhưng sẽ tạo bottleneck — mỗi instance không đủ connections cho request của chính nó.

6.2. RDS Proxy: Centralized connection pool

AWS RDS Proxy đặt giữa application instances và database, đóng vai trò một connection pool chung cho toàn bộ fleet.

RDS Proxy hoạt động bằng cách multiplexing: nhận hàng trăm connections từ application, nhưng chỉ mở một số lượng nhỏ connections thật sự đến database. Khi một app connection cần chạy query, Proxy gán nó vào một DB connection rảnh, chạy query, rồi trả DB connection lại pool — tương tự như local pool, nhưng ở tầng infrastructure.

Ngoài multiplexing, RDS Proxy còn mang lại:

6.3. Cấu hình quan trọng của RDS Proxy

SettingÝ nghĩaGiá trị thường dùng
MaxConnectionsPercent% của max_connections mà Proxy được dùng50–80%
MaxIdleConnectionsPercent% idle connections Proxy giữ lại10–50%
ConnectionBorrowTimeoutClient chờ tối đa để mượn connection120s
InitQuerySQL chạy khi mượn connection (reset state)SET timezone='UTC'

Ví dụ: PostgreSQL có max_connections = 100, bạn set MaxConnectionsPercent = 70% → Proxy dùng tối đa 70 connections đến database, còn 30 connections dự phòng cho admin, monitoring, migration tools.

6.4. Khi nào cần RDS Proxy?

Không phải lúc nào cũng cần RDS Proxy. Nếu application chỉ chạy 1-2 instances với local pool quản lý tốt, thêm RDS Proxy chỉ tăng chi phí và thêm latency (thêm 1 hop network).

RDS Proxy thật sự cần khi:


7. Tổng kết

Từ đầu bài đến giờ, chúng ta đã đi qua hành trình:

Chi phí 1 connection (TCP + TLS + Auth + Memory = 5.5+ RTT + ~10MB) → Cộng hưởng khi spike (port exhaustion, OOM, CPU burn) → Local pool (reuse connection, bounded resource, queue thay vì reject) → Global pool (multiplex N instances qua RDS Proxy).

Một vài nguyên tắc thực tế:

  1. Luôn dùng connection pool — dù application nhỏ. Chi phí setup pool gần như bằng 0, nhưng lợi ích rõ ràng từ request đầu tiên.
  2. Set max pool dựa trên database, không dựa trên application — nếu max_connections = 100 và có 5 instances, mỗi instance nên set max = 15-18, không phải max = 50.
  3. Luôn release connection trong finally — connection leak là bug nguy hiểm nhất với pool vì nó âm thầm và chỉ phát hiện khi pool cạn kiệt.
  4. Monitor pool metrics — theo dõi pool size, wait time, checkout count. Nếu wait time tăng đều → cần tăng max hoặc tối ưu query.
  5. Thêm RDS Proxy khi local pool không đủ — đặc biệt khi dùng Lambda hoặc auto-scaling, đừng cố giải quyết bằng cách giảm max pool của mỗi instance.

Liên quan