Skip to content

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ấtDuy 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àoDatabase 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 gianID 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ước8 byte hay 16 byte? Khoá chính nằm trong mọi index (MySQL chương 4).
Lộ thông tinNhì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ộcCó 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/10024 thà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ảnCấu tạoGhi chú
v1Thời gian + địa chỉ MACLộ 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
v4122 bit ngẫu nhiênPhổ biến nhất; không có thứ tự
v5 (v3)Hash của một namespace + tênCùng đầu vào luôn ra cùng UUID: tạo ID ổn định từ khoá tự nhiên (email, URL)
v748 bit Unix timestamp (ms) + phần ngẫu nhiênChuẩ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ùng Math.random.
  • Lưu 16 byte, không lưu chuỗi 36 ký tự: BINARY(16) trong MySQL (UUID_TO_BIN), kiểu uuid trong 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
IDThời điểmMáySeq
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 BIGINT làm khoá chính, dùng để join và làm khoá ngoại; public_id ngẫ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áchKích thướcThứ tự thời gianSinh ở đâuPhụ thuộcLộ thông tin
Tự tăng8 byteTăng tuyệt đối (trong một database)Database, lúc insertDatabaseSố lượng, dò được
UUIDv416 byteKhôngBất kỳ đâuKhôngKhông
UUIDv7 / ULID16 byteTheo msBất kỳ đâuKhôngThời điểm tạo
Snowflake8 byteGần đúng (k-sorted)AppCấp machine id, đồng hồThời điểm, máy
Cấp theo khối8 byteGần đúngService ID / appDatabase (thưa)Một phần số lượng

Chọn nhanh ​

  • Một database, hệ thống vừa: BIGINT tự tăng làm khoá chính, thêm public_id ngẫ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_id riêng.

Ghi nhớ

  1. Tiêu chí: phạm vi duy nhất, ai sinh, thứ tự, kích thước, lộ thông tin, phụ thuộc.
  2. UUIDv4 làm khoá chính gây page split; UUIDv7 có thứ tự, lưu 16 byte.
  3. 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.
  4. ID 64 bit trả ra API dạng chuỗi vì JavaScript chỉ chính xác tới 2⁵³.
  5. ID khó đoán không thay được kiểm tra quyền.