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  connection thay bạn  nghe hay nhưng cũng  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  không `$disconnect()` trong serverless function, mỗi cold start tạo một pool mới  100 Lambda instances  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  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   do nên  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  connection đang giữ một transaction open nhưng không làm   đây  dấu hiệu  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, đó  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 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 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 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 đã built-in context manager, nhưng wrap thêm như trên giúp enforce pattern nhất quán trong codebase ( tránh ai đó 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, 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 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 pattern đẹp nhất về mặt idiom compiler enforce cleanup thay 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 tăng `pool_size` từ 10 lên 50. Điều này không fix leak chỉ trì hoãn crash thêm vài tiếng. Tăng pool size còn side effect: database phải xử nhiều kết nối hơn, mỗi connection Postgres tốn khoảng 5-10MB RAM 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 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 thể tăng lên 20-30 nếu workload nhiều I/O wait, nhưng 100+ thường 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 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 `num_cpus × 2 + 1` trên Lambda với 1 vCPU 3, ít thôi, nhưng nhân với số concurrent invocation thì nhanh chóng quá limit. **TypeORM** `connectionTimeout` `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 queue dồn lại. **SQLAlchemy** `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 một trong những bug khó debug nhất không crash ngay không stack trace 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.