CRUD & Query Model: MongoDB đọc và ghi dữ liệu ra sao
Năm bài trước dựng nền: bức tranh tổng thể (01), document là gì (02), document trông ra sao ở mức byte (03), nên gom gì vào một document (04), và các pattern thiết kế schema cùng cách thay đổi schema theo thời gian (05). Bài này khép lại Phần 1. Giờ là lúc làm cái mà ứng dụng làm cả ngày: đọc và ghi.
find, updateOne, insertMany thì ai cũng gọi được. Nhưng có nhiều câu hỏi mà cú pháp không tự trả lời. Vì sao find() không trả hết kết quả trong một lần? Vì sao { $ne: "SALE10" } lại khớp với cả những document không có field đó? Vì sao hai điều kiện trên cùng một mảng có thể khớp với hai phần tử khác nhau? Vì sao "tăng rồi đọc lại" có thể phát trùng số hoá đơn? Và vì sao một query chỉ trả về 21 document mà vẫn tốn 143 ms?
Bài này nằm ở đâu
Cần biết trước : bài 01 (đường đi của query, COLLSCAN, batch đầu 101 document)
bài 02 (single-document atomicity), bài 03 (so sánh theo kiểu BSON)
Giới thiệu : filter và operator, projection, sort/skip/limit, cursor và getMore,
update operator vs replacement, upsert, findOneAndUpdate, delete,
bulkWrite, ngữ nghĩa query trên mảng và $elemMatch
Dẫn tới : bài 07, Index FundamentalsMôi trường lab: MongoDB 8.3.11 chạy trong Docker (image
mongo:8, standalone), giới hạn 4 CPU, 4 GB RAM, WiredTiger cache 1 GB, máy host Apple M4. Bài này dùng database riêng tênlab05. Riêng phần Delete được đo sau, trên containermongo-lab-06(2 CPU, 3 GB RAM, cache 1 GB, databaselab06), và ghi rõ ở đó. Mọi con số đều đo thật, trừ khi được ghi là minh hoạ.
Hình dung trước: phiếu yêu cầu gửi kho lưu trữ
Quay lại kho lưu trữ hồ sơ ở bài 01. Bạn không vào kho tự lục. Bạn điền một phiếu yêu cầu rồi gửi cho nhân viên kho. Một phiếu đọc thì có mấy ô:
Phiếu yêu cầu (đọc) Trong MongoDB
─────────────────── ─────────────
"hồ sơ nào?" tenant t0042, pending filter
"photo trang nào?" chỉ trang tổng tiền projection
"xếp theo gì?" mới nhất trước sort
"bỏ qua bao nhiêu, lấy bao nhiêu?" skip / limit
nhân viên mang ra theo từng xe đẩy cursor + batch (getMore)Phiếu ghi thì có hai kiểu. Một là phiếu sửa: "ở hồ sơ số u1, đổi gói thành pro, cộng thêm 1 lần đăng nhập". Hai là phiếu thay thế: "rút hồ sơ u1 ra, bỏ đi, đặt tờ này vào chỗ đó". Hai kiểu phiếu này cho kết quả rất khác nhau, và phần lớn bug ghi dữ liệu trong MongoDB đến từ việc nhầm giữa chúng.
Đây chỉ là cách hình dung. MongoDB không có "nhân viên" hay "xe đẩy". Phiếu yêu cầu thật là một command BSON (
find,update,getMore...) gửi qua wire protocol, và "xe đẩy" là một batch trong message trả về. Nhưng thứ tự các bước và chi phí của chúng khớp với cách hình dung này.
Mental model: một query là một dây chuyền các stage
Trước khi đi vào từng phần, hãy nhìn query như một dây chuyền. Mỗi document đi qua lần lượt các trạm. Trạm nào thấy document không đạt thì loại ra.
find({ tenantId: "t0042" }, { total: 1 }).sort({ createdAt: -1 }).skip(40).limit(20)
COLLSCAN đọc từng document trong collection (chưa có index phù hợp)
│ filter giữ document có tenantId = "t0042"
▼
SORT xếp theo createdAt giảm dần
▼
SKIP / LIMIT bỏ 40, lấy 20
▼
PROJECTION chỉ giữ field total (và _id)
▼
batch 1 ──► app batch 2 ──► app (getMore) ...Ba ý cần giữ trong đầu suốt bài:
- Thứ tự áp dụng là cố định: filter → sort → skip → limit → projection. Thứ tự bạn viết
.limit().skip()hay.skip().limit()không làm đổi kết quả. - Projection, skip, limit không làm bớt việc tìm. Chúng chỉ quyết định cái gì được gửi về.
- Kết quả về theo từng batch, không về một lần.
Phần còn lại của bài đi lần lượt qua từng trạm, rồi chuyển sang đường ghi.
Filter: mô tả những document bạn muốn
30 giây
Filter là một document mô tả điều kiện. { tenantId: "t0042", status: "pending" } nghĩa là tenantId bằng t0042 và status bằng pending. Nhiều field trong cùng một filter mặc định nối với nhau bằng AND. Muốn so sánh khác "bằng" thì dùng query operator, là các khoá bắt đầu bằng $.
Các nhóm operator hay dùng
| Nhóm | Operator | Ví dụ |
|---|---|---|
| So sánh | $eq $ne $gt $gte $lt $lte $in $nin | { total: { $gte: 20000000 } } |
| Logic | $and $or $nor $not | { $or: [ {...}, {...} ] } |
| Kiểu dữ liệu | $exists $type | { couponCode: { $exists: true } } |
| Mảng | $elemMatch $all $size | phần mảng ở dưới |
| Khác | $regex $expr $jsonSchema $mod | $expr so sánh hai field với nhau |
Muốn đi vào field lồng bên trong thì dùng dot notation: "items.sku" nghĩa là field sku bên trong items.
Chạy thử trên collection orders 1 triệu đơn hàng (cách sinh dữ liệu ở phần thí nghiệm cuối bài):
const c = q => db.orders.countDocuments(q);
c({ tenantId: "t0042" });
c({ tenantId: "t0042", status: "pending" });
c({ tenantId: "t0042", status: { $in: ["pending", "refunded"] } });
c({ tenantId: "t0042", $or: [{ status: "pending" }, { total: { $gte: 20000000 } }] });
c({ tenantId: "t0042", status: { $ne: "completed" } });tenant t0042 : 2003
t0042 + status pending : 196
t0042 + status in [pending,refund]: 389
t0042 + (pending OR total>=20tr) : 203
t0042 + status ne completed : 590Bẫy: $ne và field không tồn tại
Không document nào trong orders có field couponCode. Thử hai câu sau:
db.orders.countDocuments({ couponCode: { $exists: true } });
db.orders.countDocuments({ couponCode: { $ne: "SALE10" } });couponCode exists : 0
couponCode ne SALE10 : 1000000$ne nghĩa là "không bằng", và một field không tồn tại thì đúng là không bằng "SALE10". Thế nên query "các đơn không dùng mã SALE10" trả về cả 1 triệu đơn, kể cả đơn chưa bao giờ có khái niệm mã giảm giá. Với schema linh hoạt (bài 02), "field vắng mặt" là một trạng thái riêng, và bạn phải tự quyết định nó thuộc về nhóm nào. Nếu ý bạn là "có mã nhưng không phải SALE10", hãy viết rõ: { couponCode: { $exists: true, $ne: "SALE10" } }.
Bẫy thứ hai đã có ở bài 03: so sánh chỉ khớp trong cùng nhóm kiểu BSON. { userId: 42 } không thấy document có userId: "42". Filter đúng cú pháp nhưng sai kiểu thì trả về rỗng mà không báo lỗi.
Projection: chỉ mang về những gì cần
Projection là tham số thứ hai của find. Bạn chọn một trong hai kiểu:
- Inclusion (chọn field muốn lấy):
{ status: 1, total: 1 }. - Exclusion (chọn field muốn bỏ):
{ items: 0 }.
_id luôn được trả về trừ khi bạn ghi rõ _id: 0. Ngoài _id, không được trộn hai kiểu:
db.orders.findOne({}, { tenantId: 1, items: 0 });projection error: Location31254 - Cannot do exclusion on field items in inclusion projectionDot notation cũng dùng được trong projection. { _id: 0, status: 1, total: 1, "items.sku": 1 } trả về:
{ status: 'completed', total: 8280000,
items: [ { sku: 'SKU-4780' }, { sku: 'SKU-367' }, { sku: 'SKU-1790' } ] }Projection tiết kiệm cái gì, và không tiết kiệm cái gì
Lấy 2.003 đơn của tenant t0042, một lần lấy nguyên document, một lần chỉ lấy total:
bytes returned full: 431805 projected: 32048
projection -> stage: PROJECTION_SIMPLE <- COLLSCAN docsExamined: 1000000 nReturned: 2003Lượng dữ liệu gửi về app giảm khoảng 13,5 lần (431.805 xuống 32.048 byte). Nhưng docsExamined vẫn là 1.000.000. Server vẫn phải đọc từng document để biết document đó có thuộc t0042 hay không, rồi mới cắt bớt field.
Projection
│
├── ✓ ít byte qua mạng hơn
├── ✓ app tốn ít RAM và CPU để decode BSON hơn
└── ✗ KHÔNG giảm số document server phải đọcỞ phiếu yêu cầu, "chỉ photo trang tổng tiền" làm cho tập giấy bạn nhận mỏng đi. Nhưng nhân viên vẫn phải lật từng bìa để tìm đúng hồ sơ.
Sort, skip và limit
Server làm gì khi bạn sort mà không có index
Xem plan của một trang danh sách "đơn mới nhất của tenant, trang thứ N" (limit(20), skip tăng dần):
db.orders.find({ tenantId: "t0042" }).sort({ createdAt: -1 }).skip(sk).limit(20).explain("executionStats")skip 0 | plan: SORT(limit 20) <- COLLSCAN | docsExamined: 1000000 | nReturned: 20
skip 1000 | plan: SKIP(skip 1000) <- SORT(limit 1020) <- COLLSCAN | docsExamined: 1000000 | nReturned: 20
skip 1900 | plan: SKIP(skip 1900) <- SORT(limit 1920) <- COLLSCAN | docsExamined: 1000000 | nReturned: 20Đọc từ phải sang trái:
- COLLSCAN đọc cả 1 triệu document và giữ lại 2.003 đơn của t0042.
- SORT(limit 1020) xếp những đơn đó và chỉ cần giữ 1.020 cái đầu (skip + limit).
- SKIP vứt 1.000 document đầu, trả 20.
Sort không có index là blocking sort: phải đọc hết input rồi mới biết document nào đứng đầu. Nó dùng bộ nhớ ra sao và giới hạn ở đâu là chuyện của bài 15.
skip không miễn phí
Không sort thì COLLSCAN có thể dừng sớm. Nhưng skip vẫn phải đi qua những document bị bỏ:
no-sort skip 0 | plan: LIMIT(limit 20) <- COLLSCAN | docsExamined: 20 | nReturned: 20
no-sort skip 500000 | plan: LIMIT(limit 20) <- SKIP(skip 500000) <- COLLSCAN | docsExamined: 500020 | nReturned: 20Để trả về 20 document ở "trang 25.001", server đọc 500.020 document. Tài liệu MongoDB nói thẳng: skip() phải quét từ đầu tập kết quả, và offset càng lớn thì càng chậm.
Trang 1 ──► đọc 20 document
Trang 25001 ──► đọc 500.020 document, vứt 500.000Thêm một chi tiết dễ bỏ qua: nếu sort theo field có giá trị trùng (nhiều đơn cùng createdAt), thứ tự giữa các document trùng không được đảm bảo giữa các lần chạy. Phân trang bằng skip khi đó có thể lặp hoặc sót document. Tài liệu khuyên thêm một field duy nhất vào sort, thường là _id: sort({ createdAt: -1, _id: -1 }). Cách phân trang không dùng skip (range/cursor pagination theo createdAt + _id) sẽ được bàn ở bài 13, sau khi có index.
Cursor và batch: vì sao find() không trả hết một lần
30 giây
find() không trả về một mảng. Nó trả về một cursor, tức một "con trỏ" đang mở trên server. Lần gọi đầu mang về batch đầu tiên. Khi app đọc hết batch đó, driver tự gửi lệnh getMore để lấy batch tiếp theo, cho đến khi server báo cursor đã cạn (cursorId: 0).
Mental model: xe đẩy hồ sơ
Bạn yêu cầu 2.003 hồ sơ. Nhân viên không bê cả 2.003 bìa ra quầy một lần, vì quầy không đủ chỗ và bạn cũng chưa chắc đọc hết. Họ đẩy ra một xe nhỏ trước. Bạn đọc xong thì bấm chuông, họ đẩy xe tiếp theo. Kho nhớ bạn đang dừng ở đâu nhờ một số phiếu (cursor id).
App / driver mongod
│ find { filter } │
│ ───────────────────────────────────────►│ chạy plan, gom tới khi đủ batch đầu
│ ◄─────────────────────────────────────── │ firstBatch: 101 docs, cursorId: 5200...
│ (app đọc hết 101 doc) │
│ getMore 5200... │
│ ───────────────────────────────────────►│ chạy tiếp từ chỗ đã dừng
│ ◄─────────────────────────────────────── │ nextBatch: 1902 docs, cursorId: 0 (cạn)Quan sát thật
Bài 01 đã thấy batch đầu mặc định là 101 document. Ở đây ta đẩy thêm vài trường hợp, gọi thẳng command find và getMore để nhìn thấy từng batch:
let r = db.runCommand({ find: "orders", filter: { tenantId: "t0042" } });
// rồi lặp: db.runCommand({ getMore: r.cursor.id, collection: "orders" }) cho tới khi id = 0find -> firstBatch: 101 cursorId: 5200192963447768025
getMore #1 -> nextBatch: 1902 cursorId: 0
tong so document: 2003
batchSize 500 -> batches: [500,500,500,500,3]
selective -> firstBatch: 21 cursorId: 0
full scan getMore #1 -> 75580 docs, 15.51 MiBBốn điều đáng chú ý:
- Batch đầu 101 document, các
getMoresau không có giới hạn số lượng mặc định.getMoređầu tiên lấy luôn 1.902 document còn lại. batchSizeđặt được. VớibatchSize: 500, cùng 2.003 document về thành 5 chuyến: 500, 500, 500, 500, 3.- Trần thật là 16 MiB mỗi batch. Khi
find({})trên cả collection,getMoređầu tiên mang về 75.580 document, tổng 15,51 MiB, rồi dừng vì batch tiếp theo sẽ vượt 16 MiB. Tài liệu của lệnhgetMoreghi rõ: không đặtbatchSizethì mỗi batch tối đa 16 MiB; có đặt thì lấy giá trị nhỏ hơn giữabatchSizedocument và 16 MiB. - Query chọn lọc thì cả kết quả nằm trong batch đầu, và cursor đóng ngay (
firstBatch: 21, cursorId: 0). Nghe thì tốt, nhưng hãy nghĩ ngược lại: server chỉ được trả batch đầu khi đã gom đủ 101 kết quả hoặc đã đi hết collection. Chỉ có 21 kết quả nên nó phải quét hết 1 triệu document rồi mới gửi được byte đầu tiên về app. Với COLLSCAN, thời gian chờ document đầu tiên có thể bằng thời gian của cả query.
Batch size là một trade-off
Batch nhỏ (batchSize thấp) Batch lớn
├── ✓ app nhận dữ liệu đầu sớm ├── ✓ ít round-trip qua mạng
├── ✓ ít RAM mỗi lần ở cả hai phía ├── ✗ mỗi message nặng, tốn RAM
└── ✗ nhiều round-trip getMore └── ✗ app chờ lâu hơn cho batch đầuBa chi tiết thực tế khác:
- Shell không phải server. Trong mongosh,
find()hiện 20 document rồi bảo bạn gõit. Con số 20 làdisplayBatchSizecủa shell, không phải batch của server. - Cursor có hạn sử dụng. Cursor không hoạt động sẽ bị server đóng sau
cursorTimeoutMillis, mặc định 10 phút (lab đọc được600000). Mỗi lần trả batch thì đồng hồ được đặt lại. App xử lý mỗi batch quá lâu có thể gặp lỗiCursorNotFound. - Cursor không phải ảnh chụp. Trong lúc bạn đọc dần từng batch, các thao tác ghi khác vẫn chạy. Tài liệu cảnh báo một document có thể được trả về nhiều hơn một lần nếu nó bị cập nhật trong lúc cursor đang mở. Vì sao lại vậy (query nhường tài nguyên giữa chừng, gọi là yielding) là chủ đề của bài 15; chuyện "đọc nhất quán" thuộc về read concern và snapshot, bài 17.
Ghi dữ liệu: update operator hay replacement
Mental model: phiếu sửa và phiếu thay thế
Phiếu SỬA (update operator) Phiếu THAY THẾ (replacement)
"ở hồ sơ u1: plan = pro, "rút hồ sơ u1 ra, đặt tờ này vào:
loginCount + 1" { plan: 'pro' }"
│ │
▼ ▼
các field khác giữ nguyên ✓ mọi field khác BIẾN MẤT ✗ (trừ _id)Trước và sau, trên dữ liệu thật
Một user có đủ thông tin:
db.users.insertOne({ _id: "u1", tenantId: "t0042", name: "Lan", plan: "free", loginCount: 3, tags: ["beta"] });Trước: dùng replacement để "đổi gói".
db.users.replaceOne({ _id: "u1" }, { plan: "pro" });
db.users.findOne({ _id: "u1" });{ _id: 'u1', plan: 'pro' }tenantId, name, loginCount, tags đều mất. Document cũ đã bị thay toàn bộ, chỉ còn _id (vì _id bất biến, bài 02). Phương thức cũ update() (đã deprecated) nhận một document không có $ sẽ âm thầm làm đúng việc này. updateOne thì chặn lại:
db.users.updateOne({ _id: "u1" }, { plan: "pro" });error: Update document requires atomic operatorsSau: dùng update operator.
db.users.updateOne({ _id: "u1" }, {
$set: { plan: "pro" },
$inc: { loginCount: 1 },
$addToSet: { tags: "paid" },
$currentDate: { updatedAt: true }
});{ _id: 'u1', tenantId: 't0042', name: 'Lan', plan: 'pro', loginCount: 4,
tags: [ 'beta', 'paid' ], updatedAt: ISODate('2026-10-08T03:46:54.374Z') }Chỉ những field được nhắc tới mới thay đổi. Toàn bộ thay đổi trên áp dụng cùng lúc, atomic, vì chúng nằm trong một lệnh ghi lên một document (bài 02).
| Nhóm | Operator | Dùng khi |
|---|---|---|
| Field | $set $unset $inc $mul $min $max $rename $currentDate $setOnInsert | đổi, xoá, cộng dồn field |
| Mảng | $push $addToSet $pull $pullAll $pop, và $, $[], $[<id>] | thêm, bớt, sửa phần tử mảng |
| Modifier | $each $position $slice $sort | đi kèm $push, ví dụ giữ 50 phần tử mới nhất |
Khi nào replacement là đúng? Khi app thật sự sở hữu toàn bộ document và muốn ghi đè nó, ví dụ lưu lại một cấu hình mà app vừa dựng đầy đủ. Khi đó hãy gọi replaceOne để ý định rõ ràng.
Có một khác biệt nữa về chi phí: update operator chỉ gửi qua mạng phần thay đổi, còn replacement gửi nguyên document. Với document vài KB và hàng nghìn lệnh ghi mỗi giây, khác biệt này đáng kể.
Upsert: "có thì sửa, chưa có thì tạo"
upsert: true gộp hai nhánh vào một lệnh. Nếu không document nào khớp filter, MongoDB tạo document mới từ các điều kiện bằng (equality) trong filter, cộng với kết quả của update operator. $setOnInsert chỉ chạy ở nhánh tạo mới.
const up = {
$setOnInsert: { createdAt: ISODate("2026-10-01T00:00:00Z"), plan: "free" },
$inc: { loginCount: 1 }
};
db.users.updateOne({ tenantId: "t0042", email: "minh@example.com" }, up, { upsert: true }); // lần 1
db.users.updateOne({ tenantId: "t0042", email: "minh@example.com" }, up, { upsert: true }); // lần 2lần 1: { matchedCount: 0, modifiedCount: 0, upsertedCount: 1, insertedId: ObjectId('6ac7...ca58') }
lần 2: { matchedCount: 1, modifiedCount: 1, upsertedCount: 0, insertedId: null }
{ tenantId: 't0042', email: 'minh@example.com',
createdAt: ISODate('2026-10-01T00:00:00.000Z'), loginCount: 2, plan: 'free' }tenantId và email được chép từ filter sang document mới. createdAt và plan chỉ được đặt một lần. loginCount tăng ở cả hai lần.
⚠ Upsert có một điểm yếu khi chạy đồng thời. Nếu hai request cùng upsert một email chưa tồn tại, cả hai có thể cùng thấy "không có" và cùng tạo mới. Tài liệu của findOneAndUpdate khuyên: để tránh upsert nhiều lần, hãy đảm bảo các field trong filter có unique index. Đây là một trong những lý do index không chỉ để tăng tốc. Bài 09 (unique index) sẽ quay lại chuyện này.
findOneAndUpdate: sửa và lấy kết quả trong một bước
updateOne chỉ trả về số lượng (matchedCount, modifiedCount). Khi bạn cần chính document sau khi sửa, ví dụ để lấy số hoá đơn tiếp theo, hãy dùng findOneAndUpdate. Lưu ý mặc định của nó: trả về bản TRƯỚC khi sửa.
db.counters.insertOne({ _id: "invoice:t0042", seq: 100 });
db.counters.findOneAndUpdate({ _id: "invoice:t0042" }, { $inc: { seq: 1 } });
db.counters.findOneAndUpdate({ _id: "invoice:t0042" }, { $inc: { seq: 1 } }, { returnDocument: "after" });{ _id: 'invoice:t0042', seq: 100 } // mặc định: bản cũ, dù seq đã thành 101
{ _id: 'invoice:t0042', seq: 102 } // returnDocument: "after"Vì sao không "updateOne rồi findOne lại" cho đơn giản? Thí nghiệm: hai tiến trình mongosh chạy song song, mỗi bên xin 2.000 số hoá đơn từ cùng một counter.
// Cách A: hai bước
db.counters.updateOne({ _id: "invoice:t0042" }, { $inc: { seq: 1 } });
got.push(db.counters.findOne({ _id: "invoice:t0042" }).seq);
// Cách B: một bước
got.push(db.counters.findOneAndUpdate({ _id: "invoice:t0042" },
{ $inc: { seq: 1 } }, { returnDocument: "after" }).seq);Kết quả thật, 3 lần chạy mỗi cách, mỗi lần reset counter về 0:
| Lần | Cách A: số phát ra / số khác nhau | Cách A: số bị trùng | Cách B: số phát ra / số khác nhau | Cách B: số bị trùng |
|---|---|---|---|---|
| 1 | 4.000 / 2.238 | 1.762 | 4.000 / 4.000 | 0 |
| 2 | 4.000 / 2.275 | 1.725 | 4.000 / 4.000 | 0 |
| 3 | 4.000 / 2.218 | 1.782 | 4.000 / 4.000 | 0 |
Điều thú vị là ở cả hai cách, counter cuối cùng đều đúng bằng 4.000: mỗi lệnh $inc vẫn atomic, không lần tăng nào bị mất. Cái sai nằm ở bước đọc. Giữa lúc A tăng counter và lúc A đọc lại, B đã kịp tăng thêm, nên A và B cùng đọc ra một số. Khoảng 44% số hoá đơn bị phát trùng. findOneAndUpdate sửa và trả về trong cùng một thao tác atomic trên một document, nên không có khe hở.
Cách A (2 bước) Cách B (1 bước)
A: $inc → seq = 57 A: findOneAndUpdate → nhận 57
B: $inc → seq = 58 B: findOneAndUpdate → nhận 58
A: đọc → 58 ✗ không ai chen vào giữa ✓
B: đọc → 58 ✗ trùng sốBài 02 đã thấy lỗi này ở dạng "đọc, tính ở app, rồi ghi" (lost update). Ở đây lệnh ghi đã đúng mà vẫn hỏng, vì lần đọc kết quả bị tách khỏi lệnh ghi.
bulkWrite: gửi nhiều lệnh ghi trong một chuyến
Ghi 10.000 thay đổi bằng 10.000 lệnh riêng nghĩa là 10.000 round-trip. bulkWrite (và insertMany) gom chúng vào ít chuyến hơn. Câu hỏi quan trọng là chuyện gì xảy ra khi một lệnh ở giữa bị lỗi.
Thí nghiệm: collection đã có _id: 2, gửi bốn lệnh insert _id 1, 2, 3, 4. Lệnh thứ hai (index 1) chắc chắn lỗi trùng khoá.
db.bw.bulkWrite([1, 2, 3, 4].map(i => ({ insertOne: { document: { _id: i } } })), { ordered });index: 1 code: 11000 errmsg: E11000 duplicate key error collection: lab05.bw index: _id_ dup key: { _id: 2 }
ordered=true | insertedCount: 1 | _id trong collection: [1,2]
ordered=false | insertedCount: 3 | _id trong collection: [1,2,3,4]ordered: true (mặc định) ordered: false
insert 1 ✓ insert 1 ✓
insert 2 ✗ DỪNG insert 2 ✗ bỏ qua, đi tiếp
insert 3 – không chạy insert 3 ✓
insert 4 – không chạy insert 4 ✓ordered: truechạy tuần tự và dừng ở lỗi đầu tiên. Dùng khi lệnh sau phụ thuộc lệnh trước, ví dụ insert rồi update chính document đó.ordered: falsechạy tiếp mọi lệnh không lỗi, và theo tài liệu thì các lệnh có thể được thực hiện song song. Dùng cho import hoặc đồng bộ dữ liệu, nơi các lệnh độc lập với nhau. Bài này sinh 1 triệu document bằnginsertMany(..., { ordered: false }).
⚠ Trong cả hai trường hợp, bulkWrite không atomic như một khối. Lệnh _id: 1 đã ghi thì vẫn nằm đó dù lệnh sau lỗi, không có rollback. Mỗi lệnh con atomic trên document của nó, chỉ vậy thôi. Ngoài ra server nhận tối đa maxWriteBatchSize lệnh mỗi batch (lab đọc được 100000). Driver tự chia mảng lớn hơn thành nhiều batch.
Xoá dữ liệu: deleteOne, deleteMany, findOneAndDelete
Phiếu xoá cũng là một filter: "rút khỏi kho những hồ sơ khớp mô tả này". Ba lệnh khác nhau ở số document bị xoá và thứ được trả về:
// sessions: s1, s2 (u0007, expired) và s3 (u0009, active), cùng tenant t0042
db.sessions.deleteOne({ userId: "u0007" }); // xoá document khớp đầu tiên
db.sessions.findOneAndDelete({ userId: "u0007" }); // xoá và trả về chính document đó
db.sessions.deleteMany({ tenantId: "t0042" }); // xoá mọi document khớp
db.sessions.deleteMany({ tenantId: "t9999" }); // không khớp gì: không lỗideleteOne : { acknowledged: true, deletedCount: 1 }
findOneAndDelete : { _id: 's2', tenantId: 't0042', userId: 'u0007', status: 'expired' }
deleteMany : { acknowledged: true, deletedCount: 1 }
deleteMany none : { acknowledged: true, deletedCount: 0 }findOneAndDelete là bản "xoá và lấy kết quả trong một bước" giống findOneAndUpdate, hợp với hàng đợi việc: lấy một job ra và chắc chắn không worker nào lấy trùng. Muốn biết deleteOne xoá document nào khi nhiều cái cùng khớp thì đừng đoán: xoá theo _id, hoặc dùng findOneAndDelete với sort. Và như updateMany, deleteMany atomic theo từng document chứ không như một khối.
Xoá theo filter hay theo _id cũng khác nhau ở chi phí. Explain trong lab: deleteMany({ tenantId: "t0042" }) trên 500.000 đơn chạy BATCHED_DELETE ← COLLSCAN, đọc cả 500.000 document để tìm 478 cái cần xoá. Xoá theo _id dùng EXPRESS_DELETE trên index _id_, đọc đúng 1 key và 1 document. Tìm để xoá chính là một query, nên mọi điều ở phần filter đều áp dụng.
Xoá nhiều không miễn phí
Tìm được rồi thì mỗi document bị xoá còn phải được gỡ khỏi mọi index của collection. Thí nghiệm: 500.000 đơn, deleteMany({ status: "cancelled" }) xoá 49.109 document, một lần collection chỉ có _id_, một lần thêm 3 index phụ. 5 lượt, mỗi lượt nạp lại dữ liệu:
Môi trường: container mongo-lab-06, MongoDB 8.3.11, 2 CPU, 3 GB RAM, cache 1 GB, Apple M4, database lab06
chỉ _id_ : 140, 145, 140, 145, 157 ms → median 145 ms
_id_ + 3 index : 254, 243, 941, 233, 252 ms → median 252 ms (941 ms là một lượt lệch, chưa rõ nguyên nhân)Cùng số document, thêm 3 index làm lệnh xoá chậm khoảng 1,7 lần. Đây là cái giá ghi của index, bài 07 sẽ đo kỹ. Trên replica set, mỗi document bị xoá còn là một entry trong oplog mà các secondary phải chạy lại (bài 25). Xoá hàng triệu document một lúc vì vậy nên chia thành nhiều đợt nhỏ.
Hai lựa chọn khác thay cho việc tự viết job xoá:
- TTL index: server tự xoá document khi một field ngày tháng đã quá hạn, hợp với session, log, OTP. Nó vẫn là những lệnh delete chạy nền, với cùng chi phí ở trên (bài 09).
- Soft delete: không xoá, chỉ đặt
deletedAt. Đây là lựa chọn mô hình dữ liệu chứ không phải tính năng: giữ được lịch sử và khôi phục được, nhưng mọi query phải nhớ thêm điều kiệndeletedAt: null, và collection không nhỏ đi.
Atomic theo từng document, và những gì nằm ngoài
Gom lại phần ghi bằng một quy tắc mà tài liệu MongoDB nêu rõ: thao tác ghi atomic ở mức một document, dù sửa bao nhiêu field. Khi một lệnh như updateMany() sửa nhiều document, mỗi document được sửa atomic, nhưng cả lệnh thì không, và thao tác khác có thể chen vào giữa.
Atomic ✓ Không atomic ✗
1 updateOne / findOneAndUpdate updateMany / deleteMany nhìn như một khối
/ deleteOne trên 1 document bulkWrite nhìn như một khối
"ghi rồi đọc lại" từ app (thí nghiệm hoá đơn)Cần atomic trên nhiều document thì có multi-document transaction (bài 16). Còn một câu hỏi chưa đặt ra: khi server trả acknowledged: true, dữ liệu đã bền tới mức nào? Đã ghi journal chưa, đã sang secondary chưa? Đó là write concern, bài 18 (cơ chế journal bên dưới nằm ở bài 22). Trong lab standalone này, mọi kết quả ở trên dùng write concern mặc định.
Mảng: khi một field chứa nhiều giá trị
Hình dung trước: hai câu hỏi về một lớp học
Hai câu nghe giống nhau nhưng khác nhau hoàn toàn:
- "Tìm lớp có ai đó cao trên 1m50, và có ai đó thấp dưới 1m60." Người cao có thể là bạn A, người thấp có thể là bạn B.
- "Tìm lớp có một bạn cao trong khoảng 1m50 đến 1m60." Một người phải thoả cả hai điều kiện.
MongoDB có cả hai cách hỏi. Cách thứ nhất là cách mặc định khi bạn đặt điều kiện thẳng lên field mảng. Cách thứ hai là $elemMatch.
Quy tắc gốc: multikey matching
Khi field là mảng, một điều kiện như { vals: { $gt: 1 } } khớp nếu ít nhất một phần tử thoả điều kiện. Nhưng khi đặt nhiều điều kiện cùng lúc ($gt và $lt), tài liệu MongoDB giải thích: mảng chỉ cần thoả các điều kiện theo một tổ hợp nào đó của các phần tử. Một phần tử có thể thoả $gt, một phần tử khác thoả $lt, hoặc một phần tử thoả cả hai.
Thí nghiệm với 5 document nhỏ để thấy từng trường hợp:
db.readings.insertMany([
{ _id: "A", vals: [3] },
{ _id: "B", vals: [0, 10] },
{ _id: "C", vals: [7, 8, 2] },
{ _id: "D", vals: [9, 12] },
{ _id: "E", vals: 4 }
]);
db.readings.find({ vals: { $gt: 1, $lt: 5 } });
db.readings.find({ vals: { $elemMatch: { $gt: 1, $lt: 5 } } });plain : ["A","B","C","E"]
elemMatch : ["A","C"]| Doc | vals | Không dùng $elemMatch | $elemMatch | Vì sao |
|---|---|---|---|---|
| A | [3] | ✓ | ✓ | 3 nằm trong (1, 5) |
| B | [0, 10] | ✓ | ✗ | 10 thoả > 1, 0 thoả < 5, nhưng không phần tử nào nằm giữa 1 và 5 |
| C | [7, 8, 2] | ✓ | ✓ | 2 nằm trong (1, 5) |
| D | [9, 12] | ✗ | ✗ | không phần tử nào < 5 |
| E | 4 (không phải mảng) | ✓ | ✗ | $elemMatch chỉ khớp với field là mảng |
Document B là cái bẫy. Nếu vals là các lần đo nhiệt độ và bạn muốn "có lần đo nằm trong ngưỡng", B là kết quả sai. Document E cho thấy thêm một khác biệt: $elemMatch loại các document mà field không phải mảng.
Trên dữ liệu thật: mảng các object
Với mảng các object như items, bẫy này còn khó thấy hơn. "Đơn có món nào mua từ 9 cái trở lên với đơn giá từ 950.000đ":
const plain = { "items.qty": { $gte: 9 }, "items.price": { $gte: 950000 } };
const em = { items: { $elemMatch: { qty: { $gte: 9 }, price: { $gte: 950000 } } } };
db.orders.countDocuments(plain);
db.orders.countDocuments(em);orders plain : 45633
orders elemMatch: 23977Cách viết dot notation trả về gần gấp đôi. 21.656 đơn thừa ra là những đơn mà điều kiện số lượng và điều kiện giá nằm ở hai món khác nhau, ví dụ đơn này:
{ tenantId: 't0478',
items: [ { sku: 'SKU-4259', qty: 7, price: 990000 }, ← giá cao, nhưng qty 7
{ sku: 'SKU-4897', qty: 10, price: 820000 } ] } ← qty 10, nhưng giá thấpMột báo cáo "món bán sỉ giá cao" viết bằng dot notation sẽ sai gần gấp đôi, không có lỗi nào được báo, và rất khó phát hiện bằng mắt.
Quy tắc ngắn gọn:
Nhiều điều kiện phải đúng trên CÙNG một phần tử → $elemMatch
Mỗi điều kiện chỉ cần đúng ở MỘT phần tử nào đó → điều kiện thường / dot notation
Chỉ có một điều kiện → hai cách gần như tương đương
(trừ chuyện $elemMatch đòi field là mảng)Cả hai query trên đều đang là COLLSCAN. Khi có index trên một field mảng (gọi là multikey index), cách viết điều kiện còn ảnh hưởng đến việc index được dùng hiệu quả tới đâu. Đó là chủ đề của bài 09.
Thí nghiệm: một query chọn lọc trên 1 triệu document
Giờ ghép mọi thứ lại cho câu query mà gần như mọi ứng dụng SaaS đều có: "các đơn đang chờ xử lý của tenant này trong tháng gần đây, mới nhất trước."
Setup
MongoDB version : 8.3.11 (Docker image mongo:8, standalone)
Hardware : Apple M4 host; container giới hạn 4 CPU, 4 GB RAM
Configuration : WiredTiger cache 1 GB (--wiredTigerCacheSizeGB 1)
Dataset : lab05.orders, 1.000.000 document
Kích thước : 215 MB chưa nén (avgObjSize 215 byte), 72,6 MB trên đĩa
Indexes : chỉ có _id_ (mặc định)Dataset
500 tenant, mỗi tenant 200 user, trạng thái phân bố 70% completed, 10% pending, 10% cancelled, 10% refunded, createdAt rải đều trong 365 ngày trước 2026-10-01. Số sinh từ PRNG có seed cố định, nên chạy lại sẽ ra đúng dữ liệu này.
// trích gen.js: 100 lần insertMany, mỗi lần 10.000 document
docs.push({
tenantId: "t" + String(Math.floor(rnd() * 500)).padStart(4, "0"),
userId: "u" + String(Math.floor(rnd() * 200)).padStart(4, "0"),
status: pickStatus(rnd()),
createdAt: new Date(END - Math.floor(rnd() * 365 * DAY)),
total: items.reduce((s, it) => s + it.qty * it.price, 0), // VND, số nguyên
items // 1–3 món { sku, qty, price }
});
db.orders.insertMany(docs, { ordered: false });inserted 1000000 docs in 22561 msGom thành batch 10.000 thay vì insert từng cái là ứng dụng trực tiếp phần bulkWrite ở trên: 100 round-trip thay vì 1 triệu.
Query và explain
db.orders.find({
tenantId: "t0042",
status: "pending",
createdAt: { $gte: ISODate("2026-09-01T00:00:00Z") }
}).sort({ createdAt: -1 }).explain("executionStats")Output thật (đã cắt bớt):
explainVersion: '1'
winningPlan: {
stage: 'SORT', sortPattern: { createdAt: -1 },
inputStage: {
stage: 'COLLSCAN',
filter: { '$and': [ { status: { '$eq': 'pending' } },
{ tenantId: { '$eq': 't0042' } },
{ createdAt: { '$gte': ISODate('2026-09-01T00:00:00.000Z') } } ] },
direction: 'forward'
}
}
rejectedPlans: 0
executionStats: {
nReturned: 21,
executionTimeMillis: 161,
totalKeysExamined: 0,
totalDocsExamined: 1000000
}Đọc từng con số
winningPlan: SORT ← COLLSCAN. Không có index nào dùng được, nên planner chỉ có một lựa chọn (rejectedPlans: 0). COLLSCAN đọc lần lượt mọi document, áp filter lên từng cái, rồi SORT xếp những cái còn lại.totalKeysExamined: 0. Không đụng tới index nào.totalDocsExamined: 1.000.000,nReturned: 21. Để trả về 21 document, server xem cả 1 triệu. Tức là khoảng 47.600 document bị đọc rồi vứt cho mỗi document có ích.executionTimeMillis: 161ở lần chạy này. Theo tài liệu, con số này gồm thời gian chọn plan và thực thi, không gồm thời gian gửi dữ liệu qua mạng. Một lần đo thì chưa đủ tin, nên phần tiếp theo đo lặp lại.
Mấy field này (plan thắng, keys examined, docs examined, nReturned, thời gian) là đủ cho tới hết Phần 2. Từng stage làm việc ra sao bên trong (works, advanced, needTime...) để dành cho bài 10 và bài 15.
Đo lặp lại: chi phí đi theo kích thước collection, không theo số kết quả
Mỗi query chạy explain("executionStats") 9 lần, cả bộ chạy 2 lượt. Bảng ghi median của từng lượt:
| Query | nReturned | totalDocsExamined | median lượt 1 | median lượt 2 |
|---|---|---|---|---|
| tenant + pending + từ 01/09 | 21 | 1.000.000 | 143 ms | 143 ms |
| chỉ tenant | 2.003 | 1.000.000 | 138 ms | 144 ms |
status: "completed" | 699.990 | 1.000.000 | 150 ms | 152 ms |
| tenant không tồn tại | 0 | 1.000.000 | 149 ms | 138 ms |
Gọi find(...).toArray() thật (gồm cả đường về app) cho query đầu: median 144 ms và 141 ms ở hai lượt.
Đây là điểm quan trọng nhất của bài. Query trả về 0 document, 21 document hay 700.000 document đều tốn khoảng 140–150 ms, vì cả bốn đều làm cùng một việc: đọc 1 triệu document. Với COLLSCAN, chi phí do kích thước collection quyết định, không phải do kích thước kết quả. Thêm điều kiện để kết quả nhỏ hơn không làm query rẻ hơn.
Bài 01 đo query tương tự trên 200.000 document mất 33–39 ms. Ở đây có gấp 5 lần document và mất khoảng 4 lần thời gian. Hình dạng document hai bài khác nhau nên đây chỉ là so sánh thô, nhưng nó khớp với nhận định "COLLSCAN tăng tuyến tính theo kích thước collection".
Và đây vẫn là trường hợp tốt nhất. 215 MB dữ liệu nằm gọn trong 1 GB cache (lúc đo, cache đang giữ 491 MB), nên không có lần đọc đĩa nào. Khi collection lớn hơn cache, mỗi lần quét còn kéo theo I/O và đẩy dữ liệu nóng khác ra khỏi cache (bài 21).
Từ một query sang cả hệ thống
143 ms cho một request nghe vẫn chấp nhận được. Nhưng server không chỉ chạy một request: mỗi lượt quét giữ một CPU bận khoảng 140 ms chỉ để lật 1 triệu document, nên khi nhiều client cùng gọi, chúng xếp hàng chờ CPU và latency của mọi người tăng theo. Khác biệt giữa query performance và system performance là chủ đề của bài 13; đo tải và tính capacity nằm ở bài 34.
Kết luận của thí nghiệm
Quay lại kho lưu trữ. Ta muốn 21 hồ sơ. Nhân viên lật 1.000.000 bìa để tìm chúng, và lần nào cũng lật lại từ đầu, vì kho không có cuốn sổ nào ghi "hồ sơ của t0042 nằm ở đâu".
HIỆN TẠI ĐIỀU TA MUỐN
Query Query
↓ ↓
COLLSCAN: 1.000.000 document ??? đi thẳng tới đơn của t0042,
↓ status pending, từ 01/09
filter: vứt 999.979 ↓
↓ ~21 document
21 kết quảKhông cú pháp nào trong bài này lấp được khoảng cách đó. Filter, projection, limit đều không giảm totalDocsExamined khi kết quả hiếm hoặc khi phải sort. Thứ duy nhất làm được việc đó là cho server biết trước dữ liệu nằm ở đâu.
So với PostgreSQL
Phần lớn khái niệm trong bài có bản tương ứng bên SQL. Khác biệt nằm ở chi tiết, và chính chi tiết gây bug:
| Việc | MongoDB | PostgreSQL |
|---|---|---|
| Lọc và chọn cột | find(filter, projection) | SELECT cột ... WHERE ... |
| Giá trị vắng mặt | field không tồn tại; $ne khớp cả field vắng | NULL; col <> 'x' không khớp hàng có NULL |
| Lấy kết quả từng phần | cursor + batch là mặc định | libpq mặc định nhận cả kết quả; muốn từng phần thì dùng DECLARE CURSOR/FETCH hoặc fetch size của driver |
| Sửa và trả về | findOneAndUpdate(..., { returnDocument: "after" }) | UPDATE ... RETURNING * |
| Upsert | upsert: true, chạy được cả khi không có unique index (nhưng có thể tạo trùng khi chạy đồng thời) | INSERT ... ON CONFLICT ... DO UPDATE bắt buộc có unique index hoặc constraint làm đích xung đột. MERGE (từ PostgreSQL 15) không cần constraint, nhưng cũng không chống được chạy đồng thời: hai MERGE cùng thấy "chưa có" thì cùng insert, và nếu có unique index thì một bên nhận lỗi uniqueness violation |
| Xoá và trả về | findOneAndDelete | DELETE ... RETURNING * |
| Không có index phù hợp | COLLSCAN | Seq Scan |
Dòng upsert đáng chú ý nhất. Với ON CONFLICT, PostgreSQL buộc bạn khai báo ràng buộc trước khi cho upsert, và đổi lại đảm bảo mỗi hàng hoặc được insert hoặc được update. MongoDB cho chạy ngay, và để ràng buộc về sau cho bạn tự nhớ. MERGE của PostgreSQL giống MongoDB ở điểm này: tài liệu PostgreSQL nói rõ nó không đảm bảo insert hay update sẽ xảy ra khi có thao tác đồng thời.
Những lỗi thường gặp
- Truyền document không có
$vào lệnh update vì muốn "đổi một field". Driver hiện đại chặn ởupdateOne, nhưngreplaceOnehayfindOneAndReplacesẽ xoá sạch các field còn lại. - Dùng
$ne/$ninmà quên field có thể vắng mặt. Kết quả gồm cả những document "không liên quan". - Đặt nhiều điều kiện lên field mảng mà không dùng
$elemMatch. Trong lab, báo cáo đếm ra 45.633 thay vì 23.977. - "Ghi rồi đọc lại" để lấy giá trị vừa tạo. Hãy dùng
findOneAndUpdatevớireturnDocument: "after". - Quên rằng
findOneAndUpdatemặc định trả bản cũ. - Tin rằng
bulkWrite,updateManyhaydeleteManylà all-or-nothing. Lỗi giữa chừng không rollback những gì đã ghi. - Phân trang bằng
skiplớn, sort theo field không duy nhất. Vừa chậm dần theo offset, vừa có thể lặp hoặc sót document. - Nghĩ rằng
limithay projection làm query rẻ đi. Chúng giảm thứ gửi về, không giảm thứ phải đọc, nhất là khi có sort.
Tóm tắt
- Một query là dây chuyền filter → sort → skip → limit → projection, và kết quả về theo batch: batch đầu 101 document, các
getMoresau tối đa 16 MiB mỗi batch (trong lab: 75.580 document, 15,51 MiB). - Projection giảm byte gửi về (431.805 xuống 32.048 byte trong lab) nhưng không giảm số document phải đọc.
skip(n)vẫn phải đi qua n document (500.020 document choskip(500000)). - Update operator chỉ sửa field được nhắc tới. Replacement thay cả document. Upsert tạo document từ các điều kiện bằng trong filter cộng
$setOnInsert, và cần unique index để an toàn khi chạy đồng thời. findOneAndUpdatesửa và trả kết quả trong một bước atomic. Trong lab, "tăng rồi đọc lại" phát trùng khoảng 1.750 trên 4.000 số hoá đơn, cònfindOneAndUpdatekhông trùng số nào.bulkWrite:ordereddừng ở lỗi đầu tiên,unorderedchạy tiếp. Cả hai đều không atomic như một khối.- Mảng: nhiều điều kiện thường có thể khớp trên các phần tử khác nhau.
$elemMatchđòi một phần tử thoả tất cả. - Delete:
deleteOne/deleteManytrả vềdeletedCount,findOneAndDeletetrả về chính document đã xoá. Xoá không miễn phí: mỗi document xoá kéo theo xoá key trong mọi index (trong lab, 3 index phụ làmdeleteMany49.109 document chậm khoảng 1,7 lần). - COLLSCAN trên 1 triệu document: khoảng 140–150 ms dù trả về 0, 21 hay 700.000 document.
Tự kiểm tra
- Query
find({ tenantId: "t0042" }).limit(5)không sort thường rất nhanh dù là COLLSCAN. Thêm.sort({ createdAt: -1 })thì vì sao nó lại phải đọc cả collection? (Sort không có index là blocking: phải thấy mọi document khớp mới biết 5 cái nào đứng đầu.) - Một API gọi
find()cho query chỉ khớp 3 document và thấy batch đầu về rất chậm, còncursorIdlà 0. Chuyện gì đã xảy ra trên server? (Không đủ 101 kết quả nên server phải đi hết collection mới gửi được batch đầu.) { scores: { $gte: 80, $lte: 90 } }có khớp vớiscores: [50, 95]không? Viết lại để chỉ khớp khi có một điểm nằm trong đoạn [80, 90]. (Có khớp: 95 thoả$gte, 50 thoả$lte. Viết lại:{ scores: { $elemMatch: { $gte: 80, $lte: 90 } } }.)bulkWrite1.000 lệnh vớiordered: truelỗi ở lệnh thứ 400. Trong DB có gì? Vớiordered: falsethì sao? (399 lệnh đầu đã ghi, không rollback. Với unordered, mọi lệnh không lỗi đều được ghi, tức 999.)
Nếu phải giải thích bài này mà không dùng thuật ngữ MongoDB nào: bạn gửi một tờ phiếu mô tả hồ sơ cần tìm, kho mang ra theo từng xe đẩy. Phiếu sửa chỉ sửa đúng những dòng ghi trên phiếu. Và khi kho không có sổ ghi chỗ để, nhân viên lật mọi bìa, dù bạn chỉ cần hai mươi bìa.
Bài tiếp theo
Thí nghiệm cuối bài để lại một con số khó chịu: 1.000.000 document bị đọc để trả về 21, và thời gian gần như không đổi dù kết quả là 0 hay 700.000. Mọi công cụ trong bài này (filter chặt hơn, projection, limit) đều không chạm được tới con số đó.
Bài 07, Index Fundamentals, mở đầu Phần 2 và trả lời câu hỏi bỏ ngỏ ở sơ đồ "điều ta muốn": làm sao để server đi thẳng tới đúng những document cần, cái "mục lục" đó trông thế nào bên trong, và nó phải trả giá gì (RAM, đĩa, mỗi lệnh ghi). Ta sẽ chạy lại đúng query này trên đúng 1 triệu đơn hàng này, đặt explain("executionStats") trước và sau cạnh nhau, rồi xem totalDocsExamined thay đổi ra sao.
Tài liệu tham khảo
- Query Predicates (danh sách query operator)
- Query an Array
- $elemMatch (query)
- Cursors
- getMore command
- Iterate a Cursor in mongosh
- cursor.skip()
- Update Operators
- db.collection.updateOne()
- db.collection.findOneAndUpdate()
- db.collection.deleteMany()
- db.collection.findOneAndDelete()
- db.collection.bulkWrite()
- Atomicity and Transactions
- Explain Results
- PostgreSQL: MERGE và Transaction Isolation (hành vi của MERGE khi chạy đồng thời)