Chọn khoá chính là quyết định khó đổi nhất trong schema. Tôi nạp một triệu dòng vào ba bảng giống hệt nhau, chỉ khác kiểu khoá, rồi mổ chỉ mục ra xem.
Bảng đầy đủ
| Khoá chính | Nạp | Chỉ mục | Trang lá | Mật độ | Phân mảnh | Tra cứu |
|---|---|---|---|---|---|---|
bigserial (8 byte) |
1,45 s | 21 MB | 2.733 | 90,1% | 0,0% | 9–10 ms |
| UUID tăng dần (16 byte) | 2,52 s | 30 MB | 3.832 | 90,0% | 0,0% | 25–26 ms |
| UUID v4 ngẫu nhiên | 2,78 s | 38 MB | 4.883 | 70,7% | 50,0% | 24 ms |
Hai cột mật độ và phân mảnh là câu trả lời cho toàn bộ bài này.
Vì sao khoá ngẫu nhiên làm chỉ mục phình
Chỉ mục B-tree lưu khoá theo thứ tự trong các trang 8 kB. Khi một trang đầy và cần chèn thêm, PostgreSQL tách trang làm đôi.
Với khoá tăng dần, mọi giá trị mới đều lớn hơn mọi giá trị cũ, nên chúng luôn vào trang cuối cùng bên phải. PostgreSQL nhận ra điều này và tách theo kiểu đặc biệt: trang cũ giữ nguyên gần đầy, trang mới bắt đầu trống. Kết quả là 90% mật độ, 0% phân mảnh — đo được ở cả bigserial lẫn UUID tăng dần.
Với khoá ngẫu nhiên, mỗi giá trị mới có thể rơi vào bất kỳ trang nào. Trang đó bị tách 50/50, và cả hai nửa nằm lại ở mức nửa đầy. Đo được: 70,7% mật độ, 50% phân mảnh, và 4.883 trang lá thay vì 3.832 cho cùng số khoá cùng kích thước.
Chênh lệch giữa hai loại UUID là 8 MB trên một triệu dòng — cùng kiểu dữ liệu, cùng 16 byte mỗi khoá, chỉ khác thứ tự sinh ra.
Nhưng tra cứu thì không chậm hơn
Đây là chỗ tôi đo ngược với điều mình dự đoán:
| Tra cứu 10.000 khoá | |
|---|---|
bigserial |
9–10 ms |
| UUID tăng dần | 25–26 ms |
| UUID v4 ngẫu nhiên | 24 ms |
Hai loại UUID gần bằng nhau. Phân mảnh 50% không làm việc tìm chậm đi — B-tree vẫn đi từ gốc xuống lá qua đúng số tầng như nhau.
Cái làm UUID chậm hơn bigserial 2,5 lần là kích thước khoá: 16 byte thay vì 8. Chỉ mục lớn gấp rưỡi tới gấp đôi nghĩa là ít khoá vừa trong mỗi trang, cây cao hơn, và nhiều trang phải đọc hơn.
Nên phân mảnh là vấn đề dung lượng và bộ nhớ đệm, không phải vấn đề độ trễ tra cứu. Trên hệ thống mà chỉ mục vừa trong RAM, bạn sẽ không thấy nó. Trên hệ thống mà chỉ mục lớn hơn shared_buffers, 38 MB thay vì 30 MB nghĩa là nhiều lần đọc đĩa hơn.
Tôi đo sai lần đầu, và cách phát hiện
Lần chạy đầu, "UUID tăng dần" của tôi cho chỉ mục 42 MB — còn lớn hơn UUID ngẫu nhiên. Ngược hoàn toàn với lý thuyết.
Tôi sinh chúng bằng clock_timestamp() làm 48 bit đầu, giống cách UUIDv7 hoạt động. Kiểm tra lại:
-- dem so cap lien tiep bi giam trong 10.000 dong dau
so cap lien tiep bi giam: 4990 / 9999
so moc thoi gian khac nhau: 1
Gần một nửa số cặp bị giảm — tức là ngẫu nhiên, không tăng dần gì cả. Nguyên nhân: một triệu dòng chèn xong trong 3 giây, nên hầu hết chia sẻ cùng một mốc mili giây, và phần còn lại của UUID là ngẫu nhiên. Tôi đã tạo ra UUID ngẫu nhiên có chung tiền tố, rồi gọi nó là "tăng dần".
Sửa bằng cách dùng chính số thứ tự dòng làm phần đầu, rồi kiểm lại:
so cap lien tiep bi giam: 0 / 9999
Bài học: khi bạn tạo dữ liệu thử có một tính chất cần thiết cho phép đo, hãy kiểm tra tính chất đó tồn tại thật. Ở đây chỉ mất một câu SELECT đếm nghịch thế.
(Điều này cũng nói lên một chuyện thực tế về UUIDv7: nếu bạn chèn nhanh hơn độ phân giải mốc thời gian, thứ tự chỉ đúng ở mức lô chứ không đúng ở mức từng dòng. Với tải thật rải theo thời gian thì không sao.)
Chọn thế nào
bigint GENERATED ALWAYS AS IDENTITY là mặc định tốt. Nhỏ nhất, nhanh nhất, chỉ mục chặt nhất. Dùng nó thay serial — serial là cú pháp cũ, tạo ra một sequence mà quyền sở hữu dễ lệch khi bạn ALTER TABLE.
CREATE TABLE don_hang (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
...
);
Dùng UUID khi bạn cần sinh id ở phía client, hoặc gộp dữ liệu từ nhiều nguồn. Đó là lý do chính đáng duy nhất — và nó là lý do đủ mạnh. Nhưng nếu dùng, hãy dùng loại tăng dần theo thời gian (UUIDv7). PostgreSQL 18 có sẵn uuidv7(); các phiên bản trước thì dùng thư viện phía ứng dụng.
Đừng dùng UUID vì "id tuần tự làm lộ số lượng đơn hàng". Đó là vấn đề của id công khai, và cách sửa đúng là có một cột ma_cong_khai riêng — khoá chính vẫn là bigint cho các quan hệ nội bộ.
Đừng dùng UUID v4 làm khoá chính của bảng lớn. Bạn trả 81% dung lượng chỉ mục so với bigserial và 27% so với UUID tăng dần, đổi lại không được gì mà UUIDv7 không cho.
Thử ba mươi giây
Xem chỉ mục của bạn phân mảnh tới đâu:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS kich_thuoc,
(pgstatindex(indexrelid)).avg_leaf_density::numeric(5,1) AS mat_do,
(pgstatindex(indexrelid)).leaf_fragmentation::numeric(5,1) AS phan_manh
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 10;
Mật độ dưới 75% kèm phân mảnh trên 30% là dấu hiệu khoá ngẫu nhiên hoặc nhiều lần xoá. Cách chữa tạm là REINDEX CONCURRENTLY; cách chữa thật là đổi cách sinh khoá.
Phần sau đo NULL và logic ba trạng thái — chỗ truy vấn trả về kết quả sai mà không báo lỗi.