Skip to content

Partition Strategies — Chiến lược phân vùng bảng

Bạn đã đánh index đúng chỗ, đã viết query tối ưu, đã dùng EXPLAIN ANALYZE kiểm tra — nhưng bảng orders với 200 triệu dòng vẫn chậm. Mỗi INSERT mất 500ms vì B-Tree index phải rebalance. Mỗi SELECT dù chỉ lấy dữ liệu tháng này vẫn phải scan qua metadata của toàn bộ bảng.

Đây không còn là vấn đề query — mà là vấn đề kiến trúc dữ liệu. Partitioning chính là lời giải.

Bài này đưa bạn từ single-table đến partitioned table, và khi single-node không còn đủ — sang distributed sharding. Mỗi chiến lược có trade-off riêng, và chọn sai sẽ biến hệ thống thành cơn ác mộng khi rebalance.


Bức tranh tư duy

Hãy tưởng tượng một thư viện khổng lồ với 10 triệu cuốn sách. Nếu tất cả xếp trên một kệ dài liên tục, tìm bất kỳ cuốn nào cũng phải lần từ đầu. Nhưng nếu chia theo chủ đề (khoa học, văn học, lịch sử) vào các phòng riêng biệt — bạn chỉ cần vào đúng phòng, và tìm kiếm nhanh hơn hàng chục lần.

Partitioning chính xác là nguyên lý đó: chia bảng lớn thành nhiều bảng nhỏ, mỗi partition chứa một tập con dữ liệu, nhưng ứng dụng vẫn nhìn thấy một bảng duy nhất.

🎯 Mục tiêu

Sau bài này bạn sẽ phân biệt được Range, Hash, List partitioning — biết khi nào dùng loại nào, cách implement trên PostgreSQL, và hiểu khi nào cần chuyển sang sharding distributed.


Cốt lõi kỹ thuật

1. Range Partitioning — Phân vùng theo khoảng giá trị

Đây là chiến lược phổ biến nhất. Chia dữ liệu theo khoảng liên tục của một cột — thường là ngày tháng hoặc ID range.

Khi nào dùng: Dữ liệu có tính thời gian rõ ràng (orders, logs, events), và query hầu hết filter theo khoảng thời gian.

sql
-- PostgreSQL 12+ Declarative Partitioning
CREATE TABLE orders (
    id          BIGSERIAL,
    user_id     BIGINT NOT NULL,
    total       DECIMAL(12, 2) NOT NULL,
    status      VARCHAR(20) NOT NULL,
    created_at  TIMESTAMP NOT NULL,
    PRIMARY KEY (id, created_at)  -- partition key PHẢI nằm trong PK
) PARTITION BY RANGE (created_at);

-- Tạo partitions theo quý
CREATE TABLE orders_2024_q1 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');

CREATE TABLE orders_2024_q2 PARTITION OF orders
    FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');

CREATE TABLE orders_2024_q3 PARTITION OF orders
    FOR VALUES FROM ('2024-07-01') TO ('2024-10-01');

CREATE TABLE orders_2024_q4 PARTITION OF orders
    FOR VALUES FROM ('2024-10-01') TO ('2025-01-01');

💡 HPN Pro Tip: Partition Pruning

Khi query có WHERE created_at BETWEEN '2024-04-01' AND '2024-06-30', PostgreSQL tự động loại bỏ các partition không liên quan (Q1, Q3, Q4). Gọi là partition pruning — kiểm tra bằng EXPLAIN sẽ thấy chỉ scan orders_2024_q2.

Range theo ID (cho distributed systems):

sql
CREATE TABLE users (
    id      BIGINT NOT NULL,
    name    TEXT NOT NULL,
    email   TEXT NOT NULL,
    PRIMARY KEY (id)
) PARTITION BY RANGE (id);

CREATE TABLE users_0_10m PARTITION OF users
    FOR VALUES FROM (0) TO (10000000);

CREATE TABLE users_10m_20m PARTITION OF users
    FOR VALUES FROM (10000000) TO (20000000);

2. Hash Partitioning — Phân vùng theo hàm băm

Chia dữ liệu đều vào N partition dựa trên hash value của partition key. Không có "khoảng" — mỗi row được gán partition bằng hash(key) % N.

Khi nào dùng: Dữ liệu không có thứ tự tự nhiên, cần phân bổ đều tải (ví dụ: user_id, session_id).

sql
CREATE TABLE sessions (
    id          UUID NOT NULL,
    user_id     BIGINT NOT NULL,
    data        JSONB,
    created_at  TIMESTAMP NOT NULL,
    PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);

-- Tạo 8 partitions (nên là lũy thừa của 2)
CREATE TABLE sessions_p0 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE sessions_p1 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 8, REMAINDER 1);
CREATE TABLE sessions_p2 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 8, REMAINDER 2);
CREATE TABLE sessions_p3 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 8, REMAINDER 3);
-- ... p4 đến p7 tương tự

⚠️ Hash partition không hỗ trợ range query

WHERE user_id BETWEEN 100 AND 200 sẽ scan tất cả partitions vì hash phá vỡ thứ tự tự nhiên. Chỉ dùng hash khi query chủ yếu là point lookup (WHERE user_id = X).

3. List Partitioning — Phân vùng theo danh sách giá trị

Chia dữ liệu theo tập hợp giá trị rời rạc — thường là region, status, category.

Khi nào dùng: Cột partition có số lượng giá trị hữu hạn và query thường filter theo giá trị đó.

sql
CREATE TABLE payments (
    id          BIGSERIAL,
    amount      DECIMAL(12, 2) NOT NULL,
    currency    VARCHAR(3) NOT NULL,
    region      VARCHAR(10) NOT NULL,
    created_at  TIMESTAMP NOT NULL,
    PRIMARY KEY (id, region)
) PARTITION BY LIST (region);

CREATE TABLE payments_apac PARTITION OF payments
    FOR VALUES IN ('VN', 'TH', 'SG', 'MY', 'ID', 'PH');

CREATE TABLE payments_eu PARTITION OF payments
    FOR VALUES IN ('DE', 'FR', 'UK', 'IT', 'ES', 'NL');

CREATE TABLE payments_na PARTITION OF payments
    FOR VALUES IN ('US', 'CA', 'MX');

-- DEFAULT partition cho các region chưa khai báo
CREATE TABLE payments_other PARTITION OF payments DEFAULT;

💡 HPN Pro Tip: Default Partition

Luôn tạo DEFAULT partition. Nếu không, INSERT với giá trị region không nằm trong list nào sẽ bị lỗi ngay lập tức — và đó thường là lúc 3 giờ sáng production gặp region mới.

4. Composite Partitioning — Kết hợp nhiều chiến lược

Khi một cấp partition không đủ, bạn có thể partition lồng nhau: Range → Hash hoặc List → Range.

sql
-- Cấp 1: List theo region
CREATE TABLE events (
    id          BIGSERIAL,
    region      VARCHAR(10) NOT NULL,
    event_type  VARCHAR(50) NOT NULL,
    created_at  TIMESTAMP NOT NULL,
    payload     JSONB,
    PRIMARY KEY (id, region, created_at)
) PARTITION BY LIST (region);

-- Cấp 2: Range theo thời gian TRONG MỖI region
CREATE TABLE events_apac PARTITION OF events
    FOR VALUES IN ('VN', 'TH', 'SG')
    PARTITION BY RANGE (created_at);

CREATE TABLE events_apac_2024_h1 PARTITION OF events_apac
    FOR VALUES FROM ('2024-01-01') TO ('2024-07-01');

CREATE TABLE events_apac_2024_h2 PARTITION OF events_apac
    FOR VALUES FROM ('2024-07-01') TO ('2025-01-01');

5. Horizontal Sharding vs Vertical Partitioning

Đây là hai khái niệm thường bị nhầm lẫn:

PartitioningSharding
Phạm viTrong 1 database serverAcross nhiều database servers
Mục đíchGiảm scan rangeScale beyond single node
TransparencyApp không cần biếtApp/middleware phải route
Ví dụPG declarative partitioningVitess, Citus, custom routing

6. Shard Keys — Chọn đúng hay chết

Shard key quyết định dữ liệu đi vào shard nào. Chọn sai → hotspot (một shard quá tải trong khi các shard khác rảnh).

Tiêu chí chọn shard key tốt:

  1. Cardinality cao — Nhiều giá trị unique để phân bổ đều
  2. Query pattern alignment — Hầu hết query có shard key trong WHERE
  3. Write distribution — Writes phân bổ đều, không dồn vào 1 shard
  4. Monotonic avoidance — Tránh auto-increment ID (ghi dồn vào shard cuối)
sql
-- SAI: Shard by created_at (monotonic → hotspot ở shard mới nhất)
-- Mọi write đều đổ vào partition/shard chứa thời gian hiện tại

-- SAI: Shard by country_code (low cardinality, skewed distribution)
-- 60% users ở VN → shard VN quá tải

-- ĐÚNG: Shard by user_id (high cardinality, even distribution)
-- Mỗi user có ID unique, hash phân bổ đều

-- ĐÚNG: Composite key (tenant_id, user_id) cho multi-tenant
-- Mỗi tenant data nằm trọn trong 1 shard → no cross-shard query

7. Cross-Shard Queries — Thách thức lớn nhất

Khi query cần dữ liệu từ nhiều shard, mọi thứ trở nên phức tạp:

sql
-- Query này cần scan TẤT CẢ shards nếu shard by user_id
SELECT region, COUNT(*), SUM(total)
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY region;

-- Scatter-Gather pattern:
-- 1. Gửi query đến MỌI shard (scatter)
-- 2. Mỗi shard trả partial result
-- 3. Coordinator merge kết quả (gather)
-- → Latency = slowest shard + merge time

Patterns giảm thiểu cross-shard:

  • Denormalization: Duplicate data cần thiết vào mỗi shard
  • Global tables: Bảng reference nhỏ (countries, currencies) replicate toàn bộ
  • Colocated sharding: Các bảng liên quan dùng chung shard key

Thực chiến: E-commerce Orders Table

Bài toán: Bảng orders đạt 150 triệu rows. Query dashboard theo tháng chậm 8 giây. INSERT peak giờ sale đạt 50K/phút.

Giải pháp: Range partition theo tháng + index trên mỗi partition:

sql
-- Bước 1: Tạo partitioned table
CREATE TABLE orders_v2 (
    id          BIGSERIAL,
    user_id     BIGINT NOT NULL,
    total       DECIMAL(12, 2) NOT NULL,
    status      VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- Bước 2: Script tạo partition hàng tháng
-- (Production: dùng pg_partman extension để tự động)
DO $$
DECLARE
    start_date DATE := '2024-01-01';
    end_date DATE;
    partition_name TEXT;
BEGIN
    FOR i IN 0..11 LOOP
        end_date := start_date + INTERVAL '1 month';
        partition_name := 'orders_v2_' || TO_CHAR(start_date, 'YYYY_MM');

        EXECUTE FORMAT(
            'CREATE TABLE %I PARTITION OF orders_v2
             FOR VALUES FROM (%L) TO (%L)',
            partition_name, start_date, end_date
        );

        -- Index riêng cho mỗi partition (tự động thừa kế nếu khai báo trên parent)
        EXECUTE FORMAT(
            'CREATE INDEX %I ON %I (user_id, created_at)',
            partition_name || '_user_idx', partition_name
        );

        start_date := end_date;
    END LOOP;
END $$;

-- Bước 3: Migrate dữ liệu (offline hoặc dùng pg_repack)
INSERT INTO orders_v2 SELECT * FROM orders;

-- Bước 4: Verify partition pruning
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*), SUM(total)
FROM orders_v2
WHERE created_at >= '2024-06-01'
  AND created_at < '2024-07-01';
-- Expect: chỉ scan orders_v2_2024_06, bỏ qua 11 partitions khác

Kết quả:

  • Dashboard query: 8s → 0.4s (chỉ scan 1 partition thay vì toàn bộ)
  • INSERT throughput: tăng 3x (B-Tree index nhỏ hơn, ít page split)
  • Archival dễ dàng: DROP TABLE orders_v2_2023_01 xóa cả tháng trong milliseconds

Sai lầm điển hình

⚠️ Cạm bẫy

Chọn shard key theo auto-increment ID Auto-increment ID là monotonic — tất cả writes đổ vào shard cuối cùng (chứa range ID lớn nhất). Khi traffic tăng, shard đó thành bottleneck trong khi các shard cũ gần như idle. Dùng hash-based sharding hoặc composite key thay vì range trên auto-increment.

⚠️ Cạm bẫy

Cross-shard JOIN trong hot path

sql
-- ĐỪNG LÀM: Shard orders by user_id, shard products by category
-- JOIN orders với products = cross-shard → latency cực cao
SELECT o.id, p.name
FROM orders o
JOIN products p ON o.product_id = p.id
WHERE o.user_id = 42;
-- Nếu orders ở shard 3, products ở shard 7 → network roundtrip

Giải pháp: Colocate related tables trên cùng shard key, hoặc denormalize product_name vào orders.

⚠️ Cạm bẫy

Quên tạo DEFAULT partition Không có DEFAULT partition, bất kỳ INSERT nào với giá trị ngoài range/list đã khai báo sẽ bị ERROR. Trên production với data từ nhiều source, đây là ticking time bomb. Luôn có DEFAULT partition và monitor kích thước nó.

⚠️ Cạm bẫy

Rebalancing nightmare khi thêm shard Thêm shard vào hệ thống hash-based đòi hỏi re-hash và migrate data — downtime hoặc double-write period. Lên kế hoạch capacity trước khi hết chỗ. Dùng consistent hashing hoặc virtual shards để giảm lượng data di chuyển.


Under the Hood: PostgreSQL Partition Internals

Declarative Partitioning (PG 10+)

PostgreSQL implement partition bằng table inheritance bên dưới. Mỗi partition là một child table riêng biệt với constraint CHECK tự động.

Partition Pruning hoạt động thế nào:

  1. Query planner đọc constraint exclusion của mỗi partition
  2. So sánh WHERE clause với constraint → loại bỏ partition không match
  3. Chỉ scan các partitions còn lại
sql
-- Xem constraint của partition
SELECT
    relname,
    pg_get_expr(relpartbound, oid) AS partition_bound
FROM pg_class
WHERE relname LIKE 'orders_v2_%'
ORDER BY relname;

-- Kết quả:
-- orders_v2_2024_01 | FOR VALUES FROM ('2024-01-01') TO ('2024-02-01')
-- orders_v2_2024_02 | FOR VALUES FROM ('2024-02-01') TO ('2024-03-01')
-- ...

Partition Pruning yêu cầu:

  • enable_partition_pruning = on (default từ PG 11)
  • WHERE clause phải dùng partition key trực tiếp (không qua function)
sql
-- Pruning HOẠT ĐỘNG
WHERE created_at >= '2024-06-01' AND created_at < '2024-07-01'

-- Pruning KHÔNG hoạt động (function wrap phá pruning)
WHERE EXTRACT(MONTH FROM created_at) = 6

-- Pruning KHÔNG hoạt động (cast implicit)
WHERE created_at >= '2024-06-01'::text

💡 HPN Pro Tip: pg_partman Extension

Trong production, đừng tự tạo partition bằng tay. Dùng pg_partman — extension tự động tạo partition mới, archive partition cũ, và dọn dẹp. Setup một lần, chạy mãi mãi.


Checklist ghi nhớ

✅ Checklist triển khai

Chọn chiến lược partition

  • [ ] Range: dữ liệu time-series, logs, events — query filter theo khoảng thời gian
  • [ ] Hash: dữ liệu không có thứ tự tự nhiên, cần phân bổ đều — chỉ point lookup
  • [ ] List: dữ liệu phân loại rõ ràng (region, status, tenant) — filter theo category
  • [ ] Composite: khi 1 cấp không đủ granularity

Implementation checklist

  • [ ] Partition key nằm trong PRIMARY KEY (PostgreSQL yêu cầu)
  • [ ] Có DEFAULT partition cho giá trị ngoài khai báo
  • [ ] Index trên mỗi partition (tự động nếu khai báo trên parent table PG 11+)
  • [ ] Test partition pruning bằng EXPLAIN trước khi deploy
  • [ ] Dùng pg_partman cho auto-maintenance partition mới

Sharding checklist

  • [ ] Shard key có cardinality cao, phân bổ đều writes
  • [ ] Tránh monotonic keys (auto-increment, timestamp)
  • [ ] Related tables colocated trên cùng shard key
  • [ ] Global/reference tables replicated toàn bộ shards
  • [ ] Capacity plan cho rebalancing trước khi hết chỗ

Bài tập luyện tập

🧠 Quiz

Câu 1: Khi nào KHÔNG nên dùng Hash Partitioning?

  • [ ] A) Khi cần phân bổ dữ liệu đều giữa các partition
  • [x] B) Khi query chủ yếu là range scan (WHERE col BETWEEN x AND y)
  • [ ] C) Khi partition key có cardinality cao
  • [ ] D) Khi muốn tránh hotspot trên writes

💡 Hash partitioning phá vỡ thứ tự tự nhiên của dữ liệu. Range scan phải quét TẤT CẢ partitions vì hash(100) và hash(101) có thể nằm ở partition khác nhau. Dùng Range partitioning cho range query.

🧠 Quiz

Câu 2: Tại sao auto-increment ID là shard key tệ?

  • [ ] A) Vì auto-increment có cardinality thấp
  • [ ] B) Vì auto-increment không unique
  • [x] C) Vì auto-increment là monotonic — writes dồn vào shard cuối
  • [ ] D) Vì auto-increment không compatible với hash function

💡 Auto-increment ID liên tục tăng (monotonic). Trong range-based sharding, tất cả writes mới đổ vào shard chứa range cao nhất — gây hotspot. Trong hash-based sharding thì OK vì hash phân bổ đều, nhưng range-based sẽ thành bottleneck nghiêm trọng.

🧠 Quiz

Câu 3: PostgreSQL partition pruning sẽ KHÔNG hoạt động trong trường hợp nào?

  • [ ] A) WHERE created_at >= '2024-01-01' AND created_at < '2024-02-01'
  • [ ] B) WHERE created_at = '2024-06-15 10:30:00'
  • [x] C) WHERE EXTRACT(YEAR FROM created_at) = 2024
  • [ ] D) WHERE created_at BETWEEN '2024-03-01' AND '2024-03-31'

💡 Khi partition key bị bọc trong function (EXTRACT, DATE_TRUNC, ...), PostgreSQL planner không thể so sánh trực tiếp với partition bounds → phải scan tất cả partitions. Luôn dùng partition key trực tiếp trong WHERE clause.


Liên kết học tiếp