CRUD (P2/3): Ghi và xoá: update, upsert, bulkWrite
Ở phần trước: find đọc theo filter, projection và cursor theo batch. Không có index thì query chọn lọc vẫn quét hết collection, và cursor không phải snapshot.
- Cần đọc trước: Đọc dữ liệu: filter, projection, sort, cursor
- Dẫn tới: Mảng và thí nghiệm 1 triệu document, phần tiếp theo.
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, xem bài Document Model). 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 Document Model).
| 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 Specialized Indexes (phần 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 Document Model đã 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 trước (Đọc dữ liệu: filter, projection, sort, cursor) đề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 Index Fundamentals 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 Replica Set & Oplog). 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 Specialized Indexes).
- 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 Transactions). 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, chủ đề của bài Durability & Consistency (cơ chế journal bên dưới nằm ở bài Journal & Checkpoint). Trong lab standalone này, mọi kết quả ở trên dùng write concern mặc định.
Cột mốc: Bạn đã biết khi nào dùng update operator, replacement, upsert hay bulkWrite, và vì sao updateMany không atomic như một khối. Tiếp theo: Mảng và thí nghiệm 1 triệu document.
Hỏi & đáp
Hai tiến trình chạy song song, mỗi bên xin 2.000 số hoá đơn bằng updateOne({ $inc: { seq: 1 } }) rồi findOne để đọc số. Lab cho kết quả gì?
bulkWrite 1.000 lệnh với mặc định ordered: true, lệnh thứ 400 lỗi trùng khoá. Trong database còn lại gì?
API đăng ký dùng updateOne({ tenantId, email }, { $setOnInsert: {...} }, { upsert: true }). Hai request cùng email đến đồng thời. Làm sao chắc chắn không tạo ra hai user?