Iceberg 4 — Schema & Partition Evolution, Hidden Partitioning

13 thg 7, 2026 3 lượt xem
#data-engineering
#iceberg
#partitioning
#schema-evolution

Vì sao "tiến hóa" là điểm mạnh khác biệt nhất

Ba bài trước đã dựng nền: tổng quan Iceberg, kiến trúc metadata nhiều tầng, và ACID/time travel. Nhưng nếu chỉ có ACID thì Iceberg vẫn "na ná" một cái Hive table có giao dịch. Cái làm Iceberg thực sự khác biệt — và giải đúng những nỗi đau kinh niên của data lake kiểu Hive — là khả năng tiến hóa (evolution) cấu trúc bảng mà không phải viết lại dữ liệu và không sợ vỡ bảng: đổi schema an toàn, và đổi cách phân vùng an toàn.

Trong ngân hàng, bảng sống nhiều năm. Cột mới liên tục được thêm (chỉ tiêu rủi ro, trường theo quy định mới của NHNN), cột cũ bị đổi tên hoặc mở rộng kiểu (int → bigint khi số dư vượt ngưỡng). Cách phân vùng cũng phải đổi theo lượng dữ liệu: hôm nay days là vừa, sang năm giao dịch tăng gấp 5 lần thì days thành partition khổng lồ. Với Hive, mỗi thay đổi như vậy là một dự án migration đau đớn; với Iceberg, phần lớn chỉ là một thao tác metadata trong vài giây. Bài này mổ xẻ ba năng lực đó cùng sort order và các cạm bẫy phân vùng.

Schema evolution an toàn nhờ field ID

Vấn đề của Hive: bám theo tên và vị trí cột

Hive/Parquet cổ điển ánh xạ cột theo tên hoặc thứ tự vị trí trong file. Nghe vô hại, nhưng đây là nguồn của vô số lỗi âm thầm:

  • Rename cột làm mất dữ liệu: đổi tên balbalance, các file cũ vẫn ghi tên bal, engine đọc theo tên mới thấy balance toàn NULL.
  • Reorder / drop cột làm lệch dữ liệu theo vị trí: nếu đọc theo index cột, xóa một cột giữa bảng khiến mọi cột sau đó "trượt" một ô — số dư nhảy sang cột ngày, thảm họa thầm lặng.
  • Thêm cột giữa bảng gần như bất khả thi nếu không rewrite.

Cách Iceberg làm: mỗi cột có một field ID bất biến

Iceberg gán cho mỗi cột một field ID — một số nguyên duy nhất, bất biến — ngay khi cột ra đời. Data file (Parquet/ORC/Avro) lưu dữ liệu theo field ID này, không theo tên cũng không theo vị trí. Tên cột chỉ là nhãn hiển thị trong schema, có thể đổi thoải mái mà không đụng tới byte dữ liệu nào.

Hệ quả là các thao tác schema sau đều an toàn và tức thời (chỉ ghi metadata mới, không rewrite data file):

Thao tácCơ chế theo field IDAn toàn?
Add columnCấp field ID mới; file cũ không có ID này → đọc ra NULL (hoặc default)An toàn, không rewrite
Drop columnBỏ field ID khỏi schema; file cũ vẫn còn byte nhưng bị bỏ quaAn toàn
Rename columnĐổi nhãn tên, giữ nguyên field IDAn toàn tuyệt đối
Reorder columnChỉ đổi thứ tự hiển thị; đọc theo ID nên vô hạiAn toàn
Widening kiểuint→long, float→double, decimal tăng precisionAn toàn, không rewrite

Type promotion (widening) chỉ cho phép mở rộng — những phép luôn an toàn về giá trị: int → long, float → double, và tăng precision của decimal(P,S). Chiều ngược lại (long→int, giảm precision) bị cấm vì có thể mất dữ liệu — Iceberg từ chối để bảo vệ bạn, thay vì để bạn tự bắn vào chân.

Điểm cực kỳ giá trị: add column an toàn nghĩa là bạn không bao giờ phải "backfill" chỉ để thêm cột. Cột mới xuất hiện tức thì trên toàn bảng; dữ liệu lịch sử đọc ra NULL cho cột đó (đúng về mặt ngữ nghĩa — hồi đó chưa có trường này), dữ liệu mới ghi giá trị thật.

-- Spark SQL — MINH HOẠ, không chạy trong sandbox
ALTER TABLE bank.txn ADD COLUMN risk_score double AFTER amount;
ALTER TABLE bank.txn RENAME COLUMN bal TO balance;
ALTER TABLE bank.txn ALTER COLUMN amount TYPE bigint;   -- widening int->bigint
ALTER TABLE bank.txn DROP COLUMN legacy_flag;

Các câu SQL trong bài là cú pháp Spark/Trino trên Iceberg, không phải PostgreSQL, nên chỉ để minh họa — không đánh dấu chạy được trong sandbox.

Hidden partitioning — "đặc sản" của Iceberg

Nỗi đau phân vùng kiểu Hive

Trong Hive, partition là một cột vật lý riêng trong bảng và trong đường dẫn thư mục (/dt=2026-06-30/). Điều này kéo theo hai gánh nặng đặt lên con người:

  1. Người ghi phải tự nuôi cột partition. Có cột created_at TIMESTAMP rồi, nhưng để phân vùng theo ngày vẫn phải tạo thêm cột dt STRING và mỗi lần ghi phải nhớ tính dt = to_date(created_at). Quên hoặc tính sai → dữ liệu rơi nhầm partition.
  2. Người đọc phải biết cột partition và lọc đúng nó. Query WHERE created_at >= '2026-06-01' không cắt tỉa partition, vì partition nằm ở cột dt. Phải viết WHERE dt >= '2026-06-01'. Analyst không biết bảng phân vùng thế nào sẽ quét toàn bảng (full scan) mà không hề hay — hóa đơn điện toán tăng vọt, query chậm, và không có cảnh báo.

Cách Iceberg làm: partition = một transform trên cột thật

Iceberg định nghĩa partition bằng partition transform áp lên cột dữ liệu có thật, và lưu quan hệ này trong metadata chứ không phải trong cột hay đường dẫn. Bạn khai báo "phân vùng theo days(created_at)" — Iceberg tự dẫn xuất giá trị partition từ created_at mỗi khi ghi. Không có cột dt thủ công. Đó là hidden partitioning: phân vùng bị "ẩn" khỏi cả người ghi lẫn người đọc.

Các transform chuẩn:

TransformÝ nghĩaDùng cho
years(ts) / months(ts) / days(ts) / hours(ts)Cắt cột thời gian theo năm/tháng/ngày/giờCột timestamp/date
bucket(N, col)Băm col vào N bucket (hash), phân tán đềuCột high-cardinality (id)
truncate(L, col)Cắt còn L ký tự / làm tròn sốString tiền tố, số theo khoảng
identity(col)Dùng nguyên giá trị làm partition (kiểu Hive)Cột cardinality thấp (mã chi nhánh)

Điều kỳ diệu ở phía đọc gọi là partition pruning tự động qua transform. Khi bạn viết:

-- Trino — MINH HOẠ. Query KHÔNG cần biết bảng phân vùng thế nào
SELECT COUNT(*) FROM iceberg.bank.txn
WHERE created_at >= TIMESTAMP '2026-06-01 00:00:00'
  AND created_at <  TIMESTAMP '2026-07-01 00:00:00';

Iceberg biết partition là days(created_at), tự áp cùng transform lên vế điều kiện, và chỉ đọc các partition ngày trong tháng 6 — dù bạn lọc trên cột created_at gốc chứ không phải trên một cột partition nào. Analyst viết SQL "ngây thơ" trên cột thật vẫn được cắt tỉa đúng. So với Hive, đây là khác biệt sinh tử: loại bỏ cả một lớp lỗi "quên lọc cột partition" và "phân vùng lệch do tính sai cột dẫn xuất".

Khai báo lúc tạo bảng:

-- Spark SQL — MINH HOẠ
CREATE TABLE bank.txn (
    id        bigint,
    account_id bigint,
    amount    bigint,
    kind      string,
    created_at timestamp
) USING iceberg
PARTITIONED BY (days(created_at), bucket(16, account_id));

Ở đây days(created_at) gom theo ngày để cắt tỉa theo thời gian, còn bucket(16, account_id) phân tán đều các giao dịch của cùng tài khoản để câu truy vấn theo account_id chỉ chạm 1/16 dữ liệu và tránh skew (một vài tài khoản "khủng" làm lệch partition). Trino/Flink dùng cùng khái niệm transform.

Partition evolution — đổi cách phân vùng mà không rewrite

Đây là năng lực mà gần như không hệ nào khác làm sạch được. Khi dữ liệu lớn dần, cách phân vùng tối ưu cũng đổi. Ví dụ ban đầu days(created_at) là hợp lý (mỗi ngày vài chục triệu dòng), nhưng sau khi giao dịch tăng mạnh, mỗi partition ngày trở nên quá lớn, ta muốn chuyển sang hours(created_at) cho dữ liệu mới.

Trong Hive, "đổi partition" nghĩa là tạo bảng mới với layout mới rồi chép/ghi lại toàn bộ dữ liệu lịch sử — tốn kém, rủi ro, ngừng dịch vụ. Iceberg cho phép đổi partition spec bằng một lệnh metadata, và giữ nguyên dữ liệu cũ tại spec cũ:

-- Spark SQL — MINH HOẠ: từ nay ghi theo giờ
ALTER TABLE bank.txn ADD PARTITION FIELD hours(created_at);
ALTER TABLE bank.txn DROP PARTITION FIELD days(created_at);

Cơ chế: Iceberg lưu nhiều partition spec cho cùng một bảng, mỗi data file được đánh dấu nó được ghi theo spec-id nào. Dữ liệu cũ vẫn nằm dưới spec days; dữ liệu mới ghi theo spec hours. Khi lập kế hoạch query, planner xử lý đồng thời cả hai: với phần bảng theo days nó cắt tỉa theo ngày, với phần theo hours nó cắt tỉa theo giờ — hoàn toàn trong suốt với người viết SQL.

Lưu ý quan trọng: partition evolution chỉ áp cho dữ liệu ghi sau khi đổi spec. Nó không "chia nhỏ" lại dữ liệu cũ. Nếu bạn thực sự cần dữ liệu cũ theo layout mới (hiếm), phải rewrite chủ động qua thao tác bảo trì. Trong đa số trường hợp, cứ để dữ liệu cũ yên là hợp lý — dữ liệu càng cũ càng ít bị truy vấn chi tiết.

Sort order / clustering — cắt tỉa mịn hơn trong partition

Partition cắt tỉa ở mức thô (bỏ qua cả partition). Bên trong mỗi partition vẫn còn nhiều data file; để cắt tỉa mịn hơn, Iceberg dùng thống kê min/max từng cột trên mỗi file (lưu ở manifest) để bỏ qua file (file skipping) khi giá trị lọc không nằm trong khoảng của file.

Thống kê này chỉ hiệu quả khi dữ liệu được gom cụm (clustering) theo cột hay lọc. Vì thế Iceberg cho khai báo sort order — thứ tự sắp xếp dữ liệu khi ghi/compaction:

-- Spark SQL — MINH HOẠ
ALTER TABLE bank.txn WRITE ORDERED BY account_id, created_at;

Khi dữ liệu trong partition được sắp theo account_id, các file có khoảng account_id không giao nhau, nên query lọc theo account_id bỏ qua được phần lớn file. Iceberg còn hỗ trợ sắp xếp không gian lấp đầy (Z-order/space-filling) để tối ưu khi lọc theo nhiều cột cùng lúc. Sort order gắn chặt với compaction và điều chỉnh kích thước file — chủ đề của bài bảo trì & hiệu năng.

Cạm bẫy phân vùng cần tránh

Hidden partitioning gỡ được lỗi "quên lọc", nhưng chọn transform vẫn là quyết định thiết kế và chọn sai gây hại thật:

  • Over-partitioning (quá nhiều partition nhỏ). Phân vùng theo hours khi mỗi giờ chỉ vài nghìn dòng → hàng chục nghìn file tí hon. "Small files problem" khiến metadata phình, planning chậm, đọc kém vì overhead mở file lấn át. Nguyên tắc thô: nhắm mỗi partition đủ lớn để chứa vài file cỡ vài trăm MB.
  • Phân vùng theo cột high-cardinality bằng identity. PARTITIONED BY (account_id) với hàng triệu tài khoản = hàng triệu partition — sập metadata. Đúng cách là bucket(N, account_id): cố định số partition = N, vẫn phân tán đều, vẫn cắt tỉa được cho lọc bằng (account_id = ?).
  • Chọn transform không khớp mẫu truy vấn. Phân vùng theo bucket(amount) nhưng người dùng luôn lọc theo thời gian → không cắt tỉa được gì. Transform phải bám cột và toán tử lọc phổ biến nhất.
  • Đổi spec quá thường xuyên. Mỗi spec là một layout; trộn quá nhiều spec làm planning phức tạp. Đổi khi thực sự cần, không phải theo hứng.

Quy tắc vàng: phân vùng theo cách bạn lọc, gom cụm (sort) theo cách bạn tìm trong partition, và nhắm kích thước partition/file vừa phải — không quá to (kém cắt tỉa) không quá nhỏ (small files).

Use case thực tế

Bối cảnh (số liệu ước lượng minh họa). NCB đưa bảng giao dịch core.txn lên Iceberg: ~1,2 tỉ dòng lịch sử, ~9 triệu giao dịch/ngày, truy vấn bằng Trino cho báo cáo và Spark cho ETL. Mẫu truy vấn phổ biến: (a) lọc theo khoảng thời gian (đối soát ngày, báo cáo kỳ), (b) tra cứu lịch sử theo account_id.

1) Thiết kế phân vùng ẩn ban đầu. Đội chọn PARTITIONED BY (days(created_at), bucket(16, account_id)). days(created_at) khớp mẫu lọc theo thời gian; bucket(16, account_id) phân tán đều để tra cứu một tài khoản chỉ chạm ~1/16 dữ liệu trong ngày và tránh skew do vài tài khoản doanh nghiệp giao dịch cực nhiều. Điểm được việc nhất về vận hành: analyst viết WHERE created_at BETWEEN ...account_id = ... trên cột thật vẫn được cắt tỉa tự động, không ai phải nhớ tên cột partition, không còn sự cố "quên WHERE dt= nên full scan cả bảng" như thời Hive — vốn từng khiến vài query nuốt hàng TB quét và treo cụm.

2) Partition evolution khi tăng trưởng. Sau một năm, lượng giao dịch/ngày tăng ~2,5 lần, mỗi partition ngày phình tới hàng chục GB làm compaction và một số query trong ngày chậm. Thay vì migrate cả bảng, đội chạy một lệnh đổi spec sang hours(created_at) cho dữ liệu mới. Không byte dữ liệu cũ nào bị viết lại; dữ liệu năm cũ vẫn theo days, dữ liệu từ mốc đổi theo hours, và cùng một câu báo cáo chạy xuyên hai vùng nhờ planner tự xử lý cả hai spec. Thời gian "migration" tính bằng giây, không có downtime, không có cửa sổ rewrite hàng giờ.

-- Spark SQL — MINH HOẠ: đổi phân vùng theo giờ cho dữ liệu mới
ALTER TABLE core.txn ADD PARTITION FIELD hours(created_at);
ALTER TABLE core.txn DROP PARTITION FIELD days(created_at);

3) Thêm cột theo quy định mới, an toàn. NHNN yêu cầu bổ sung trường phân loại kênh và điểm rủi ro giao dịch. Đội chỉ chạy ALTER TABLE core.txn ADD COLUMN channel string, ADD COLUMN risk_score double. Cột xuất hiện tức thì trên 1,2 tỉ dòng; dữ liệu lịch sử đọc ra NULL (đúng ngữ nghĩa — trước đây chưa thu thập), pipeline mới ghi giá trị thật từ hôm nay. Không backfill, không rewrite, không downtime — việc mà trên Hive từng là dự án nhiều ngày với rủi ro lệch cột. Khi một chỉ số vượt ngưỡng int, một lệnh ALTER COLUMN ... TYPE bigint (widening) xử lý gọn, cũng không đụng data file.

Kết quả tổng hợp: thay đổi cấu trúc bảng từ chỗ là "sự kiện" cần lên kế hoạch, xin cửa sổ dừng và kiểm thử kỹ, trở thành thao tác thường nhật vài giây; đồng thời loại hẳn nhóm sự cố "quên cột partition" và "lệch cột do rename/reorder".

Ghi nhớ

  • Schema evolution an toàn nhờ field ID. Iceberg ánh xạ cột theo field ID bất biến, không theo tên/vị trí → add/drop/rename/reorder và widening kiểu đều chỉ ghi metadata, không rewrite data file, không vỡ bảng. Khác hẳn Hive (đổi cột dễ mất/lệch dữ liệu).
  • Add column không cần backfill: cột mới hiện tức thì, dữ liệu cũ đọc ra NULL đúng ngữ nghĩa. Type promotion chỉ cho phép mở rộng an toàn (int→long, float→double, tăng precision decimal); thu hẹp bị cấm.
  • Hidden partitioning: partition = transform (years/months/days/hours, bucket(N,col), truncate(L,col), identity) trên cột thật, lưu trong metadata. Người ghi không nuôi cột dt thủ công; người đọc lọc trên cột gốc vẫn được cắt tỉa tự động — không cần biết bảng phân vùng thế nào (khác Hive phải WHERE dt=).
  • Partition evolution: đổi spec (vd dayshours) bằng lệnh metadata, giữ nguyên dữ liệu cũ ở spec cũ; Iceberg lưu nhiều spec, planner cắt tỉa đồng thời cả hai. Không rewrite, không downtime — chỉ áp cho dữ liệu ghi sau khi đổi.
  • Sort order / clustering gom cụm dữ liệu trong partition để thống kê min/max giúp file skipping mịn hơn — bổ trợ cho partition pruning, gắn với compaction (xem bảo trì & hiệu năng).
  • Cạm bẫy: over-partitioning (small files), dùng identity cho cột high-cardinality (dùng bucket(N,·) thay thế), chọn transform không khớp mẫu lọc, đổi spec quá thường xuyên. Nguyên tắc: phân vùng theo cách bạn lọc, sort theo cách bạn tìm, giữ kích thước partition/file vừa phải.
  • Xem thêm: tổng quan Iceberg, kiến trúc metadata, ACID & time travel.

Nguồn tham khảo

Bài viết liên quan

So sánh các định dạng dữ liệu (CSV, JSON, XML, Avro, Parquet, ORC) và lý do lưu theo cột nhanh hơn cho phân tích. Bài đi sâu vào row vs columnar storage, nén (Snappy/gzip/zstd), schema evolution, OLTP vs OLAP, object storage và partitioning để tối ưu chi phí lẫn tốc độ truy vấn.

13 thg 7, 2026 10

Data Engineering là ngành xây dựng và vận hành hệ thống biến dữ liệu thô thành dữ liệu sạch, tin cậy, sẵn sàng cho phân tích và AI. Bài giới thiệu vai trò Data Engineer trong vòng đời dữ liệu (nguồn → ingestion → storage → transformation → serving), phân biệt với Analyst/Scientist/ML Engineer, bức tranh hệ sinh thái công cụ và bài toán đưa dữ liệu core banking sang kho phân tích.

13 thg 7, 2026 9

Stream processing là gì, khác biệt batch vs stream (bounded/unbounded), micro-batch (Spark) vs true streaming, Apache Flink là gì và định vị so với Spark Structured Streaming và Kafka Streams. Kiến trúc runtime JobManager/TaskManager, các tầng API, triết lý streaming-first và bối cảnh phát hiện gian lận ngân hàng.

13 thg 7, 2026 8

Vì sao một máy không đủ và cần xử lý phân tán: từ MapReduce, Hadoop/HDFS đến Apache Spark in-memory. Kiến trúc driver–executor–cluster manager, các mức trừu tượng RDD/DataFrame/Dataset, cơ chế lazy evaluation với DAG, và vì sao shuffle (wide dependency) là phần tốn kém nhất. Kèm PySpark, Spark SQL và các kỹ thuật tối ưu (partition, broadcast join, cache, chống skew) cùng khi nào KHÔNG nên dùng Spark.

13 thg 7, 2026 7

Cảm nhận của bạn

Bình luận

Bạn cần để viết bình luận.

Chưa có bình luận. Hãy là người đầu tiên chia sẻ!