UUID vs ULID vs serial IDs: tìm hiểu cách mỗi loại ảnh hưởng đến lập chỉ mục, sắp xếp, sharding và quy trình xuất/nhập dữ liệu trong dự án thực tế.

Việc chọn ID có vẻ nhàm chán trong tuần đầu. Rồi bạn ra mắt, dữ liệu lớn dần, và quyết định "đơn giản" đó xuất hiện ở khắp nơi: index, URL, log, export và tích hợp.
Câu hỏi thực sự không phải là "cái nào tốt nhất?" mà là "bạn muốn tránh nỗi đau nào sau này?" ID khó thay đổi vì chúng bị sao chép sang các bảng khác, được cache bởi client, và được các hệ thống khác phụ thuộc.
Khi ID không phù hợp với cách sản phẩm phát triển, bạn thường thấy nó ở vài chỗ:
Luôn có đánh đổi giữa tiện lợi hiện tại và linh hoạt sau này. Serial integer dễ đọc và thường nhanh, nhưng có thể lộ số lượng bản ghi và làm việc ghép dữ liệu phức tạp hơn. UUID ngẫu nhiên tuyệt vời để đảm bảo duy nhất giữa các hệ thống, nhưng gây hại cho index và khó đọc với con người. ULID cố gắng vừa duy nhất toàn cầu vừa có thứ tự gần theo thời gian, nhưng vẫn có chi phí lưu trữ và công cụ.
Một cách suy nghĩ hữu dụng: ID chủ yếu dành cho ai?
Nếu ID chủ yếu cho con người (support, debug, ops), ngắn và dễ quét thường thắng. Nếu dành cho máy (ghi phân tán, client offline, hệ thống đa vùng), tính duy nhất toàn cục và tránh va chạm quan trọng hơn.
Khi người ta tranh luận "UUID vs ULID vs serial IDs," họ thực sự chọn cách mỗi hàng được gán một nhãn duy nhất. Nhãn đó ảnh hưởng đến việc chèn, sắp xếp, ghép và di chuyển dữ liệu sau này.
Serial ID là một bộ đếm. Cơ sở dữ liệu cấp 1, sau đó 2, rồi 3… (thường lưu là integer hoặc bigint). Dễ đọc, rẻ để lưu và thường nhanh vì hàng mới nằm ở cuối index.
UUID là định danh 128-bit trông ngẫu nhiên, như 3f8a.... Trong nhiều cấu hình, nó có thể sinh mà không cần hỏi cơ sở dữ liệu cho số tiếp theo, nên các hệ thống khác nhau có thể tạo ID độc lập. Đổi lại, các insert trông ngẫu nhiên khiến index phải làm việc nhiều hơn và chiếm nhiều không gian hơn bigint.
ULID cũng là 128-bit, nhưng thiết kế để gần như sắp xếp theo thời gian. ULID mới hơn thường được sắp sau ULID cũ hơn, đồng thời vẫn duy trì tính duy nhất toàn cục. Bạn thường được lợi ích "sinh ở bất cứ đâu" như UUID nhưng có hành vi sắp xếp thân thiện hơn.
Tóm tắt đơn giản:
Serial phổ biến cho ứng dụng một database và công cụ nội bộ. UUID xuất hiện khi dữ liệu được tạo trên nhiều dịch vụ, thiết bị hoặc vùng. ULID phổ biến khi đội muốn sinh ID phân tán nhưng vẫn quan tâm đến thứ tự sắp xếp, phân trang hoặc truy vấn "mới nhất trước".
Khóa chính thường được hỗ trợ bởi một index (thường là B-tree). Hãy nghĩ về index như một danh bạ điện thoại đã được sắp xếp: mỗi hàng mới cần một mục được đặt vào đúng chỗ để việc tìm kiếm nhanh.
Với ID ngẫu nhiên (UUIDv4 kinh điển), mục mới rơi khắp index. Điều đó nghĩa là cơ sở dữ liệu phải chạm nhiều trang index, chia trang thường xuyên hơn và ghi thêm. Theo thời gian bạn có nhiều churn trên index: nhiều công việc cho mỗi insert, nhiều cache miss và index lớn hơn mong đợi.
Với ID gần như tăng dần (serial/bigint, hoặc ID theo thời gian như nhiều ULID), cơ sở dữ liệu thường có thể append mục mới gần cuối index. Điều này thân thiện với cache vì các trang gần đây vẫn nóng, và insert trơn tru hơn ở tốc độ ghi cao.
Kích thước khóa quan trọng vì mục index không miễn phí:
Khóa lớn hơn nghĩa là ít mục hơn vừa một trang index. Điều này thường dẫn đến index sâu hơn, nhiều trang phải đọc cho một truy vấn và cần nhiều RAM hơn để giữ tốc độ.
Nếu bạn có một bảng "events" với chèn liên tục, khóa chính UUID ngẫu nhiên có thể cảm thấy chậm hơn bigint sớm hơn, ngay cả khi lookup một hàng vẫn mượt. Nếu bạn dự kiến nhiều ghi, chi phí lập chỉ mục thường là khác biệt thực sự đầu tiên bạn nhận ra.
Nếu bạn đã xây "Load more" hoặc infinite scroll, bạn đã thấy nỗi đau của ID không sắp xếp tốt. Một ID "sắp xếp tốt" khi order theo nó cho bạn thứ tự ổn định, có ý nghĩa (thường là thời gian tạo) để phân trang dự đoán được.
Với ID ngẫu nhiên (như UUIDv4), hàng mới bị phân tán. Sắp xếp theo id không khớp thời gian, và phân trang cursor kiểu "cho tôi các mục sau id này" trở nên không đáng tin. Thường bạn phải quay sang created_at, điều đó ổn nhưng cần cẩn thận.
ULID được thiết kế để gần như sắp xếp theo thời gian. Nếu bạn sắp xếp theo ULID (dưới dạng chuỗi hoặc dạng binary), các mục mới thường đến sau các mục cũ hơn. Điều này làm cho phân trang cursor đơn giản hơn vì cursor có thể là ULID cuối cùng nhìn thấy.
ULID giúp thứ tự dạng thời gian cho feed, cursor đơn giản hơn và giảm chèn ngẫu nhiên so với UUIDv4.
Nhưng ULID không đảm bảo thứ tự thời gian chính xác khi nhiều ID được sinh trong cùng millisecond trên nhiều máy. Nếu bạn cần thứ tự chính xác, vẫn nên dùng một timestamp thực sự.
created_at vẫn tốt hơnSắp xếp theo created_at thường an toàn hơn khi bạn backfill dữ liệu, import bản ghi lịch sử hoặc cần tie-break rõ ràng.
Một mẫu thực tế là order theo (created_at, id), trong đó id chỉ là tie-breaker.
Sharding nghĩa là chia một cơ sở dữ liệu thành nhiều cơ sở nhỏ hơn để mỗi shard chứa một phần dữ liệu. Teams thường làm điều này sau, khi một database đơn khó mở rộng hoặc trở thành điểm lỗi duy nhất.
Lựa chọn ID có thể làm sharding trở nên dễ quản lý hoặc đau đầu.
Với ID tuần tự (auto-increment serial hoặc bigint), mỗi shard sẽ vui vẻ sinh 1, 2, 3.... Cùng một ID có thể tồn tại trên nhiều shard. Lần đầu bạn cần gộp dữ liệu, di chuyển hàng, hoặc xây tính năng cross-shard, bạn sẽ gặp va chạm.
Bạn có thể tránh va chạm bằng phối hợp (dịch vụ ID trung tâm, hoặc range cho mỗi shard), nhưng điều đó thêm thành phần vận hành và có thể thành cổ chai.
UUID và ULID giảm nhu cầu phối hợp vì mỗi shard có thể sinh ID độc lập với rủi ro trùng lặp cực thấp. Nếu bạn nghĩ sẽ tách dữ liệu qua nhiều DB, đây là một trong những lý do mạnh mẽ phản đối chuỗi thuần túy.
Một thỏa hiệp phổ biến là thêm tiền tố shard rồi dùng sequence cục bộ trên mỗi shard. Bạn có thể lưu hai cột, hoặc đóng gói vào một giá trị.
Nó hoạt động, nhưng tạo định dạng ID tùy chỉnh. Mọi tích hợp phải hiểu nó, sắp xếp ngừng mang ý nghĩa thời gian toàn cục nếu không có logic thêm, và di chuyển dữ liệu giữa các shard có thể yêu cầu viết lại ID (làm hỏng các tham chiếu nếu ID đó được chia sẻ).
Hỏi một câu sớm: bạn có bao giờ cần kết hợp dữ liệu từ nhiều DB và giữ tham chiếu ổn định? Nếu có, hãy lên kế hoạch cho ID toàn cục từ ngày đầu, hoặc dự trù chi phí cho migration sau này.
Export/import là nơi lựa chọn ID ngừng là lý thuyết. Ngay khi bạn clone prod sang staging, khôi phục backup hoặc gộp dữ liệu từ hai hệ thống, bạn sẽ biết ID của mình có ổn định và di động hay không.
Với serial (auto-increment), bạn thường không thể replay insert vào DB khác và mong tham chiếu còn nguyên trừ khi giữ nguyên các số. Nếu bạn import chỉ một phần hàng (ví dụ 200 khách hàng và đơn hàng của họ), bạn phải load các bảng theo thứ tự đúng và giữ nguyên primary key. Nếu bất cứ thứ gì bị đánh số lại, foreign key sẽ vỡ.
UUID và ULID được sinh bên ngoài sequence của DB, nên dễ di chuyển giữa môi trường hơn. Bạn có thể copy hàng, giữ ID và quan hệ vẫn khớp. Điều này hữu ích khi khôi phục backup, export một phần, hoặc gộp dataset.
Ví dụ: xuất 50 account từ production để debug trên staging. Với UUID/ULID làm khóa chính, bạn có thể import những account đó cùng các hàng liên quan (project, invoice, log) và mọi thứ vẫn trỏ đúng parent. Với serial ID, bạn thường phải dựng bảng dịch (old_id -> new_id) và viết lại foreign key khi import.
Với import hàng loạt, những điều cơ bản quan trọng hơn loại ID:
Bạn có thể quyết định vững vàng nhanh nếu tập trung vào điều gì sẽ gây đau sau này.
Ghi ra rủi ro tương lai hàng đầu. Những sự kiện cụ thể giúp: tách thành nhiều DB, gộp dữ liệu từ hệ thống khác, ghi offline, sao chép dữ liệu giữa môi trường.
Quyết xem ID có phải cần khớp thứ tự thời gian không. Nếu muốn "mới nhất trước" mà không cần cột khác, ULID (hoặc ID sắp xếp theo thời gian khác) là phù hợp. Nếu bạn chấp nhận sắp xếp theo created_at, UUID và serial đều ổn.
Ước lượng lượng ghi và độ nhạy index. Nếu mong nhiều insert và chỉ mục khóa chính bị băm, serial BIGINT thường nhẹ nhàng với B-tree. UUID ngẫu nhiên gây nhiều churn hơn.
Chọn một mặc định, rồi ghi lại ngoại lệ. Giữ đơn giản: một mặc định cho hầu hết bảng, và quy tắc rõ ràng khi bạn thoát (thường: ID công khai vs ID nội bộ).
Để chỗ thay đổi. Tránh mã hóa ý nghĩa vào ID, quyết nơi sinh ID (DB hay app), và giữ constraint rõ ràng.
Cái bẫy lớn nhất là chọn ID vì nó phổ biến, rồi phát hiện nó mâu thuẫn với cách bạn truy vấn, scale hoặc chia sẻ dữ liệu. Hầu hết vấn đề xuất hiện sau vài tháng.
Các thất bại thường gặp:
123, 124, 125, người ta có thể đoán các bản ghi gần kề và quét hệ thống.Dấu hiệu cảnh báo bạn nên xử lý sớm:
Chọn một kiểu khóa chính và giữ nó cho hầu hết bảng. Trộn kiểu (bigint ở chỗ này, UUID ở chỗ kia) làm cho join, API và migration khó hơn.
Ước tính kích thước index ở quy mô dự kiến. Khóa rộng hơn nghĩa là primary index lớn hơn và tốn IO/RAM.
Quyết cách phân trang. Nếu phân trang theo ID, đảm bảo ID có thứ tự dự đoán (hoặc chấp nhận rằng nó không có). Nếu phân trang theo timestamp, index created_at và dùng nhất quán.
Thử kế hoạch import trên dữ liệu giống production. Xác minh bạn có thể tái tạo bản ghi mà không phá foreign key và re-import không âm thầm sinh ID mới.
Ghi ra chiến lược tránh va chạm. Ai sinh ID (DB hay app), và chuyện gì xảy ra nếu hai hệ thống tạo bản ghi offline rồi sync sau?
Đảm bảo URL công khai và log không lộ các mẫu bạn quan tâm (số lượng bản ghi, tốc độ tạo, dấu hiệu shard nội bộ). Nếu dùng serial, giả sử người ta có thể đoán ID gần kề.
Một founder đơn lẻ ra mắt CRM đơn giản: contacts, deals, notes. Một Postgres, một web app, mục tiêu chính là ra mắt.
Ban đầu, khóa chính serial bigint có vẻ hoàn hảo. Insert nhanh, index gọn, dễ đọc trong log.
Một năm sau, khách hàng yêu cầu export quý để kiểm toán, và founder bắt đầu import lead từ tool marketing. ID từng chỉ nội bộ giờ xuất hiện trong CSV, email và ticket. Nếu hai hệ thống đều dùng 1, 2, 3..., việc gộp trở nên lộn xộn. Bạn phải thêm cột nguồn, bảng mapping hoặc viết lại ID khi import.
Đến năm hai, có app di động cần tạo bản ghi offline rồi sync. Bạn cần ID có thể sinh trên client mà không hỏi DB và rủi ro va chạm thấp khi dữ liệu về nhiều môi trường.
Một thỏa hiệp thường bền:
Nếu phân vân giữa UUID, ULID và serial, quyết dựa trên cách dữ liệu di chuyển và tăng trưởng.
Một câu cho từng trường hợp phổ biến:
bigint serial làm khóa chính.Thường là trộn lẫn tốt nhất. Dùng serial bigint cho bảng nội bộ không rời DB (join table, job nền), và dùng UUID/ULID cho thực thể công khai như users, orgs, invoices và bất cứ thứ gì có thể export, sync hoặc tham chiếu từ dịch vụ khác.
Nếu bạn xây trên Koder.ai (koder.ai), đáng để quyết mẫu ID trước khi tạo nhiều bảng và API. Chế độ lập kế hoạch và snapshot/rollback của nền tảng giúp áp dụng và kiểm tra thay đổi schema sớm, khi hệ thống còn nhỏ và dễ chỉnh sửa.
Bắt đầu từ nỗi đau bạn muốn tránh trong tương lai: chèn chậm do ghi ngẫu nhiên vào chỉ mục, phân trang khó chịu, migration rủi ro, hoặc va chạm ID khi nhập/ghép dữ liệu. Nếu dữ liệu có khả năng di chuyển giữa các hệ thống hoặc được tạo ở nhiều nơi, ưu tiên ID toàn cục (UUID/ULID) và tách riêng vấn đề sắp xếp theo thời gian.
Serial bigint là lựa chọn tốt khi bạn chỉ có một cơ sở dữ liệu, lượng ghi lớn và ID chỉ dùng nội bộ. Nó nhỏ gọn, nhanh với chỉ mục B-tree và dễ đọc trong log. Nhược điểm chính là khó ghép dữ liệu sau này do va chạm ID và có thể lộ số lượng bản ghi nếu dùng công khai.
Chọn UUID khi bản ghi có thể được tạo ở nhiều dịch vụ, vùng, thiết bị hoặc client offline và bạn muốn rủi ro trùng lặp cực thấp mà không cần phối hợp. UUID cũng phù hợp cho ID hiển thị công khai vì khó đoán. Đổi lại, bạn sẽ có chỉ mục lớn hơn và chèn ngẫu nhiên hơn so với khóa tuần tự.
ULID hợp lý khi bạn muốn ID có thể sinh ở bất cứ đâu và đồng thời gần như sắp xếp theo thời gian. Điều này đơn giản hóa phân trang theo cursor và giảm bớt vấn đề chèn ngẫu nhiên của UUIDv4. Tuy nhiên ULID không phải là dấu thời gian chính xác; nếu cần thứ tự tuyệt đối, vẫn nên dùng created_at.
Có, đặc biệt với UUIDv4 ngẫu nhiên trên bảng ghi nhiều. Chèn ngẫu nhiên phân tán khắp chỉ mục chính dẫn đến nhiều page split, làm mất cache và khiến chỉ mục lớn dần. Thường bạn sẽ thấy tác động ở tỷ lệ chèn duy trì thấp hơn và nhu cầu IO/RAM cao hơn trước khi lookup đơn hàng chậm.
Sắp xếp theo một ID ngẫu nhiên (như UUIDv4) không khớp với thời gian tạo, nên các cursor “sau id này” không cho dòng thời gian ổn định. Cách giải là phân trang theo created_at và thêm ID làm tie-breaker, ví dụ (created_at, id). Nếu muốn phân trang chỉ bằng ID, ID có thể sắp xếp theo thời gian như ULID thường dễ hơn.
Serial sẽ va chạm qua các shard vì mỗi shard sinh 1, 2, 3... độc lập. Có thể tránh bằng phối hợp (range cho mỗi shard hoặc dịch vụ cấp ID), nhưng điều đó thêm phức tạp và có thể trở thành cổ chai. UUID/ULID giảm nhu cầu phối hợp vì mỗi shard có thể sinh ID an toàn riêng.
UUID/ULID an toàn hơn cho việc xuất, nhập và ghép dữ liệu vì bạn có thể sao chép hàng, giữ nguyên ID và các tham chiếu vẫn khớp. Với serial, nhập một phần thường yêu cầu bảng ánh xạ (old_id -> new_id) và viết lại foreign key — dễ sai. Nếu bạn thường sao chép môi trường hoặc ghép dataset, ID toàn cục tiết kiệm thời gian.
Mô hình phổ biến là có hai ID: một khóa chính nội bộ gọn (serial bigint) cho join và hiệu năng lưu trữ, và một ID công khai không đổi (ULID hoặc UUID) cho URL, API, xuất và tham chiếu giữa hệ thống. Điều quan trọng là coi ID công khai là bất biến và không tái sử dụng.
Lên kế hoạch sớm và áp dụng nhất quán cho bảng và API. Trong Koder.ai, quyết định chiến lược ID mặc định ở chế độ lập kế hoạch trước khi sinh nhiều schema và endpoint, rồi dùng snapshot/rollback để xác minh thay đổi khi dự án còn nhỏ. Khó nhất không phải là sinh ID mới — mà là cập nhật foreign key, cache, log và các tích hợp ngoài vẫn tham chiếu ID cũ.