ข้ามไปยังเนื้อหา

Upsert และ bulk write

สองท่าสุดท้ายในโมดูลนี้คือการทำมากขึ้นด้วย round trip น้อยลง upsert ตอบคำถามที่คุณเจอบ่อย: “อัปเดต document นี้ถ้ามีอยู่ ไม่เช่นนั้นสร้างใหม่” bulk write ให้คุณส่งรายการ operation ผสม — insert, update, delete — ไปยัง server ในการเรียกเดียวแทนที่จะเดินทางผ่านเครือข่ายทีละ operation ทั้งสองไม่ได้เพิ่ม operator ใหม่ แต่ประกอบเมธอดที่คุณรู้จักให้มีประสิทธิภาพมากขึ้น

เราจบด้วย collection members อีกครั้ง ทุก output ด้านล่างจับมาจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 (แท็บ Python, Go และ Rust รันหนึ่งครั้งในโปรแกรม scratch บน server เดียวกัน)

mongosh/bulk-seed.js
db.members.insertMany([
{ name: "Grace", joined: 2024, fines: 0 },
{ name: "Linus", joined: 2023, fines: 2 },
]);
assert.strictEqual(db.members.countDocuments(), 2);

เพิ่ม option upsert ให้การอัปเดต แล้วเมื่อ filter ไม่ตรงอะไรเลย MongoDB จะ insert document ใหม่ที่สร้างจาก filter บวก update เมื่อ filter ตรง มันทำตัวเหมือนการอัปเดตปกติ ที่นี่เราบันทึกการเข้าชมของ “Katherine” — ถ้าเธอยังไม่ใช่สมาชิก นี่จะสร้างเธอ:

mongosh/upsert.js
const first = db.members.updateOne(
{ name: "Katherine" },
{ $set: { joined: 2026 }, $inc: { visits: 1 } },
{ upsert: true }
);
console.log(first);
console.log(db.members.findOne({ name: "Katherine" }));
const second = db.members.updateOne(
{ name: "Katherine" },
{ $set: { joined: 2026 }, $inc: { visits: 1 } },
{ upsert: true }
);
console.log(second);
assert.strictEqual(first.upsertedCount, 1);
assert.strictEqual(second.matchedCount, 1);
assert.strictEqual(db.members.findOne({ name: "Katherine" }).visits, 2);

เพราะไม่มี Katherine อยู่ การเรียกครั้งแรกจึงสร้างเธอ acknowledgement ต่างกันระหว่าง shell และ driver และเนื้อหาบทเก่าผิดตรงนี้: shell รายงาน _id ใหม่เป็น insertedId (ไม่มี field upsertedId ใน mongosh) พร้อม upsertedCount: 1 ขณะที่ Node driver รายงาน upsertedId และ upsertedCount: 1 ทั้งสองแบบ matchedCount: 0 คือสัญญาณว่าเกิดการ insert ไม่ใช่ update:

mongosh
{
acknowledged: true,
insertedId: ObjectId('6abcaadc73c3d0e445c6944e'),
matchedCount: 0,
modifiedCount: 0,
upsertedCount: 1
}
Node
{
acknowledged: true,
modifiedCount: 0,
upsertedId: new ObjectId('6abcaae473c3d0e445c69452'),
upsertedCount: 1,
matchedCount: 0
}
Python 6abcab0173c3d0e445c696c9 (res.upserted_id)
Go ObjectID("6abcab0373c3d0e445c696ca") (res.UpsertedID)
Rust Some(ObjectId("6abcab0673c3d0e445c696cb"))

document ใหม่รวม filter และ update operator (findOne) และการรันการเรียกเดิมครั้งที่สองจะอัปเดตเธอแทน — matchedCount: 1, modifiedCount: 1, upsertedCount: 0 และ visits เพิ่มเป็น 2 (ใน Node ack ที่สองมี upsertedId: null):

{ _id: ObjectId('6abcaadc73c3d0e445c6944e'), name: 'Katherine', joined: 2026, visits: 1 }
{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }

document ที่ insert คือเงื่อนไข equality ของ filter บวก update เงื่อนไขที่ไม่ใช่ equality ไม่ถูก คัดลอก และ $setOnInsert เขียน field เฉพาะเมื่อ upsert insert (ไม่ทำอะไรในทางอัปเดต):

mongosh/upsert-setoninsert.js
const r1 = db.people.updateOne(
{ name: "Ada", joined: { $gt: 2000 } }, // name is equality, joined is a range
{ $setOnInsert: { fines: 0, createdBy: "upsert" }, $inc: { visits: 1 } },
{ upsert: true }
);
console.log(r1.upsertedCount, db.people.findOne({ name: "Ada" }, { _id: 0 }));
db.people.updateOne({ name: "Ada" }, { $setOnInsert: { fines: 99 }, $inc: { visits: 1 } }, { upsert: true });
console.log(db.people.findOne({ name: "Ada" }, { _id: 0 })); // fines still 0: $setOnInsert skipped
assert.strictEqual(db.people.findOne({ name: "Ada" }).fines, 0);
assert.strictEqual("joined" in db.people.findOne({ name: "Ada" }), false);
1 { name: 'Ada', createdBy: 'upsert', fines: 0, visits: 1 }
{ name: 'Ada', createdBy: 'upsert', fines: 0, visits: 2 }

equality ของ name มาจาก filter; range บน joined ไม่มา (ไม่มี field joined ถูกสร้าง); fines: 99 ในการเรียกครั้งที่สองถูกเมินเพราะ document มีอยู่แล้ว

bulkWrite รับรายการ operation และส่งไปพร้อมกัน แต่ละรายการระบุชื่อ operation และมี filter กับ update เดียวกับที่คุณจะส่งแยกกัน จึงผสม insert, update และ delete ได้อย่างอิสระ ที่นี่เรารับสมาชิกใหม่หนึ่งคน คิดค่าปรับ และลบสมาชิกที่หมดอายุ — ทั้งหมดในการเรียกเดียว:

mongosh/bulk-write.js
const res = db.members.bulkWrite([
{ insertOne: { document: { name: "Tim", joined: 2026, fines: 0 } } },
{ updateOne: { filter: { name: "Grace" }, update: { $inc: { fines: 1 } } } },
{ deleteOne: { filter: { name: "Linus" } } }
]);
console.log(res);
assert.strictEqual(res.insertedCount, 1);
assert.strictEqual(res.deletedCount, 1);

acknowledgement เดียวสรุปทุกอย่างที่เกิดขึ้นข้ามทั้งสาม operation รูปร่างของ shell และ Node (และ bulk_api_result ของ Python) ต่างกันในรายละเอียด:

mongosh
{
acknowledged: true,
insertedCount: 1,
insertedIds: { '0': ObjectId('6abcaadcbdebc81d34174703') },
matchedCount: 1,
modifiedCount: 1,
deletedCount: 1,
upsertedCount: 0,
upsertedIds: {}
}
Node
BulkWriteResult {
insertedCount: 1, matchedCount: 1, modifiedCount: 1, deletedCount: 1, upsertedCount: 0,
upsertedIds: {}, insertedIds: { '0': new ObjectId('6abcaae45031e43a4870ad5a') }
}
Python {'writeErrors': [], 'writeConcernErrors': [], 'nInserted': 1, 'nUpserted': 0, 'nMatched': 1, 'nModified': 1, 'nRemoved': 1, 'upserted': []}
Go, Rust: 1 1 1

จุดสำคัญคือ round trip: แทนที่จะคุยกับ server สามครั้งแยกกัน driver รวมรายการเป็นชุดแล้วส่งครั้งเดียว operation ชนิดอื่นทำงานแบบเดียวกัน (updateMany, replaceOne, deleteMany และ upsert: true บนรายการ update); ในการรัน mongosh bulk ที่มี updateOne แบบ upsert, updateMany และ replaceOne คืน matchedCount: 4, modifiedCount: 4, upsertedCount: 1, upsertedIds: { '0': ObjectId('...') }

หมายเหตุเรื่อง Rust bulk_write ของ Rust driver อยู่บน client (ครอบคลุมหลาย namespace) และต้องใช้ server MongoDB 8.0+; ชนิด model เป็น builder (InsertOneModel::builder()...) ที่ห่อด้วย WriteModel รูป struct-literal (WriteModel::InsertOne { namespace: ..., document: ... }) ที่ร่างเก่าของบทนี้แสดงคอมไพล์ไม่ผ่านบน Rust driver 3.9.1 (variant WriteModel::InsertOne has no field named namespace) — โค้ดด้านบนคือสิ่งที่เรารัน

flowchart LR
  App["Your app"] -->|"one bulkWrite call"| Batch["Batched: insert + update + delete"]
  Batch -->|"single round trip"| Server["MongoDB server"]
  Server --> R1["insertedCount: 1"]
  Server --> R2["modifiedCount: 1"]
  Server --> R3["deletedCount: 1"]
bulkWrite รวมหลาย operation ไว้ใน round trip เดียวและคืนผลลัพธ์รวม

คำแนะนำเก่าบอกให้แบ่ง batch ใหญ่เอง แต่ driver ทำให้แล้วบนสาย (เคารพ maxWriteBatchSize และ maxMessageSizeBytes ของ server ให้คุณ) การเรียกเดียวจึงรับรายการใหญ่มากได้ บน Mac เครื่องนี้กับ replica set โหนดเดียว:

app/bulk-large.mjs
const ops = Array.from({ length: 250000 }, (_, i) => ({ insertOne: { document: { n: i } } }));
let t = Date.now();
const r = await db.collection("bulk").bulkWrite(ops, { ordered: false });
console.log("250k-op bulkWrite:", r.insertedCount, "in", Date.now() - t, "ms");
t = Date.now();
const r2 = await db.collection("many").insertMany(Array.from({ length: 100000 }, (_, i) => ({ n: i })));
console.log("100k insertMany :", r2.insertedCount, "in", Date.now() - t, "ms");
if (r.insertedCount !== 250000) throw new Error("expected all 250000");
250k-op bulkWrite: 250000 in 833 ms
100k insertMany : 100000 in 323 ms

เวลามาจากสองการรันบนเครื่องเดียว (862 ms และ 833 ms สำหรับ 250 000 operation) — ประเด็นคือ 250 000 operation ผ่านไปในการเรียกเดียวโดยไม่ต้องแบ่ง batch เอง สิ่งที่ยังต้องระวังคือขีดจำกัดที่ มองเห็นได้ เช่นขนาด 16 MiB ของ document หนึ่งใบ

โดยค่าเริ่มต้น bulkWrite เป็น ordered: รัน operation ตามลำดับและหยุดที่อันแรกที่ error ทิ้งที่เหลือไว้ไม่ได้รัน สลับเป็น unordered แล้ว server รัน operation ในลำดับใดก็ได้และไปต่อผ่านความล้มเหลว ใช้ทุก operation ที่สำเร็จได้ Unordered มักเร็วกว่าและเป็นตัวเลือกที่ถูกเมื่อ operation ไม่พึ่งพากัน ตัวอย่างเดิม insert สองชื่อที่ต่างกันจึงไม่มีทางล้มเหลวทั้งสองแบบ; เพื่อเห็นความต่างต้องมีสิ่งที่ล้มเหลว — unique index กับตัวซ้ำ:

mongosh/bulk-ordered.js
db.roster.createIndex({ name: 1 }, { unique: true });
db.roster.insertOne({ name: "Tim", joined: 2026 });
const ops = () => [
{ insertOne: { document: { name: "Barbara", joined: 2026 } } },
{ insertOne: { document: { name: "Tim", joined: 2026 } } }, // duplicate: fails
{ insertOne: { document: { name: "Margaret", joined: 2026 } } },
];
for (const ordered of [true, false]) {
db.roster.deleteMany({ name: { $in: ["Barbara", "Margaret"] } });
let err;
try { db.roster.bulkWrite(ops(), { ordered }); } catch (e) { err = e; }
console.log(`ordered=${ordered}:`, err.name, err.code, "inserted", err.result.insertedCount,
"failed at", err.writeErrors.map((w) => w.index), "->", db.roster.find({}, { _id: 0, name: 1 }).sort({ name: 1 }).toArray().map((d) => d.name));
}

ผลจริง: ด้วย ordered: true insert ที่สอง (index 1 Tim ซ้ำ) ล้มเหลวด้วย code 11000 และ Margaret ที่ตามหลังไม่เคยรัน — มีเพียง Barbara ที่ถูก insert ด้วย ordered: false ตัวซ้ำยังถูกรายงาน แต่ Barbara และ Margaret เข้าไปทั้งคู่:

ordered=true: MongoBulkWriteError 11000 inserted 1 failed at [ 1 ] -> [ 'Barbara', 'Tim' ]
ordered=false: MongoBulkWriteError 11000 inserted 2 failed at [ 1 ] -> [ 'Barbara', 'Margaret', 'Tim' ]

ทั้งสองโหมด error บอกชัดว่า operation ไหนล้มเหลว (writeErrors[i].index) และสำเร็จกี่รายการ (result.insertedCount); ไม่มีโหมดใด rollback สิ่งที่ใช้ไปแล้ว

ใน Compass: ไม่มีแผง bulk-write; รัน bulkWrite ใน shell ที่ฝังอยู่ หรือใน driver ของคุณ

  • upsert สร้าง document ใหม่จาก ทั้ง เงื่อนไข equality ของ filter และ update operator — เงื่อนไข range ไม่ถูกคัดลอก (ดู $setOnInsert ด้านบน) ทำ filter ให้เจาะจงเพื่อให้ document ที่ insert ออกมาตามที่คาด
  • หลัง upsert ตรวจ matchedCount: 0 พร้อม insertedId (shell) / upsertedId (driver) หมายถึงมี document ถูกสร้าง; 1 หมายถึงอัปเดตอันที่มีอยู่
  • upsert ปลอดภัยจากการแข่งขันก็ต่อเมื่อมี unique index บน field ของ filter (พิสูจน์ด้านล่าง)
  • bulkWrite แบบ ordered หยุดที่ error แรก ถ้าต้องการให้ทุก operation ที่เป็นอิสระถูกลองไม่ว่าจะมีความล้มเหลว ตั้ง ordered: false
  • bulkWrite เกี่ยวกับประสิทธิภาพ ไม่ใช่ transaction ถ้าไม่ห่อด้วย transaction operation ไม่ all-or-nothing — บางอันสำเร็จขณะที่อันอื่นล้มเหลวได้ (inserted 1 ด้านบน)
  • ผลใน shell มี insertedCount บน bulkWrite แต่ไม่มีบน insertMany; ตรวจว่าคุณถือ object ไหน

ผู้ช่วย AI ที่ถูกขอตัวนับการเข้าชมหน้าจะหยิบ upsert และอธิบายว่า “รับประกันหนึ่งแถวต่อหน้า”:

🐛 หา bug ในโค้ดนี้

// AI comment: "upsert is atomic, so there can never be two rows for the same page"
async function recordView(db, page) {
await db.collection("counters").updateOne(
{ page },
{ $inc: { views: 1 } },
{ upsert: true }
);
}
เฉลย

การอัปเดตหนึ่ง document เป็น atomic แต่ “ไม่พบแล้ว insert” เป็นสองขั้น และเมื่อไม่มี unique index การเรียกพร้อมกันสองครั้งอาจไม่พบทั้งคู่และ insert ทั้งคู่ เรายิง upsert พร้อมกัน 100 ครั้งต่อหน้าเป็นเวลา 20 หน้าบน client เดียว: 17 ถึง 19 จาก 20 หน้ามี row ซ้ำ และหน้าหนึ่งสะสมได้ถึง 16 document (สามครั้งที่รัน: 19/20 สูงสุด 7 document, 17/20 สูงสุด 13, 17/20 สูงสุด 16 — จำนวนแน่นอนขึ้นกับจังหวะเวลา) ทุก upsert รายงานสำเร็จ และผลรวมยังถูกต้อง (2000 views) จึงไม่มีอะไรดูผิดปกติ เมื่อมี unique index บน page การรันเดียวกันให้ หนึ่ง document ต่อหน้าพอดี ไม่มี upsert ถูกปฏิเสธ รวม 2000 views (server retry ผู้แพ้การแข่งขันเป็น update):

app/upsert-race.mjs
const ROUNDS = 20, PARALLEL = 100;
async function race(useUniqueIndex) {
const c = db.collection(useUniqueIndex ? "counters_unique" : "counters_plain");
if (useUniqueIndex) await c.createIndex({ page: 1 }, { unique: true });
let roundsWithDuplicates = 0, maxDocs = 0, rejected = 0;
for (let r = 0; r < ROUNDS; r++) {
const page = `/home-${r}`;
const results = await Promise.allSettled(Array.from({ length: PARALLEL }, () =>
c.updateOne({ page }, { $inc: { views: 1 } }, { upsert: true })));
rejected += results.filter((x) => x.status === "rejected").length;
const docs = await c.countDocuments({ page });
if (docs > 1) roundsWithDuplicates++;
maxDocs = Math.max(maxDocs, docs);
}
const total = (await c.aggregate([{ $group: { _id: null, v: { $sum: "$views" } } }]).next()).v;
return { roundsWithDuplicates: `${roundsWithDuplicates}/${ROUNDS}`, maxDocsForOnePage: maxDocs, rejectedUpserts: rejected, totalViews: total };
}
const plain = await race(false);
const unique = await race(true);
console.log("without unique index:", plain);
console.log("with unique index :", unique);
if (unique.maxDocsForOnePage !== 1 || unique.totalViews !== ROUNDS * PARALLEL) throw new Error("unique index should give one document per page");
without unique index: {
roundsWithDuplicates: '17/20',
maxDocsForOnePage: 16,
rejectedUpserts: 0,
totalViews: 2000
}
with unique index : {
roundsWithDuplicates: '0/20',
maxDocsForOnePage: 1,
rejectedUpserts: 0,
totalViews: 2000
}

วิธีแก้คือบรรทัดเดียว ทำครั้งเดียว: createIndex({ page: 1 }, { unique: true })

upsert คือ “ตรงก็ใช้ ไม่ตรงก็สร้าง” ที่ประเมินบน server แต่ ความไม่ซ้ำ ของสิ่งที่มันสร้างแข็งแรงเท่าข้อจำกัดที่อยู่เบื้องหลังเท่านั้น bulk write คือ รายการคำสั่งอิสระในซองเดียว: ซองประหยัด round trip, ordered ตัดสินว่ารายการหยุดที่ความล้มเหลวแรกหรือไม่ และไม่มีอันไหนให้ all-or-nothing รวมกัน: unique index ปกป้องความถูกต้อง ซอง bulk ปกป้อง latency

ตัวเลือกBenefitCost
bulkWriteรวมหลาย operation ใน round trip เดียว; insert 250 000 รายการในราว 850 ms ในการเรียกเดียวผลลัพธ์รวมเป็นชุดเดียว — ต้องแกะ writeErrors เองเมื่อบางอันล้มเหลว; ไม่ atomic
bulkWrite ordered (ค่าเริ่มต้น)คาดการณ์ได้: operation รันตามลำดับรายการและหยุดที่ error แรกความล้มเหลวหนึ่งครั้งทิ้ง operation ที่ตามมาทั้งหมดไม่ได้รัน (Margaret ด้านบน)
bulkWrite unorderedไปต่อผ่านความล้มเหลว; มักเร็วกว่าไม่รับประกันลำดับ; ต้องอ่าน writeErrors
upsert (มี unique index)round trip เดียวครอบคลุมทั้ง insert-ถ้าไม่มี และ update-ถ้ามี; ปลอดภัยจากการแข่งขันต้องมี unique index (ต้นทุนการเขียนและพื้นที่เพิ่ม)
upsert (ไม่มี unique index)ไม่มีอะไรต้องดูแลเพิ่มการเรียกพร้อมกันทำ document ซ้ำ (17-19 จาก 20 หน้าในการรันของเรา)
ตรวจก่อนเขียนแบบชัดเจน (findOne ก่อน)ควบคุมละเอียดก่อนเขียนสอง round trip และช่องว่างการแข่งขันเดียวกันระหว่าง find กับ write
  • “upsert ป้องกัน duplicate เสมอ” — เฉพาะเมื่อมี unique index หลัง filter; ถ้าไม่มี เราได้ถึง 16 document สำหรับหนึ่งหน้า
  • “upsertedId อยู่ในทุกผลลัพธ์” — mongosh รายงาน insertedId และ upsertedCount; driver รายงาน upsertedId
  • “ต้องแบ่ง bulk write ใหญ่เอง” — driver แบ่ง batch บนสายให้; 250 000 operation รันในการเรียกเดียวในราว 850 ms แบ่ง batch เฉพาะเพื่อหน่วยความจำหรือการรายงานความคืบหน้าของคุณเอง
  • “Unordered แปลว่าเร็วและปลอดภัย” — มันไปต่อผ่านความล้มเหลว แต่คุณยังต้องอ่าน writeErrors และไม่มีอะไรถูก rollback
  • “range ใน filter ของ upsert ไปอยู่ใน document ใหม่” — คัดลอกเฉพาะเงื่อนไข equality
  • “bulkWrite คือ transaction” — ไม่ใช่; inserted 1 ยังคงอยู่หลังความล้มเหลว

ตัวอย่างจากของจริง

reference ของ updateOne ระบุข้อกำหนดที่เราวัด: “To avoid multiple upserts, ensure that the filter field(s) are uniquely indexed” (db.collection.updateOne, เรียกอ่านเมื่อ 2026-09-30) ในการรันของเรา unique index ไม่ทำให้ upsert ใดถูกปฏิเสธ — ผู้แพ้การแข่งขันถูก server retry เป็น update — ดังนั้น 2000 จาก 2000 การเพิ่มตกลงบน 20 document

แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน (options.Update() ของ Go ถูกแก้เป็น options.UpdateOne() สำหรับ driver v2 และแท็บ Rust bulk_write ถูกเขียนใหม่ — ทั้งคู่คอมไพล์ไม่ผ่านตามที่เขียนไว้เดิม) ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

upsert รันและ filter ไม่ตรง document ที่มีอยู่ใดเลย เกิดอะไรขึ้น?
ประโยชน์หลักของ bulkWrite เหนือการเรียกแยกคืออะไร?
มี unique index บน name และ operation กลางจากสาม insert ซ้ำ bulkWrite แบบ ordered และ unordered ต่างกันอย่างไร?
หลัง upsert ผลลัพธ์ใดบ่งบอกว่ามี document ใหม่ถูกสร้าง?
upsert พร้อมกัน 100 ครั้งด้วย filter เดียวกันกับ collection ที่ไม่มี unique index การรันจริงแสดงอะไร?