Hình dung một tủ hồ sơ xếp theo thứ tự bảng chữ cái. Nếu hồ sơ mới luôn xếp cuối — tên nào cũng đứng sau tên cũ — bạn chỉ việc nhồi đầy ngăn cuối rồi mở ngăn mới, ngăn nào cũng gần đầy và gọn gàng. Còn nếu mỗi hồ sơ mới phải chen vào giữa một ngăn đã đầy, bạn buộc phải xẻ đôi ngăn đó thành hai ngăn nửa vơi để có chỗ — cái tủ phình lên toàn ngăn nửa trống. Chỉ mục B-tree của khoá chính đúng là cái tủ đó: khoá tăng dần xếp cuối, khoá ngẫu nhiên chen giữa. 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.

Muốn biết chỉ mục của mình phân mảnh tới đâu, hỏi pgstattuple:

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á.

Mẫu số chung

Bài học đầu tiên, đọc từ "cùng 16 byte, chỉ khác thứ tự, chênh 8 MB": với một cấu trúc giữ thứ tự, cái thứ tự bạn đưa dữ liệu vào quyết định sức khoẻ của nó nhiều hơn chính dữ liệu — nối vào cuối thì rẻ và chặt, chen vào giữa thì buộc tách trang và phình. Cùng số khoá, cùng kích thước; chỉ vì khoá ngẫu nhiên rơi vào giữa mà mật độ tụt còn 70% và số trang lá tăng. Cùng nguyên lý "thứ tự truy cập quan trọng hơn khối lượng" ở khắp nơi: ghi tuần tự so với ghi ngẫu nhiên vào B-tree hay LSM, I/O đĩa tuần tự so với random, chèn vào mảng đã sắp so với chèn bừa, khoá phân vùng theo thời gian. Nguyên tắc: khi cấu trúc duy trì trật tự, hãy cho nó ăn theo trật tự — chọn khoá tăng dần đơn điệu ở đâu có thể, vì mẫu hình ghi thường tốn kém hơn lượng ghi.

Điều thứ hai, đọc từ "đừng dùng UUID vì sợ lộ số đơn": đừng dồn nhiều mối quan tâm khác nhau lên một thứ — khoá chính đang bị bắt làm ba việc cùng lúc: khoá nối nội bộ, id sinh được ở client, và id công khai không đoán được — ép cả ba lên một cột (UUIDv4 làm PK) là trả giá cho cả ba ở mọi nơi. Tách ra: bigint làm PK cho các quan hệ nội bộ, một cột ma_cong_khai riêng cho phần không- đoán-được — mỗi mối quan tâm trả giá đúng chỗ nó cần. Cùng cái bẫy "một thứ gánh nhiều vai" ở khắp nơi: một cột vừa để hiển thị vừa làm định danh, trộn khoá tự nhiên với khoá thay thế, một giá trị cấu hình mang ba nghĩa. Nguyên tắc: khi một thứ bị bắt phục vụ nhiều mục đích, bạn trả chi phí của từng mục đích ở mọi nơi — hãy tách các mối quan tâm ra và chỉ trả giá ở nơi thật sự cần.

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.