Lộ trình học Python FastAPI Backend
FastAPI ra mắt năm 2018 và chỉ trong vài năm đã trở thành lựa chọn mặc định cho API Python mới. Lý do rất cụ thể: async native, validation bằng type hints, và tài liệu API tự sinh — ba thứ mà Flask và Django phải cài thêm thư viện mới có.
Bài viết này là lộ trình 14 tuần, đi từ nền tảng async đến deploy production.
🎯 FastAPI phù hợp với ai?
| Nên chọn FastAPI khi | Nên chọn framework khác khi |
|---|---|
| Làm API cho mobile/SPA | Cần web app có giao diện + admin (→ Django) |
| Phục vụ model machine learning | Cần hiệu năng tối đa cho tính toán CPU-bound (→ Go/Rust) |
| Cần nhiều kết nối I/O đồng thời (gọi API ngoài, DB) | Team đã chuẩn hoá Django |
| Muốn tài liệu API tự động | Cần CMS/ERP sẵn có |
| Thích code hiện đại với type hints | Dự án rất nhỏ, không cần async |
💡 Sự thật về hiệu năng: FastAPI nhanh hơn Flask chủ yếu nhờ
asynckhi có nhiều I/O đồng thời. Nếu app của bạn chỉ tính toán CPU và ít request, chênh lệch không lớn. Đừng chọn FastAPI chỉ vì benchmark — hãy chọn vìasyncvàPydanticthực sự giải quyết vấn đề của bạn.
🗓 Lộ trình 14 tuần
Tuần 1–2 Python hiện đại: type hints, async/await, context manager
Tuần 3–4 FastAPI cơ bản: route, param, response model
Tuần 5–6 Pydantic: validation, serialization, settings
Tuần 7–8 Database: SQLAlchemy 2.0 async + Alembic
Tuần 9 Dependency Injection và cấu trúc dự án
Tuần 10 Xác thực JWT + phân quyền
Tuần 11–12 Testing với pytest, background task, cache
Tuần 13 Docker, uvicorn/gunicorn, deploy
Tuần 14 Dự án tổng hợp + phỏng vấn
🐍 Tuần 1–2: Python hiện đại
FastAPI dựa hoàn toàn vào type hints và async. Hai tuần này là bắt buộc, không thể bỏ qua.
Type hints — không còn là tuỳ chọn
from typing import Annotated, Literal, Protocol
from collections.abc import Sequence, AsyncIterator
from decimal import Decimal
def tinh_tong_gia(danh_sach: Sequence[dict]) -> Decimal:
return sum((Decimal(str(item["gia"])) for item in danh_sach), Decimal(0))
TrangThai = Literal["cho_xu_ly", "dang_giao", "hoan_thanh", "da_huy"]
def cap_nhat_trang_thai(don_hang_id: int, trang_thai: TrangThai) -> dict:
...
class CoTheLuu(Protocol):
"""Protocol — duck typing có kiểm tra kiểu."""
def luu(self) -> None: ...
Vì sao quan trọng với FastAPI: type hint chính là schema API. don_hang_id: int → FastAPI tự ép kiểu và trả 422 nếu client gửi "abc".
Async/await — hiểu cho đúng
import asyncio
import time
import httpx
# ❌ Chặn event loop — toàn bộ server đứng khi hàm này chạy
async def goi_api_sai(url: str) -> dict:
response = httpx.get(url) # httpx.get là SYNC!
return response.json()
# ✅ Nhường quyền điều khiển trong lúc chờ
async def goi_api_dung(url: str) -> dict:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url)
return response.json()
# ✅ Chạy nhiều request đồng thời thay vì tuần tự
async def goi_nhieu_url(urls: list[str]) -> list[dict]:
async with httpx.AsyncClient(timeout=10) as client:
return list(await asyncio.gather(*(goi_mot(client, u) for u in urls)))
async def goi_mot(client: httpx.AsyncClient, url: str) -> dict:
r = await client.get(url)
return r.json()
Ba quy tắc async không được vi phạm:
| Quy tắc | Vì sao |
|---|---|
Không gọi hàm blocking trong async def | Chặn event loop → toàn bộ server đứng |
await mọi coroutine | Không await thì hàm không chạy, chỉ tạo object |
Dùng thư viện hỗ trợ async (httpx, asyncpg, aiofiles) | requests, psycopg2 đều blocking |
Khi nào dùng async def, khi nào dùng def?
@app.get("/tinh-toan-nang")
def tinh_toan(n: int): # ← def (sync): FastAPI chạy trong threadpool
return {"ket_qua": sum(i * i for i in range(n))}
@app.get("/goi-api-ngoai")
async def goi_api(): # ← async def: dành cho I/O
async with httpx.AsyncClient() as c:
return (await c.get("https://api.example.com")).json()
💡 Nguyên tắc thực dụng: nếu thân hàm chỉ gọi I/O async →
async def. Nếu hàm chạy tính toán nặng hoặc dùng thư viện sync → dùngdefvà để FastAPI tự đẩy vào threadpool. Đừng cố biến mọi thứ thành async.
Context manager — dùng hàng ngày
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def vong_doi(app: FastAPI):
"""Chạy 1 lần khi server khởi động và 1 lần khi tắt."""
app.state.http = httpx.AsyncClient(timeout=10)
print("🚀 Khởi động: đã tạo HTTP client")
try:
yield
finally:
await app.state.http.aclose()
print("🛑 Tắt: đã đóng HTTP client")
app = FastAPI(lifespan=vong_doi)
🌱 Tuần 3–4: FastAPI cơ bản
pip install "fastapi[standard]" sqlalchemy alembic asyncpg pydantic-settings
fastapi dev main.py
from fastapi import FastAPI, HTTPException, Path, Query, status
from typing import Annotated
app = FastAPI(title="API Ghi Chú", version="1.0.0")
GHI_CHU: dict[int, dict] = {}
_bo_dem = 0
@app.get("/")
async def goc():
return {"thong_diep": "API đang chạy", "phien_ban": "1.0.0"}
@app.get("/ghi-chu")
async def danh_sach(
da_xong: bool | None = None,
tu_khoa: Annotated[str | None, Query(max_length=100)] = None,
gioi_han: Annotated[int, Query(ge=1, le=100)] = 20,
offset: Annotated[int, Query(ge=0)] = 0,
):
ket_qua = list(GHI_CHU.values())
if da_xong is not None:
ket_qua = [g for g in ket_qua if g["da_xong"] == da_xong]
if tu_khoa:
ket_qua = [g for g in ket_qua if tu_khoa.lower() in g["tieu_de"].lower()]
return {"tong": len(ket_qua), "du_lieu": ket_qua[offset : offset + gioi_han]}
@app.get("/ghi-chu/{ghi_chu_id}")
async def chi_tiet(ghi_chu_id: Annotated[int, Path(ge=1)]):
if ghi_chu_id not in GHI_CHU:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Không tìm thấy ghi chú id={ghi_chu_id}",
)
return GHI_CHU[ghi_chu_id]
Điều FastAPI làm tự động cho bạn:
- Ép kiểu:
gioi_han=999vượtle=100→ trả 422 kèm giải thích. - Tài liệu: mở
/docs— có Swagger UI đầy đủ, thử được ngay. - OpenAPI schema:
/openapi.jsonđể sinh client SDK. - Sắp xếp tham số: path param khai báo trước, query param sau — nhưng FastAPI không phụ thuộc thứ tự.
✅ Tuần 5–6: Pydantic — trái tim của FastAPI
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field, EmailStr, field_validator, model_validator, ConfigDict
class GhiChuVao(BaseModel):
"""Dữ liệu client gửi lên."""
model_config = ConfigDict(str_strip_whitespace=True)
tieu_de: str = Field(min_length=1, max_length=200, examples=["Mua sữa"])
noi_dung: str = Field(default="", max_length=5000)
do_uu_tien: int = Field(default=2, ge=1, le=5)
nhan: list[str] = Field(default_factory=list, max_length=10)
@field_validator("nhan")
@classmethod
def chuan_hoa_nhan(cls, v: list[str]) -> list[str]:
return [n.strip().lower() for n in v if n.strip()]
@model_validator(mode="after")
def kiem_tra_logic(self):
if self.do_uu_tien == 5 and not self.noi_dung:
raise ValueError("Ghi chú ưu tiên 5 phải có nội dung")
return self
class GhiChuRa(BaseModel):
"""Dữ liệu trả về — có thêm id và thời gian."""
model_config = ConfigDict(from_attributes=True)
id: int
tieu_de: str
noi_dung: str
do_uu_tien: int
nhan: list[str]
da_xong: bool
ngay_tao: datetime
class PhanHoiDanhSach(BaseModel):
tong: int
du_lieu: list[GhiChuRa]
@app.post("/ghi-chu", response_model=GhiChuRa, status_code=status.HTTP_201_CREATED)
async def tao_moi(ghi_chu: GhiChuVao) -> GhiChuRa:
global _bo_dem
_bo_dem += 1
ban_ghi = {
"id": _bo_dem,
**ghi_chu.model_dump(),
"da_xong": False,
"ngay_tao": datetime.now(),
}
GHI_CHU[_bo_dem] = ban_ghi
return GhiChuRa(**ban_ghi)
Bốn lợi ích của response_model:
- Lọc dữ liệu thừa — nếu bạn vô tình trả
mat_khau_hash, nó không xuất hiện trong response. - Tài liệu chính xác — Swagger hiển thị đúng schema trả về.
- Kiểm tra kiểu — dữ liệu sai sẽ báo lỗi ngay ở server, không tới client.
- Tách biệt — schema vào và schema ra khác nhau được.
Cấu hình bằng pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
class CauHinh(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str
secret_key: str
debug: bool = False
cors_origins: list[str] = ["http://localhost:3000"]
jwt_thuat_toan: str = "HS256"
jwt_het_han_phut: int = 60
cau_hinh = CauHinh() # tự đọc từ .env và biến môi trường
Đây là cách đúng để quản lý cấu hình: type-safe, có validate, có giá trị mặc định.
🗄️ Tuần 7–8: SQLAlchemy 2.0 async + Alembic
# db.py
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from cau_hinh import cau_hinh
engine = create_async_engine(
cau_hinh.database_url,
echo=cau_hinh.debug,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
)
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def lay_session() -> AsyncIterator[AsyncSession]:
"""Dependency cung cấp session cho mỗi request."""
async with SessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
models.py:
from datetime import datetime
from sqlalchemy import String, Text, Boolean, Integer, DateTime, Index, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import ARRAY
from db import Base
class GhiChu(Base):
__tablename__ = "ghi_chu"
id: Mapped[int] = mapped_column(primary_key=True)
tieu_de: Mapped[str] = mapped_column(String(200), nullable=False)
noi_dung: Mapped[str] = mapped_column(Text, default="")
do_uu_tien: Mapped[int] = mapped_column(Integer, default=2)
nhan: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
da_xong: Mapped[bool] = mapped_column(Boolean, default=False)
chu_so_huu_id: Mapped[int] = mapped_column(ForeignKey("nguoi_dung.id", ondelete="CASCADE"))
ngay_tao: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
chu_so_huu: Mapped["NguoiDung"] = relationship(back_populates="ghi_chu")
__table_args__ = (
Index("idx_ghi_chu_chu_so_huu_da_xong", "chu_so_huu_id", "da_xong"),
)
Truy vấn async:
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
async def lay_danh_sach(session: AsyncSession, user_id: int,
da_xong: bool | None = None, gioi_han: int = 20):
stmt = select(GhiChu).where(GhiChu.chu_so_huu_id == user_id)
if da_xong is not None:
stmt = stmt.where(GhiChu.da_xong.is_(da_xong))
stmt = stmt.order_by(GhiChu.do_uu_tien.desc(), GhiChu.ngay_tao.desc()).limit(gioi_han)
ket_qua = await session.scalars(stmt)
return list(ket_qua)
async def dem_theo_uu_tien(session: AsyncSession, user_id: int) -> dict[int, int]:
stmt = (
select(GhiChu.do_uu_tien, func.count(GhiChu.id))
.where(GhiChu.chu_so_huu_id == user_id)
.group_by(GhiChu.do_uu_tien)
)
ket_qua = await session.execute(stmt)
return {muc: so for muc, so in ket_qua.all()}
Alembic — migration cho async
alembic init -t async migrations
alembic/env.py cần sửa target_metadata = Base.metadata và import models để Alembic thấy chúng.
alembic revision --autogenerate -m "tao bang ghi_chu"
alembic upgrade head
⚠️ Luôn đọc file migration do Alembic sinh ra trước khi
upgrade. Autogenerate không phát hiện được: đổi tên cột (nó hiểu là xoá + tạo mới → mất dữ liệu), thay đổi kiểu dữ liệu có mất mát, và các ràng buộc phức tạp.
💉 Tuần 9: Dependency Injection và cấu trúc dự án
app/
├── main.py
├── cau_hinh.py
├── db.py
├── models/
│ ├── __init__.py
│ └── ghi_chu.py
├── schemas/
│ ├── __init__.py
│ └── ghi_chu.py
├── services/
│ └── ghi_chu.py
├── routers/
│ ├── __init__.py
│ ├── auth.py
│ └── ghi_chu.py
├── dependencies.py
└── core/
├── security.py
└── exceptions.py
# dependencies.py
from typing import Annotated
from fastapi import Depends, HTTPException, Header, status
from sqlalchemy.ext.asyncio import AsyncSession
from db import lay_session
from core.security import giai_ma_token
SessionDep = Annotated[AsyncSession, Depends(lay_session)]
async def nguoi_dung_hien_tai(
session: SessionDep,
authorization: Annotated[str | None, Header()] = None,
) -> NguoiDung:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Thiếu token xác thực")
try:
payload = giai_ma_token(authorization[7:])
except TokenHetHan:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token đã hết hạn")
except TokenKhongHopLe:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token không hợp lệ")
user = await session.get(NguoiDung, int(payload["sub"]))
if user is None or not user.dang_hoat_dong:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Tài khoản không tồn tại")
return user
CurrentUser = Annotated[NguoiDung, Depends(nguoi_dung_hien_tai)]
async def yeu_cau_admin(user: CurrentUser) -> NguoiDung:
if user.vai_tro != "admin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "Cần quyền quản trị")
return user
AdminUser = Annotated[NguoiDung, Depends(yeu_cau_admin)]
# routers/ghi_chu.py
from fastapi import APIRouter, status
from dependencies import SessionDep, CurrentUser
router = APIRouter(prefix="/ghi-chu", tags=["Ghi chú"])
@router.get("", response_model=PhanHoiDanhSach)
async def danh_sach(
session: SessionDep,
user: CurrentUser,
da_xong: bool | None = None,
gioi_han: int = 20,
):
ds = await lay_danh_sach(session, user.id, da_xong, gioi_han)
return {"tong": len(ds), "du_lieu": ds}
@router.post("", response_model=GhiChuRa, status_code=status.HTTP_201_CREATED)
async def tao_moi(du_lieu: GhiChuVao, session: SessionDep, user: CurrentUser):
gc = GhiChu(**du_lieu.model_dump(), chu_so_huu_id=user.id)
session.add(gc)
await session.flush()
await session.refresh(gc)
return gc
Vì sao Dependency Injection mạnh: logic xác thực viết một lần, dùng ở hàng chục route, và FastAPI cache kết quả trong cùng request (không truy vấn user 2 lần).
Một quy tắc bảo mật then chốt: luôn lọc theo user.id trong truy vấn, đừng lấy bản ghi rồi mới kiểm tra chủ sở hữu bằng Python:
# ❌ Sai — vẫn lộ thông tin "bản ghi tồn tại"
gc = await session.get(GhiChu, ghi_chu_id)
if gc.chu_so_huu_id != user.id:
raise HTTPException(403)
# ✅ Đúng — không tìm thấy gì cả
stmt = select(GhiChu).where(GhiChu.id == ghi_chu_id, GhiChu.chu_so_huu_id == user.id)
gc = await session.scalar(stmt)
if gc is None:
raise HTTPException(404, "Không tìm thấy ghi chú")
🔐 Tuần 10: JWT và phân quyền
# core/security.py
from datetime import datetime, timedelta, timezone
import jwt
from passlib.context import CryptContext
from cau_hinh import cau_hinh
class TokenHetHan(Exception): ...
class TokenKhongHopLe(Exception): ...
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
def bam_mat_khau(mat_khau: str) -> str:
return pwd.hash(mat_khau)
def kiem_tra_mat_khau(mat_khau: str, da_bam: str) -> bool:
return pwd.verify(mat_khau, da_bam)
def tao_token(user_id: int, vai_tro: str = "user") -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"vai_tro": vai_tro,
"iat": now,
"exp": now + timedelta(minutes=cau_hinh.jwt_het_han_phut),
}
return jwt.encode(payload, cau_hinh.secret_key, algorithm=cau_hinh.jwt_thuat_toan)
def giai_ma_token(token: str) -> dict:
try:
return jwt.decode(token, cau_hinh.secret_key, algorithms=[cau_hinh.jwt_thuat_toan])
except jwt.ExpiredSignatureError as e:
raise TokenHetHan from e
except jwt.InvalidTokenError as e:
raise TokenKhongHopLe from e
⚠️ Bốn lỗi JWT người mới hay mắc:
- Đặt
expsai múi giờ (phải dùng UTC).- Lưu dữ liệu nhạy cảm trong payload — JWT không được mã hoá, chỉ được ký; ai cũng đọc được.
- Không có cơ chế thu hồi (logout) — cần thêm blacklist hoặc dùng refresh token ngắn hạn.
- Để secret trong code.
🧪 Tuần 11–12: Testing, background task, cache
pip install pytest pytest-asyncio httpx
# tests/conftest.py
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from main import app
from db import Base, lay_session
@pytest_asyncio.fixture
async def session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with maker() as s:
yield s
await engine.dispose()
@pytest_asyncio.fixture
async def client(session):
async def override():
yield session
app.dependency_overrides[lay_session] = override
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
Điểm mấu chốt: app.dependency_overrides cho phép thay database thật bằng database test — đây chính là lợi ích lớn nhất của Dependency Injection.
# tests/test_ghi_chu.py
import pytest
@pytest.mark.asyncio
async def test_danh_sach_rong(client):
r = await client.get("/ghi-chu")
assert r.status_code == 401 # chưa đăng nhập
@pytest.mark.asyncio
async def test_tao_ghi_chu(client, token_hop_le):
r = await client.post(
"/ghi-chu",
json={"tieu_de": "Việc A", "do_uu_tien": 3},
headers={"Authorization": f"Bearer {token_hop_le}"},
)
assert r.status_code == 201
assert r.json()["tieu_de"] == "Việc A"
@pytest.mark.asyncio
@pytest.mark.parametrize("payload,ma_loi", [
({}, 422), # thiếu tieu_de
({"tieu_de": ""}, 422), # rỗng
({"tieu_de": "X" * 201}, 422), # quá dài
({"tieu_de": "A", "do_uu_tien": 5}, 422), # ưu tiên 5 thiếu nội dung
({"tieu_de": "A", "do_uu_tien": 99}, 422), # ngoài khoảng
])
async def test_du_lieu_khong_hop_le(client, token_hop_le, payload, ma_loi):
r = await client.post("/ghi-chu", json=payload,
headers={"Authorization": f"Bearer {token_hop_le}"})
assert r.status_code == ma_loi
Background task
from fastapi import BackgroundTasks
async def gui_email_nhac_viec(email: str, tieu_de: str) -> None:
async with httpx.AsyncClient() as c:
await c.post("https://api.email.com/send", json={"to": email, "subject": tieu_de})
@router.post("/{ghi_chu_id}/nhac-viec", status_code=202)
async def nhac_viec(ghi_chu_id: int, tasks: BackgroundTasks,
session: SessionDep, user: CurrentUser):
gc = await lay_mot(session, ghi_chu_id, user.id)
if gc is None:
raise HTTPException(404, "Không tìm thấy ghi chú")
tasks.add_task(gui_email_nhac_viec, user.email, gc.tieu_de)
return {"thong_diep": "Đã lên lịch gửi nhắc việc"}
⚠️ Giới hạn của
BackgroundTasks: chạy trong cùng process. Nếu server restart, task mất. Với tác vụ quan trọng (thanh toán, gửi hóa đơn), dùng Celery/Redis hoặc ARQ — có retry và lưu trạng thái.
Cache
from fastapi_cache import FastAPICache
from fastapi_cache.backends.redis import RedisBackend
from fastapi_cache.decorator import cache
import redis.asyncio as redis
@app.on_event("startup")
async def khoi_dong_cache():
FastAPICache.init(RedisBackend(redis.from_url("redis://localhost")), prefix="api")
@router.get("/thong-ke")
@cache(expire=300) # cache 5 phút
async def thong_ke(session: SessionDep, user: CurrentUser):
return await tinh_thong_ke(session, user.id)
Chỉ nên cache dữ liệu tốn kém để tính và chấp nhận được cũ vài phút. Đừng cache dữ liệu người dùng vừa thay đổi — họ sẽ bấm F5 và không thấy gì đổi.
🐳 Tuần 13: Docker và deploy
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY --chown=appuser:appuser . .
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH \
PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8000/suc-khoe')"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
Multi-stage build giúp image nhỏ hơn nhiều (không mang theo gcc).
# Deploy lên Render / Railway: chỉ cần start command
uvicorn main:app --host 0.0.0.0 --port $PORT
Checklist production:
-
debug=False -
pool_pre_ping=Truecho database connection - CORS chỉ domain thật
- Rate limit endpoint đăng nhập (
slowapi) - Log có request ID để trace
- Health check endpoint
- Migration chạy trong pipeline, không chạy tay
- Backup database tự động
🎯 Tuần 14: Dự án tổng hợp
API quản lý chi tiêu — đủ nhỏ để hoàn thành, đủ lớn để thể hiện mọi kỹ năng:
✅ Async SQLAlchemy + PostgreSQL + Alembic
✅ Đăng ký/đăng nhập JWT, refresh token
✅ CRUD giao dịch, danh mục
✅ Báo cáo theo tháng (GROUP BY + aggregate)
✅ Phân quyền: user chỉ thấy dữ liệu của mình
✅ Background task gửi báo cáo qua email
✅ Cache báo cáo bằng Redis
✅ Test coverage > 75%
✅ Docker + CI + deploy có link thật
✅ Swagger docs đầy đủ
🔍 Debug hiệu năng async
Đo trước, tối ưu sau
import asyncio
import time
import httpx
URL = "https://api.coingecko.com/api/v3/simple/price"
COINS = ["bitcoin", "ethereum", "solana", "cardano", "polkadot",
"ripple", "dogecoin", "litecoin"]
async def tuan_tu() -> list[dict]:
ket_qua = []
async with httpx.AsyncClient(timeout=10) as c:
for coin in COINS:
r = await c.get(URL, params={"ids": coin, "vs_currencies": "usd"})
ket_qua.append(r.json())
return ket_qua
async def dong_thoi() -> list[dict]:
async with httpx.AsyncClient(timeout=10) as c:
phan_hoi = await asyncio.gather(
*(c.get(URL, params={"ids": coin, "vs_currencies": "usd"}) for coin in COINS)
)
return [r.json() for r in phan_hoi]
async def main():
for ham in (tuan_tu, dong_thoi):
t = time.perf_counter()
await ham()
print(f"{ham.__name__:<10}: {time.perf_counter() - t:.2f}s")
asyncio.run(main())
Với 8 API (mỗi cái ~0,3s), cách tuần tự mất ~2,4s, cách đồng thời mất ~0,3s. Nhanh hơn 8 lần mà không tối ưu gì phức tạp — đây là toàn bộ giá trị của async.
Phát hiện code blocking — nguyên nhân số 1 làm server đứng
import asyncio
import time
import logging
logger = logging.getLogger(__name__)
class PhatHienBlocking:
"""Cảnh báo khi event loop bị chặn quá lâu."""
def __init__(self, nguong_ms: float = 200):
self.nguong = nguong_ms / 1000
self.tac_vu: asyncio.Task | None = None
async def bat_dau(self):
self.tac_vu = asyncio.create_task(self._giam_sat())
async def ket_thuc(self):
if self.tac_vu:
self.tac_vu.cancel()
async def _giam_sat(self):
while True:
truoc = time.perf_counter()
await asyncio.sleep(0.05)
do_tre = time.perf_counter() - truoc - 0.05
if do_tre > self.nguong:
logger.warning(
"⚠️ Event loop bị chặn %.0fms — kiểm tra code blocking!",
do_tre * 1000,
)
Bốn thủ phạm blocking phổ biến nhất:
| Code blocking | Thay bằng |
|---|---|
requests.get(...) | httpx.AsyncClient |
time.sleep(n) | await asyncio.sleep(n) |
psycopg2 | asyncpg / psycopg3 async |
open(path).read() | aiofiles |
bcrypt.hashpw() (rất nặng) | Chạy trong run_in_threadpool |
from fastapi.concurrency import run_in_threadpool
from passlib.context import CryptContext
pwd = CryptContext(schemes=["bcrypt"])
@app.post("/dang-ky")
async def dang_ky(du_lieu: NguoiDungVao, session: SessionDep):
# bcrypt tốn ~250ms CPU — phải đẩy ra threadpool, đừng chặn event loop
mat_khau_bam = await run_in_threadpool(pwd.hash, du_lieu.mat_khau)
...
Profiling — tìm nút thắt cụ thể
import cProfile
import pstats
import io
def profile_ham(ham, *args, **kwargs):
pr = cProfile.Profile()
pr.enable()
ket_qua = ham(*args, **kwargs)
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats("cumulative")
ps.print_stats(15)
print(s.getvalue())
return ket_qua
Với endpoint đơn lẻ, dùng middleware đo thời gian đơn giản và log ra những request chậm hơn ngưỡng — thường đã đủ để tìm ra vấn đề.
📊 Quan sát và vận hành
Cấu trúc log tốt
import logging
import sys
import uuid
from contextvars import ContextVar
from fastapi import Request
request_id: ContextVar[str] = ContextVar("request_id", default="-")
class BoThemRequestId(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id.get()
return True
def cau_hinh_logging():
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter(
'{"time":"%(asctime)s","level":"%(levelname)s",'
'"logger":"%(name)s","request_id":"%(request_id)s","msg":"%(message)s"}'
))
handler.addFilter(BoThemRequestId())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(logging.INFO)
@app.middleware("http")
async def them_request_id(request: Request, call_next):
ma = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
request_id.set(ma)
response = await call_next(request)
response.headers["X-Request-ID"] = ma
return response
Log dạng JSON là chuẩn công nghiệp: dễ parse, dễ tìm kiếm, tích hợp được với mọi hệ thống giám sát.
Metric và health check
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
@app.get("/suc-khoe", tags=["Hệ thống"])
async def suc_khoe(session: SessionDep):
"""Health check cho load balancer — kiểm tra cả database."""
try:
await session.execute(text("SELECT 1"))
except Exception as e:
raise HTTPException(503, f"Database không phản hồi: {e}") from e
return {"trang_thai": "ok"}
Bốn metric quan trọng nhất cần theo dõi:
| Metric | Ngưỡng cảnh báo | Ý nghĩa |
|---|---|---|
http_request_duration_p95 | > 1s | Trải nghiệm người dùng |
http_requests_total{status=~"5.."} | > 1% | Tỷ lệ lỗi server |
db_pool_connections_in_use | > 80% pool | Sắp cạn connection |
event_loop_lag | > 100ms | Có code blocking |
💡 Điểm mấu chốt về health check: chỉ trả
"ok"khi database cũng hoạt động. Nếu health check chỉ kiểm tra process còn sống, load balancer sẽ tiếp tục đẩy traffic vào một instance không truy vấn được database — biến một sự cố nhỏ thành sự cố toàn hệ thống.
⚠️ 7 sai lầm khiến FastAPI app chạy chậm hoặc sập
- Gọi hàm blocking trong
async def— nguyên nhân số 1. Đổi sanghttpx/asyncpghoặc bỏasync. - Không dùng
response_model— vô tình trả dữ liệu nhạy cảm. - Lấy bản ghi rồi kiểm tra quyền bằng Python — phải lọc trong truy vấn.
- Mở session DB cho mỗi hàm — dùng dependency với
yield. - Không đóng connection pool — cạn connection khi tải cao.
- Dùng
BackgroundTaskscho tác vụ quan trọng — mất task khi restart. - Không viết test vì "FastAPI tự validate rồi" — validation không kiểm tra logic nghiệp vụ và phân quyền.
❓ Câu hỏi thường gặp
FastAPI có thay thế được Django không? Cho API thuần: có. Cho web app có giao diện và admin: không — Django mạnh hơn nhiều ở mảng đó. Nhiều công ty dùng cả hai: Django cho back-office, FastAPI cho API public.
FastAPI có ORM không? Không. Dùng SQLAlchemy 2.0 (phổ biến nhất), SQLModel (của cùng tác giả FastAPI, gộp Pydantic + SQLAlchemy), hoặc Tortoise ORM.
Có nên dùng async cho mọi thứ không?
Không. Chỉ dùng khi có I/O chờ (DB, API ngoài, file). Tính toán CPU-bound nên dùng def (FastAPI đẩy vào threadpool) hoặc ProcessPoolExecutor.
Pydantic v1 và v2 khác nhau nhiều không?
Rất nhiều. V2 viết lại bằng Rust, nhanh hơn đáng kể, nhưng API thay đổi: @validator → @field_validator, Config → model_config, .dict() → .model_dump(). Hãy dùng v2 và đọc tài liệu migration nếu gặp code cũ.
Bao lâu để đi làm với FastAPI? Nếu đã biết Python và SQL: 3–5 tháng học nghiêm túc. Nếu học từ đầu: 7–9 tháng.