CRUD (P2/3): Ghi và xoá: update, upsert, bulkWrite

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

Ở 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.

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 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 Document Model).

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 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ầ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 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: 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 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ệ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 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ì?

  1. Counter cuối đúng 4.000, nhưng khoảng 44% số phát ra bị trùng

    Mỗi $inc vẫn atomic nên 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 và A đọc lại, B đã tăng thêm. Ba lần chạy trùng 1.762, 1.725 và 1.782 số; findOneAndUpdate với returnDocument: "after" không trùng số nào. Xem mục "findOneAndUpdate: sửa và lấy kết quả trong một bước".

  2. Counter cuối nhỏ hơn 4.000 vì một số lần $inc bị ghi đè

    Đó là lost update của kiểu "đọc, tính ở app, rồi ghi". Ở đây $inc chạy trên server và atomic, nên counter cuối đúng bằng 4.000 ở cả hai cách. Xem mục "findOneAndUpdate: sửa và lấy kết quả trong một bước".

  3. Không trùng số nào, vì $inc là atomic trên một document

    Lệnh ghi atomic, nhưng lần đọc kết quả bị tách khỏi lệnh ghi, nên có khe hở cho tiến trình khác chen vào. Lab đo khoảng 1.750 số trùng mỗi lần. Xem mục "findOneAndUpdate: sửa và lấy kết quả trong một bước".

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ì?

  1. Không có gì: bulkWrite lỗi thì cả khối bị huỷ

    bulkWrite không atomic như một khối, và không có rollback. Mỗi lệnh con chỉ atomic trên document của nó. Xem mục "bulkWrite: gửi nhiều lệnh ghi trong một chuyến".

  2. 999 lệnh: driver bỏ qua lệnh lỗi rồi chạy tiếp

    Đó là hành vi của ordered: false. Trong lab, ordered=false ghi 3 trên 4 lệnh, còn ordered=true chỉ ghi 1. Xem mục "bulkWrite: gửi nhiều lệnh ghi trong một chuyến".

  3. 399 lệnh đầu được ghi, rồi được hoàn tác khi lỗi trả về

    Không có bước hoàn tác nào: lệnh đã ghi thì nằm đó dù lệnh sau lỗi. Cần all-or-nothing trên nhiều document thì phải dùng transaction. Xem mục "Atomic theo từng document, và những gì nằm ngoài".

  4. 399 lệnh đầu vẫn nằm lại; 600 lệnh sau không chạy

    ordered: true chạy tuần tự và dừng ở lỗi đầu tiên, nhưng không rollback phần đã ghi. Lab với 4 lệnh, lỗi ở lệnh thứ hai: insertedCount: 1, collection còn [1,2]. Xem mục "bulkWrite: gửi nhiều lệnh ghi trong một chuyến".

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?

  1. $setOnInsert đã đảm bảo chỉ tạo document một lần

    $setOnInsert chỉ quyết định field nào được đặt ở nhánh tạo mới. Hai request cùng thấy "không có" thì cả hai đều đi vào nhánh tạo mới. Xem mục "Upsert: có thì sửa, chưa có thì tạo".

  2. Đổi sang findOneAndUpdate, vì nó atomic trong một bước

    findOneAndUpdate atomic trên một document đã có. Khi chưa có document nào, hai upsert đồng thời vẫn có thể cùng tạo mới; chính tài liệu của findOneAndUpdate khuyên dùng unique index. Xem mục "Upsert: có thì sửa, chưa có thì tạo".

  3. Tạo unique index trên { tenantId, email }

    Upsert chạy được cả khi không có unique index, nhưng khi đó có thể tạo trùng lúc chạy đồng thời. Unique index biến chuyện "không được trùng" thành ràng buộc của database. PostgreSQL ON CONFLICT buộc khai báo ràng buộc này trước. Xem mục "Upsert"; so sánh với PostgreSQL nằm ở Mảng và thí nghiệm 1 triệu document.