Service chạy ngon lành suốt đêm, đến 8h sáng peak hour bắt đầu — timeout lan dần, log đầy connection pool exhausted, restart service là hết, 3 tiếng sau lại fail y chang. Đây không phải traffic spike. Đây là connection leak đang tích lũy âm thầm từng kết nối một.
Điều khó chịu nhất của connection leak là nó không crash ngay. Nó để service chạy bình thường hàng giờ, rồi mới bóp nghẹt từ từ vào đúng lúc traffic cao nhất. Restart là cách fix tạm thời duy nhất mà ai cũng biết — nhưng gốc rễ vẫn còn đó.
Connection pool là gì thực ra
Hiểu nôm na thì pool giống hàng đợi taxi — có N taxi, request nào đến cũng phải chờ taxi rảnh. Xử lý xong, taxi về bãi đậu chờ khách tiếp. Connection leak = taxi đi rồi không về bãi đậu. Bãi đậu dần hết taxi, request sau phải đứng chờ mãi mãi cho đến khi timeout.
Khi bạn config pool_size = 20, bạn đang nói: “database của mình chỉ phục vụ tối đa 20 kết nối đồng thời từ service này.” Mỗi request lấy một connection từ pool, dùng xong phải trả lại. Nếu không trả, pool cạn, request tiếp theo block cho đến khi timeout — thường là 30 giây hoặc theo config acquire_timeout.
Vấn đề là pool không tự biết connection đó đang “bị giữ” hay “đang dùng”. Nó chỉ biết: đã cho mượn, chưa thấy trả.
Các con đường dẫn đến leak
Exception nuốt connection
Đây là nguyên nhân phổ biến nhất. Code acquire connection trong try, exception ném ra, nhảy thẳng vào catch — connection không được release vì không có finally.
# LEAK: nếu exception xảy ra, connection không được đóng
def get_user(user_id: int):
conn = pool.getconn()
cursor = conn.cursor()
try:
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
return cursor.fetchone()
except Exception as e:
logger.error(f"Query failed: {e}")
# conn không được release ở đây — leak!
raise
```text
Một exception nhỏ trong query, connection biến mất khỏi pool vĩnh viễn. Sau 20 lần như vậy, pool cạn.
### ORM ẩn connection management
Prisma, TypeORM, SQLAlchemy đều quản lý connection thay bạn — nghe hay nhưng cũng là nơi bug ẩn náu. TypeORM `QueryRunner` yêu cầu gọi `release()` thủ công sau khi dùng. Prisma mặc định dùng connection pooling qua PgBouncer nội bộ nhưng nếu bạn dùng `$connect()` trực tiếp mà không `$disconnect()` trong serverless function, mỗi cold start tạo một pool mới — 100 Lambda instances là 100 pool, mỗi pool 5 connections = 500 connections vào DB.
```typescript
// LEAK: QueryRunner không được release
async function transferMoney(fromId: number, toId: number, amount: number) {
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.decrement(Account, { id: fromId }, "balance", amount);
await queryRunner.manager.increment(Account, { id: toId }, "balance", amount);
await queryRunner.commitTransaction();
// thiếu queryRunner.release() ở đây — leak!
} catch (err) {
await queryRunner.rollbackTransaction();
// thiếu queryRunner.release() ở đây nữa — leak!
throw err;
}
}
```text
### Promise.all() fail silent
Khi một promise trong `Promise.all()` reject, các promise khác vẫn chạy tiếp nhưng result bị bỏ qua. Nếu promise đó đang giữ connection, connection không được release cho đến khi GC dọn (hoặc không bao giờ, tùy ORM).
```typescript
// RISK: nếu query2 fail, query1 connection có thể không được release đúng cách
const [result1, result2] = await Promise.all([
expensiveQuery1(conn1), // conn1 không release nếu Promise.all reject
expensiveQuery2(conn2),
]);
```text
---
## Detect với pg_stat_activity
Trước khi fix, hãy xác nhận mình đang thực sự bị leak bằng cách query thẳng vào Postgres (đây là lý do nên có monitoring DB riêng, không chỉ app metric):
```sql
-- Xem tất cả connection đang idle nhưng còn giữ transaction
SELECT
pid,
usename,
application_name,
state,
query_start,
now() - query_start AS duration,
left(query, 80) AS last_query
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'idle in transaction (aborted)')
ORDER BY query_start;
```text
`idle in transaction` nghĩa là connection đang giữ một transaction open nhưng không làm gì — đây là dấu hiệu rõ nhất của leak. Nếu thấy nhiều dòng với `duration` tính bằng phút (hoặc giờ), code đang leak.
```sql
-- Đếm connection theo state để monitor liên tục
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = 'your_db_name'
GROUP BY state;
```text
Thêm query này vào Grafana dashboard. Khi `idle in transaction` tăng liên tục không giảm, đó là leak đang xảy ra real-time.
Lưu ý: pg_stat_activity cần quyền pg_monitor hoặc superuser. Trong production, nên tạo một role riêng chỉ có quyền đọc view này cho monitoring.
---
## Reproduce locally để test fix
Trước khi fix, hãy viết code tạo leak có chủ đích để verify fix của bạn thực sự hoạt động:
```python
import psycopg2
from psycopg2 import pool
# Pool nhỏ để dễ thấy leak
connection_pool = pool.SimpleConnectionPool(
minconn=1,
maxconn=5,
dsn="postgresql://user:pass@localhost/testdb"
)
def leaky_function():
"""Hàm này leak connection mỗi lần gọi."""
conn = connection_pool.getconn()
cursor = conn.cursor()
cursor.execute("SELECT pg_sleep(0.1)")
# Không có putconn() — connection bị mất
def test_leak():
"""Gọi 6 lần để vượt quá maxconn=5."""
for i in range(6):
try:
leaky_function()
print(f"Call {i+1}: OK")
except Exception as e:
print(f"Call {i+1}: FAIL — {e}") # Call 6 sẽ fail ở đây
test_leak()
```text
Chạy đoạn này sẽ thấy call thứ 6 fail với `connection pool exhausted`. Sau đó fix và verify hành vi thay đổi.
---
## Fix pattern
### try/finally — đơn giản nhất
```python
def get_user_safe(user_id: int):
conn = connection_pool.getconn()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
return cursor.fetchone()
except Exception as e:
logger.error(f"Query failed: {e}")
raise
finally:
# Luôn chạy dù có exception hay không
connection_pool.putconn(conn)
```text
`finally` chạy kể cả khi exception ném ra, kể cả khi `return` được gọi. Đây là guarantee quan trọng nhất.
### Async context manager (Python) — idiomatic và safe hơn
```python
from contextlib import asynccontextmanager
import asyncpg
@asynccontextmanager
async def get_connection(pool: asyncpg.Pool):
"""Context manager tự động release connection."""
conn = await pool.acquire()
try:
yield conn
finally:
await pool.release(conn)
async def get_user_safe(pool: asyncpg.Pool, user_id: int):
async with get_connection(pool) as conn:
# Thoát khỏi block là connection tự release
return await conn.fetchrow(
"SELECT * FROM users WHERE id = $1", user_id
)
```text
Asyncpg thực ra đã có built-in context manager, nhưng wrap thêm như trên giúp enforce pattern nhất quán trong codebase (và tránh ai đó vô tình dùng `pool.acquire()` không qua context manager).
### TypeORM — fix QueryRunner leak
```typescript
async function transferMoney(fromId: number, toId: number, amount: number) {
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.decrement(Account, { id: fromId }, "balance", amount);
await queryRunner.manager.increment(Account, { id: toId }, "balance", amount);
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
// Luôn release, dù commit hay rollback
await queryRunner.release();
}
}
```text
### TypeScript using statement (ES2022+)
```typescript
class DatabaseConnection implements Disposable {
private conn: PoolClient;
constructor(conn: PoolClient) {
this.conn = conn;
}
[Symbol.dispose]() {
// Tự gọi khi ra khỏi using block
this.conn.release();
}
async query(sql: string, params: unknown[]) {
return this.conn.query(sql, params);
}
}
async function getUser(pool: Pool, userId: number) {
using db = new DatabaseConnection(await pool.connect());
// Ra khỏi function — dù throw hay return — db.release() tự gọi
return await db.query("SELECT * FROM users WHERE id = $1", [userId]);
}
```text
`using` statement còn khá mới (TypeScript 5.2+), nhưng đây là pattern đẹp nhất về mặt idiom — compiler enforce cleanup thay vì developer phải nhớ.
---
## Pool sizing không phải fix
Khi gặp `connection pool exhausted`, phản ứng đầu tiên của nhiều team là tăng `pool_size` từ 10 lên 50. Điều này không fix leak — nó chỉ trì hoãn crash thêm vài tiếng.
Tăng pool size còn có side effect: database phải xử lý nhiều kết nối hơn, mỗi connection Postgres tốn khoảng 5-10MB RAM và một backend process riêng. Pool 100 connections × 10MB = 1GB RAM chỉ cho connection overhead, chưa tính dữ liệu trong memory.
Số liệu để tính pool size hợp lý theo công thức của PgBouncer team:
```text
pool_size = (số CPU của DB server × 2) + số effective spindle disk
```text
Với DB server 4 CPU, 2 disk: pool_size ≈ 10. Bạn có thể tăng lên 20-30 nếu workload nhiều I/O wait, nhưng 100+ thường là dấu hiệu của leak hoặc architecture issue.
Cảnh báo: Nếu bạn đang dùng serverless (Lambda, Cloud Run), mỗi instance tạo pool riêng. 100 instances × pool_size 5 = 500 connections. Dùng PgBouncer hoặc RDS Proxy trước DB để pool connection tập trung.
---
## Gotcha với ORM phổ biến
**Prisma** mặc định không expose connection ra ngoài — mọi thứ qua `prisma.$transaction()`. Điều này tốt nhưng có một gotcha: trong serverless, nếu không gọi `prisma.$disconnect()` khi function shutdown, Prisma giữ pool mở. Số connection tối đa của Prisma mặc định là `num_cpus × 2 + 1` — trên Lambda với 1 vCPU là 3, ít thôi, nhưng nhân với số concurrent invocation thì nhanh chóng quá limit.
**TypeORM** có `connectionTimeout` và `acquireTimeout` nhưng mặc định khá cao (thường 15-30 giây). Giảm `acquireTimeout` xuống 3-5 giây để fail fast thay vì queue dồn lại.
**SQLAlchemy** có `pool_pre_ping=True` — option này kiểm tra connection còn sống không trước khi dùng, tự động dispose stale connection. Nên bật mặc định.
```python
from sqlalchemy import create_engine
engine = create_engine(
"postgresql://user:pass@localhost/db",
pool_size=10,
max_overflow=5,
pool_timeout=30, # giây chờ acquire
pool_recycle=1800, # recycle connection sau 30 phút
pool_pre_ping=True, # kiểm tra connection trước khi dùng
)
```text
Connection leak là một trong những bug khó debug nhất vì nó không crash ngay và không có stack trace rõ ràng. Mình thường xuyên thấy các team mất 2-3 ngày debug mới tìm ra root cause, trong khi `pg_stat_activity` cho thấy câu trả lời trong 30 giây — nếu biết nhìn.