BSON & ObjectId (P2/3): Date, thứ tự so sánh và ObjectId
Ở phần trước: BSON gắn kiểu cho từng giá trị, int32 tốn 4 byte và double tốn 8 byte. Tiền nên lưu bằng int64 theo đơn vị nhỏ nhất, hoặc decimal128 khi cần phần lẻ.
- Cần đọc trước: BSON, các kiểu và kiểu số
- Dẫn tới: UUID và lựa chọn
_id; so với PostgreSQL
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ạngstring 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 < MaxKeyThí 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 dateKế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ênmissingvànullhò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"]< double9.5< int10< long100. - 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ớ:
- 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. - Cùng ngoặc số thì kiểu cụ thể không quan trọng. int
42, long42, double42và decimal42.00đều khớp với42. 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 Document Model) để server từ chối ghi sai kiểu. - Kiểm tra dữ liệu cũ bằng
$type. Pipeline$grouptheo{ $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
Ý chính
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 0042Khô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:
454f588ca2và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 (
d05d4bvà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 sinhserver-generated: 6ac711f6 1202b55ba5 d6ca57
client-generated: 6ac711f6 acb1910cc6 ad711bgetTimestamp() 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 process | Tí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
_idlà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.
Cột mốc: Bạn đã có thể lưu ngày bằng Date, nói vì sao chuỗi 42 không khớp số 42 khi query, và đọc được 12 byte của ObjectId. Tiếp theo: UUID và lựa chọn
_id; so với PostgreSQL.
Hỏi & đáp
Trường userId chứa lẫn 42, "42", NumberLong("42"), 42.0 và NumberDecimal("42.00"). find({ userId: { $gt: 0 } }) trả về những document nào?
API nhận ?since=2026-10-01 rồi query { createdAt: { $gte: req.query.since } }, trong khi createdAt lưu kiểu Date. Kết quả là gì?
Hai server ứng dụng chèn đơn trong cùng một giây, A chèn trước B khoảng 250 ms, _id là ObjectId do driver sinh. Sắp xếp theo _id tăng dần cho kết quả gì?
Ở ngân hàng, mỗi máy phát phiếu in giờ (chỉ đến giây), mã của máy và số thứ tự riêng của máy. Xếp các phiếu theo chuỗi in trên đó cho biết gì?