BigQuery 4 — Partitioning & clustering

13 thg 7, 2026 2 lượt xem
#clustering
#sql
#bigquery
#cost
#pruning
#partitioning

BigQuery 4 — Partitioning & clustering

Trong bài Storage & tables chúng ta đã thấy BigQuery lưu dữ liệu ở định dạng cột (columnar) và tách rời hoàn toàn khỏi lớp tính toán. Bài này trả lời câu hỏi thực tế nhất khi bảng của bạn lớn dần: làm sao để một truy vấn không phải đọc toàn bộ bảng? Câu trả lời có hai tầng — partitioning (phân vùng) và clustering — và hiểu đúng chúng là ranh giới giữa một hoá đơn BigQuery vài chục đô một tháng và một hoá đơn vài nghìn đô.

Vì sao chuyện này đáng tiền — theo nghĩa đen

Với mô hình on-demand (mặc định), BigQuery tính tiền theo số byte mà truy vấn quét, không phải theo số dòng trả về, cũng không theo thời gian chạy. Một câu SELECT COUNT(*) FROM t WHERE d = '2026-06-30' nếu buộc phải đọc cả bảng 2 TB thì bạn trả tiền cho 2 TB — dù kết quả chỉ là một con số.

Vì lưu trữ theo cột, BigQuery đã có sẵn một mức tối ưu: nó chỉ đọc những cột bạn nhắc tới trong truy vấn. Đây là lý do SELECT * là một thói quen đắt đỏ (chi tiết ở bài Chi phí & hiệu năng). Nhưng columnar chỉ giảm chiều ngang (số cột). Partitioning và clustering giảm chiều dọc (số dòng phải đọc trong mỗi cột). Ghép cả hai lại: truy vấn chỉ đọc đúng vài cột, trong đúng vài phần dữ liệu.

Nguyên tắc vàng của cả bài gói gọn một câu:

Byte quét ít đi = tiền ít đi VÀ nhanh hơn. Đây là trường hợp hiếm hoi mà tối ưu chi phí và tối ưu tốc độ trùng khít nhau.

Partitioning — chia bảng thành các mảnh có tên

Partition là một mảnh vật lý của bảng, được xác định bởi giá trị của một cột (hoặc thời điểm nạp dữ liệu). Toàn bộ dữ liệu của một partition được lưu tách biệt với các partition khác. Khi truy vấn lọc theo cột phân vùng, BigQuery chỉ mở những mảnh liên quan và bỏ qua phần còn lại.

Điểm cốt lõi phải nhớ: một bảng chỉ được phân vùng theo đúng một tiêu chí (một cột time-unit, hoặc ingestion time, hoặc một cột integer). Không có "partition theo 2 cột" như một số hệ khác. Muốn cắt nhỏ thêm, bạn dùng clustering (phần sau).

BigQuery hỗ trợ ba kiểu phân vùng:

1. Time-unit column — phân vùng theo cột thời gian

Phổ biến nhất. Bạn chọn một cột DATE, TIMESTAMP hoặc DATETIME làm khoá phân vùng, với độ mịn (granularity):

  • Cột DATE: theo ngày / tháng / năm.
  • Cột TIMESTAMP / DATETIME: theo giờ / ngày / tháng / năm.

Ranh giới partition được tính theo UTC. Đây là chi tiết dễ gây lỗi: nếu nghiệp vụ của bạn theo giờ Việt Nam (UTC+7), một giao dịch lúc 06:00 sáng ngày 30/6 giờ VN thực ra rơi vào partition 2026-06-29 theo UTC. Hãy thống nhất múi giờ từ đầu.

-- (minh hoạ — BigQuery)
CREATE TABLE sales.events
(
  event_time TIMESTAMP,
  customer_id INT64,
  amount      NUMERIC,
  country     STRING
)
PARTITION BY DATE(event_time)   -- phân vùng theo ngày (rút DATE từ TIMESTAMP)
OPTIONS (
  require_partition_filter = TRUE,
  partition_expiration_days = 400
);

2. Ingestion-time — phân vùng theo thời điểm nạp

Nếu dữ liệu không có sẵn cột thời gian nghiệp vụ đáng tin, bạn có thể để BigQuery gán mỗi dòng vào partition dựa trên thời điểm nó được nạp. Khi đó bảng có hai pseudocolumn (cột giả, không tốn lưu trữ):

  • _PARTITIONTIMETIMESTAMP đầu ngày UTC của partition.
  • _PARTITIONDATEDATE tương ứng.
-- (minh hoạ — BigQuery)
CREATE TABLE logs.raw_events (payload STRING)
PARTITION BY _PARTITIONDATE
OPTIONS (require_partition_filter = TRUE);

-- Truy vấn phải lọc trên pseudocolumn
SELECT COUNT(*)
FROM logs.raw_events
WHERE _PARTITIONDATE BETWEEN '2026-06-01' AND '2026-06-30';

Ưu điểm: đơn giản, không cần chuẩn bị cột. Nhược điểm: "ngày nạp" không phải lúc nào cũng bằng "ngày phát sinh sự kiện" — nếu bạn nạp bù dữ liệu cũ (backfill), tất cả rơi vào partition của hôm nạp.

3. Integer range — phân vùng theo dải số nguyên

Dùng khi khoá phân vùng tự nhiên là một số nguyên (ví dụ customer_id, region_id). Bạn khai báo giá trị đầu, giá trị cuối và bước nhảy:

-- (minh hoạ — BigQuery)
CREATE TABLE crm.accounts (account_id INT64, name STRING)
PARTITION BY RANGE_BUCKET(account_id, GENERATE_ARRAY(0, 100000, 1000));
-- 100 partition, mỗi cái gom 1000 account_id

Giá trị nằm ngoài dải rơi vào partition đặc biệt __UNPARTITIONED__; giá trị NULL rơi vào __NULL__.

Ba "van an toàn" cần biết

  • Giới hạn số partition. Một bảng có trần 10.000 partition. Con số này quyết định chọn granularity: phân vùng theo giờ chỉ đủ cho ~416 ngày dữ liệu (10.000 / 24), nên với bảng lịch sử dài hạn hãy dùng theo ngày (đủ ~27 năm) hoặc theo tháng.
  • require_partition_filter = TRUE. Bật lên thì mọi truy vấn bắt buộc phải có điều kiện lọc trên cột phân vùng, nếu không BigQuery từ chối chạy. Đây là "phanh tay" chống việc lỡ tay quét cả bảng tỷ dòng — cực kỳ nên bật cho bảng lớn.
  • Partition expiration. Đặt partition_expiration_days để BigQuery tự xoá partition quá hạn. Đây là cách rẻ và sạch nhất để giữ lịch sử lăn (rolling window) — ví dụ chỉ giữ 400 ngày gần nhất — thay vì viết job DELETE định kỳ.

Partition pruning — cơ chế thực sự tiết kiệm tiền

Partition pruning là hành vi BigQuery đọc điều kiện WHERE trên cột phân vùng và chỉ mở đúng các partition khớp. Đây mới là nơi tiền được cắt. Partitioning mà không có pruning thì vô nghĩa.

-- (minh hoạ — BigQuery)
-- Bảng events partition theo DATE(event_time), tổng 2 TB, mỗi ngày ~5 GB
SELECT customer_id, SUM(amount) AS total
FROM sales.events
WHERE DATE(event_time) BETWEEN '2026-06-01' AND '2026-06-30'  -- prune: chỉ 30 partition
GROUP BY customer_id;
-- Quét ~150 GB thay vì 2 TB → rẻ hơn ~13 lần

Điều quyết định pruning có xảy ra hay không là: biểu thức lọc phải để BigQuery nhìn thấy giá trị partition tại thời điểm lập kế hoạch. Có vài cái bẫy khiến pruning biến mất âm thầm (truy vấn vẫn đúng, chỉ là quét sạch bảng và bạn phải trả tiền):

  • Bọc hàm không "cắt được" lên cột partition. WHERE DATE(event_time) = '2026-06-30' thì pruning tốt (vì bảng vốn partition theo DATE(event_time)). Nhưng WHERE CAST(event_time AS STRING) LIKE '2026-06-30%' biến cột thành một biểu thức mà bộ lập kế hoạch không map được về partition → mất pruning.
  • So sánh với giá trị không xác định lúc lập kế hoạch. Nếu điều kiện lọc partition đến từ một subquery (WHERE d = (SELECT MAX(d) FROM ...)), BigQuery thường không prune được vì chưa biết giá trị khi lập plan. Hãy tính giá trị ra trước rồi truyền vào như một tham số/hằng.
  • OR bắc cầu sang cột khác. WHERE d = '2026-06-30' OR country = 'VN' buộc phải xét mọi dòng để kiểm điều kiện thứ hai → mất pruning.

Mẹo kiểm tra: trước khi chạy, dùng dry run hoặc nhìn ước tính "This query will process X bytes" trên console. Nếu X gần bằng kích thước cả bảng dù bạn đã lọc theo ngày → pruning đang hỏng, sửa WHERE trước khi bấm chạy.

Clustering — sắp xếp dữ liệu bên trong

Clustering sắp xếp vật lý dữ liệu trong bảng theo giá trị của tối đa 4 cột. Sau khi sắp xếp, các dòng có giá trị gần nhau nằm chung trong các storage block. Khi truy vấn lọc/gộp/join theo cột cluster, BigQuery làm block pruning: chỉ đọc các block có thể chứa giá trị cần, bỏ qua các block khác.

Điểm khác biệt bản chất so với partitioning:

PartitioningClustering
Đơn vị cắtPartition (mảnh có tên)Storage block (bên trong dữ liệu)
Số nhómCó trần (10.000)Không giới hạn, tự động
Chi phí biết trước? — dry run báo byte chính xácKhông — chỉ biết byte thực tế sau khi chạy
Quản lýBạn khai báo khoá + granularityBigQuery tự sắp xếp, tự re-cluster ngầm
Số cột1 tiêu chítối đa 4 cột, theo thứ tự

Thứ tự cột cluster là quan trọng — quy tắc prefix

Clustering hoạt động như một khoá sắp xếp phức hợp (composite sort key). Vì thế truy vấn được lợi khi lọc theo cột cluster từ trái sang, theo tiền tố (prefix). Với bảng CLUSTER BY country, status, event_date:

  • Lọc theo country → tốt.
  • Lọc theo country status → rất tốt.
  • Lọc chỉ theo status (bỏ qua country) → không được block pruning hiệu quả, vì dữ liệu chưa được gom theo status trước.

Quy tắc thực hành: cột được lọc thường xuyên nhất / có tính chọn lọc cao nhất để lên đầu.

Vài lưu ý định lượng từ tài liệu Google: clustering chỉ thật sự phát huy khi bảng đủ lớn (từ khoảng 64 MB trở lên, dưới ngưỡng đó cả bảng đã là một khối nhỏ nên chẳng có gì để prune). Ngược lại, partitioning phát huy tốt nhất khi mỗi partition đủ "dày" — trung bình từ vài GB trở lên; nếu partition quá nhỏ và vụn thì overhead metadata lấn át lợi ích.

Phối hợp cả hai — mẫu tối ưu cho bảng sự kiện lớn

Đây là kết hợp "kinh điển" và là câu trả lời cho hầu hết bảng lớn trong thực tế:

Partition theo thời gian (ngày) + Cluster theo cột lọc/join thường gặp (ví dụ customer_id).

Cơ chế xếp tầng: BigQuery partition trước → truy vấn lọc theo ngày chỉ chạm vào vài partition. Bên trong mỗi partition, dữ liệu lại được cluster theo customer_id → nếu truy vấn còn lọc thêm theo khách hàng, block pruning cắt tiếp bên trong. Hai lớp giảm byte chồng lên nhau.

-- (minh hoạ — BigQuery)
CREATE TABLE sales.events
(
  event_time  TIMESTAMP,
  customer_id INT64,
  amount      NUMERIC,
  country     STRING
)
PARTITION BY DATE(event_time)          -- lớp 1: cắt theo ngày
CLUSTER BY customer_id, country        -- lớp 2: sắp xếp trong partition
OPTIONS (
  require_partition_filter = TRUE,
  partition_expiration_days = 730
);

-- Truy vấn tận dụng CẢ hai lớp
SELECT SUM(amount) AS total
FROM sales.events
WHERE DATE(event_time) BETWEEN '2026-03-01' AND '2026-03-31'  -- partition pruning
  AND customer_id = 42;                                        -- block pruning

Use case thực tế — bảng giao dịch tỷ dòng

Hãy đặt vào một bài toán ngân hàng/thanh toán: bảng transactions 1,5 tỷ dòng, ~2 TB, tăng thêm ~5 GB mỗi ngày. Các truy vấn phổ biến: (a) báo cáo theo khoảng ngày, (b) tra sao kê của một tài khoản trong một khoảng ngày.

Nếu để bảng "phẳng" (không partition, không cluster): mỗi truy vấn quét gần 2 TB. Với đơn giá on-demand ~5 USD/TB (kiểm tra bảng giá hiện hành theo region của bạn), một truy vấn ~10 USD. 200 truy vấn/ngày của đội phân tích ≈ 2.000 USD/ngày — chưa làm gì đã cháy ngân sách.

Thiết kế đúng: PARTITION BY DATE(txn_time) + CLUSTER BY account_id + require_partition_filter = TRUE.

  • Truy vấn (a) — báo cáo 1 tháng: pruning xuống ~30 partition ≈ 150 GB. Từ 10 USD còn ~0,75 USD.
  • Truy vấn (b) — sao kê 1 tài khoản trong 1 tháng: partition pruning về 30 ngày, rồi block pruning theo account_id cắt tiếp trong mỗi ngày — thực tế thường chỉ đọc vài trăm MB. Chi phí gần như không đáng kể.
  • require_partition_filter chặn đứng những truy vấn "quên WHERE" — thủ phạm số một của hoá đơn sốc.
  • partition_expiration_days giữ cửa sổ lịch sử theo quy định (ví dụ 7 năm với dữ liệu tài chính, hoặc ngắn hơn cho log) mà không cần job dọn dẹp thủ công.

Cùng một dữ liệu, cùng một câu hỏi nghiệp vụ — chỉ khác ở DDL — chi phí chênh nhau hơn một bậc độ lớn.

So với partition của Oracle/Postgres

Nếu bạn đến từ Postgres partitioning & sharding hoặc từ Oracle, hãy chú ý mấy khác biệt về cơ chế:

  • BigQuery tự quản. Không có CREATE TABLE ... PARTITION OF cho từng partition con như declarative partitioning của Postgres, cũng không có "local index" / "global index" như Oracle. Bạn khai báo khoá phân vùng một lần, BigQuery tạo và xoá partition tự động.
  • Không có index B-tree. Postgres/Oracle tăng tốc bằng chỉ mục B-tree; BigQuery không có index kiểu đó cho truy vấn phân tích thông thường. Vai trò "cắt bớt dữ liệu đọc" được đảm nhiệm bởi partitioning + clustering + lưu trữ cột. Đây là thay đổi tư duy lớn: đừng đi tìm CREATE INDEX, hãy thiết kế khoá phân vùng và cột cluster.
  • Clustering ≠ list/hash partition. CLUSTER BY của BigQuery gần với "khoá sắp xếp" hơn là "partition theo list/hash" của Postgres — nó không tạo các nhóm rời rạc có tên, mà sắp xếp liên tục và để BigQuery tự re-cluster ngầm.

Điểm chung về tư tưởng thì vẫn y hệt: đọc ít dữ liệu hơn thì nhanh hơn. Chỉ là công cụ để đạt điều đó khác nhau.

Ghi nhớ

  • Chi phí = byte quét. Mọi kỹ thuật trong bài đều nhằm giảm byte đọc; giảm byte đồng thời giảm tiền và tăng tốc.
  • Bảng lớn → luôn partition theo thời gian. Chọn granularity sao cho không vượt trần 10.000 partition (thường là theo ngày).
  • Cluster theo cột hay lọc/join nhất, đặt cột chọn lọc cao lên đầu (quy tắc prefix, tối đa 4 cột).
  • Mẫu vàng: partition theo ngày + cluster theo customer_id/account_id cho bảng sự kiện/giao dịch.
  • Bật require_partition_filter = TRUE cho bảng lớn — phanh tay chống quét toàn bảng.
  • Coi chừng mất pruning: đừng bọc hàm không-cắt-được lên cột partition, tránh lọc partition bằng subquery, cẩn thận với OR.
  • Dry run trước khi chạy để xác nhận số byte — nếu gần bằng cả bảng thì WHERE đang sai.
  • Đừng SELECT * — columnar chỉ giúp khi bạn chọn đúng cột (xem Chi phí & hiệu năng).

Đọc tiếp

Bài viết liên quan

Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.

13 thg 7, 2026 4

Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.

13 thg 7, 2026 4

Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.

13 thg 7, 2026 3

Truy vấn lồng, CTE (WITH), CTE đệ quy và hàm cửa sổ — vũ khí cho phân tích nâng cao.

13 thg 7, 2026 3