Chọn kiểu khóa chính là một trong những quyết định thiết kế khó đảo ngược nhất — nó lan ra mọi khóa ngoại, mọi index, mọi lần chèn. Cuộc tranh luận kinh điển: bigserial (số nguyên tuần tự) hay UUID? UUID hấp dẫn vì phân tán toàn cục (sinh ở bất kỳ đâu không sợ trùng) và không lộ số lượng bản ghi. Nhưng UUID ngẫu nhiên có một cái giá ẩn mà nhiều người không đo: nó làm chậm chèn và phình index. Bài này đo thật cái giá đó, và giải thích vì sao UUIDv7 thay đổi cuộc chơi.

Ba lựa chọn khóa chính

CREATE TABLE t (id bigserial PRIMARY KEY);                           -- 8 byte, tuần tự 1,2,3...
CREATE TABLE t (id uuid PRIMARY KEY DEFAULT gen_random_uuid());      -- 16 byte, NGẪU NHIÊN (v4)
CREATE TABLE t (id uuid PRIMARY KEY DEFAULT gen_uuidv7());           -- 16 byte, thời gian ở đầu (PG18)

Khác biệt cốt lõi không phải là kích thước (dù 16 byte so với 8 cũng đáng kể), mà là thứ tự sinh. bigserial sinh khóa tăng dần; gen_random_uuid() (UUID v4) sinh khóa ngẫu nhiên hoàn toàn. Thứ tự này quyết định cách khóa mới rơi vào cây btree của index.

Ảnh chụp đoạn mã SQL nền tối minh hoạ khóa chính bigserial vs UUID cái giá của ngẫu nhiên, ba lựa chọn khóa chính CREATE TABLE t id bigserial PRIMARY KEY 8 byte tuần tự 1 2 3 CREATE TABLE t id uuid PRIMARY KEY DEFAULT gen_random_uuid 16 byte ngẫu nhiên v4 CREATE TABLE t id uuid PRIMARY KEY DEFAULT gen_uuidv7 16 byte thời gian ở đầu PG18, vì sao UUID ngẫu nhiên chậm khi chèn bigserial khóa mới luôn lớn nhất chèn ở rìa phải cây btree một trang nóng đầy tuần tự ít tách trang ít WAL, uuid v4 khóa mới rơi ngẫu nhiên khắp cây btree tách trang rải rác làm bẩn nhiều trang nhiều WAL mỗi lần chèn phải nạp một trang index khác vào bộ nhớ, UUIDv7 thời gian ở đầu tuần tự trở lại v4 9446429a 7d3e 48 bit đầu ngẫu nhiên v7 0192a3f1 b2c0 7 48 bit đầu bằng mốc thời gian mili giây khóa mới lại tăng dần theo thời gian chèn ở rìa phải như bigserial vẫn phân tán toàn cục an toàn lại thân thiện với index, ràng buộc duy nhất vẫn được ép INSERT INTO pk_uuid id v SELECT id 0 FROM pk_uuid LIMIT 1 ERROR duplicate key value violates unique constraint pk_uuid_pkey

Hình 1: bigserial sinh khóa tuần tự nên chèn ở rìa phải btree; UUID v4 ngẫu nhiên rơi khắp cây gây tách trang rải rác. UUIDv7 đặt thời gian ở đầu để lấy lại tính tuần tự.

Đo thật: UUID chèn chậm 2,7 lần

Chèn 2 triệu dòng vào hai bảng, một dùng bigserial, một dùng gen_random_uuid():

Ảnh chụp bảng kết quả đo thật nền tối bigserial vs UUID v4 chèn 2 triệu dòng PostgreSQL 16.15 timing pg_relation_size shared_buffers 128MB, tốc độ chèn 2 triệu dòng bigserial tuần tự 1315 mili giây chèn ở rìa phải btree một trang nóng uuid v4 ngẫu nhiên 3500 mili giây khoảng 2,7 lần chậm tách trang rải rác toàn cây, kích thước index và bảng bigserial 8 byte index PK 43 MB bảng 84 MB uuid v4 16 byte index PK 76 MB bảng 100 MB index UUID lớn hơn khoảng 1,8 lần 16 byte thay vì 8 và mỗi khóa ngoại trỏ tới nó cũng gấp đôi, ràng buộc duy nhất INSERT trùng id ERROR duplicate key value violates unique constraint pk_uuid_pkey, cốt lõi bigserial tuần tự chèn nhanh index gọn 8 byte nhưng lộ thứ tự số lượng và khó gộp dữ liệu nhiều nguồn UUID v4 ngẫu nhiên phân tán toàn cục an toàn nhưng chèn chậm khoảng 2,7 lần và index to gấp đôi UUIDv7 PG18 đặt thời gian ở đầu lấy lại tốc độ chèn của bigserial mà vẫn phân tán với đa số hệ thống một CSDL bigserial vẫn là mặc định tốt

Hình 2: Chèn 2 triệu dòng: bigserial 1.315 ms so với UUID v4 3.500 ms (~2,7× chậm). Index PK bigserial 43 MB so với UUID 76 MB (~1,8× lớn hơn); bảng 84 MB so với 100 MB. Chèn trùng id báo lỗi duplicate key value violates unique constraint.

  • Tốc độ chèn: bigserial 1.315 ms so với UUID v4 3.500 ms — chậm hơn ~2,7 lần.
  • Index PK: bigserial 43 MB so với UUID 76 MB — lớn hơn ~1,8 lần (16 byte so với 8).
  • Bảng: 84 MB so với 100 MB.

Vì sao UUID ngẫu nhiên chậm? Với bigserial, mỗi khóa mới luôn lớn nhất, nên nó chèn vào rìa phải của cây btree — một trang "nóng" duy nhất, lấp tuần tự, ít tách trang, ít WAL. Với UUID v4, khóa mới rơi ngẫu nhiên khắp cây: mỗi lần chèn có thể phải nạp một trang index khác vào bộ nhớ, gây tách trang rải rác, làm bẩn nhiều trang, sinh nhiều WAL. Trên bảng lớn hơn bộ nhớ, khoảng cách này còn giãn ra nữa.

UUIDv7: thời gian ở đầu, tuần tự trở lại

Vấn đề của UUID không phải là "UUID" mà là "ngẫu nhiên". UUIDv7 giải quyết đúng điều đó: nó đặt mốc thời gian mili giây ở 48 bit đầu, phần còn lại mới ngẫu nhiên.

v4:  9446429a-7d3e-...   (48 bit đầu ngẫu nhiên → rơi khắp btree)
v7:  0192a3f1-b2c0-7...   (48 bit đầu = thời gian → tăng dần)

Kết quả: khóa v7 tăng dần theo thời gian, nên chèn ở rìa phải btree giống bigserial — lấy lại tốc độ chèn — mà vẫn giữ tính phân tán toàn cục và không lộ số lượng. PostgreSQL 18 có gen_uuidv7() sẵn; các phiên bản trước cần hàm tự viết hoặc sinh ở tầng ứng dụng. Nếu bạn cần UUID, hãy dùng v7 chứ đừng dùng v4 cho khóa chính.

Đánh đổi cần cân nhắc

bigserial là mặc định tốt cho hệ thống một-CSDL. Nếu ứng dụng của bạn chạy trên một PostgreSQL (hoặc một cụm với một nguồn ghi), bigserial thắng rõ: chèn nhanh, index gọn, khóa ngoại nhỏ (mỗi khóa ngoại trỏ tới PK cũng chỉ 8 byte thay vì 16). Đừng chọn UUID chỉ vì "nghe hiện đại".

UUID đáng giá khi cần sinh khóa phân tán. Khi khóa phải sinh được ở nhiều nơi mà không phối hợp (nhiều dịch vụ, sinh ở client, gộp dữ liệu từ nhiều nguồn, đồng bộ offline), UUID giải quyết bài toán mà bigserial không làm được — hai máy không thể cùng cấp một số tuần tự an toàn. Ở đây UUID (v7) là đúng, và cái giá chèn chấp nhận được.

bigserial lộ thông tin qua khóa. Số tuần tự để lộ số lượng bản ghi (đơn hàng #1050 cho biết bạn có ~1050 đơn) và thứ tự tạo. Với dữ liệu nhạy cảm về mặt cạnh tranh hoặc riêng tư, tính không đoán được của UUID là một ưu điểm bảo mật thật. Cân nhắc theo ngữ cảnh — đôi khi che giấu quy mô quan trọng hơn vài mili giây chèn.

Ba ý mang về

  1. UUID v4 ngẫu nhiên làm chèn chậm và index phình: đo thật, chèn 2 triệu dòng mất 3.500 ms với UUID so với 1.315 ms của bigserial (~2,7×), và index PK 76 MB so với 43 MB (~1,8×) — vì khóa ngẫu nhiên rơi khắp cây btree gây tách trang rải rác.
  2. bigserial nhanh và gọn nhưng lộ thứ tự/số lượng: khóa tuần tự chèn ở rìa phải btree (một trang nóng, ít WAL) và chỉ 8 byte, nhưng để lộ quy mô dữ liệu và không sinh được phân tán ở nhiều nguồn.
  3. UUIDv7 lấy lại tốc độ chèn mà vẫn phân tán: đặt thời gian ở 48 bit đầu để khóa tăng dần theo thời gian (chèn rìa phải như bigserial), giữ tính toàn cục — nếu cần UUID cho khóa chính, dùng v7 (PostgreSQL 18 có gen_uuidv7()) chứ đừng dùng v4.

Phần sau ta xét một câu hỏi index thường bị bỏ sót và gây chậm âm thầm: Phần sau đo khóa ngoại có cần index không — vì sao PostgreSQL không tự tạo index cho cột khóa ngoại, và cái giá thật khi xóa dòng cha hoặc JOIN.