UUID hay bigint làm khoá chính: cái giá trên B-tree, WAL và foreign key Trung cấp
Thời gian đọc ước tính: 9 phút
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”Bảng orders có 10 triệu row, khoá chính id bigint. URL đơn là /orders/10000231: ai đặt hai đơn cách nhau một tuần cũng ước được số đơn bán ra. Mobile app còn muốn tự sinh id khi offline.
Team đề xuất đổi sang UUID. order_items, payments, shipments đều có foreign key trỏ về orders.id, nên phải đổi theo.
Câu hỏi: đổi sang UUID thì B-tree index, WAL và các foreign key trả giá bao nhiêu, và loại UUID nào ít tốn nhất?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Id chỉ dùng nội bộ | bigint GENERATED ALWAYS AS IDENTITY |
Không đưa id ra ngoài |
| Không muốn lộ số lượng qua URL/API | bigint PK + cột public_id uuid UNIQUE |
Foreign key vẫn bigint |
| Client hoặc nhiều service tự sinh id | UUID v7 làm PK | Chấp nhận lộ thời điểm tạo |
| Không được lộ cả thứ tự lẫn thời điểm | UUID v4 | Chấp nhận index và WAL lớn hơn |
| PostgreSQL 17 cần v7 | Sinh ở ứng dụng hoặc extension | uuidv7() có từ PostgreSQL 18 |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”bigint identity |
UUID v4 | UUID v7 | |
|---|---|---|---|
| Kích thước key | 8 byte | 16 byte | 16 byte |
| Thứ tự khi insert | Tăng dần (có thể có lỗ) | Ngẫu nhiên | Gần tăng dần theo ms |
| Lộ gì ra ngoài | Thứ tự, ước được số lượng | Không lộ thứ tự | Thời điểm tạo |
- UUID v4: 122 bit ngẫu nhiên. Hai id sinh liền nhau nằm ở hai chỗ bất kỳ trên cây index.
- UUID v7 (RFC 9562): 48 bit Unix timestamp (ms) ở đầu, sau đó là version và bit ngẫu nhiên. Id sinh sau thường lớn hơn id sinh trước.
- Full-page image (FPI): với
full_page_writes = on(mặc định), lần sửa đầu tiên của một page sau checkpoint ghi nguyên page vào WAL.
Kích thước key lặp lại ở primary key index, ở mỗi cột foreign key order_id và index trên cột đó.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Vì sao cùng 16 byte mà v4 tốn hơn v7 nhiều? Câu trả lời nằm ở page nào bị chạm khi insert.
Sau CHECKPOINT: lần sửa đầu tiên của mỗi page ghi full-page image (FPI) vào WAL.
Trạng thái sau từng bước:
- Sau checkpoint: chưa leaf nào có FPI trong chu kỳ mới.
- Key tăng dần (bigint, v7): key mới luôn lớn nhất, rơi vào leaf phải nhất. Một FPI, các insert sau dùng lại page đang nóng trong cache.
- Leaf phải nhất đầy: theo tài liệu
CREATE INDEX, leaf được lấp tới fillfactor 90% khi index mở rộng về bên phải, không chia đôi. - UUID v4: mỗi insert chạm một leaf bất kỳ, phải nằm trong shared buffers và có thể sinh thêm một FPI 8 kB.
- Leaf giữa cây đầy: page split chia key ra hai page còn nhiều chỗ trống; index phình và leaf liền kề không nằm cạnh nhau trên đĩa.
Vậy thứ tự insert quan trọng hơn kích thước key: v7 trả giá 16 byte như v4 nhưng giữ đường insert của bigint.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”- PostgreSQL 17 chỉ có
gen_random_uuid()(v4). PostgreSQL 18 thêmuuidv7()và aliasuuidv4(). uuid_extract_timestamp()của PG17 chỉ đọc UUID v1; PG18 đọc cả v7.- Cần v7 trên PG17: sinh ở ứng dụng bằng thư viện theo RFC 9562, hoặc extension bên thứ ba (kiểm tra managed service có cho cài). Hàm SQL tự viết như demo chạy được nhưng chậm.
GENERATED ALWAYS AS IDENTITY(chuẩn SQL) từ chối giá trị tự nhập trừ khi cóOVERRIDING SYSTEM VALUE.serialchỉ là sequence cộngDEFAULT, nên insert tay mộtidvẫn lọt qua.- Sequence có lỗ: rollback hay
INSERT ... ON CONFLICTvẫn tiêunextval(xem upsert). - WAL nhiều hơn thì replica và backup cũng nhận nhiều hơn.
- Node.js
pgtrảbigintdạngstring(xem lỗi kiểu dữ liệu).
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Hàm v7 trong demo là hàm SQL tự viết cho PG17, không phải built-in:
CREATE FUNCTION uuid_v7_demo() RETURNS uuid LANGUAGE sql VOLATILE AS $$ SELECT encode( substring(int8send(floor(t.ms)::bigint) FROM 3) -- unix_ts_ms || int2send((x'7000'::int | floor((t.ms - floor(t.ms)) * 4096)::int)::smallint) -- ver + rand_a || substring(uuid_send(gen_random_uuid()) FROM 9), -- var + rand_b 'hex')::uuid FROM (SELECT extract(epoch FROM clock_timestamp()) * 1000 AS ms) AS t$$;rand_a chứa phần lẻ của ms, cách RFC 9562 cho phép (mục 6.2). Hai id trong cùng micro giây vẫn có thể ngược thứ tự.
docker compose -f examples/docker-compose.yml exec -T postgres \ psql -U hub -d hub -q -v rows=10000000 -v extra=500000 \ < examples/src/uuid-vs-bigint-keys/uuid-keys-demo.sqlMột lần chạy trên PostgreSQL 17.11 (Docker trên Apple M5, 10 CPU / 8 GB RAM, shared_buffers = 128MB, max_wal_size = 1GB, wal_compression = off):
| Đo | bigint | UUID v4 | UUID v7 |
|---|---|---|---|
| Nạp 10 triệu row | 8,1 s · 1504 MB WAL | 40,1 s · 2471 MB WAL | 38,0 s · 1644 MB WAL |
500.000 row sau CHECKPOINT |
0,7 s · 75 MB WAL | 3,4 s · 364 MB WAL | 1,5 s · 82 MB WAL |
| FPI sau checkpoint (FPI B-tree) | 7 (10 kB) | 49.230 (272 MB) | 4 (11 kB) |
| Block PK index đọc ngoài shared buffers | 4 | 346.791 | 3 |
| Heap / PK index | 603 MB / 225 MB | 684 MB / 410 MB | 684 MB / 316 MB |
avg_leaf_density / leaf_fragmentation |
90,09 / 0 | 69,55 / 49,73 | 90,04 / 0 |
Đọc kết quả:
- WAL: v4 ghi gần 5 lần bigint; 272 MB là FPI của B-tree. v7 gần bằng bigint.
- Cache miss: v4 đọc 346.791 block index ngoài shared buffers (có thể vẫn trúng cache của OS).
- Kích thước: index v7 lớn hơn bigint khoảng 40% do key 16 byte; v4 lớn thêm 30% vì leaf chỉ đầy khoảng 70%.
Riêng việc sinh 10 triệu id tốn 23,8 giây với hàm v7 SQL và 4,5 giây với gen_random_uuid(), nên thời gian nạp v7 chủ yếu là chi phí hàm demo.
Số đo thay đổi mỗi lần chạy (lần trước: v4 nạp 33 giây). Số FPI lấy từ pg_stat_wal, tính toàn cluster. Đây là máy dev, không phải benchmark.
Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
bigint: key 8 byte, insert về bên phải, ít WAL, đọc và debug dễ. -
UUID v7: sinh ở đâu cũng được, đường insert gần như bigint.
-
UUID v4: không lộ thứ tự hay thời điểm.
-
Cột
public_idriêng: ẩn số lượng mà foreign key vẫn 8 byte.
< slot="cons"> >
-
bigint: đưa ra ngoài thì lộ số lượng; cần database cấp id. -
UUID: mọi foreign key và index trên nó gấp đôi kích thước key.
-
UUID v4: page split, cache miss, WAL tăng mạnh khi bảng lớn hơn bộ nhớ.
-
UUID v7: lộ thời điểm tạo; trên PG17 phải sinh ngoài database.
Nhược điểm
Cây quyết định cho orders:
Đang tải diagram…
Checklist production
Phần tiêu đề “Checklist production”- Xác định id có ra ngoài không (URL, API, email); nếu có, chọn
public_idriêng hoặc UUID làm PK. - Bảng mới dùng
bigint GENERATED ALWAYS AS IDENTITY, không dùngintegerhayserial. - Chọn UUID thì ưu tiên v7; trên PG17 ghi rõ nơi sinh id (ứng dụng hay extension).
- Ước lượng chi phí: số cột foreign key và index trỏ về bảng nhân 8 byte mỗi row.
- Id client gửi lên: validate, coi lỗi
23505là request lặp lại, không tin timestamp trong id. - Theo dõi
avg_leaf_density, WAL mỗi giờ và kích thước index. - Đổi kiểu khoá trên bảng đang chạy: làm theo expand–contract.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Sai lầm đầu tiên là đổi cả khoá chính chỉ để ẩn số lượng:
Đổi PK sang UUID v4 chỉ để URL không lộ số lượng
Hay gặp
CREATE TABLE orders (id uuid PRIMARY KEY DEFAULT gen_random_uuid() /* ... */);CREATE TABLE order_items (order_id uuid NOT NULL REFERENCES orders /* ... */); -- mọi FK 16 byteNên viết
ALTER TABLE orders ADD COLUMN public_id uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE;Vì sao
Chỉ thêm một index UUID; foreign key vẫn bigint. Bảng lớn thì backfill theo lô thay vì một câu lệnh.
Sai lầm thứ hai liên quan tới quyền riêng tư:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| “UUID nào cũng 16 byte như nhau” | v4 làm index và WAL lớn hơn nhiều | Ưu tiên v7 |
| Id bigint liên tục, dùng làm số hoá đơn | Có lỗ do rollback và ON CONFLICT |
Số hoá đơn đánh riêng |
| Timestamp trong v7 do client sinh là đáng tin | Đồng hồ client sai, sắp xếp sai | Lưu created_at riêng |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Vì sao 500.000 insert vào bảng v4 sinh khoảng 49.000 FPI, còn bảng bigint chỉ vài FPI?
- Index v7 và v4 cùng key 16 byte. Điều gì làm index v4 lớn hơn 30%?
orderscó 4 bảng trỏ foreign key tới nó, mỗi bảng 30 triệu row. Đổi sang UUID thêm bao nhiêu byte key, chưa tính index?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- PostgreSQL 17 — UUID Functions: PG17 chỉ có v4.
- PostgreSQL 18 — UUID Functions:
uuidv7(),uuidv4(). - CREATE INDEX, fillfactor: lấp leaf khi mở rộng về bên phải, split gây phân mảnh.
- full_page_writes: FPI sau checkpoint.
- Identity Columns:
ALWAYSvàBY DEFAULT. - pgstattuple: cột của
pgstatindex(). - RFC 9562: bố cục UUID v7 (5.7), thứ tự trong cùng ms (6.2), bảo mật (8).
- B-tree index, Storage layout: leaf page và page 8 kB.