Ở bài trước, bàn về Entity, chúng ta đã có cái nhìn tương đối về chúng từ vai trò trách nhiệm trong domain cho tới cấu trúc nội tại. Ở đó, tôi đã cố tình bỏ ngỏ một thành phần dù đã có hẳn một đoạn nói về: Identity hay ID cho ngắn gọn. Bài viết này, chúng ta sẽ đi sâu vào tìm hiểu ID ở những khía cạnh sâu hơn:
- Tại sao lại cần ID?
- Dữ liệu nào có thể sử dụng làm ID?
- ID được sinh ra khi nào?
- Thiết kế ID.
(Edit: Mình cập nhật lại giá trị xác suất trùng khi sinh ngẫu nhiên. Mình xin lỗi vì bản trước tính toán nhầm.)
Tại sao ID lại quan trọng?
Phần này bạn có thể tham khảo lại đoạn đầu trong bài viết trước, bàn về Entity.
Ở đây, tôi sẽ liệt kê ra một số điểm chính: Khi nào cần quản lý một đối tượng domain, ta cần gán cho nó một ID, nhờ đó ta có thể:
- Phân biệt được đối tượng đó với những đối tượng khác.
- Quản lý được vòng đời của đối tượng, qua đó đảm được tính liên tục trong hoạt động domain.
- Phục vụ hoạt động nghiệp vụ.
Những gì có thể tạo nên ID?
Những gì có thể sử dụng làm ID? Hay nói cách khác, ta dùng Number hay String để đại diện cho giá trị của ID? Cùng xem lại những lựa chọn mà chúng ta thường cân nhắc và sử dụng.
Dãy số tăng dần
Đây là một lựa chọn thường gặp. Cơ chế sinh giá trị được ủy quyền cho database bằng cách đặt ID là một giá trị tự động tăng hoặc lấy tường minh từ sequence.


Đây là cách sử dụng ID phổ biến tại các dự án. ID dạng này có những đặc điểm sau:
- Đơn giản, dễ triển khai. Ứng dụng không cần triển khai cơ chế sinh ra dữ liệu. Khi sử dụng database sequence, có phức tạp hơn vì phải truy vấn lên. Dù vậy, nói chung cách này là đơn giản.
- Dễ nhớ, dễ trao đổi. Với nhiều trường hợp, dãy số là đủ nhỏ nên mang lại tính hữu ích khi xem dữ liệu thủ công, trong báo cáo hoặc phân tích dữ liệu.
- Có hiệu suất cao, vì:
- Thường là các số nguyên (INT, BIGINT) nên key nhỏ gọn, có thể xử lý nhanh chóng.
- Tiết kiệm bộ nhớ.
- Hiệu quả với cơ chế indexing.
- Thứ tự tăng dần của dữ liệu cũng giúp duy trì cấu trúc dữ liệu hiệu quả trong bộ nhớ và lưu trữ
- Nhược điểm:
- Rò rỉ thông tin. Khóa tự tăng có thể dễ dàng bị đoán trước. Có thể đoán được tổng số dữ liệu, bản ghi tiếp theo,...
- Không mang ý nghĩa nghiệp vụ. Chỉ đơn giản là một con số và không cung cấp bất kỳ thông tin nào mà nó đại diện. Điều này có thể gây khó khăn cho việc theo dõi và phân tích dữ liệu nếu chỉ dựa vào ID. Ví dụ, "12345" khác hẳn với "USR-12345".
- Xung đột khi mở rộng. Trong doanh nghiệp, cùng một loại thực thể có thể tồn tại ở nhiều database khác nhau, ví dụ như User. Tại mỗi database đó, khóa tự tăng đảm bảo tính duy nhất nhưng khi cần tích hợp nhiều database lại, tính duy nhất đó không còn được đảm bảo.
- Khó kiểm soát và khó tái sử dụng. Ứng dụng bị phụ thuộc vào database. Những thực thể bị xóa hoặc invalid không còn sử dụng thì ID của chúng khó được sử dụng để cấp phát cho những thực thể mới gây lãng phí và dữ liệu không liền mạch.
- Giới hạn kích thước. Đôi khi sử dụng INT không đủ và ta cần phải chuyển lên BIGINT.
Có tiền tố
Một dạng nữa khi sử dụng dãy số tăng dần là dùng tiền tố phía trước. Ví dụ rõ nhất là số điện thoại: 097920ABDE thì 3 số đầu tiên quy định nhà mạng. Hay một ví dụ khác là "USR-123456", tiến tố "USR" quy định đây là ID của User, hậu tố "12345" là số sinh theo dạng tự tăng.
Dạng ID này giải quyết một số nhược điểm của dạng thuần số nhưng cũng mang lại một số nhược điểm.
Giá trị ngẫu nhiên
Trong những năm gần đây, giá trị ID ngẫu nhiên được sử dụng nhiều hơn do dữ liệu tăng trưởng lớn, kiến trúc phân tán được áp dụng rộng rãi. Tùy theo phạm vi dữ liệu mà ta có những cách sinh giá trị khác nhau để giảm thiểu rủi ro trùng lặp. Xác suất trùng được tính dựa trên công thức ở Bài toán sinh nhật (Birthday Problem) các thông tin { Số lượng dữ liệu sinh ra trong một khoảng thời gian, Tổng số giá trị có thể }.
Khi bạn thấy rằng dữ liệu về một loại thực thể nào đó đủ nhỏ, bạn có thể yên tâm về cách lựa chọn sinh ngẫu nhiên một số đơn giản? Chú ý rằng nếu chỉ sử dụng thuần túy số ngẫu nhiên thì theo thời gian, xác suất trùng lặp sẽ tăng dần. Do đó, để đảm bảo xác suất trùng lặp mỗi ngày như nhau, ta nên đặt trước dãy số này giá trị chỉ ngày tháng năm.
Ví dụ, với 1M (một triệu) ID cần sinh mỗi ngày, xác suất trùng lặp theo từng cơ chế như dưới đây.
Ngày thứ | 1M - 4 Byte | 1M - 8 Byte | 10M - 4 Byte | 10M - 8 Byte |
1 | 100% | 0.00000271% | 100% | 0.000271% |
100 | 100% | 0.0271% | 100% | 2.67% |
1000 | 100% | 2.67% | 100% | 93.33% |
3000 | 100% | 21.64% | 100% | 99.999999997% |
Từ bảng trên ta thấy rằng:
- Với lượng dữ liệu tăng trưởng khoảng 1 triệu/ngày, ngẫu nhiên trên số 8 Byte là không đủ tốt ở ngày thứ 1000 (tới ngày đó, giá trị tích lũy là 1000 x 1,000,000 = 1 tỷ). Cũng theo công thức tính, xác suất trùng lên tới 1% tại ngày thứ 609, nghĩa là sau gần 2 năm!
- Với lượng dữ liệu cực lớn, khoảng 10 triệu/ngày, không gian 8 Byte là không đủ. Tới ngày thứ 61, tỉ lệ trùng có thể đạt tới 1%.
Giờ ta xem vào 2 cơ chế sinh ID thường gặp hiện nay: UUID và Snowflake.
UUID v4 (128 bit): thuật toán hoàn toàn ngẫu nhiên không dựa theo thời gian.
Snowflake 64 bit thời gian:
- Với 41 bit thời gian, ta có tối đa 69.73 năm.
- Với 10 bit worker, ta có tối đa 1024 worker.
- Với 12 bit sequence, ta có tối đa 4096 giá trị. Đây là số giá trị có thể sinh trong cùng 1 mili seconds tại 1 worker.
- Vậy ta có, tối đa 4,194,304 giá trị / mili giây.
- ID trùng xảy nếu: cùng timestamp + cùng worker + sequence bị lặp.
Ta sẽ lựa chọn 20 workers - là một giá trị điển hình cho một dịch vụ tương đối bận rộn. Với 1 triệu bản ghi/ngày:
- Tổng số bản ghi tối đa mỡi mili giây: 20 x 4096 = 81,920.
- Tốc độ trung bình thực tế: 1 triệu/86,400,000 ~ 0.01157 bản ghi/mili giây.
- Chia đều cho 20 workers: 0.01157/20 ~ 0.0005785 bản ghi/mili giây/worker.
- Xác suất trùng được "reset" sau mỗi mili giây.
- Thay vì dùng công thức Birthday, ta dùng Poisson:
- Đầu tiên, dùng Poisson tính xác suất xảy ra có k dữ liệu sinh ra trong một mili giây với lamda = 0.0005785 bản ghi/mili giây. Ở đây, xet k từ 0 đến 4096.
- Sau đó, dùng Birthday để tính xác suất trùng của k dữ liệu đó trong không gian 4096 giá trị.
Ngày thứ | 1M - UUID v4 | 1M - Snowflake | 10M - UUID v4 | 10M - Snowflake |
1 | ~0% | ~0% | ~0% | ~0% |
100 | ~0% | ~0% | ~0% | ~0% |
1000 | ~0% | ~0% | ~0% | ~0% |
3000 | ~0% | ~0% | ~0% | ~0% |
Với UUID v4, trong 128 bit có 122 bit sinh ngẫu nhiên. Xác suất trùng đạt ngưỡng 1% vào khoảng ngày thứ 10 nghìn tỷ khi trung bình 1 triệu bản ghi / ngày.
Với Snowflake, để xác suất trùng đạt ngưỡng 1% thì lượng dữ liệu cần khoảng 6.65 tỷ bản ghi/ngày (giả sử phân phối đều) với 20 workers.
Định danh bên ngoài
Một trường hợp lựa chọn ID nữa là lấy từ bên ngoài.
- Hệ thống quản lý nhân sự lấy chính số CCCD làm ID nội bộ.
- Hệ thống quản lý người dùng subscribe chủ đề qua email hay số điện thoại.
- Hoặc, dữ liệu submit từ form tạo mới của người dùng bao gồm một số thông tin mang tính duy nhất.
Tuy nhiên, cách này thường không được sử dụng khi dữ liệu một loại thực thể nào đó có tốc độ tăng trưởng cao như ví dụ trên của chúng ta. Trong những trường hợp đó, sinh ngẫu nhiên là một giải pháp tốt.
ID được sinh ra khi nào?
Thời điểm sinh ID tưởng như không cần bàn tới mà lại cực kỳ quan trọng với DDD.
DDD thường chia Domain thành các Subdomain và tiếp tục chia thành các Bounded Context, do vậy mà nó phù hợp một cách tự nhiên với kiến trúc Microservices. Nếu Entity sinh ra và được lưu vào database và kết thúc use case, ta có thể lựa chọn cách sinh ID muộn. Nhưng với hệ thống phân tán như Microservices, một Entity sinh ra hay làm điều gì đó cần thông báo ra bên ngoài để Microservice khác dựa vào đó để làm việc của mình. Đó là Domain Events. Khi đó, ID cần được bổ sung vào Domain Events để xác định đó là Entity nào. Vậy ta cùng xem cách xử lý điều này khi ID được sinh ra sau và trước khi lưu dữ liệu.
ID được tạo sau
Xem lại hình 1 và hình 2 bên trên ta thấy, ID được sinh ra sau khi dữ liệu được insert vào database. Xuất hiện 2 tình huống nghiệp vụ:
Nếu save data xong là xong
User constructor:

Use case tạo User:

Trong hình 3 ta thấy rằng đối tượng User mới được tạo ra thành công và được lưu xuống database. Rõ ràng là user được tạo ra không hề có ID. Điều này là phù hợp nếu nhu cầu nghiệp vụ của ta không cần tới ID.
Ngược lại, nếu cần thì sao? Cùng đi vào phần sau.
Nếu cần ID để tạo Domain Events
Để có ID làm việc khác, ta sẽ có nhiều việc cần làm hơn nếu sử dụng cơ chế tạo ID như hình 1 và 2: Insert xong data đồng thời phải lấy về data vừa insert đó. Điều này khiến ta phải thay đổi chữ ký hàm UserRepository.save(User):
User save(User user);
Thay vì void, giờ nó trả về User với ID vừa tạo.
Code tại use case trông sẽ thế này:

Hoặc, nếu không muốn thay đổi chữ ký hàm, ta cần cập nhật chính đối tượng User truyền vào:

Ở hình 5 và hình 6, ta tạm thời bỏ qua cách tạo và publish Domain Event vì có những cách làm tốt hơn sau này sẽ bàn tới. Điểm nhấn là:
- Thông thường, kết thúc một use case, các hàm giao tiếp với database mới được gọi. Tức là lời gọi userRepository.save() nên để cuối hàm.
- Việc lời gọi này đặt giữa hàm sau đó tiếp tục làm những việc khác gây ra sự bối rối, khó hiểu.
Cùng xem kỹ hơn xử lý của UserRepository.save(User):

Đoạn code trong hình 7 cho thấy cách hoạt động cơ bản nhất để insert và select về ID vừa tạo trong database. Cách này ta có thể sử dụng khi không có framework hỗ trợ và quan trọng hơn: truy vấn bằng thông tin mang tính duy nhất, trường hợp này là số CCCD. Nếu không có thông tin duy nhất này, ta có thể sử dụng tính năng cung cấp bởi hệ quản trị cơ sở dữ liệu như sau:
- MySQL có function LAST_INSERT_ID()
- PostgreSQL có mệnh đề RETURNING
Cách sử dụng như sau:

Câu hỏi đặt ra: Liệu có đảm bảo chính xác ID lấy về là của dữ liệu vừa insert không? Trả lời: Với MySQL, đảm bảo cùng transaction là được, còn với PostgreSQL luôn đảm bảo vì RETURNING nằm trong mệnh đề INSERT.
Trông có vẻ cồng kềnh nhưng là điều phải làm khi ta không muốn sử dụng framework. Giờ thử một số cách khác tiện dụng hơn.

Với JPA, ta có thể return luôn "savedUser", đối với KeyHolder, ta cần set ngược lại cho User đầu vào hoặc tạo một User mới. Hai cách này đều giúp ta giảm việc soạn thảo mệnh đề SELECT. Về bản chất, Hibernate và JdbcTemplate đều sử dụng PreparedStatement.getGeneratedKeys() ở đằng sau hậu trường.
Giờ ta đi tới một phát biểu, tôi cũng không biết có nên đặt nó làm nguyên tắc hay không nhưng cứ tạm gọi là thế vì gần như 100% trường hợp, tôi đều làm như vậy:
Nguyên tắc: ID là phần cốt lõi của Entity (Aggregate), cần được xác định ngay trong Domain, nơi nó vừa được tạo ra.
Do đó, ta sẽ đi tới cách tạo ID trước khi lưu dữ liệu.
ID được tạo trước
Phiên bản mới của use case tạo mới User:

So sánh Hình 10 và Hình 5, vẫn 4 câu lệnh đó nhưng sự thay đổi là rất lớn:
- Code điều phối (code của use case) chia thành hai nhóm: nhóm trên phụ trách nghiệp vụ, nhóm dưới phụ trách truyền thông tin ra thế giới bên ngoài (messaging, database).
- User được tạo bởi constructor được dùng ngay cho biên soạn Domain Event, nghĩa là nó đã có ID.
- User được tạo có ngay ID khẳng định: Tôi được tạo ra và tôi có ngay định danh. Và như vậy, constructor này là điểm đến tin cậy cho việc tạo User. Giả sử cơ chế validate User nằm trong lời gọi này, đây sẽ là điểm tin cậy hoàn toàn cho lời gọi tạo mới User: Đã lấy về được instance ở đây là save được vào database.
Constructor này tạo ID như thế nào?
Giờ xem lại phần trước, bạn thấy ta có mấy cách tạo ID ngoài giá trị tự tăng trong database:
- Database sequence. Nó khác với ID tự tăng về cách sử dụng tại ứng dụng.
- Sinh ngẫu nhiên.
- Lấy tự kho ID bên ngoài.
Ta cùng kết hợp các thông tin này để so sánh với nhau.

Hình 11 cho thấy hai cách đưa ID vào User khác nhau: Cách 1: UUID được tạo trong bản thân constructor của User, cách 2: Tạo ID trước và truyền vào để khởi tạo User.
Tuy đều đạt được đích cuối cùng là tạo User với ID ngẫu nhiên từ ứng dụng nhưng chúng cho ta những tình huống lựa chọn khác nhau:
- Đầu tiên, đặt việc tạo ID vào trong User, ta có tính đóng gói tốt hơn, người dùng không cần quan tâm ID được tạo như thế nào, họ chỉ đưa ra yêu cầu và có User mới trả về. Mặc dù vậy, họ lại không có lựa chọn về cách sinh ID. Giả sử sau này, thay vì dùng UUID, họ muốn ID được sinh với dãy ký tự ngắn gọn hơn thì sao? Họ buộc phải sửa code User hoặc nếu điều đó thuộc trách nhiệm của team khác thì họ sẽ cần đưa ra yêu cầu.
- Tạo ID bên ngoài hàm constructor. Tính đóng gói giảm đi nhưng client lại có nhiều sự lựa chọn hơn. Họ có thể thay đổi thuạt toán tạo ID, có thể lấy ID từ bên ngoài, ...
- Dù sao, cả hai cách này đều mang đến hiệu năng cao hơn nhiều so với việc sử dụng ID do database sinh ra (dãy số tự tăng hay sequence) nhờ các cơ chế sinh ngẫu nhiên. Điều này còn đặc biệt hơn nữa trong hệ thống phân tán như Microservices.
Giờ ta sẽ tăng lên cấp độ nữa cho việc sinh ID. Bạn có thấy câu lệnh: UUID userId = UUID.randomUUID()
là quá tường minh không? Nếu tôi thay nó bằng:
UserId userId = idFactory.create();
thì bạn có thấy ta có thêm một bậc tự do nữa không?

Lúc này, domain chỉ cần biết ID của User là UserId, còn bên trong nó là định dạng gì, giá trị được sinh ra thế nào hoàn toàn ủy quyền cho IdFactory. Qua đó, ta có thể thay thế thuật toán sinh ID UUIDFactory bằng phiên bản StringHashedFactory mà không ảnh hưởng tới lõi nghiệp vụ, thậm chí ta còn có thể chuyển sang cơ chế Snowflake như mục trên. Bên trong UUIDFactory.create() có thể đơn thuần là một lời gọi: return UUID.randomUUID() hoặc nếu bạn muốn, bạn có thể cài đặt một thuật toán tạo ID của riêng bạn.
Cùng cách thức, giờ ta có thể chuyển sang cách sử dụng ID ở kho bên ngoài dễ dàng.
Giả sử, ta muốn sinh ID từ database sequence hay một kho lưu trữ lượng ID hữu hạn ở đâu đó (giả sử trong database). Mỗi lời gọi vào kho, kho sẽ trả về ID tiếp theo được sử dụng.
Code của ta chuyển thành:

Hình 13 cho thấy sự thay thế đơn giản, class SequenceFactory thực hiện lời gọi xuống database và lấy về giá trị tiếp theo ở lời gọi idFactory.create()
. Bạn có thể thay thế cách thức triển khai bởi bất kỳ lời gọi nào từ Database cho tới HTTP, ...
Ở trên, tôi không sử dụng cơ chế Dependency Injection cho IdFactory, nếu bạn muốn bạn có thể. Lưu ý rằng, càng nhiều bậc tự do, việc khởi tạo đối tượng càng phức tạp.
Ở đây, User constructor đã đánh đổi tính đóng gói với sự tự do, linh hoạt bằng cách thêm hay bớt tham số Id. Tùy vào hoàn cảnh, bạn hãy lựa chọn cách thức phù hợp cho ứng dụng của mình.
Còn một điểm nữa mà idFactory.create() không làm được: Ta muốn tạo ID dựa trên giá trị truyền vào, ví dụ như: idFactory.create(cccd); => cách tạo này thông thường dành cho thông tin đầu vào đã mang ý nghĩa là ID, nó là một Natual Key hay Key đơn giản, như đề cập trong bài viết này. ID mà hàm create(cccd) tạo ra sẽ là unique nếu như cccd là unique, và hai giá trị cccd giống nhau sẽ sinh ra hai ID giống nhau. ID lúc này là một Surrogate Key.
Trường hợp này, nếu còn muốn sử dụng IdFactory, ta có thể đẩy create() lên mức tổng quát cao hơn: create(IdCreationInput) với IdCreationInput là một abstract class.
Thiết kế ID
Tới đây, chúng ta đã hiểu gần như toàn bộ những điểm quan trọng nhất khi làm việc với ID của Entity. Chúng ta tổng kết lại thành một định hướng:
- Lựa chọn kiểu dữ liệu của ID và thuật toán sinh ra chúng.
- Thiết kế class Id, như UserId trong ví dụ trên. Id cần thiết kế là môt Value Object, như đề cập trong bài viết này.
- Lựa chọn thời điểm sinh ID cũng như vị trí sử dụng.