So sánh UUID v1, v4, v7 và thiết kế khoá chính DB
Tóm tắt (TL;DR)
Nói thế này có thể gây tranh cãi, nhưng dùng UUID v4 làm khoá chính cho bảng insert khối lượng lớn gần như là tự bắn vào chân. Tôi từng xem xét một workload PostgreSQL 16 dùng gen_random_uuid() (v4) làm khoá chính cho bảng events. Mỗi INSERT rơi vào một lá B-tree ngẫu nhiên, làm nguội shared_buffers và phân mảnh index tích luỹ ở mức phần trăm hai chữ số cuối. Khi chuyển kiểu cột sang v7 — vẫn là kiểu uuid 16 byte, vẫn cùng index — độ trễ INSERT trung bình giảm còn khoảng một phần ba, và các chỉ số phân mảnh trong pg_stat_user_indexes ổn định lại. Chọn phiên bản UUID không phải “quyết định mật mã” mà là quyết định thiết kế cơ sở dữ liệu. UUID là giá trị 128 bit, với biểu diễn hex 8-4-4-4-12, trong đó 4 bit gắn cứng cho version và 2–3 bit cho variant. v1 khắc timestamp và định danh node (lịch sử là địa chỉ MAC), tạo thứ tự thời gian xấp xỉ nhưng lộ host. v4 dùng 122 bit ngẫu nhiên để đạt độ khó đoán mạnh, đổi lại không sắp xếp được. v7 được chuẩn hoá trong RFC 9562 (2024), đặt timestamp Unix ms vào 48 bit cao và điền ngẫu nhiên cho phần còn lại, kết hợp độ an toàn của v4 với tính địa phương index của v1. Với các ID API công khai mà “che dấu thời điểm tạo” thực sự quan trọng, v4 vẫn là mặc định. Ở gần như mọi chỗ khác, v7 là lựa chọn tốt hơn, còn v1 thuộc về di sản — dù DB vẫn chấp nhận, đừng chọn nó làm mặc định cho thiết kế mới.
Bối cảnh và khái niệm
UUID là 128 bit, thường viết dưới dạng 32 chữ số hex nối bằng dấu nối: xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx. Vị trí M chứa version (1, 4, 7, v.v.), và bit cao của N đánh dấu variant. Phần còn lại có ý nghĩa khác nhau theo version.
Version 1 thiết kế để đạt tính duy nhất xuyên suốt máy và thời gian. 60 bit timestamp đo khoảng 100 nano giây kể từ 1582-10-15, trường clock sequence lo việc đồng hồ quay ngược, và 48 bit node ID nguyên bản là địa chỉ MAC. Hệ quả về quyền riêng tư rất rõ: một UUID v1 tạo trên laptop của bạn có MAC của laptop đó được encode vào, dễ dàng trích xuất bằng CLI uuid -d. Các thư viện hiện đại có thể ngẫu nhiên hoá node ID để tránh rò rỉ, nhưng nhiều triển khai vẫn tuân theo quy tắc gốc.
Version 4 là 122 bit ngẫu nhiên với 6 bit cố định cho version và variant. Nó giả định RNG mạnh (crypto.randomUUID() trong trình duyệt hoặc gen_random_uuid() của PostgreSQL). Ở quy mô thực tế, xung đột gần như không thể xảy ra — theo giới hạn sinh nhật, ngay cả khi sinh một tỷ UUID thì xác suất xung đột chỉ khoảng 1 phần nghìn tỷ. Điểm yếu là hai UUID v4 liên tiếp không có mối quan hệ với nhau, nên khi chèn vào index, chúng chạm các trang ngẫu nhiên, rất tệ cho cả tính địa phương cache lẫn ghi khuếch đại (WAL và full-page write).
Version 7 là một phần của RFC 9562 xuất bản năm 2024, RFC này thay thế RFC 4122 và bổ sung v6, v7, v8. v7 đặt timestamp Unix milli giây 48 bit ở phần cao, rồi nhãn version, rand_a nhỏ, nhãn variant, và đuôi rand_b 62 bit. Hiệu ứng thực tế là các v7 sinh cùng ms kề nhau trong sắp xếp, và xuyên qua các ms chúng được sắp theo thời gian. Riêng phần đuôi ngẫu nhiên cũng đủ entropy để đảm bảo tính duy nhất trong cùng ms. PostgreSQL 18 đã có hàm native uuidv7(); phiên bản trước đó có thể dùng extension pg_uuidv7 hoặc thư viện ở tầng ứng dụng (uuid 9.x của Node, uuid6 của Python).
Bit variant quan trọng vì nó phân biệt họ RFC 9562/4122 với các UUID di sản của Microsoft hoặc Apollo. Bài viết này giả định variant RFC (chữ hex đầu của nhóm N là 8, 9, a hoặc b).
Định dạng lưu trữ là vấn đề riêng. Kiểu uuid native của PostgreSQL 16 lưu giá trị 16 byte. MySQL thường dùng BINARY(16) hoặc CHAR(36); kiểu sau nhân đôi dung lượng và biến so sánh thành so sánh ký tự. Chọn version và chọn định dạng lưu trữ đan xen với nhau. Sắp xếp v7 theo byte thì rẻ và chính xác; sắp xếp chuỗi hex cũng chính xác nhưng chậm; còn v4 thì dù lưu kiểu nào, sắp xếp cũng không có ý nghĩa.
So sánh và dữ liệu
| Thuộc tính | v1 | v4 | v7 |
|---|---|---|---|
| Nguyên liệu tạo | Timestamp + clock sequence + node ID | 122 bit ngẫu nhiên | 48 bit Unix ms + đuôi ngẫu nhiên |
| Quyền riêng tư | Lộ node ID (thường là MAC) | Không lộ host hoặc thời gian | Lộ thời điểm tạo (ms), không lộ host |
| Sắp theo thời gian | Được (nhưng byte order ≠ time order, cần sắp lại) | Không | Được — thứ tự ký tự khớp thứ tự thời gian |
| Tính địa phương index | Trung bình | Kém (chèn ngẫu nhiên khắp B-tree) | Tốt (gần như đơn điệu tăng) |
| Ứng dụng tiêu biểu | Hệ thống di sản, một số ID COM/Windows | ID API công khai, session token, salt | Event log, insert lớn, phân trang theo thời gian |
| Entropy | Thấp (chủ yếu là thời gian và node) | Cao (khoảng 122 bit) | Đuôi cao (khoảng 74 bit), xung đột trong ms hiếm |
Mô hình tinh thần đơn giản: v4 tối đa hoá tính khó đoán nhưng hy sinh hiệu năng index; v7 khôi phục mẫu “chèn ở cuối” mà DB yêu thích, đồng thời vẫn đủ khó đoán cho đa số ứng dụng; v1 là di sản — cần biết nó tồn tại nhưng không nên chọn.
Tình huống thực tế
Tình huống 1 — Event log theo kiểu append-intensive. Workload trong đoạn mở bài đúng hình dáng này: hàng triệu dòng mỗi ngày, query phổ biến là “24 giờ gần nhất theo thời gian”. v7 thắng trực tiếp: dòng mới gắn vào đuôi index khoá chính nên page hot vẫn nóng, và query phạm vi thời gian ánh xạ sang các đoạn index liền kề. Chỉ đổi default cột từ v4 sang v7 mà không đụng code query đã thấy giảm đáng kể độ trễ ghi và phân mảnh index.
Tình huống 2 — ID công khai hướng tới người dùng. Các link chia sẻ như /orders/{id} cần khó đoán để tránh bị liệt kê đơn hàng của người khác. v4 là mặc định an toàn. Nếu muốn cả lợi ích của v7, đừng quên rằng v7 lộ thời điểm tạo ở mức ms. Với đơn hàng có thể chấp nhận, nhưng trong ngữ cảnh nhạy cảm hơn (tín hiệu kinh doanh như số giao dịch mỗi phút) thì đây là rò rỉ. Giải pháp trung gian tôi khuyến nghị cho một team là mẫu ID kép — v7 làm khoá chính nội bộ, còn v4 hoặc slug ngẫu nhiên ngắn phơi ra ngoài — giữ được hiệu năng index mà không rò rỉ thông tin thời gian ra ngoài.
Tình huống 3 — Hệ thống đa vùng và sharding. Tiền tố timestamp của v7 cho phép các vùng khác nhau sinh UUID cùng ms vẫn trộn lẫn theo thời gian một cách tự nhiên, nhưng không đảm bảo thứ tự giữa các vùng trong cùng ms. Nếu cần thứ tự liên vùng nghiêm ngặt hơn, ULID (48 bit timestamp + 80 bit random, biểu diễn Crockford Base32) cho đặc tính gần như tương đương với chuỗi 26 ký tự ngắn hơn. Nếu cần đảm bảo còn nghiêm ngặt hơn, ID kiểu Snowflake (cả bản Twitter gốc lẫn biến thể Discord đều 64 bit) có machine ID rõ ràng, đổi lại bạn phải chịu thêm gánh nặng điều phối cấp machine ID.
Những hiểu lầm thường gặp
“UUID luôn chậm trong DB.” Chậm hơn int 4 byte, nhưng phần lớn chi phí thực tế là phân mảnh do chèn ngẫu nhiên trong B-tree, và v7 loại gần hết chi phí đó. Lưu dưới dạng 16 byte thay vì chuỗi 36 ký tự giảm một nửa kích thước index và tăng tốc so sánh. Phần lớn benchmark “UUID chậm” thực ra là “v4 lưu dạng CHAR(36) trong MySQL chậm”.
“Chỉ v4 là an toàn.” Đuôi ngẫu nhiên của v7 cũng là bể entropy đủ lớn, nên với đa số ứng dụng — tham chiếu session, ID API — việc kẻ tấn công liệt kê là không thực tế. Vấn đề không phải khả năng đoán được mà là rò rỉ timestamp; v7 tiết lộ thời điểm tạo dòng. Nếu sự rò rỉ đó chấp nhận được (thường là vậy), v7 hợp lý ngay cả cho ID công khai.
“UUID phải lưu dưới dạng chuỗi.” Dạng chuỗi là 36 ký tự (32 hex + 4 dấu nối), nhưng nhị phân chỉ 16 byte, và sắp xếp byte chính là sắp xếp đúng. Nhớ rằng byte order không đơn điệu của v1 hoặc byte order cần khớp thời gian của v7 đều cần lưu nhị phân mới thuận. Kiểu uuid của PostgreSQL đã lưu 16 byte nên bạn không phải lo chuyển đổi nhị phân thủ công.
“Rò rỉ MAC của v1 không ai thèm nhìn.” Lật ngược MAC từ v1 là một biến đổi công khai, và công cụ forensic (như uuid -d) mặc định trích nó ra. Nếu UUID xuất hiện trong URL, ticket hỗ trợ, log chia sẻ ra ngoài — đó là rò rỉ thông tin thực sự.
Danh sách kiểm tra
- UUID có dùng làm khoá của index bị chèn thường xuyên không? Mặc định v7. Chỉ chọn v4 khi thực sự cần tính khó đoán về thời điểm tạo.
- UUID có hiển thị cho người dùng hoặc đối tác không? Cả hai đều chạy. Chỉ cần kiểm tra xem rò rỉ timestamp của v7 có chấp nhận được không.
- Có phải Postgres không? Dùng kiểu
uuidnative (16 byte). Với MySQL, dùngBINARY(16)trừ khi thực sự cần tương thích chuỗi. - Cần đảm bảo thứ tự giữa nhiều generator không? v7 một mình chưa đủ. Dùng ULID (cùng tiền tố thời gian) hoặc ID kiểu Snowflake có machine ID rõ ràng.
- Code base có còn v1 không? Ghi lại rò rỉ MAC và lên kế hoạch di trú khi schema cho phép.
- Sinh ở client? Dùng thư viện gọi RNG mạnh về mật mã (v4 trong trình duyệt mới là
crypto.randomUUID(), v7 thường bọc cùng RNG đó).
Công cụ liên quan
UUID generator của Patrache Studio tạo v4 và v7 cục bộ, nên giá trị sinh ra không lưu log ở dịch vụ bên thứ ba. UUID gần như luôn đi trong payload JSON, và Khác biệt giữa format JSON, kiểm tra cú pháp và JSON Schema bàn về mẫu schema giữ kiểu cho các ID này khi chúng di chuyển giữa các service. Khi bạn cần biểu diễn văn bản gọn gàng như ID ngắn 22 ký tự từ UUID 16 byte, Base64 và URL encoding: Khi nào cần và khi nào dùng sai giải thích tại sao bạn phải chọn Base64URL thay vì Base64 chuẩn.
Tài liệu tham khảo
- IETF RFC 9562, “Universally Unique IDentifiers (UUIDs)” — https://datatracker.ietf.org/doc/html/rfc9562
- IETF RFC 4122, “A Universally Unique IDentifier (UUID) URN Namespace” (đã bị RFC 9562 thay thế) — https://datatracker.ietf.org/doc/html/rfc4122
- Tài liệu PostgreSQL, “UUID Type” — https://www.postgresql.org/docs/current/datatype-uuid.html
- Đặc tả ULID — https://github.com/ulid/spec