Python 8 — Làm việc với SQL, API & 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.cursorlà 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 Python và dò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.
Use case thực tế
Bài toán tại NCB (minh hoạ): Đội dữ liệu cần làm giàu hồ sơ khách hàng doanh nghiệp bằng dữ liệu tỷ giá và thông tin định danh từ một dịch vụ đối tác. Mỗi đêm pipeline phải lấy về ~2.000 bản ghi qua REST API (giới hạn 100 bản ghi/trang, rate limit ~5 request/giây) rồi ghi vào bảng dw.customer_enrichment trong kho dữ liệu PostgreSQL.
Cách làm khớp nội dung bài:
- Gọi API có phòng thủ. Dùng
httpxvớitimeout=10,raise_for_status(); gặp429thì đọc headerRetry-Aftervà chờ đúng số giây — như mẫufetch_allphân trang trong bài. - Song song có kiểm soát. 20 trang dữ liệu nếu gọi tuần tự (mỗi trang ~0,5s) mất ~10s; với
asyncio.gather+httpx.AsyncClientchỉ còn cỡ thời gian request chậm nhất. Đặtasyncio.Semaphore(5)để không vượt rate limit của đối tác (ước lượng). - Ghi CSDL an toàn. Dùng SQLAlchemy Core
engine.connect()với câu lệnh tham số hoá (:uid,:name) — tuyệt đối không ghép chuỗi — rồicommit()theo batch để giảm số round-trip. - Chịu lỗi từng phần.
asyncio.gather(*tasks, return_exceptions=True)giúp một trang lỗi (token hết hạn, 500) không làm hỏng cả mẻ; các bản ghi lỗi được ghi log để chạy lại.
Kết quả (ước lượng): thời gian nạp mỗi đêm giảm từ hàng chục phút xuống dưới một phút, và code có pytest mock phản hồi API nên thay đổi được kiểm thử mà không gọi mạng thật.
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 quaSessionvới add/query/commit. - requests gọi REST: đặt
timeout, kiểm tra status code, dùngraise_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/awaitnhường lượt tại điểm chờ — cực hợp với tác vụ I/O-bound;asyncio.gatherchạy nhiều request song song,Semaphoregiớ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
- Vì sao ghép chuỗi để dựng SQL nguy hiểm, và tham số hoá khắc phục thế nào?
- Phân biệt vai trò của
Engine,Sessionvàmodeltrong SQLAlchemy ORM. resp.raise_for_status()làm gì, và status 429 nghĩa là gì? Bạn nên phản ứng ra sao?- 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.
asyncio.gatherkhác gì so với việcawaittừng coroutine một cách tuần tự?- 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.
Nguồn tham khảo
- PEP 249 — Python Database API Specification v2.0 (peps.python.org/pep-0249)
- Python Documentation —
asyncio(Asynchronous I/O), mục Coroutines and Tasks (docs.python.org/3/library/asyncio.html) - Python Documentation —
sqlite3(DB-API 2.0 interface for SQLite databases) - SQLAlchemy Documentation — Unified Tutorial (Core và ORM), phần Engine, Connection và Session (docs.sqlalchemy.org)
- HTTPX Documentation — Async Support và Clients (python-httpx.org)
- Requests Documentation — Quickstart và Advanced Usage (requests.readthedocs.io)
- pytest Documentation — Getting Started, Fixtures và Parametrize (docs.pytest.org)
Bài viết liên quan
Học cách tổ chức code Python: định nghĩa hàm với tham số vị trí/từ khoá/mặc định, *args/**kwargs, lambda và hàm bậc cao, closure, decorator, generator với yield. Đóng gói code thành module và package, cô lập thư viện bằng môi trường ảo venv, quản lý phụ thuộc với pip và requirements.txt để dự án tái lập được trên mọi máy.
Hướng dẫn OOP trong Python từ class/instance, kế thừa và super(), đa hình & duck typing, encapsulation tới dunder methods, @property, classmethod/staticmethod, dataclass và type hints (mypy). Kèm nguyên tắc clean code: đặt tên rõ nghĩa, hàm nhỏ, DRY, SOLID cùng chuẩn PEP8 với công cụ ruff/black.
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.
Biến script thành pipeline đáng tin cậy: cấu trúc project & packaging (uv/poetry), type hints & pydantic, kiểm thử với pytest, logging & cấu hình, đóng gói Docker, và tích hợp CI cho code dữ liệu.
Cảm nhận của bạn
Bình luận
Chưa có bình luận. Hãy là người đầu tiên chia sẻ!