Mục lục
Assumptions: Staff/Principal · 3 region active-active, peak ~50k writes/s, 100M row/năm, 4 secondary index/bảng, outbox, ID lộ ra API
Ràng buộc bắt buộc: ID phải tồn tại trước khi ghi outbox; ba region ghi độc lập; ID lộ ra API. Các con số tải và dung lượng chỉ là giả định để tính, không phải kết quả đo.
Hai lần retry, hai kết quả khác nhau
Giả định một tình huống thiết kế, không phải sự cố có thật: order service commit thành công, nhưng response timeout ở gateway. Client retry.
Với BIGSERIAL, lần retry lấy ID mới và tạo order thứ hai. Nhưng lỗi không nằm ở sequence: nếu client gửi kèm một request_id ổn định và bảng có unique constraint trên đó, lần retry thành no-op dù PK là sequence, UUIDv4 hay UUIDv7.
Đây là hai quyết định khác nhau: ID sinh ở đâu, khi nào; và retry nhận diện cùng một ý định bằng gì. UUIDv7 chỉ tác động quyết định thứ nhất — nó cho phép sinh ID trước request và sắp xếp gần theo thời gian. Nó không tự tạo idempotency. UUIDv4 cũng là khoá idempotency tốt nếu client tạo một lần và giữ nguyên qua các lần retry.
Bài tách hai quyết định đó, so sánh bốn chiến lược ID theo coordination và failure mode.
ID phải làm bốn việc, và chúng xung đột
| Vai trò | Yêu cầu | Cái giá |
|---|---|---|
| Định danh | Duy nhất trong không gian và thời gian | Coordination hoặc entropy |
| Tham chiếu | Nhỏ, cố định, so sánh nhanh | 64 bit lý tưởng; 128 bit là gấp đôi |
| Thứ tự | Dùng cho pagination, replay, debug | Phụ thuộc clock hoặc allocator |
| Che giấu | Không lộ số lượng, thời điểm, topology | Xung đột trực tiếp với thứ tự |
Ba xung đột: thứ tự cần thời gian hoặc allocator; che giấu cần entropy — UUIDv7 đạt cả hai bằng cách công khai timestamp. Tham chiếu cần 8 byte; unique không coordination cần 16 byte, và InnoDB nhân bản PK vào secondary index. Định danh cần bất biến; thứ tự cần clock đơn điệu — clock không đơn điệu.
ID cũng là hợp đồng: nó nằm trong URL, event đã publish, cache key và webhook. Đổi kiểu PK là thay đổi contract, không chỉ migration.
Cơ chế nào thực sự bảo đảm điều gì
| Thuộc tính | Cơ chế bảo đảm |
|---|---|
| Sinh không trùng | Entropy hoặc quy tắc phân bổ |
| Dữ liệu không trùng | Unique constraint trong phạm vi database enforce |
| Xử lý conflict | Retry, báo lỗi hoặc reconcile khi merge |
| Idempotency | Khoá ổn định, kiểm tra payload, unique constraint |
| Thứ tự gần theo thời gian | Layout ID và chính sách clock |
| Thứ tự commit | Nguồn có semantics commit, không phải PK |
Constraint chỉ từ chối giá trị trùng trong phạm vi nó enforce, không bảo đảm generator không tạo trùng. Ba region ghi độc lập thì uniqueness toàn cục là xác suất, trừ khi có tầng hợp nhất; request_id còn phụ thuộc phạm vi idempotency.
Sequence ID
BIGSERIAL hoặc AUTO_INCREMENT: một allocator trong database cấp số tăng dần. nextval không nằm trong transaction, nên abort, crash hoặc INSERT ... ON CONFLICT đều tiêu số và tạo gap; CACHE n cấp theo block.
| Được | Mất |
|---|---|
| 4–8 byte, index locality tốt nhất | Round trip trong transaction |
| Đọc, sort, debug dễ; range scan rẻ | Gap khi abort, crash, cache, failover |
| Không cần thư viện, vận hành quen thuộc | Retry không idempotent nếu thiếu khoá ổn định |
| Sort theo thứ tự insert gần đúng | Enumeration; trùng nếu shard không prefix |
Dùng ở đâu. Monolith hoặc modular monolith một database; B2B SaaS single-region; shard theo tenant có prefix hoặc range allocation; bảng lookup.
Không nên ở đâu. Allocator toàn cục cho multi-region active-active khi không có cơ chế phối hợp — range allocation theo region có thể dùng, nhưng phải chia range không chồng lấn và xử lý va chạm. Không phù hợp khi ID phải có trước commit.
Điều kiện. Một allocator, hoặc các range rời nhau; unique constraint ở tầng dữ liệu; không hứa gapless.
UUIDv4
122 bit ngẫu nhiên từ CSPRNG, không cần biết gì về node khác. Birthday bound với 10^9 ID là khoảng 10^-19; rủi ro thật là entropy source bị clone.
| Được | Mất |
|---|---|
| Không coordination, sinh ở đâu cũng được | 16 byte, insert ngẫu nhiên trong B-tree |
| Opaque: không lộ thời gian, thứ tự, topology | Không order; range scan theo thời gian đắt |
| Merge dữ liệu từ hệ độc lập | Log, URL, debug khó đọc |
| Làm khoá idempotency ổn định | Entropy kém khi clone VM, snapshot restore |
Dùng ở đâu. Request ID, trace ID, correlation ID; tên file và blob; offline-first mobile; merge dataset; request_id cho retry; bảng write thấp.
Không nên ở đâu. PK clustered cho bảng insert-heavy (InnoDB); bảng cần range scan hoặc cursor pagination theo thời gian; analytics sort theo ID.
Điều kiện. CSPRNG đủ tốt; unique constraint; idempotency chỉ có khi client giữ nguyên khoá — định dạng không quyết định điều đó.
Snowflake
41 bit ms + 10 bit worker + 12 bit sequence: 4.096 ID/ms/worker, 64 bit, sắp theo thời gian, không round trip. 10 bit worker giới hạn 1.024 worker; 41 bit ms đủ khoảng 69 năm tính từ epoch tự chọn.
| Được | Mất |
|---|---|
| 8 byte, sort theo ms, range scan tốt | Worker ID phải duy nhất trong phạm vi khai báo |
| Không round trip, throughput cao | Clock lùi làm ID sai hoặc generator dừng |
| Locality tốt hơn UUIDv4, không bằng sequence đơn điệu | Lộ timestamp và worker (topology) |
| Có thể chia bit theo region | Serverless, ephemeral khó giữ worker ID |
Dùng ở đâu. Service write cao đã có worker registry (StatefulSet cộng lease); chat, message, feed cần ID sort theo thời gian; order ID trong marketplace single-region.
Không nên ở đâu. Serverless autoscale không có lease; nhiều region ghi độc lập mà chưa định nghĩa worker ID duy nhất toàn hệ thống hay trong region; ID công khai lộ thời điểm tạo.
Điều kiện. Worker ID duy nhất theo phạm vi đã khai báo; clock không lùi quá ngưỡng; unique constraint vẫn cần. Chia bit region chỉ thu hẹp phạm vi cấp phát, không tự bảo đảm uniqueness.
UUIDv7
48 bit unix_ts_ms cộng 74 bit ngẫu nhiên (12 bit rand_a, 62 bit rand_b) theo RFC 9562; counter tuỳ chọn trong các bit ngẫu nhiên để monotonic trong cùng ms. PostgreSQL 18 có uuidv7(). ULID là thiết kế trước đó với cùng mục tiêu; UUIDv7 được chuẩn hoá trong RFC 9562.
| Được | Mất |
|---|---|
| 16 byte nhưng ordered theo ms | Vẫn 16 byte; index đắt hơn 8 byte |
| Không coordination ở generator | Trong cùng ms vẫn random nếu thiếu counter |
| Sinh trước request, retry an toàn khi giữ khoá | Lộ thời điểm tạo ở độ phân giải ms |
| Chuẩn hoá, có sẵn trong PostgreSQL 18 | Thư viện khác nhau về counter và monotonic |
Dùng ở đâu. OLTP phân tán mới; event và outbox; multi-region; cursor pagination theo khoá; nơi cần ID trước khi request tới server.
Không nên ở đâu. Budget 8 byte; cần thứ tự commit; cần giấu thời gian; hệ sort theo thứ tự nghiệp vụ.
Điều kiện. Clock không lùi; counter nếu cần monotonic thật; unique constraint vẫn cần — không coordination ở generator không có nghĩa dữ liệu không cần kiểm soát trùng. Sinh ở client khi ID phải có trước request; database sinh khi chỉ cần sau.
Đặt cạnh nhau
| Sequence | UUIDv4 | Snowflake | UUIDv7 | |
|---|---|---|---|---|
| Coordination | Allocator DB | Không | Worker registry | Không |
| 8 byte | Có | Không | Có | Không |
| Thứ tự gần | Trong một sequence | Không | Theo ms | Theo ms |
| Lộ ra ngoài | Enumerable | Opaque | Time, worker | Time |
| Index | Tốt nhất | Kém nhất | Tốt | Tốt, trừ biên phải |
| Điều kiện uniqueness | Allocator/range + constraint | CSPRNG + constraint | Worker + clock + constraint | Clock + constraint |
Idempotency là thuộc tính của giao thức request — khoá ổn định, kiểm tra payload, unique constraint — không phải tiêu chí phân biệt generator. Cả bốn chiến lược đều dùng được nếu có khoá ổn định.
ID nằm ở đâu trong transaction
ID phải tồn tại trước commit ở bốn điểm: khoá chính, row outbox, envelope của event, và bản ghi idempotency. Với 2PC, ID còn phải được quyết định trước prepare để mọi participant tham chiếu cùng một operation.
Outbox và 2PC giải quyết hai vấn đề khác nhau. Outbox ghi business row và event trong cùng transaction, relay publish sau — xử lý dual-write DB↔broker. 2PC cần coordinator log lưu trạng thái prepare/commit để phục hồi sau crash; ID ổn định giúp đối chiếu, không giải quyết in-doubt. Đừng đồng nhất hai cơ chế.
-- id: định danh entity; request_id: khoá ổn định qua các lần retry
INSERT INTO orders (id, request_id, customer_id, amount, status)
VALUES ($1, $2, $3, $4, 'pending')
ON CONFLICT (request_id) DO NOTHING
RETURNING id;request_id do client tạo một lần và giữ nguyên; id có thể là UUIDv7 hoặc do database cấp. Unique constraint đặt trên request_id. DO NOTHING trả 0 row ở lần retry, nên caller phải phân biệt "đã áp dụng" với "chưa từng áp dụng" bằng SELECT theo request_id, hoặc dùng DO UPDATE ... RETURNING. Idempotent chỉ đúng khi payload không đổi: cùng khoá nhưng số tiền khác thì cần fingerprint hoặc version để phát hiện.
Với sequence, ON CONFLICT vẫn gọi nextval trước khi phát hiện conflict, nên retry tạo gap. Và nếu không có request_id ổn định, sequence cấp ID mới cho mỗi lần retry — vấn đề là giao thức, không phải định dạng.
Ba loại thứ tự
Cần phân biệt generation order (ID được sinh), commit order (transaction trở nên bền vững), và visibility order (reader quan sát được).
T2 sinh ID trước nhưng commit sau: sắp theo ID đặt T2 trước, sắp theo commit thì ngược lại. Không generator nào biết thứ tự commit.
Clock làm timestamp không đơn điệu: NTP step, leap smear, VM pause. Snowflake thường chọn chờ, từ chối hoặc phục hồi. UUIDv7 cũng có thể giữ monotonicity nếu generator có state/counter — RFC 9562 mô tả — nhưng layout timestamp + random không tự bảo đảm; hành vi khi lùi tuỳ implementation.
Hệ quả: last-write-wins dựa trên ID hoặc timestamp client sai khi clock lệch. CDC sắp theo WAL/binlog trong từng partition, không theo PK. Cursor pagination dùng UUIDv7 cho thứ tự ổn định theo khoá; nếu cần thứ tự nghiệp vụ hoặc commit, dùng (created_at, id) hoặc nguồn riêng.
Thứ tự commit phải lấy từ nguồn có semantics phù hợp: commit LSN trong WAL (không phải LSN bất kỳ trong transaction), event order/GTID theo replication topology, commit timestamp đủ phân giải, hoặc HLC nếu hệ thống định nghĩa. Không suy ra từ PK hay timestamp client; với ba region, phải chỉ rõ hệ thống nào giữ thứ tự toàn cục.
Mười sáu byte và biên phải của B-tree
Số học giả định: 100 triệu row, bốn secondary index, GB thập phân. Hai engine trả giá khác nhau:
| Engine | Phần được tính | PK 8 byte | PK 16 byte | Chênh lệch |
|---|---|---|---|---|
| InnoDB | PK + 4 secondary index (PK lặp trong từng index) | 4,0 GB | 8,0 GB | +4,0 GB |
| PostgreSQL | Heap + PK index; secondary index dùng TID, không đổi | 1,6 GB | 3,2 GB | +1,6 GB |
Chưa tính page overhead, fill factor, WAL, TOAST và compression; con số chỉ đo phần chịu ảnh hưởng bởi độ rộng PK.
Điều này chỉ thành quyết định khi nối với working set: nếu working set vừa buffer pool, 4 GB thêm có thể chỉ là dung lượng đĩa; nếu không, nó thành I/O và p99. Với UUIDv4, insert rơi ngẫu nhiên: page tách, fill factor giảm. Với UUIDv7, insert dồn về biên phải: ít page tách hơn nhưng latch và page split cuối index có thể thành contention khi write rate và concurrency đủ cao; mức độ phụ thuộc database và schema.
Hai pattern bổ sung
Dual ID. Internal PK 8 byte (sequence hoặc Snowflake), external ID 16 byte hoặc opaque (UUIDv7, hoặc derive bằng HMAC). API dùng external; join và index dùng internal. Dùng khi ID lộ ra ngoài hoặc cần đổi cách sinh mà không phá contract. Cái giá: hai identity, một mapping, và câu hỏi "ID nào là nguồn sự thật" phải trả lời dứt khoát. EmDash tách entry.id (slug URL) khỏi entry.data.id (ULID API).
Gapless. Trong phạm vi một miền nghiệp vụ được cấp phát nhất quán, không allocator phân tán nào vừa gapless vừa scale ngang cho throughput ghi. Tách legal number khỏi PK; cấp legal number bằng allocator tuần tự có ledger; chấp nhận trần throughput.
Vận hành
Dung lượng index ≈ số row × kích thước PK × (1 + số secondary index) × overhead B-tree; tính trước khi disk đầy.
Clock: owner là on-call của service sở hữu generator. Alert khi offset vượt granularity (1 ms với Snowflake). Hành động: dừng rollout, kiểm tra NTP/chrony và worker, không tự retry.
Worker ID: cấp qua lease (etcd, consul) thay vì config tĩnh. Hai node sinh cùng bộ ba (ms, worker, sequence) là sự cố cấu hình.
Observability: duplicate key rate (alert > 0), sequence gap rate (tăng đột biến), clock offset p99, insert p99 theo số row, buffer pool hit rate.
Migration: thêm cột, dual-write, backfill theo batch, đối soát, cutover đọc, rồi ngừng ghi ID cũ. Giữ rollback một chu kỳ; nếu ID đã lộ, đó là thay đổi contract.
Security: ID tuần tự cho phép enumeration; UUIDv7 lộ thời điểm tạo và volume. Cả hai không thay thế authorization.
Framework quyết định
- ID lộ ra ngoài? Nếu enumeration hoặc timestamp leak là rủi ro → opaque/dual ID; authorization vẫn kiểm tra độc lập.
- ID phải được biết trước khi request tới server? Có → client-generated.
- Nếu không, ID phải có trước khi ghi row + outbox? Có → application- hoặc database-generated đều được; đánh giá transaction boundary. Retry an toàn vẫn cần
request_idriêng. - Có thành phần phụ thuộc thứ tự commit? Có → dùng nguồn commit order phù hợp, không dùng ID.
- Write rate × index count × working set có nằm trong budget 16 byte? Không → 8 byte hoặc dual ID.
- Vận hành được worker ID + clock discipline? Không → Snowflake không phù hợp.
- Có yêu cầu gapless/pháp lý? Có → tách legal number; chấp nhận single writer.
Tín hiệu xem lại: insert p99 tăng theo số row; buffer pool miss tăng khi working set không đổi; duplicate-key alert; clock offset alert lặp; một shard p99 cao bất thường.
ADR mẫu
Bối cảnh. 50k writes/s, ba region active-active, outbox, ID lộ API, 100M row/năm. Ràng buộc: ID trước khi ghi row + outbox; client cần ID trước request.
Quyết định. UUIDv7 làm PK, sinh ở client khi cần; request_id riêng làm khoá idempotency; unique constraint cả hai, enforce trong phạm vi region ghi.
Loại bỏ. Sequence: cần allocator toàn cục hoặc range partitioning qua ba region; nếu chỉ một region, đây là lựa chọn rẻ hơn. Snowflake: khả thi với worker ID theo region, nhưng cần registry và clock discipline chưa có. UUIDv4: locality kém hơn; chưa có benchmark nên đây là lý do định tính.
Hệ quả. Index tăng ~4 GB ở 100M row (InnoDB); chấp nhận để đổi lấy không cần allocator, và chấp nhận lộ thời điểm tạo. PK uniqueness dựa vào entropy; request_id chỉ idempotent trong phạm vi region enforce. Retry xuyên region cần cơ chế nhận diện và hội tụ riêng.
Xem lại khi. Index budget hoặc p99 vượt ngưỡng đội đặt; timestamp leak thành vấn đề; đã có worker registry đáng tin. Ngưỡng 500M row là quy ước của đội, không phải giới hạn phổ quát.
Kết luận
ID strategy là quyết định về coordination, transaction và vận hành.
Sequence phù hợp khi database cấp ID và không cần định danh trước request. Snowflake khi 8 byte và thứ tự gần thời gian quan trọng, đồng thời vận hành được worker và clock. UUIDv4 ưu tiên opaque. UUIDv7 cho ID trước request mà không cần allocator.
Không có mặc định cho mọi hệ. Nếu ID phải có trước khi request tới server, client-generated giúp; nếu chỉ cần trước commit, application hoặc database-generated cũng đủ. Idempotency vẫn cần khoá ổn định, payload check và unique constraint — ID không thay thế transaction protocol.
Thứ tự ID không phải thứ tự commit. Câu hỏi đúng: đặt coordination ở đâu, và đội sẵn sàng trả giá nào?