BSON & ObjectId: dữ liệu thật sự trông thế nào

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

Vì sao bài này quan trọng

Ở bài 02 ta nói document là đơn vị dữ liệu của MongoDB, và nhìn nó qua cú pháp giống JSON. Bài này mở nắp ra xem bên dưới: document thực sự được lưu thành byte như thế nào, mỗi kiểu dữ liệu tốn bao nhiêu chỗ, các giá trị được so sánh với nhau ra sao, và cái _id mặc định (ObjectId) có cấu trúc gì. Bài 04 sau đó mới bàn cách nhóm dữ liệu vào document (embed hay reference), và khi đó những con số về kích thước ở đây sẽ được dùng đến.

Mục đích không phải là thuộc lòng bảng mã. Hiểu phần này giúp bạn tránh một nhóm bug rất khó thấy:

  • query trả về rỗng dù dữ liệu "rõ ràng là có",
  • số dư ví lệch đi một chút sau vài lần cộng,
  • sắp xếp theo ngày ra sai thứ tự,
  • "lấy bản ghi mới nhất theo _id" trả về bản ghi không phải mới nhất.
Cần biết trước     : bài 02 (document, _id, schema validation); bài 01 cho bức tranh tổng thể
Khái niệm mới      : BSON vs JSON, kích thước từng kiểu, int/long/double/decimal128, Date,
                     thứ tự so sánh giữa các kiểu, type bracketing, cấu trúc ObjectId,
                     UUID và các lựa chọn _id khác
Dẫn tới            : bài 04 Data Modeling (embed hay reference)
Vị trí trong series: Phần 1 Nền tảng, bài 3/6, ngay trước Data Modeling

Trong bài, mỗi khẳng định quan trọng có một nhãn cho biết nó đến từ đâu:

  • [Tài liệu]: tài liệu MongoDB hoặc đặc tả BSON mô tả. Bạn có thể dựa vào.
  • [Quan sát]: đo được trong lab của bài này. Đúng với môi trường bên dưới, nhưng nên kiểm lại trên phiên bản của bạn.
  • [Chi tiết triển khai]: cách hệ thống đang làm hiện nay, không phải cam kết API.
  • [Mô hình đơn giản]: cách hình dung cho dễ hiểu, không phải hiện thực chính xác.
Setup
MongoDB version : 8.3.11 (Docker image mongo:8), mongosh 2.12.0
Hardware        : 4 CPU, 4 GB RAM, host Apple M4
Configuration   : WiredTiger cache 1 GB
Dataset         : vài chục document nhỏ, mỗi thí nghiệm một collection trong database lab04
Indexes         : chỉ index mặc định trên _id

Mô hình tư duy

Giải thích trong 30 giây

MongoDB không lưu document dưới dạng văn bản JSON. Nó lưu một dạng nhị phân gọi là BSON, trong đó mỗi giá trị đi kèm nhãn kiểu và mỗi khối dữ liệu ghi sẵn độ dài của nó. Vì có nhãn kiểu, MongoDB phân biệt được 42 (số) với "42" (chuỗi), và khi so sánh, nó xét kiểu trước rồi mới xét giá trị. _id mặc định là một ObjectId 12 byte, gồm thời điểm tạo, một giá trị ngẫu nhiên riêng của mỗi process và một bộ đếm.

Hình dung: lá thư và đoàn tàu hàng

Hãy tưởng tượng JSON là một lá thư viết tay. Muốn tìm dòng nói về "tuổi", bạn phải đọc từ đầu, từng chữ một. Khi thấy 30, bạn phải tự đoán đó là số nguyên, số thực hay một phần của ngày tháng.

BSON (Binary JSON) giống một đoàn tàu hàng:

[đầu tàu: dài 36 byte] [int32 | _id | 1] [string | name | dài 3 | "An"] [int32 | age | 30] [hết]
        │                    │      │
        │                    │      └── tên toa (tên field)
        │                    └── nhãn loại hàng (kiểu dữ liệu)
        └── tổng chiều dài cả đoàn (độ dài document)

Ánh xạ sang khái niệm thật:

tổng chiều dài ghi ở đầu tàu   = length prefix của document
nhãn loại hàng trên mỗi toa    = type byte của từng field
chiều dài ghi ở cửa toa        = length prefix của chuỗi / document con
bỏ qua toa không cần, không mở = traversable: nhảy qua một khối nhờ biết độ dài

[Mô hình đơn giản] Đây chỉ là cách hình dung. Hình ảnh này đúng ở chỗ: vì độ dài được ghi trước, một chương trình đọc BSON có thể bỏ qua cả một chuỗi hay document con mà không cần giải mã nó. Đặc tả BSON gọi tính chất này là traversable. Nhưng đừng suy ra rằng mọi thao tác của MongoDB đều chỉ "nhảy toa". Server làm gì với từng document còn tùy query, và bài 15 (Query Execution Engine) sẽ nói kỹ.

Bốn ý cần giữ trong đầu

  1. Mọi giá trị trong BSON đều có kiểu (type) đi kèm, lưu ngay trong byte.
  2. Document và chuỗi đều có tiền tố độ dài (length-prefixed).
  3. Khi so sánh hay sắp xếp, MongoDB xét kiểu trước, giá trị sau. "42" và 42 thuộc hai thế giới khác nhau.
  4. ObjectId là thời điểm tạo (giây) + giá trị ngẫu nhiên của process + bộ đếm. Vì vậy nó gần như tăng theo thời gian, nhưng không tăng tuyệt đối.

BSON hoạt động thế nào

Bên dưới: 36 byte của một document

Ta dùng một document cố ý thật nhỏ để có thể đọc từng byte. Chèn nó vào, rồi dùng mongodump xuất đúng byte BSON mà server trả ra:

docker exec -i mongo-lab mongosh --quiet --eval '
  const d = db.getSiblingDB("lab04");
  d.hex.insertOne({ _id: 1, name: "An", age: 30 })'

docker exec -i mongo-lab sh -c 'mongodump --quiet -d lab04 -c hex --out - | od -A d -t x1'
0000000 24 00 00 00 10 5f 69 64 00 01 00 00 00 02 6e 61
0000016 6d 65 00 03 00 00 00 41 6e 00 10 61 67 65 00 1e
0000032 00 00 00 00
0000036

Giải nghĩa từng đoạn:

offset  byte (hex)        ý nghĩa
------  ----------------  -----------------------------------------------
0       24 00 00 00       tổng độ dài document = 0x24 = 36 byte
4       10                kiểu 0x10 = int32
5       5f 69 64 00       tên field "_id" + byte 0 kết thúc
9       01 00 00 00       giá trị 1
13      02                kiểu 0x02 = string
14      6e 61 6d 65 00    "name\0"
19      03 00 00 00       độ dài chuỗi = 3 (tính cả byte 0 cuối)
23      41 6e 00          "An\0"
26      10                kiểu 0x10 = int32
27      61 67 65 00       "age\0"
31      1e 00 00 00       giá trị 30
35      00                kết thúc document

Đọc bảng trên ta thấy ba điều:

  • [Tài liệu] Số được ghi theo little-endian, tức byte ít quan trọng đứng trước: 01 00 00 00 là 1. Đặc tả BSON yêu cầu như vậy cho mọi kiểu. Ngoại lệ là phần timestamp và counter của ObjectId, sẽ nói ở phần sau.
  • [Tài liệu] Tên field nằm trong từng document. Chuỗi "name" và "age" được lưu lại ở mọi document của collection.
  • [Tài liệu] Mảng cũng là document. Theo đặc tả, ['red', 'blue'] được mã hóa như document {'0': 'red', '1': 'blue'}.

Cái giá: BSON không phải lúc nào cũng nhỏ hơn JSON

[Quan sát] Chuỗi JSON {"_id":1,"name":"An","age":30} dài 30 byte (đo bằng $strLenBytes), còn BSON của nó là 36 byte. BSON không được thiết kế để nén. Nó trả thêm vài byte để đổi lấy khả năng đọc nhanh (nhảy qua khối nhờ biết độ dài) và kiểu dữ liệu rõ ràng.

Vì tên field lặp lại trong từng document, tên dài cũng tốn chỗ thật. Cùng một dữ liệu khách hàng với hai cách đặt tên:

db.getSiblingDB("lab04").aggregate([{ $documents: [{}] }, { $project: { _id: 0,
  longNames:  { $bsonSize: { $literal: { customerEmailAddress: "an@x.vn",
                  customerPhoneNumber: "0901234567", customerCreatedAt: new Date(0) } } },
  shortNames: { $bsonSize: { $literal: { email: "an@x.vn",
                  phone: "0901234567", createdAt: new Date(0) } } } } }])
[ { longNames: 102, shortNames: 65 } ]

[Quan sát] Chênh nhau 37 byte mỗi document, khoảng 36%. Không cần đặt tên kiểu e, p khó đọc. Chỉ nên bỏ những tiền tố thừa như customer... khi collection đã tên là customers. Ở đây ta chỉ đo byte; byte thừa ảnh hưởng hiệu năng thế nào được đo ở bài 05 (kích thước document) và bài 21 (cache).

JSON làm mất kiểu dữ liệu

Khi xuất document ra JSON thường, thông tin kiểu bị rơi mất. MongoDB có Extended JSON (EJSON) để giữ kiểu bằng các khóa đặc biệt như $oid và $numberLong:

const doc = { _id: ObjectId("6ac71201a9fded4f0c2e7ec8"), qty: 42,
  total: NumberDecimal("19.99"), views: NumberLong("9007199254740993"),
  at: new Date("2026-10-08T10:00:00Z"), ref: UUID("340a73e3-5b2b-45a8-892d-712753c15406") };
print(JSON.stringify(doc));
print(EJSON.stringify(doc));                      // relaxed
print(EJSON.stringify(doc, { relaxed: false }));  // canonical
{"_id":"6ac71201a9fded4f0c2e7ec8","qty":42,"total":{"$numberDecimal":"19.99"},"views":{"high":2097152,"low":1,"unsigned":false},"at":"2026-10-08T10:00:00.000Z","ref":"NApz41srRaiJLXEnU8FUBg=="}
{"_id":{"$oid":"6ac71201a9fded4f0c2e7ec8"},"qty":42,"total":{"$numberDecimal":"19.99"},"views":9007199254740992,"at":{"$date":"2026-10-08T10:00:00Z"},"ref":{"$binary":{"base64":"NApz41srRaiJLXEnU8FUBg==","subType":"04"}}}
{"_id":{"$oid":"6ac71201a9fded4f0c2e7ec8"},"qty":{"$numberInt":"42"},"total":{"$numberDecimal":"19.99"},"views":{"$numberLong":"9007199254740993"},"at":{"$date":{"$numberLong":"1791453600000"}},"ref":{"$binary":{"base64":"NApz41srRaiJLXEnU8FUBg==","subType":"04"}}}

[Quan sát] Với JSON.stringify, _id và at biến thành chuỗi trơn, còn views biến thành một object lạ. Nếu import lại, bạn sẽ có chuỗi chứ không có ObjectId hay Date. Bản EJSON relaxed dễ đọc hơn nhưng in views thành 9007199254740992, sai mất 1 đơn vị, vì số JavaScript không biểu diễn chính xác được giá trị đó. Chỉ bản canonical giữ nguyên mọi thứ. Khi cần chuyển dữ liệu giữa các hệ thống mà phải giữ đúng kiểu, hãy dùng canonical EJSON hoặc BSON gốc (mongodump).

Các kiểu chính và chúng tốn bao nhiêu byte

Đặc tả BSON định nghĩa khoảng hai chục kiểu, nhưng hằng ngày bạn chỉ gặp chừng mười kiểu. Để đo, ta chèn các document dạng { v: <giá trị> } vào lab04.sizes rồi hỏi server kích thước bằng $bsonSize:

db.getSiblingDB("lab04").sizes.aggregate([
  { $project: { _id: 0, label: 1, type: { $type: "$v" }, size: { $bsonSize: { v: "$v" } } } }
])

Mỗi document { v: ... } luôn có 8 byte cố định: 4 byte độ dài, 1 byte kết thúc, 1 byte kiểu, và 2 byte cho tên "v\0". Cột cuối là phần còn lại, tức kích thước của chính giá trị.

[Quan sát], khớp với cách mã hóa trong đặc tả:

Giá trị$type{v: …} (byte, đo được)Riêng giá trị
nullnull80
truebool91
2026 (int32)int124
NumberLong("2026")long168
2026.5double168
new Date(...)date168
NumberDecimal("2026")decimal2416
ObjectId()objectId2012
UUID()binData2921 (4 độ dài + 1 subtype + 16)
"2026"string179 (4 độ dài + 4 ký tự + 1 byte 0)
"2026-10-08T10:00:00.000Z"string3729
ObjectId dạng chuỗi hexstring3729
UUID dạng chuỗi 36 ký tựstring4941

Ba dòng cuối cho thấy cùng một thông tin nhưng lưu dạng chuỗi thì tốn gấp đôi đến gấp ba. Trên một document, vài chục byte không đáng kể. Nhưng giả sử collection có 100 triệu document (con số ví dụ). Chỉ riêng một trường ngày lưu dạng chuỗi đã tốn thêm khoảng 2 GB so với Date (21 byte × 100 triệu), chưa kể mỗi index chứa trường đó còn lưu thêm một bản (bài 07).

Kiểu số: int32, int64, double, decimal128

Bốn kiểu số và lúc nên dùng

KiểuAlias $typeKích thướcDùng cho
32-bit integerint4 byteđếm, số lượng, tuổi: nằm trong khoảng ±2,1 tỷ
64-bit integerlong8 byteID số, bộ đếm lớn, tiền tính theo đơn vị nhỏ nhất (đồng, cent)
Doubledouble8 bytesố đo, tọa độ, tỷ lệ: chấp nhận sai số nhỏ
Decimal128decimal16 bytetiền và các phép tính cần chính xác thập phân

Hãy hình dung double như một cái cân điện tử: nó cho ra số rất gần đúng, nhưng có những giá trị (như 0.1) nó không bao giờ hiển thị chính xác tuyệt đối. Decimal128 giống đếm tiền mặt: 10 tờ 10 nghìn là đúng 100 nghìn, không lệch một đồng.

Về kỹ thuật, double là số dấu chấm động nhị phân (IEEE 754), giống number của JavaScript hay float64 của Go. Nó không biểu diễn chính xác được những số như 0.1. [Tài liệu] Decimal128 có 34 chữ số thập phân có nghĩa, và tài liệu MongoDB khuyến nghị nó cho các phép tính tài chính cần chính xác.

Thí nghiệm: 0.1 + 0.2 trên server

db.getSiblingDB("lab04").aggregate([{ $documents: [{}] }, { $project: {
  dbl:   { $add: [0.1, 0.2] },
  dec:   { $add: [NumberDecimal("0.1"), NumberDecimal("0.2")] },
  dblEq: { $eq: [{ $add: [0.1, 0.2] }, 0.3] },
  decEq: { $eq: [{ $add: [NumberDecimal("0.1"), NumberDecimal("0.2")] }, NumberDecimal("0.3")] }
} }])
[ { dbl: 0.30000000000000004, dec: Decimal128('0.3'), dblEq: false, decEq: true } ]

Giờ thử một tình huống giống đời thực: một ví được cộng 0.1 mười lần bằng $inc, mỗi lần là một update riêng.

const d = db.getSiblingDB("lab04");
d.wallet.insertOne({ _id: "w1", dbl: 0, dec: NumberDecimal("0") });
for (let i = 0; i < 10; i++)
  d.wallet.updateOne({ _id: "w1" }, { $inc: { dbl: 0.1, dec: NumberDecimal("0.1") } });
printjson(d.wallet.findOne());
print(d.wallet.countDocuments({ dbl: 1 }), d.wallet.countDocuments({ dec: 1 }));
{ _id: 'w1', dbl: 0.9999999999999999, dec: Decimal128('1.0') }
0 1

[Quan sát] Số dư double là 0.9999999999999999, nên query "ví có đúng 1" không tìm thấy. Bản decimal128 ra 1.0 và khớp với 1.

[Quan sát + Chi tiết triển khai] Khi dùng $sum để cộng mười giá trị 0.1 (dạng double) trong một lần aggregate, server trả về đúng 1, trong khi $add cùng mười giá trị đó trả về 0.9999999999999999. Vậy là bộ tích lũy $sum cộng chính xác hơn phép cộng lần lượt. Đây là cách hiện thực, không phải lời hứa, nên đừng dựa vào nó. Dữ liệu tiền đã lưu bằng double thì sai số vẫn nằm sẵn trong từng document.

Vậy lưu tiền thế nào?

  • What? Lưu tiền bằng kiểu chính xác: int64 theo đơn vị nhỏ nhất (VND lưu số đồng, USD lưu số cent), hoặc decimal128 khi cần phần lẻ (tỷ giá, lãi suất, đơn giá lẻ).
  • Why? Phép cộng trừ trên hai kiểu này không tích lũy sai số nhị phân.
  • What happens if we don't? Như thí nghiệm trên: số dư lệch, query bằng nhau trượt, đối soát cuối ngày không khớp.
decimal128 cho tiền
   ├── ✓ chính xác thập phân, đọc ra đúng số hiển thị
   ├── ✗ 16 byte, gấp đôi int64/double
   ├── ⚠ tính toán có thể chậm hơn double (chưa đo)
   └── ⚠ ngôn ngữ phía ứng dụng cũng phải dùng kiểu decimal, không ép về float

int64 theo đơn vị nhỏ nhất
   ├── ✓ 8 byte, nhanh, cộng trừ chính xác tuyệt đối
   └── ✗ phải quy ước số chữ số lẻ cho từng loại tiền ở tầng ứng dụng

Bẫy riêng của JavaScript và mongosh

mongosh và Node.js dùng number của JavaScript, vốn là double, nên có vài điểm dễ trượt chân:

const d = db.getSiblingDB("lab04");
d.t.insertMany([{ _id: 1, v: 42.0 }, { _id: 2, v: Double(42) }, { _id: 3, v: 42.5 }]);
d.t.aggregate([{ $project: { v: 1, t: { $type: "$v" } } }]);
printjson({ jsLiteral: 9007199254740993, long: NumberLong("9007199254740993") });
[ { _id: 1, v: 42, t: 'int' }, { _id: 2, v: 42, t: 'double' }, { _id: 3, v: 42.5, t: 'double' } ]
{ jsLiteral: 9007199254740992, long: Long('9007199254740993') }
  • [Quan sát] Viết 42.0 trong mongosh vẫn ra int, vì JavaScript không phân biệt 42.0 với 42. Muốn chắc chắn là double thì dùng Double(42).
  • [Quan sát] Số nguyên lớn hơn 2^53 bị làm tròn ngay từ phía client, trước khi tới server. Hãy truyền NumberLong hoặc NumberDecimal bằng chuỗi. mongosh còn tự cảnh báo: "specifying a number as argument is deprecated and may lead to loss of precision, pass a string instead".

Còn tràn số thì sao? Ta tăng một int32 đang ở giá trị lớn nhất:

d.ctr.insertOne({ _id: 1, n: NumberInt("2147483647") });
d.ctr.updateOne({ _id: 1 }, { $inc: { n: NumberInt("1") } });
[ { _id: 1, n: Long('2147483648'), t: 'long' } ]

[Quan sát] Server không để số quay vòng thành âm mà đổi kiểu trường thành long. Kết quả đúng, nhưng giờ collection có một trường mang hai kiểu khác nhau. Query không bị ảnh hưởng, vì mọi kiểu số được so chung một nhóm (xem phần sau). Nhưng code ứng dụng đọc trường đó với một kiểu cố định thì có thể bất ngờ.

Date: đừng lưu ngày giờ bằng chuỗi

[Tài liệu] Kiểu Date của BSON là số nguyên 64-bit có dấu, đếm mili giây kể từ Unix epoch (1/1/1970 UTC). Giá trị âm là các ngày trước 1970. Nó luôn là UTC và không chứa múi giờ. Đổi sang giờ Việt Nam là việc của lúc hiển thị, ví dụ $dateToString với timezone: "Asia/Ho_Chi_Minh". Đừng nhầm với kiểu Timestamp: tài liệu ghi rõ kiểu đó dành cho MongoDB dùng nội bộ, còn ứng dụng nên dùng Date.

Ta đã thấy Date tốn 8 byte, còn chuỗi ISO tốn 29 byte. Nhưng vấn đề lớn hơn là tính đúng. Thí nghiệm với ba sự kiện, lưu song song dạng chuỗi (at) và dạng Date (atDate):

d.events.insertMany([
  { _id: "a", at: "2026-9-30",  atDate: new Date("2026-09-30T00:00:00Z") },
  { _id: "b", at: "2026-10-01", atDate: new Date("2026-10-01T00:00:00Z") },
  { _id: "c", at: "2026-10-15", atDate: new Date("2026-10-15T00:00:00Z") },
]);
d.events.find({ at:     { $gte: "2026-10-01" } });                       // chuỗi
d.events.find({ atDate: { $gte: new Date("2026-10-01T00:00:00Z") } });   // Date
d.events.find().sort({ at: -1 });
d.events.find({ atDate: { $gte: "2026-10-01" } });                       // sai kiểu toán hạng
string range >= '2026-10-01': ["a","b","c"]
Date   range >= 2026-10-01  : ["b","c"]
sort by string desc: ["2026-9-30","2026-10-15","2026-10-01"]
Date query with string operand: []

Chuỗi được so sánh từng byte, nên "2026-9-30" lớn hơn "2026-10-01" (ký tự 9 lớn hơn 1). Kết quả là ngày 30/9 lọt vào khoảng "từ 1/10 trở đi" và đứng đầu khi sắp xếp giảm dần. Chỉ cần một nguồn dữ liệu quên đệm số 0 là đủ hỏng. Dòng cuối cho thấy chiều ngược lại: trường đúng là Date nhưng truyền vào một chuỗi thì query trả về rỗng, không có lỗi nào. Lý do nằm ở phần tiếp theo.

So sánh và sắp xếp giữa các kiểu

Hình dung: xếp hàng theo khu, rồi theo số

Hãy tưởng tượng sân bay xếp hành khách lên máy bay theo nhóm trước, rồi mới theo số ghế. Mọi người trong nhóm 1 lên trước mọi người trong nhóm 2, dù ghế của họ số mấy. MongoDB sắp xếp các giá trị khác kiểu theo cách tương tự: kiểu quyết định "nhóm", rồi giá trị mới quyết định thứ tự trong nhóm.

[Mô hình đơn giản] Hình ảnh này khớp với quy tắc sắp xếp. Nhưng với query ($eq, $gt…), MongoDB còn chặt hơn: nó không so giữa các nhóm mà bỏ qua luôn những giá trị khác nhóm. Phần type bracketing bên dưới nói rõ.

Thứ tự BSON: kiểu trước, giá trị sau

[Tài liệu] Vì collection không bắt buộc schema, một trường có thể chứa nhiều kiểu. MongoDB định nghĩa một thứ tự cho mọi kiểu, từ thấp đến cao:

MinKey < Null < Numbers (int, long, double, decimal) < Symbol, String
  < Object < Array < BinData < ObjectId < Boolean < Date
  < Timestamp < Regular Expression < JavaScript < JavaScript with scope < MaxKey

Thí nghiệm: một trường v chứa đủ thứ kiểu, sắp xếp tăng dần.

d.mixed.insertMany([
  { _id: "date", v: new Date("2020-01-01T00:00:00Z") }, { _id: "bool", v: true },
  { _id: "oid", v: ObjectId("000000000000000000000000") },
  { _id: "str10", v: "10" }, { _id: "str9", v: "9" }, { _id: "int10", v: 10 },
  { _id: "dbl9.5", v: 9.5 }, { _id: "dec2", v: NumberDecimal("2") },
  { _id: "long100", v: NumberLong("100") }, { _id: "null", v: null }, { _id: "missing" },
  { _id: "object", v: { a: 1 } }, { _id: "array", v: [5, "x"] },
  { _id: "emptyArr", v: [] }, { _id: "binData", v: UUID() },
]);
d.mixed.find({}, { _id: 1 }).sort({ v: 1, _id: 1 });
emptyArr missing null dec2 array dbl9.5 int10 long100 str10 str9 object binData oid bool date

Kết quả quan sát được khớp với từng quy tắc trong tài liệu:

  • Mảng rỗng đứng trước cả null. Trường không tồn tại được coi như null, nên missing và null hòa nhau và phải phân thắng thua bằng _id.
  • Mọi kiểu số được so chung một nhóm theo giá trị toán học: decimal 2 < mảng [5,"x"] < double 9.5 < int 10 < long 100.
  • Mảng, khi sắp xếp tăng dần, được đại diện bởi phần tử nhỏ nhất (ở đây là 5), nên nó nằm lẫn giữa các số.
  • "10" đứng trước "9", vì mặc định chuỗi được so từng byte. Muốn so theo ngôn ngữ, hay coi chữ số là số, thì phải dùng collation.
  • Mọi chuỗi đứng sau mọi số. Mọi Date đứng sau mọi boolean.

Type bracketing: vì sao "42" không bằng 42

[Tài liệu] Với các toán tử so sánh trong query ($eq, $gt, $lt…), MongoDB chỉ so sánh trên những document mà kiểu BSON của trường khớp với kiểu của toán hạng. Tài liệu gọi cơ chế này là type bracketing. Mọi kiểu số được coi là cùng một "ngoặc" (bracket). Chuỗi là một ngoặc khác, Date là một ngoặc khác nữa.

Thí nghiệm kinh điển: userId đến từ nhiều nguồn, có chỗ lưu dạng số, có chỗ lưu dạng chuỗi (lấy thẳng từ query string của HTTP).

d.orders.insertMany([
  { _id: 1, userId: 42 }, { _id: 2, userId: "42" }, { _id: 3, userId: NumberLong("42") },
  { _id: 4, userId: 42.0 }, { _id: 5, userId: NumberDecimal("42.00") },
]);
find({userId: 42})        -> [1,3,4,5]
find({userId: "42"})      -> [2]
find({userId: {$gt: 0}})  -> [1,3,4,5]
find({userId: {$gt: ""}}) -> [2]
types: [{"_id":"decimal","n":1},{"_id":"int","n":2},{"_id":"long","n":1},{"_id":"string","n":1}]
BEFORE (lưu lẫn kiểu)                 AFTER (chuẩn hóa ở biên)

find({ userId: 42 })                  find({ userId: 42 })
   ↓                                     ↓
khớp 4 document số                    khớp mọi đơn của user 42
✗ đơn có userId "42" biến mất          ✓

Hai điều cần nhớ:

  1. Khác ngoặc kiểu thì không bao giờ khớp. Hỏi bằng số thì document chứa chuỗi "42" trở nên vô hình, kể cả với $gt: 0. Không có lỗi, không có cảnh báo, chỉ là thiếu dữ liệu.
  2. Cùng ngoặc số thì kiểu cụ thể không quan trọng. int 42, long 42, double 42 và decimal 42.00 đều khớp với 42. Trộn int với double hầu như không làm sai kết quả query. Cái nguy hiểm thật là trộn số với chuỗi, và Date với chuỗi.

Cách phòng:

  • Chuẩn hóa kiểu ở biên của ứng dụng: parse tham số HTTP thành số hoặc Date trước khi đưa vào query.
  • Dùng schema validation với bsonType (bài 02) để server từ chối ghi sai kiểu.
  • Kiểm tra dữ liệu cũ bằng $type. Pipeline $group theo { $type: "$userId" } như ở trên cho biết ngay một trường đang chứa những kiểu nào.

ObjectId: 12 byte có cấu trúc

Giải thích trong 30 giây

Nếu bạn không tự đặt _id, MongoDB dùng ObjectId: 12 byte gồm giây hiện tại, một số ngẫu nhiên riêng của process sinh ra nó và một bộ đếm. Vì giây đứng đầu, ObjectId sinh sau thường lớn hơn ObjectId sinh trước. Nhưng trong cùng một giây, hoặc giữa các máy lệch đồng hồ, thì không có gì bảo đảm.

Hình dung: máy phát số thứ tự ở ngân hàng

Hãy tưởng tượng một ngân hàng có nhiều máy phát phiếu, mỗi máy ở một cửa. Mỗi phiếu in ba thứ:

[ giờ:phút:giây ] [ mã máy ] [ số thứ tự của máy ]
   10:15:07         K7Q        0042

Không cần trung tâm điều phối nào mà phiếu vẫn không trùng: hai máy khác mã, và một máy không bao giờ in trùng số thứ tự của chính nó. Nhưng nếu xếp các phiếu theo chuỗi in trên đó, hai người lấy phiếu trong cùng một giây ở hai máy khác nhau sẽ được xếp theo mã máy, không theo ai đến trước.

giờ in trên phiếu      = timestamp (4 byte, chỉ đến giây)
mã máy                 = random value (5 byte, mỗi process một giá trị)
số thứ tự của máy      = counter (3 byte)
không cần trung tâm    = driver tự sinh _id, không phải hỏi server

[Mô hình đơn giản] Đây chỉ là cách hình dung. "Mã máy" trong ObjectId không do ai cấp mà được sinh ngẫu nhiên, nên về lý thuyết vẫn có thể trùng, chỉ là xác suất rất nhỏ. Và "máy" ở đây là một process: hai ứng dụng trên cùng một server có hai giá trị khác nhau.

Bố cục 12 byte

[Tài liệu] Theo tài liệu MongoDB:

 byte:   0   1   2   3   4   5   6   7   8   9  10  11
       +---------------+-------------------+-----------+
       |   timestamp   |   random value    |  counter  |
       |   4 byte      |   5 byte          |  3 byte   |
       +---------------+-------------------+-----------+
         giây kể từ      sinh 1 lần cho      tăng dần, khởi
         Unix epoch      mỗi process,        tạo bằng số ngẫu
         (big-endian)    đổi khi restart     nhiên (big-endian)
  • Timestamp (4 byte): thời điểm tạo, tính bằng giây, không phải mili giây.
  • Random value (5 byte): sinh một lần cho mỗi process phía client, riêng cho từng máy và process, và sinh lại khi process khởi động lại.
  • Counter (3 byte): tăng dần trong mỗi process, khởi tạo bằng một giá trị ngẫu nhiên.

Timestamp và counter được ghi theo big-endian, ngược với phần còn lại của BSON. Nhờ vậy, khi so sánh 12 byte từ trái sang phải, timestamp được so trước, nên ObjectId sắp theo thời gian ở mức giây.

Thí nghiệm: mổ xẻ ObjectId thật

Ta chạy cùng một script trong hai process mongosh liên tiếp. Mỗi process sinh 5 ObjectId và cắt chúng thành ba phần:

const split = id => { const h = id.toString(); return `${h.slice(0,8)} | ${h.slice(8,18)} | ${h.slice(18)}`; };
for (let i = 0; i < 5; i++) print(split(ObjectId()));
print("getTimestamp():", ObjectId().getTimestamp().toISOString(), " now:", new Date().toISOString());
--- process 1
6ac711ed | 454f588ca2 | d05d4b
6ac711ed | 454f588ca2 | d05d4c
6ac711ed | 454f588ca2 | d05d4d
6ac711ed | 454f588ca2 | d05d4e
6ac711ed | 454f588ca2 | d05d4f
getTimestamp(): 2026-10-08T03:45:49.000Z  now: 2026-10-08T03:45:49.477Z
--- process 2
6ac711ed | 174cbaf76f | f1a390
6ac711ed | 174cbaf76f | f1a391
6ac711ed | 174cbaf76f | f1a392
6ac711ed | 174cbaf76f | f1a393
6ac711ed | 174cbaf76f | f1a394
getTimestamp(): 2026-10-08T03:45:49.000Z  now: 2026-10-08T03:45:49.722Z

[Quan sát] Mọi điều tài liệu mô tả đều hiện ra ở đây:

  • Cả 10 id có cùng timestamp 6ac711ed (= 1791431149 giây), vì được sinh trong cùng một giây.
  • Mỗi process có một random value riêng, cố định suốt đời process: 454f588ca2 và 174cbaf76f.
  • Counter tăng đúng 1 mỗi lần, mỗi process bắt đầu từ một điểm ngẫu nhiên (d05d4b và f1a390).
  • getTimestamp() trả về một Date, nhưng phần mili giây luôn là .000. Thời điểm thật là .477, còn ObjectId chỉ nhớ đến giây.

[Quan sát] Server cũng có thể tự sinh ObjectId, ví dụ khi một upsert tạo document mới mà không có _id. Khi đó random value là của process mongod, khác với của client:

c.updateOne({ proc: "server" }, { $set: { at: new Date() } }, { upsert: true });  // mongod sinh _id
print("server-generated:", s(c.findOne({ proc: "server" })._id));
print("client-generated:", s(ObjectId()));                                     // mongosh sinh
server-generated: 6ac711f6 1202b55ba5 d6ca57
client-generated: 6ac711f6 acb1910cc6 ad711b

getTimestamp() và lọc theo thời gian

Vì 4 byte đầu là thời gian, có thể lọc document theo khoảng thời gian tạo bằng chính _id. Cách làm là dựng một ObjectId "biên" có timestamp mong muốn và phần còn lại bằng 0:

const secs = Math.floor(new Date("2026-10-08T00:00:00Z").getTime() / 1000);
ObjectId(secs).toString();                  // '6ac6dd00dcfdc9da974196bf'
ObjectId.createFromTime(secs).toString();   // '6ac6dd000000000000000000'
db.getSiblingDB("lab04").seq.countDocuments({ _id: { $gte: ObjectId.createFromTime(secs) } });

[Quan sát] ObjectId(secs) có điền phần random và counter, nên không phải giá trị nhỏ nhất của giây đó. Làm biên dưới cho range query thì createFromTime (các byte sau toàn 0) an toàn hơn. Mẹo này tiện khi dọn dẹp hay điều tra dữ liệu. Nếu thời gian tạo là dữ liệu nghiệp vụ, hãy lưu một trường createdAt kiểu Date riêng: nó có mili giây, đánh index được riêng, và không phụ thuộc việc _id có phải ObjectId hay không.

ObjectId đảm bảo gì và không đảm bảo gì

[Tài liệu] Giá trị ObjectId nên tăng theo thời gian, nhưng không nhất thiết đơn điệu (monotonic), vì hai lý do. Thứ nhất, nó chỉ có độ phân giải một giây, nên các id sinh trong cùng một giây không có thứ tự bảo đảm. Thứ hai, nó do client sinh ra, mà đồng hồ của các client có thể lệch nhau.

Để thấy điều này, ta cho hai process A rồi B chạy nối tiếp. Mỗi process chèn 3 document có ghi thời điểm chèn, sau đó ta sắp xếp theo _id. Chạy vài lần cho đến khi cả hai rơi vào cùng một giây (lần thử thứ 3):

for p in A B; do
  docker exec -i mongo-lab mongosh --quiet --eval "
    const c = db.getSiblingDB('lab04').seq;
    for (let i = 1; i <= 3; i++) c.insertOne({ proc: '$p', i, at: new Date() });"
done
docker exec -i mongo-lab mongosh --quiet --eval '
  const s = id => { const h = id.toString(); return h.slice(0,8)+" "+h.slice(8,18)+" "+h.slice(18); };
  print("sorted by _id:");
  db.getSiblingDB("lab04").seq.find().sort({ _id: 1 })
    .forEach(d => print(" ", s(d._id), d.proc + d.i, d.at.toISOString().slice(11,23)))'
sorted by _id:
  6ac71201 a9fded4f0c 2e7ec8 B1 03:46:09.844
  6ac71201 a9fded4f0c 2e7ec9 B2 03:46:09.849
  6ac71201 a9fded4f0c 2e7eca B3 03:46:09.850
  6ac71201 d1375a74bd 43981a A1 03:46:09.591
  6ac71201 d1375a74bd 43981b A2 03:46:09.609
  6ac71201 d1375a74bd 43981c A3 03:46:09.610

[Quan sát] B chèn sau A khoảng 250 ms nhưng lại đứng trước khi sắp xếp theo _id. Trong cùng một giây, thứ tự do random value quyết định (a9fd… < d137…), mà giá trị đó hoàn toàn ngẫu nhiên. Đúng như hai người lấy phiếu ở hai máy khác nhau trong cùng một giây.

ObjectId cóObjectId không có
Duy nhất trên thực tế (random 5 byte + counter 3 byte cho mỗi giây)Thứ tự chính xác giữa các process hay máy khác nhau trong cùng một giây
Gần như tăng dần theo thời gian ở mức giâyĐơn điệu khi đồng hồ client lệch hoặc bị chỉnh lùi
Thời điểm tạo (đến giây) qua getTimestamp()Độ chính xác mili giây
Counter tăng dần trong một processTính bí mật: ai có id đều biết nó được tạo lúc nào

Hệ quả thực tế:

  • Đừng dùng sort({ _id: 1 }) như hàng đợi theo đúng thứ tự sự kiện, hay cho logic "lấy bản ghi mới nhất" cần chính xác. Hãy dùng một trường thời gian, hoặc số thứ tự do một nguồn duy nhất cấp.
  • Dùng _id làm con trỏ phân trang (_id > lastId) thì vẫn ổn, vì phân trang chỉ cần một thứ tự ổn định và duy nhất, không cần đúng thứ tự thời gian.
  • Đừng để lộ ObjectId ở nơi thời điểm tạo là thông tin nhạy cảm, và đừng coi nó như token khó đoán.

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 ở trên):

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 ở trên: 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 07 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.

Tự kiểm tra (nếu trả lời được bằng lời của bạn, bạn đã nắm bài):

  1. Vì sao find({ userId: { $gt: 0 } }) bỏ sót document có userId: "42", mà không báo lỗi?
  2. Một API nhận ?since=2026-10-01 rồi query { createdAt: { $gte: req.query.since } } trên trường kiểu Date. Kết quả là gì, và vì sao?
  3. Hai server ứng dụng cùng chèn đơn hàng trong cùng một giây. Sắp xếp theo _id có cho đúng thứ tự đặt hàng không? Nếu cần đúng thứ tự thì lưu thêm gì?

Bài tiếp theo

Bài 04, 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" ở trên.

Bài 04 dùng các con số này cùng giới hạn 16 MB của bài 02 để 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