BSON & ObjectId (P3/3): UUID và lựa chọn `_id`; so với PostgreSQL
Ở phần trước: Date là số mili giây tính từ 1970 theo UTC, và query chỉ so sánh giữa các giá trị cùng kiểu. ObjectId gồm 4 byte giây, 5 byte random và 3 byte counter, nên không nhất thiết đơn điệu giữa các process.
- Cần đọc trước: Date, thứ tự so sánh và ObjectId
- Dẫn tới: Data Modeling, bài tiếp theo. Phần này là phần cuối của bài BSON & ObjectId.
UUID và các lựa chọn khác cho _id
[Tài liệu] _id có thể mang giá trị thuộc bất kỳ kiểu BSON nào, trừ mảng, regex và undefined, miễn là duy nhất trong collection. Tài liệu còn cảnh báo riêng: đừng lưu regex trong _id để replication chạy đúng. [Quan sát] Trong lab, server từ chối mảng với lỗi The '_id' value cannot be of type array (regex cũng bị từ chối tương tự).
Các lựa chọn hay gặp (kích thước lấy từ bảng đo ở BSON, các kiểu và kiểu số):
| Lựa chọn | Kích thước giá trị | Ưu điểm | Nhược điểm |
|---|---|---|---|
| ObjectId | 12 byte | mặc định, client tự sinh, gần như tăng theo thời gian | lộ thời điểm tạo; hệ thống ngoài phải hiểu kiểu ObjectId |
UUID v4 dạng binData subtype 4 | 21 byte | chuẩn chung, sinh ở đâu cũng được, không đoán được | hoàn toàn ngẫu nhiên, không có thứ tự |
| UUID dạng chuỗi 36 ký tự | 41 byte | dễ đọc, dễ log | gần gấp đôi bản binary; dễ lẫn chữ hoa và thường |
| UUID v7 (timestamp ở đầu) dạng binary | 21 byte | chuẩn UUID mà vẫn tăng theo thời gian | phải sinh ở ứng dụng; vẫn lộ thời điểm tạo |
Khóa tự nhiên (mã đơn, tenantId:orderNo) | tùy | không cần thêm trường, tra cứu trực tiếp | _id không sửa được; khóa nghiệp vụ hay thay đổi hơn ta tưởng |
| Số tăng dần int64 | 8 byte | nhỏ, thứ tự rõ ràng | phải có nơi cấp số: thêm một round-trip, dễ thành điểm nghẽn |
Vài lưu ý:
- Lưu UUID dạng binary, không phải chuỗi.
UUID()trong mongosh tạobinDatasubtype 4, đúng chuẩn. Subtype 3 là định dạng UUID cũ ("legacy"), mỗi driver từng sắp byte theo một kiểu. Nếu gặp subtype 3 trong dữ liệu cũ, hãy kiểm tra cấu hình UUID representation của driver trước khi đọc hay ghi chéo giữa các ngôn ngữ. - Chọn một kiểu và giữ nguyên. Một collection mà nửa
_idlà ObjectId, nửa là chuỗi hex sẽ dính đúng cái bẫy type bracketing ở phần trước (Date, thứ tự so sánh và ObjectId): tìm theo chuỗi sẽ không ra ObjectId. - Id tăng dần hay ngẫu nhiên ảnh hưởng đến index
_id: chèn dồn về một đầu hay rải khắp cây. Bài Index Fundamentals giải thích và đo điều này.
So với PostgreSQL
Hãy quay lại bug userId: "42". Trong PostgreSQL, nếu cột user_id là bigint, bug này không thể xảy ra: cột có một kiểu cố định, và giá trị sai kiểu bị ép kiểu hoặc bị từ chối ngay lúc ghi. Trong MongoDB, mỗi giá trị mang kiểu riêng, còn trường thì không có kiểu cố định. Vì thế một trường có thể chứa lẫn số và chuỗi, cho đến khi bạn thêm schema validation.
Hai cách này không phải "một cái tốt, một cái xấu":
| Vấn đề | MongoDB | PostgreSQL |
|---|---|---|
| Kiểu dữ liệu gắn với | từng giá trị (BSON type byte) | cột (schema) |
| Trộn số với chuỗi trong cùng trường | có thể; chặn bằng $jsonSchema | không, với cột có kiểu |
| Số trong dữ liệu dạng JSON | int32/int64/double/decimal128 riêng biệt | jsonb lưu mọi số JSON dưới dạng numeric [Tài liệu PostgreSQL] |
| Ngày giờ trong dữ liệu dạng JSON | có kiểu Date thật trong BSON | JSON không có kiểu ngày; ngày trong jsonb là chuỗi, trừ khi tách ra cột timestamptz |
| UUID | binData subtype 4 (16 byte dữ liệu) | kiểu uuid 128-bit; PostgreSQL 18 có cả uuidv4() và uuidv7() [Tài liệu PostgreSQL] |
Bài học chung: MongoDB cho bạn sự linh hoạt về kiểu ở từng giá trị. Cái giá là trách nhiệm giữ kiểu nhất quán chuyển sang ứng dụng, hoặc sang schema validation mà bạn chủ động bật.
Những lỗi hay gặp
| Lỗi | Hậu quả | Cách tránh |
|---|---|---|
| Lưu ngày bằng chuỗi | range và sort sai; tốn 29 thay vì 8 byte | dùng Date; parse ở biên |
| Lưu tiền bằng double | số dư lệch; query bằng nhau trượt | int64 theo đơn vị nhỏ nhất hoặc decimal128 |
Lẫn "42" và 42 trong một trường | query thiếu dữ liệu, không báo lỗi | chuẩn hóa ở biên; $jsonSchema với bsonType; audit bằng $type |
NumberLong(9007199254740993) truyền số | mất chính xác trước khi tới server | truyền chuỗi: NumberLong("...") |
| Export bằng JSON thường hoặc EJSON relaxed | mất ObjectId/Date; long bị làm tròn | canonical EJSON hoặc mongodump |
Coi sort({_id: 1}) là thứ tự sự kiện chính xác | sai thứ tự giữa các process cùng giây | dùng createdAt hoặc số thứ tự từ một nguồn |
_id UUID lưu dạng chuỗi | gần gấp đôi kích thước, dễ lẫn hoa/thường | binData subtype 4 |
Điều cần nhớ
Nói không dùng thuật ngữ: dữ liệu trong MongoDB giống những toa hàng có dán nhãn loại hàng và ghi sẵn kích thước. Khi tìm kiếm, MongoDB chỉ so hàng cùng loại, nên "số 42" và "chữ 42" không bao giờ gặp nhau. Mã định danh mặc định thì giống phiếu số thứ tự ngân hàng: in giờ, mã máy và số thứ tự. Đại khái nó theo thời gian, nhưng không cho biết chính xác ai đến trước trong cùng một giây.
Thêm thuật ngữ vào:
- BSON là định dạng nhị phân có kiểu và có tiền tố độ dài. Nó đổi vài byte (36 so với 30 byte JSON trong ví dụ) lấy khả năng duyệt nhanh và kiểu rõ ràng. Tên field được lưu lại trong mọi document.
- Kích thước giá trị: int32 4, int64/double/Date 8, ObjectId 12, decimal128 16, UUID binary 21. Lưu ngày, ObjectId hay UUID dạng chuỗi tốn gấp 2–3 lần.
- Tiền: dùng int64 theo đơn vị nhỏ nhất hoặc decimal128, không dùng double. Ngày giờ: dùng Date (UTC, mili giây), không dùng chuỗi.
- So sánh và sắp xếp: kiểu trước, giá trị sau. Query dùng type bracketing: sai kiểu thì trả về rỗng mà không báo lỗi. Các kiểu số đi chung một nhóm.
- ObjectId = 4 byte giây + 5 byte random của process + 3 byte counter. Nó gần như tăng theo thời gian, không đơn điệu giữa các process, và không có mili giây.
Cột mốc: Bạn đã có thể chọn kiểu cho
_idvà giữ nó nhất quán, và nhận ra vì sao khóa nối khác kiểu làm tra cứu trả về rỗng. Bài BSON & ObjectId khép lại ở đây.
Hỏi & đáp
Collection mới dùng UUID v4 làm _id. Một bạn định lưu chuỗi 36 ký tự cho dễ đọc khi log. Nên làm gì?
Trong PostgreSQL, cột user_id kiểu bigint không thể chứa "42". Còn trường userId trong MongoDB thì sao?
Bài tiếp theo
Bài tiếp theo, Data Modeling: embed hay reference?, là nơi những con số trong bài này bắt đầu ảnh hưởng đến quyết định thiết kế. Khi chọn nhúng dữ liệu vào document hay tách ra rồi tham chiếu, bạn đang chọn byte nào nằm ở đâu và lặp lại bao nhiêu lần:
- Tên field lặp lại ở mọi phần tử. Nhúng một mảng 500 địa chỉ hay 500 dòng đơn hàng nghĩa là lặp tên field của document con 500 lần, cộng thêm tên phần tử
"0","1", … vì mảng được mã hóa như document. - Reference cũng có giá. Mỗi tham chiếu bằng ObjectId tốn 12 byte giá trị, và trong mảng còn thêm byte kiểu và tên phần tử. Theo cách mã hóa trong đặc tả (tính tay, không đo), một mảng 1.000 ObjectId đã khoảng 17 KB. Tham chiếu bằng UUID dạng chuỗi thì gần gấp ba.
- Dữ liệu sao chép mang theo kiểu của nó. Khi denormalize (copy tên khách hàng, ngày đặt hàng sang một collection khác), một ngày lưu dạng chuỗi tốn 29 thay vì 8 byte ở mỗi bản sao, và còn kéo theo bẫy sắp xếp và type bracketing của chuỗi.
- Kiểu của khóa nối phải khớp. Nếu
orders.userIdlà chuỗi cònusers._idlà ObjectId, việc tra cứu từ đơn sang user (bằng query hay$lookup) trả về rỗng mà không báo lỗi, đúng như thí nghiệm"42"ở phần trước (Date, thứ tự so sánh và ObjectId).
Bài Data Modeling dùng các con số này cùng giới hạn 16 MB của bài Document Model để trả lời câu hỏi chính: với từng access pattern, nên embed hay reference.