Surrogate vs natural key — UUIDv7 thay UUIDv4 cứu B-tree page split
Bigserial vs UUID v4 vs UUIDv7 vs natural key. Page split cost, enumeration attack risk, distributed system tradeoff. Snowflake context.
TaskFlow ban đầu dùng tasks.id BIGSERIAL — append-only, B-tree leaf page chỉ thêm về bên phải. 100k INSERT mỗi giờ chạy ổn. Một ngày nọ, team quyết định switch sang id UUID (random v4) để chuẩn bị cho distributed system. Write throughput rớt 40%, page split tăng đột biến, bloat phình to trong vài tuần. Lý do: UUID v4 random insert vào mọi vị trí trên B-tree — cứ vài insert lại phải split một page, kéo theo write amplification gấp 2–3 lần.
Bài này map 4 dạng primary key phổ biến, giải thích tại sao sequential key và random key ảnh hưởng B-tree khác nhau, phân tích rủi ro enumeration attack với serial PK, và cho decision matrix để chọn đúng key cho từng tình huống.
1. Analogy — Sổ ghi chú có chỗ trống
Hãy tưởng tượng B-tree leaf page là một cuốn sổ ghi chú có số trang cố định. Với BIGSERIAL sequential: mỗi entry mới luôn ghi vào cuối sổ — trang cuối còn trống, ghi tiếp, không cần làm gì thêm. Với UUID v4 random: mỗi entry mới phải chèn vào một vị trí ngẫu nhiên giữa sổ — trang đó đã đầy thì phải xé đôi trang, dồn nội dung, tạo trang mới, cập nhật mục lục. Đây là page split — tốn I/O, gây write amplification.
| Sổ ghi chú | B-tree |
|---|---|
| Trang sổ | Leaf page (thường 8 KB) |
| Ghi tiếp đuôi | Append-only insert (sequential key) |
| Chèn giữa trang đã đầy | Random insert (UUID v4) |
| Xé đôi trang, dồn nội dung | Page split |
| Mục lục phải cập nhật sau split | Internal node update sau split |
| Sổ phình, nhiều trang nửa trống | B-tree bloat sau nhiều split |
Sequential key = ghi cuối sổ, không tốn công. Random key = chèn giữa sổ mỗi lần, cứ vài lần lại phải xé trang. UUID v7 lấy điểm tốt của cả hai: sequential trong mỗi millisecond nhờ timestamp prefix, unguessable nhờ 74 bit random còn lại.
2. 4 dạng primary key — bảng tradeoff
| Key type | Size | Sequential? | Page split | Distributed-friendly | Expose count |
|---|---|---|---|---|---|
INT SERIAL (4 byte) | 4 byte | Có | Thấp | Không (single counter) | Có — vd ?id=1234 |
BIGINT BIGSERIAL (8 byte) | 8 byte | Có | Thấp | Không | Có |
UUID v4 random | 16 byte | Không | Cao | Có | Không |
UUID v7 time-prefix | 16 byte | Có | Thấp | Có | Một phần (timestamp) |
| Snowflake (Twitter) | 8 byte | Có (per node) | Thấp | Có (multi-node) | Một phần |
| Natural key (email, slug) | Biến động | Không | Phụ thuộc | Không | Có |
Nhận xét ngay: BIGSERIAL và UUID v7 đều sequential — page split thấp. UUID v4 random là outlier tệ nhất về page split. Snowflake đạt được cả sequential lẫn distributed nhưng cần coordination infra.
3. Sequential vs random — cơ chế B-tree page split
B-tree leaf page trong PostgreSQL mặc định 8 KB, chứa khoảng 200–400 row tùy row size. Khi insert vào vị trí giữa page đã đầy, PG phải:
- Cấp phát page mới.
- Di chuyển nửa số entry sang page mới (split 50/50).
- Cập nhật parent node trỏ đến page mới.
- Ghi cả hai page xuống disk.
Với sequential key, bước này hầu như không xảy ra — insert luôn vào page cuối cùng, page đó gần như luôn có chỗ trống.
-- BIGSERIAL: append-only, B-tree leaf page chi them phai
INSERT INTO tasks(id, title) VALUES (1001, 'A'), (1002, 'B'), (1003, 'C');
-- Leaf page [1..1000] day -> page moi [1001, 1002, 1003]
-- Khong split, ghi tiep duoc.
-- UUID v4: random insert moi vi tri
INSERT INTO tasks(id, title) VALUES
('a3f2c1e0-9b8c-4d2f-8e1a-7c6b5d4e3f2a', 'A'), -- page X
('e7d1b4c3-2f6a-4a1d-9c8b-5e4d3f2a1b0c', 'B'), -- page Y (xa)
('1c4b8e0d-7f3a-4b2c-8d1e-6a5b4c3d2e1f', 'C'); -- page Z
-- Moi insert co the trigger page split + write amplification 2-3x
Kết quả đo lường thực tế (pgbench, 10M row): BIGSERIAL duy trì fill factor gần 90%, UUID v4 bloat lên 60–70% sau vài triệu insert — nghĩa là 30–40% disk space là dead tuple và half-empty page từ split.
Forward: Module 5 bài 2 của khoá này đi sâu B-tree page split, fill factor tuning, và VACUUM behavior.
4. UUID v4 vs UUID v7 — điểm khác biệt
-- UUID v4: 122 bit random + 6 bit version/variant
-- PG 13+ built-in: gen_random_uuid()
-- 'a3f2c1e0-9b8c-4d2f-8e1a-7c6b5d4e3f2a'
-- ^^^^^^^^ ^^^^ ^^^^ 100% random structure
-- UUID v7: 48 bit timestamp (ms) + 74 bit random (RFC 9562, May 2024)
-- PG 18 native: uuidv7()
-- '01952e8a-3c4d-7000-8000-7c6b5d4e3f2a'
-- ^^^^^^^^ ^^^^ ^^ timestamp prefix -> sequential per ms
-- random suffix -> unguessable next value
-- Compare sort order:
SELECT gen_random_uuid() AS v4_unsorted;
-- Insert 5 UUID v4 -> B-tree sau 5 insert: 5 vi tri khac nhau tren tree
-- UUID v7 insert theo thu tu thoi gian:
-- '01952e8a-...' < '01952e8b-...' < '01952e8c-...'
-- Sequential trong cung ms, sort tu nhien theo creation time
UUID v7 được chuẩn hoá trong RFC 9562 (May 2024). Hỗ trợ rộng rãi:
- PostgreSQL 18: hàm native
uuidv7() - PostgreSQL 13–17: extension
pg_uuidv7(PGXN) - npm
uuidpackage v9+:uuidv7() - Java:
UuidCreator.getTimeOrderedEpoch()(uuid-creator) - Python: package
uuid7
Migration từ v4 sang v7: không migrate existing row — PK immutable, migration sẽ cascade update toàn bộ FK. Pattern đúng: chấp nhận mixed schema (row cũ giữ v4, row mới dùng v7). B-tree vẫn hoạt động — chỉ là row cũ (v4, random) không sequential; row mới (v7) sequential. Fill factor cải thiện dần theo thời gian khi data mới chiếm phần lớn.
5. Snowflake — distributed alternative 8 byte
Twitter Snowflake (2010) là giải pháp distributed ID generation 64-bit:
1 bit reserved | 41 bit timestamp (ms) | 10 bit machine ID | 12 bit sequence
Sequential trong mỗi node (machine ID xác định), distributed giữa các node. Ưu điểm:
- 8 byte (bằng BIGINT) — gấp đôi tiết kiệm storage so với UUID 16 byte
- Sequential per node → page split thấp
- Distributed — không có single point of failure như sequence
Nhược điểm:
- Cần coordination để assign machine ID (Zookeeper, etcd, hoặc manual config)
- Nếu machine ID conflict → duplicate key (nghiêm trọng)
- Timestamp visible → expose rough creation time
Real-world: Twitter tweet ID, Discord channel/message ID, Instagram media ID đều là Snowflake variant.
Rule đơn giản: nếu không có infra coordination cho machine ID, UUID v7 là lựa chọn đủ tốt — sequential, uncoordinated, distributed-friendly.
6. Natural key — khi nào hợp lý
Natural key là cột có nghĩa nghiệp vụ đóng vai trò PK, thay vì surrogate (auto-generated ID).
-- Lookup table voi code bat bien: natural key hop ly
CREATE TABLE countries (
code CHAR(2) PRIMARY KEY, -- ISO 3166-1 alpha-2: 'US', 'VN', 'JP'
name TEXT NOT NULL
);
-- Junction table M:N: composite PK tu FK, khong can surrogate
CREATE TABLE project_members (
user_id BIGINT REFERENCES users(id),
project_id BIGINT REFERENCES projects(id),
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, project_id)
);
Natural key phù hợp khi key thoả mãn cả ba điều kiện: immutable (không bao giờ thay đổi), universally unique (không có hai entity nào cùng giá trị), và small (kích thước nhỏ để index hiệu quả).
Không dùng natural key cho:
-- EMAIL: user co the thay doi email -> cascade update toan bo FK
-- SLUG: rebrand doi slug -> break URL, cascade horror
-- USERNAME: rename -> cascade; platform allowance for reuse
-- SSN / CMND: privacy concern + format thay doi theo quy dinh
Email và username là anti-pattern phổ biến nhất làm PK. Khi user đổi email hoặc username, tất cả FK trỏ đến row đó phải cascade update — nếu có index trên FK đó, PG phải rebuild index. Với schema có 10 bảng FK, một operation đổi email thành một transaction chạm 10 bảng đồng thời. Dùng BIGSERIAL surrogate PK, để email/username là UNIQUE column bình thường.
7. Pitfall — enumeration attack với serial PK
Serial PK (id = 1, 2, 3, ...) lộ thông tin về dataset:
GET /api/users/1234 -> user voi id 1234
GET /api/users/1235 -> guess next -> data leak
GET /api/users/1 -> first user (likely admin account)
GET /api/users/999999 -> 404 -> tong user duoi 1 trieu
Thông tin bị lộ:
- Tổng số user (max id xấp xỉ user count)
- Thứ tự signup (
id = 1là user đầu tiên — thường là admin) - Có thể enumerate toàn bộ dataset qua HTTP scan
Ba lớp phòng thủ (ngoài authorization):
-- Pattern 1: UUID o public URL
-- GET /api/users/a3f2c1e0-9b8c-... (khong guess duoc next)
-- Pattern 2: Dual key - BIGSERIAL DB + UUID public_id
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
public_id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
-- ...
);
-- API: GET /api/users/:public_id (unguessable)
-- DB join: WHERE id = ... (sequential, fast index)
-- Pattern 3: Hashids / Sqids
-- Encode BIGSERIAL thanh string khong guess duoc: 1234 -> "xKJ9mP"
-- One-way mapping: decode "xKJ9mP" -> 1234 tai app layer
-- Giu BIGSERIAL trong DB, expose string o URL
Pattern dual key (BIGSERIAL internal + UUID public) phổ biến nhất: hưởng lợi B-tree sequential insert từ BIGSERIAL, unguessable public URL từ UUID.
-- TaskFlow ap dung dual key cho tasks:
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
public_id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
-- ...
);
-- API route: GET /api/tasks/:public_id
-- Internal query: WHERE id = $1 (sequential B-tree scan)
Dual key chống enumeration attack ở tầng URL — kẻ tấn công không biết ID tiếp theo để guess. Nhưng đây không phải thay thế cho authorization. Nếu endpoint không check quyền truy cập, UUID vẫn bị leak qua response của chính người dùng hợp lệ. Authorization (RLS, middleware check) và unguessable ID là hai lớp bảo vệ độc lập — cần cả hai.
8. Decision matrix — chọn key theo context
| Context | Khuyến nghị |
|---|---|
| OLTP single-DB, internal only | BIGSERIAL |
| OLTP single-DB + public URL | BIGSERIAL PK + UUID public_id |
| Distributed multi-DB / sharding | UUID v7 (RFC 9562) |
| Multi-node coordination có infra | Snowflake (8 byte) |
| Lookup table code bất biến | Natural key (CHAR(2), TEXT) |
| Bảng junction M:N | Composite PK từ FK (không cần surrogate) |
9. Applied — TaskFlow PK rationale
-- users.id BIGSERIAL: single-DB OLTP, no distributed requirement yet
-- B-tree sequential -> page split thap, insert throughput cao
-- public_id UUID v4: unguessable URL /api/users/:public_id
-- tasks.id BIGSERIAL: same reasoning as users
-- tasks.public_id UUID v4: /api/tasks/:public_id
-- project_members: composite PK (user_id, project_id)
-- No surrogate needed: relationship uniqueness is the key
-- Index tren composite PK cover ca hai lookup direction
PRIMARY KEY (user_id, project_id)
-- Future: neu TaskFlow scale distributed (5+ shard)
-- Migration strategy: them column id_v7 UUID NOT NULL DEFAULT uuidv7()
-- Dual write period: ghi ca BIGSERIAL va UUIDv7
-- Switch FK references sang id_v7 tung buoc
-- Drop BIGSERIAL khi tat ca service da migrate
-- (xem Module 10 cua khoa nay cho sharding pattern chi tiet)
Forward: Module 10 của khoá này đi sâu sharding pattern và UUID v7 migration strategy cho distributed setup.
10. Deep Dive — Identity strategy
- PostgreSQL Documentation — UUID Type — official UUID type,
gen_random_uuid(), vàuuidv7()(PG 18). - RFC 9562 — Universally Unique IDentifiers (UUIDs) — UUID v7 official spec (May 2024). Section 5.7 định nghĩa timestamp layout và monotonicity.
- Dimitri Fontaine — "The Next Generation of UUID" — practical PG-side UUID v7 setup, extension cho PG 13–17, migration pattern từ v4.
Ghi chú: PG docs cho cú pháp và built-in functions, RFC 9562 cho v7 chuẩn spec (đọc Section 5.7 + 6.1 là đủ), Fontaine blog cho PG-specific implementation và migration decision cho team chưa upgrade lên PG 18.
Liên kết khoá học khác
- Khoá Spring — bài 4.2 Entity Mapping — apply ID strategy vào JPA
@GeneratedValue.
11. Tóm tắt
BIGSERIALappend-only, B-tree sequential — page split thấp, phù hợp OLTP single-DB.UUID v4random insert toàn bộ B-tree — page split cao, write amplification 2–3x, bloat tăng nhanh.UUID v7timestamp-prefix sequential, RFC 9562 (May 2024) — fix page split của v4, giữ distributed-friendly.- Snowflake 8 byte, sequential per node — distributed có coordination; không có infra thì UUID v7 đủ.
- Natural key chỉ hợp lý khi key immutable + universally unique + small (vd ISO country code, junction PK).
- Dual key pattern:
BIGSERIALPK nội bộ (B-tree friendly) +UUIDpublic_id trên URL (chống enumeration attack). - Forward: Module 5 bài 2 của khoá này (B-tree page split + fill factor deep dive), Module 10 của khoá này (sharding và UUID v7 migration).
12. Tự kiểm tra
Q1Vì sao UUID v4 random gây page split nhiều còn BIGSERIAL thì không? Giải thích theo cơ chế B-tree leaf insert.▸
B-tree leaf page có kích thước cố định (PG mặc định 8 KB). Khi insert một key mới, PG phải đặt nó đúng vị trí sorted trên leaf page tương ứng. Với BIGSERIAL, key mới luôn lớn hơn mọi key hiện có — nên luôn vào page cuối cùng. Page cuối thường còn chỗ trống (fill factor chưa đạt 100%), ghi thêm mà không cần split.
Với UUID v4 random, mỗi key mới là giá trị ngẫu nhiên — có thể rơi vào bất kỳ vị trí nào trên B-tree. Nếu page tại vị trí đó đã đầy, PG phải: cấp phát page mới, di chuyển nửa số entry sang page mới (split 50/50), cập nhật parent node. Đây là page split — gây thêm 2–3 I/O operation so với insert thông thường. Với 100k INSERT mỗi giờ vào UUID v4 table, có thể xảy ra hàng nghìn split mỗi giờ, dẫn đến bloat và write amplification tích lũy.
Q2Phân biệt UUID v4 và UUID v7. Khi nào nên migrate sang v7 thay vì giữ v4?▸
UUID v4: 122 bit hoàn toàn random, không có cấu trúc temporal. Sort order không có nghĩa, insert phân tán đều khắp B-tree. Không sortable theo creation time.
UUID v7 (RFC 9562, May 2024): 48 bit timestamp millisecond + 74 bit random. Sequential trong cùng millisecond, sortable theo creation time. B-tree insert behavior gần giống BIGSERIAL — page split thấp hơn nhiều so với v4.
Nên migrate sang v7 khi: (1) table có write throughput cao và đo được page split / bloat tăng nhanh; (2) cần sort theo creation time tự nhiên mà không cần thêm created_at column riêng; (3) đang hoặc sắp dùng distributed system (sharding). Không nên migrate existing PK — chỉ apply cho new row vì PK immutable. Chấp nhận mixed v4 (row cũ) + v7 (row mới) trong transition period.
Q3Enumeration attack với serial PK — 2 cách defend (không kể authorization)?▸
Cách 1 — Dual key (BIGSERIAL + UUID public_id): giữ BIGSERIAL làm internal PK (B-tree friendly), thêm column public_id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(). Public URL dùng public_id — kẻ tấn công không thể đoán UUID tiếp theo. Internal query vẫn dùng BIGSERIAL — sequential, fast.
Cách 2 — Hashids / Sqids: encode BIGSERIAL thành string ngắn không đoán được tại app layer (vd 1234 → "xKJ9mP"). Decode về BIGSERIAL khi query DB. DB không thay đổi, chỉ URL layer thay đổi. Ưu điểm: không cần thêm column; nhược điểm: thêm layer encode/decode, và security-by-obscurity (không phải cryptographic guarantee như UUID).
Q4Natural key (email, slug) — khi nào hợp lý, khi nào nên dùng surrogate? Cho 2 ví dụ mỗi loại.▸
Natural key hợp lý khi key immutable + universally unique + small:
- ISO 3166-1 country code
CHAR(2)('US','VN'): không bao giờ thay đổi, universally unique, 2 byte. - Junction table composite PK
(user_id, project_id): hai FK kết hợp là key tự nhiên của relationship — không cần surrogate thứ ba.
Surrogate cần thiết khi natural key có thể thay đổi hoặc có nghiệp vụ riêng:
- Email: user có thể đổi email → cascade update toàn bộ FK trỏ vào bảng
users— nghiêm trọng khi schema có nhiều bảng reference. - Slug bài viết / tên sản phẩm: rebrand hoặc SEO optimization đổi slug → break URL, phải redirect, cascade trong DB. Dùng BIGSERIAL PK, slug là UNIQUE column riêng.
Q5TaskFlow scale lên 100M task, cần 5 shard. Migrate id BIGSERIAL sang UUIDv7 cần làm gì? Tradeoff?▸
Không thể migrate existing PK trực tiếp vì PK immutable và có hàng chục FK reference. Cần dual-write migration:
- Bước 1: thêm column
id_v7 UUID NOT NULL DEFAULT uuidv7()— backfill row cũ bằng batch UPDATE (không dùng trigger để tránh lock contention). - Bước 2: dual write period — mọi INSERT ghi cả
id(BIGSERIAL) vàid_v7(UUID v7). Read vẫn dùngid. - Bước 3: migrate FK từng bảng sang
id_v7theo thứ tự ít traffic nhất trước. - Bước 4: switch read path sang
id_v7, drop BIGSERIAL sau monitoring window.
Tradeoff: UUID v7 16 byte (gấp đôi BIGSERIAL 8 byte) — index lớn hơn, storage tăng. Nhưng: không cần centralized sequence generator (scale distributed), sequential insert (page split thấp), sortable theo creation time. Migration kéo dài vài tuần với dual write overhead. Không có downtime nếu làm đúng online DDL pattern.
Bài tiếp theo: Migration với Atlas — declarative thay Flyway/Liquibase
Bài này có giúp bạn hiểu bản chất không?