Giao diện
Sinh ID
Câu hỏi phỏng vấn hay gặp: “bảng này dùng kiểu ID gì”, “AUTO_INCREMENT có vấn đề gì khi hệ thống lớn”, “thiết kế dịch vụ sinh ID cho hệ phân tán”. Không có kiểu ID tốt nhất cho mọi trường hợp; mỗi cách đánh đổi giữa vài tiêu chí.
Tiêu chí chọn
| Tiêu chí | Câu hỏi |
|---|---|
| Phạm vi duy nhất | Duy nhất trong một bảng, trong cả hệ thống, hay giữa nhiều hệ thống / nhiều vùng? |
| Ai sinh, lúc nào | Database sinh lúc insert, hay app / client sinh trước? Cần biết ID trước khi ghi khi tạo nhiều bản ghi liên quan cùng lúc, dùng ID làm khoá idempotency, hoặc client làm việc offline. |
| Thứ tự theo thời gian | ID sau có lớn hơn ID trước không? Quan trọng cho B+Tree (insert vào cuối, không page split), sort theo ID thay cho sort theo thời gian, keyset pagination. |
| Kích thước | 8 byte hay 16 byte? Khoá chính nằm trong mọi index (MySQL chương 4). |
| Lộ thông tin | Nhìn ID có đoán được số lượng đơn, thời điểm tạo, máy sinh? Có dò được ID khác không? |
| Phụ thuộc | Có cần gọi một điểm tập trung (database, service ID) mỗi lần sinh không? Điểm đó sập thì sao? |
1. Tự tăng (AUTO_INCREMENT, sequence)
MySQL AUTO_INCREMENT, PostgreSQL GENERATED … AS IDENTITY (dựa trên sequence).
Ưu điểm: 8 byte (BIGINT), tăng dần nên insert luôn vào cuối B+Tree, đơn giản, dễ đọc khi debug.
Nhược điểm:
- Chỉ biết ID sau khi insert. App không tự sinh trước được, muốn tạo cha và con cùng lúc phải insert cha trước để lấy ID.
- Một nguồn duy nhất. Nhiều database hoặc nhiều shard sinh độc lập sẽ trùng nhau. MySQL có cách chia
auto_increment_increment/auto_increment_offset(server 1 sinh số lẻ, server 2 sinh số chẵn), nhưng đổi số server về sau rất khó. - Lộ số lượng và dễ dò. Đơn hôm nay là 10.023, tuần sau là 10.500: đối thủ ước được số đơn. Đổi
/orders/10024thành/orders/10025để xem đơn người khác: đây là lỗi IDOR, lỗi phân quyền chứ không phải lỗi ID, nhưng ID tăng dần khiến khai thác dễ hơn nhiều. - Có lỗ hổng: rollback, insert lỗi, sequence được cache đều bỏ qua số. Không dùng ID để đếm số bản ghi. Sequence của PostgreSQL không quay lại khi transaction rollback.
Chi tiết riêng của MySQL:
innodb_autoinc_lock_mode = 2(mặc định từ 8.0) cho phép insert song song không khoá cả bảng, nhưng ID của một lệnh insert nhiều dòng có thể không liền nhau.- Từ 8.0, bộ đếm được lưu bền. Ở 5.7, khi khởi động lại, bộ đếm được tính lại từ
MAX(id) + 1: xoá các dòng có ID lớn nhất rồi restart thì ID cũ bị cấp lại. Đây là nguồn lỗi nếu ID đã được dùng ở hệ thống khác.
2. UUID
128 bit, viết dạng chuỗi 36 ký tự 018f3a6e-7c2b-7d4e-9f10-3b5a1c2d4e6f. Các phiên bản hay gặp:
| Phiên bản | Cấu tạo | Ghi chú |
|---|---|---|
| v1 | Thời gian + địa chỉ MAC | Lộ MAC; phần thời gian bị xếp ngược nên chuỗi không sắp theo thời gian. MySQL UUID() sinh v1 |
| v4 | 122 bit ngẫu nhiên | Phổ biến nhất; không có thứ tự |
| v5 (v3) | Hash của một namespace + tên | Cùng đầu vào luôn ra cùng UUID: tạo ID ổn định từ khoá tự nhiên (email, URL) |
| v7 | 48 bit Unix timestamp (ms) + phần ngẫu nhiên | Chuẩn hoá trong RFC 9562 (2024); sắp được theo thời gian |
- Xác suất trùng của v4 không đáng lo: phải sinh khoảng 2,7 × 10¹⁸ UUID mới có 50% khả năng có một cặp trùng, với điều kiện dùng nguồn ngẫu nhiên tốt (
crypto), không dùngMath.random. - Lưu 16 byte, không lưu chuỗi 36 ký tự:
BINARY(16)trong MySQL (UUID_TO_BIN), kiểuuuidtrong PostgreSQL. - v4 làm khoá chính trên B+Tree: mỗi insert rơi vào một page ngẫu nhiên: page split, phân mảnh, page chỉ lấp khoảng 69%, buffer pool phải giữ cả cây (MySQL chương 1).
- v7 sửa đúng vấn đề đó: ID mới luôn lớn hơn ID cũ (theo mili giây), nên insert vào cuối cây như AUTO_INCREMENT mà vẫn sinh được ở bất kỳ đâu. Cái giá: lộ thời điểm tạo. PostgreSQL 18 có hàm
uuidv7(); MySQL chưa có hàm sinh v7, sinh ở tầng app.
3. ULID, KSUID, NanoID
- ULID: 128 bit = 48 bit timestamp (ms) + 80 bit ngẫu nhiên, viết bằng Crockford base32 thành 26 ký tự, sắp được theo chuỗi. Ý tưởng giống UUIDv7 nhưng ra đời trước. Nay đã có v7 là chuẩn, lưu dạng UUID thì nên chọn v7.
- KSUID (Segment): 160 bit = 32 bit timestamp (giây) + 128 bit ngẫu nhiên, base62 27 ký tự.
- NanoID: chuỗi ngẫu nhiên ngắn, an toàn cho URL, độ dài tuỳ chọn. Hợp cho ID công khai, link chia sẻ; không có thứ tự.
4. Snowflake: 64 bit, sinh tại chỗ
Twitter công bố năm 2010. Một ID 64 bit vừa kiểu BIGINT:
| 1 bit dấu | 41 bit thời gian (ms từ epoch tự chọn) | 10 bit máy | 12 bit sequence |- 41 bit mili giây dùng được khoảng 69 năm kể từ epoch.
- 10 bit máy: tối đa 1024 máy sinh ID.
- 12 bit sequence: 4096 ID mỗi mili giây mỗi máy, khoảng 4 triệu ID mỗi giây mỗi máy.
Ưu điểm: 8 byte; tăng theo thời gian (giữa các máy chỉ gần đúng thứ tự, gọi là k-sorted); mỗi máy tự sinh, không gọi mạng.
Khó khăn khi vận hành:
- Cấp machine id duy nhất. Đặt cố định trong cấu hình, lấy từ thứ tự pod trong Kubernetes StatefulSet, hoặc xin thuê (lease) từ etcd / ZooKeeper / database khi khởi động. Hai máy cùng id là có thể sinh trùng.
- Đồng hồ chạy lùi. NTP chỉnh giờ làm thời gian quay lại: nếu cứ sinh, ID có thể trùng ID đã phát. Phải từ chối sinh hoặc chờ đồng hồ đuổi kịp lần sinh cuối (Hệ phân tán › Thời gian).
- Hết sequence trong một mili giây: chờ sang mili giây sau.
- JavaScript chỉ biểu diễn chính xác số nguyên tới 2⁵³. ID 64 bit trả về trong JSON dạng số sẽ bị làm tròn ở frontend, trỏ sang bản ghi khác. Luôn trả ID dạng chuỗi trong API. Đây là lỗi rất hay gặp.
Thử: Snowflake ID
Đồng hồ mô phỏng: 2026-10-13 00:06:40.000 UTC
64 bit của ID mới nhất
0001110101000111100000100000101010101111110000100101000000000000
41 bit thời gian (ms từ 2010-11-04)10 bit máy12 bit sequence
| ID | Thời điểm | Máy | Seq |
|---|---|---|---|
| Chưa sinh ID nào. | |||
ID này đủ nhỏ để JavaScript đọc đúng.
Các biến thể: Sonyflake (đơn vị 10 ms, 16 bit máy, sống lâu hơn); Instagram sinh ngay trong PostgreSQL bằng hàm PL/pgSQL (41 bit thời gian + 13 bit shard + 10 bit sequence lấy từ sequence của shard); Discord dùng Snowflake với epoch riêng.
5. Cấp phát theo khối (segment, hi/lo)
Một dịch vụ sinh ID (hoặc từng instance app) xin một khối số từ database, rồi phát dần trong RAM:
sql
-- mỗi lần xin 1000 số cho loại "order"
UPDATE id_segments SET max_id = max_id + step WHERE biz_tag = 'order';
SELECT max_id, step FROM id_segments WHERE biz_tag = 'order';
-- được khoảng (max_id - step, max_id]; phát dần trong bộ nhớ- Ưu điểm: số tăng dần, ngắn, 8 byte; database chỉ bị gọi một lần cho mỗi 1000 ID. Xin trước khối tiếp theo khi khối hiện tại dùng gần hết (double buffer) thì database chậm cũng không ảnh hưởng.
- Nhược điểm: instance khởi động lại thì phần còn lại của khối bị bỏ (có lỗ hổng); thứ tự giữa các instance chỉ gần đúng; vẫn phụ thuộc database nhưng ít hơn nhiều.
- Leaf của Meituan dùng cách này; hi/lo của Hibernate cùng ý tưởng.
- Redis
INCRđơn giản và rất nhanh, nhưng phải bật lưu bền; failover sang replica chưa kịp đồng bộ có thể cấp lại số đã phát, tức là trùng.
6. ID công khai và ID nội bộ
- Tách hai cột:
id BIGINTlàm khoá chính, dùng để join và làm khoá ngoại;public_idngẫu nhiên (UUIDv4, NanoID) cóUNIQUE, dùng trong URL và API. Không lộ số lượng, không dò được, và đổi cách sinh ID công khai không ảnh hưởng khoá ngoại. - Che ID bằng mã hoá ngược được (Sqids, Hashids): chỉ là che, không phải bảo mật.
- ID có tiền tố theo loại, kiểu Stripe:
cus_…,pi_…,inv_…. Nhìn ID biết ngay là gì, dán nhầm ID khách hàng vào chỗ cần ID hoá đơn thì báo lỗi rõ ràng. - Mã cho người đọc (mã đơn hàng đọc qua điện thoại): tiền tố + ngày + vài ký tự ngẫu nhiên, ví dụ
DH-261013-4F7K; dùng bảng chữ bỏ các ký tự dễ nhầm (0/O, 1/I/L) như Crockford base32. - ID khó đoán không thay được kiểm tra quyền. Mọi API vẫn phải kiểm tra người gọi có quyền xem bản ghi đó.
So sánh
| Cách | Kích thước | Thứ tự thời gian | Sinh ở đâu | Phụ thuộc | Lộ thông tin |
|---|---|---|---|---|---|
| Tự tăng | 8 byte | Tăng tuyệt đối (trong một database) | Database, lúc insert | Database | Số lượng, dò được |
| UUIDv4 | 16 byte | Không | Bất kỳ đâu | Không | Không |
| UUIDv7 / ULID | 16 byte | Theo ms | Bất kỳ đâu | Không | Thời điểm tạo |
| Snowflake | 8 byte | Gần đúng (k-sorted) | App | Cấp machine id, đồng hồ | Thời điểm, máy |
| Cấp theo khối | 8 byte | Gần đúng | Service ID / app | Database (thưa) | Một phần số lượng |
Chọn nhanh
- Một database, hệ thống vừa:
BIGINTtự tăng làm khoá chính, thêmpublic_idngẫu nhiên nếu ID lộ ra ngoài. - Cần sinh ID ở app hoặc client, nhiều database, gộp dữ liệu nhiều nguồn: UUIDv7, lưu 16 byte.
- Hệ phân tán lớn, cần 8 byte và gần đúng thứ tự: Snowflake hoặc cấp theo khối.
- ID trong URL công khai: ngẫu nhiên (UUIDv4, NanoID) hoặc
public_idriêng.
Ghi nhớ
- Tiêu chí: phạm vi duy nhất, ai sinh, thứ tự, kích thước, lộ thông tin, phụ thuộc.
- UUIDv4 làm khoá chính gây page split; UUIDv7 có thứ tự, lưu 16 byte.
- Snowflake = 41 bit thời gian + 10 bit máy + 12 bit sequence; khó ở cấp machine id và đồng hồ chạy lùi.
- ID 64 bit trả ra API dạng chuỗi vì JavaScript chỉ chính xác tới 2⁵³.
- ID khó đoán không thay được kiểm tra quyền.