BSON & ObjectId (P3/3): UUID và lựa chọn `_id`; so với PostgreSQL

7 phút đọcSeries: MongoDB: từ gốc đến internals

Ở 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.

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ọnKích thước giá trịƯu điểmNhược điểm
ObjectId12 bytemặc định, client tự sinh, gần như tăng theo thời gianlộ thời điểm tạo; hệ thống ngoài phải hiểu kiểu ObjectId
UUID v4 dạng binData subtype 421 bytechuẩn chung, sinh ở đâu cũng được, không đoán đượchoàn toàn ngẫu nhiên, không có thứ tự
UUID dạng chuỗi 36 ký tự41 bytedễ đọc, dễ loggần gấp đôi bản binary; dễ lẫn chữ hoa và thường
UUID v7 (timestamp ở đầu) dạng binary21 bytechuẩn UUID mà vẫn tăng theo thời gianphải sinh ở ứng dụng; vẫn lộ thời điểm tạo
Khóa tự nhiên (mã đơn, tenantId:orderNo)tùykhô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 int648 bytenhỏ, thứ tự rõ ràngphả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ạo binData subtype 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 _id là 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 đềMongoDBPostgreSQL
Kiểu dữ liệu gắn vớitừng giá trị (BSON type byte)cột (schema)
Trộn số với chuỗi trong cùng trườngcó thể; chặn bằng $jsonSchemakhông, với cột có kiểu
Số trong dữ liệu dạng JSONint32/int64/double/decimal128 riêng biệtjsonb 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 JSONcó kiểu Date thật trong BSONJSON không có kiểu ngày; ngày trong jsonb là chuỗi, trừ khi tách ra cột timestamptz
UUIDbinData 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ỗiHậu quảCách tránh
Lưu ngày bằng chuỗirange và sort sai; tốn 29 thay vì 8 bytedùng Date; parse ở biên
Lưu tiền bằng doublesố dư lệch; query bằng nhau trượtint64 theo đơn vị nhỏ nhất hoặc decimal128
Lẫn "42" và 42 trong một trườngquery thiếu dữ liệu, không báo lỗichuẩ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 servertruyền chuỗi: NumberLong("...")
Export bằng JSON thường hoặc EJSON relaxedmất ObjectId/Date; long bị làm tròncanonical EJSON hoặc mongodump
Coi sort({_id: 1}) là thứ tự sự kiện chính xácsai thứ tự giữa các process cùng giâydùng createdAt hoặc số thứ tự từ một nguồn
_id UUID lưu dạng chuỗigần gấp đôi kích thước, dễ lẫn hoa/thườngbinData 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 _id và 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ì?

  1. Lưu chuỗi cũng được, vì chuỗi và binary tốn số byte như nhau

    Không bằng nhau: UUID dạng chuỗi 36 ký tự tốn 41 byte, còn binData subtype 4 chỉ 21 byte. Xem mục "UUID và các lựa chọn khác".

  2. Lưu binData subtype 3, vì đó là dạng chuẩn của UUID

    Subtype 3 là định dạng UUID cũ (legacy), mỗi driver từng sắp byte theo một kiểu. Dạng chuẩn là subtype 4, đúng thứ UUID() trong mongosh tạo ra. Xem mục "UUID và các lựa chọn khác".

  3. Chuyển sang số int64 tăng dần, vì MongoDB tự cấp số cho _id

    MongoDB không tự cấp số tăng dần: int64 chỉ 8 byte nhưng phải có nơi cấp số, tức thêm một round-trip và dễ thành điểm nghẽn. Xem mục "UUID và các lựa chọn khác".

  4. Lưu dạng binary subtype 4 (21 byte), vì chuỗi 41 byte to gần gấp đôi và dễ lẫn hoa thường

    UUID dạng chuỗi 36 ký tự tốn 41 byte, gần gấp đôi bản binary subtype 4 (21 byte), lại dễ lẫn chữ hoa và thường. UUID() trong mongosh đã tạo sẵn binData subtype 4 đúng chuẩn. Xem mục "UUID và các lựa chọn khác".

Trong PostgreSQL, cột user_id kiểu bigint không thể chứa "42". Còn trường userId trong MongoDB thì sao?

  1. Cũng bị chặn: kiểu được cố định cho từng trường ngay khi tạo collection

    Không: trong MongoDB kiểu gắn với từng giá trị (BSON type byte), còn trường không có kiểu cố định như cột của PostgreSQL. Xem mục "So với PostgreSQL".

  2. Chứa lẫn số và chuỗi được, cho đến khi bạn bật schema validation

    Mỗi giá trị mang kiểu riêng nên một trường có thể lẫn số và chuỗi, và $jsonSchema với bsonType chặn được. Cái giá: trách nhiệm giữ kiểu nhất quán chuyển sang ứng dụng hoặc schema validation. Xem mục "So với PostgreSQL".

  3. Chứa lẫn được, nhưng MongoDB tự ép "42" thành số

    Không có ép kiểu tự động: bảng lỗi hay gặp ghi rõ trộn "42" và 42 trong một trường làm query thiếu dữ liệu mà không báo lỗi. Xem mục "Những lỗi hay gặp".

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.userId là chuỗi còn users._id là 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.

Tài liệu tham khảo