Python 8 — Làm việc với SQL, API & Async

13 thg 7, 2026 4 lượt xem
#api
#python
#sqlalchemy
#async

Trong data engineering, ba kỹ năng xuất hiện gần như mỗi ngày: đọc/ghi cơ sở dữ liệu, gọi REST API để lấy dữ liệu từ dịch vụ bên ngoài, và chạy nhiều tác vụ I/O song song để pipeline không bị nghẽn. Bài này đi sâu cả ba, với ví dụ thực hành và những "cạm bẫy" hay gặp.

1. Kết nối cơ sở dữ liệu

1.1. DB-API: lớp nền tảng

Python chuẩn hoá cách nói chuyện với CSDL qua DB-API 2.0 (PEP 249). Mọi driver (sqlite3, psycopg2 cho PostgreSQL, pymysql cho MySQL) đều có cùng bộ khái niệm: connection, cursor, execute, fetchone/fetchall, commit.

import sqlite3

conn = sqlite3.connect("warehouse.db")
cur = conn.cursor()

cur.execute("""
    CREATE TABLE IF NOT EXISTS events (
        id      INTEGER PRIMARY KEY,
        user_id INTEGER,
        action  TEXT,
        ts      TEXT
    )
""")

cur.execute(
    "INSERT INTO events (user_id, action, ts) VALUES (?, ?, ?)",
    (42, "login", "2026-06-30T08:00:00"),
)
conn.commit()  # bắt buộc để lưu thay đổi

cur.execute("SELECT id, action FROM events WHERE user_id = ?", (42,))
for row in cur.fetchall():
    print(row)  # (1, 'login')

conn.close()

Ba điểm cần nhớ:

  • commit() mới ghi thật. Không commit, dữ liệu mất khi đóng kết nối.
  • cursor là con trỏ duyệt kết quả, không phải kết quả.
  • Luôn close() hoặc dùng context manager để trả tài nguyên.

1.2. Tham số hoá — chống SQL injection

Đây là quy tắc không bao giờ phá vỡ: không bao giờ ghép chuỗi để dựng câu SQL.

# SAI — lỗ hổng SQL injection
action = "login'; DROP TABLE events; --"
cur.execute(f"SELECT * FROM events WHERE action = '{action}'")  # NGUY HIỂM

# ĐÚNG — để driver chèn giá trị an toàn
cur.execute("SELECT * FROM events WHERE action = ?", (action,))

Khi tham số hoá, driver gửi câu lệnh và dữ liệu tách riêng; CSDL không bao giờ hiểu dữ liệu thành mã. Lưu ý placeholder khác nhau theo driver: sqlite3 dùng ?, psycopg2 dùng %s.

1.3. SQLAlchemy Core — engine và SQL biểu đạt

sqlite3 đủ cho việc nhỏ, nhưng dự án thật thường dùng SQLAlchemy vì nó che giấu khác biệt giữa các CSDL và quản lý connection pool.

Engine là điểm vào trung tâm — nó giữ pool kết nối và biết phương ngữ (dialect) của CSDL.

from sqlalchemy import create_engine, text

# Chuỗi kết nối: dialect+driver://user:pass@host:port/dbname
engine = create_engine("postgresql+psycopg2://etl:secret@localhost:5432/dw")
# Ví dụ SQLite dùng ngay dưới đây:
engine = create_engine("sqlite:///warehouse.db", echo=False)

with engine.connect() as conn:
    result = conn.execute(
        text("SELECT action, COUNT(*) AS n FROM events WHERE user_id = :uid GROUP BY action"),
        {"uid": 42},
    )
    for row in result:
        print(row.action, row.n)   # truy cập theo tên cột
    conn.commit()

Với SQLAlchemy Core, placeholder là :tên và bạn truyền dict. Vẫn an toàn injection y như DB-API.

1.4. SQLAlchemy ORM — model, session, query

ORM ánh xạ bảng thành lớp Pythondòng thành đối tượng. Bạn làm việc với object thay vì câu SQL thô.

from datetime import datetime
from sqlalchemy import String, Integer, DateTime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase):
    pass

class Event(Base):
    __tablename__ = "events"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    user_id: Mapped[int] = mapped_column(Integer, index=True)
    action: Mapped[str] = mapped_column(String(50))
    ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

    def __repr__(self) -> str:
        return f"<Event {self.action} u={self.user_id}>"

Base.metadata.create_all(engine)   # tạo bảng nếu chưa có

Mọi thao tác ORM diễn ra trong một Session — đơn vị công việc gom các thay đổi rồi flush xuống CSDL khi commit.

with Session(engine) as session:
    # Thêm
    session.add(Event(user_id=42, action="login"))
    session.add_all([
        Event(user_id=42, action="view"),
        Event(user_id=7,  action="login"),
    ])
    session.commit()

    # Truy vấn (API 2.0)
    from sqlalchemy import select
    stmt = select(Event).where(Event.user_id == 42).order_by(Event.id)
    for ev in session.scalars(stmt):
        print(ev)

    # Cập nhật: sửa thuộc tính rồi commit
    ev = session.get(Event, 1)
    ev.action = "login_ok"
    session.commit()

    # Xoá
    session.delete(ev)
    session.commit()

Core hay ORM? Core gần SQL, kiểm soát cao, hợp cho ETL aggregate lớn. ORM tiện cho logic nghiệp vụ với nhiều quan hệ giữa bảng. Trong data engineering, nhiều pipeline dùng Core (hoặc thậm chí SQL thuần) cho hiệu năng, và ORM cho phần metadata/cấu hình.

2. Gọi REST API

2.1. requests — thư viện kinh điển

import requests

resp = requests.get(
    "https://api.example.com/v1/users",
    params={"role": "analyst", "active": "true"},
    headers={"Authorization": "Bearer TOKEN", "Accept": "application/json"},
    timeout=10,   # LUÔN đặt timeout
)

# Kiểm tra status code
if resp.status_code == 200:
    data = resp.json()      # parse JSON thành dict/list
    print(data["users"][0])
else:
    print("Lỗi:", resp.status_code, resp.text)

POST gửi dữ liệu JSON:

resp = requests.post(
    "https://api.example.com/v1/events",
    json={"user_id": 42, "action": "purchase"},  # json= tự set Content-Type
    headers={"Authorization": "Bearer TOKEN"},
    timeout=10,
)
resp.raise_for_status()   # ném exception nếu 4xx/5xx
created = resp.json()

2.2. Xử lý lỗi & status code đúng cách

API thật sẽ thất bại — mạng chập chờn, server quá tải, token hết hạn. Code production cần phòng thủ.

import requests
from requests.exceptions import RequestException

def fetch_users(page: int) -> dict:
    try:
        resp = requests.get(
            "https://api.example.com/v1/users",
            params={"page": page},
            timeout=10,
        )
        resp.raise_for_status()           # 4xx/5xx -> HTTPError
        return resp.json()
    except requests.Timeout:
        raise RuntimeError(f"Timeout ở trang {page}")
    except requests.HTTPError as e:
        # phân biệt lỗi client vs server
        code = e.response.status_code
        if code == 429:
            raise RuntimeError("Bị rate limit")
        raise RuntimeError(f"HTTP {code}: {e.response.text[:200]}")
    except RequestException as e:
        raise RuntimeError(f"Lỗi mạng: {e}")

Ý nghĩa nhóm status code: 2xx thành công, 3xx chuyển hướng, 4xx lỗi phía bạn (401 chưa xác thực, 403 không có quyền, 404 không tồn tại, 429 quá nhiều request), 5xx lỗi phía server (nên retry).

2.3. Phân trang & rate limit

Hầu hết API giới hạn số bản ghi mỗi lần trả về. Bạn phải lặp qua các trang.

import time
import requests

def fetch_all(url: str, token: str) -> list[dict]:
    items, page = [], 1
    while True:
        resp = requests.get(
            url,
            params={"page": page, "per_page": 100},
            headers={"Authorization": f"Bearer {token}"},
            timeout=10,
        )
        if resp.status_code == 429:
            # tôn trọng header Retry-After nếu có
            wait = int(resp.headers.get("Retry-After", "5"))
            time.sleep(wait)
            continue
        resp.raise_for_status()
        batch = resp.json()["data"]
        if not batch:
            break                # hết dữ liệu
        items.extend(batch)
        page += 1
        time.sleep(0.2)          # giãn nhịp, tránh chạm rate limit
    return items

2.4. httpx — kế thừa requests, hỗ trợ async

httpx có API gần giống requests nhưng dùng được cả đồng bộ lẫn bất đồng bộ — điểm này quan trọng cho phần sau.

import httpx

# Đồng bộ — dùng Client để tái sử dụng kết nối
with httpx.Client(timeout=10) as client:
    r = client.get("https://api.example.com/v1/users", params={"page": 1})
    r.raise_for_status()
    print(r.json())

3. Lập trình bất đồng bộ với asyncio

3.1. Vì sao cần async cho I/O-bound

Khi gọi 100 API, phần lớn thời gian là chờ mạng phản hồi, CPU gần như ngồi không. Code đồng bộ gọi tuần tự: trang 2 chỉ bắt đầu khi trang 1 xong. Async cho phép, trong lúc chờ một request, máy đi gửi request khác.

  • I/O-bound (mạng, đĩa, CSDL) → async ăn điểm lớn.
  • CPU-bound (tính toán nặng) → async không giúp; cần multiprocessing.

Async không phải đa luồng thật: nó chạy trên một luồng, dùng event loop điều phối, "nhường lượt" tại mỗi điểm await khi tác vụ đang chờ.

3.2. async/await và event loop

async def tạo coroutine — hàm có thể tạm dừng. await nói "tạm dừng tại đây cho tới khi việc này xong, trong lúc đó loop làm việc khác".

import asyncio

async def say(msg: str, delay: float) -> str:
    await asyncio.sleep(delay)   # mô phỏng chờ I/O, không block luồng
    print(msg)
    return msg

async def main():
    # await tuần tự — mất ~3s
    await say("một", 1)
    await say("hai", 2)

asyncio.run(main())   # khởi tạo event loop và chạy coroutine gốc

Chỉ await thôi vẫn tuần tự. Sức mạnh đến khi chạy song song.

3.3. Gọi nhiều API song song với asyncio.gather

import asyncio
import httpx

async def fetch(client: httpx.AsyncClient, url: str) -> dict:
    r = await client.get(url, timeout=10)
    r.raise_for_status()
    return r.json()

async def fetch_many(urls: list[str]) -> list[dict]:
    async with httpx.AsyncClient() as client:
        tasks = [fetch(client, u) for u in urls]
        # gather chạy tất cả đồng thời, trả kết quả theo đúng thứ tự
        return await asyncio.gather(*tasks)

urls = [f"https://api.example.com/v1/page/{i}" for i in range(1, 21)]
results = asyncio.gather  # minh hoạ
data = asyncio.run(fetch_many(urls))
print(f"Lấy {len(data)} trang")

20 request I/O-bound: tuần tự có thể mất 20×0.5s = 10s; song song chỉ tốn cỡ thời gian của request chậm nhất. Để tránh "bắn" quá nhiều request cùng lúc và bị rate limit, giới hạn đồng thời bằng asyncio.Semaphore:

async def fetch_limited(sem, client, url):
    async with sem:                      # tối đa N request cùng lúc
        return await fetch(client, url)

async def fetch_throttled(urls, concurrency=5):
    sem = asyncio.Semaphore(concurrency)
    async with httpx.AsyncClient() as client:
        tasks = [fetch_limited(sem, client, u) for u in urls]
        return await asyncio.gather(*tasks)

Nếu muốn lỗi một task không làm hỏng cả nhóm, dùng asyncio.gather(*tasks, return_exceptions=True) — kết quả sẽ chứa cả object exception để bạn xử lý từng cái.

4. Kiểm thử với pytest

Pipeline đáng tin cậy cần test. pytest là chuẩn de facto: hàm bắt đầu bằng test_, dùng assert thuần.

# file: test_pipeline.py

def parse_event(raw: dict) -> dict:
    return {"user": raw["user_id"], "action": raw["action"].lower()}

def test_parse_event():
    out = parse_event({"user_id": 42, "action": "LOGIN"})
    assert out == {"user": 42, "action": "login"}

def test_parse_event_missing_key():
    import pytest
    with pytest.raises(KeyError):
        parse_event({"user_id": 42})

Chạy: pytest -v. Vài kỹ thuật hay dùng:

import pytest

# Tham số hoá: một test, nhiều bộ dữ liệu
@pytest.mark.parametrize("inp,expected", [
    ("LOGIN", "login"),
    ("View",  "view"),
])
def test_normalize(inp, expected):
    assert inp.lower() == expected

# Fixture: chuẩn bị tài nguyên dùng chung
@pytest.fixture
def sample_rows():
    return [{"user_id": 1, "action": "X"}, {"user_id": 2, "action": "Y"}]

def test_count(sample_rows):
    assert len(sample_rows) == 2

Khi test code gọi API, đừng gọi mạng thật — hãy mock (giả lập) phản hồi để test nhanh và ổn định. Với code async, dùng pytest-asyncio và đánh dấu @pytest.mark.asyncio.

Tóm tắt

  • DB-API là nền chung; luôn tham số hoá câu lệnh (?, :name, %s) để chặn SQL injection, đừng bao giờ ghép chuỗi.
  • SQLAlchemy Core cho SQL biểu đạt với engine + connection pool; ORM ánh xạ bảng thành lớp, làm việc qua Session với add/query/commit.
  • requests gọi REST: đặt timeout, kiểm tra status code, dùng raise_for_status(); xử lý phân trang và rate limit (429, Retry-After).
  • httpx giống requests nhưng hỗ trợ async.
  • asyncio dùng một event loop, async/await nhường lượt tại điểm chờ — cực hợp với tác vụ I/O-bound; asyncio.gather chạy nhiều request song song, Semaphore giới hạn đồng thời.
  • pytest kiểm thử bằng assert, fixture và parametrize; mock API thay vì gọi mạng thật.

Tự kiểm tra

  1. Vì sao ghép chuỗi để dựng SQL nguy hiểm, và tham số hoá khắc phục thế nào?
  2. Phân biệt vai trò của Engine, Sessionmodel trong SQLAlchemy ORM.
  3. resp.raise_for_status() làm gì, và status 429 nghĩa là gì? Bạn nên phản ứng ra sao?
  4. Khi nào async giúp tăng tốc, khi nào không? Giải thích bằng khái niệm I/O-bound vs CPU-bound.
  5. asyncio.gather khác gì so với việc await từng coroutine một cách tuần tự?
  6. Vì sao nên mock API trong test thay vì gọi endpoint thật?

Đọc tiếp

  • Ôn lại nền tảng tại Python 1 — Giới thiệu.
  • Tiếp theo, đào sâu phần SQL (truy vấn nâng cao, JOIN, window function, tối ưu) để khai thác tối đa CSDL mà bạn vừa học cách kết nối từ Python.

Bài viết liên quan

Vì sao Python là ngôn ngữ số một của data engineer: vai trò trong pipeline (ingest/transform/orchestrate), hệ sinh thái thư viện (pandas/polars/pyarrow/sqlalchemy), quản lý môi trường (venv/uv/poetry), và khi nào dùng Python vs SQL/Spark.

13 thg 7, 2026 4

Định nghĩa hàm, tham số, *args/**kwargs, lambda, module/package, pip và virtualenv.

13 thg 7, 2026 4

Lớp, kế thừa, đa hình, dunder methods, dataclass, type hints và nguyên tắc viết code sạch.

13 thg 7, 2026 3

Vì sao Python, cài đặt, chạy script vs REPL, biến và kiểu dữ liệu cơ bản, quy ước code.

13 thg 7, 2026 3