Surrogate vs natural key — UUIDv7 thay UUIDv4 cứu page split
Auto-increment vs UUID v4 vs UUIDv7 vs natural key. Page split cost trên index có thứ tự, enumeration attack risk, distributed tradeoff. Snowflake — nguyên lý agnostic.
TL;DR: Chọn primary key là quyết định thiết kế có hệ quả lâu dài. Sequential key (auto-increment, UUID v7) chèn ở cuối index có thứ tự → page split thấp. Random key (UUID v4) chèn khắp nơi → page split cao, write amplification 2–3 lần, bloat tăng nhanh. UUID v7 (RFC 9562) lấy điểm tốt của cả hai: timestamp-prefix nên sequential, phần random nên unguessable + distributed-friendly. Serial PK lộ thông tin (enumeration attack) → dùng dual key (auto-increment nội bộ + UUID public). Natural key chỉ hợp khi immutable + universally unique + small. Nguyên lý agnostic — đúng cho mọi RDBMS dùng index có thứ tự.
TaskFlow ban đầu dùng tasks.id auto-increment — append-only, leaf page của index có thứ tự 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 cây index — 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 index có thứ tự 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. Tất cả là nguyên lý agnostic — cơ chế chi tiết của index có thứ tự được mổ ở Module 6 — Storage & indexing.
1. Analogy — Sổ ghi chú có chỗ trống
Hãy tưởng tượng leaf page của index có thứ tự là một cuốn sổ ghi chú có số trang cố định. Với key sequential (auto-increment): 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ú | Index có thứ tự |
|---|---|
| Trang sổ | Leaf page (thường vài KB, tuỳ engine) |
| 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 | 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ờ phần random còn lại.
Sequential key vào cuối, random key vào giữa rồi xé trang:
flowchart TD
subgraph SEQ["Sequential key (auto-increment / UUID v7)"]
S1["Insert -> luon vao page cuoi"] --> S2["Page cuoi con cho -> ghi tiep"]
S2 --> S3["Page split hiem -> fill factor cao"]
end
subgraph RND["Random key (UUID v4)"]
R1["Insert -> vi tri ngau nhien"] --> R2["Trang da day -> xe doi (split)"]
R2 --> R3["Write amplification + bloat"]
end2. 4 dạng primary key — bảng tradeoff
| Key type | Size | Sequential? | Page split | Distributed-friendly | Expose count |
|---|---|---|---|---|---|
| Auto-increment 4 byte (INTEGER) | 4 byte | Có | Thấp | Không (single counter) | Có — vd ?id=1234 |
| Auto-increment 8 byte (BIGINT) | 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: auto-increment BIGINT 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.
Cách sinh ID tăng dần là dialect: chuẩn SQL hiện đại dùng id BIGINT GENERATED ALWAYS AS IDENTITY; một số engine có shorthand riêng (kiểu giả SERIAL/BIGSERIAL, thuộc tính AUTO_INCREMENT, hoặc SEQUENCE tách rời). Trong bài, viết theo chuẩn GENERATED ... AS IDENTITY cho portable; bản chất là một bộ đếm sinh giá trị tăng dần.
3. Sequential vs random — cơ chế page split
Leaf page của index có thứ tự có kích thước cố định (thường vài KB, tuỳ engine), chứa hàng trăm entry tùy row size. Khi insert vào vị trí giữa page đã đầy, engine 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.
-- Auto-increment: append-only, 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ế (benchmark trên ~10M row): key sequential 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à space chết và half-empty page từ split. Con số cụ thể khác nhau theo engine, nhưng xu hướng là phổ quát.
Forward: Module 6 — Storage & indexing đi sâu cơ chế index có thứ tự, page split, và fill factor.
4. UUID v4 vs UUID v7 — điểm khác biệt
-- UUID v4: 122 bit random + 6 bit version/variant
-- 'a3f2c1e0-9b8c-4d2f-8e1a-7c6b5d4e3f2a' -> 100% random structure
-- Cach sinh la dialect: vai engine co ham gen_random_uuid(), vai engine ham UUID(),
-- hoac sinh o app layer.
-- UUID v7: 48 bit timestamp (ms) + 74 bit random (RFC 9562, May 2024)
-- '01952e8a-3c4d-7000-8000-7c6b5d4e3f2a'
-- ^^^^^^^^ ^^^^ timestamp prefix -> sequential per ms
-- random suffix -> unguessable next value
-- UUID v7 sort tu nhien theo thoi gian tao:
-- '01952e8a-...' < '01952e8b-...' < '01952e8c-...'
-- Sequential trong cung ms -> insert vao cuoi index, page split thap
UUID v7 được chuẩn hoá trong RFC 9562 (May 2024) và được hỗ trợ rộng rãi: nhiều RDBMS có hàm sinh native hoặc extension; thư viện ở các ngôn ngữ (JavaScript, Java, Python...) đều có hàm uuidv7(). Vì cách sinh là dialect, một lựa chọn portable là sinh UUID v7 ở app layer rồi insert giá trị vào — không phụ thuộc engine có hàm native hay không.
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). Index 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ư một sequence trung tâm
Nhược điểm:
- Cần coordination để assign machine ID (service điều phối, 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 VARCHAR(100) 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 TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
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 đó, engine 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 surrogate PK (auto-increment), để email/username là UNIQUE column bình thường. Nguyên tắc này đúng ở mọi RDBMS.
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 - auto-increment DB + UUID public
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id UUID UNIQUE NOT NULL, -- gan gia tri UUID v4/v7 (sinh la dialect)
email VARCHAR(254) UNIQUE NOT NULL
-- ...
);
-- API: GET /api/users/:public_id (unguessable)
-- DB join: WHERE id = ... (sequential, fast index)
-- Pattern 3: Hashids / Sqids
-- Encode auto-increment thanh string khong guess duoc: 1234 -> "xKJ9mP"
-- One-way mapping: decode "xKJ9mP" -> 1234 tai app layer
-- Giu auto-increment trong DB, expose string o URL
Pattern dual key (auto-increment internal + UUID public) phổ biến nhất: hưởng lợi sequential insert từ auto-increment, unguessable public URL từ UUID.
-- TaskFlow ap dung dual key cho tasks:
CREATE TABLE tasks (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id UUID UNIQUE NOT NULL, -- UUID sinh o app/DB (dialect)
title VARCHAR(500) NOT NULL
-- ...
);
-- API route: GET /api/tasks/:public_id
-- Internal query: WHERE id = $1 (sequential index 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 (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 | Auto-increment (BIGINT IDENTITY) |
| OLTP single-DB + public URL | Auto-increment 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), VARCHAR) |
| Bảng junction M:N | Composite PK từ FK (không cần surrogate) |
9. Applied — TaskFlow PK rationale
-- users.id auto-increment (BIGINT IDENTITY): single-DB OLTP, no distributed requirement yet
-- Index co thu tu sequential -> page split thap, insert throughput cao
-- public_id UUID: unguessable URL /api/users/:public_id
-- tasks.id auto-increment: same reasoning as users
-- tasks.public_id UUID: /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 (gan gia tri UUID v7)
-- Dual write period: ghi ca auto-increment va UUIDv7
-- Switch FK references sang id_v7 tung buoc
-- Drop auto-increment khi tat ca service da migrate
Migration đổi PK trên bảng production cần làm online, từng bước — Bài 06 — Schema migration đi sâu pattern an toàn (expand-contract, dual-write, backfill).
10. Deep Dive — Identity strategy
- RFC 9562 — Universally Unique IDentifiers (UUIDs) — UUID v7 official spec (May 2024). Section 5.7 định nghĩa timestamp layout và monotonicity — đọc Section 5.7 + 6.1 là đủ.
- Use The Index, Luke — góc nhìn agnostic về index có thứ tự, vì sao random key gây fragmentation, và cost của page split.
- Buildkite — "Goodbye integers, hello UUIDs" — case study thực tế (engine-agnostic insight) về tradeoff khi đổi từ auto-increment sang UUID, và vì sao thứ tự UUID quan trọng cho index.
Ghi chú: RFC 9562 cho v7 chuẩn spec; Use The Index Luke cho intuition agnostic về index có thứ tự và page split. Khi triển khai trên engine cụ thể, đối chiếu tài liệu engine cho hàm sinh UUID/identity và cách tuning fill factor.
11. Liên hệ các bài khác
- Bài 01 — Data types: UUID, auto-increment đứng ở nhóm "định danh là dialect" — bài này deep dive tradeoff giữa chúng.
- Bài 02 — Constraints: PK/UNIQUE tự sinh index; FK trỏ tới PK — chọn loại PK ảnh hưởng mọi FK trong schema.
- Bài 06 — Schema migration: đổi PK (auto-increment → UUID v7) trên bảng production cần expand-contract online.
- Module 6 — Storage & indexing: cơ chế index có thứ tự, page split, fill factor — vì sao random key tốn kém.
12. Tóm tắt
- Auto-increment append-only, index sequential — page split thấp, phù hợp OLTP single-DB.
UUID v4random insert toàn bộ cây index — 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. Cách sinh là dialect; sinh ở app layer cho portable.- 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: auto-increment PK nội bộ (index friendly) +
UUIDpublic_id trên URL (chống enumeration attack). - Nguyên lý agnostic, đúng ở mọi RDBMS. Forward: Bài 06 — Schema migration, Module 6 — Storage & indexing.
13. Tự kiểm tra
Q1Vì sao UUID v4 random gây page split nhiều còn auto-increment thì không? Giải thích theo cơ chế insert vào index có thứ tự.▸
Leaf page của index có thứ tự có kích thước cố định (thường vài KB, tuỳ engine). Khi insert một key mới, engine phải đặt nó đúng vị trí sorted trên leaf page tương ứng. Với auto-increment, 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 cây. Nếu page tại vị trí đó đã đầy, engine 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. Cơ chế này đúng cho mọi engine dùng index có thứ tự.
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 cây index. 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. Insert behavior gần giống auto-increment — 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 (auto-increment + UUID public): giữ auto-increment làm internal PK (index friendly), thêm column public_id UUID UNIQUE NOT NULL (gán giá trị UUID v4/v7). 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 auto-increment — sequential, fast.
Cách 2 — Hashids / Sqids: encode auto-increment thành string ngắn không đoán được tại app layer (vd 1234 -> "xKJ9mP"). Decode về số 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 surrogate PK (auto-increment), slug là UNIQUE column riêng.
Q5TaskFlow scale lên 100M task, cần 5 shard. Migrate id auto-increment 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 online:
- Bước 1: thêm column
id_v7 UUID NOT NULL(gán giá trị UUID v7) — 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(auto-increment) 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 auto-increment sau monitoring window.
Tradeoff: UUID v7 16 byte (gấp đôi BIGINT 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 (xem bài 06). Quy trình này phổ quát — chỉ cú pháp DDL và cách sinh UUID là dialect.
Bài tiếp theo: Schema migration & evolution — versioned migration, expand-contract
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên