Chuyển tới nội dung chính

1 bài viết được gắn thẻ "Async"

Xem tất cả thẻ

Lộ trình học Python FastAPI Backend

· 20 phút để đọc

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 khiNên chọn framework khác khi
Làm API cho mobile/SPACần web app có giao diện + admin (→ Django)
Phục vụ model machine learningCầ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ự độngCần CMS/ERP sẵn có
Thích code hiện đại với type hintsDự á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ờ async khi 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ì async và Pydantic thự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ắcVì sao
Không gọi hàm blocking trong async defChặn event loop → toàn bộ server đứng
await mọi coroutineKhô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ùng def và để 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=999 vượt le=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:

  1. 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.
  2. Tài liệu chính xác — Swagger hiển thị đúng schema trả về.
  3. Kiểm tra kiểu — dữ liệu sai sẽ báo lỗi ngay ở server, không tới client.
  4. 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:

  1. Đặt exp sai múi giờ (phải dùng UTC).
  2. 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.
  3. Không có cơ chế thu hồi (logout) — cần thêm blacklist hoặc dùng refresh token ngắn hạn.
  4. Để 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=True cho 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 blockingThay bằng
requests.get(...)httpx.AsyncClient
time.sleep(n)await asyncio.sleep(n)
psycopg2asyncpg / 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:

MetricNgưỡng cảnh báoÝ nghĩa
http_request_duration_p95> 1sTrải nghiệm người dùng
http_requests_total{status=~"5.."}> 1%Tỷ lệ lỗi server
db_pool_connections_in_use> 80% poolSắp cạn connection
event_loop_lag> 100msCó 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​

  1. Gọi hàm blocking trong async def — nguyên nhân số 1. Đổi sang httpx/asyncpg hoặc bỏ async.
  2. Không dùng response_model — vô tình trả dữ liệu nhạy cảm.
  3. Lấy bản ghi rồi kiểm tra quyền bằng Python — phải lọc trong truy vấn.
  4. Mở session DB cho mỗi hàm — dùng dependency với yield.
  5. Không đóng connection pool — cạn connection khi tải cao.
  6. Dùng BackgroundTasks cho tác vụ quan trọng — mất task khi restart.
  7. 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.


📚 Bài viết liên quan​