CRUD & Query Model: MongoDB đọc và ghi dữ liệu ra sao

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

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 Fundamentals

Mô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ên lab05. Riêng phần Delete được đo sau, trên container mongo-lab-06 (2 CPU, 3 GB RAM, cache 1 GB, database lab06), 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:

  1. 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ả.
  2. 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ề.
  3. 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ómOperatorVí 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 $sizephầ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       : 590

Bẫ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 projection

Dot 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: 2003

Lượ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.000

Thê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 = 0
find      -> 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 MiB

Bốn điều đáng chú ý:

  1. Batch đầu 101 document, các getMore sau 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.
  2. batchSize đặt được. Với batchSize: 500, cùng 2.003 document về thành 5 chuyến: 500, 500, 500, 500, 3.
  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ệnh getMore ghi rõ: không đặt batchSize thì mỗi batch tối đa 16 MiB; có đặt thì lấy giá trị nhỏ hơn giữa batchSize document và 16 MiB.
  4. 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 đầu

Ba 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à displayBatchSize củ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 được 600000). 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ỗi CursorNotFound.
  • 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 operators

Sau: 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ómOperatorDù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 2
lầ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ầnCách A: số phát ra / số khác nhauCách A: số bị trùngCách B: số phát ra / số khác nhauCách B: số bị trùng
14.000 / 2.2381.7624.000 / 4.0000
24.000 / 2.2751.7254.000 / 4.0000
34.000 / 2.2181.7824.000 / 4.0000

Đ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: true chạ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: false chạ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ằng insertMany(..., { 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ỗi
deleteOne        : { 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ện deletedAt: 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:

  1. "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.
  2. "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"]
DocvalsKhông dùng $elemMatch$elemMatchVì 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
E4 (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: 23977

Cá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ấp

Mộ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 ms

Gom 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:

QuerynReturnedtotalDocsExaminedmedian lượt 1median lượt 2
tenant + pending + từ 01/09211.000.000143 ms143 ms
chỉ tenant2.0031.000.000138 ms144 ms
status: "completed"699.9901.000.000150 ms152 ms
tenant không tồn tại01.000.000149 ms138 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ệcMongoDBPostgreSQL
Lọc và chọn cộtfind(filter, projection)SELECT cột ... WHERE ...
Giá trị vắng mặtfield không tồn tại; $ne khớp cả field vắngNULL; col <> 'x' không khớp hàng có NULL
Lấy kết quả từng phầncursor + batch là mặc địnhlibpq 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 *
Upsertupsert: 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ềfindOneAndDeleteDELETE ... RETURNING *
Không có index phù hợpCOLLSCANSeq 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ưng replaceOne hay findOneAndReplace sẽ xoá sạch các field còn lại.
  • Dùng $ne / $nin mà 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 findOneAndUpdate với returnDocument: "after".
  • Quên rằng findOneAndUpdate mặc định trả bản cũ.
  • Tin rằng bulkWrite, updateMany hay deleteMany là all-or-nothing. Lỗi giữa chừng không rollback những gì đã ghi.
  • Phân trang bằng skip lớ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 limit hay 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 getMore sau 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 cho skip(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.
  • findOneAndUpdate sử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òn findOneAndUpdate không trùng số nào.
  • bulkWrite: ordered dừng ở lỗi đầu tiên, unordered chạ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/deleteMany trả về deletedCount, findOneAndDelete trả 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àm deleteMany 49.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

  1. 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.)
  2. Một API gọi find() cho query chỉ khớp 3 document và thấy batch đầu về rất chậm, còn cursorId là 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.)
  3. { scores: { $gte: 80, $lte: 90 } } có khớp với scores: [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 } } }.)
  4. bulkWrite 1.000 lệnh với ordered: true lỗi ở lệnh thứ 400. Trong DB có gì? Với ordered: false thì 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