Bỏ qua để đến nội dung
Knowledge Hub

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

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?

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
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 đó.

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.

Key tăng dần chỉ chạm leaf phải nhất; UUID v4 rải insert khắp leaf, mỗi page một FPI sau checkpoint
Key tăng dần chỉ chạm leaf phải nhất; UUID v4 rải insert khắp leaf, mỗi page một FPI sau checkpointBước 1/6: Sau CHECKPOINT: lần sửa đầu tiên của mỗi page ghi full-page image (FPI) vào WAL.Leaf page củaorders_pkey (sắp theokey, trái → phải)Insert tăng dầnbigint / UUID v7Insert UUID v4key ngẫu nhiênLeaf 1key nhỏsạchLeaf 2sạchLeaf 3sạchLeaf phải nhấtkey lớn nhấtsạch
Leaf 1 · Leaf 2 · Leaf 3 · Leaf phải nhất
Bước 1/6

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:

  1. Sau checkpoint: chưa leaf nào có FPI trong chu kỳ mới.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

  • PostgreSQL 17 chỉ có gen_random_uuid() (v4). PostgreSQL 18 thêm uuidv7() và alias uuidv4().
  • 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. serial chỉ là sequence cộng DEFAULT, nên insert tay một id vẫn lọt qua.
  • Sequence có lỗ: rollback hay INSERT ... ON CONFLICT vẫn tiêu nextval (xem upsert).
  • WAL nhiều hơn thì replica và backup cũng nhận nhiều hơn.
  • Node.js pg trả bigint dạng string (xem lỗi kiểu dữ liệu).

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

Terminal window
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.sql

Mộ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.

Ư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_id riê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…

  1. Xác định id có ra ngoài không (URL, API, email); nếu có, chọn public_id riêng hoặc UUID làm PK.
  2. Bảng mới dùng bigint GENERATED ALWAYS AS IDENTITY, không dùng integer hay serial.
  3. Chọn UUID thì ưu tiên v7; trên PG17 ghi rõ nơi sinh id (ứng dụng hay extension).
  4. Ước lượng chi phí: số cột foreign key và index trỏ về bảng nhân 8 byte mỗi row.
  5. Id client gửi lên: validate, coi lỗi 23505 là request lặp lại, không tin timestamp trong id.
  6. Theo dõi avg_leaf_density, WAL mỗi giờ và kích thước index.
  7. Đổi kiểu khoá trên bảng đang chạy: làm theo expand–contract.

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 byte

Nê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
  1. 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?
  2. Index v7 và v4 cùng key 16 byte. Điều gì làm index v4 lớn hơn 30%?
  3. orders có 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?