Khi một bảng lớn lên tới hàng chục triệu hàng — nhật ký sự kiện, đơn hàng qua nhiều năm — có một kỹ thuật hay được nhắc tới: phân vùng (partitioning), chia một bảng logic thành nhiều bảng con vật lý. Lời hứa là truy vấn nhanh hơn và dọn dữ liệu cũ dễ hơn. Cả hai đều thật — nhưng chỉ trong một điều kiện cụ thể, và tôi đã tưởng phân vùng giúp mọi truy vấn cho tới khi đo ra nó chẳng giúp gì cho một loại truy vấn rất thường gặp.
Chia bảng theo khóa phân vùng
Phân vùng chia một bảng thành nhiều bảng con (partition) theo giá trị của một khóa phân vùng. Kiểu phổ biến nhất là RANGE — ví dụ theo tháng của cột thời gian: mỗi tháng một partition. (Còn LIST theo danh sách giá trị, và HASH chia đều theo băm.) Bạn vẫn đọc và ghi vào một bảng logic; PostgreSQL tự định tuyến mỗi hàng vào partition đúng.
Một chi tiết đáng nhớ tôi thấy khi đo: bảng cha thật ra không chứa dữ liệu — pg_total_relation_size của nó là 0 byte. Nó chỉ là một cái vỏ định tuyến; toàn bộ hàng nằm trong các bảng con. Điều này mở ra hai lợi ích mà bài này đo: loại bớt vùng khi tra, và vứt cả vùng khi dọn.
Đo: lọc theo khóa phân vùng chỉ quét một vùng
Tôi dựng hai bảng 1,2 triệu hàng, cột ts là "thời gian" chạy từ 1 đến 1.200.000. Một bảng thường, và một bảng phân vùng theo RANGE(ts) thành 12 vùng (như 12 tháng), mỗi vùng 100.000 hàng. Cả hai không đánh index, để cô lập đúng tác dụng của phân vùng. Rồi hỏi các hàng trong một khoảng thời gian hẹp:
SELECT count(*) FROM ... WHERE ts BETWEEN 500001 AND 520000;
bảng thường: Seq Scan, đọc 6487 trang (quét cả bảng)
bảng phân vùng: chỉ quét vùng ev_p5, 541 trang (~12 lần ít hơn)
PostgreSQL nhìn điều kiện ts BETWEEN 500001 AND 520000, biết khoảng đó chỉ rơi vào đúng một vùng (vùng chứa 500001–600000), và loại bỏ (prune) 11 vùng còn lại — không hề đọc chúng. Đây là partition pruning: thay vì quét cả bảng để lọc, CSDL loại ngay những vùng không thể chứa kết quả. Số trang giảm ~12 lần, đúng bằng tỉ lệ 1 vùng trên 12. Trên một bảng chia theo tháng qua vài năm, một truy vấn "tháng này" có thể bỏ qua hàng chục vùng. Đáng chú ý là tôi đo điều này không đánh index: pruning và index là hai cơ chế khác nhau để tránh quét thừa. Chúng còn bổ trợ nhau — đánh index trên từng vùng thì mỗi index nhỏ hơn (chỉ phủ dữ liệu một tháng), cây thấp hơn, và pruning loại vùng trước khi index của vùng đó cần dùng tới.
Dọn dữ liệu cũ: DROP cả vùng thay vì DELETE
Lợi ích thứ hai lộ ra khi xóa dữ liệu cũ — việc rất thường làm với bảng nhật ký (giữ 12 tháng, bỏ tháng cũ nhất). Với bảng thường, đó là một DELETE hàng loạt; với bảng phân vùng, đó là DROP cả một vùng:
xóa 100.000 hàng cũ:
DELETE FROM bảng thường WHERE ts <= 100000: 50,9 ms
DROP TABLE vùng_cũ: 1,5 ms (~34 lần nhanh hơn)
DROP một partition nhanh gần 34 lần vì nó chỉ là thao tác metadata — xóa nguyên một file bảng, không đụng từng hàng. DELETE thì phải đánh dấu từng hàng là chết, sinh WAL cho mỗi hàng, và — như bài giao dịch dài đã đo — để lại hàng chết mà VACUUM phải dọn sau. DROP không để lại bloat nào. Với dữ liệu xoay theo thời gian, đây thường là lý do lớn nhất để phân vùng, hơn cả tốc độ truy vấn.
Một lần tôi đo hớ: phân vùng không giúp truy vấn không lọc theo khóa
Sau khi thấy pruning cắt 12 lần số trang, tôi kết luận vội "phân vùng làm bảng này nhanh hơn". Nhưng câu đó thiếu một chữ quan trọng: nhanh hơn cho truy vấn lọc theo khóa phân vùng. Tôi đo một truy vấn lọc theo một cột khác — val, không phải ts:
SELECT count(*) FROM bảng phân vùng WHERE val = 42;
-> quét CẢ 12 partition, đọc 6492 trang
Không hề nhanh hơn bảng thường (6487 trang) — thực ra còn nhỉnh hơn một chút vì phải mở và quét 12 bảng riêng thay vì một. Lý do: điều kiện val = 42 không nói gì về ts, nên PostgreSQL không thể loại vùng nào — giá trị val = 42 có thể nằm ở bất kỳ tháng nào. Nó buộc phải quét mọi vùng. Cái sai của tôi là gán tác dụng của pruning cho bảng thay vì cho truy vấn khớp khóa phân vùng. Phân vùng không tăng tốc mọi thứ; nó chỉ cho phép loại vùng khi — và chỉ khi — truy vấn lọc theo khóa phân vùng.
Đây là điều quyết định phân vùng có đáng hay không: chọn khóa phân vùng theo cách bạn thường tra dữ liệu. Nếu phần lớn truy vấn lọc theo thời gian, phân vùng theo thời gian là thắng lớn. Nếu truy vấn chủ yếu tra theo user_id mà bạn lại phân vùng theo ngày, phần lớn truy vấn sẽ quét mọi vùng và bạn chỉ nhận thêm phức tạp. Và một cảnh báo nữa: quá nhiều vùng (hàng nghìn) làm chính bước lập kế hoạch chậm đi, vì planner phải cân nhắc từng vùng.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: phân vùng là công cụ cho dữ liệu lớn xoay theo một chiều rõ ràng, thường là thời gian. Lợi ích thật gồm pruning (khi truy vấn lọc theo khóa) và DROP vùng cũ tức thì. Đừng phân vùng một bảng nhỏ hay một bảng mà truy vấn không có một khóa chung — chi phí phức tạp không đáng. Một bảng vài triệu hàng với index tốt thường đã đủ nhanh mà không cần phân vùng; phân vùng bắt đầu trả công khi bảng lớn tới mức việc bảo trì (VACUUM, xóa dữ liệu cũ, sao lưu) trên một khối liền trở nên nặng nề.
Hệ quả thứ hai: khóa phân vùng phải khớp mẫu truy vấn của bạn. Con số mang theo: phân vùng chia bảng theo khóa; truy vấn lọc theo khóa phân vùng được prune về đúng vùng cần (541 trang so với 6487 của bảng thường, ~12 lần ít), và DROP một vùng cũ mất 1,5ms so DELETE 51ms (~34 lần, không bloat); nhưng truy vấn KHÔNG chứa khóa phân vùng phải quét mọi vùng (6492 trang, không lợi gì) — phân vùng chỉ giúp khi điều kiện khớp khóa. Trước khi phân vùng, hỏi: truy vấn của tôi có luôn lọc theo cái khóa tôi định chia không?
Thử ba mươi giây
Trong psql, tạo một bảng phân vùng nhỏ: CREATE TABLE e (ts int, v int) PARTITION BY RANGE (ts); CREATE TABLE e1 PARTITION OF e FOR VALUES FROM (1) TO (101); CREATE TABLE e2 PARTITION OF e FOR VALUES FROM (101) TO (201); rồi INSERT INTO e SELECT g, g FROM generate_series(1,200) g;. Chạy EXPLAIN SELECT * FROM e WHERE ts < 50; — bạn sẽ thấy kế hoạch chỉ chạm e1, vùng e2 bị loại (pruned). Giờ đổi thành EXPLAIN SELECT * FROM e WHERE v = 150; — kế hoạch quét cả hai vùng, vì v không phải khóa phân vùng nên không loại được vùng nào. Hai lệnh EXPLAIN đó, trong nửa phút, cho bạn thấy ranh giới của phân vùng: nó chỉ mạnh khi bạn hỏi đúng theo khóa nó chia.