Vượt qua rào cản 1,2 tỷ dòng dữ liệu
Tôi đã từng làm việc trên một hệ thống ghi log nơi mà một bảng logs duy nhất đã phình to lên tới 1,2 tỷ dòng. Các truy vấn phân tích đơn giản vốn trước đây chỉ mất vài mili giây bắt đầu kéo dài từ 15 đến 20 giây. Các hoạt động bảo trì định kỳ như VACUUM hoặc thêm index trở thành những tác vụ rủi ro cao kéo dài suốt cả cuối tuần. Đây là rào cản mở rộng tất yếu mà bạn sẽ gặp phải khi dữ liệu vượt quá khả năng xử lý hiệu quả của một bảng vật lý duy nhất.
Trong khi nhiều đội ngũ chuyển sang các cụm NoSQL phức tạp khi chạm tới điểm này, tính năng declarative partitioning của PostgreSQL thường cung cấp một lộ trình gọn gàng hơn. Nó cho phép bạn chia một bảng logic khổng lồ thành các phần vật lý nhỏ hơn. Sự thay đổi kiến trúc này giúp các index của bạn luôn nhỏ gọn, truy vấn tinh gọn và các khung thời gian bảo trì (maintenance windows) nằm trong tầm kiểm soát.
Thiết lập Range Partitioning
PostgreSQL xử lý việc phân vùng thông qua mệnh đề PARTITION BY. Dữ liệu dạng chuỗi thời gian (time-series) là ứng cử viên phổ biến nhất cho phương pháp này. Hãy tưởng tượng một bảng orders nhận được 5 triệu dòng mới mỗi tháng. Nếu không phân vùng, index cuối cùng sẽ trở nên quá lớn để có thể nằm gọn trong RAM.
Đầu tiên, chúng ta định nghĩa bảng cha (parent table). Lưu ý rằng bảng này đóng vai trò như một bản mẫu; bản thân nó không lưu trữ dữ liệu.
CREATE TABLE orders (
order_id SERIAL,
customer_id INT NOT NULL,
order_date DATE NOT NULL,
total_amount NUMERIC,
PRIMARY KEY (order_id, order_date)
) PARTITION BY RANGE (order_date);
Một chi tiết quan trọng ở đây: partition key (order_date) phải được bao gồm trong primary key. Sau khi bảng cha tồn tại, bạn có thể tạo các phân vùng lưu trữ thực tế cho các tháng cụ thể:
-- Phân vùng cho tháng 01 năm 2024
CREATE TABLE orders_2024_01 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
-- Phân vùng cho tháng 02 năm 2024
CREATE TABLE orders_2024_02 PARTITION OF orders
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
Khi bạn chèn một dòng với ngày trong tháng 1, PostgreSQL sẽ tự động điều hướng nó đến bảng orders_2024_01. Trong quá trình truy vấn, bộ lập kế hoạch (planner) sử dụng cơ chế “Partition Pruning”. Nếu mệnh đề WHERE của bạn lọc dữ liệu cho tháng 1, engine sẽ bỏ qua mọi phân vùng khác. Thay vì quét một bảng 500GB, nó có thể chỉ chạm vào một phân đoạn 10GB.
Lựa chọn chiến lược phù hợp
Range partitioning là tiêu chuẩn cho ngày tháng, nhưng nó không phải là công cụ duy nhất. Tùy thuộc vào cách bạn truy cập dữ liệu, List hoặc Hash partitioning có thể hiệu quả hơn.
1. Range Partitioning
Sử dụng phương pháp này cho ngày tháng, timestamp hoặc các ID tuần tự. Nó hoàn hảo cho dữ liệu có vòng đời tự nhiên. Việc xóa bỏ một tháng dữ liệu cũ thông qua lệnh DROP TABLE orders_2023_01 diễn ra gần như tức thì. Nó tránh được gánh nặng I/O khổng lồ và việc phình to log giao dịch (transaction log bloat) do lệnh DELETE thông thường gây ra.
2. List Partitioning
Phương pháp này hoạt động tốt nhất cho dữ liệu phân loại như khu vực hoặc ID phòng ban. Nếu ứng dụng của bạn lọc mạnh theo country_code, bạn có thể tách biệt dữ liệu theo địa lý để giữ các truy vấn cục bộ trong các vùng cụ thể.
CREATE TABLE users (id INT, name TEXT, country_code TEXT)
PARTITION BY LIST (country_code);
CREATE TABLE users_vn PARTITION OF users FOR VALUES IN ('VN');
CREATE TABLE users_us PARTITION OF users FOR VALUES IN ('US');
3. Hash Partitioning
Hash partitioning lý tưởng để phân bổ tải I/O khi không có dải logic nào cụ thể. Nếu một bảng sessions quá bận rộn đến mức một đĩa đơn không thể xử lý các tác vụ ghi, hãy sử dụng Hash partitioning để trải rộng dữ liệu trên nhiều tệp vật lý bằng logic lấy dư (modulus).
CREATE TABLE web_sessions (session_id UUID, data TEXT)
PARTITION BY HASH (session_id);
-- Chia dữ liệu thành 4 phần bằng nhau
CREATE TABLE web_sessions_0 PARTITION OF web_sessions FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE web_sessions_1 PARTITION OF web_sessions FOR VALUES WITH (MODULUS 4, REMAINDER 1);
-- Lặp lại cho phần dư 2 và 3...
Thực tế về Indexing và Bảo trì
Quản lý một cơ sở dữ liệu được phân vùng đòi hỏi sự thay đổi trong cách bạn suy nghĩ về index. PostgreSQL không hỗ trợ “Global Indexes” trải dài trên tất cả các phân vùng. Khi bạn định nghĩa một index trên bảng cha, PostgreSQL sẽ tạo một index cục bộ (local index) cho mỗi phân vùng. Điều này giúp các cây index riêng lẻ luôn nông và nhanh, nhưng bạn phải bao gồm partition key trong các truy vấn của mình để đảm bảo bộ lập kế hoạch biết nên sử dụng index nào.
Tự động hóa với pg_partman
Việc tạo bảng thủ công là một rủi ro. Nếu bạn quên tạo phân vùng cho tháng tới, ứng dụng của bạn sẽ gặp lỗi ngay khi đồng hồ điểm nửa đêm. Tôi khuyên bạn nên sử dụng extension pg_partman. Nó tự động hóa việc tạo phân vùng và xử lý việc lưu trữ dữ liệu dựa trên lịch trình mà bạn xác định.
-- Tự động hóa quản lý phân vùng theo giờ
SELECT partman.create_parent('public.server_logs', 'event_time', 'native', 'hourly');
Lưu trữ (Archiving) hiệu quả
Partitioning giúp việc lưu trữ dữ liệu trở nên liền mạch. Bạn có thể “detach” (tách) một phân vùng để biến nó thành một bảng độc lập. Điều này cho phép bạn chuyển dữ liệu cũ sang bộ nhớ rẻ hơn hoặc dump sang S3 mà không ảnh hưởng đến hiệu năng của bảng cha đang hoạt động.
ALTER TABLE orders DETACH PARTITION orders_2023_01;
Bài học từ thực tế
Sau khi di chuyển nhiều hệ thống có lưu lượng truy cập cao sang kiến trúc này, tôi đã xác định được một vài cạm bẫy phổ biến cần tránh:
- Tránh phân vùng quá mức (Over-Partitioning): Tôi đã thấy các nhà phát triển tạo các phân vùng theo ngày cho các bảng chỉ tăng 10.000 dòng mỗi ngày. Điều này tạo ra hàng nghìn tệp nhỏ khiến bộ lập kế hoạch truy vấn bị nhầm lẫn. Hãy hướng tới các phân vùng có kích thước từ 1GB đến 10GB.
- Cam kết với Partition Key: Bạn không thể dễ dàng thay đổi partition key một khi bảng đã hoạt động. Hãy chọn cột được sử dụng thường xuyên nhất trong các mệnh đề
WHERE, vì đây là yếu tố thúc đẩy hiệu năng. - Ràng buộc Unique: Bất kỳ
UNIQUEhoặcPRIMARY KEYnào cũng phải bao gồm partition key. Đây có thể là một vấn đề đau đầu đối với các schema chỉ dựa vào UUID toàn cục, vì vậy hãy lập kế hoạch chiến lược ID của bạn sớm. - Hút bụi (Vacuuming): Partitioning không thay thế
VACUUM, nhưng nó làm cho quá trình này hiệu quả hơn. Tiến trìnhautovacuumcó thể làm việc trên các bảng nhỏ lẻ, giúp giảm thời gian tranh chấp khóa (lock contention) trên phần cứng của bạn.
Partitioning không phải là giải pháp vạn năng cho mọi truy vấn chậm, nhưng đối với các tập dữ liệu đạt đến mốc tỷ dòng, đó là một mô hình kiến trúc quan trọng. Bằng cách chia nhỏ dữ liệu thành các phần có thể quản lý được, bạn sẽ giành lại quyền kiểm soát hiệu năng và sức khỏe lâu dài của cơ sở dữ liệu.

