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.
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 PARTITIONlà 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ên và cá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.
Bài viết liên quan
20 Jun 2026
Tích hợp cổng thanh toán VNPay vào API NestJS: Hướng dẫn từ A đến Z
Hướng dẫn tích hợp cổng thanh toán VNPay vào API NestJS end-to-end: tạo URL thanh toán, ký HMAC-SHA512 vnp_SecureHash, xử lý Return URL và IPN, xác thực chữ ký và đảm bảo idempotency cho luồng thanh toán đơn hàng.
19 Jun 2026
Phân Tích & Tính Phí API Realtime Với ClickHouse Và Redis Streams
Cách GoGoDuk đo lường lượng dùng API ở quy mô lớn bằng ClickHouse — ingestion qua Redis Streams, schema ReplacingMergeTree và materialized view AggregatingMergeTree cho dashboard theo từng khách hàng và tính phí theo mức dùng, realtime.
16 Jun 2026
Directions API cho Việt Nam: Chỉ Đường Xe Máy & Turn-by-Turn Tương Thích Google
Hướng dẫn lập trình viên tích hợp Directions API GoGoDuk: gọi /v1/directions, đọc ETA và quãng đường thực, giải mã polyline vẽ lên bản đồ, hiển thị chỉ đường rẽ từng bước và xử lý fallback ESTIMATED.
12 Jun 2026
Chuyển từ Google Maps API sang API Bản Đồ Việt Nam: Chi Phí & Code
So sánh chi phí và hạn chế của Google Maps Platform tại Việt Nam, rồi chuyển geocoding, reverse geocoding và gợi ý địa chỉ sang GoGoDuk kèm code thực tế.