Python DE 4 — Định dạng file & I/O hiệu quả

13 thg 7, 2026 4 lượt xem
#data-engineering
#parquet
#s3
#python
#io
#file-formats

Python DE 4 — Định dạng file & I/O hiệu quả

Trong một pipeline dữ liệu, định dạng file là quyết định âm thầm nhưng ảnh hưởng nặng nhất tới chi phí và tốc độ. Cùng một tập dữ liệu, chọn CSV hay Parquet có thể chênh nhau vài lần về dung lượng lưu trữ và hàng chục lần về thời gian truy vấn. Bài này đi sâu vào cách chọn định dạng, vì sao Parquet gần như là mặc định cho analytics, cách nén và partition, cách đọc/ghi trực tiếp trên cloud storage (S3/GCS), và cách xử lý file lớn hơn RAM theo luồng.

Loạt bài liên quan: pandas chuyên sâu, Polars & Arrow/DuckDB, Kết nối database.


1. Bức tranh toàn cảnh: row vs column

Trước khi so sánh từng định dạng, cần hiểu hai triết lý lưu trữ căn bản.

  • Row-oriented (theo dòng): các giá trị của một bản ghi được lưu liền nhau. Rất nhanh khi bạn ghi từng bản ghi mới hoặc đọc trọn một bản ghi (transactional, streaming). CSV, JSON, Avro thuộc nhóm này.
  • Columnar (theo cột): các giá trị của cùng một cột được lưu liền nhau. Rất nhanh khi bạn quét vài cột trên hàng triệu dòng (analytics, aggregation). Parquet, ORC thuộc nhóm này.

Điểm mấu chốt cho Data Engineering: hầu hết truy vấn phân tích chỉ đọc vài cột trên rất nhiều dòng (ví dụ SELECT SUM(amount) GROUP BY date). Lưu theo cột cho phép engine chỉ đọc đúng cột amountdate, bỏ qua phần còn lại — đây là lợi thế cấu trúc mà row format không thể có.


2. Các định dạng & khi nào dùng

CSV — phổ biến, dễ đọc, nhưng nhiều bẫy

CSV (Comma-Separated Values) là "ngôn ngữ chung" của trao đổi dữ liệu: con người đọc được, mọi công cụ mở được, không cần thư viện đặc biệt. Nhưng với vai trò định dạng lưu trữ trong pipeline, CSV có nhiều điểm yếu:

  • Không có kiểu dữ liệu: mọi thứ đều là text. 007, 2026-06-30, true đều là chuỗi cho tới khi có ai đó đoán kiểu.
  • Chậm và cồng kềnh: không nén sẵn, không có metadata, phải parse từng ký tự.
  • Bẫy encoding/delimiter/quote: file xuất từ Excel có thể là latin1 hoặc có BOM UTF-8; dấu phẩy trong nội dung phải được bao trong dấu nháy; dấu xuống dòng bên trong ô làm vỡ parser ngây thơ.

CSV phù hợp để trao đổi với người dùng cuối, xuất báo cáo nhỏ, hoặc dữ liệu nguồn nhận từ bên ngoài — nhưng nên chuyển sang Parquet càng sớm càng tốt trong pipeline (xem mục 9).

JSON / JSONL — bán cấu trúc, lồng nhau

JSON tốt cho dữ liệu bán cấu trúc, lồng nhau (nested), ví dụ payload từ API hay event có mảng và object bên trong. Nhược điểm: một file JSON là một mảng lớn — muốn đọc phải nạp toàn bộ vào RAM và parse hết, rất khó stream.

JSONL (JSON Lines / NDJSON) giải quyết điều này: mỗi dòng là một object JSON độc lập, kết thúc bằng \n.

{"id": 1, "user": "an", "tags": ["a","b"]}
{"id": 2, "user": "binh", "tags": ["c"]}

Nhờ đó bạn đọc/ghi từng dòng theo luồng, ghép nối được nhiều file, phù hợp cho log, event stream, ingest tăng dần. JSONL là dạng bán cấu trúc được ưa dùng nhất trong ingest.

Parquet — cột, nén, có schema (mặc định cho analytics)

Apache Parquet là định dạng columnar có nén, mang theo schema, và hỗ trợ predicate pushdown (bỏ qua row group không thỏa điều kiện lọc) cùng column pushdown (chỉ đọc cột cần). Đây là định dạng nền tảng của lakehouse hiện đại và được hỗ trợ gốc bởi BigQuery, Spark, DuckDB, Snowflake. Với analytics, Parquet gần như luôn là lựa chọn mặc định.

Avro — dòng, schema evolution, hợp streaming

Apache Avro là định dạng row-oriented, serialize nhị phân gọn, dùng JSON để định nghĩa schema. Điểm mạnh nhất là schema evolution linh hoạt (thêm/bớt trường mà không phải viết lại dữ liệu cũ) và tốc độ ghi cao. Vì vậy Avro là lựa chọn tự nhiên cho streaming và message bus — nó là định dạng phổ biến trong hệ Kafka Connect / Schema Registry. Khi cần đọc trọn bản ghi và ghi liên tục, Avro thắng.

ORC — cột, hệ Hive

ORC (Optimized Row Columnar) cũng là định dạng columnar có nén và indexing tích hợp, tối ưu cho hệ Hive/Hadoop và bảng ACID trong Hive. Về mặt kỹ thuật ORC rất cạnh tranh với Parquet (thậm chí nhanh hơn chút ở một số benchmark nhờ built-in index và vectorized read), nhưng hệ sinh thái công cụ ngoài Hadoop nghiêng mạnh về Parquet. Nếu bạn không sống trong hệ Hive, thường chọn Parquet.

Bảng so sánh

Định dạngKiểu lưuNénSchemaĐọc vài cộtSchema evolutionCon người đọcUse case chính
CSVRowKhông (thêm ngoài)KhôngKhôngKhôngTrao đổi, xuất báo cáo nhỏ, dữ liệu nguồn
JSON/JSONLRowKhông (thêm ngoài)Không (linh hoạt)KhôngLinh hoạtBán cấu trúc, nested, log/event
ParquetColumnCó (sẵn)Có (rất tốt)KhôngAnalytics, lakehouse, data warehouse
AvroRowCó (sẵn)KhôngRất tốtKhôngStreaming, Kafka, trao đổi giữa hệ thống
ORCColumnCó (sẵn)Có (rất tốt)KhôngHive/Hadoop, bảng ACID

Sơ đồ chọn định dạng theo use case:


3. Vì sao Parquet thắng cho analytics

Ba cơ chế của Parquet cộng lại tạo ra khác biệt lớn so với CSV:

1. Column pruning (chỉ đọc cột cần). Với SELECT amount, date FROM ..., Parquet đọc đúng hai cột đó từ disk. CSV buộc phải đọc và parse mọi cột trong mỗi dòng rồi mới vứt bỏ phần thừa. Bảng càng rộng, khoảng cách càng lớn.

2. Nén tốt hơn nhờ dữ liệu đồng nhất. Khi các giá trị cùng cột nằm cạnh nhau, chúng thường tương tự nhau (cùng kiểu, phạm vi hẹp, lặp lại), nên nén hiệu quả hơn nhiều so với việc trộn lẫn kiểu như trong một dòng CSV. Parquet/ORC có thể giảm dung lượng đáng kể so với dữ liệu thô — trong nhiều benchmark, columnar format giảm tới khoảng 75% so với raw.

3. Thống kê & row group để skip (predicate pushdown). Parquet chia dữ liệu thành các row group, mỗi group lưu min/max cho từng cột. Với điều kiện WHERE date = '2026-06-30', engine đọc thống kê và bỏ qua nguyên cả row group không chứa ngày đó mà không cần giải nén. CSV không có metadata này nên luôn phải quét tuần tự (full scan).

Kết quả thực tế thường thấy: chuyển một fact table lớn từ row format sang Parquet có thể giảm mạnh thời gian job aggregation — một báo cáo dẫn con số gần 60% chỉ nhờ khả năng bỏ qua phần lớn dữ liệu. Không phải phép màu, mà là hệ quả trực tiếp của column pruning + skip.


4. Nén: đánh đổi tốc độ và kích thước

Parquet cho phép chọn codec nén cho mỗi cột. Ba lựa chọn thường gặp:

  • snappy — nhanh nhất khi nén/giải nén, tỉ lệ nén vừa phải. Là mặc định trong hầu hết công cụ và là lựa chọn an toàn cho dữ liệu "nóng" đọc thường xuyên.
  • gzip — nén nhỏ hơn snappy nhưng chậm hơn rõ rệt. Hợp cho dữ liệu ít đọc, muốn tiết kiệm lưu trữ.
  • zstd — thường cho tỉ lệ nén tốt hơn gzip mà tốc độ vẫn cạnh tranh; ngày càng được ưa chuộng cho dữ liệu "lạnh".

Nguyên tắc: dữ liệu đọc nhiều → snappy (ưu tiên CPU/tốc độ); dữ liệu lưu trữ dài hạn, ít đọc → zstd/gzip (ưu tiên dung lượng). Đừng nén CPU-đắt cho dữ liệu bạn quét liên tục.


5. Đọc/ghi trong Python

Ba công cụ chủ lực đều dùng Apache Arrow làm nền chung nên đọc/ghi Parquet rất mượt: pandas (qua PyArrow), polars, và pyarrow trực tiếp. (Chi tiết Polars/DuckDB xem bài 3; pandas xem bài 2.)

# (minh hoạ) — đọc/ghi Parquet cơ bản với pandas
import pandas as pd

df = pd.read_csv("sales.csv")

# Ghi ra Parquet, nén snappy (mặc định)
df.to_parquet("sales.parquet", engine="pyarrow", compression="snappy")

# Đọc lại — chỉ lấy đúng 2 cột cần (column pushdown)
df2 = pd.read_parquet("sales.parquet", columns=["date", "amount"])

Với PyArrow trực tiếp, bạn kiểm soát nhiều hơn về row group và schema:

# (minh hoạ) — PyArrow: đọc/ghi có kiểm soát
import pyarrow as pa
import pyarrow.parquet as pq

table = pa.Table.from_pandas(df)
pq.write_table(table, "sales.parquet", compression="zstd", row_group_size=100_000)

# Đọc kèm lọc — predicate pushdown, chỉ nạp row group thỏa điều kiện
table2 = pq.read_table(
    "sales.parquet",
    columns=["date", "amount"],
    filters=[("date", "=", "2026-06-30")],
)

Partition khi ghi (theo ngày) để prune

Với dữ liệu lớn, đừng ghi một file khổng lồ. Hãy partition theo cột lọc thường dùng (điển hình là ngày). Khi partition theo date, mỗi giá trị ngày tạo một thư mục riêng; truy vấn WHERE date = '2026-06-30' chỉ đọc đúng thư mục đó (partition pruning), bỏ qua toàn bộ ngày khác ngay ở tầng thư mục — nhanh hơn cả row-group skip.

# (minh hoạ) — ghi Parquet dạng partition theo ngày
import pyarrow.parquet as pq
import pyarrow as pa

table = pa.Table.from_pandas(df)  # df có cột "date"
pq.write_to_dataset(
    table,
    root_path="warehouse/sales",
    partition_cols=["date"],       # tạo warehouse/sales/date=2026-06-30/...
    compression="snappy",
)

# Đọc lại: engine tự prune theo thư mục partition
ds = pq.read_table("warehouse/sales", filters=[("date", "=", "2026-06-30")])

Cấu trúc sinh ra dạng Hive-style partitioning:

warehouse/sales/
  date=2026-06-28/part-0.parquet
  date=2026-06-29/part-0.parquet
  date=2026-06-30/part-0.parquet

Lưu ý: đừng partition quá nhỏ. Partition theo cột có quá nhiều giá trị (ví dụ user_id) sẽ sinh hàng triệu file tí hon — "small files problem" giết chết hiệu năng. Chọn cột có độ phân tán vừa phải (ngày, tháng, region), và nhắm mỗi file ~128MB–1GB.


6. Cloud storage: đọc/ghi trên S3 / GCS

Trong pipeline thật, dữ liệu nằm trên object storage (S3, GCS, Azure Blob), không phải disk cục bộ. Hệ sinh thái PyData chuẩn hóa việc này qua fsspec — một API filesystem thống nhất — cùng các implementation cụ thể: s3fs (S3) và gcsfs (GCS). Nhờ vậy, chỉ cần dùng đường dẫn s3:// hoặc gs:// là pandas/polars/pyarrow đọc trực tiếp.

# (minh hoạ) — đọc Parquet trực tiếp trên S3
import pandas as pd

# s3fs được gọi ngầm qua fsspec khi thấy scheme "s3://"
df = pd.read_parquet(
    "s3://my-bucket/warehouse/sales/date=2026-06-30/",
    storage_options={
        "key": "AKIA...",          # hoặc để trống -> auto-discovery
        "secret": "....",
    },
)

# Ghi ngược lên S3 cũng chỉ cần đổi đường dẫn
df.to_parquet("s3://my-bucket/out/result.parquet", storage_options={...})

Credential: nên tránh nhúng key trực tiếp trong code. s3fs hỗ trợ auto-discovery — tự lấy từ biến môi trường (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), file ~/.aws/credentials, hoặc IAM role của máy/EKS/Lambda. Với GCS, gcsfs mặc định dùng Application Default Credentials (gcloud, service account, metadata service). Đây là cách an toàn và được khuyến nghị: để runtime cấp quyền, không hard-code secret.

storage_options được forward thẳng xuống fsspec.open, nên mọi tham số backend (endpoint tùy chỉnh cho MinIO, region, anon...) đều truyền qua đây.


7. File lớn: streaming / chunk, tránh nạp hết vào RAM

Khi file lớn hơn RAM, "đọc hết vào DataFrame" là công thức để OOM. Có mấy chiến lược:

Chunk với pandas — đọc theo lô:

# (minh hoạ) — xử lý CSV lớn theo chunk, ghi dần ra Parquet
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq

writer = None
for chunk in pd.read_csv("huge.csv", chunksize=500_000):
    table = pa.Table.from_pandas(chunk)
    if writer is None:
        writer = pq.ParquetWriter("huge.parquet", table.schema, compression="snappy")
    writer.write_table(table)
if writer:
    writer.close()

Lazy scan với Polars / DuckDB — cách hiện đại hơn: mô tả phép biến đổi rồi để engine tự stream và chỉ đọc phần cần. DuckDB có thể query thẳng file Parquet (kể cả s3://) mà không nạp toàn bộ vào bộ nhớ.

# (minh hoạ) — Polars lazy scan: không nạp hết vào RAM
import polars as pl

lf = (
    pl.scan_parquet("s3://my-bucket/warehouse/sales/**/*.parquet")
    .filter(pl.col("date") == "2026-06-30")
    .group_by("region")
    .agg(pl.col("amount").sum())
)
result = lf.collect()   # chỉ lúc này mới thực thi, đọc streaming

Chi tiết scan_parquet, lazy execution và DuckDB nằm ở bài 3 — Polars & Arrow/DuckDB. Nguyên tắc chung: push filter/select xuống càng sâu càng tốt, để engine đọc ít nhất có thể.


8. Bẫy CSV thường gặp (và cách né)

CSV là nơi ẩn nhiều bug âm thầm nhất. Danh sách kiểm tra khi đọc CSV:

  • Encoding: file từ Excel/Windows có thể là latin1/cp1252 hoặc UTF-8 có BOM. Đọc sai encoding cho ra ký tự lỗi é, . Chỉ định rõ encoding="utf-8" (hoặc utf-8-sig để nuốt BOM), fallback latin1 khi cần.
  • dtype inference sai: pandas đoán kiểu từ vài dòng đầu. Cột phần lớn là số nhưng có vài ô chuỗi sẽ bị đoán nhầm, hoặc cột ID bị ép thành float. Nên khai báo dtype= tường minh.
  • Số 0 đầu bị mất: mã bưu chính 007, số điện thoại 0912... bị đọc thành số nguyên → mất số 0 đầu. Ép các cột này thành str.
  • Ngày tháng: định dạng DD/MM/YYYY vs MM/DD/YYYY dễ đảo ngày–tháng. Chỉ định parse_dates= và format rõ ràng.
  • Dòng lỗi: số cột không khớp, dấu nháy không đóng. Cân nhắc on_bad_lines="warn" để phát hiện thay vì im lặng bỏ.
# (minh hoạ) — đọc CSV "phòng thủ"
import pandas as pd

df = pd.read_csv(
    "messy.csv",
    encoding="utf-8-sig",
    dtype={"zip_code": "str", "phone": "str", "user_id": "str"},
    parse_dates=["created_at"],
    on_bad_lines="warn",
)

Kết luận thực dụng: CSV chỉ nên tồn tại ở ranh giới của pipeline (nhận từ ngoài / xuất cho người). Ngay khi dữ liệu vào hệ thống, hãy validate rồi chuyển sang Parquet để mọi bước sau được hưởng kiểu, nén, và pushdown.


9. Use case thực tế — Chuyển kho CSV sang Parquet partition theo ngày trên S3

Bối cảnh: đội bạn có một thư mục S3 chứa hàng nghìn file CSV export hằng ngày (s3://raw/sales/2026-06-30.csv, ...). Dashboard truy vấn ngày càng chậm và tốn tiền vì mỗi query đều full-scan CSV. Mục tiêu: xây một layer curated dạng Parquet partition theo date để dashboard chỉ đọc đúng ngày cần.

Các bước:

  1. Đọc raw theo luồng: dùng chunk hoặc scan_csv, tránh nạp hết. Với CSV, đọc "phòng thủ" như mục 8 (encoding, dtype, số 0 đầu).
  2. Chuẩn hóa schema: ép kiểu tường minh (amount → float, date → date, user_id → string), loại bỏ/log dòng lỗi. Đây là "hợp đồng" (contract) cho tất cả bước sau.
  3. Ghi Parquet partition theo date lên s3://curated/sales/, nén snappy, nhắm file ~256MB. Với dữ liệu mới hằng ngày, chỉ ghi partition của ngày đó (idempotent: ghi đè date=2026-06-30/).
  4. Truy vấn: dashboard/DuckDB đọc s3://curated/sales/ với filter theo ngày → chỉ đọc một thư mục partition, một vài cột.
# (minh hoạ) — job chuyển 1 ngày CSV -> Parquet partition trên S3
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq

day = "2026-06-30"
df = pd.read_csv(
    f"s3://raw/sales/{day}.csv",
    encoding="utf-8-sig",
    dtype={"user_id": "str", "zip": "str"},
    parse_dates=["ts"],
)
df["date"] = day  # cột partition

pq.write_to_dataset(
    pa.Table.from_pandas(df),
    root_path="s3://curated/sales",
    partition_cols=["date"],
    compression="snappy",
    existing_data_behavior="delete_matching",  # idempotent cho partition ngày đó
)

Kết quả điển hình: dung lượng giảm nhiều lần, truy vấn "một ngày" nhanh hơn hàng chục lần vì kết hợp partition pruning + column pruning + row-group skip. Chi phí đọc S3 (thường tính theo lượng dữ liệu quét) cũng giảm theo. Đây chính là "hình dạng" của một lakehouse ở mức tối giản.


10. Ghi nhớ

  • Row vs column là quyết định gốc: ghi/đọc trọn bản ghi (streaming) → row (Avro/CSV/JSON); quét vài cột trên nhiều dòng (analytics) → column (Parquet/ORC).
  • Parquet là mặc định cho analytics nhờ column pruning + nén tốt + row-group statistics để skip. So với CSV, khác biệt là bậc độ lớn.
  • CSV chỉ nên ở ranh giới pipeline; chuyển sang Parquet càng sớm càng tốt để hưởng kiểu, nén, pushdown và né mọi bẫy encoding/dtype.
  • JSONL cho bán cấu trúc/streaming; Avro cho Kafka và schema evolution; ORC cho hệ Hive.
  • Nén: snappy cho dữ liệu nóng (tốc độ), zstd/gzip cho dữ liệu lạnh (dung lượng).
  • Partition theo cột lọc thường dùng (ngày) để prune; tránh partition quá nhỏ gây "small files problem".
  • Cloud storage qua fsspec + s3fs/gcsfs: dùng đường dẫn s3:///gs://, để runtime cấp credential (env/IAM/ADC) thay vì hard-code secret.
  • File lớn: luôn stream/chunk hoặc lazy scan (Polars/DuckDB); push filter/select xuống sâu để đọc ít nhất có thể.

Nguồn tham khảo

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 5

Đị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 4

Exception handling, context manager (with), đọc/ghi file, JSON/CSV và logging đúng cách.

13 thg 7, 2026 4