1. Sự cố lúc 2 giờ sáng: Khi connection pool bốc hơi sạch
2 giờ 15 phút, chuông PagerDuty reo dồn dập. Toàn bộ endpoint thống kê trả về mã 504 Gateway Timeout. Pod ứng dụng treo cứng ngắc, RAM container chạm ngưỡng 95% và log tràn ngập dòng thông báo quen thuộc:
sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00 (Background on this error at: https://sqlalche.me/e/20/3o7r)
Hệ thống backend viết bằng FastAPI chạy async kết hợp SQLAlchemy 2.0. Ban ngày tải khoảng 60 req/s thì vận hành rất mượt mà. Tuy nhiên, đúng 2 giờ sáng, cron job đồng bộ 50.000 đơn hàng kích hoạt cùng lúc với đợt webhook đối tác dội về. Toàn bộ 15 kết nối trong pool cạn kiệt chỉ sau 25 giây. Mình SSH khẩn cấp vào kiểm tra pg_stat_activity trên PostgreSQL. Hơn 10 session đang nằm im lìm ở trạng thái idle in transaction suốt nhiều phút.
2. Vì sao code Async vẫn làm nghẽn kết nối database?
Lục lại git log và theo dõi vết call stack, team tìm ra 3 lỗ hổng tai hại:
Lỗi 1: Thất thoát session do quản lý vòng đời lơ đễnh
Nhiều background task tự tạo AsyncSession nhưng không dùng context manager async with. Khi hàm gặp lỗi mạng đột ngột giữa chừng, câu lệnh nhảy ra ngoài mà không đóng session. Kết nối bị giữ treo lơ lửng cho tới khi chạm mốc timeout 30 giây của server, khiến request API mới đến không còn slot trống để dùng.
Lỗi 2: Vấp bẫy MissingGreenlet vì Lazy Loading
Đây là sai lầm kinh điển nhất khi đưa async vào ORM. Khi bạn đọc một quan hệ chưa nạp sẵn dữ liệu (ví dụ gọi order.user), SQLAlchemy cố kích hoạt truy vấn đồng bộ ngầm dưới nền. Nhưng trên event loop async, hành động này lập tức quăng exception:
sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called in this context; can't call await_only() here.
Một bạn dev trước đó đã sửa vội bằng cách bọc hàm query vào asyncio.to_thread. Giải pháp chắp vá này biến worker async thành luồng blocking đồng bộ. Kết quả là throughput của service sụt thảm hại từ 1.100 req/s xuống vỏn vẹn 35 req/s.
Lỗi 3: Nạp ồ ạt bằng all() khiến bộ nhớ phình to
Đoạn batch job quét dữ liệu dùng result.scalars().all() để lấy 50.000 bản ghi một lượt. Python buộc phải khởi tạo 50.000 instance model cùng lúc, thổi bay hơn 1.8GB RAM chỉ trong vài giây. Tệ hơn, kết nối database bị giữ chặt liên tục suốt 40 giây chạy vòng lặp xử lý, thay vì được trả về pool ngay sau khi nhận dữ liệu.
3. Những hướng xử lý tạm thời và cái giá phải trả
Trước khi viết lại kiến trúc session, nhóm đã cân nhắc vài phương án thường thấy:
- Nâng kịch khung
pool_sizevàmax_overflow: Đổi cấu hình từ 15 lên 80 kết nối. Cách này chỉ hoãn thời điểm sập server thêm chừng 3 phút. Mỗi kết nối Postgres tiêu tốn khoảng 5-10MB RAM. Việc mở quá nhiều kết nối khiến cụm database quá tải RAM, chạm trầnmax_connectionsvà kéo sập luôn cả service thanh toán chạy chung DB. - Đẩy tác vụ sang ThreadPoolExecutor: Dùng engine sync thông thường rồi chia thread xử lý. Cách này ngốn nhiều CPU để context switch giữa các OS thread, làm mất đi ưu thế nhẹ tải của mô hình AsyncIO.
- Viết Raw SQL thuần với driver asyncpg: Tốc độ truy vấn của
asyncpgrất ấn tượng. Đổi lại, dự án mất toàn bộ hệ thống type hint, logic relation giữa các bảng và khả năng autogenerate migration của Alembic.
4. Giải pháp triệt để: Tối ưu AsyncSession và truy vấn trong SQLAlchemy 2.0
Để hệ thống chạy mượt mà mà vẫn tận dụng được ORM, chúng ta cần chuẩn hóa lại 3 phần: engine, session lifecycle và cơ chế load quan hệ.
Bước 1: Thiết lập Engine và Factory bảo đảm dọn dẹp kết nối
Hãy dùng driver bất đồng bộ asyncpg và nhớ đặt cờ expire_on_commit=False. Cờ này giữ cho dữ liệu object không bị xóa sạch sau khi commit, ngăn chặn triệt để nguy cơ trigger lazy load ngoài phiên làm việc:
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
create_async_engine,
async_sessionmaker,
AsyncSession,
)
DATABASE_URL = "postgresql+asyncpg://app_user:[email protected]:5432/production_db"
engine = create_async_engine(
DATABASE_URL,
pool_size=10,
max_overflow=15,
pool_timeout=30,
pool_recycle=1800, # Làm mới kết nối sau mỗi 30 phút để tránh đứt socket ngầm
pool_pre_ping=True, # Kiểm tra kết nối sống trước khi cấp phát
echo=False,
)
AsyncSessionFactory = async_sessionmaker(
bind=engine,
class_=AsyncSession,
autoflush=False,
expire_on_commit=False,
)
# Context manager chuẩn dùng cho FastAPI Dependency hoặc background worker
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionFactory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
Bước 2: Triệt tiêu MissingGreenlet bằng Eager Loading tường minh
Trong môi trường async, mọi relationship cần dùng đều phải được định nghĩa nạp trước. Dùng joinedload cho quan hệ 1-1 (hoặc N-1) và dùng selectinload cho quan hệ 1-N:
from sqlalchemy import select
from sqlalchemy.orm import selectinload, joinedload
from models import User, Order, Profile
async def get_user_with_details(session: AsyncSession, user_id: int):
stmt = (
select(User)
.options(
joinedload(User.profile), # Quan hệ 1-1: dùng phép LEFT JOIN trong cùng 1 query
selectinload(User.orders) # Quan hệ 1-N: dùng query SELECT ... WHERE id IN (...) riêng biệt, nhanh hơn nhiều
)
.where(User.id == user_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
Dữ liệu được gom đủ trong đúng một lần await. Ứng dụng không còn phát sinh I/O đồng bộ ngầm, lỗi MissingGreenlet biến mất hoàn toàn.
Bước 3: Stream từng cụm dữ liệu với yield_per
Đối với tác vụ xử lý hàng chục nghìn bản ghi, chuyển ngay sang stream_scalars() kèm theo tùy chọn yield_per():
async def process_batch_orders(session: AsyncSession):
stmt = (
select(Order)
.where(Order.status == "PENDING")
.execution_options(yield_per=1000) # Lấy từng cụm 1.000 dòng từ server cursor
)
result = await session.stream_scalars(stmt)
async for order in result:
order.status = "PROCESSED"
await session.commit()
Hiệu quả ghi nhận ngay trên dashboard monitoring sau khi refactor:
- Connection pool duy trì ổn định ở mức 4 đến 6 active connections trong giờ cao điểm.
- Mức tiêu thụ RAM của worker giảm từ 1.8GB xuống dưới 260MB khi xử lý file lớn.
- Thời gian phản hồi P99 của API giảm mạnh từ 2.800ms xuống còn 72ms.
- Lỗi
TimeoutErrorđược dẹp yên hoàn toàn.

