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

7 bài viết được gắn thẻ "Lộ trình Python"

Xem tất cả thẻ

Từ học sinh đến đi làm: Lộ trình học Python theo từng giai đoạn

· 26 phút để đọc

Python không có một lộ trình duy nhất. Một học sinh cấp 2 cần thứ hoàn toàn khác một kế toán 32 tuổi muốn chuyển ngành, và cả hai lại khác một sinh viên năm 4 đang tìm việc thực tập.

Bài viết này chia lộ trình thành 6 giai đoạn theo độ tuổi và hoàn cảnh, mỗi giai đoạn có: mục tiêu cụ thể, nội dung cần học, thời lượng thực tế, bài tập, dự án để đưa vào portfolio, và tiêu chí để biết mình đã sẵn sàng bước tiếp.

💡 Đọc như thế nào: Đừng đọc hết. Tìm đúng giai đoạn của bạn, đọc phần đó, làm theo 4–8 tuần, rồi quay lại đọc phần tiếp theo. Đọc toàn bộ mà không viết dòng code nào là cách nhanh nhất để quên hết.


📋 Bảng tổng quan 6 giai đoạn​

Giai đoạnĐối tượngThời lượngMục tiêu cuốiSản phẩm đầu ra
1Học sinh cấp 2 (12–15t)3–6 thángTư duy lập trình5–10 chương trình nhỏ
2Học sinh cấp 3 (15–18t)6–12 thángNền tảng vững + định hướng2–3 dự án + chứng chỉ
3Sinh viên năm 1–212–18 thángNền tảng CNTT + Python3–5 dự án GitHub
4Sinh viên năm 3–412 thángSẵn sàng thực tập1 dự án "đủ sâu" + CV
5Người đi làm chuyển ngành9–15 thángCó việc làm đầu tiênPortfolio + phỏng vấn
6Người đi làm nâng cấp6–12 thángTăng lương / đổi vị tríDự án production + chuyên môn sâu

Một sự thật cần nói thẳng: thời lượng trên là thời gian học thật (không phải thời gian kể từ ngày bắt đầu). Học 2 giờ/ngày đều đặn trong 6 tháng hiệu quả hơn 8 giờ/ngày trong 3 tuần rồi bỏ.


🧒 Giai đoạn 1: Học sinh cấp 2 (12–15 tuổi)​

Mục tiêu​

Không phải "thành thạo Python". Mục tiêu duy nhất là xây tư duy lập trình: biết chia một vấn đề thành các bước nhỏ, biết máy tính làm việc theo lệnh tuần tự, và thấy lập trình vui.

Nội dung cần học​

Thứ tựChủ đềThời lượng
1Biến, kiểu dữ liệu, print, input1 tuần
2if/elif/else1 tuần
3for, while1–2 tuần
4List, dict cơ bản1 tuần
5Hàm def1 tuần
6Module random, turtle, time2 tuần
7Xử lý lỗi đơn giản1 tuần

Không cần học: OOP, decorator, async, virtual environment, Git. Những thứ này sẽ tự nhiên đến ở giai đoạn 2.

Ví dụ bài tập phù hợp lứa tuổi​

Trò chơi đoán số — bài tập kinh điển, dạy vòng lặp + điều kiện + nhập liệu:

import random

so_bi_mat = random.randint(1, 100)
so_lan_doan = 0

print("Mình đang nghĩ một số từ 1 đến 100. Bạn đoán thử xem!")

while True:
doan = int(input("Số bạn đoán: "))
so_lan_doan += 1

if doan < so_bi_mat:
print("⬆️ Cao hơn nữa!")
elif doan > so_bi_mat:
print("⬇️ Thấp hơn nữa!")
else:
print(f"🎉 Đúng rồi! Bạn đoán {so_lan_doan} lần.")
break

Hãy để học sinh tự sửa thành: giới hạn 7 lần đoán, gợi ý "gần đúng rồi" khi lệch ≤5, lưu kỷ lục vào file.

Vẽ hình bằng turtle — học sinh thấy kết quả trực quan nên rất hứng thú:

import turtle

man = turtle.Turtle()
man.speed(0)

for i in range(100):
man.forward(i * 2)
man.right(59)

turtle.done()

Chỉ 6 dòng tạo ra hình xoáy đẹp. Thử đổi 59 thành số khác để thấy hình thay đổi — đây là bài học trực giác tuyệt vời về tham số.

5 dự án để làm​

  1. Máy tính bỏ túi — cộng trừ nhân chia, xử lý chia cho 0.
  2. Trò chơi đoán số có giới hạn lượt.
  3. Chương trình học từ vựng tiếng Anh — đọc từ file, hiện nghĩa, làm quiz.
  4. Vẽ hoa văn bằng turtle — dùng vòng lặp lồng nhau.
  5. Trắc nghiệm toán — sinh đề ngẫu nhiên, chấm điểm.

Tiêu chí sẵn sàng sang giai đoạn 2​

  • Tự viết được chương trình 50–80 dòng mà không cần nhìn mẫu.
  • Giải thích được for khác while ở điểm nào.
  • Biết tự tra lỗi bằng cách đọc thông báo lỗi (rất quan trọng!).
  • Không còn sợ màn hình đỏ.

Sai lầm của phụ huynh và giáo viên​

  • ❌ Ép học OOP quá sớm. Trẻ chưa có đủ bài toán để thấy OOP giải quyết vấn đề gì → học vẹt.
  • ❌ Chỉ cho xem, không cho gõ. Xem video 10 tiếng không bằng tự gõ 1 tiếng.
  • ❌ Nhảy sang AI/Data Science. Học sinh cấp 2 chưa cần ML; nền tảng vững quan trọng hơn.
  • ✅ Nên: để trẻ dạy lại cho người khác. Dạy lại là cách kiểm tra hiểu biết tốt nhất.

🎓 Giai đoạn 2: Học sinh cấp 3 (15–18 tuổi)​

Mục tiêu​

Nền tảng vững (đủ để tự học mọi thứ sau này) + bắt đầu chọn hướng đi. Đây cũng là giai đoạn lý tưởng để có sản phẩm đầu tiên trong hồ sơ xét tuyển đại học.

Nội dung cần học​

Bắt buộc:

  • OOP đầy đủ: class, kế thừa, __str__, __init__.
  • Xử lý file: text, CSV, JSON.
  • Xử lý ngoại lệ: try/except/finally, tự tạo exception.
  • Module & package: import, from ... import, if __name__ == "__main__".
  • Git cơ bản — đây là kỹ năng nhiều bạn cấp 3 bỏ qua và hối hận ở đại học.

Nên học thêm:

  • Một trong hai: web (requests, Flask) hoặc dữ liệu (pandas, matplotlib).
  • Cấu trúc dữ liệu & giải thuật cơ bản: list, stack, queue, tìm kiếm, sắp xếp.

Ví dụ: OOP qua bài toán thực tế​

Đừng dạy OOP bằng class Animal. Hãy dạy bằng thứ học sinh quan tâm — ví dụ quản lý điểm:

class HocSinh:
def __init__(self, ten, lop):
self.ten = ten
self.lop = lop
self.diem = {}

def them_diem(self, mon, diem):
if not 0 <= diem <= 10:
raise ValueError("Điểm phải từ 0 đến 10")
self.diem[mon] = diem
return self

@property
def diem_trung_binh(self):
if not self.diem:
return 0
return sum(self.diem.values()) / len(self.diem)

@property
def xep_loai(self):
tb = self.diem_trung_binh
if tb >= 8.0:
return "Giỏi"
if tb >= 6.5:
return "Khá"
if tb >= 5.0:
return "Trung bình"
return "Yếu"

def __str__(self):
return f"{self.ten} ({self.lop}) — TB {self.diem_trung_binh:.2f} — {self.xep_loai}"


hs = HocSinh("Minh", "12A1")
hs.them_diem("Toán", 9).them_diem("Lý", 8).them_diem("Hoá", 7.5)
print(hs)

Ở đây học sinh học được: __init__, method, @property, raise exception, method chaining (them_diem(...).them_diem(...) nhờ return self). Tất cả đều dùng được ngay.

Kiến thức nền khoa học máy tính (nên học song song)​

Chủ đềVì sao quan trọngBài tập
Độ phức tạp O(n)Biết code nào nhanh, code nào chậmSo sánh tìm kiếm tuyến tính vs nhị phân
Stack / QueueNền tảng của mọi thuật toánKiểm tra ngoặc đúng/sai
Sắp xếpHiểu cách máy tính tổ chức dữ liệuCài bubble sort rồi so với sorted()
Đệ quyTư duy chia nhỏ vấn đềTính giai thừa, Fibonacci, tháp Hà Nội

Dự án để đưa vào hồ sơ xét tuyển​

  1. Ứng dụng học từ vựng hoàn chỉnh (đọc/ghi JSON, lưu tiến độ, quiz).
  2. Web app nhỏ bằng Flask — ví dụ tra cứu điểm chuẩn đại học.
  3. Công cụ phân tích dữ liệu — lấy dữ liệu công khai, vẽ biểu đồ, rút ra nhận xét.
  4. Game 2D bằng pygame.
  5. Bot Telegram/Discord đơn giản.

Cuộc thi để thử sức​

  • Học sinh giỏi Tin học các cấp (nếu bạn theo hướng thuật toán).
  • Olympic Tin học / ICPC (khi lên đại học).
  • Kaggle — tham gia competition cho người mới.
  • Hackathon ở trường hoặc trực tuyến.

Tiêu chí sẵn sàng sang giai đoạn 3​

  • Viết được class có nhiều method, biết khi nào nên dùng OOP.
  • Dùng Git: add, commit, push, tạo repo trên GitHub.
  • Đọc hiểu tài liệu tiếng Anh đơn giản (docs Python, Stack Overflow).
  • Có ít nhất 1 dự án hoàn chỉnh trên GitHub (không phải bài tập sao chép).

🏫 Giai đoạn 3: Sinh viên năm 1–2​

Mục tiêu​

Học Python song song với nền tảng CNTT của trường: cấu trúc dữ liệu, giải thuật, cơ sở dữ liệu, mạng máy tính. Đây là giai đoạn vàng — thời gian nhiều nhất, ít áp lực nhất.

Điều quan trọng nhất giai đoạn này​

Đừng học framework trước khi hiểu nền tảng. Rất nhiều sinh viên năm 2 nhảy thẳng vào React/Django rồi 2 năm sau vẫn không giải thích được index trong database hoạt động ra sao.

Thứ tự đúng:

Năm 1:       Python cơ bản → Cấu trúc dữ liệu → Giải thuật → OOP
Năm 1 (học kỳ 2): SQL → Git → Linux cơ bản
Năm 2: Web/API → Database nâng cao → Testing → Một framework
Năm 2 (học kỳ 2): Docker → CI/CD → Dự án nhóm

Nội dung chi tiết​

Học kỳ 1 — Nền tảng:

  • Cấu trúc dữ liệu: list, dict, set, tuple — và độ phức tạp của từng thao tác.
  • Giải thuật: sort, search, đệ quy, quy hoạch động cơ bản.
  • OOP nâng cao: kế thừa, đa hình, abstract class, magic methods.
from abc import ABC, abstractmethod


class Hinh(ABC):
@abstractmethod
def dien_tich(self):
...

def __str__(self):
return f"{type(self).__name__} — diện tích {self.dien_tich():.2f}"


class HinhTron(Hinh):
def __init__(self, r):
self.r = r

def dien_tich(self):
return 3.14159 * self.r ** 2


class HinhChuNhat(Hinh):
def __init__(self, dai, rong):
self.dai = dai
self.rong = rong

def dien_tich(self):
return self.dai * self.rong


for h in [HinhTron(3), HinhChuNhat(4, 5)]:
print(h) # đa hình qua __str__ + dien_tich

Học kỳ 2 — Dữ liệu & công cụ:

  • SQL: SELECT, JOIN, GROUP BY, INDEX, transaction.
  • Python + SQL:
import sqlite3

with sqlite3.connect("shop.db") as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS san_pham (
id INTEGER PRIMARY KEY,
ten TEXT NOT NULL,
gia REAL NOT NULL
)
""")
conn.executemany(
"INSERT INTO san_pham (ten, gia) VALUES (?, ?)",
[("Bàn phím", 250000), ("Chuột", 150000), ("Màn hình", 2500000)],
)
conn.commit()

# Luôn dùng tham số hoá (?) — KHÔNG nối chuỗi SQL
for ten, gia in conn.execute("SELECT ten, gia FROM san_pham WHERE gia > ?", (200000,)):
print(f"{ten}: {gia:,.0f} đ")

⚠️ Cảnh báo bảo mật: không bao giờ viết f"SELECT * FROM users WHERE name = '{name}'". Đó là lỗ hổng SQL Injection kinh điển. Luôn dùng tham số ?.

Học kỳ 3 — Web & Testing:

  • REST API, HTTP method, status code.
  • Một framework: Flask hoặc FastAPI.
  • Testing với pytest:
import pytest
from shop import tinh_giam_gia


def test_giam_gia_binh_thuong():
assert tinh_giam_gia(100000, 10) == 90000


def test_giam_gia_bien():
assert tinh_giam_gia(0, 10) == 0


def test_giam_gia_am_thi_loi():
with pytest.raises(ValueError):
tinh_giam_gia(-1000, 10)

Viết test làm bạn khác biệt. 90% sinh viên ra trường chưa từng viết một test nào.

5 dự án cho portfolio sinh viên​

  1. API quản lý chi tiêu — FastAPI + PostgreSQL + JWT + test.
  2. Web scraping + dashboard — thu thập dữ liệu, lưu DB, hiển thị Streamlit.
  3. CLI tool đóng gói được bằng pipx (ví dụ: quản lý ghi chú).
  4. Bot Discord/Telegram có database.
  5. Dự án nhóm 3–4 người — quan trọng nhất, vì dạy bạn làm việc nhóm và Git branch.

Tiêu chí sẵn sàng sang giai đoạn 4​

  • Giải thích được 3 lỗi SQL injection / XSS / CSRF.
  • Viết được API CRUD có authentication.
  • Có 3–5 repo trên GitHub, mỗi repo có README tử tế.
  • Biết dùng git branch, merge, xử lý conflict.

💼 Giai đoạn 4: Sinh viên năm 3–4 và thực tập​

Mục tiêu​

Chuyển từ "biết code" sang "làm được việc". Nhà tuyển dụng không hỏi bạn biết gì — họ hỏi bạn đã làm gì.

Điều khác biệt lớn nhất​

Năm 1–2Năm 3–4
Học để biếtHọc để làm
Dự án cá nhân nhỏDự án đủ sâu, có người dùng
Code chạy đượcCode dễ đọc, có test, có tài liệu
Chỉ dùng Git cơ bảnLàm việc nhánh, review code
Không quan tâm hiệu năngBiết đo và tối ưu

Nội dung cần bổ sung​

1. Viết code "production-ready":

# ❌ Sinh viên năm 2
def get_user(id):
import sqlite3
conn = sqlite3.connect("db.sqlite")
cur = conn.execute(f"SELECT * FROM users WHERE id = {id}")
return cur.fetchone()

# ✅ Sinh viên năm 4
import logging
from dataclasses import dataclass

logger = logging.getLogger(__name__)


@dataclass
class User:
id: int
ten: str
email: str


def lay_user_theo_id(user_id: int) -> User | None:
"""Lấy user theo id, trả None nếu không tồn tại."""
if user_id <= 0:
raise ValueError(f"user_id phải dương, nhận {user_id}")

with get_connection() as conn:
row = conn.execute(
"SELECT id, ten, email FROM users WHERE id = ?", (user_id,)
).fetchone()

if row is None:
logger.info("Không tìm thấy user id=%s", user_id)
return None

return User(*row)

Sự khác biệt: type hint, docstring, không SELECT *, tham số hoá, logging, xử lý trường hợp biên.

2. Docker — bắt buộc phải biết:

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN useradd -m appuser && chown -R appuser /app
USER appuser

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

3. CI/CD cơ bản — GitHub Actions chạy test tự động mỗi lần push:

name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest -v

Chỉ file này thôi đã khiến CV bạn khác biệt rõ rệt.

Tìm thực tập: chiến lược thực tế​

  1. Bắt đầu sớm — từ đầu học kỳ 1 năm 3, không đợi đến kỳ cuối.
  2. Nhắm đúng công ty nhỏ trước — startup nhận thực tập sinh dễ hơn và cho làm việc thật.
  3. Viết CV theo dự án, không theo môn học. "Xây API quản lý chi tiêu, 12 endpoint, có test coverage 80%" mạnh hơn "Học môn Lập trình Web: 8.5".
  4. Đóng góp open source — kể cả sửa lỗi chính tả trong docs cũng đáng, vì nó chứng minh bạn biết Git workflow.
  5. Mạng lưới — tham gia meetup, group Python/Django/FastAPI Việt Nam.

Tiêu chí sẵn sàng đi làm​

  • Có 1 dự án đủ sâu (không phải 10 dự án nông).
  • Giải thích được mọi dòng code trong dự án của bạn.
  • Biết debug khi không có Stack Overflow (đọc traceback, đọc source).
  • Đã từng làm việc nhóm với Git.
  • Trả lời được câu: "Điểm yếu lớn nhất trong dự án của bạn là gì?"

🔄 Giai đoạn 5: Người đi làm chuyển ngành (25–40 tuổi)​

Thực tế cần chấp nhận​

Bạn có ít thời gian hơn sinh viên, nhưng có lợi thế lớn: kinh nghiệm ngành cũ. Kế toán hiểu nghiệp vụ tài chính, nhân sự hiểu quy trình HR, y tế hiểu dữ liệu lâm sàng. Đây là lợi thế cạnh tranh thật, hãy khai thác.

Lộ trình 12 tháng thực tế (2 giờ/ngày)​

ThángNội dungSản phẩm
1Python cơ bản: biến, điều kiện, vòng lặp10 bài tập nhỏ
2Hàm, list/dict, xử lý file CSVCông cụ xử lý file Excel
3OOP cơ bản, module, pip, virtualenvRefactor lại code tháng 2
4pandas — đọc/lọc/nhóm dữ liệuPhân tích file dữ liệu thật của bạn
5matplotlib/plotly + báo cáo tự độngBáo cáo tự động gửi email
6SQL cơ bản + Python kết nối DBTruy vấn dữ liệu công ty (bản giả lập)
7Git + GitHubĐưa 3 dự án lên GitHub
8FastAPI hoặc Flask cơ bảnAPI đơn giản
9Streamlit/DashDashboard tương tác
10Testing + DockerĐóng gói dự án
11Dự án tổng hợp "đủ sâu"1 sản phẩm hoàn chỉnh
12CV, portfolio, luyện phỏng vấnỨng tuyển

Chiến lược chọn hướng chuyển ngành​

Đừng chọn hướng "hot nhất". Chọn hướng giao thoa giữa Python và ngành cũ của bạn:

Ngành cũHướng chuyển hiệu quả nhấtVì sao
Kế toán / Tài chínhData Analyst tài chínhHiểu nghiệp vụ, chỉ thiếu công cụ
Nhân sựHR Analytics / AutomationTự động hoá báo cáo nhân sự
MarketingMarketing Analytics / Web scrapingĐo lường hiệu quả chiến dịch
Y tế / DượcHealth Data AnalystHiểu dữ liệu lâm sàng — rất khan hiếm
Giáo viênEdTech / Data Analyst giáo dụcHiểu người học
LogisticsSupply chain analyticsTối ưu tồn kho, vận chuyển
Kiểm thử thủ công (QA)Automation Test EngineerBước chuyển tự nhiên nhất, chỉ cần Python + Selenium/Playwright

💡 Chuyển nội bộ luôn dễ hơn chuyển ra ngoài. Nếu công ty hiện tại có team data/IT, hãy xin chuyển ngang. Bạn đã hiểu sản phẩm — đó là thứ khó dạy nhất.

Ví dụ: tự động hoá ngay công việc hiện tại​

Đây là cách tốt nhất để học. Ví dụ một kế toán phải tổng hợp 12 file Excel mỗi tháng:

from pathlib import Path
import pandas as pd

THU_MUC = Path("bao-cao-2026")
tong_hop = []

for file in sorted(THU_MUC.glob("*.xlsx")):
df = pd.read_excel(file, sheet_name="Sheet1")
df["nguon"] = file.stem
tong_hop.append(df)
print(f"✓ {file.name}: {len(df)} dòng")

if not tong_hop:
raise SystemExit("Không tìm thấy file nào!")

ket_qua = pd.concat(tong_hop, ignore_index=True)
ket_qua = ket_qua.drop_duplicates(subset=["ma_don", "ngay"])

tong_theo_thang = (
ket_qua.assign(thang=pd.to_datetime(ket_qua["ngay"]).dt.to_period("M"))
.groupby("thang")
.agg(so_don=("ma_don", "nunique"), doanh_thu=("so_tien", "sum"))
.reset_index()
)

tong_theo_thang.to_excel("tong-hop.xlsx", index=False)
print(f"\n✅ Đã tổng hợp {len(ket_qua)} dòng → tong-hop.xlsx")

Công việc mất 2 ngày nay chỉ còn 10 giây. Đây chính là dự án portfolio mạnh nhất — vì nó giải quyết vấn đề thật và bạn giải thích được mọi chi tiết.

Sai lầm phổ biến của người chuyển ngành​

  • ❌ Học quá rộng. Chọn 1 hướng, học sâu.
  • ❌ Cố học hết trước khi làm dự án. Ngược lại: làm dự án rồi học cái thiếu.
  • ❌ Không dùng kinh nghiệm ngành cũ. Đó là vũ khí của bạn.
  • ❌ Sợ "già rồi". Tuổi không phải vấn đề nếu portfolio tốt; thiếu sản phẩm mới là vấn đề.
  • ✅ Nên: nói với sếp về việc tự động hoá — nhiều người được tăng lương/công nhận trước khi chuyển hẳn.

📈 Giai đoạn 6: Người đi làm muốn nâng cấp​

Khi nào bạn ở giai đoạn này​

Bạn đã đi làm với Python 2–5 năm, muốn: tăng lương, lên senior, hoặc chuyển sang vị trí chuyên sâu (Data Engineer, MLOps, Solution Architect).

So sánh mức độ​

CấpCần gìThể hiện ở đâu
JuniorViết được code chạyTicket hoàn thành
MidCode sạch, có test, tự debugÍt bug, review nhẹ
SeniorThiết kế hệ thống, dẫn dắt người khácQuyết định kiến trúc, mentoring
LeadChiến lược kỹ thuật, giao tiếp stakeholderRoadmap, tầm nhìn

Bốn trục cần phát triển​

1. Hiệu năng & tối ưu:

import time
import numpy as np
from functools import lru_cache

# ❌ Tính lặp lại — chậm
def tinh_tong_cham(n):
return sum(i * i for i in range(n))

# ✅ Cache khi hàm thuần tuý và gọi nhiều lần
@lru_cache(maxsize=128)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)

# ✅ Vector hoá — nhanh hơn vòng lặp Python hàng chục lần
def tinh_tong_nhanh(n):
arr = np.arange(n)
return int((arr * arr).sum())

for ham, ten in [(tinh_tong_cham, "vòng lặp"), (tinh_tong_nhanh, "numpy")]:
t = time.perf_counter()
ham(1_000_000)
print(f"{ten}: {time.perf_counter() - t:.4f}s")

2. Kiến trúc hệ thống: hiểu khi nào dùng queue (Celery, RQ), khi nào cache (Redis), khi nào message broker (RabbitMQ, Kafka), cách thiết kế idempotent API.

3. Chất lượng & quan sát: structured logging, metric (Prometheus), tracing (OpenTelemetry), alerting.

4. Con người: code review tử tế, viết RFC/tài liệu thiết kế, mentoring junior, giao tiếp với team không kỹ thuật.

Cách chứng minh mình xứng đáng lên senior​

  • Viết tài liệu thiết kế cho một tính năng lớn và bảo vệ được trước team.
  • Giải quyết một vấn đề đau đầu mà chưa ai dám đụng (nợ kỹ thuật, test flaky, deploy chậm).
  • Nâng chuẩn cả team: thêm linting, CI, template PR, pre-commit hook.
  • Mentor ít nhất 1 junior và giúp họ tiến bộ rõ rệt.
  • Đo lường và kể lại bằng số: "giảm thời gian deploy từ 25 phút xuống 4 phút", "giảm 40% request lỗi".

Không ai được thăng chức vì "code giỏi". Người ta được thăng vì tạo ảnh hưởng vượt ra ngoài công việc cá nhân.


🧭 Chọn nhánh chuyên môn​

Học xong nền tảng, bạn cần chọn nhánh. So sánh 6 nhánh phổ biến:

NhánhCông cụ chínhMức độ khóNhu cầu thị trườngLương khởi điểm
BackendFlask/Django/FastAPI, SQL, DockerTrung bình⭐⭐⭐⭐⭐ Rất caoTrung bình–cao
Data Analystpandas, SQL, BI toolsThấp–TB⭐⭐⭐⭐⭐ Rất caoTrung bình
Data EngineerAirflow, Spark, Kafka, cloudCao⭐⭐⭐⭐ CaoCao
ML / AI Engineerscikit-learn, PyTorch, MLOpsCao⭐⭐⭐⭐ CaoCao
Automation / QAPlaywright, Selenium, CIThấp⭐⭐⭐⭐ CaoTrung bình
DevOps / CloudDocker, K8s, Terraform, AWSCao⭐⭐⭐⭐⭐ Rất caoRất cao

Cách chọn nhanh bằng 3 câu hỏi:

  1. Bạn thích logic và hệ thống hay dữ liệu và phân tích? → Backend/DevOps hay Data/AI.
  2. Bạn muốn thấy kết quả ngay hay chấp nhận dự án dài? → Automation/Data Analyst hay Data Engineer/MLOps.
  3. Bạn giỏi toán không? → Nếu không thích toán, tránh ML và chọn Backend/Data Analyst/DevOps.

🗓 Thời khoá biểu 12 tuần mẫu (cho người mới, 1.5 giờ/ngày)​

TuầnChủ đềBài tập
1Cài đặt, biến, kiểu, input/output10 bài tính toán
2if/else, toán tử logicMáy tính, phân loại điểm
3for, while, rangeVẽ hình, bảng cửu chương
4List, dict, set, tupleQuản lý danh bạ bằng list
5Hàm, tham số, giá trị trả vềChia nhỏ bài tuần 4 thành hàm
6Ôn tập + dự án nhỏ #1Trò chơi/tiện ích CLI
7Xử lý file: text, CSV, JSONĐọc file điểm, xuất báo cáo
8Ngoại lệ, try/except, validationLàm cho dự án #1 không crash
9OOP: class, kế thừa, __str__Chuyển dự án #1 sang class
10Module, pip, virtualenvTách dự án thành nhiều file
11Git & GitHubĐưa tất cả lên GitHub có README
12Dự án nhỏ #2 + ôn tậpCông cụ tự động hoá của bạn

Nhịp học mẫu mỗi ngày (90 phút):

  • 10 phút: xem lại bài hôm qua (không mở đáp án).
  • 25 phút: học khái niệm mới.
  • 45 phút: gõ code — bắt buộc, không copy-paste.
  • 10 phút: ghi chú 3 điều học được + 1 điều chưa hiểu.

⚠️ 10 sai lầm phổ biến nhất​

  1. Học mà không gõ. Xem 20 giờ video = 0 kỹ năng.
  2. Nhảy cóc. Học Django khi chưa hiểu hàm.
  3. Tutorial hell. Làm hết tutorial này sang tutorial khác, không tự làm gì.
  4. Không dùng Git từ đầu. Đến lúc cần thì phải làm quen ngược.
  5. Sợ lỗi. Lỗi là thông tin, không phải thất bại.
  6. Học một mình. Không hỏi, không review, không biết mình sai ở đâu.
  7. So sánh với người khác. Người ta học 3 năm, bạn học 3 tháng.
  8. Chờ "đủ giỏi mới làm dự án". Không bao giờ có ngày đó.
  9. Chỉ học, không viết. Viết lại là cách học hiệu quả nhất.
  10. Bỏ giữa chừng. Đây là sai lầm phổ biến nhất và cũng tốn kém nhất.

💼 Xây portfolio thuyết phục​

Công thức 1 dự án tốt:

Vấn đề thật  +  Giải pháp kỹ thuật rõ ràng  +  Số liệu kết quả  +  README tử tế

README nên có:

  • 1 câu mô tả dự án làm gì.
  • ảnh chụp màn hình (bắt buộc — người xem không cài đặt dự án của bạn).
  • Hướng dẫn chạy trong 3 lệnh.
  • Công nghệ dùng và lý do chọn.
  • Khó khăn gặp phải và cách giải quyết ← phần nhà tuyển dụng đọc kỹ nhất.

Nên: 1 dự án sâu hơn 10 dự án nông. Một API có 15 endpoint, có test, có CI, có Docker mạnh hơn 10 bài clone todo list.


🎤 Chuẩn bị phỏng vấn theo giai đoạn​

Giai đoạnTrọng tâmCâu hỏi điển hình
Thực tập sinhNền tảng + thái độ"Giải thích list vs tuple", "Em tự học thế nào?"
JuniorPython + SQL + dự án"Vì sao dùng dict thay vì list ở đây?"
MidThiết kế + chất lượng"Em test API này thế nào?", "Xử lý race condition ra sao?"
SeniorKiến trúc + ảnh hưởng"Thiết kế hệ thống X", "Em từng dẫn dắt ai chưa?"

Bộ câu hỏi Python hay gặp:

  • List và tuple khác nhau thế nào? Khi nào dùng cái nào?
  • is khác == ở điểm nào?
  • Shallow copy và deep copy — phân biệt ra sao?
  • Vì sao tham số mặc định def f(x=[]) là lỗi kinh điển?
  • Decorator hoạt động thế nào? Viết một cái đo thời gian.
  • Generator khác list ở đâu? Khi nào dùng yield?
  • GIL là gì và ảnh hưởng gì đến đa luồng?
  • Xử lý 1 triệu dòng CSV mà không tràn RAM như thế nào?

❓ Câu hỏi thường gặp​

Bao lâu thì có việc làm với Python? Với người học nghiêm túc 2 giờ/ngày: 9–12 tháng để có việc đầu tiên. Nếu bạn đã có nền tảng CNTT, có thể 4–6 tháng cho vị trí junior. Bất kỳ ai hứa "3 tháng có việc lương 20 triệu" đều đang bán giấc mơ.

Có cần học đại học CNTT không? Không bắt buộc cho mọi vị trí, nhưng giúp rất nhiều. Nếu không học đại học, bạn phải bù bằng portfolio thật tốt + đóng góp open source + mạng lưới quan hệ.

Nên học Python 2 hay Python 3? Python 3. Python 2 đã ngừng hỗ trợ từ 2020, không còn lý do gì để học.

Nên chọn Flask, Django hay FastAPI? Cả ba. Học Flask trước để hiểu web hoạt động ra sao, rồi Django nếu cần làm web lớn, FastAPI nếu làm API. Biết một cái thì học hai cái còn lại rất nhanh.

Nên học trước toán hay code? Với backend, automation, data analyst: code trước, toán sau (nếu cần). Với ML/AI: cần toán (đại số tuyến tính, xác suất, giải tích) — nhưng hãy học song song, đừng chờ học xong toán mới code.

Học Python xong có bị AI thay thế không? AI thay thế việc gõ code đơn giản, không thay thế người hiểu vấn đề và thiết kế giải pháp. Điều này nghĩa là: nền tảng càng quan trọng, học vẹt càng vô dụng.

Nên học song song mấy thứ một lúc? Một thứ chính, một thứ phụ. Ví dụ: Python (chính) + SQL (phụ). Học 5 thứ song song là công thức của việc bỏ cuộc.


🎯 Kết luận​

Lộ trình đúng không phải là lộ trình nhanh nhất — mà là lộ trình bạn thực sự đi tiếp được.

Ba nguyên tắc áp dụng cho mọi giai đoạn:

  1. Gõ code mỗi ngày. 30 phút mỗi ngày tốt hơn 8 giờ mỗi cuối tuần.
  2. Làm dự án thật. Dự án dạy bạn nhiều hơn 10 khóa học.
  3. Cho người khác xem. GitHub, blog, chia sẻ trong cộng đồng — vì phản hồi từ bên ngoài mới giúp bạn tiến bộ.

Bạn đang ở giai đoạn nào? Hãy chọn đúng phần của bài này, đặt mục tiêu cho 6 tuần tới, rồi bắt đầu ngay hôm nay.


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

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​

Lộ trình học Python Flask Backend

· 20 phút để đọc

Flask là framework web Python nhỏ nhất trong ba framework lớn (Flask, Django, FastAPI). Chính vì nhỏ mà nó là lựa chọn tốt nhất để hiểu web hoạt động ra sao — bạn tự tay lắp từng bộ phận, không có "phép thuật" che đi bản chất.

Nhược điểm của điều đó: bạn phải tự chọn ORM, tự chọn thư viện auth, tự tổ chức project. Đây là lộ trình giúp bạn làm đúng ngay từ đầu, tránh những sai lầm khiến codebase Flask trở thành mớ hỗn độn sau 6 tháng.


🎯 Flask phù hợp với ai?​

Bạn nên học Flask nếuBạn nên chọn framework khác nếu
Muốn hiểu bản chất web trước khi dùng framework lớnCần làm sản phẩm lớn gấp (→ Django)
Làm API nhỏ, microserviceCần hiệu năng cực cao, async native (→ FastAPI)
Cần kiểm soát tuyệt đối từng thành phầnCần admin panel sẵn (→ Django)
Làm prototype nhanhLàm hệ thống phục vụ model ML (→ FastAPI)
Duy trì hệ thống Flask đang có ở công ty—

💡 Lời khuyên thực tế: học Flask trước, rồi học FastAPI hoặc Django sau. Người học Flask trước hiểu web sâu hơn rõ rệt so với người nhảy thẳng vào Django.


🗓 Lộ trình 16 tuần​

Tuần 1–3    Python nền tảng đủ để làm web
Tuần 4–5 Flask cơ bản: route, template, form
Tuần 6–7 Cấu trúc project chuyên nghiệp (Application Factory + Blueprint)
Tuần 8–9 Database: SQLAlchemy + Alembic migration
Tuần 10–11 REST API + validation + error handling
Tuần 12 Xác thực: session và JWT
Tuần 13–14 Testing với pytest
Tuần 15 Docker + deploy production
Tuần 16 Dự án tổng hợp + phỏng vấn

🐍 Tuần 1–3: Python nền tảng cho web​

Bạn không cần biết mọi thứ về Python, nhưng phải chắc những phần sau vì chúng xuất hiện mỗi ngày trong code Flask:

1. Dict — cấu trúc dữ liệu trung tâm của web:

# JSON request/response về bản chất là dict
du_lieu = {"ten": "Minh", "tuoi": 25, "so_thich": ["đọc sách", "chạy bộ"]}

# Các thao tác phải thành thạo
du_lieu.get("email", "chưa có") # lấy có giá trị mặc định
du_lieu.setdefault("diem", 0) # gán nếu chưa tồn tại
{**du_lieu, "tuoi": 26} # tạo dict mới, ghi đè
{k: v for k, v in du_lieu.items() if k != "so_thich"} # dict comprehension

2. Hàm, decorator — Flask dùng decorator ở khắp nơi:

from functools import wraps
import time
import logging

logger = logging.getLogger(__name__)


def do_thoi_gian(f):
"""Decorator đo thời gian chạy — bạn sẽ tự viết nhiều cái như thế này."""
@wraps(f)
def wrapper(*args, **kwargs):
bat_dau = time.perf_counter()
try:
return f(*args, **kwargs)
finally:
thoi_gian = time.perf_counter() - bat_dau
logger.info("%s chạy mất %.4fs", f.__name__, thoi_gian)
return wrapper


@do_thoi_gian
def xu_ly_du_lieu(ban_ghi):
return [r for r in ban_ghi if r["active"]]

Hiểu @wraps và *args/**kwargs là điều kiện để đọc hiểu @app.route, @login_required, @cache.

3. Xử lý ngoại lệ và làm việc với file:

from pathlib import Path
import json

CAU_HINH = Path("config.json")


def doc_cau_hinh() -> dict:
if not CAU_HINH.exists():
return {}
try:
return json.loads(CAU_HINH.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise ValueError(f"config.json không hợp lệ: {e}") from e

Tiêu chí hết tuần 3: viết được script 200 dòng chia thành nhiều hàm, dùng dict/list thành thạo, hiểu decorator đơn giản.


🌱 Tuần 4–5: Flask cơ bản​

python -m venv .venv
.venv\Scripts\activate
pip install flask python-dotenv
# app.py
from flask import Flask, render_template, request, redirect, url_for, flash, session

app = Flask(__name__)
app.secret_key = "đọc-từ-env-trong-thực-tế"

GHI_CHU = []


@app.route("/")
def trang_chu():
return render_template("index.html", ghi_chu=GHI_CHU)


@app.route("/them", methods=["GET", "POST"])
def them_ghi_chu():
if request.method == "POST":
noi_dung = request.form.get("noi_dung", "").strip()

if not noi_dung:
flash("Bạn chưa nhập nội dung", "loi")
elif len(noi_dung) > 500:
flash("Nội dung quá dài (tối đa 500 ký tự)", "loi")
else:
GHI_CHU.append({"id": len(GHI_CHU) + 1, "noi_dung": noi_dung})
flash("Đã thêm ghi chú", "thanh_cong")
return redirect(url_for("trang_chu"))

return render_template("them.html")

Ba cơ chế Flask cần nắm chắc:

Cơ chếVai tròGhi nhớ
url_for()Sinh URL từ tên hàmLuôn dùng thay vì viết URL cứng
render_template()Render Jinja2Template phải trong templates/
redirect() + flash()Chuyển trang + thông báoPattern POST-redirect-GET

Jinja2 — bảng cú pháp đầy đủ:

{% extends "base.html" %}          {# kế thừa layout #}
{% block noi_dung %}{% endblock %} {# định nghĩa vùng ghi đè #}

{{ bien }} {# in giá trị #}
{{ bien|default("N/A") }} {# filter với mặc định #}
{{ gia|round(2) }} {# filter #}

{% if dieu_kien %}...{% elif %}...{% else %}...{% endif %}
{% for item in ds %}...{% endfor %}
{% for item in ds %}{{ loop.index }} — {{ item }}{% endfor %}

{% include "partial.html" %} {# nhúng file #}
{% macro the_hien(x) %}...{% endmacro %} {# macro #}

⚠️ Khác biệt với Django: Jinja2 (Flask) dùng {{ ham() }} được, Django template thì không. Jinja2 cũng không tự escape biến trong một số ngữ cảnh — luôn cẩn thận với |safe.

Tiêu chí hết tuần 5: làm được app CRUD 1 bảng dùng template, có flash message và validate form.


🏗️ Tuần 6–7: Cấu trúc project chuyên nghiệp​

Đây là bước ngoặt phân biệt người viết Flask "đồ chơi" và người viết Flask đi làm.

Sai lầm của 90% người mới​

❌ app.py (2000 dòng, mọi thứ trong đây)

Vấn đề: không test được, không chia việc được, import vòng, không có config môi trường.

Cấu trúc đúng — Application Factory + Blueprint​

project/
├── app/
│ ├── __init__.py ← application factory
│ ├── config.py ← cấu hình theo môi trường
│ ├── extensions.py ← khởi tạo db, migrate, login...
│ ├── models/
│ │ ├── __init__.py
│ │ └── ghi_chu.py
│ ├── blueprints/
│ │ ├── __init__.py
│ │ ├── main.py
│ │ └── api.py
│ ├── templates/
│ │ ├── base.html
│ │ └── main/index.html
│ └── static/
├── migrations/ ← do Alembic sinh tự động
├── tests/
│ ├── conftest.py
│ └── test_ghi_chu.py
├── .env
├── requirements.txt
├── Dockerfile
└── wsgi.py

app/config.py:

import os
from pathlib import Path

GOC = Path(__file__).resolve().parent.parent


class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-key-doi-trong-production")
SQLALCHEMY_TRACK_MODIFICATIONS = False
JSON_SORT_KEYS = False


class DevConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///dev.db")


class TestConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"


class ProdConfig(Config):
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"] # bắt buộc có
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"


CAU_HINH = {"dev": DevConfig, "test": TestConfig, "prod": ProdConfig}

app/extensions.py:

from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_cors import CORS

db = SQLAlchemy()
migrate = Migrate()
cors = CORS()

app/__init__.py:

import os
import logging
from flask import Flask

from .config import CAU_HINH
from .extensions import db, migrate, cors


def create_app(moi_truong: str | None = None) -> Flask:
"""Application factory — luôn dùng pattern này."""
app = Flask(__name__)
moi_truong = moi_truong or os.environ.get("FLASK_ENV", "dev")
app.config.from_object(CAU_HINH[moi_truong])

db.init_app(app)
migrate.init_app(app, db)
cors.init_app(app, resources={r"/api/*": {"origins": "*"}})

from .blueprints.main import bp as main_bp
from .blueprints.api import bp as api_bp
app.register_blueprint(main_bp)
app.register_blueprint(api_bp, url_prefix="/api")

cau_hinh_logging(app)
dang_ky_error_handler(app)

return app


def cau_hinh_logging(app: Flask) -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
)


def dang_ky_error_handler(app: Flask) -> None:
@app.errorhandler(404)
def khong_tim_thay(e):
return {"loi": "Không tìm thấy tài nguyên"}, 404

@app.errorhandler(500)
def loi_server(e):
app.logger.exception("Lỗi 500")
return {"loi": "Lỗi hệ thống"}, 500

app/blueprints/api.py:

from flask import Blueprint, jsonify

bp = Blueprint("api", __name__)


@bp.get("/suc-khoe")
def suc_khoe():
return jsonify({"trang_thai": "ok"})


@bp.get("/ghi-chu")
def danh_sach():
return jsonify({"ghi_chu": []})

Vì sao Application Factory quan trọng:

  1. Test được — mỗi test tạo app riêng với config riêng.
  2. Nhiều môi trường — dev/test/prod dùng cùng code, khác config.
  3. Tránh import vòng — extension khởi tạo riêng, gắn sau.
  4. Chạy nhiều instance — cần cho testing song song.

Tiêu chí hết tuần 7: tạo mới project Flask theo cấu trúc trên trong 15 phút, không cần xem mẫu.


🗄️ Tuần 8–9: Database với SQLAlchemy và Alembic​

pip install flask-sqlalchemy flask-migrate
flask --app wsgi db init # chỉ chạy lần đầu
flask --app wsgi db migrate -m "tạo bảng ghi_chu"
flask --app wsgi db upgrade

app/models/ghi_chu.py:

from datetime import datetime, timezone
from sqlalchemy import String, Text, Boolean, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column

from ..extensions import db


class GhiChu(db.Model):
__tablename__ = "ghi_chu"

id: Mapped[int] = mapped_column(primary_key=True)
tieu_de: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
noi_dung: Mapped[str] = mapped_column(Text, default="")
da_xong: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
ngay_tao: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
ngay_cap_nhat: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(timezone.utc)
)

def to_dict(self) -> dict:
return {
"id": self.id,
"tieu_de": self.tieu_de,
"noi_dung": self.noi_dung,
"da_xong": self.da_xong,
"ngay_tao": self.ngay_tao.isoformat() if self.ngay_tao else None,
}

def __repr__(self) -> str:
return f"<GhiChu {self.id} {self.tieu_de!r}>"

Ba quy tắc vàng về migration:

  1. Không bao giờ sửa file trong migrations/ bằng tay.
  2. Luôn backup trước khi db upgrade trên production.
  3. Mỗi migration = một thay đổi logic. Đừng gộp 5 thay đổi vào 1 migration.

Truy vấn — luôn dùng cách hiện đại (SQLAlchemy 2.0 style):

from sqlalchemy import select
from .extensions import db
from .models.ghi_chu import GhiChu


def lay_tat_ca(chi_chua_xong: bool = False) -> list[GhiChu]:
stmt = select(GhiChu).order_by(GhiChu.ngay_tao.desc())
if chi_chua_xong:
stmt = stmt.where(GhiChu.da_xong.is_(False))
return list(db.session.scalars(stmt))


def tim_theo_tu_khoa(tu_khoa: str) -> list[GhiChu]:
stmt = select(GhiChu).where(GhiChu.tieu_de.ilike(f"%{tu_khoa}%"))
return list(db.session.scalars(stmt))

⚠️ Dùng ilike với tham số — SQLAlchemy tự tham số hoá, an toàn khỏi SQL injection. Nhưng nếu bạn dùng db.session.execute(f"SELECT ... WHERE x = '{value}'") thì đó là lỗ hổng.

Tiêu chí hết tuần 9: tạo model, migrate, CRUD được từ Flask shell, biết dùng ilike, order_by, limit.


🔌 Tuần 10–11: REST API hoàn chỉnh​

# app/blueprints/api.py
from flask import Blueprint, jsonify, request
from marshmallow import Schema, fields, validate, ValidationError

from ..extensions import db
from ..models.ghi_chu import GhiChu

bp = Blueprint("api", __name__)


class GhiChuSchema(Schema):
tieu_de = fields.Str(required=True, validate=validate.Length(min=1, max=200))
noi_dung = fields.Str(load_default="", validate=validate.Length(max=5000))
da_xong = fields.Bool(load_default=False)


schema = GhiChuSchema()
schema_nhieu = GhiChuSchema(many=True)


@bp.get("/ghi-chu")
def danh_sach():
chi_chua_xong = request.args.get("chua_xong", "").lower() in ("1", "true", "yes")
try:
gioi_han = min(int(request.args.get("gioi_han", 50)), 200)
except ValueError:
return jsonify({"loi": "gioi_han phải là số nguyên"}), 400

ds = lay_tat_ca(chi_chua_xong)[:gioi_han]
return jsonify({"du_lieu": schema_nhieu.dump(ds), "so_luong": len(ds)})


@bp.get("/ghi-chu/<int:ghi_chu_id>")
def chi_tiet(ghi_chu_id: int):
gc = db.session.get(GhiChu, ghi_chu_id)
if gc is None:
return jsonify({"loi": f"Không tìm thấy ghi chú id={ghi_chu_id}"}), 404
return jsonify(schema.dump(gc))


@bp.post("/ghi-chu")
def tao_moi():
try:
du_lieu = schema.load(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"loi": "Dữ liệu không hợp lệ", "chi_tiet": e.messages}), 422

gc = GhiChu(**du_lieu)
db.session.add(gc)
db.session.commit()
return jsonify(schema.dump(gc)), 201


@bp.put("/ghi-chu/<int:ghi_chu_id>")
def cap_nhat(ghi_chu_id: int):
gc = db.session.get(GhiChu, ghi_chu_id)
if gc is None:
return jsonify({"loi": "Không tìm thấy"}), 404

try:
du_lieu = schema.load(request.get_json(silent=True) or {}, partial=True)
except ValidationError as e:
return jsonify({"loi": "Dữ liệu không hợp lệ", "chi_tiet": e.messages}), 422

for khoa, gia_tri in du_lieu.items():
setattr(gc, khoa, gia_tri)
db.session.commit()
return jsonify(schema.dump(gc))


@bp.delete("/ghi-chu/<int:ghi_chu_id>")
def xoa(ghi_chu_id: int):
gc = db.session.get(GhiChu, ghi_chu_id)
if gc is None:
return jsonify({"loi": "Không tìm thấy"}), 404
db.session.delete(gc)
db.session.commit()
return "", 204

Điểm mấu chốt cần nhớ:

ViệcCách làm đúngCách làm sai
ValidateMarshmallow/Pydantic → 422Tự viết if rải rác
Trả lỗiJSON có key loi + mã trạng thái đúngTrả 200 kèm success: false
Commit DBCommit một lần, có rollbackCommit nhiều lần trong 1 request
Phân tranggioi_han + offset, có trần tối đaTrả hết 1 triệu bản ghi

🔐 Tuần 12: Xác thực và phân quyền​

pip install flask-login werkzeug pyjwt
from flask_login import UserMixin, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash


class NguoiDung(db.Model, UserMixin):
__tablename__ = "nguoi_dung"

id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(120), unique=True, index=True)
mat_khau_hash: Mapped[str] = mapped_column(String(255))
vai_tro: Mapped[str] = mapped_column(String(20), default="user")

def dat_mat_khau(self, mat_khau: str) -> None:
self.mat_khau_hash = generate_password_hash(mat_khau, method="scrypt")

def kiem_tra_mat_khau(self, mat_khau: str) -> bool:
return check_password_hash(self.mat_khau_hash, mat_khau)

@property
def la_admin(self) -> bool:
return self.vai_tro == "admin"

Ba lỗi bảo mật nghiêm trọng nhất khi làm auth:

  1. Lưu mật khẩu dạng thô hoặc dùng MD5/SHA1. Phải dùng werkzeug.security (scrypt) hoặc bcrypt/argon2.
  2. Không giới hạn số lần đăng nhập sai. Thêm rate limit → chống brute force.
  3. Tin tưởng vai_tro từ client gửi lên. Vai trò phải lấy từ server, không bao giờ từ request body.
# ⚠️ LỖI KINH ĐIỂN — không bao giờ làm thế này
@app.post("/api/xoa")
def xoa():
du_lieu = request.get_json()
if du_lieu.get("vai_tro") == "admin": # ❌ client tự khai mình là admin!
...

🧪 Tuần 13–14: Testing với pytest​

pip install pytest pytest-cov pytest-flask

tests/conftest.py:

import pytest
from app import create_app
from app.extensions import db as _db


@pytest.fixture(scope="session")
def app():
app = create_app("test")
with app.app_context():
_db.create_all()
yield app
_db.drop_all()


@pytest.fixture
def client(app):
return app.test_client()


@pytest.fixture(autouse=True)
def sach_database(app):
"""Mỗi test chạy trên database sạch."""
yield
with app.app_context():
_db.session.rollback()
_db.drop_all()
_db.create_all()

tests/test_ghi_chu.py:

def test_danh_sach_rong(client):
r = client.get("/api/ghi-chu")
assert r.status_code == 200
assert r.get_json()["so_luong"] == 0


def test_tao_ghi_chu(client):
r = client.post("/api/ghi-chu", json={"tieu_de": "Việc cần làm"})
assert r.status_code == 201
assert r.get_json()["tieu_de"] == "Việc cần làm"


def test_tao_thieu_tieu_de(client):
r = client.post("/api/ghi-chu", json={"noi_dung": "abc"})
assert r.status_code == 422
assert "tieu_de" in r.get_json()["chi_tiet"]


def test_lay_khong_ton_tai(client):
r = client.get("/api/ghi-chu/99999")
assert r.status_code == 404


def test_xoa_thanh_cong(client):
tao = client.post("/api/ghi-chu", json={"tieu_de": "X"}).get_json()
r = client.delete(f"/api/ghi-chu/{tao['id']}")
assert r.status_code == 204
assert client.get(f"/api/ghi-chu/{tao['id']}").status_code == 404
pytest -v --cov=app --cov-report=term-missing

Mục tiêu: coverage ≥ 70% cho tầng API. Đừng chạy theo 100% — hãy tập trung test các trường hợp biên: dữ liệu rỗng, giá trị âm, trùng lặp, không có quyền.


🐳 Tuần 15: Docker và deploy​

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn

COPY . .

RUN useradd --create-home appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "--access-logfile", "-", "wsgi:app"]

wsgi.py:

from app import create_app

app = create_app("prod")

if __name__ == "__main__":
app.run()
docker build -t ghi-chu-api .
docker run -p 8000:8000 --env-file .env ghi-chu-api

Gunicorn: bao nhiêu worker?

Số worker = (2 × số CPU core) + 1

Ví dụ server 2 core → 5 worker. Với app nặng I/O (nhiều truy vấn DB), có thể dùng thêm --threads 2.

Checklist trước khi deploy:

  • SECRET_KEY sinh ngẫu nhiên, đọc từ env
  • DEBUG = False
  • Database migration chạy tự động trong pipeline
  • nginx làm reverse proxy + HTTPS
  • Log ra file hoặc dịch vụ log
  • Endpoint /suc-khoe cho load balancer
  • CORS chỉ mở cho domain thật

🎯 Tuần 16: Dự án tổng hợp​

Xây API quản lý ghi chú có người dùng — nhỏ nhưng đầy đủ mọi thứ:

✅ Application factory + Blueprint
✅ PostgreSQL + SQLAlchemy + Alembic
✅ Đăng ký/đăng nhập (JWT hoặc session)
✅ Mỗi user chỉ thấy ghi chú của mình
✅ Tìm kiếm, lọc, phân trang
✅ Test coverage > 70%
✅ Docker + deploy có link thật
✅ README có ảnh chụp + hướng dẫn chạy 3 lệnh

🔍 Debug và quan sát ứng dụng Flask​

Logging đúng cách​

import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path


def cau_hinh_logging(app):
"""Cấu hình logging: vừa ra console vừa ghi file xoay vòng."""
dinh_dang = logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s"
)

console = logging.StreamHandler()
console.setFormatter(dinh_dang)

Path("logs").mkdir(exist_ok=True)
file_handler = RotatingFileHandler(
"logs/app.log", maxBytes=5_000_000, backupCount=5, encoding="utf-8"
)
file_handler.setFormatter(dinh_dang)

app.logger.handlers.clear()
app.logger.addHandler(console)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.DEBUG if app.config["DEBUG"] else logging.INFO)

# Giảm ồn từ thư viện bên thứ ba
logging.getLogger("werkzeug").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)

RotatingFileHandler tự xoay file khi vượt 5 MB và giữ 5 bản cũ — bạn không phải lo log làm đầy đĩa.

Đo thời gian xử lý mỗi request​

import time
import uuid
from flask import g, request


@app.before_request
def bat_dau_do():
g.ma_request = uuid.uuid4().hex[:8]
g.thoi_diem_bat_dau = time.perf_counter()


@app.after_request
def ket_thuc_do(response):
thoi_gian = (time.perf_counter() - g.thoi_diem_bat_dau) * 1000
response.headers["X-Request-ID"] = g.ma_request
app.logger.info(
"[%s] %s %s -> %s (%.1f ms)",
g.ma_request, request.method, request.path, response.status_code, thoi_gian,
)
if thoi_gian > 500:
app.logger.warning("[%s] Request chậm: %s", g.ma_request, request.path)
return response

Header X-Request-ID cho phép bạn lần theo một request cụ thể qua toàn bộ log — vô giá khi debug trên production.

Bảng lỗi Flask hay gặp​

LỗiNguyên nhânCách sửa
TemplateNotFoundSai thư mụcTemplate phải trong templates/ của app
BuildError: Could not build url for endpointSai tên hàm trong url_forKhớp đúng tên hàm Python
Working outside of application contextTruy vấn DB ngoài contextDùng with app.app_context():
Working outside of request contextDùng request ngoài routeTruyền dữ liệu qua tham số
RuntimeError: The session is unavailableThiếu SECRET_KEYĐặt app.secret_key
Circular importImport app trong module khácDùng application factory + blueprint
OperationalError: database is lockedSQLite ghi đồng thờiDùng PostgreSQL cho production
413 Request Entity Too LargeFile upload quá lớnĐặt MAX_CONTENT_LENGTH
# Giới hạn kích thước upload — bảo vệ server khỏi bị làm nghẽn
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 16 MB

🧱 Service layer — tách logic nghiệp vụ khỏi route​

Đây là bước nâng cấp quan trọng nhất để codebase Flask sống được lâu dài.

Vấn đề khi để logic trong route​

# ❌ Route làm quá nhiều việc
@bp.post("/don-hang")
def tao_don_hang():
du_lieu = request.get_json()

# validate
if not du_lieu.get("khach_hang_id"):
return jsonify({"loi": "Thiếu khách hàng"}), 400

# kiểm tra tồn kho
for item in du_lieu["items"]:
sp = db.session.get(SanPham, item["san_pham_id"])
if sp is None or sp.ton_kho < item["so_luong"]:
return jsonify({"loi": f"Hết hàng: {item['san_pham_id']}"}), 409

# tính tiền
tong = sum(item["so_luong"] * item["don_gia"] for item in du_lieu["items"])
giam_gia = tinh_giam_gia(tong, du_lieu.get("ma_giam_gia"))

# tạo bản ghi
dh = DonHang(tong_tien=tong - giam_gia, khach_hang_id=du_lieu["khach_hang_id"])
db.session.add(dh)
db.session.flush()

# trừ tồn kho
for item in du_lieu["items"]:
sp = db.session.get(SanPham, item["san_pham_id"])
sp.ton_kho -= item["so_luong"]

db.session.commit()

# gửi email
gui_email_xac_nhan(dh)

return jsonify(dh.to_dict()), 201

Route này không thể test riêng, không thể tái sử dụng (ví dụ khi nhập đơn từ file Excel), và không thể đọc hiểu khi nghiệp vụ phức tạp thêm.

Cách tách đúng​

services/don_hang.py:

from dataclasses import dataclass
from decimal import Decimal
from sqlalchemy import select, update
from sqlalchemy.orm import Session

from ..extensions import db
from ..models.don_hang import DonHang, ChiTietDonHang
from ..models.san_pham import SanPham
from .exceptions import HetHangError, KhachHangKhongTonTai


@dataclass
class MucDonHang:
san_pham_id: int
so_luong: int
don_gia: Decimal


class DichVuDonHang:
"""Toàn bộ nghiệp vụ đơn hàng nằm ở đây — không phụ thuộc Flask."""

def __init__(self, session: Session):
self.session = session

def tao_don_hang(self, khach_hang_id: int, items: list[MucDonHang],
ma_giam_gia: str | None = None) -> DonHang:
if not items:
raise ValueError("Đơn hàng phải có ít nhất một sản phẩm")

with self.session.begin_nested(): # savepoint — rollback riêng nếu lỗi
self._kiem_tra_ton_kho(items)

tong = sum(m.so_luong * m.don_gia for m in items)
giam_gia = self._tinh_giam_gia(tong, ma_giam_gia)

dh = DonHang(khach_hang_id=khach_hang_id, tong_tien=tong - giam_gia)
self.session.add(dh)
self.session.flush()

for muc in items:
self.session.add(ChiTietDonHang(
don_hang_id=dh.id, san_pham_id=muc.san_pham_id,
so_luong=muc.so_luong, don_gia=muc.don_gia,
))
self.session.execute(
update(SanPham)
.where(SanPham.id == muc.san_pham_id)
.values(ton_kho=SanPham.ton_kho - muc.so_luong)
)

return dh

def _kiem_tra_ton_kho(self, items: list[MucDonHang]) -> None:
for muc in items:
sp = self.session.get(SanPham, muc.san_pham_id)
if sp is None or sp.ton_kho < muc.so_luong:
raise HetHangError(muc.san_pham_id, muc.so_luong)

def _tinh_giam_gia(self, tong: Decimal, ma: str | None) -> Decimal:
if not ma:
return Decimal(0)
# gọi repository mã giảm giá...
return Decimal(0)

Route giờ chỉ còn làm nhiệm vụ HTTP:

@bp.post("/don-hang")
@yeu_cau_dang_nhap
def tao_don_hang():
"""HTTP: parse → gọi service → format response. Không chứa nghiệp vụ."""
try:
du_lieu = schema.load(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"loi": "Dữ liệu không hợp lệ", "chi_tiet": e.messages}), 422

dich_vu = DichVuDonHang(db.session)
try:
dh = dich_vu.tao_don_hang(
khach_hang_id=g.nguoi_dung.id,
items=[MucDonHang(**m) for m in du_lieu["items"]],
ma_giam_gia=du_lieu.get("ma_giam_gia"),
)
db.session.commit()
except HetHangError as e:
db.session.rollback()
return jsonify({"loi": str(e)}), 409

gui_email_xac_nhan(dh) # hoặc đẩy vào Celery
return jsonify(dh.to_dict()), 201

Ba lợi ích cụ thể:

  1. Test được — DichVuDonHang không cần HTTP client, không cần Flask app. Test thuần Python, chạy trong mili-giây.
  2. Tái sử dụng được — cùng service dùng cho API, cho trang admin, cho script nhập đơn từ Excel.
  3. Đọc hiểu được — người mới vào dự án đọc DichVuDonHang.tao_don_hang là hiểu toàn bộ nghiệp vụ, không cần lần theo route.
# Test thuần Python — không cần Flask, không cần HTTP
def test_tao_don_hang_het_hang(session):
sp = SanPham(ten="Bàn phím", gia=Decimal("250000"), ton_kho=1)
session.add(sp)
session.flush()

dich_vu = DichVuDonHang(session)
with pytest.raises(HetHangError):
dich_vu.tao_don_hang(1, [MucDonHang(sp.id, 5, Decimal("250000"))])

⚠️ 7 sai lầm khiến code Flask thành mớ hỗn độn​

  1. Mọi thứ trong một file app.py. Sau 1000 dòng là không cứu được nữa.
  2. Không dùng application factory. Không test được, không có config theo môi trường.
  3. Truy vấn DB trong route. Tách ra service/repository.
  4. Không dùng migration. db.create_all() ở production là thảm hoạ.
  5. Bắt Exception chung chung. Che giấu bug thật.
  6. Hard-code secret. Một lần push lên GitHub là phải đổi hết.
  7. Không viết test. Bạn sẽ sợ sửa code, và sợ sửa code nghĩa là dự án chết.

❓ Câu hỏi thường gặp​

Flask có đủ dùng cho dự án lớn không? Có. Pinterest dùng Flask ở quy mô rất lớn. Nhưng với team đông, bạn cần kỷ luật về cấu trúc (application factory, blueprint, service layer) — đó chính là lý do nhiều team chọn Django: nó ép bạn theo cấu trúc đúng.

Flask có hỗ trợ async không? Có (từ 2.0), nhưng không phải điểm mạnh. Nếu bạn cần async native, FastAPI phù hợp hơn. Flask dùng asyncio cần cẩn thận vì vẫn chạy trên WSGI.

Nên dùng SQLAlchemy hay viết SQL thuần? Với dự án thật: SQLAlchemy. Nhưng phải học SQL trước, vì bạn sẽ cần debug query sinh ra và viết query phức tạp.

Flask-Login hay JWT? Session (Flask-Login) cho web app có giao diện. JWT cho API phục vụ mobile/SPA. Không có cái nào "tốt hơn" — phụ thuộc ngữ cảnh.

Bao lâu để đi làm được với Flask? 4–6 tháng học nghiêm túc (2 giờ/ngày) nếu bạn đã có nền Python. Nếu học từ đầu: 8–10 tháng.


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

Lộ trình học Python cho người học Data Science / AI

· 29 phút để đọc

Data Science và AI là hai lĩnh vực bị hiểu sai nhiều nhất trong ngành công nghệ. Người ngoài nghĩ đây là công việc "dạy máy tính thông minh". Thực tế, 70–80% thời gian là làm sạch dữ liệu và hiểu vấn đề — phần xây model chỉ chiếm phần nhỏ.

Bài viết này là lộ trình 24 tuần, nói thẳng về những gì cần học và những gì không cần.


🎯 Trước khi bắt đầu: ba sự thật cần biết​

1. Toán quan trọng, nhưng không phải toán thi đại học. Bạn cần đại số tuyến tính (ma trận, vector), xác suất thống kê, và giải tích cơ bản (đạo hàm). Bạn không cần giải tích phức, phương trình vi phân, hay hình học vi phân (trừ khi làm research thuần).

2. Data Science ≠ AI Engineer ≠ ML Engineer.

Vị tríCông việc chínhCông cụ
Data AnalystTrả lời câu hỏi kinh doanhSQL, pandas, BI tools
Data ScientistPhân tích, thí nghiệm, xây model dự đoánPython, scikit-learn, thống kê
ML EngineerĐưa model vào sản phẩm, scalePyTorch, Docker, MLOps, cloud
AI ResearchNghiên cứu thuật toán mớiToán nặng, PyTorch, đọc paper
AI Application DevXây app dùng LLM/APIPython, prompt engineering, RAG

Đa số việc làm ở Việt Nam nằm ở Data Analyst → Data Scientist → ML Engineer. Research thuần rất ít và thường yêu cầu bằng tiến sĩ.

3. Thị trường đã thay đổi.

Với sự bùng nổ của LLM, công việc "train model từ đầu" giảm mạnh. Nhu cầu tăng ở:

  • MLOps — đưa model vào production, giám sát, cập nhật.
  • AI application — dùng LLM API, RAG, fine-tuning nhẹ.
  • Data engineering — hạ tầng dữ liệu cho AI.

💡 Lời khuyên thực tế: nếu bạn muốn làm AI, hãy học kỹ thuật phần mềm + dữ liệu thật tốt, rồi thêm AI. Người chỉ biết gọi model.fit() mà không biết deploy, không biết xử lý dữ liệu, sẽ rất khó có việc.


🗓 Lộ trình 24 tuần​

Tuần 1–4     Python cho khoa học dữ liệu: numpy, pandas
Tuần 5–7 Toán nền tảng: đại số tuyến tính, xác suất, giải tích
Tuần 8–10 Phân tích dữ liệu & trực quan hoá
Tuần 11–15 Machine Learning với scikit-learn
Tuần 16–19 Deep Learning với PyTorch
Tuần 20 NLP và LLM cơ bản
Tuần 21 MLOps: đóng gói và triển khai model
Tuần 22–23 Dự án portfolio
Tuần 24 Phỏng vấn và định hướng

🐍 Tuần 1–4: Python cho khoa học dữ liệu​

pip install numpy pandas matplotlib seaborn jupyter
jupyter lab

NumPy — nền tảng của mọi thứ​

import numpy as np

# Mảng và hình dạng
a = np.array([[1, 2, 3], [4, 5, 6]]) # shape (2, 3)
print(a.shape, a.dtype, a.ndim)

# Vector hoá — nhanh hơn vòng lặp Python hàng chục lần
arr = np.arange(1_000_000)
print((arr * 2 + 1).sum())

# Broadcasting — quy tắc then chốt
diem = np.array([[8, 7, 9], [6, 8, 7]]) # (2, 3) — 2 học sinh, 3 môn
he_so = np.array([1.0, 1.5, 2.0]) # (3,) — tự broadcast
print(diem * he_so) # (2, 3)

# Thống kê dọc theo trục
print(diem.mean(axis=1)) # điểm TB của từng học sinh → [8.0, 7.0]
print(diem.mean(axis=0)) # điểm TB của từng môn → [7.0, 7.5, 8.0]

# Tạo dữ liệu giả để thử nghiệm
rng = np.random.default_rng(42) # seed để tái lập kết quả
du_lieu = rng.normal(loc=100, scale=15, size=(1000, 5))

# Đại số tuyến tính — nền tảng của ML
X = np.array([[1, 2], [3, 4], [5, 6]])
print(X.T) # chuyển vị
print(X @ X.T) # nhân ma trận
print(np.linalg.inv(np.array([[2.0, 1.0], [1.0, 3.0]])))

⚠️ Quy tắc số 1 của NumPy: không bao giờ lặp qua từng phần tử. Mọi thứ đều vector hoá được, và vector hoá nhanh hơn 10–100 lần.

pandas — công cụ làm việc hàng ngày​

import pandas as pd

df = pd.read_csv("du-lieu.csv", parse_dates=["ngay"])

# Luôn khám phá trước khi làm gì khác
print(df.shape, df.dtypes.to_dict())
print(df.isna().mean().sort_values(ascending=False).head(10))
print(df.describe(include="all"))

# Biến đổi
df["doanh_thu"] = df["so_luong"] * df["don_gia"]
df["thang"] = df["ngay"].dt.to_period("M")
df["nhom"] = pd.cut(df["doanh_thu"], bins=[0, 1e6, 5e6, np.inf],
labels=["Thấp", "Trung bình", "Cao"])

# Nhóm
tom_tat = (df.groupby(["thang", "nhom"], observed=True)
.agg(so_don=("id", "count"),
dt=("doanh_thu", "sum"),
tb=("doanh_thu", "mean"))
.reset_index())

# Merge — như JOIN trong SQL
df_full = df.merge(khach_hang, on="khach_hang_id", how="left", validate="many_to_one")

validate= trong merge là tham số ít người biết nhưng cực hữu ích: nó báo lỗi nếu quan hệ bạn giả định không đúng (ví dụ bạn nghĩ many_to_one nhưng thực tế là many_to_many → dữ liệu sẽ nhân lên).


🧮 Tuần 5–7: Toán nền tảng​

Học toán song song với code, không học chay.

Đại số tuyến tính — quan trọng nhất​

Khái niệmÝ nghĩa trong MLỨng dụng
VectorMột điểm dữ liệu (các đặc trưng)Mọi dòng dữ liệu là một vector
Ma trậnToàn bộ tập dữ liệuX shape (n_samples, n_features)
Tích vô hướngĐộ tương đồngCosine similarity, attention
Nhân ma trậnBiến đổi tuyến tínhMọi layer của neural network
Trị riêng / vector riêngHướng chính của dữ liệuPCA, giảm chiều
Chuẩn (norm)Độ lớn vectorRegularization (L1, L2)
import numpy as np

# Tích vô hướng = độ tương đồng
a = np.array([1, 2, 3])
b = np.array([2, 4, 6]) # b = 2a → cùng hướng
cos = (a @ b) / (np.linalg.norm(a) * np.linalg.norm(b))
print(f"Cosine similarity: {cos:.4f}") # = 1.0

# PCA từ đầu — hiểu nó hoạt động thế nào
X = np.random.default_rng(42).normal(size=(200, 5))
X_tam = X - X.mean(axis=0) # 1. chuẩn hoá tâm
cov = np.cov(X_tam, rowvar=False) # 2. ma trận hiệp phương sai
gia_tri_rieng, vector_rieng = np.linalg.eigh(cov)
idx = np.argsort(gia_tri_rieng)[::-1] # 3. sắp xếp giảm dần
X_pca = X_tam @ vector_rieng[:, idx[:2]] # 4. chiếu xuống 2 chiều

print(f"Tỷ lệ phương sai giữ lại: "
f"{gia_tri_rieng[idx[:2]].sum() / gia_tri_rieng.sum():.2%}")

Hiểu PCA ở mức này quan trọng hơn gọi PCA(n_components=2).fit_transform(X) mà không biết nó làm gì.

Xác suất thống kê​

Khái niệmDùng ở đâu
Phân phối xác suấtGiả định của model, kiểm tra dữ liệu
BayesNaive Bayes, suy luận xác suất
Kỳ vọng, phương saiHàm mất mát, đánh giá
Ước lượng hợp lý cực đạiNền tảng của hồi quy logistic
Khoảng tin cậy, p-valueĐánh giá thí nghiệm A/B
Định lý giới hạn trung tâmVì sao bootstrap hoạt động

Giải tích — chỉ cần đạo hàm​

# Đạo hàm = hướng và tốc độ thay đổi → cơ sở của gradient descent
def ham_mat_mat(w):
return (w - 3) ** 2 + 2 # cực tiểu tại w = 3


def dao_ham(w):
return 2 * (w - 3)


# Gradient descent từ đầu
w = 0.0
learning_rate = 0.1
for buoc in range(50):
grad = dao_ham(w)
w -= learning_rate * grad
if buoc % 10 == 0:
print(f"Bước {buoc:>2}: w = {w:.6f}, loss = {ham_mat_mat(w):.6f}")

print(f"→ Hội tụ tại w = {w:.6f} (đáp án đúng: 3.0)")

Viết gradient descent bằng tay một lần giúp bạn hiểu optimizer.step() về sau.


📊 Tuần 8–10: EDA và trực quan hoá​

import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd

df = pd.read_csv("du-lieu.csv")

# --- 1. Kiểm tra phân phối và outlier ---
fig, axes = plt.subplots(2, 3, figsize=(16, 9))
for ax, cot in zip(axes.ravel(), df.select_dtypes("number").columns[:6]):
sns.histplot(df[cot].dropna(), kde=True, ax=ax, color="#2563eb")
ax.set_title(f"Phân phối: {cot}")
plt.tight_layout()
plt.show()

# --- 2. Ma trận tương quan ---
plt.figure(figsize=(12, 10))
corr = df.select_dtypes("number").corr()
sns.heatmap(corr, annot=len(corr) <= 12, fmt=".2f", cmap="RdBu_r",
center=0, square=True, cbar_kws={"shrink": 0.8})
plt.title("Ma trận tương quan")
plt.show()

# --- 3. Tìm tương quan mạnh với biến mục tiêu ---
muc_tieu = "gia"
tuong_quan = (corr[muc_tieu].drop(muc_tieu)
.sort_values(key=abs, ascending=False))
print("Tương quan với biến mục tiêu:")
print(tuong_quan.head(10).round(3))

Ba điều cần nhớ về tương quan:

  1. Chỉ áp dụng cho quan hệ tuyến tính. Hai biến có quan hệ hình chữ U có thể có r ≈ 0.
  2. Nhạy với outlier. Một điểm dị thường có thể tạo tương quan giả.
  3. Tương quan ≠ nhân quả. Luôn luôn.

🤖 Tuần 11–15: Machine Learning với scikit-learn​

Đây là phần cốt lõi. scikit-learn vẫn là công cụ quan trọng nhất — không phải deep learning.

Quy trình 7 bước chuẩn​

import numpy as np
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, roc_auc_score, confusion_matrix

# --- 1. Tách dữ liệu TRƯỚC khi làm bất cứ gì khác ---
X = df.drop(columns=["nhan"])
y = df["nhan"]

X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y # stratify giữ tỷ lệ lớp
)

# --- 2. Tiền xử lý bằng Pipeline (KHÔNG fit trên test!) ---
cot_so = X.select_dtypes("number").columns.tolist()
cot_chu = X.select_dtypes("object").columns.tolist()

tien_xu_ly = ColumnTransformer([
("so", Pipeline([
("dien_khuyet", SimpleImputer(strategy="median")),
("chuan_hoa", StandardScaler()),
]), cot_so),
("chu", Pipeline([
("dien_khuyet", SimpleImputer(strategy="constant", fill_value="Không rõ")),
("ma_hoa", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
]), cot_chu),
])

# --- 3. Pipeline hoàn chỉnh ---
model = Pipeline([
("tien_xu_ly", tien_xu_ly),
("phan_loai", RandomForestClassifier(random_state=42, n_jobs=-1)),
])

# --- 4. Cross-validation TRƯỚC khi tuning ---
diem_cv = cross_val_score(model, X_train, y_train, cv=5, scoring="roc_auc")
print(f"ROC-AUC CV: {diem_cv.mean():.4f} ± {diem_cv.std():.4f}")

# --- 5. Tuning siêu tham số ---
luoi = {
"phan_loai__n_estimators": [100, 300, 500],
"phan_loai__max_depth": [None, 10, 20],
"phan_loai__min_samples_leaf": [1, 2, 5],
}

tim_kiem = GridSearchCV(model, luoi, cv=5, scoring="roc_auc", n_jobs=-1, verbose=1)
tim_kiem.fit(X_train, y_train)
print(f"Bộ tham số tốt nhất: {tim_kiem.best_params_}")

# --- 6. Đánh giá trên tập test (CHỈ MỘT LẦN) ---
mo_hinh_tot_nhat = tim_kiem.best_estimator_
y_du_doan = mo_hinh_tot_nhat.predict(X_test)
y_xac_suat = mo_hinh_tot_nhat.predict_proba(X_test)[:, 1]

print(classification_report(y_test, y_du_doan))
print(f"ROC-AUC test: {roc_auc_score(y_test, y_xac_suat):.4f}")
print(f"Ma trận nhầm lẫn:\n{confusion_matrix(y_test, y_du_doan)}")

# --- 7. Feature importance ---
ten_dac_trung = mo_hinh_tot_nhat.named_steps["tien_xu_ly"].get_feature_names_out()
do_quan_trong = mo_hinh_tot_nhat.named_steps["phan_loai"].feature_importances_

for ten, diem in sorted(zip(ten_dac_trung, do_quan_trong),
key=lambda x: -x[1])[:10]:
print(f"{ten:<40} {diem:.4f}")

Những lỗi nghiêm trọng nhất trong ML​

1. Data leakage — lỗi chết người số 1:

# ❌ RÒ RỈ DỮ LIỆU: fit scaler trên TOÀN BỘ dữ liệu
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X) # đã "thấy" cả tập test!
X_train, X_test = train_test_split(X_scaled, ...)

# ✅ ĐÚNG: chỉ fit trên train (Pipeline làm việc này tự động)
X_train, X_test, y_train, y_test = train_test_split(X, y)
# rồi dùng Pipeline như ví dụ trên

Leakage làm model báo accuracy 95% khi validation nhưng chỉ 60% khi thực tế.

2. Không có baseline:

from sklearn.dummy import DummyClassifier

# Luôn so sánh với baseline trước
baseline = DummyClassifier(strategy="most_frequent")
baseline.fit(X_train, y_train)
print(f"Baseline accuracy: {baseline.score(X_test, y_test):.4f}")

# Nếu model của bạn = 0.72 và baseline = 0.70 → model gần như vô dụng!

3. Chỉ nhìn accuracy với dữ liệu mất cân bằng:

Nếu 99% giao dịch là hợp lệ, model "luôn dự đoán hợp lệ" đạt 99% accuracy — và bắt được 0% gian lận.

from sklearn.metrics import precision_recall_fscore_support

p, r, f1, _ = precision_recall_fscore_support(y_test, y_du_doan, average="binary")
print(f"Precision: {p:.4f} | Recall: {r:.4f} | F1: {f1:.4f}")
Chỉ sốÝ nghĩaKhi nào ưu tiên
AccuracyTỷ lệ đúngDữ liệu cân bằng
PrecisionTrong số dự đoán "positive", bao nhiêu đúngKhi false positive tốn kém (spam filter)
RecallTrong số "positive" thật, bắt được bao nhiêuKhi false negative nguy hiểm (ung thư, gian lận)
F1Trung bình điều hoà của hai cái trênCần cân bằng
ROC-AUCKhả năng phân biệt hai lớpSo sánh model tổng quát
PR-AUCNhư ROC nhưng tốt hơn khi mất cân bằngDữ liệu lệch mạnh

🧠 Tuần 16–19: Deep Learning với PyTorch​

pip install torch torchvision
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

# --- Chuẩn bị dữ liệu ---
X, y = make_classification(n_samples=5000, n_features=20, n_informative=12,
random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

scaler = StandardScaler().fit(X_train)
X_train = scaler.transform(X_train)
X_test = scaler.transform(X_test)

train_ds = TensorDataset(
torch.tensor(X_train, dtype=torch.float32),
torch.tensor(y_train, dtype=torch.float32).unsqueeze(1),
)
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)


# --- Định nghĩa model ---
class MangPhanLoai(nn.Module):
def __init__(self, so_dac_trung: int):
super().__init__()
self.mang = nn.Sequential(
nn.Linear(so_dac_trung, 128),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(128, 64),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(64, 1),
)

def forward(self, x):
return self.mang(x)


thiet_bi = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = MangPhanLoai(X_train.shape[1]).to(thiet_bi)

ham_mat_mat = nn.BCEWithLogitsLoss()
bo_toi_uu = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(bo_toi_uu, patience=5, factor=0.5)

# --- Vòng lặp huấn luyện ---
so_vong = 50
lich_su = {"train_loss": [], "val_loss": []}

X_test_t = torch.tensor(X_test, dtype=torch.float32).to(thiet_bi)
y_test_t = torch.tensor(y_test, dtype=torch.float32).unsqueeze(1).to(thiet_bi)

for vong in range(so_vong):
model.train()
tong_loss = 0.0
for xb, yb in train_loader:
xb, yb = xb.to(thiet_bi), yb.to(thiet_bi)

bo_toi_uu.zero_grad()
du_doan = model(xb)
loss = ham_mat_mat(du_doan, yb)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
bo_toi_uu.step()

tong_loss += loss.item() * len(xb)

# Đánh giá trên validation
model.eval()
with torch.no_grad():
val_loss = ham_mat_mat(model(X_test_t), y_test_t).item()

train_loss = tong_loss / len(train_ds)
lich_su["train_loss"].append(train_loss)
lich_su["val_loss"].append(val_loss)

scheduler.step(val_loss)

if (vong + 1) % 10 == 0:
print(f"Vòng {vong + 1:>3}/{so_vong} — "
f"train loss {train_loss:.4f} | val loss {val_loss:.4f} | "
f"lr {bo_toi_uu.param_groups[0]['lr']:.2e}")

# --- Đánh giá cuối ---
model.eval()
with torch.no_grad():
xac_suat = torch.sigmoid(model(X_test_t)).cpu().numpy().ravel()

from sklearn.metrics import roc_auc_score
print(f"\nROC-AUC: {roc_auc_score(y_test, xac_suat):.4f}")

Bốn khái niệm PyTorch phải nắm:

Khái niệmVai trò
nn.ModuleLớp cơ sở cho mọi model
forward()Định nghĩa luồng tính toán
loss.backward()Tính gradient
optimizer.step()Cập nhật trọng số
model.train() / .eval()Bật/tắt Dropout, BatchNorm
torch.no_grad()Tắt tính gradient khi suy luận (tiết kiệm bộ nhớ)

⚠️ Lỗi khiến 90% người mới mất hàng giờ: quên model.eval() khi đánh giá. Dropout vẫn hoạt động → kết quả validation sai và không tái lập được.

Khi nào dùng deep learning thay vì scikit-learn?

Dùng scikit-learnDùng deep learning
Dữ liệu dạng bảng (tabular)Ảnh, văn bản, âm thanh
Dữ liệu ít (< 100k dòng)Dữ liệu lớn (> 1M mẫu)
Cần giải thích được quyết địnhCần độ chính xác tối đa
Cần nhanh, ít tinh chỉnhCó GPU và thời gian
Baseline và prototypeBài toán thực sự phức tạp

💡 Sự thật quan trọng: với dữ liệu dạng bảng, XGBoost/LightGBM thường đánh bại neural network. Đừng dùng deep learning chỉ vì nó nghe "hiện đại" hơn.


💬 Tuần 20: NLP và LLM cơ bản​

# --- Cách 1: Dùng API (thực tế nhất hiện nay) ---
from openai import OpenAI

client = OpenAI()


def phan_tich_cam_xuc(doan_van: str) -> dict:
phan_hoi = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content":
"Bạn là chuyên gia phân tích cảm xúc. Trả về JSON với "
"'cam_xuc' (tich_cuc|tieu_cuc|trung_tinh) và 'ly_do'."},
{"role": "user", "content": doan_van},
],
response_format={"type": "json_object"},
temperature=0,
)
import json
return json.loads(phan_hoi.choices[0].message.content)


print(phan_tich_cam_xuc("Sản phẩm giao nhanh, đóng gói cẩn thận. Rất hài lòng!"))

Các khái niệm LLM cần biết:

Khái niệmÝ nghĩa
TokenĐơn vị văn bản model xử lý (~0.75 từ tiếng Anh)
EmbeddingVector biểu diễn ngữ nghĩa
Context windowSố token tối đa model xử lý một lần
Temperature0 = ổn định, 1+ = sáng tạo/ngẫu nhiên
RAGTruy xuất tài liệu liên quan rồi đưa vào prompt
Fine-tuningHuấn luyện thêm trên dữ liệu riêng
HallucinationModel bịa thông tin nghe hợp lý

RAG — kỹ thuật quan trọng nhất khi làm AI application:

import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("keepitreal/vietnamese-sbert")


def tim_tai_lieu_lien_quan(cau_hoi: str, kho_tai_lieu: list[str], top_k: int = 3):
"""Tìm đoạn tài liệu liên quan nhất bằng độ tương đồng cosine."""
vector_kho = model.encode(kho_tai_lieu, normalize_embeddings=True)
vector_hoi = model.encode([cau_hoi], normalize_embeddings=True)[0]

diem = vector_kho @ vector_hoi # cosine đã chuẩn hoá
idx = np.argsort(diem)[::-1][:top_k]

return [(kho_tai_lieu[i], float(diem[i])) for i in idx]


kho = [
"Chính sách đổi trả: khách hàng được đổi trong 7 ngày.",
"Phí vận chuyển nội thành là 30.000 đồng.",
"Thời gian bảo hành sản phẩm điện tử là 12 tháng.",
]

for noi_dung, diem in tim_tai_lieu_lien_quan("Tôi muốn đổi hàng thì sao?", kho):
print(f"[{diem:.3f}] {noi_dung}")

🚀 Tuần 21: MLOps — đưa model vào sản phẩm​

Đây là kỹ năng được trả lương cao nhất và cũng là thứ thiếu hụt nhất trên thị trường.

"""Đóng gói model thành API hoàn chỉnh."""
from contextlib import asynccontextmanager
from pathlib import Path

import joblib
import pandas as pd
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field


class DuLieuVao(BaseModel):
dac_trung: list[float] = Field(min_length=20, max_length=20)


class KetQuaRa(BaseModel):
du_doan: int
xac_suat: float
phien_ban_model: str


@asynccontextmanager
async def vong_doi(app: FastAPI):
"""Load model một lần khi khởi động — không load mỗi request."""
duong_dan = Path("model/xgb_v1.2.0.joblib")
if not duong_dan.exists():
raise RuntimeError(f"Không tìm thấy model: {duong_dan}")

app.state.model = joblib.load(duong_dan)
app.state.phien_ban = duong_dan.stem.split("_")[-1]
print(f"✅ Đã load model phiên bản {app.state.phien_ban}")
yield
app.state.model = None


app = FastAPI(title="API Dự đoán", lifespan=vong_doi)


@app.get("/suc-khoe")
async def suc_khoe():
return {"trang_thai": "ok", "model": app.state.phien_ban}


@app.post("/du-doan", response_model=KetQuaRa)
async def du_doan(du_lieu: DuLieuVao):
try:
X = pd.DataFrame([du_lieu.dac_trung])
du_doan = int(app.state.model.predict(X)[0])
xac_suat = float(app.state.model.predict_proba(X)[0][1])
except Exception as e:
raise HTTPException(500, f"Lỗi suy luận: {e}") from e

return KetQuaRa(du_doan=du_doan, xac_suat=xac_suat,
phien_ban_model=app.state.phien_ban)

Bốn trụ cột MLOps cần biết:

Trụ cộtCông cụMục đích
VersioningDVC, MLflowTheo dõi dữ liệu và model theo phiên bản
ServingFastAPI, TorchServeĐưa model thành API
MonitoringEvidently, PrometheusPhát hiện model "xuống cấp"
PipelineAirflow, PrefectTự động hoá train → test → deploy

Data drift — vấn đề mà người mới không biết:

Model train trên dữ liệu 2025 có thể sai dần vào 2026 vì hành vi người dùng thay đổi. Cần giám sát phân phối dữ liệu đầu vào:

from scipy.stats import ks_2samp
import numpy as np

def kiem_tra_drift(du_lieu_train: np.ndarray, du_lieu_hien_tai: np.ndarray,
nguong: float = 0.05) -> dict:
"""Kiểm định Kolmogorov-Smirnov cho từng đặc trưng."""
ket_qua = {}
for i in range(du_lieu_train.shape[1]):
_, p_value = ks_2samp(du_lieu_train[:, i], du_lieu_hien_tai[:, i])
ket_qua[f"dac_trung_{i}"] = {
"p_value": round(p_value, 4),
"co_drift": p_value < nguong,
}
so_drift = sum(1 for v in ket_qua.values() if v["co_drift"])
print(f"⚠️ {so_drift}/{len(ket_qua)} đặc trưng có drift")
return ket_qua

💼 Tuần 22–23: Dự án portfolio​

Ba dự án nên làm (chọn 2):

Dự ánThể hiện được
Dự đoán giá/churn có deployToàn bộ pipeline: EDA → model → API → deploy
Hệ thống RAG trả lời tài liệu nội bộLLM, embedding, vector DB, đánh giá
Pipeline MLOps có monitoringTự động train, versioning, phát hiện drift

Cấu trúc repo chuẩn:

du-an/
├── README.md ← quan trọng nhất
├── requirements.txt
├── data/
│ ├── raw/ ← dữ liệu gốc, không sửa
│ └── processed/
├── notebooks/
│ ├── 01_eda.ipynb
│ └── 02_modeling.ipynb
├── src/
│ ├── data/ ← hàm load + làm sạch
│ ├── features/ ← tạo đặc trưng
│ ├── models/ ← train + predict
│ └── api/ ← FastAPI serving
├── tests/
├── models/ ← model đã train (dùng DVC nếu lớn)
├── Dockerfile
└── .github/workflows/ci.yml

Điều nhà tuyển dụng tìm kiếm:

  1. Tái lập được — có seed, có requirements.txt, có hướng dẫn chạy.
  2. Code sạch, không chỉ notebook — notebook để khám phá, src/ để sản phẩm.
  3. Có deploy thật — link truy cập được, không chỉ screenshot.
  4. Hiểu hạn chế của model — bạn biết nó sai ở đâu.
  5. Có test — kể cả vài test đơn giản cho hàm tiền xử lý cũng gây ấn tượng.

🎤 Tuần 24: Phỏng vấn​

Cấu trúc phỏng vấn Data Scientist / ML Engineer:

VòngNội dungTrọng số
1Sàng lọc, giới thiệu dự án10%
2SQL + Python coding25%
3ML theory (bias-variance, regularization, metrics)25%
4ML system design25%
5Behavior + culture15%

Câu hỏi lý thuyết hay gặp:

  1. Bias-variance tradeoff — giải thích và cách xử lý từng phía.
  2. L1 và L2 regularization khác nhau thế nào? Khi nào dùng cái nào?
  3. Vì sao Random Forest giảm overfitting so với một cây đơn lẻ?
  4. Gradient boosting hoạt động thế nào? Khác Random Forest ở đâu?
  5. Xử lý dữ liệu mất cân bằng — kể 4 cách và ưu nhược điểm.
  6. Cross-validation dùng thế nào cho dữ liệu chuỗi thời gian?
  7. Precision và recall — khi nào ưu tiên cái nào? Cho ví dụ thực tế.
  8. Data leakage là gì? Cho 3 ví dụ tinh vi.

Câu hỏi ML system design (quan trọng nhất, chiếm 25%):

"Thiết kế hệ thống phát hiện gian lận giao dịch thẻ tín dụng cho ngân hàng có 10 triệu giao dịch/ngày."

Khung trả lời:

1. LÀM RÕ YÊU CẦU
- Độ trễ cho phép? (gợi ý: < 100ms cho giao dịch online)
- Chi phí false positive vs false negative?
- Có nhãn không? Bao lâu có nhãn? (chargeback mất 30-60 ngày)

2. DỮ LIỆU
- Nguồn: giao dịch, thiết bị, lịch sử khách hàng, vị trí
- Mất cân bằng cực nặng (~0,1% gian lận)
- Nhãn đến muộn → cần chiến lược cập nhật

3. ĐẶC TRƯNG
- Tần suất giao dịch 1h/24h/7d
- Lệch giữa vị trí giao dịch và vị trí thường ngày
- Số merchant khác nhau trong 24h
- Thời gian từ giao dịch trước

4. MODEL
- Baseline: rule-based + ngưỡng
- Chính: LightGBM (nhanh, xử lý mất cân bằng tốt, giải thích được)
- Metric: PR-AUC + recall@precision=0.9

5. TRIỂN KHAI
- Serving online < 100ms: model nhỏ, feature store
- Batch scoring cho phân tích hậu kiểm
- Fallback: nếu model lỗi → dùng rule cũ

6. GIÁM SÁT
- Data drift, model drift
- Tỷ lệ false positive theo thời gian (đo mức làm phiền khách)
- Retrain hàng tuần khi có nhãn mới

7. ĐÁNH ĐỔI
- Chặn nhiều → giảm gian lận nhưng tăng khách bực bội
- Cần quy trình review thủ công cho giao dịch nghi ngờ

🧪 Đánh giá model đúng cách​

Phần lớn người học dành 90% thời gian để tăng accuracy và 10% để kiểm tra xem con số đó có đáng tin không. Tỷ lệ này nên đảo ngược.

Chiến lược chia dữ liệu phải khớp với thực tế​

from sklearn.model_selection import (train_test_split, KFold, GroupKFold,
TimeSeriesSplit, LeaveOneGroupOut)

# --- Dữ liệu độc lập, phân phối giống nhau ---
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2,
random_state=42, stratify=y)

# --- Dữ liệu chuỗi thời gian: KHÔNG được chia ngẫu nhiên ---
# Chia ngẫu nhiên cho model "thấy tương lai" → kết quả đẹp giả tạo
tscv = TimeSeriesSplit(n_splits=5)
for train_idx, val_idx in tscv.split(X):
...

# --- Dữ liệu nhóm: cùng một khách hàng không được xuất hiện ở cả train và test ---
gkf = GroupKFold(n_splits=5)
for train_idx, val_idx in gkf.split(X, y, groups=df["khach_hang_id"]):
...

# --- Leave-one-group-out: kiểm tra khả năng tổng quát hoá sang nhóm mới ---
logo = LeaveOneGroupOut()
for train_idx, val_idx in logo.split(X, y, groups=df["khu_vuc"]):
...

⚠️ Ví dụ điển hình của chia sai: bài toán dự đoán khách hàng rời bỏ dịch vụ. Nếu chia ngẫu nhiên theo dòng, cùng một khách hàng có thể nằm cả ở train và test. Model chỉ cần "nhớ" khách hàng thay vì học pattern → accuracy 0,95 khi validation, 0,65 khi thực tế.

Chọn metric khớp với chi phí nghiệp vụ​

Tình huốngMetric nên dùngVì sao
Dữ liệu cân bằng, sai sót ngang nhauAccuracy, F1Đơn giản, dễ hiểu
Mất cân bằng nhẹ (1:10)F1, ROC-AUCAccuracy bắt đầu gây hiểu nhầm
Mất cân bằng nặng (1:1000)PR-AUC, Recall@PrecisionROC-AUC bị lạc quan quá mức
Chi phí false positive caoPrecisionVí dụ: tự động chặn tài khoản
Chi phí false negative caoRecallVí dụ: tầm soát bệnh, phát hiện gian lận
Cần xếp hạng, không cần ngưỡngROC-AUC, NDCGGợi ý sản phẩm, tìm kiếm
Kết quả cần xác suất đáng tinLog loss, Brier score, calibrationDự đoán rủi ro, định giá
from sklearn.metrics import (average_precision_score, precision_recall_curve,
brier_score_loss, log_loss, roc_curve, auc)
import numpy as np

ap = average_precision_score(y_test, y_xac_suat) # PR-AUC
brier = brier_score_loss(y_test, y_xac_suat) # hiệu chuẩn
ll = log_loss(y_test, y_xac_suat)
print(f"PR-AUC: {ap:.4f} | Brier: {brier:.4f} | LogLoss: {ll:.4f}")

# Chọn ngưỡng theo mục tiêu nghiệp vụ, KHÔNG dùng 0.5 mặc định
precision, recall, nguong = precision_recall_curve(y_test, y_xac_suat)

# Ví dụ: cần precision >= 0.90, tìm recall tốt nhất có thể
idx = np.where(precision[:-1] >= 0.90)[0]
if len(idx):
i = idx[np.argmax(recall[i])]
print(f"Ngưỡng {nguong[i]:.3f} → precision {precision[i]:.3f}, recall {recall[i]:.3f}")

Ngưỡng 0,5 là mặc định của thư viện, không phải lựa chọn có căn cứ. Với bài toán gian lận, bạn cần ngưỡng cao hơn nhiều để giảm false positive.

Calibration — xác suất có đáng tin không?​

from sklearn.calibration import calibration_curve, CalibratedClassifierCV
import matplotlib.pyplot as plt

xac_suat_thuc, xac_suat_du_doan = calibration_curve(y_test, y_xac_suat, n_bins=10)

plt.figure(figsize=(7, 7))
plt.plot([0, 1], [0, 1], "k--", label="Hoàn hảo")
plt.plot(xac_suat_du_doan, xac_suat_thuc, "o-", label="Model")
plt.xlabel("Xác suất dự đoán")
plt.ylabel("Tỷ lệ dương thực tế")
plt.title("Biểu đồ hiệu chuẩn")
plt.legend()
plt.show()

# Hiệu chỉnh nếu model tự tin quá mức
model_hieu_chuan = CalibratedClassifierCV(model, method="isotonic", cv=5)
model_hieu_chuan.fit(X_train, y_train)

Vì sao quan trọng: nhãn "khách hàng này có 70% khả năng mua" chỉ có ích nếu 70% số khách hàng như vậy thực sự mua. Nếu chỉ 35% mua, mọi quyết định dựa trên con số đó đều sai.

Error analysis — bước mà hầu hết mọi người bỏ qua​

Đừng dừng ở việc biết accuracy. Hãy đọc từng dự đoán sai:

import pandas as pd

# Xây bảng dự đoán sai kèm thông tin ngữ cảnh
danh_gia = X_test.copy()
danh_gia["thuc_te"] = y_test.values
danh_gia["du_doan"] = y_du_doan
danh_gia["xac_suat"] = y_xac_suat

sai = danh_gia[danh_gia["thuc_te"] != danh_gia["du_doan"]]

# Phân tích lỗi theo từng nhóm
print(sai.groupby("khu_vuc").size().sort_values(ascending=False).head())
print(sai.groupby("nhom_tuoi").size().sort_values(ascending=False).head())

# Lỗi "tự tin nhưng sai" — nguy hiểm nhất
tu_tin_sai = sai[sai["xac_suat"].between(0.85, 0.999)]
print(f"Số dự đoán tự tin nhưng sai: {len(tu_tin_sai)} "
f"({len(tu_tin_sai)/len(sai):.1%} tổng số lỗi)")

# Xem vài ví dụ cụ thể để hiểu nguyên nhân
print(tu_tin_sai[['khu_vuc', 'nhom_tuoi', 'xac_suat']].head(10))

Câu hỏi cần trả lời sau error analysis:

  1. Lỗi tập trung ở một nhóm cụ thể không? (nếu có → model có định kiến)
  2. Có lỗi "tự tin nhưng sai" không? (nguy hiểm nhất trong thực tế)
  3. Lỗi có liên quan đến dữ liệu thiếu hoặc ngoài phân phối không?
  4. Nếu chỉ cải thiện được một loại lỗi, loại nào đáng cải thiện nhất?

Với bài toán phân loại, ma trận nhầm lẫn theo từng nhóm thường tiết lộ vấn đề mà metric tổng thể che mất.


📖 Cách tự học và cập nhật kiến thức​

Lĩnh vực này thay đổi nhanh đến mức khoá học bạn mua hôm nay sẽ lỗi thời sau 18 tháng. Kỹ năng quan trọng nhất là tự học có hệ thống.

Bốn nguồn học chất lượng nhất​

NguồnDùng đểGhi chú
Paper gốcHiểu thuật toán từ nguồnarXiv, paperswithcode
Documentation chính thứcDùng đúng APILuôn cập nhật nhất
Source codeHiểu cơ chế bên trongscikit-learn, PyTorch
Blog/khảo sát chất lượngNắm bức tranh tổng thểDistill, Lil'Log, Kaggle

Cách đọc một paper ML​

Đừng đọc từ đầu đến cuối. Đọc theo thứ tự này:

1. Tiêu đề + Abstract      (1 phút)  — bài này giải quyết vấn đề gì?
2. Hình 1 + bảng 1 (3 phút) — trực giác cốt lõi là gì?
3. Kết luận (2 phút) — kết quả có gì đáng chú ý?
4. Method (kỹ) (20 phút) — cách làm cụ thể
5. Thí nghiệm + bảng số liệu (15 phút) — có gì đáng nghi ngờ không?
6. Related work (5 phút) — đặt trong bối cảnh nào

Ba câu hỏi phải tự trả lời sau khi đọc:

  1. Ý tưởng cốt lõi, giải thích trong một câu.
  2. So với phương pháp trước đó, cải thiện ở điểm nào?
  3. Có dùng được cho bài toán của mình không, và cần điều kiện gì?

Duy trì kỹ năng code song song với kỹ năng mô hình​

# Mỗi tháng: đọc source của một thư viện bạn dùng hàng ngày
python -c "import sklearn; print(sklearn.__file__)"

Mở source sklearn/linear_model/_logistic.py và đọc cách họ tổ chức code, cách họ xử lý tham số, cách họ viết docstring. Đây là cách học nhanh nhất từ người giỏi mà hầu như không ai làm.

Lộ trình tự học 12 tháng song song công việc​

ThángHoạt độngSản phẩm
1–2Đọc 1 paper/tuần + tóm tắt8 bài tóm tắt
3–4Tái lập 1 paper đơn giảnRepo GitHub
5–6Tham gia 1 competition KaggleXếp hạng + notebook
7–8Đóng góp cho 1 thư viện mã nguồn mở1 PR được merge
9–10Viết 3 bài blog kỹ thuậtBlog cá nhân
11–12Xây 1 dự án end-to-end có deployPortfolio

Viết lại là cách học hiệu quả nhất. Khi bạn cố giải thích một thuật toán cho người khác, bạn phát hiện ra mình chưa hiểu chỗ nào. Đó chính là giá trị.


⚠️ 7 sai lầm khiến bạn học 1 năm mà không xin được việc​

  1. Chỉ học model, không học làm sạch dữ liệu. 80% công việc thật là xử lý dữ liệu.
  2. Không biết SQL. Mọi vòng phỏng vấn đều có SQL.
  3. Chỉ làm Kaggle, không deploy. Model không deploy = dự án chưa xong.
  4. Học deep learning trước machine learning cổ điển. Như học chạy trước khi biết đi.
  5. Không có baseline. Không biết model của mình tốt hay chỉ ngang đoán bừa.
  6. Không hiểu hạn chế của model. Nhà tuyển dụng hỏi câu này để kiểm tra bạn có thật sự làm hay copy.
  7. Bỏ qua kỹ năng kỹ thuật phần mềm. Git, Docker, testing — không có thì không qua vòng kỹ thuật.

❓ Câu hỏi thường gặp​

Cần bằng thạc sĩ/tiến sĩ không? Cho vị trí ML Engineer và AI Application Developer: không cần. Cho Research Scientist: thường cần. Đa số việc làm ở Việt Nam không yêu cầu bằng cao học.

Không giỏi toán thì có học được không? Được — cho vị trí Data Analyst, ML Engineer, AI Application Developer. Không được — cho Research Scientist. Bạn cần đại số tuyến tính và xác suất cơ bản ở mọi vị trí, nhưng không cần toán chuyên sâu.

Học Data Science bao lâu thì có việc? Nếu đã biết lập trình và SQL: 6–9 tháng. Nếu từ con số 0: 12–18 tháng. Bất kỳ ai hứa "3 tháng thành Data Scientist lương cao" đều đang bán khoá học, không bán sự thật.

AI có thay thế Data Scientist không? Thay thế phần viết code lặp lại và làm EDA cơ bản. Không thay thế phần hiểu vấn đề, chọn metric đúng, phán đoán về dữ liệu và đánh đổi nghiệp vụ.

Nên học TensorFlow hay PyTorch? PyTorch. Nó chiếm ưu thế trong nghiên cứu và ngày càng phổ biến trong công nghiệp. TensorFlow vẫn có chỗ đứng ở production mobile/embedded nhưng PyTorch là lựa chọn an toàn hơn để học.

Có nên học LLM trước Machine Learning truyền thống không? Không. Học ML cổ điển trước để hiểu overfitting, validation, metric, và đánh đổi. Những khái niệm này áp dụng nguyên vẹn cho LLM.


🎯 Tóm lại​

4 tuần  → Python: numpy, pandas
3 tuần → Toán: đại số tuyến tính, xác suất, đạo hàm
3 tuần → EDA và trực quan hoá
5 tuần → Machine Learning với scikit-learn ← phần quan trọng nhất
4 tuần → Deep Learning với PyTorch
1 tuần → NLP và LLM cơ bản
1 tuần → MLOps: đóng gói và deploy
2 tuần → Dự án portfolio
1 tuần → Phỏng vấn

Sự thật cuối cùng: người có việc làm trong ngành này không phải người biết nhiều thuật toán nhất, mà là người có thể đưa một model từ notebook ra production và chứng minh nó tạo ra giá trị. Hãy xây dựng theo hướng đó ngay từ dự án đầu tiên.


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

Lộ trình học Python Django Backend

· 25 phút để đọc

Django là framework "pin kèm mọi thứ" của Python: ORM, admin, xác thực, form, migration, bảo mật — tất cả có sẵn. Triết lý của nó là quyết định thay bạn để bạn không phải chọn lại từ đầu mỗi dự án.

Điều đó có nghĩa Django phù hợp nhất khi bạn cần giao sản phẩm nhanh và làm việc nhóm đông người với quy ước chung.


🎯 Django phù hợp với ai?​

Nên chọn Django khiNên chọn framework khác khi
Làm web app có giao diện + adminCần API siêu nhẹ, ít tính năng (→ Flask)
Cần CRUD nhanh cho nhiều bảngCần async native, WebSocket nhiều (→ FastAPI)
Team đông, cần quy ước chungMuốn toàn quyền kiểm soát từng thành phần (→ Flask)
Cần auth, phân quyền, bảo mật sẵnPrototype vài chục dòng
Làm CMS, ERP, hệ thống nội bộ—

💡 Lợi thế độc nhất: Django Admin. Chỉ với 10 dòng admin.py, bạn có giao diện quản trị có tìm kiếm, lọc, phân quyền, export. Xây thứ tương tự bằng Flask mất vài tuần.


🗓 Lộ trình 16 tuần​

Tuần 1–2    Python + kiến thức web cơ bản (HTTP, SQL)
Tuần 3–4 Django cơ bản: project, app, view, template, URL
Tuần 5–7 Model, migration, Django ORM, admin
Tuần 8–9 Form, class-based view, xác thực
Tuần 10–11 Django REST Framework: serializer, viewset, router
Tuần 12 Testing, tối ưu truy vấn, cache
Tuần 13 Celery, tác vụ nền, signal
Tuần 14–15 Docker, deploy production, bảo mật
Tuần 16 Dự án tổng hợp + phỏng vấn

🐍 Tuần 1–2: Nền tảng bắt buộc​

Trước khi học Django, bạn cần chắc hai thứ mà nhiều người bỏ qua rồi trả giá:

1. SQL — vì ORM không cứu bạn khi truy vấn chậm:

SELECT t.ten, COUNT(s.id) AS so_sach, SUM(s.gia) AS tong
FROM tac_gia t
JOIN sach s ON s.tac_gia_id = t.id
WHERE s.nam_xuat_ban >= 2020
GROUP BY t.id, t.ten
HAVING COUNT(s.id) > 2
ORDER BY tong DESC
LIMIT 10;

Hiểu JOIN, GROUP BY, INDEX, EXPLAIN là điều kiện để debug QuerySet của Django.

2. HTTP — vì Django là framework web:

Thành phầnCần hiểu
MethodGET, POST, PUT, PATCH, DELETE — và tính idempotent của từng cái
Mã trạng thái200, 201, 204, 400, 401, 403, 404, 500
HeaderContent-Type, Authorization, Cookie
Cơ chếRequest/response, session, cookie, CSRF

🌱 Tuần 3–4: Django cơ bản​

python -m venv .venv
.venv\Scripts\activate
pip install django
django-admin startproject config .
python manage.py startapp blog

Vì sao dùng config làm tên project: tránh trùng tên với package bên trong (nhiều người đặt project là mysite rồi gặp lỗi import khó hiểu sau này).

MTV — kiến trúc của Django​

flowchart LR
A[Browser] -->|URL| B[urls.py]
B --> C[views.py]
C --> D[models.py ORM]
D --> E[(Database)]
C --> F[Template]
F --> A
Thành phầnVai tròSo với MVC
ModelĐịnh nghĩa dữ liệuGiống Model
TemplateHiển thịGiống View
ViewXử lý logicGiống Controller

URL và View​

# blog/views.py
from django.http import JsonResponse, Http404
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages

from .models import BaiViet


def danh_sach_bai_viet(request):
ds = BaiViet.objects.filter(da_xuat_ban=True).select_related("tac_gia")
return render(request, "blog/danh_sach.html", {"bai_viet": ds})


def chi_tiet_bai_viet(request, slug: str):
bai_viet = get_object_or_404(BaiViet, slug=slug, da_xuat_ban=True)
return render(request, "blog/chi_tiet.html", {"bai_viet": bai_viet})
# blog/urls.py
from django.urls import path
from . import views

app_name = "blog"

urlpatterns = [
path("", views.danh_sach_bai_viet, name="danh_sach"),
path("<slug:slug>/", views.chi_tiet_bai_viet, name="chi_tiet"),
]
# config/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
path("admin/", admin.site.urls),
path("blog/", include("blog.urls")),
]

Bốn loại path converter:

ConverterKhớp vớiVí dụ
<int:id>Số nguyên/bai-viet/42/
<slug:slug>Chuỗi slug hoá/bai-viet/hoc-python-co-ban/
<str:ten>Chuỗi không có //tac-gia/minh/
<uuid:ma>UUID/don-hang/550e8400-.../

⚠️ Luôn dùng get_object_or_404 thay vì BaiViet.objects.get(...). get() ném DoesNotExist → lỗi 500. get_object_or_404 mới trả 404 đúng.


🗄️ Tuần 5–7: Model, Migration và ORM​

Model đúng chuẩn​

# blog/models.py
from django.db import models
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify


class DanhMuc(models.Model):
ten = models.CharField("Tên danh mục", max_length=100, unique=True)
slug = models.SlugField("Đường dẫn", max_length=120, unique=True)

class Meta:
verbose_name = "Danh mục"
verbose_name_plural = "Danh mục"
ordering = ["ten"]

def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.ten, allow_unicode=True)
super().save(*args, **kwargs)

def __str__(self):
return self.ten


class BaiViet(models.Model):
TRANG_THAI = [
("nhap", "Bản nháp"),
("cho_duyet", "Chờ duyệt"),
("xuat_ban", "Đã xuất bản"),
]

tieu_de = models.CharField("Tiêu đề", max_length=250)
slug = models.SlugField("Đường dẫn", max_length=260, unique=True, db_index=True)
noi_dung = models.TextField("Nội dung")
tom_tat = models.CharField("Tóm tắt", max_length=500, blank=True)

tac_gia = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="bai_viet",
verbose_name="Tác giả",
)
danh_muc = models.ForeignKey(
DanhMuc, on_delete=models.SET_NULL, null=True, blank=True,
related_name="bai_viet", verbose_name="Danh mục",
)
the = models.ManyToManyField("The", blank=True, related_name="bai_viet", verbose_name="Thẻ")

trang_thai = models.CharField(max_length=20, choices=TRANG_THAI, default="nhap", db_index=True)
luot_xem = models.PositiveIntegerField("Lượt xem", default=0)
ngay_tao = models.DateTimeField(auto_now_add=True, db_index=True)
ngay_cap_nhat = models.DateTimeField(auto_now=True)
ngay_xuat_ban = models.DateTimeField(null=True, blank=True)

class Meta:
verbose_name = "Bài viết"
verbose_name_plural = "Bài viết"
ordering = ["-ngay_tao"]
indexes = [
models.Index(fields=["trang_thai", "-ngay_tao"]),
models.Index(fields=["tac_gia", "trang_thai"]),
]
constraints = [
models.CheckConstraint(
check=models.Q(luot_xem__gte=0),
name="luot_xem_khong_am",
),
]

def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.tieu_de, allow_unicode=True)[:260]
if self.trang_thai == "xuat_ban" and self.ngay_xuat_ban is None:
self.ngay_xuat_ban = timezone.now()
super().save(*args, **kwargs)

def get_absolute_url(self):
return reverse("blog:chi_tiet", kwargs={"slug": self.slug})

@property
def da_xuat_ban(self):
return self.trang_thai == "xuat_ban"

def __str__(self):
return self.tieu_de


class The(models.Model):
ten = models.CharField(max_length=60, unique=True)
slug = models.SlugField(max_length=80, unique=True)

def __str__(self):
return self.ten

Năm quyết định thiết kế quan trọng trong đoạn code trên:

Quyết địnhLý do
on_delete=models.CASCADE cho tác giảXoá user → xoá bài của họ
on_delete=models.SET_NULL cho danh mụcXoá danh mục không nên xoá bài
db_index=True cho slug, trang_thai, ngay_taoCác trường hay lọc/sắp xếp
indexes tổ hợpQuery thật luôn lọc theo nhiều cột
CheckConstraintRàng buộc logic ngay ở tầng database

⚠️ Tuyệt đối không dùng FloatField cho tiền. Luôn DecimalField. Sai số dấu phẩy động có thể làm lệch sổ sách vài đồng — và trong tài chính, đó là lỗi không thể chấp nhận.

Migration — quy trình bất di bất dịch​

python manage.py makemigrations blog
python manage.py migrate
python manage.py showmigrations blog
python manage.py sqlmigrate blog 0001 # xem SQL thật sẽ chạy

Ba quy tắc:

  1. Không bao giờ sửa file trong migrations/ bằng tay.
  2. Luôn sqlmigrate để xem SQL trước khi migrate production.
  3. Đổi tên cột cần migration đặc biệt — Django hiểu là xoá + tạo mới → mất dữ liệu.

ORM — bảng tra cứu cần thuộc​

from django.db.models import Q, F, Count, Sum, Avg, Max, Case, When, Value, IntegerField

# --- Lọc ---
BaiViet.objects.filter(trang_thai="xuat_ban")
BaiViet.objects.filter(luot_xem__gte=1000)
BaiViet.objects.filter(tieu_de__icontains="python")
BaiViet.objects.filter(danh_muc__isnull=True)
BaiViet.objects.filter(ngay_tao__year=2026, ngay_tao__month=10)
BaiViet.objects.filter(Q(trang_thai="xuat_ban") | Q(luot_xem__gt=5000))
BaiViet.objects.exclude(trang_thai="nhap")

# --- Sắp xếp & giới hạn ---
BaiViet.objects.order_by("-luot_xem")[:10]
BaiViet.objects.order_by(F("luot_xem").desc(nulls_last=True))

# --- Truy vấn liên bảng ---
BaiViet.objects.filter(the__ten="python").distinct()
BaiViet.objects.filter(danh_muc__slug="huong-dan")

# --- Aggregate ---
BaiViet.objects.aggregate(tong_luot_xem=Sum("luot_xem"), tb=Avg("luot_xem"))

# --- Annotate (tính toán trên từng bản ghi) ---
DanhMuc.objects.annotate(so_bai=Count("bai_viet")).filter(so_bai__gt=0)

# --- Cập nhật bằng F() — tránh race condition ---
BaiViet.objects.filter(pk=1).update(luot_xem=F("luot_xem") + 1)

# --- bulk_create / bulk_update — nhanh hơn nhiều lần ---
BaiViet.objects.bulk_create([BaiViet(tieu_de=f"Bài {i}") for i in range(1000)], batch_size=500)

F() — điều mà người mới luôn làm sai:

# ❌ SAI — đọc rồi ghi, có race condition
bv = BaiViet.objects.get(pk=1)
bv.luot_xem = bv.luot_xem + 1 # hai request đồng thời → mất 1 lượt
bv.save()

# ✅ ĐÚNG — database tự tăng, atomic
BaiViet.objects.filter(pk=1).update(luot_xem=F("luot_xem") + 1)

Django Admin​

# blog/admin.py
from django.contrib import admin
from django.utils.html import format_html
from .models import BaiViet, DanhMuc, The


@admin.register(DanhMuc)
class DanhMucAdmin(admin.ModelAdmin):
list_display = ("ten", "slug", "so_bai_viet")
search_fields = ("ten",)
prepopulated_fields = {"slug": ("ten",)}

@admin.display(description="Số bài viết")
def so_bai_viet(self, obj):
return obj.bai_viet.count()


@admin.register(BaiViet)
class BaiVietAdmin(admin.ModelAdmin):
list_display = ("tieu_de", "tac_gia", "danh_muc", "trang_thai_badge", "luot_xem", "ngay_tao")
list_filter = ("trang_thai", "danh_muc", "ngay_tao")
search_fields = ("tieu_de", "noi_dung", "tac_gia__username")
prepopulated_fields = {"slug": ("tieu_de",)}
date_hierarchy = "ngay_tao"
autocomplete_fields = ("tac_gia", "danh_muc")
filter_horizontal = ("the",)
list_select_related = ("tac_gia", "danh_muc") # ← tránh N+1 trong admin
readonly_fields = ("luot_xem", "ngay_tao", "ngay_cap_nhat")
list_per_page = 25
actions = ["duyet_hang_loat"]

fieldsets = (
("Nội dung", {"fields": ("tieu_de", "slug", "tom_tat", "noi_dung")}),
("Phân loại", {"fields": ("danh_muc", "the")}),
("Xuất bản", {"fields": ("tac_gia", "trang_thai", "ngay_xuat_ban")}),
("Thống kê", {"fields": ("luot_xem", "ngay_tao", "ngay_cap_nhat"), "classes": ("collapse",)}),
)

@admin.display(description="Trạng thái", ordering="trang_thai")
def trang_thai_badge(self, obj):
mau = {"nhap": "#6b7280", "cho_duyet": "#f59e0b", "xuat_ban": "#16a34a"}[obj.trang_thai]
return format_html(
'<span style="background:{};color:#fff;padding:2px 8px;border-radius:10px">{}</span>',
mau, obj.get_trang_thai_display(),
)

@admin.action(description="Duyệt và xuất bản các bài đã chọn")
def duyet_hang_loat(self, request, queryset):
so_luong = queryset.update(trang_thai="xuat_ban")
self.message_user(request, f"Đã xuất bản {so_luong} bài viết.")

Đoạn này cho bạn: badge màu theo trạng thái, action hàng loạt, fieldsets gọn gàng, và list_select_related để admin không chạy N+1 query.


📝 Tuần 8–9: Form, Class-based View, xác thực​

# blog/forms.py
from django import forms
from django.core.exceptions import ValidationError
from .models import BaiViet


class BaiVietForm(forms.ModelForm):
class Meta:
model = BaiViet
fields = ["tieu_de", "tom_tat", "noi_dung", "danh_muc", "the", "trang_thai"]
widgets = {
"tom_tat": forms.Textarea(attrs={"rows": 3, "maxlength": 500}),
"noi_dung": forms.Textarea(attrs={"rows": 20}),
}
help_texts = {"tom_tat": "Tối đa 500 ký tự, hiển thị ở trang danh sách."}

def clean_tieu_de(self):
tieu_de = self.cleaned_data["tieu_de"].strip()
if len(tieu_de) < 10:
raise ValidationError("Tiêu đề phải có ít nhất 10 ký tự.")
return tieu_de

def clean(self):
du_lieu = super().clean()
if du_lieu.get("trang_thai") == "xuat_ban" and not du_lieu.get("tom_tat"):
self.add_error("tom_tat", "Bài xuất bản phải có tóm tắt.")
return du_lieu

Class-based View — dùng khi nào?

Loại viewDùng choVí dụ
TemplateViewTrang tĩnhGiới thiệu, liên hệ
ListViewDanh sách có phân trangDanh sách bài viết
DetailViewChi tiết một bản ghiChi tiết bài viết
CreateViewForm tạo mớiTạo bài viết
UpdateViewForm cập nhậtSửa bài viết
DeleteViewXác nhận xoáXoá bài viết
from django.views.generic import ListView, DetailView, CreateView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.urls import reverse_lazy


class DanhSachBaiVietView(ListView):
model = BaiViet
template_name = "blog/danh_sach.html"
context_object_name = "bai_viet"
paginate_by = 12

def get_queryset(self):
qs = BaiViet.objects.filter(trang_thai="xuat_ban").select_related("tac_gia", "danh_muc")
if danh_muc := self.request.GET.get("danh_muc"):
qs = qs.filter(danh_muc__slug=danh_muc)
if tu_khoa := self.request.GET.get("q"):
qs = qs.filter(tieu_de__icontains=tu_khoa)
return qs


class TaoBaiVietView(LoginRequiredMixin, CreateView):
model = BaiViet
form_class = BaiVietForm
template_name = "blog/form.html"
success_url = reverse_lazy("blog:danh_sach")

def form_valid(self, form):
form.instance.tac_gia = self.request.user
return super().form_valid(form)

💡 Kinh nghiệm thực tế: dùng ListView/DetailView vì chúng tiết kiệm nhiều code. Nhưng khi logic phình ra, quay về function view — dễ đọc hơn. Đừng cố nhồi mọi thứ vào generic view.


🔌 Tuần 10–11: Django REST Framework​

pip install djangorestframework djangorestframework-simplejwt django-filter
# blog/serializers.py
from rest_framework import serializers
from .models import BaiViet, DanhMuc


class DanhMucSerializer(serializers.ModelSerializer):
so_bai_viet = serializers.IntegerField(read_only=True)

class Meta:
model = DanhMuc
fields = ["id", "ten", "slug", "so_bai_viet"]


class BaiVietListSerializer(serializers.ModelSerializer):
tac_gia_ten = serializers.CharField(source="tac_gia.username", read_only=True)
danh_muc_ten = serializers.CharField(source="danh_muc.ten", read_only=True, default=None)

class Meta:
model = BaiViet
fields = ["id", "tieu_de", "slug", "tom_tat", "tac_gia_ten",
"danh_muc_ten", "luot_xem", "ngay_xuat_ban"]


class BaiVietDetailSerializer(serializers.ModelSerializer):
tac_gia = serializers.StringRelatedField(read_only=True)
danh_muc = DanhMucSerializer(read_only=True)
danh_muc_id = serializers.PrimaryKeyRelatedField(
queryset=DanhMuc.objects.all(), source="danh_muc", write_only=True,
required=False, allow_null=True,
)

class Meta:
model = BaiViet
fields = ["id", "tieu_de", "slug", "tom_tat", "noi_dung",
"tac_gia", "danh_muc", "danh_muc_id", "the",
"trang_thai", "luot_xem", "ngay_tao", "ngay_xuat_ban"]
read_only_fields = ["slug", "luot_xem", "ngay_tao", "ngay_xuat_ban"]

def validate_tieu_de(self, value):
if len(value.strip()) < 10:
raise serializers.ValidationError("Tiêu đề phải có ít nhất 10 ký tự.")
return value.strip()
# blog/api_views.py
from django.db.models import Count, Q
from rest_framework import viewsets, filters, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import F

from .models import BaiViet, DanhMuc
from .serializers import BaiVietListSerializer, BaiVietDetailSerializer, DanhMucSerializer


class BaiVietViewSet(viewsets.ModelViewSet):
queryset = (
BaiViet.objects
.select_related("tac_gia", "danh_muc")
.prefetch_related("the")
)
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ["trang_thai", "danh_muc"]
search_fields = ["tieu_de", "tom_tat", "noi_dung"]
ordering_fields = ["ngay_tao", "luot_xem"]
ordering = ["-ngay_tao"]
lookup_field = "slug"

def get_serializer_class(self):
if self.action == "list":
return BaiVietListSerializer
return BaiVietDetailSerializer

def get_permissions(self):
if self.action in ("list", "retrieve"):
return [permissions.AllowAny()]
return [permissions.IsAuthenticated()]

def perform_create(self, serializer):
serializer.save(tac_gia=self.request.user)

@action(detail=True, methods=["post"], permission_classes=[permissions.AllowAny])
def tang_luot_xem(self, request, slug=None):
BaiViet.objects.filter(slug=slug).update(luot_xem=F("luot_xem") + 1)
bai_viet = self.get_object()
return Response({"luot_xem": bai_viet.luot_xem}, status=status.HTTP_200_OK)


class DanhMucViewSet(viewsets.ReadOnlyModelViewSet):
queryset = DanhMuc.objects.annotate(so_bai_viet=Count("bai_viet", filter=Q(bai_viet__trang_thai="xuat_ban")))
serializer_class = DanhMucSerializer
lookup_field = "slug"
# blog/api_urls.py
from rest_framework.routers import DefaultRouter
from .api_views import BaiVietViewSet, DanhMucViewSet

router = DefaultRouter()
router.register("bai-viet", BaiVietViewSet, basename="bai-viet")
router.register("danh-muc", DanhMucViewSet, basename="danh-muc")

urlpatterns = router.urls

Những gì bạn vừa có mà không phải viết:

  • CRUD đầy đủ cho /api/bai-viet/ và /api/danh-muc/.
  • Tìm kiếm ?search=python, lọc ?trang_thai=xuat_ban, sắp xếp ?ordering=-luot_xem.
  • Phân trang tự động.
  • Endpoint tuỳ chỉnh POST /api/bai-viet/{slug}/tang_luot_xem/.
  • Giao diện thử API tại /api/ trong browser.

Cấu hình DRF trong settings.py:

REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {"anon": "60/min", "user": "2000/hour"},
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}

⚡ Tuần 12: Testing và tối ưu truy vấn​

Testing​

# blog/tests/test_api.py
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth.models import User
from rest_framework.test import APIClient

from blog.models import BaiViet


class BaiVietAPITest(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user("minh", password="matkhau123")
self.bai_viet = BaiViet.objects.create(
tieu_de="Hướng dẫn Django cơ bản",
noi_dung="Nội dung...",
tac_gia=self.user,
trang_thai="xuat_ban",
)

def test_danh_sach_khong_can_dang_nhap(self):
r = self.client.get(reverse("bai-viet-list"))
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 1)

def test_tao_bai_viet_can_dang_nhap(self):
r = self.client.post(reverse("bai-viet-list"), {"tieu_de": "Bài mới"})
self.assertEqual(r.status_code, 401)

def test_tao_bai_viet_thanh_cong(self):
self.client.force_authenticate(self.user)
r = self.client.post(reverse("bai-viet-list"), {
"tieu_de": "Bài viết hoàn toàn mới",
"noi_dung": "Nội dung",
"trang_thai": "nhap",
})
self.assertEqual(r.status_code, 201)
self.assertEqual(r.data["tac_gia"]["id"], self.user.id)

def test_tieu_de_qua_ngan(self):
self.client.force_authenticate(self.user)
r = self.client.post(reverse("bai-viet-list"), {"tieu_de": "Ngắn"})
self.assertEqual(r.status_code, 400)
self.assertIn("tieu_de", r.data)

def test_tang_luot_xem(self):
url = reverse("bai-viet-tang-luot-xem", kwargs={"slug": self.bai_viet.slug})
self.client.post(url)
self.client.post(url)
self.bai_viet.refresh_from_db()
self.assertEqual(self.bai_viet.luot_xem, 2)
python manage.py test --parallel
coverage run --source='.' manage.py test && coverage report

Tối ưu N+1 — vấn đề hiệu năng số 1 của Django​

# ❌ N+1: 1 query lấy 100 bài + 100 query lấy tác giả = 101 query
for bv in BaiViet.objects.all():
print(bv.tac_gia.username) # mỗi lần là 1 query!

# ✅ 1 query duy nhất với JOIN
for bv in BaiViet.objects.select_related("tac_gia", "danh_muc"):
print(bv.tac_gia.username)

# ❌ N+1 với ManyToMany: 1 + 100 query
for bv in BaiViet.objects.all():
print([t.ten for t in bv.the.all()])

# ✅ prefetch_related: 2 query tổng cộng
for bv in BaiViet.objects.prefetch_related("the"):
print([t.ten for t in bv.the.all()])
HàmDùng choSố query
select_relatedForeignKey, OneToOne1 (JOIN)
prefetch_relatedManyToMany, quan hệ ngược2
Prefetchprefetch có lọc2

Công cụ phát hiện N+1:

# Cài django-debug-toolbar hoặc dùng assertNumQueries trong test
from django.test.utils import CaptureQueriesContext
from django.db import connection

with CaptureQueriesContext(connection) as ctx:
list(BaiViet.objects.select_related("tac_gia").all()[:20])
print(f"Số query: {len(ctx)}") # phải là 1, không phải 21

Cache:

from django.core.cache import cache
from django.views.decorators.cache import cache_page


@cache_page(60 * 15) # cache view 15 phút
def danh_sach_bai_viet(request):
...


def lay_bai_viet_noi_bat():
khoa = "bai_viet_noi_bat"
du_lieu = cache.get(khoa)
if du_lieu is None:
du_lieu = list(BaiViet.objects.filter(trang_thai="xuat_ban").order_by("-luot_xem")[:10])
cache.set(khoa, du_lieu, timeout=300)
return du_lieu

⏰ Tuần 13: Celery, tác vụ nền và signal​

pip install celery redis
# config/celery.py
import os
from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery("config")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
# blog/tasks.py
from celery import shared_task
from django.core.mail import send_mail
from django.utils import timezone
from datetime import timedelta


@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def gui_email_thong_bao(self, bai_viet_id: int):
from .models import BaiViet
try:
bv = BaiViet.objects.select_related("tac_gia").get(pk=bai_viet_id)
send_mail(
subject=f"Bài viết đã xuất bản: {bv.tieu_de}",
message=f"Bài viết của bạn đã được xuất bản: {bv.get_absolute_url()}",
from_email="noreply@example.com",
recipient_list=[bv.tac_gia.email],
fail_silently=False,
)
return f"Đã gửi email cho {bv.tac_gia.email}"
except Exception as exc:
raise self.retry(exc=exc)


@shared_task
def don_dep_ban_nhap_cu():
"""Xoá bản nháp không cập nhật trong 90 ngày."""
from .models import BaiViet
han = timezone.now() - timedelta(days=90)
so_luong, _ = BaiViet.objects.filter(trang_thai="nhap", ngay_cap_nhat__lt=han).delete()
return f"Đã xoá {so_luong} bản nháp cũ"
# Chạy worker (terminal riêng)
celery -A config worker -l info

# Chạy scheduler cho task định kỳ
celery -A config beat -l info

⚠️ Sai lầm phổ biến với Celery: truyền object model vào task thay vì id. Khi đó object bị serialize vào queue và có thể đã cũ hoặc không deserialize được. Luôn truyền id rồi truy vấn lại trong task.


🐳 Tuần 14–15: Docker và deploy​

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn

COPY . .

RUN python manage.py collectstatic --noinput
RUN useradd --create-home appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", \
"--workers", "4", "--timeout", "60", "--access-logfile", "-"]

settings.py cho production:

import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # bắt buộc, không có mặc định
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")

DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": os.environ.get("POSTGRES_HOST", "db"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
"CONN_MAX_AGE": 60, # tái sử dụng kết nối
"OPTIONS": {"connect_timeout": 10},
}
}

# Bảo mật production — bật hết
SECURE_SSL_REDIRECT = not DEBUG
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
SECURE_HSTS_SECONDS = 31536000 if not DEBUG else 0
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"

CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")

CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ.get("REDIS_URL", "redis://localhost:6379/1"),
}
}

LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"day_du": {"format": "{asctime} | {levelname:<8} | {name} | {message}", "style": "{"},
},
"handlers": {
"console": {"class": "logging.StreamHandler", "formatter": "day_du"},
},
"root": {"handlers": ["console"], "level": "INFO"},
}
# Migration trong pipeline deploy — không chạy tay
python manage.py migrate --noinput
python manage.py collectstatic --noinput

Checklist bảo mật Django trước khi lên production:

python manage.py check --deploy
  • DEBUG = False
  • SECRET_KEY từ env, đủ dài và ngẫu nhiên
  • ALLOWED_HOSTS khai báo rõ ràng
  • HTTPS bắt buộc + HSTS
  • Cookie Secure, HttpOnly, SameSite
  • CSRF_TRUSTED_ORIGINS cấu hình đúng
  • Backup database tự động + test khả năng phục hồi
  • Log tập trung, có cảnh báo lỗi 500

🎯 Tuần 16: Dự án tổng hợp​

Hệ thống quản lý nội dung có API — tận dụng tối đa thế mạnh của Django (admin) và DRF (API):

✅ Model đầy đủ quan hệ: FK, M2M, index, constraint
✅ Django Admin cấu hình đầy đủ (filter, search, action, badge)
✅ REST API với DRF: viewset, serializer lồng nhau, phân trang
✅ JWT auth + phân quyền theo vai trò
✅ Celery gửi email khi xuất bản bài
✅ Tối ưu: select_related, prefetch_related, cache Redis
✅ Test coverage > 70%
✅ Docker + docker-compose (web + postgres + redis + celery)
✅ Deploy có link thật, `check --deploy` sạch

🔍 Debug Django trong thực tế​

Debug là kỹ năng bạn dùng hàng ngày, nhưng hầu như không khoá học nào dạy.

Django shell — công cụ mạnh nhất​

python manage.py shell
>>> from blog.models import BaiViet
>>> from django.db import connection, reset_queries
>>> from django.conf import settings
>>> settings.DEBUG = True

>>> # Đếm số query của một đoạn code
>>> reset_queries()
>>> ds = list(BaiViet.objects.filter(trang_thai="xuat_ban"))
>>> [(b.tieu_de, b.tac_gia.username) for b in ds]
>>> print(f"Số query: {len(connection.queries)}")

>>> # Xem SQL thật
>>> print(BaiViet.objects.filter(trang_thai="xuat_ban").query)

>>> # Kiểm tra dữ liệu bẩn
>>> BaiViet.objects.filter(tom_tat="").count()
>>> BaiViet.objects.filter(ngay_xuat_ban__gt=timezone.now()).count()

💡 Mẹo: cài django-extensions rồi dùng python manage.py shell_plus — tự import toàn bộ model, tiết kiệm rất nhiều thời gian.

django-debug-toolbar — bắt N+1 ngay trên trình duyệt​

pip install django-debug-toolbar
# settings.py (chỉ bật khi DEBUG)
if DEBUG:
INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ["127.0.0.1"]

# urls.py
if settings.DEBUG:
import debug_toolbar
urlpatterns += [path("__debug__/", include(debug_toolbar.urls))]

Bảng SQL của toolbar hiển thị mọi truy vấn và thời gian. Nếu thấy 50 truy vấn giống nhau trên một trang, bạn đã gặp N+1.

Logging — biết chuyện gì đang xảy ra trên production​

import logging

logger = logging.getLogger(__name__)


def xuat_ban_bai_viet(bai_viet_id: int) -> bool:
from .models import BaiViet

logger.info("Bắt đầu xuất bản bài viết id=%s", bai_viet_id)

try:
bv = BaiViet.objects.get(pk=bai_viet_id)
except BaiViet.DoesNotExist:
logger.warning("Không tìm thấy bài viết id=%s", bai_viet_id)
return False

if bv.trang_thai == "xuat_ban":
logger.info("Bài viết id=%s đã xuất bản trước đó, bỏ qua", bai_viet_id)
return True

try:
with transaction.atomic():
bv.trang_thai = "xuat_ban"
bv.ngay_xuat_ban = timezone.now()
bv.save(update_fields=["trang_thai", "ngay_xuat_ban"])
except Exception:
logger.exception("Lỗi khi xuất bản bài viết id=%s", bai_viet_id)
raise

logger.info("Đã xuất bản bài viết id=%s", bai_viet_id)
return True

Ba cấp độ log và cách dùng đúng:

Cấp độDùng khiVí dụ
DEBUGChi tiết chỉ cần khi phát triểnGiá trị biến trung gian
INFOSự kiện bình thường, quan trọng"Đã xuất bản bài viết 42"
WARNINGBất thường nhưng hệ thống vẫn chạy"Không tìm thấy bài viết"
ERRORThao tác thất bại"Không gửi được email"
exception()Lỗi có tracebackTrong khối except

⚠️ Dùng logger.info("id=%s", id) thay vì logger.info(f"id={id}") — cách đầu chỉ format chuỗi khi log thực sự được ghi, tiết kiệm CPU khi log bị tắt.

Bảng lỗi Django hay gặp​

LỗiNguyên nhânCách sửa
no such tableChưa migratepython manage.py migrate
Table already existsMigration lệch trạng thái--fake-initial rồi kiểm tra showmigrations
TemplateDoesNotExistSai cấu trúc thư mụcCần app/templates/app/file.html
AppRegistryNotReadyImport model quá sớmImport trong hàm
Reverse for 'x' not foundSai name= hoặc thiếu app_nameKiểm tra urls.py
RelatedObjectDoesNotExistTruy cập OneToOne chưa cóDùng hasattr() hoặc try/except
Migration conflict khi làm nhómHai người tạo migration cùng sốmakemigrations --merge
Maximum recursion depthSignal gọi save() trong save()Dùng update() hoặc cờ _dang_luu

📦 Quản lý phụ thuộc và cấu hình môi trường​

Tách requirements theo môi trường​

requirements/
├── base.txt ← dùng chung
├── dev.txt ← thêm cho phát triển
└── prod.txt ← chỉ cho production

base.txt:

Django==5.1.*
djangorestframework==3.15.*
django-filter==24.*
psycopg[binary]==3.2.*
python-dotenv==1.0.*

dev.txt:

-r base.txt
pytest==8.*
pytest-django==4.*
coverage==7.*
django-debug-toolbar==4.*
ruff==0.6.*
black==24.*
factory-boy==3.*

prod.txt:

-r base.txt
gunicorn==23.*
whitenoise==6.*
sentry-sdk==2.*
django-redis==5.*

Cách này giữ image production nhỏ (không mang theo pytest, debug toolbar) và giảm rủi ro bảo mật.

Đọc cấu hình bằng environ​

# settings.py
import os
from pathlib import Path

def env_bool(ten: str, mac_dinh: bool = False) -> bool:
return os.environ.get(ten, str(mac_dinh)).lower() in ("1", "true", "yes", "on")


def env_list(ten: str, mac_dinh: list[str] | None = None) -> list[str]:
gia_tri = os.environ.get(ten, "")
return [x.strip() for x in gia_tri.split(",") if x.strip()] or (mac_dinh or [])


SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = env_bool("DJANGO_DEBUG")
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", ["localhost"])
CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS")

if not DEBUG and SECRET_KEY.startswith("django-insecure"):
raise RuntimeError("SECRET_KEY không an toàn đang được dùng ở production!")

💡 Kiểm tra if not DEBUG này gây crash ngay khi khởi động. Đó là điều bạn muốn — tốt hơn nhiều so với việc phát hiện ra sau khi bị tấn công.

Quản lý file tĩnh và media​

LoạiLà gìPhục vụ bởi
staticCSS, JS, ảnh của bạnnginx, whitenoise, CDN
mediaFile người dùng uploadnginx, S3, Cloudinary
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]

MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

⚠️ Django không phục vụ file tĩnh ở production. runserver làm được điều đó, nhưng đó chỉ là tính năng của môi trường phát triển. Production phải dùng nginx/whitenoise/CDN — nếu không, hiệu năng sẽ rất tệ.


🤝 Làm việc nhóm trong dự án Django​

Quy ước code — bắt buộc phải tự động hoá​

# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = [
"E", "W", # pycodestyle
"F", # pyflakes
"I", # isort
"B", # bugbear — bắt lỗi logic phổ biến
"DJ", # flake8-django
"S", # bandit — bảo mật
]
ignore = ["E501"]

[tool.ruff.lint.per-file-ignores]
"**/settings*.py" = ["F405"]
"**/migrations/*" = ["E501", "S"]
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ["--maxkb=1000"]

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: ["--fix"]
- id: ruff-format
pip install pre-commit
pre-commit install

Từ giờ mọi commit đều tự động kiểm tra và format. Đây là việc quan trọng nhất bạn có thể làm trong ngày đầu tiên ở bất kỳ dự án nào.

Xử lý conflict migration khi làm nhóm​

Đây là vấn đề khiến nhiều người mất hàng giờ:

Hai người cùng tạo migration sau 0005_moi.py:
Người A: 0006_them_truong_a.py
Người B: 0006_them_truong_b.py ← trùng số!

Quy trình xử lý:

# 1. Pull code mới nhất
# 2. Django sẽ báo conflict hoặc tạo 2 migration cùng tên
# 3. Chạy merge để gộp thành một nhánh tuyến tính
python manage.py makemigrations --merge --no-input

# 4. Kiểm tra lại
python manage.py showmigrations app
python manage.py migrate --plan

Ba quy tắc phòng ngừa:

  1. Luôn tạo migration ngay sau khi sửa model, đừng để tích tụ.
  2. Test migration cả hai chiều trên database copy: migrate app 0001 rồi migrate app.
  3. Không bao giờ sửa migration đã được merge vào main. Nếu sai, tạo migration mới để sửa.

Migration có rủi ro mất dữ liệu — phải làm theo 2 bước​

Đổi tên cột trực tiếp sẽ khiến Django hiểu là xoá cột cũ và tạo cột mới:

# BƯỚC 1: thêm cột mới, giữ cột cũ
class BaiViet(models.Model):
tieu_de_moi = models.CharField(max_length=250, null=True) # cho phép null tạm

# BƯỚC 2: sau khi deploy và migrate, chạy data migration copy dữ liệu
# migrations/0008_copy_tieu_de.py
def copy_du_lieu(apps, schema_editor):
BaiViet = apps.get_model("blog", "BaiViet")
BaiViet.objects.update(tieu_de_moi=models.F("tieu_de"))

# BƯỚC 3 (deploy sau): xoá cột cũ

Với dự án lớn, mỗi bước là một lần deploy riêng. Với bảng 50 triệu dòng, ALTER TABLE có thể khoá bảng vài phút — cần kế hoạch bảo trì.


⚠️ 8 sai lầm khiến dự án Django khó bảo trì​

  1. Logic nghiệp vụ trong view. Tách ra services.py.
  2. Fat model với 2000 dòng. Chia thành mixin hoặc module riêng.
  3. Không tối ưu truy vấn. N+1 là nguyên nhân làm chậm số 1.
  4. Dùng FloatField cho tiền. Dùng DecimalField.
  5. Đọc rồi ghi thay vì dùng F(). Race condition.
  6. Không dùng migration cho thay đổi schema. create_all() chỉ có trong tutorial.
  7. Bỏ qua admin.py. Bạn đang vứt đi lợi thế lớn nhất của Django.
  8. Không viết test. Model phình ra, không ai dám sửa.

❓ Câu hỏi thường gặp​

Django có phù hợp cho API thuần không? Có, DRF rất mạnh. Nhưng nếu bạn chỉ cần API và không dùng admin/ORM template, FastAPI thường gọn hơn.

Nên học Django hay DRF trước? Django trước. DRF dựa hoàn toàn trên ORM và model của Django. Học DRF khi chưa hiểu ORM là công thức của việc copy-paste.

Django có hỗ trợ async không? Có (từ 3.1) cho view và ORM (từ 4.1), nhưng hệ sinh thái chưa async hoàn toàn. Nếu async là yêu cầu trung tâm, FastAPI phù hợp hơn.

Django Admin có dùng cho khách hàng được không? Không nên. Admin là công cụ nội bộ. Khách hàng cần giao diện riêng. Dùng admin cho nhân viên vận hành, không cho người dùng cuối.

Bao lâu để đi làm với Django? Nếu đã biết Python + SQL: 4–6 tháng. Nếu học từ đầu: 9–12 tháng.


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

Lộ trình học Python để trở thành Backend Developer

· 26 phút để đọc

Backend developer là người xây dựng "phần chìm của tảng băng": API, database, xác thực, xử lý nghiệp vụ — tất cả những gì người dùng không nhìn thấy nhưng nếu hỏng thì cả sản phẩm sập.

Python là một trong những lựa chọn tốt nhất để bắt đầu, nhờ cú pháp rõ ràng và ba framework trưởng thành: Flask, Django, FastAPI.

Bài viết này là lộ trình 12 tháng thực tế, không phải danh sách từ khoá. Mỗi giai đoạn đều có: cần học gì, code mẫu, dự án phải làm, và tiêu chí để biết đã sẵn sàng bước tiếp.


🗺️ Bức tranh toàn cảnh​

Giai đoạn 1 (tuần 1–8)    Python nền tảng + OOP
Giai đoạn 2 (tuần 9–12) Git + Linux + công cụ
Giai đoạn 3 (tuần 13–20) Web cơ bản + Flask
Giai đoạn 4 (tuần 21–28) Database + SQL + ORM
Giai đoạn 5 (tuần 29–36) API chuyên nghiệp + Auth + Testing
Giai đoạn 6 (tuần 37–44) Django hoặc FastAPI (chọn 1)
Giai đoạn 7 (tuần 45–48) Docker + Deploy + CI/CD
Giai đoạn 8 (tuần 49–52) Dự án tổng hợp + phỏng vấn

Backend developer thực sự làm gì mỗi ngày?

Hoạt độngTỷ lệ thời gian
Viết code tính năng mới30%
Đọc và hiểu code cũ20%
Debug và sửa lỗi20%
Review code của đồng nghiệp10%
Viết test10%
Họp, trao đổi thiết kế, tài liệu10%

💡 Chú ý: chỉ 30% thời gian là "viết code mới". Vì vậy đọc code và debug là kỹ năng quan trọng ngang viết code.


🧱 Giai đoạn 1 (tuần 1–8): Python nền tảng và OOP​

Nội dung​

TuầnChủ đềKỹ năng đạt được
1Biến, kiểu dữ liệu, toán tử, input/printViết script tính toán
2if/elif/else, vòng lặpXử lý logic
3List, tuple, dict, setChọn cấu trúc dữ liệu đúng
4Hàm, tham số, *args/**kwargs, scopeChia nhỏ chương trình
5Xử lý file, CSV, JSON, pathlibĐọc/ghi dữ liệu
6Ngoại lệ, try/except/else/finallyCode không crash
7OOP: class, kế thừa, magic methodsThiết kế đối tượng
8Module, package, virtualenv, pipTổ chức dự án

Điều backend developer phải nắm chắc ở giai đoạn này​

1. Chọn đúng cấu trúc dữ liệu — đây là kỹ năng bị đánh giá thấp nhất:

Cấu trúcTruy cậpThêm/XoáDùng khi
listO(1) theo indexO(n) ở đầu, O(1) ở cuốiDữ liệu có thứ tự
dictO(1) theo keyO(1)Tra cứu theo khoá
setO(1) kiểm tra tồn tạiO(1)Loại trùng, kiểm tra membership
tupleO(1)Không đổiDữ liệu bất biến
# ❌ Tìm kiếm trong list — O(n), chậm khi dữ liệu lớn
danh_sach_email = ["a@x.com", "b@x.com", "c@x.com"]
if "c@x.com" in danh_sach_email: # quét từng phần tử
...

# ✅ Dùng set — O(1)
tap_email = {"a@x.com", "b@x.com", "c@x.com"}
if "c@x.com" in tap_email: # tra trực tiếp
...

Với 1 triệu bản ghi, khác biệt này là giây vs micro-giây.

2. try/except đúng cách — không nuốt lỗi:

# ❌ Bắt mọi lỗi, che giấu bug thật
try:
ket_qua = xu_ly_du_lieu(duong_dan)
except Exception:
pass

# ✅ Bắt lỗi cụ thể, log lại, xử lý đúng cách
import logging

logger = logging.getLogger(__name__)

try:
with open(duong_dan, encoding="utf-8") as f:
ket_qua = xu_ly(f.read())
except FileNotFoundError:
logger.warning("Không tìm thấy file %s, dùng cấu hình mặc định", duong_dan)
ket_qua = CAU_HINH_MAC_DINH
except json.JSONDecodeError as e:
logger.error("File %s không phải JSON hợp lệ: %s", duong_dan, e)
raise

3. OOP dùng cho việc thật, không phải class Animal:

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import Decimal


class PhuongThucThanhToan(ABC):
"""Interface cho mọi phương thức thanh toán."""

@abstractmethod
def thanh_toan(self, so_tien: Decimal) -> bool:
...

@property
@abstractmethod
def ten(self) -> str:
...


@dataclass
class ThanhToanThe(PhuongThucThanhToan):
so_the: str
han_dung: str

@property
def ten(self) -> str:
return f"Thẻ ****{self.so_the[-4:]}"

def thanh_toan(self, so_tien: Decimal) -> bool:
if so_tien <= 0:
raise ValueError("Số tiền phải dương")
# gọi cổng thanh toán...
return True


@dataclass
class ThanhToanViDienTu(PhuongThucThanhToan):
ma_vi: str
so_du: Decimal = field(default=Decimal("0"))

@property
def ten(self) -> str:
return f"Ví {self.ma_vi}"

def thanh_toan(self, so_tien: Decimal) -> bool:
if self.so_du < so_tien:
return False
self.so_du -= so_tien
return True


for pt in [ThanhToanThe("4111111111111234", "12/28"),
ThanhToanViDienTu("VI-001", Decimal("500000"))]:
ok = pt.thanh_toan(Decimal("200000"))
print(f"{pt.ten}: {'✅ thành công' if ok else '❌ thất bại'}")

Ở đây bạn học: ABC, @abstractmethod, @dataclass, Decimal cho tiền, @property — tất cả đều dùng hàng ngày ở backend.

Dự án giai đoạn 1​

Công cụ dòng lệnh quản lý chi tiêu — lưu vào JSON:

import json
from pathlib import Path
from datetime import date
from decimal import Decimal

FILE = Path("chi-tieu.json")


def doc_du_lieu() -> list[dict]:
if not FILE.exists():
return []
return json.loads(FILE.read_text(encoding="utf-8"))


def luu_du_lieu(ds: list[dict]) -> None:
FILE.write_text(json.dumps(ds, ensure_ascii=False, indent=2), encoding="utf-8")


def them(so_tien: str, danh_muc: str, ghi_chu: str = "") -> None:
ds = doc_du_lieu()
ds.append({
"ngay": date.today().isoformat(),
"so_tien": str(Decimal(so_tien)),
"danh_muc": danh_muc,
"ghi_chu": ghi_chu,
})
luu_du_lieu(ds)
print(f"✅ Đã thêm {so_tien} cho {danh_muc}")


def bao_cao_theo_danh_muc() -> None:
ds = doc_du_lieu()
if not ds:
print("Chưa có dữ liệu.")
return
tong: dict[str, Decimal] = {}
for item in ds:
tong[item["danh_muc"]] = tong.get(item["danh_muc"], Decimal(0)) + Decimal(item["so_tien"])
for danh_muc, so in sorted(tong.items(), key=lambda x: -x[1]):
print(f"{danh_muc:<16} {so:>12,.0f} đ")
print(f"{'TỔNG':<16} {sum(tong.values()):>12,.0f} đ")


if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Dùng: python chi_tieu.py them <số_tiền> <danh_mục>")
print(" python chi_tieu.py bao-cao")
sys.exit(1)

lenh = sys.argv[1]
if lenh == "them":
them(sys.argv[2], sys.argv[3], " ".join(sys.argv[4:]))
elif lenh == "bao-cao":
bao_cao_theo_danh_muc()
else:
print(f"Lệnh không hợp lệ: {lenh}")

Tiêu chí hoàn thành giai đoạn 1​

  • Viết được chương trình 150+ dòng chia thành nhiều hàm, không cần tra cú pháp
  • Giải thích được khi nào dùng list vs dict vs set
  • Dùng try/except đúng chỗ, không nuốt lỗi
  • Biết tạo venv và cài package

🛠️ Giai đoạn 2 (tuần 9–12): Git, Linux và công cụ​

Nhiều người bỏ qua giai đoạn này và trả giá suốt sự nghiệp.

Git — bắt buộc​

# Cấu hình lần đầu
git config --global user.name "Tên Bạn"
git config --global user.email "email@example.com"

# Workflow cơ bản hàng ngày
git status
git add .
git commit -m "feat: thêm chức năng đăng nhập"
git push origin main

# Làm việc nhánh — KỸ NĂNG QUAN TRỌNG NHẤT
git checkout -b feature/them-dang-nhap
# ... code ...
git commit -m "feat: hoàn thiện đăng nhập"
git push origin feature/them-dang-nhap
# rồi mở Pull Request trên GitHub

Conventional Commits — quy ước commit mà mọi team chuyên nghiệp dùng:

Tiền tốÝ nghĩa
feat:Thêm tính năng
fix:Sửa bug
docs:Sửa tài liệu
refactor:Tái cấu trúc, không đổi hành vi
test:Thêm/sửa test
chore:Việc lặt vặt (deps, config)

Viết commit rõ ràng giúp bạn được đánh giá cao hơn ngay từ ngày đầu đi làm.

Linux / command line cơ bản​

LệnhCông dụng
ls -laLiệt kê file, gồm file ẩn
cd, pwdDi chuyển, xem thư mục hiện tại
grep -rn "text" .Tìm text trong toàn bộ thư mục
find . -name "*.py"Tìm file theo mẫu
tail -f app.logXem log đang chạy (dùng hàng ngày!)
ps aux | grep pythonXem tiến trình Python
chmod +x script.shCấp quyền chạy
ssh user@serverKết nối server
curl -X POST ...Test API từ terminal

Công cụ nên cài​

Công cụVai trò
VS Code hoặc PyCharmIDE
Postman / InsomniaTest API trực quan
TablePlus / DBeaverXem database
Docker DesktopChạy database local dễ dàng

🌐 Giai đoạn 3 (tuần 13–20): Web hoạt động thế nào + Flask​

Kiến thức web bắt buộc​

HTTP request/response — nền tảng của mọi thứ:

GET /api/san-pham?page=2 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGc...
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json

{"data": [...], "page": 2, "total": 47}

Bảng mã trạng thái — phải thuộc lòng:

NhómMãÝ nghĩaKhi nào dùng
2xx200OKGET/PUT thành công
2xx201CreatedPOST tạo mới thành công
2xx204No ContentDELETE thành công
4xx400Bad RequestDữ liệu gửi lên sai định dạng
4xx401UnauthorizedChưa đăng nhập
4xx403ForbiddenĐã đăng nhập nhưng không có quyền
4xx404Not FoundKhông tìm thấy tài nguyên
4xx409ConflictTrùng dữ liệu (email đã tồn tại)
4xx422UnprocessableDữ liệu đúng định dạng nhưng sai logic
4xx429Too Many RequestsVượt rate limit
5xx500Internal Server ErrorLỗi phía server (bug)
5xx503Service UnavailableServer quá tải/bảo trì

⚠️ Lỗi kinh điển của người mới: trả về 200 OK kèm {"success": false, "error": "..."}. Điều này phá vỡ mọi thư viện client. Hãy dùng đúng mã trạng thái.

Flask — hiểu web từ gốc​

from flask import Flask, jsonify, request, abort

app = Flask(__name__)

# "Database" trong bộ nhớ
SACH = {
1: {"id": 1, "ten": "Lập trình Python", "gia": 250000},
2: {"id": 2, "ten": "Cấu trúc dữ liệu", "gia": 180000},
}
_bo_dem = 2


@app.get("/api/sach")
def danh_sach_sach():
return jsonify(list(SACH.values()))


@app.get("/api/sach/<int:sach_id>")
def chi_tiet_sach(sach_id):
sach = SACH.get(sach_id)
if sach is None:
abort(404, description=f"Không tìm thấy sách id={sach_id}")
return jsonify(sach)


@app.post("/api/sach")
def them_sach():
global _bo_dem
du_lieu = request.get_json(silent=True) or {}

if not du_lieu.get("ten"):
abort(400, description="Thiếu trường 'ten'")
if not isinstance(du_lieu.get("gia"), int) or du_lieu["gia"] <= 0:
abort(400, description="'gia' phải là số nguyên dương")

_bo_dem += 1
SACH[_bo_dem] = {"id": _bo_dem, **du_lieu}
return jsonify(SACH[_bo_dem]), 201


@app.put("/api/sach/<int:sach_id>")
def cap_nhat_sach(sach_id):
if sach_id not in SACH:
abort(404)
du_lieu = request.get_json(silent=True) or {}
SACH[sach_id] = {"id": sach_id, **du_lieu}
return jsonify(SACH[sach_id])


@app.delete("/api/sach/<int:sach_id>")
def xoa_sach(sach_id):
if SACH.pop(sach_id, None) is None:
abort(404)
return "", 204


@app.errorhandler(404)
def khong_tim_thay(e):
return jsonify({"loi": str(e.description)}), 404


@app.errorhandler(400)
def du_lieu_sai(e):
return jsonify({"loi": str(e.description)}), 400

Đọc kỹ đoạn này — nó chứa gần hết những gì cần biết về REST API cơ bản: routing, path param, validate, mã trạng thái, error handler.

Dự án giai đoạn 3​

API quản lý thư viện — CRUD đầy đủ cho 2 bảng (sách, người mượn), có validate và error handler. Đây là dự án bạn sẽ nâng cấp qua các giai đoạn sau.


🗄️ Giai đoạn 4 (tuần 21–28): Database và ORM​

SQL — không thể bỏ qua​

Cho dù dùng ORM, bạn phải hiểu SQL. Không hiểu SQL nghĩa là không debug được query chậm.

-- Tạo bảng với ràng buộc đầy đủ
CREATE TABLE tac_gia (
id SERIAL PRIMARY KEY,
ten VARCHAR(120) NOT NULL,
email VARCHAR(120) UNIQUE NOT NULL
);

CREATE TABLE sach (
id SERIAL PRIMARY KEY,
ten VARCHAR(200) NOT NULL,
gia NUMERIC(12,0) NOT NULL CHECK (gia > 0),
tac_gia_id INTEGER NOT NULL REFERENCES tac_gia(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW()
);

-- INDEX — thứ quyết định query nhanh hay chậm
CREATE INDEX idx_sach_tac_gia ON sach(tac_gia_id);

-- JOIN + GROUP BY + HAVING
SELECT t.ten,
COUNT(s.id) AS so_sach,
SUM(s.gia) AS tong_gia_tri
FROM tac_gia t
LEFT JOIN sach s ON s.tac_gia_id = t.id
GROUP BY t.id, t.ten
HAVING COUNT(s.id) > 1
ORDER BY tong_gia_tri DESC
LIMIT 10;

Bốn khái niệm SQL quan trọng nhất:

Khái niệmÝ nghĩaVì sao quan trọng
INDEXCấu trúc tăng tốc tra cứuQuery 5 giây → 5 mili-giây
JOINKết hợp nhiều bảng90% query thật cần JOIN
TRANSACTIONNhóm thao tác "tất cả hoặc không gì cả"Chuyển tiền: trừ A và cộng B phải cùng thành công
EXPLAINXem kế hoạch thực thi queryCông cụ debug hiệu năng số 1
-- Luôn dùng EXPLAIN khi query chậm
EXPLAIN ANALYZE SELECT * FROM sach WHERE tac_gia_id = 5;

ORM với SQLAlchemy​

from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Numeric, ForeignKey, DateTime, func, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session


class Base(DeclarativeBase):
pass


class TacGia(Base):
__tablename__ = "tac_gia"

id: Mapped[int] = mapped_column(primary_key=True)
ten: Mapped[str] = mapped_column(String(120))
email: Mapped[str] = mapped_column(String(120), unique=True)

sach: Mapped[list["Sach"]] = relationship(back_populates="tac_gia", cascade="all, delete-orphan")

def __repr__(self) -> str:
return f"TacGia(id={self.id}, ten={self.ten!r})"


class Sach(Base):
__tablename__ = "sach"

id: Mapped[int] = mapped_column(primary_key=True)
ten: Mapped[str] = mapped_column(String(200))
gia: Mapped[Decimal] = mapped_column(Numeric(12, 0))
tac_gia_id: Mapped[int] = mapped_column(ForeignKey("tac_gia.id"))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

tac_gia: Mapped[TacGia] = relationship(back_populates="sach")


# Sử dụng
with Session(engine) as session:
tg = TacGia(ten="Nguyễn Văn A", email="a@example.com")
tg.sach = [Sach(ten="Python cơ bản", gia=Decimal("250000"))]
session.add(tg)
session.commit()

# Truy vấn với eager loading — tránh N+1 query
stmt = (
select(TacGia)
.join(TacGia.sach)
.where(Sach.gia > 200000)
.options(selectinload(TacGia.sach))
)
for tac_gia in session.scalars(stmt):
print(tac_gia.ten, [s.ten for s in tac_gia.sach])

🔥 Vấn đề N+1 query là lỗi hiệu năng phổ biến nhất với ORM. Nếu bạn lặp qua 100 tác giả và mỗi lần truy cập tac_gia.sach lại phát sinh 1 query → 101 query. selectinload/joinedload giải quyết việc này.

Dự án giai đoạn 4​

Nâng cấp API thư viện: chuyển từ dict trong bộ nhớ sang PostgreSQL + SQLAlchemy, thêm relationship, thêm index, viết 5 query phức tạp có JOIN + GROUP BY.


🔐 Giai đoạn 5 (tuần 29–36): API chuyên nghiệp​

Xác thực với JWT​

from datetime import datetime, timedelta, timezone
from passlib.context import CryptContext
import jwt

SECRET = "đọc-từ-biến-môi-trường-trong-thực-tế"
THUAT_TOAN = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def bam_mat_khau(mat_khau: str) -> str:
return pwd_context.hash(mat_khau)


def kiem_tra_mat_khau(mat_khau: str, da_bam: str) -> bool:
return pwd_context.verify(mat_khau, da_bam)


def tao_token(user_id: int, vai_tro: str = "user") -> str:
payload = {
"sub": str(user_id),
"vai_tro": vai_tro,
"exp": datetime.now(timezone.utc) + timedelta(hours=24),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, SECRET, algorithm=THUAT_TOAN)


def giai_ma_token(token: str) -> dict:
return jwt.decode(token, SECRET, algorithms=[THUAT_TOAN])

Ba quy tắc bảo mật không được vi phạm:

  1. Không bao giờ lưu mật khẩu dạng thô — luôn băm bằng bcrypt/argon2 (không dùng MD5/SHA1).
  2. Không bao giờ để secret trong code — dùng biến môi trường.
  3. Luôn kiểm tra quyền — xác thực (bạn là ai) khác uỷ quyền (bạn được làm gì).

Phân quyền​

from functools import wraps
from flask import request, jsonify, g


def yeu_cau_dang_nhap(f):
@wraps(f)
def wrapper(*args, **kwargs):
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return jsonify({"loi": "Thiếu token"}), 401
try:
g.nguoi_dung = giai_ma_token(header[7:])
except jwt.ExpiredSignatureError:
return jsonify({"loi": "Token đã hết hạn"}), 401
except jwt.InvalidTokenError:
return jsonify({"loi": "Token không hợp lệ"}), 401
return f(*args, **kwargs)
return wrapper


def yeu_cau_quyen(vai_tro_can: str):
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
if g.nguoi_dung.get("vai_tro") != vai_tro_can:
return jsonify({"loi": "Không có quyền truy cập"}), 403
return f(*args, **kwargs)
return wrapper
return decorator


@app.delete("/api/sach/<int:sach_id>")
@yeu_cau_dang_nhap
@yeu_cau_quyen("admin")
def xoa_sach(sach_id):
...

Validation với Pydantic​

from pydantic import BaseModel, Field, EmailStr, field_validator
from decimal import Decimal


class SachVao(BaseModel):
ten: str = Field(min_length=1, max_length=200)
gia: Decimal = Field(gt=0, le=100_000_000)
tac_gia_email: EmailStr

@field_validator("ten")
@classmethod
def chuan_hoa_ten(cls, v: str) -> str:
return " ".join(v.split())


sach = SachVao.model_validate({"ten": " Python cơ bản ", "gia": "250000",
"tac_gia_email": "a@example.com"})
print(sach.ten) # "Python cơ bản" (đã chuẩn hoá)

Testing — kỹ năng phân biệt junior và mid​

import pytest
from app import app, SACH


@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as c:
yield c


@pytest.fixture(autouse=True)
def reset_du_lieu():
"""Mỗi test chạy trên dữ liệu sạch."""
goc = SACH.copy()
yield
SACH.clear()
SACH.update(goc)


def test_lay_danh_sach(client):
r = client.get("/api/sach")
assert r.status_code == 200
assert len(r.get_json()) == 2


def test_lay_sach_khong_ton_tai(client):
r = client.get("/api/sach/9999")
assert r.status_code == 404
assert "loi" in r.get_json()


def test_them_sach_thieu_ten(client):
r = client.post("/api/sach", json={"gia": 100000})
assert r.status_code == 400


def test_them_sach_thanh_cong(client):
r = client.post("/api/sach", json={"ten": "Sách mới", "gia": 99000})
assert r.status_code == 201
assert r.get_json()["ten"] == "Sách mới"


@pytest.mark.parametrize("gia", [0, -100, "abc", None])
def test_them_sach_gia_khong_hop_le(client, gia):
r = client.post("/api/sach", json={"ten": "X", "gia": gia})
assert r.status_code == 400

Chạy: pytest -v --cov=app để xem độ phủ test.


🚀 Giai đoạn 6 (tuần 37–44): Chọn Django hoặc FastAPI​

Sau khi đã hiểu Flask và web từ gốc, hãy chọn một hướng chuyên sâu.

Chọn Django nếu bạn muốn​

  • Làm sản phẩm hoàn chỉnh nhanh: có admin, auth, ORM, form sẵn.
  • Làm việc ở công ty có nhiều dự án web truyền thống.
  • Cần CMS, ERP, hệ thống quản lý nội bộ.

Điểm mạnh độc nhất: Django Admin cho bạn giao diện quản trị hoàn chỉnh miễn phí.

Chọn FastAPI nếu bạn muốn​

  • Làm API cho mobile/frontend hoặc phục vụ model ML.
  • Cần hiệu năng cao, xử lý nhiều request đồng thời.
  • Thích code hiện đại với type hints và tự sinh tài liệu.

Điểm mạnh độc nhất: tài liệu Swagger tự sinh — bạn giao API cho team frontend và họ tự đọc.

Nếu chưa quyết được: học FastAPI trước (nhỏ hơn, học nhanh), rồi học Django sau (dễ hơn nhiều khi đã nắm web).


🐳 Giai đoạn 7 (tuần 45–48): Docker, deploy, CI/CD​

Docker — đóng gói để "chạy được ở mọi máy"​

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN useradd --create-home appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4"]
# docker-compose.yml — chạy app + database cùng lúc
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s

api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://app:secret@db:5432/mydb
depends_on:
db:
condition: service_healthy

volumes:
pgdata:
docker compose up --build     # chạy toàn bộ hệ thống bằng 1 lệnh

CI/CD với GitHub Actions​

name: CI

on:
push:
branches: [main]
pull_request:

jobs:
kiem-tra:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 5

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Cài phụ thuộc
run: pip install -r requirements.txt -r requirements-dev.txt

- name: Kiểm tra định dạng
run: ruff check .

- name: Kiểm tra kiểu
run: mypy app

- name: Chạy test
run: pytest -v --cov=app --cov-report=term-missing
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/testdb

- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .

Checklist bảo mật trước khi deploy​

  • DEBUG = False trên production
  • Mọi secret đọc từ biến môi trường
  • HTTPS bắt buộc
  • CORS chỉ cho domain cụ thể (không *)
  • Rate limiting trên endpoint đăng nhập
  • Validate toàn bộ input, dùng tham số hoá SQL
  • Có logging và health check
  • Backup database tự động

🎯 Giai đoạn 8 (tuần 49–52): Dự án tổng hợp và phỏng vấn​

Dự án tổng hợp — chọn 1, làm thật sâu​

Ý tưởngCông nghệĐiểm mạnh khi phỏng vấn
Nền tảng blog có APIFastAPI + PostgreSQL + JWT + DockerThể hiện mọi kỹ năng
Hệ thống đặt hàngDjango + DRF + Celery + RedisThể hiện xử lý nghiệp vụ phức tạp
API phân tích dữ liệu công khaiFastAPI + pandas + cacheThể hiện xử lý dữ liệu + hiệu năng
Hệ thống quản lý nội bộDjango + Admin + PostgreSQLGần với công việc thật nhất

Checklist dự án đủ tốt:

  • Có README với ảnh chụp màn hình
  • Có Docker, chạy được bằng 1 lệnh
  • Có test với coverage > 70%
  • Có CI chạy tự động
  • Có tài liệu API (Swagger/OpenAPI)
  • Có deploy thật, có link truy cập được
  • Có migration database
  • Có xử lý lỗi và logging tử tế

Bộ câu hỏi phỏng vấn backend Python​

Python:

  1. List comprehension và generator expression khác nhau thế nào?
  2. *args và **kwargs — dùng khi nào?
  3. Vì sao def f(x=[]) là lỗi? Giải thích cơ chế.
  4. Shallow copy và deep copy — phân biệt và ví dụ.
  5. Decorator hoạt động ra sao? Viết decorator đo thời gian.
  6. GIL ảnh hưởng gì tới đa luồng? Khi nào dùng multiprocessing?
  7. __slots__ dùng để làm gì?
  8. Context manager là gì? Viết một cái.

Web & API:

  1. Khi nào dùng PUT và khi nào dùng PATCH?
  2. Làm sao chống spam/brute-force cho endpoint đăng nhập?
  3. Xử lý file upload lớn như thế nào?
  4. Idempotency trong API là gì? Vì sao quan trọng với thanh toán?
  5. CORS là gì? Vì sao browser chặn request?
  6. Làm sao versioning API?

Database:

  1. Index hoạt động thế nào? Khi nào index làm chậm?
  2. Transaction isolation level — có mấy mức?
  3. N+1 query là gì và cách phát hiện?
  4. Vì sao dùng connection pool?
  5. Optimistic vs pessimistic locking?

Kiến trúc:

  1. Khi nào cần cache? Cache invalidation ra sao?
  2. Xử lý tác vụ nặng (gửi email, xử lý ảnh) — dùng gì?
  3. Làm sao scale ứng dụng khi có 1 triệu người dùng?
  4. Monolith vs microservices — khi nào chọn cái nào?

📖 Kỹ năng đọc code — thứ không ai dạy bạn​

Trường học dạy bạn viết code. Không ai dạy bạn đọc code — nhưng đó là 50% công việc thật.

Khi vào công ty, việc đầu tiên của bạn gần như chắc chắn là: "Đọc codebase này đi, rồi sửa một bug nhỏ." Codebase có thể 50.000 dòng, do 20 người viết trong 5 năm, không có tài liệu.

Cách đọc một codebase lạ​

Bước 1 — Chạy được nó trước. Đừng đọc. Hãy chạy:

git clone <repo>
cd <repo>
python -m venv .venv && .venv\Scripts\activate
pip install -r requirements.txt
# đọc README để biết cách chạy
pytest

Nếu test chạy được, bạn đã có "lưới an toàn": sửa sai sẽ bị test phát hiện.

Bước 2 — Tìm điểm vào (entry point). Với web app, đó là nơi route được đăng ký:

# Tìm tất cả route
grep -rn "@app.route\|@app.get\|@app.post" app/
grep -rn "urlpatterns\|APIRouter" .

Bước 3 — Đi theo một luồng duy nhất. Đừng đọc lan man. Chọn một API endpoint, rồi lần theo:

Request → Route → Validation → Business logic → Database → Response

Ví dụ với POST /api/don-hang:

  1. Route nằm ở file nào?
  2. Hàm route gọi hàm nào tiếp?
  3. Dữ liệu được validate ở đâu?
  4. Logic nghiệp vụ nằm ở service nào, hay nằm luôn trong route?
  5. Truy vấn database nằm ở đâu?

Chỉ cần làm điều này với 3–5 endpoint là bạn đã hiểu kiến trúc dự án.

Bước 4 — Vẽ sơ đồ. Vẽ ra giấy cấu trúc thư mục và luồng dữ liệu. Nghe có vẻ trẻ con, nhưng nó giúp bạn nhớ và giúp bạn đặt câu hỏi đúng khi hỏi đồng nghiệp.

Bước 5 — Sửa bug nhỏ đầu tiên. Chọn bug nhỏ nhất trong issue tracker. Sửa nó, viết test cho nó, mở Pull Request. Đây là cách nhanh nhất để được coi là thành viên thật của team.

Đọc source của thư viện bạn dùng​

Đây là cách học nhanh nhất mà ít người làm. Khi bạn không hiểu Flask hoặc FastAPI xử lý một thứ gì đó, hãy mở source:

# Xem source ngay trong terminal
python -c "import flask, inspect; print(inspect.getsourcefile(flask))"

# Hoặc trong Python
import requests, inspect
print(inspect.getsource(requests.Session.request))

Đọc code của thư viện nổi tiếng giúp bạn thấy cách người giỏi viết code: cách họ đặt tên, cách họ xử lý lỗi, cách họ tổ chức module.


🧪 Cách luyện tập hiệu quả trong 12 tháng​

Quy tắc 70/20/10​

Tỷ lệHoạt độngGhi chú
70%Tự viết code, làm dự ánPhần quan trọng nhất
20%Đọc code người khác, đọc docs/sourceHọc từ người giỏi
10%Xem video, đọc sách, học lý thuyếtChỉ để gợi mở

Hầu hết người mới làm ngược lại: 80% xem video, 20% gõ code. Đó là lý do họ học mãi không tiến bộ.

Cách luyện "không copy-paste"​

Cách duy nhất để thực sự nhớ:

  1. Đọc ví dụ trong tài liệu.
  2. Đóng tài liệu lại.
  3. Viết lại từ đầu, không nhìn.
  4. Bí quá thì mở ra xem 5 giây rồi đóng lại.
  5. Sau khi chạy được, so sánh code bạn viết với code mẫu — khác chỗ nào, vì sao?

Cách này đau đớn hơn nhiều so với copy-paste. Nhưng nó là sự khác biệt giữa "biết" và "biết làm".

Lịch luyện tập mẫu theo tuần​

NgàyThời lượngNội dung
Thứ 290 phútHọc khái niệm mới + bài tập
Thứ 390 phútLuyện thuật toán (LeetCode easy)
Thứ 490 phútLàm dự án cá nhân
Thứ 590 phútĐọc code người khác hoặc đọc source thư viện
Thứ 690 phútLàm dự án cá nhân
Thứ 7120 phútHoàn thiện dự án + viết README
Chủ nhậtNghỉNão cần thời gian củng cố

Tổng: khoảng 10–11 giờ/tuần. Con số này thực tế hơn nhiều so với kế hoạch "học 4 giờ mỗi ngày" mà không ai duy trì được quá 2 tuần.

Ba câu hỏi tự kiểm tra mỗi tuần​

  1. Tuần này tôi viết được bao nhiêu dòng code tự nghĩ, không copy?
  2. Tôi có thể giải thích thứ tôi vừa học cho người khác không?
  3. Dự án tôi đang làm có tiến triển nhìn thấy được không?

Nếu cả ba câu đều "không", bạn đang lãng phí thời gian — hãy đổi cách học.


⚠️ 8 sai lầm khiến bạn tốn thêm 1 năm​

  1. Học 3 framework cùng lúc. Chọn 1, đi đến nơi đến chốn.
  2. Không học SQL vì "đã có ORM". ORM không cứu bạn khi query chạy 30 giây.
  3. Không viết test. Bạn sẽ không được nhận vào vị trí mid/senior.
  4. Không học Docker. Mọi team chuyên nghiệp đều dùng.
  5. Chỉ làm dự án todo list. Nhà tuyển dụng đã thấy 1000 cái như thế.
  6. Không đọc code của người khác. Đọc Django/FastAPI source dạy bạn nhiều hơn tutorial.
  7. Học mà không deploy. Dự án không deploy = dự án chưa xong.
  8. Không chuẩn bị phỏng vấn. Biết code mà không diễn đạt được thì vẫn trượt.

❓ Câu hỏi thường gặp​

Backend Python có còn dễ xin việc không? Có, đặc biệt ở mảng API, data platform, automation. Nhu cầu cao nhưng yêu cầu cũng cao hơn 5 năm trước — biết CRUD cơ bản không còn đủ, cần Docker, testing, cloud.

Cần học frontend không? Không cần thành thạo, nhưng hiểu HTML/CSS/JS cơ bản giúp bạn phối hợp với frontend tốt hơn nhiều. Biết React là lợi thế lớn.

Có cần bằng đại học? Không bắt buộc, nhưng nếu không có, portfolio và kinh nghiệm phải thật nổi bật.

Bao lâu để thành mid-level? Thường 2–3 năm làm việc thật. Học 12 tháng đưa bạn tới junior; từ junior lên mid cần kinh nghiệm dự án thật, không phải học thêm khoá học.

Nên học async Python không? Có, nhưng chỉ sau khi vững sync. Hiểu async/await và khi nào không nên dùng async quan trọng hơn là biết cú pháp.


🎯 Tóm lại​

Lộ trình backend Python trong 12 tháng:

8 tuần  → Python + OOP vững
4 tuần → Git + Linux + công cụ
8 tuần → Web + Flask
8 tuần → Database + SQL + ORM
8 tuần → API chuyên nghiệp + Auth + Test
8 tuần → Django HOẶC FastAPI
4 tuần → Docker + Deploy + CI/CD
4 tuần → Dự án tổng hợp + phỏng vấn

Điều duy nhất quyết định thành công không phải là lộ trình này, mà là bạn có gõ code mỗi ngày hay không.


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

Lộ trình học Python cho người muốn làm Data Analyst

· 25 phút để đọc

Data Analyst là công việc trả lời câu hỏi kinh doanh bằng dữ liệu: doanh thu tháng này giảm vì sao? Nhóm khách hàng nào đáng đầu tư nhất? Chiến dịch nào thực sự hiệu quả?

Python là công cụ quan trọng — nhưng nói thẳng: Python chỉ chiếm khoảng 40% công việc. Phần còn lại là SQL, tư duy phân tích, hiểu nghiệp vụ, và giao tiếp với người ra quyết định.

Bài viết này là lộ trình 20 tuần, trong đó phần quan trọng nhất là giai đoạn 5 — thứ mà hầu hết khoá học bỏ qua.


🎯 Data Analyst thực sự làm gì?​

Hoạt độngTỷ lệ thời gianGhi chú
Viết SQL lấy dữ liệu30%Kỹ năng #1, không phải Python
Làm sạch và kiểm tra dữ liệu20%Phần "không ai thấy" nhưng tốn thời gian nhất
Phân tích, tìm insight20%Phần thú vị nhất
Trực quan hoá và làm báo cáo20%Dashboard, slide, báo cáo định kỳ
Họp, trao đổi với stakeholder10%Quan trọng hơn bạn tưởng

💡 Sự thật cần biết trước: công việc Data Analyst có rất nhiều phần lặp lại và nhàm chán. Người thành công là người tự động hoá phần nhàm chán để dành thời gian cho phân tích thật.


🗓 Lộ trình 20 tuần​

Tuần 1–4    Python nền tảng + pandas
Tuần 5–8 SQL (quan trọng nhất!)
Tuần 9–10 Làm sạch dữ liệu thực chiến
Tuần 11–12 Trực quan hoá với matplotlib, seaborn, plotly
Tuần 13–14 Thống kê ứng dụng cho phân tích
Tuần 15–16 Excel/Sheets nâng cao + kết nối dữ liệu
Tuần 17 Tự động hoá báo cáo
Tuần 18 Dashboard với Streamlit / Power BI / Tableau
Tuần 19 Dự án portfolio
Tuần 20 Chuẩn bị phỏng vấn

🐍 Tuần 1–2: Python nền tảng​

Đừng học hết Python. Với Data Analyst, bạn cần chính xác những phần sau:

# 1. Cấu trúc dữ liệu — dùng mỗi ngày
khach_hang = {"ten": "Minh", "tuoi": 28, "thanh_pho": "Hà Nội"}
danh_sach_don = [120000, 350000, 89000]

# 2. List/dict comprehension — để biến đổi dữ liệu gọn gàng
don_lon = [d for d in danh_sach_don if d > 100000]
tong_theo_thanh_pho = {tp: 0 for tp in {"Hà Nội", "TP.HCM"}}

# 3. Vòng lặp + điều kiện — nhưng HẠN CHẾ dùng, pandas thay thế
for d in danh_sach_don:
if d > 200000:
print(f"Đơn lớn: {d:,} đ")

# 4. Hàm — tái sử dụng logic
def phan_loai_khach(so_don: int) -> str:
if so_don >= 20:
return "VIP"
if so_don >= 5:
return "Thân thiết"
return "Mới"

# 5. Xử lý file
import json
from pathlib import Path

du_lieu = json.loads(Path("du-lieu.json").read_text(encoding="utf-8"))
Path("ket-qua.json").write_text(
json.dumps(du_lieu, ensure_ascii=False, indent=2), encoding="utf-8"
)

# 6. Ngoại lệ — dữ liệu thật luôn bẩn
def chuyen_so(gt):
try:
return float(str(gt).replace(",", "").replace("đ", "").strip())
except (ValueError, AttributeError):
return None

Chủ đề KHÔNG cần học ở giai đoạn này: decorator, async, OOP phức tạp, metaclass, đa luồng. Bạn sẽ học chúng khi thật sự cần.


🐼 Tuần 3–4: pandas — công cụ chính​

pip install pandas openpyxl
import pandas as pd

# --- Đọc dữ liệu ---
df = pd.read_csv("don-hang.csv", encoding="utf-8", parse_dates=["ngay_dat"])
df_excel = pd.read_excel("bao-cao.xlsx", sheet_name="Sheet1", skiprows=3)
df_sql = pd.read_sql("SELECT * FROM don_hang", conn)

# --- Khám phá ban đầu (LUÔN làm 4 bước này) ---
print(df.shape) # (số dòng, số cột)
print(df.dtypes) # kiểu dữ liệu từng cột
print(df.head(10)) # xem 10 dòng đầu
print(df.describe()) # thống kê mô tả
print(df.isna().sum()) # đếm giá trị thiếu mỗi cột
print(df.duplicated().sum()) # đếm dòng trùng

Bảng thao tác pandas phải thành thạo:

Việc cần làmCú pháp
Chọn cộtdf[["ten", "gia"]]
Lọc dòngdf[df["gia"] > 100000]
Lọc nhiều điều kiệndf[(df.gia > 1e5) & (df.thanh_pho == "Hà Nội")]
Lọc theo danh sáchdf[df.thanh_pho.isin(["Hà Nội", "Đà Nẵng"])]
Sắp xếpdf.sort_values("gia", ascending=False)
Tạo cột mớidf["gia_vnd"] = df["gia_usd"] * 25400
Đổi tên cộtdf.rename(columns={"ten_kh": "khach_hang"})
Nhóm và tổng hợpdf.groupby("thanh_pho")["gia"].sum()
Nhóm nhiều hàmdf.groupby("thanh_pho").agg(so_don=("id","count"), dt=("gia","sum"))
Pivotdf.pivot_table(index="thang", columns="thanh_pho", values="gia", aggfunc="sum")
Nối dọcpd.concat([df1, df2], ignore_index=True)
Nối ngangdf1.merge(df2, on="khach_hang_id", how="left")
Ngày thángdf["ngay_dat"].dt.to_period("M"), .dt.dayofweek
Chuỗidf["ten"].str.strip().str.title()
Áp dụng hàmdf["loai"] = df["so_don"].apply(phan_loai_khach)

Ví dụ phân tích thực tế — doanh thu theo tháng và thành phố:

import pandas as pd

df = pd.read_csv("don-hang.csv", parse_dates=["ngay_dat"])

# Làm sạch nhanh
df = df.dropna(subset=["khach_hang_id", "gia"])
df = df[df["gia"] > 0]
df = df.drop_duplicates(subset=["ma_don"])

# Tạo cột thời gian
df["thang"] = df["ngay_dat"].dt.to_period("M").astype(str)

# Báo cáo tổng hợp
bao_cao = (
df.groupby(["thang", "thanh_pho"])
.agg(
so_don=("ma_don", "nunique"),
doanh_thu=("gia", "sum"),
gia_tb=("gia", "mean"),
)
.reset_index()
)

# Tính tăng trưởng so với tháng trước
bao_cao["tang_truong_pct"] = (
bao_cao.sort_values("thang")
.groupby("thanh_pho")["doanh_thu"]
.pct_change()
.mul(100)
.round(2)
)

print(bao_cao.to_string(index=False))

# Xuất kết quả
bao_cao.to_excel("bao-cao-doanh-thu.xlsx", index=False)
bao_cao.to_csv("bao-cao-doanh-thu.csv", index=False, encoding="utf-8-sig")

💡 encoding="utf-8-sig" khi xuất CSV cho Excel — nếu không, tiếng Việt sẽ hiển thị lỗi font.

Ba lỗi pandas người mới luôn mắc:

# ❌ SettingWithCopyWarning — thao tác trên bản sao
df[df.gia > 100]["gia"] = 0

# ✅ Dùng .loc
df.loc[df.gia > 100, "gia"] = 0

# ❌ Vòng lặp qua từng dòng — cực chậm với dữ liệu lớn
for i, row in df.iterrows():
df.loc[i, "gia_vnd"] = row["gia"] * 25400

# ✅ Vector hoá — nhanh hơn hàng trăm lần
df["gia_vnd"] = df["gia"] * 25400

# ❌ Đọc cả file Excel 500MB vào bộ nhớ
df = pd.read_excel("khong-lo.xlsx")

# ✅ Đọc từng phần
for chunk in pd.read_csv("khong-lo.csv", chunksize=100_000):
xu_ly(chunk)

🗄️ Tuần 5–8: SQL — kỹ năng quan trọng nhất​

Đây là giai đoạn quan trọng nhất của lộ trình. Data Analyst dành nhiều thời gian viết SQL hơn Python.

Cấu trúc truy vấn đầy đủ​

SELECT      kh.thanh_pho,
COUNT(DISTINCT dh.id) AS so_don,
SUM(dh.tong_tien) AS doanh_thu,
AVG(dh.tong_tien) AS gia_tri_tb,
ROUND(SUM(dh.tong_tien) FILTER (WHERE dh.trang_thai = 'hoan_thanh')
/ NULLIF(SUM(dh.tong_tien), 0) * 100, 2) AS ty_le_hoan_thanh
FROM don_hang dh
JOIN khach_hang kh ON kh.id = dh.khach_hang_id
LEFT JOIN san_pham sp ON sp.id = dh.san_pham_id
WHERE dh.ngay_dat >= '2026-01-01'
AND dh.trang_thai <> 'da_huy'
GROUP BY kh.thanh_pho
HAVING SUM(dh.tong_tien) > 100000000
ORDER BY doanh_thu DESC
LIMIT 20;

Thứ tự thực thi (quan trọng để hiểu lỗi):

FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT

Vì WHERE chạy trước GROUP BY, bạn không thể dùng alias hay hàm tổng hợp trong WHERE — phải dùng HAVING.

Các kỹ thuật SQL phải biết​

1. JOIN — hiểu rõ từng loại:

-- INNER JOIN: chỉ lấy dòng khớp ở cả hai bảng
SELECT kh.ten, dh.tong_tien
FROM khach_hang kh
INNER JOIN don_hang dh ON dh.khach_hang_id = kh.id;

-- LEFT JOIN: giữ TẤT CẢ khách hàng, kể cả người chưa mua
SELECT kh.ten, COUNT(dh.id) AS so_don
FROM khach_hang kh
LEFT JOIN don_hang dh ON dh.khach_hang_id = kh.id
GROUP BY kh.id, kh.ten; -- khách chưa mua → so_don = 0

⚠️ Bẫy kinh điển: đặt điều kiện của bảng phải vào WHERE thay vì ON sẽ biến LEFT JOIN thành INNER JOIN:

-- ❌ Sai: khách không có đơn trong 2026 sẽ bị loại hoàn toàn
LEFT JOIN don_hang dh ON dh.khach_hang_id = kh.id
WHERE dh.ngay_dat >= '2026-01-01'

-- ✅ Đúng: điều kiện nằm trong ON
LEFT JOIN don_hang dh ON dh.khach_hang_id = kh.id AND dh.ngay_dat >= '2026-01-01'

2. Window function — kỹ năng phân biệt junior và mid:

-- Xếp hạng khách hàng theo doanh thu trong từng thành phố
SELECT
kh.ten,
kh.thanh_pho,
SUM(dh.tong_tien) AS doanh_thu,
RANK() OVER (PARTITION BY kh.thanh_pho ORDER BY SUM(dh.tong_tien) DESC) AS xep_hang,
SUM(SUM(dh.tong_tien)) OVER (PARTITION BY kh.thanh_pho) AS tong_thanh_pho,
ROUND(SUM(dh.tong_tien) * 100.0
/ SUM(SUM(dh.tong_tien)) OVER (PARTITION BY kh.thanh_pho), 2) AS phan_tram
FROM khach_hang kh
JOIN don_hang dh ON dh.khach_hang_id = kh.id
GROUP BY kh.id, kh.ten, kh.thanh_pho
ORDER BY kh.thanh_pho, xep_hang;

3. CTE — viết truy vấn phức tạp cho dễ đọc:

WITH doanh_thu_thang AS (
SELECT DATE_TRUNC('month', ngay_dat) AS thang,
SUM(tong_tien) AS doanh_thu
FROM don_hang
WHERE trang_thai = 'hoan_thanh'
GROUP BY 1
),
so_sanh AS (
SELECT thang,
doanh_thu,
LAG(doanh_thu) OVER (ORDER BY thang) AS thang_truoc
FROM doanh_thu_thang
)
SELECT thang,
doanh_thu,
thang_truoc,
ROUND((doanh_thu - thang_truoc) * 100.0 / NULLIF(thang_truoc, 0), 2) AS tang_truong_pct
FROM so_sanh
ORDER BY thang;

4. CASE WHEN — phân nhóm có điều kiện:

SELECT
CASE
WHEN tong_tien >= 5000000 THEN 'Rất cao'
WHEN tong_tien >= 1000000 THEN 'Cao'
WHEN tong_tien >= 300000 THEN 'Trung bình'
ELSE 'Thấp'
END AS nhom_gia_tri,
COUNT(*) AS so_don
FROM don_hang
GROUP BY 1
ORDER BY 2 DESC;

5. Hiệu năng — hiểu INDEX:

EXPLAIN ANALYZE
SELECT * FROM don_hang WHERE khach_hang_id = 12345 AND ngay_dat >= '2026-01-01';

-- Nếu thấy "Seq Scan" trên bảng lớn → thiếu index
CREATE INDEX idx_don_hang_kh_ngay ON don_hang(khach_hang_id, ngay_dat);

-- Sau khi tạo index, chạy lại EXPLAIN → thấy "Index Scan"

Kết nối Python với database​

import pandas as pd
from sqlalchemy import create_engine, text

engine = create_engine("postgresql+psycopg://user:pass@localhost:5432/analytics")

# Đọc dữ liệu — LUÔN dùng tham số hoá
truy_van = text("""
SELECT kh.thanh_pho, SUM(dh.tong_tien) AS doanh_thu
FROM don_hang dh
JOIN khach_hang kh ON kh.id = dh.khach_hang_id
WHERE dh.ngay_dat >= :tu_ngay
GROUP BY kh.thanh_pho
ORDER BY doanh_thu DESC
""")

df = pd.read_sql(truy_van, engine, params={"tu_ngay": "2026-01-01"})
print(df.head())

🧹 Tuần 9–10: Làm sạch dữ liệu thực chiến​

Đây là phần chiếm nhiều thời gian nhất trong công việc thật. Dữ liệu thật luôn bẩn.

import pandas as pd
import numpy as np

df = pd.read_csv("du-lieu-tho.csv")

# --- 1. Kiểm tra tổng quan ---
print(df.info())
print(df.isna().mean().sort_values(ascending=False)) # tỷ lệ thiếu mỗi cột

# --- 2. Chuẩn hoá tên cột ---
df.columns = (
df.columns.str.strip()
.str.lower()
.str.replace(r"[^\w]+", "_", regex=True)
)

# --- 3. Xử lý giá trị thiếu ---
# Cột số: điền median (an toàn hơn mean khi có outlier)
df["gia"] = df["gia"].fillna(df["gia"].median())

# Cột phân loại: điền nhãn rõ ràng, KHÔNG điền mode
df["thanh_pho"] = df["thanh_pho"].fillna("Không xác định")

# Cột quan trọng thiếu nhiều: bỏ luôn
df = df.dropna(subset=["khach_hang_id"])

# --- 4. Xử lý outlier bằng IQR ---
Q1, Q3 = df["gia"].quantile([0.25, 0.75])
IQR = Q3 - Q1
gioi_han_duoi, gioi_han_tren = Q1 - 1.5 * IQR, Q3 + 1.5 * IQR
df["gia"] = df["gia"].clip(gioi_han_duoi, gioi_han_tren)

# --- 5. Chuẩn hoá chuỗi ---
df["email"] = df["email"].str.strip().str.lower()
df["ten"] = df["ten"].str.strip().str.title()

# --- 6. Chuẩn hoá ngày tháng ---
df["ngay_dat"] = pd.to_datetime(df["ngay_dat"], errors="coerce", dayfirst=True)
df = df.dropna(subset=["ngay_dat"]) # bỏ dòng ngày không parse được

# --- 7. Loại trùng ---
df = df.drop_duplicates(subset=["ma_don"], keep="last")

# --- 8. Kiểm tra tính hợp lý ---
assert (df["gia"] >= 0).all(), "Có giá âm!"
assert df["ngay_dat"].max() <= pd.Timestamp.now(), "Có ngày trong tương lai!"

print(f"✅ Sau làm sạch: {df.shape[0]:,} dòng × {df.shape[1]} cột")

⚠️ Nguyên tắc đạo đức quan trọng: mọi quyết định làm sạch dữ liệu phải được ghi lại và báo cáo. Nếu bạn bỏ 15% dữ liệu, người đọc báo cáo phải biết điều đó. Âm thầm xoá dữ liệu để có kết quả đẹp hơn là gian lận phân tích.


📊 Tuần 11–12: Trực quan hoá dữ liệu​

import matplotlib.pyplot as plt
import seaborn as sns

plt.rcParams["figure.figsize"] = (12, 6)
plt.rcParams["font.size"] = 11
sns.set_theme(style="whitegrid")

# --- Biểu đồ cột ---
theo_thanh_pho = df.groupby("thanh_pho")["gia"].sum().sort_values()

ax = theo_thanh_pho.plot(kind="barh", color="#2563eb")
ax.set_title("Doanh thu theo thành phố", fontsize=14, fontweight="bold")
ax.set_xlabel("Doanh thu (VNĐ)")
ax.set_ylabel("")
ax.bar_label(ax.containers[0], fmt="%.0f", padding=3)
plt.tight_layout()
plt.savefig("doanh-thu-thanh-pho.png", dpi=150)
plt.show()

Chọn biểu đồ đúng — quan trọng hơn code:

Loại biểu đồDùng khiKHÔNG dùng khi
CộtSo sánh giữa các nhómNhóm > 12 loại
Cột ngangTên nhóm dài—
ĐườngXu hướng theo thời gianDữ liệu không có thứ tự
TrònTối đa 4–5 phầnSo sánh chính xác giữa các phần
HistogramPhân phối một biến sốSo sánh nhóm
Box plotSo sánh phân phối nhiều nhómNgười xem không quen thống kê
ScatterTương quan hai biếnBiến phân loại
HeatmapMa trận quan hệBảng nhỏ

Bốn quy tắc trực quan hoá chuyên nghiệp:

  1. Một thông điệp mỗi biểu đồ. Nếu bạn cần 2 câu để giải thích, hãy tách ra.
  2. Tiêu đề là kết luận, không phải mô tả. ❌ "Doanh thu theo thành phố" → ✅ "TP.HCM chiếm 45% doanh thu".
  3. Bỏ mọi mực thừa. Xoá gridline thừa, khung, 3D, đổ bóng.
  4. Bắt đầu trục tung từ 0. Với biểu đồ cột, cắt trục là đánh lừa người xem (thường là vô tình).

📈 Tuần 13–14: Thống kê ứng dụng​

Bạn không cần toán nặng, nhưng cần những khái niệm sau để không kết luận sai:

import numpy as np
from scipy import stats

# --- 1. Phân biệt mean, median, mode ---
# Lương: 5 người, 1 người lương 500tr → mean bị kéo lệch
luong = [8, 9, 10, 11, 500]
print(f"Mean: {np.mean(luong):.1f} (bị outlier kéo lệch)")
print(f"Median: {np.median(luong):.1f} (đại diện tốt hơn)")

# --- 2. Độ lệch chuẩn — đo mức biến động ---
print(f"Độ lệch chuẩn: {np.std(luong, ddof=1):.2f}")

# --- 3. Kiểm định giả thuyết: hai nhóm có khác nhau thật không? ---
nhom_a = [120, 135, 128, 142, 119, 131] # thiết kế cũ
nhom_b = [145, 152, 138, 160, 149, 155] # thiết kế mới

t_stat, p_value = stats.ttest_ind(nhom_a, nhom_b)
print(f"p-value = {p_value:.4f}")
if p_value < 0.05:
print("✅ Khác biệt có ý nghĩa thống kê")
else:
print("⚠️ Chưa đủ bằng chứng để kết luận khác biệt")

# --- 4. Tương quan ---
r, p = stats.pearsonr(df["chi_phi_marketing"], df["doanh_thu"])
print(f"Hệ số tương quan r = {r:.3f}, p = {p:.4f}")

⚠️ Ba sai lầm thống kê nguy hiểm nhất:

  1. Tương quan ≠ nhân quả. Kem bán chạy và tai nạn đuối nước cùng tăng vào mùa hè — nhưng kem không gây đuối nước.
  2. p-hacking. Thử 20 cách chia nhóm rồi báo cáo cách có p < 0.05 là gian lận.
  3. Survivorship bias. Chỉ phân tích khách còn hoạt động → kết luận "tỷ lệ rời bỏ thấp".

🤖 Tuần 17: Tự động hoá báo cáo — kỹ năng được trả lương cao nhất​

Đây là phần tạo giá trị lớn nhất cho bạn. Bất kỳ báo cáo nào bạn làm thủ công hàng tuần đều có thể tự động hoá.

"""Báo cáo tuần tự động: lấy dữ liệu → tính toán → xuất file → gửi email."""
import smtplib
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
from datetime import datetime, timedelta

import pandas as pd
from sqlalchemy import create_engine, text


def lay_du_lieu(tu_ngay, den_ngay) -> pd.DataFrame:
engine = create_engine("postgresql+psycopg://user:pass@localhost/analytics")
truy_van = text("""
SELECT dh.ngay_dat, kh.thanh_pho, dh.tong_tien, dh.trang_thai
FROM don_hang dh
JOIN khach_hang kh ON kh.id = dh.khach_hang_id
WHERE dh.ngay_dat BETWEEN :tu AND :den
""")
return pd.read_sql(truy_van, engine, params={"tu": tu_ngay, "den": den_ngay})


def tinh_bao_cao(df: pd.DataFrame) -> pd.DataFrame:
df = df[df["trang_thai"] == "hoan_thanh"].copy()
df["tuan"] = df["ngay_dat"].dt.to_period("W").astype(str)

return (
df.groupby(["tuan", "thanh_pho"])
.agg(so_don=("tong_tien", "count"),
doanh_thu=("tong_tien", "sum"),
don_tb=("tong_tien", "mean"))
.round(0)
.reset_index()
)


def xuat_file(bao_cao: pd.DataFrame, tp: Path) -> Path:
with pd.ExcelWriter(tp, engine="openpyxl") as writer:
bao_cao.to_excel(writer, sheet_name="Chi tiết", index=False)

tong_hop = (bao_cao.groupby("thanh_pho")["doanh_thu"].sum()
.sort_values(ascending=False).reset_index())
tong_hop.to_excel(writer, sheet_name="Tổng hợp", index=False)

# Tự điều chỉnh độ rộng cột
for sheet in writer.sheets.values():
for cot in sheet.columns:
do_dai = max(len(str(o.value or "")) for o in cot)
sheet.column_dimensions[cot[0].column_letter].width = min(do_dai + 4, 40)
return tp


def gui_email(tp: Path, nguoi_nhan: list[str], tom_tat_html: str) -> None:
msg = MIMEMultipart("mixed")
msg["Subject"] = f"Báo cáo doanh thu tuần — {datetime.now():%d/%m/%Y}"
msg["From"] = "bao-cao@congty.com"
msg["To"] = ", ".join(nguoi_nhan)

msg.attach(MIMEText(tom_tat_html, "html", "utf-8"))

with open(tp, "rb") as f:
dinh_kem = MIMEApplication(f.read(), Name=tp.name)
dinh_kem["Content-Disposition"] = f'attachment; filename="{tp.name}"'
msg.attach(dinh_kem)

with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login("bao-cao@congty.com", "mat-khau-ung-dung")
server.send_message(msg)


def chay_bao_cao_tuan():
den_ngay = datetime.now().date()
tu_ngay = den_ngay - timedelta(days=7)

df = lay_du_lieu(tu_ngay, den_ngay)
if df.empty:
print("⚠️ Không có dữ liệu trong kỳ, bỏ qua.")
return

bao_cao = tinh_bao_cao(df)
tp = xuat_file(bao_cao, Path(f"bao-cao-{den_ngay:%Y-%m-%d}.xlsx"))

tong = bao_cao["doanh_thu"].sum()
so_don = bao_cao["so_don"].sum()
tom_tat = f"""
<h2>Báo cáo tuần {tu_ngay:%d/%m} – {den_ngay:%d/%m/%Y}</h2>
<ul>
<li>Tổng doanh thu: <b>{tong:,.0f} đ</b></li>
<li>Số đơn hoàn thành: <b>{so_don:,}</b></li>
<li>Giá trị đơn trung bình: <b>{tong / so_don:,.0f} đ</b></li>
</ul>
<p>Chi tiết xem trong file đính kèm.</p>
"""

gui_email(tp, ["sep@congty.com", "team@congty.com"], tom_tat)
print(f"✅ Đã gửi báo cáo: {tp.name}")


if __name__ == "__main__":
chay_bao_cao_tuan()

Đặt lịch chạy tự động:

Hệ điều hànhCách đặt lịch
WindowsTask Scheduler
macOS / Linuxcron: 0 8 * * 1 python /path/bao_cao.py
CloudGitHub Actions schedule:
Nâng caoAirflow, Prefect

💡 Lời khuyên sự nghiệp: tự động hoá một báo cáo thủ công mất 4 giờ/tuần thành 5 phút là dự án portfolio mạnh nhất bạn có thể làm. Nó chứng minh bạn tạo ra giá trị đo lường được — điều mà mọi nhà tuyển dụng muốn thấy.


🎨 Tuần 18: Dashboard​

Ba lựa chọn phổ biến trên thị trường Việt Nam:

Công cụChi phíKhi nào dùng
Power BIMiễn phí bản desktopPhổ biến nhất ở doanh nghiệp VN
TableauCó phí (đắt)Doanh nghiệp lớn, quốc tế
StreamlitMiễn phí, mã nguồn mởTeam kỹ thuật, cần tuỳ biến
Looker StudioMiễn phíBáo cáo marketing, Google Ads

Kết hợp Python + Power BI là combo rất mạnh: dùng Python để làm sạch và tính toán, Power BI để hiển thị và chia sẻ.

# streamlit_dashboard.py — dashboard nhanh bằng Python
import streamlit as st
import pandas as pd
import plotly.express as px

st.set_page_config(page_title="Dashboard Bán hàng", layout="wide")
st.title("📊 Dashboard Bán hàng")

@st.cache_data(ttl=600)
def tai_du_lieu():
return pd.read_csv("du-lieu-da-sach.csv", parse_dates=["ngay_dat"])

df = tai_du_lieu()

with st.sidebar:
st.header("Bộ lọc")
thanh_pho = st.multiselect("Thành phố", sorted(df["thanh_pho"].unique()),
default=sorted(df["thanh_pho"].unique()))
khoang = st.date_input("Khoảng thời gian",
value=(df["ngay_dat"].min(), df["ngay_dat"].max()))

df_loc = df[df["thanh_pho"].isin(thanh_pho)]
df_loc = df_loc[(df_loc["ngay_dat"] >= pd.Timestamp(khoang[0]))
& (df_loc["ngay_dat"] <= pd.Timestamp(khoang[1]))]

c1, c2, c3 = st.columns(3)
c1.metric("Doanh thu", f"{df_loc['tong_tien'].sum():,.0f} đ")
c2.metric("Số đơn", f"{len(df_loc):,}")
c3.metric("Đơn trung bình", f"{df_loc['tong_tien'].mean():,.0f} đ")

st.plotly_chart(
px.line(df_loc.groupby("ngay_dat")["tong_tien"].sum().reset_index(),
x="ngay_dat", y="tong_tien", title="Doanh thu theo ngày"),
use_container_width=True,
)

st.plotly_chart(
px.bar(df_loc.groupby("thanh_pho")["tong_tien"].sum().sort_values().reset_index(),
x="tong_tien", y="thanh_pho", orientation="h", title="Doanh thu theo thành phố"),
use_container_width=True,
)

💼 Tuần 19: Dự án portfolio​

Công thức một dự án Data Analyst tốt:

Câu hỏi kinh doanh rõ ràng + Dữ liệu thật + Phân tích có phương pháp
+ Trực quan hoá rõ ràng + Khuyến nghị hành động cụ thể

Ba dự án nên làm:

Dự ánKỹ năng thể hiện
Phân tích hành vi khách hàng (RFM segmentation)SQL nâng cao, pandas, phân nhóm, khuyến nghị
Dashboard doanh thu tự độngTrực quan hoá, Streamlit/Power BI, tự động hoá
Phân tích A/B testThống kê, kiểm định giả thuyết, viết kết luận

Cách trình bày dự án trên GitHub/portfolio:

## 1. Bối cảnh & câu hỏi
Công ty X có 50.000 khách hàng nhưng không biết ai đáng đầu tư giữ chân.

## 2. Dữ liệu
- Nguồn: ... (link)
- Kích thước: 1,2 triệu dòng giao dịch, 2024–2026
- Hạn chế: thiếu dữ liệu kênh marketing trong Q1

## 3. Phương pháp
- Làm sạch: loại 3,2% dòng thiếu customer_id, xử lý 812 outlier bằng IQR
- Phân nhóm RFM: chia 5 nhóm theo quintile

## 4. Kết quả
![Biểu đồ RFM](anh-rfm.png)
- Nhóm "Champions": 8% khách hàng, đóng góp 42% doanh thu
- Nhóm "At risk": 15% khách hàng, đã ngừng mua 90+ ngày

## 5. Khuyến nghị
1. Chiến dịch win-back cho nhóm At Risk: dự kiến thu hồi 12% (≈ 340 triệu)
2. Chương trình VIP cho Champions: giữ chân nhóm đóng góp 42% doanh thu

## 6. Hạn chế
- Chưa tính được chi phí acquisition nên chưa tính được LTV đầy đủ

Phần 5 và 6 là phần nhà tuyển dụng đọc kỹ nhất — 95% ứng viên bỏ qua chúng.


🎤 Tuần 20: Chuẩn bị phỏng vấn​

Cấu trúc phỏng vấn Data Analyst điển hình:

VòngNội dungChuẩn bị gì
1Sàng lọc với HRKể câu chuyện nghề nghiệp 2 phút
2SQL test (thường live coding)Luyện 50 bài SQL
3Python/pandas testgroupby, merge, làm sạch dữ liệu
4Case study nghiệp vụ"Doanh thu giảm 20%, bạn kiểm tra gì?"
5Văn hoá & hành viCâu hỏi STAR

Câu hỏi SQL hay được hỏi:

  1. Viết truy vấn tìm khách hàng có trên 5 đơn trong 30 ngày qua.
  2. Tính doanh thu tích luỹ theo tháng (running total).
  3. Tìm khách hàng chưa từng mua trong 90 ngày.
  4. Tính tỷ lệ giữ chân khách hàng theo cohort.
  5. Tìm sản phẩm bán chạy thứ 2 trong mỗi danh mục.
  6. Viết truy vấn phát hiện dòng trùng lặp.

Khung trả lời case study — dùng cấu trúc này:

1. LÀM RÕ:  "Doanh thu giảm so với kỳ nào? Giảm bao nhiêu %? Ở khu vực nào?"
2. CHIA NHỎ: tách theo thời gian, khu vực, sản phẩm, kênh, nhóm khách hàng
3. LOẠI TRỪ: vấn đề dữ liệu (tracking lỗi, mất data) trước khi kết luận về nghiệp vụ
4. GIẢ THUYẾT: nêu 2-3 giả thuyết khả dĩ nhất
5. KIỂM CHỨNG: truy vấn nào để xác nhận/phủ định từng giả thuyết
6. HÀNH ĐỘNG: đề xuất cụ thể, có số liệu dự kiến

💡 Điểm khác biệt lớn nhất: ứng viên trung bình trả lời ngay. Ứng viên giỏi hỏi lại để làm rõ trước.


📐 Checklist một phân tích đáng tin​

Trước khi gửi bất kỳ báo cáo nào, hãy tự kiểm tra qua danh sách này.

Về dữ liệu​

  • Nguồn dữ liệu có đáng tin không? Ai tạo ra, cập nhật khi nào, có độ trễ không?
  • Khoảng thời gian có phù hợp? So sánh tháng 2 (28 ngày) với tháng 3 (31 ngày) mà không chuẩn hoá là sai.
  • Có dòng trùng không? Kiểm tra bằng khóa nghiệp vụ, không phải toàn bộ dòng.
  • Giá trị thiếu chiếm bao nhiêu %? Nếu > 20% ở cột quan trọng, kết luận phải kèm cảnh báo.
  • Có dữ liệu ngoài khoảng hợp lý không? Tuổi âm, ngày trong tương lai, giá bằng 0.
  • Đã ghi lại mọi quyết định làm sạch chưa?
# Đưa việc kiểm tra vào code — chạy được, không phải tự nhớ
from dataclasses import dataclass


@dataclass
class KetQuaKiemTra:
ten: str
dat: bool
chi_tiet: str


def kiem_tra_chat_luong(df, cot_so: list[str], cot_khoa: str) -> list[KetQuaKiemTra]:
kq = []

trung = df.duplicated(subset=[cot_khoa]).sum()
kq.append(KetQuaKiemTra("Không trùng khoá", trung == 0, f"{trung} dòng trùng"))

thieu = df.isna().mean().max()
kq.append(KetQuaKiemTra("Tỷ lệ thiếu < 20%", thieu < 0.2, f"cao nhất {thieu:.1%}"))

am = (df[cot_so] < 0).any().any()
kq.append(KetQuaKiemTra("Không có giá trị âm bất thường", not am, ""))

kq.append(KetQuaKiemTra("Có dữ liệu", len(df) > 0, f"{len(df):,} dòng"))

return kq


for k in kiem_tra_chat_luong(df, ["doanh_thu"], "ma_don"):
print(f"{'✅' if k.dat else '❌'} {k.ten} — {k.chi_tiet}")

Về phương pháp​

  • Đã loại trừ vấn đề dữ liệu trước khi kết luận về nghiệp vụ chưa? Doanh thu giảm 50% thường là lỗi tracking, không phải mất khách.
  • Có so sánh với kỳ tương đương không? Cùng kỳ năm trước, cùng số ngày.
  • Có phân rã theo chiều không? Tổng không đổi nhưng cơ cấu thay đổi là thông tin quan trọng.
  • Có kiểm tra tương quan nhân quả không? Có biến gây nhiễu nào không?
  • Cỡ mẫu có đủ không? Kết luận từ 12 dòng dữ liệu là không đáng tin.
  • Đã kiểm tra giả thuyết thay thế chưa? Nếu kết quả đúng, còn cách giải thích nào khác?

Về trình bày​

  • Tiêu đề là kết luận hay mô tả? Viết "Doanh thu giảm 12% do nhóm khách hàng cũ" tốt hơn "Biểu đồ doanh thu".
  • Trục tung có bắt đầu từ 0 không? Với biểu đồ cột, cắt trục là đánh lừa người xem.
  • Có nêu rõ hạn chế của phân tích không? Không có phân tích nào hoàn hảo.
  • Có khuyến nghị cụ thể kèm số liệu dự kiến không? "Tăng 15% ngân sách cho nhóm A, dự kiến thu về 420 triệu" mạnh hơn "nên đầu tư thêm".
  • Người đọc có hiểu trong 30 giây đầu không? Nếu không, hãy viết lại phần mở đầu.

Về tái lập​

  • Người khác chạy lại được code của bạn không?
  • Có file dữ liệu hoặc truy vấn SQL kèm theo không?
  • Có ghim phiên bản thư viện không?
pip freeze > requirements.txt

💡 Phép thử cuối cùng: đưa báo cáo cho một đồng nghiệp không tham gia phân tích và hỏi họ ba câu: (1) Bạn rút ra kết luận gì? (2) Bạn có tin không, vì sao? (3) Bạn sẽ làm gì tiếp theo? Nếu họ không trả lời được, báo cáo cần viết lại — không phải vì họ kém, mà vì báo cáo chưa rõ.


⚠️ 6 sai lầm khiến bạn mãi không được nhận​

  1. Học Python mà bỏ SQL. SQL là kỹ năng #1 của Data Analyst. Bỏ qua nghĩa là trượt vòng phỏng vấn kỹ thuật.
  2. Chỉ làm dự án trên dữ liệu Titanic/Iris. Ai cũng làm. Hãy dùng dữ liệu thật, có câu hỏi kinh doanh thật.
  3. Không ghi lại quyết định làm sạch dữ liệu. Kết quả không thể tái lập là kết quả vô giá trị.
  4. Kết luận nhân quả từ tương quan. Sai lầm nghiêm trọng nhất.
  5. Báo cáo chỉ có số, không có khuyến nghị. Sếp không cần số — sếp cần biết nên làm gì.
  6. Không hỏi lại khi yêu cầu mơ hồ. "Cho tôi báo cáo doanh thu" là yêu cầu thiếu 5 thông tin.

❓ Câu hỏi thường gặp​

Cần bằng đại học không? Không bắt buộc, nhưng phổ biến. Ngành Kinh tế, Tài chính, QTKD đều vào được nếu portfolio tốt. Kỹ năng quan trọng hơn bằng cấp.

Python hay Excel quan trọng hơn? Excel vẫn rất cần — nhiều doanh nghiệp Việt Nam vẫn làm việc chính trên Excel. Nhưng Python là thứ giúp bạn tự động hoá và xử lý dữ liệu lớn mà Excel bó tay. Học cả hai.

Data Analyst có bị AI thay thế không? Phần viết code lấy dữ liệu và làm biểu đồ cơ bản đang bị tự động hoá. Phần hiểu nghiệp vụ, đặt câu hỏi đúng, và truyền đạt insight thì không. Điều này có nghĩa: kỹ năng nghiệp vụ ngày càng quan trọng.

Bao lâu để đi làm? Nếu đã có nền tảng nghiệp vụ (kế toán, marketing, vận hành): 4–6 tháng. Nếu bắt đầu từ đầu: 8–12 tháng.

Nên học Power BI hay Tableau? Power BI ở Việt Nam phổ biến hơn và miễn phí bản desktop. Học Power BI trước; Tableau dễ học thêm sau.


🎯 Tóm lại​

4 tuần  → Python + pandas
4 tuần → SQL (đầu tư nhiều nhất vào đây)
2 tuần → Làm sạch dữ liệu
2 tuần → Trực quan hoá
2 tuần → Thống kê ứng dụng
2 tuần → Excel/Sheets nâng cao
1 tuần → Tự động hoá báo cáo ← tạo giá trị lớn nhất
1 tuần → Dashboard
1 tuần → Dự án portfolio
1 tuần → Phỏng vấn

Điều quyết định thành công không phải là bạn biết bao nhiêu thư viện, mà là bạn có thể trả lời một câu hỏi kinh doanh bằng dữ liệu và đề xuất hành động cụ thể hay không.


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