NHT

PostgreSQL Partitioning: Tối ưu bảng 100 triệu dòng với Declarative Partitioning

Hướng dẫn phân vùng bảng PostgreSQL cho bảng 100 triệu dòng: chọn RANGE/LIST/HASH, tạo declarative partition, tận dụng partition pruning để query nhanh hơn, đánh index per-partition và dọn dữ liệu gần như miễn phí bằng DETACH/DROP PARTITION.

Nguyen Hoang TuanNguyen Hoang Tuan20 thg 6, 202610 phút đọc

Một bảng PostgreSQL chạy rất mượt khi có vài triệu dòng, rồi đột nhiên mọi thứ chậm lại khi nó cán mốc hàng trăm triệu. Index phình to hơn cả RAM, autovacuum chạy mãi không kịp, và một câu DELETE để dọn dữ liệu cũ khoá bảng hàng phút đồng hồ. Thêm index hay tăng work_mem chỉ giúp được một quãng — đến một ngưỡng, vấn đề không còn là "query thế nào" mà là "bảng quá lớn để xử lý như một khối duy nhất".

Đây chính là lúc partitioning (phân vùng bảng) phát huy tác dụng. Bài viết này đi qua declarative partitioning của PostgreSQL (có từ PG 11 trở đi) một cách thực dụng: khi nào cần phân vùng, chọn RANGE/LIST/HASH thế nào, cách tận dụng partition pruning để query chỉ chạm vào đúng phần dữ liệu cần thiết, đánh index per-partition ra sao, và vì sao việc dọn dữ liệu cũ trở nên gần như miễn phí.

Lưu ý: các con số dòng dữ liệu và thời gian query trong bài là minh hoạ để dễ hình dung. Cú pháp PARTITION BY, PARTITION OF ... FOR VALUES, ATTACH/DETACH/DROP PARTITION là hành vi chuẩn của PostgreSQL 11+.

Khi nào một bảng PostgreSQL cần phân vùng

Phân vùng không miễn phí — nó thêm độ phức tạp khi tạo bảng, đánh index và migrate. Đừng phân vùng chỉ vì bảng "nghe có vẻ to". Hãy cân nhắc khi bạn thấy đồng thời nhiều dấu hiệu sau:

  • Bảng đã rất lớn và còn tiếp tục lớn — thường từ khoảng 100 triệu dòng trở lên, hoặc kích thước vượt nhiều lần RAM khả dụng.
  • Dữ liệu có chiều thời gian rõ ràng — log, sự kiện, đơn hàng, giao dịch... ghi mới liên tục và cũ dần đi. Bạn thường truy vấn theo khoảng thời gian gần, và muốn xoá dữ liệu cũ định kỳ.
  • Việc dọn dữ liệu cũ đang đau đớn — một DELETE FROM events WHERE created_at < now() - interval '90 days' quét và khoá hàng chục triệu dòng, sinh ra lượng lớn dead tuple cho autovacuum xử lý.
  • Index không còn vừa bộ nhớ — index trên bảng khổng lồ không cache hết, khiến mỗi lần đọc phải chạm đĩa.

Nếu dữ liệu của bạn không có chiều phân chia tự nhiên (theo thời gian, theo khách hàng, theo khu vực), hoặc bảng chỉ vài triệu dòng, thì việc tối ưu index — như trong bài PostGIS Performance Tuning — thường là lựa chọn đúng trước khi nghĩ tới phân vùng.

Declarative partitioning: RANGE, LIST hay HASH

PostgreSQL hỗ trợ ba kiểu phân vùng khai báo (declarative). Chọn đúng kiểu dựa trên cách dữ liệu được phân chia tự nhiêncách bạn truy vấn nó:

  • RANGE — phân vùng theo dải giá trị liên tục. Phổ biến nhất, dùng cho cột thời gian: mỗi tháng (hoặc mỗi ngày/tuần) là một partition. Lý tưởng cho log, sự kiện, đơn hàng.
  • LIST — phân vùng theo tập giá trị rời rạc. Hợp khi dữ liệu chia theo danh mục cố định: theo quốc gia, theo loại sự kiện, theo trạng thái.
  • HASH — PostgreSQL băm khoá phân vùng và rải đều dữ liệu ra N partition. Dùng khi không có chiều phân chia tự nhiên nhưng vẫn muốn chia nhỏ bảng để giảm kích thước mỗi phần (ví dụ phân vùng theo customer_id).

Trong thực tế, RANGE theo thời gian là trường hợp dùng nhiều nhất, vì nó vừa giúp pruning lúc query (đa số query lọc theo thời gian gần) vừa giúp dọn dữ liệu cực rẻ (xoá nguyên partition tháng cũ). Phần còn lại của bài tập trung vào RANGE.

Tạo bảng phân vùng theo RANGE

Bảng cha được khai báo là bảng phân vùng qua mệnh đề PARTITION BY. Bản thân bảng cha không chứa dữ liệu — nó chỉ định nghĩa cấu trúc và khoá phân vùng. Dữ liệu thật nằm trong các partition con.

-- Bảng cha: định nghĩa cấu trúc + khoá phân vùng
CREATE TABLE events (
  id          bigserial,
  customer_id bigint        NOT NULL,
  event_type  text          NOT NULL,
  payload     jsonb,
  created_at  timestamptz   NOT NULL
) PARTITION BY RANGE (created_at);

-- Các partition con, mỗi tháng một partition
CREATE TABLE events_2026_05 PARTITION OF events
  FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

CREATE TABLE events_2026_06 PARTITION OF events
  FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

Lưu ý quan trọng: dải giá trị là nửa đóng nửa mởFROM bao gồm, TO loại trừ. Nhờ vậy hai partition liền kề không bao giờ chồng lấn (mốc '2026-06-01' thuộc về tháng 6, không phải tháng 5). Khi INSERT vào bảng cha events, PostgreSQL tự định tuyến dòng dữ liệu vào đúng partition dựa trên created_at — ứng dụng của bạn không cần biết partition nào tồn tại.

Nếu một dòng rơi vào khoảng không có partition nào nhận, lệnh INSERT sẽ lỗi. Bạn có thể tạo một DEFAULT partition để hứng các dòng "lạc", nhưng tốt hơn là luôn tạo sẵn partition cho tương lai (xem phần tự sinh partition bên dưới).

Partition pruning: vì sao query nhanh hơn hẳn

Đây là lợi ích lớn nhất về performance. Partition pruning là việc PostgreSQL nhận ra rằng một query chỉ liên quan đến một số ít partition, và bỏ qua hoàn toàn phần còn lại — không đọc, không quét index của chúng.

EXPLAIN
SELECT count(*) FROM events
WHERE created_at >= '2026-06-10' AND created_at < '2026-06-20';

Với điều kiện lọc trên cột phân vùng created_at, kế hoạch thực thi chỉ chạm vào events_2026_06 thay vì toàn bộ bảng. Nếu bạn có 36 partition cho 3 năm dữ liệu, query trên một tuần gần đây chỉ động tới 1 partition — bỏ qua 35 cái còn lại. Khối lượng dữ liệu cần đọc giảm theo đúng tỉ lệ đó.

Điều kiện cốt lõi để pruning hoạt động: mệnh đề WHERE phải lọc trên khoá phân vùng. Nếu bạn chỉ lọc theo customer_id mà không có ràng buộc thời gian, PostgreSQL không thể loại bớt partition và buộc phải quét tất cả. Vì vậy hãy chọn khoá phân vùng trùng với chiều mà query nóng nhất của bạn thường lọc. PostgreSQL hiện đại còn hỗ trợ pruning ngay tại thời điểm thực thi (runtime), nên cả query có tham số động hay join cũng có thể hưởng lợi.

Index và ràng buộc trên bảng phân vùng

Khi tạo index trên bảng cha, PostgreSQL tự động tạo index tương ứng trên từng partition con (và trên các partition tạo sau này). Đây chính là điểm then chốt giúp performance không tụt: thay vì một index khổng lồ trên cả trăm triệu dòng, bạn có nhiều index nhỏ — mỗi cái chỉ phục vụ dữ liệu của một partition, nên dễ vừa bộ nhớ và rẻ để duy trì.

-- Tạo trên bảng cha => áp dụng cho mọi partition, kể cả partition tương lai
CREATE INDEX ON events (customer_id, created_at);

Có một ràng buộc quan trọng cần nhớ về khoá unique: mọi UNIQUE hoặc PRIMARY KEY trên bảng phân vùng đều phải chứa khoá phân vùng. PostgreSQL không thể đảm bảo tính duy nhất xuyên suốt các partition nếu không bao gồm cột phân vùng trong khoá. Do đó một PRIMARY KEY (id) đơn thuần là không hợp lệ ở đây; bạn cần PRIMARY KEY (id, created_at).

-- Hợp lệ: khoá chính bao gồm cột phân vùng created_at
ALTER TABLE events ADD PRIMARY KEY (id, created_at);

Đây là đánh đổi thường gặp khi chuyển sang phân vùng, và cần được tính đến từ khâu thiết kế schema — nhất là khi bảng được tham chiếu bởi foreign key từ nơi khác.

Lưu trữ và dọn dữ liệu gần như miễn phí

Đây là lý do khiến phân vùng theo thời gian "đáng tiền" nhất. Với một bảng thường, xoá dữ liệu cũ nghĩa là một DELETE quét hàng chục triệu dòng: nó khoá, sinh dead tuple, dồn việc cho autovacuum và làm index phân mảnh. Với bảng phân vùng, dọn dữ liệu cũ chỉ là thao tác trên metadata:

-- Gỡ partition ra khỏi bảng (dữ liệu vẫn còn, để archive nếu muốn)
ALTER TABLE events DETACH PARTITION events_2026_05;

-- Hoặc xoá thẳng cả tháng dữ liệu cũ — gần như tức thì
DROP TABLE events_2026_05;

DROP TABLE trên một partition gần như tức thì vì nó chỉ huỷ một file dữ liệu riêng biệt, không phải đi xoá từng dòng. Không dead tuple, không cần vacuum dọn dẹp sau đó, không khoá bảng lâu. DETACH thì hữu ích khi bạn muốn tách partition ra để archive (ví dụ dump sang cold storage) trước khi xoá.

Cách tiếp cận này tương đồng với chiến lược retention bằng TTL trong thế giới OLAP. Bài phân tích usage & billing với ClickHouse cho thấy ClickHouse dùng PARTITION BY toYYYYMM(...) cùng TTL ... DELETE để tự động drop nguyên tháng cũ — cùng một ý tưởng "xoá theo partition, không xoá theo dòng", chỉ khác là OLAP làm tự động còn PostgreSQL để bạn chủ động điều khiển.

Tự sinh partition và những sai lầm thường gặp

Bảng phân vùng theo thời gian cần liên tục có partition cho dữ liệu sắp tới. Nếu hôm nay là cuối tháng và bạn chưa tạo partition cho tháng sau, INSERT đầu tiên của tháng đó sẽ lỗi. Có hai cách xử lý:

  • pg_partman — extension chuẩn để quản lý partition: tự tạo trước partition tương lai và tự dọn partition quá hạn theo chính sách retention. Đây là lựa chọn nên dùng cho hệ thống production nghiêm túc.
  • Cron job tự viết — một tác vụ định kỳ chạy CREATE TABLE ... PARTITION OF ... cho vài tháng tới. Đơn giản, đủ dùng cho hệ thống nhỏ.

Những cái bẫy thường gặp khi triển khai phân vùng:

  • Quên tạo partition tương lai. Lỗi phổ biến nhất và gây downtime trực tiếp. Luôn tạo trước vài kỳ, và giám sát để cảnh báo sớm.
  • Query không lọc trên khoá phân vùng. Mất hoàn toàn lợi ích pruning, query quét mọi partition. Hãy đảm bảo các query nóng đều có điều kiện trên cột phân vùng.
  • Quá nhiều partition. Chia quá mịn (ví dụ theo giờ trong nhiều năm) tạo ra hàng nghìn partition, làm chậm khâu lập kế hoạch query (planning). Chọn độ mịn vừa phải — thường theo tháng hoặc tuần.
  • Quên ràng buộc khoá unique. Phát hiện muộn rằng PK hiện tại không chứa cột phân vùng, buộc phải đổi schema khi đã có dữ liệu lớn.
  • Migrate bảng đang chạy production. Chuyển một bảng khổng lồ hiện hữu sang dạng phân vùng cần kế hoạch cẩn thận (tạo bảng phân vùng mới, copy dữ liệu theo lô, rồi ATTACH), không thể đổi tại chỗ trong một lệnh.

Tổng kết: OLTP partitioning so với OLAP

Declarative partitioning trong PostgreSQL xoay quanh vài ý cốt lõi: chọn khoá phân vùng trùng với chiều query nóng nhất (thường là thời gian), để partition pruning thu hẹp khối lượng đọc, đánh index trên bảng cha để mỗi partition có index nhỏ gọn, và dọn dữ liệu cũ bằng DROP PARTITION thay vì DELETE. Nắm bốn điều này thì một bảng 100 triệu dòng vẫn xử lý gọn như khi nó còn nhỏ.

Một góc nhìn đáng nhớ: bài toán "bảng quá lớn" xuất hiện ở cả OLTP lẫn OLAP, và lời giải hội tụ về cùng một ý tưởng — chia dữ liệu theo thời gian và thao tác theo từng phần. PostgreSQL (OLTP) cho bạn quyền điều khiển partition một cách tường minh; ClickHouse (OLAP) làm gần như tự động qua PARTITION BY + TTL, như trong bài usage analytics với ClickHouse. Hiểu cả hai giúp bạn chọn đúng công cụ: giữ dữ liệu giao dịch nóng trong PostgreSQL đã phân vùng, và đẩy dữ liệu phân tích khổng lồ sang một engine OLAP khi cần.

Hệ thống của bạn đang gặp vấn đề hiệu năng hay mở rộng tải?

Tôi chuyên xây dựng hạ tầng bản đồ độ trễ thấp, streaming pipeline thời gian thực (Kafka, ClickHouse) và các hệ thống backend tối ưu. Hãy cùng hợp tác để nâng cấp sản phẩm của bạn.

Hợp tác ngay

Tác giả

Nguyen Hoang Tuan

Nguyen Hoang Tuan

Full-stack developer focused on practical backend architecture, web performance, and production delivery.

Bài viết liên quan