← Tất cả bài viết

Chọn chiến lược sinh ID cho hệ thống phân tán

ID sinh ở đâu là quyết định coordination; idempotency đến từ một khóa yêu cầu ổn định, không từ UUIDv7. Và thứ tự ID không phải thứ tự commit.

12 phút đọcTuyên

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ầuCái giá
Định danhDuy nhất trong không gian và thời gianCoordination hoặc entropy
Tham chiếuNhỏ, cố định, so sánh nhanh64 bit lý tưởng; 128 bit là gấp đôi
Thứ tựDùng cho pagination, replay, debugPhụ thuộc clock hoặc allocator
Che giấuKhông lộ số lượng, thời điểm, topologyXung độ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ínhCơ chế bảo đảm
Sinh không trùngEntropy hoặc quy tắc phân bổ
Dữ liệu không trùngUnique constraint trong phạm vi database enforce
Xử lý conflictRetry, báo lỗi hoặc reconcile khi merge
IdempotencyKhoá ổn định, kiểm tra payload, unique constraint
Thứ tự gần theo thời gianLayout ID và chính sách clock
Thứ tự commitNguồ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.

ĐượcMất
4–8 byte, index locality tốt nhấtRound 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ộcRetry không idempotent nếu thiếu khoá ổn định
Sort theo thứ tự insert gần đúngEnumeration; 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.

ĐượcMất
Không coordination, sinh ở đâu cũng được16 byte, insert ngẫu nhiên trong B-tree
Opaque: không lộ thời gian, thứ tự, topologyKhông order; range scan theo thời gian đắt
Merge dữ liệu từ hệ độc lậpLog, URL, debug khó đọc
Làm khoá idempotency ổn địnhEntropy 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.

ĐượcMất
8 byte, sort theo ms, range scan tốtWorker ID phải duy nhất trong phạm vi khai báo
Không round trip, throughput caoClock lùi làm ID sai hoặc generator dừng
Locality tốt hơn UUIDv4, không bằng sequence đơn điệuLộ timestamp và worker (topology)
Có thể chia bit theo regionServerless, 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.

ĐượcMất
16 byte nhưng ordered theo msVẫn 16 byte; index đắt hơn 8 byte
Không coordination ở generatorTrong 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 18Thư 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

SequenceUUIDv4SnowflakeUUIDv7
CoordinationAllocator DBKhôngWorker registryKhông
8 byteCóKhôngCóKhông
Thứ tự gầnTrong một sequenceKhôngTheo msTheo ms
Lộ ra ngoàiEnumerableOpaqueTime, workerTime
IndexTốt nhấtKém nhấtTốtTốt, trừ biên phải
Điều kiện uniquenessAllocator/range + constraintCSPRNG + constraintWorker + clock + constraintClock + 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:

EnginePhần được tínhPK 8 bytePK 16 byteChênh lệch
InnoDBPK + 4 secondary index (PK lặp trong từng index)4,0 GB8,0 GB+4,0 GB
PostgreSQLHeap + PK index; secondary index dùng TID, không đổi1,6 GB3,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

  1. 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.
  2. ID phải được biết trước khi request tới server? Có → client-generated.
  3. 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_id riêng.
  4. 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.
  5. Write rate × index count × working set có nằm trong budget 16 byte? Không → 8 byte hoặc dual ID.
  6. Vận hành được worker ID + clock discipline? Không → Snowflake không phù hợp.
  7. 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?

References

Tuyên
NGƯỜI VIẾTTuyên

Một lập trình viên tò mò và cố gắng trở nên tốt hơn mỗi ngày.