Upsert และ bulk write
สองท่าสุดท้ายในโมดูลนี้เป็นเรื่องของการทำสิ่งต่าง ๆ ได้มากขึ้นด้วย round trip ที่น้อยลง upsert ตอบคำถามที่คุณเจอตลอดเวลา นั่นคือ “ถ้า document นี้มีอยู่แล้วก็ update ถ้ายังไม่มีก็สร้างใหม่” bulk write ให้คุณส่งรายการของ operation ที่ปนกันทั้งหมด — insert, update, delete — ไปยัง server ในการเรียกครั้งเดียว แทนที่จะยิงข้ามเครือข่ายหนึ่งรอบต่อหนึ่ง operation
ทั้งคู่ไม่ได้เพิ่ม operator ใหม่ แต่เอา method ที่คุณรู้อยู่แล้วมาประกอบใหม่ให้คุ้มขึ้น
เราจบด้วย collection members ของเราอีกครั้งหนึ่ง
Upsert: update หรือ insert
หัวข้อที่มีชื่อว่า “Upsert: update หรือ insert”เพิ่ม option upsert เข้าไปในการ update แล้วเมื่อ filter ไม่ตรงกับอะไรเลย MongoDB จะ insert document ใหม่ที่สร้างขึ้นจาก filter บวกกับ update เมื่อ filter เจอ จริง ๆ ก็ทำตัวเหมือน update ธรรมดา ในที่นี้เราบันทึกการมาเยือนของ “Katherine” — ถ้าเธอยังไม่ได้เป็นสมาชิก สิ่งนี้จะสร้างเธอขึ้นมา:
db.members.updateOne( { name: "Katherine" }, { $set: { joined: 2026 }, $inc: { visits: 1 } }, { upsert: true })const res = await db.collection("members").updateOne( { name: "Katherine" }, { $set: { joined: 2026 }, $inc: { visits: 1 } }, { upsert: true });console.log(res.upsertedId);res = db.members.update_one( {"name": "Katherine"}, {"$set": {"joined": 2026}, "$inc": {"visits": 1}}, upsert=True,)print(res.upserted_id)opts := options.Update().SetUpsert(true)res, err := coll.UpdateOne( ctx, bson.M{"name": "Katherine"}, bson.M{"$set": bson.M{"joined": 2026}, "$inc": bson.M{"visits": 1}}, opts,)if err != nil { return err}fmt.Println(res.UpsertedID)use mongodb::options::UpdateOptions;
let opts = UpdateOptions::builder().upsert(true).build();let res = coll .update_one( doc! { "name": "Katherine" }, doc! { "$set": { "joined": 2026 }, "$inc": { "visits": 1 } }, ) .with_options(opts) .await?;println!("{:?}", res.upserted_id);เพราะไม่มี Katherine อยู่ acknowledgement จึงรายงาน upsertedId — _id ของ document ที่ถูกสร้างขึ้น matchedCount เป็น 0 ที่เป็นสัญญาณบอกคุณว่าเกิดการ insert ขึ้นแทนที่จะเป็นการ update:
{ "acknowledged": true, "matchedCount": 0, "modifiedCount": 0, "upsertedId": "65f0c5e6e4b0a1c2e4b0a1f0"}document ใหม่ผสม filter และ update operator เข้าด้วยกัน:
{ "_id": "65f0c5e6e4b0a1c2e4b0a1f0", "name": "Katherine", "joined": 2026, "visits": 1}รันการเรียกเดียวกันเป๊ะ ๆ เป็นครั้งที่สอง ตอนนี้ Katherine มีอยู่แล้ว จึงกลายเป็นการ update แทน matchedCount กลายเป็น 1, visits ไต่ขึ้นไปเป็น 2 และ upsertedId ก็ไม่ปรากฏ
bulkWrite: operation หลายรายการ round trip เดียว
หัวข้อที่มีชื่อว่า “bulkWrite: operation หลายรายการ round trip เดียว”bulkWrite รับรายการของ operation แล้วส่งไปพร้อมกัน แต่ละรายการระบุ operation ของตัวเอง พร้อม filter และ update ชุดเดียวกับที่คุณจะส่งทีละครั้ง ดังนั้นคุณจึงผสม insert, update และ delete ได้อย่างอิสระ ในที่นี้เรารับสมาชิกใหม่หนึ่งคน คิดค่าปรับหนึ่งครั้ง และลบสมาชิกที่ขาดการติดต่อ — ทั้งหมดในการเรียกครั้งเดียว:
db.members.bulkWrite([ { insertOne: { document: { name: "Tim", joined: 2026, fines: 0 } } }, { updateOne: { filter: { name: "Grace" }, update: { $inc: { fines: 1 } } } }, { deleteOne: { filter: { name: "Linus" } } }])const res = await db.collection("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.insertedCount, res.modifiedCount, res.deletedCount);from pymongo import InsertOne, UpdateOne, DeleteOne
res = db.members.bulk_write([ InsertOne({"name": "Tim", "joined": 2026, "fines": 0}), UpdateOne({"name": "Grace"}, {"$inc": {"fines": 1}}), DeleteOne({"name": "Linus"}),])print(res.inserted_count, res.modified_count, res.deleted_count)models := []mongo.WriteModel{ mongo.NewInsertOneModel().SetDocument(bson.M{"name": "Tim", "joined": 2026, "fines": 0}), mongo.NewUpdateOneModel(). SetFilter(bson.M{"name": "Grace"}). SetUpdate(bson.M{"$inc": bson.M{"fines": 1}}), mongo.NewDeleteOneModel().SetFilter(bson.M{"name": "Linus"}),}res, err := coll.BulkWrite(ctx, models)if err != nil { return err}fmt.Println(res.InsertedCount, res.ModifiedCount, res.DeletedCount)use mongodb::options::WriteModel;
let res = coll .client() .bulk_write(vec![ WriteModel::InsertOne { namespace: coll.namespace(), document: doc! { "name": "Tim", "joined": 2026, "fines": 0 }, }, WriteModel::UpdateOne { namespace: coll.namespace(), filter: doc! { "name": "Grace" }, update: doc! { "$inc": { "fines": 1 } }.into(), array_filters: None, collation: None, hint: None, upsert: None, }, WriteModel::DeleteOne { namespace: coll.namespace(), filter: doc! { "name": "Linus" }, collation: None, hint: None, }, ]) .await?;println!("{} {} {}", res.inserted_count, res.modified_count, res.deleted_count);acknowledgement เพียงหนึ่งเดียวสรุปทุกอย่างที่เกิดขึ้นตลอด operation ทั้งสามรายการ:
{ "acknowledged": true, "insertedCount": 1, "matchedCount": 1, "modifiedCount": 1, "deletedCount": 1, "upsertedCount": 0}หัวใจทั้งหมดอยู่ที่ round trip นั่นคือ แทนที่จะเป็นการสนทนากับ server สามครั้งแยกกัน driver รวมรายการทั้งหมดเป็นชุดเดียวแล้วส่งไปรอบเดียว
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"]
ordered เทียบกับ unordered
หัวข้อที่มีชื่อว่า “ordered เทียบกับ unordered”โดยปริยาย bulkWrite เป็นแบบ ordered นั่นคือรัน operation ไล่ตามลำดับ แล้วหยุดที่รายการแรกที่ error ทำให้ที่เหลือไม่ได้รัน สลับไปเป็นแบบ unordered แล้ว server อาจรัน operation ในลำดับใดก็ได้และเดินหน้าต่อแม้มีความล้มเหลว โดยใช้ทุก operation ที่สามารถสำเร็จได้ unordered มักจะเร็วกว่าและเป็นตัวเลือกที่ถูกต้องเมื่อ operation ต่าง ๆ ไม่ขึ้นต่อกัน
db.members.bulkWrite( [ { insertOne: { document: { name: "Barbara", joined: 2026 } } }, { insertOne: { document: { name: "Tim", joined: 2026 } } } ], { ordered: false })await db.collection("members").bulkWrite( [ { insertOne: { document: { name: "Barbara", joined: 2026 } } }, { insertOne: { document: { name: "Tim", joined: 2026 } } }, ], { ordered: false });from pymongo import InsertOne
db.members.bulk_write( [ InsertOne({"name": "Barbara", "joined": 2026}), InsertOne({"name": "Tim", "joined": 2026}), ], ordered=False,)models := []mongo.WriteModel{ mongo.NewInsertOneModel().SetDocument(bson.M{"name": "Barbara", "joined": 2026}), mongo.NewInsertOneModel().SetDocument(bson.M{"name": "Tim", "joined": 2026}),}opts := options.BulkWrite().SetOrdered(false)_, err := coll.BulkWrite(ctx, models, opts)if err != nil { return err}use mongodb::options::{BulkWriteOptions, WriteModel};
let opts = BulkWriteOptions::builder().ordered(false).build();coll.client() .bulk_write(vec![ WriteModel::InsertOne { namespace: coll.namespace(), document: doc! { "name": "Barbara", "joined": 2026 }, }, WriteModel::InsertOne { namespace: coll.namespace(), document: doc! { "name": "Tim", "joined": 2026 }, }, ]) .with_options(opts) .await?;ใน Compass: ไม่มีแผง bulk-write แต่ upsert ปรากฏขึ้นเองอย่างเป็นธรรมชาติ — เมื่อคุณแก้ไข document การกระทำ Update จะมีช่อง checkbox upsert ให้ เพื่อให้ filter ที่ไม่ตรงกัน insert แทน
เคล็ดลับและจุดที่ต้องระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและจุดที่ต้องระวัง”- upsert สร้าง document ใหม่จาก ทั้ง เงื่อนไข equality ของ filter และ update operator ทำให้ filter เจาะจง (field ที่ระบุระเบียน) เพื่อให้ document ที่ insert ออกมาหน้าตาตรงกับที่คุณคาดไว้
- หลังจาก upsert ให้ตรวจ
matchedCountค่า0บวกกับupsertedIdหมายความว่ามี document ถูกสร้างขึ้น ส่วน1หมายความว่ารายการที่มีอยู่ถูก update - ordered
bulkWriteหยุดที่ error แรก ถ้าคุณต้องการให้ทุก operation ที่เป็นอิสระต่อกันถูกพยายามทำโดยไม่สนความล้มเหลว ก็กำหนด option แบบ unordered bulkWriteเป็นเรื่องของประสิทธิภาพ ไม่ใช่ transaction เว้นแต่คุณจะห่อไว้ใน transaction operation ชุดนี้ไม่ได้เป็นแบบทั้งหมดหรือไม่ทำเลย บางรายการสำเร็จ ส่วนบางรายการล้มเหลวได้
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
bulkWrite | รวมหลาย operation เป็น round trip เดียว เร็วกว่าการเรียกแยกมาก | ผลลัพธ์รวมเป็นก้อนเดียว ต้องแกะ error ของแต่ละ operation เองเวลามีบางตัวล้มเหลว |
| upsert | round trip เดียว จบทั้ง insert-ถ้าไม่มี และ update-ถ้ามี | ถ้ามีหลาย request แข่งกันด้วย filter เดียวกันพร้อมกัน (race condition) อาจเกิด document ซ้ำได้ถ้าไม่มี unique index รองรับ |
check-then-insert/update แบบ explicit (findOne ก่อน แล้วค่อย insert/update) | ควบคุมได้ละเอียด ตัดสินใจ logic เพิ่มเติมได้ก่อนเขียน | สอง round trip แทนที่จะเป็นหนึ่ง และยังมีช่องว่างระหว่าง find กับ write ที่ request อื่นแทรกเข้ามาได้ (race เช่นกัน แต่ควบคุมยากกว่า) |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- เชื่อว่า upsert ป้องกัน document ซ้ำได้เสมอ — ถ้าไม่มี unique index ครอบ field ที่ใช้เป็น filter การยิง upsert พร้อมกันหลาย request ด้วย filter เดียวกันสามารถสร้าง document ซ้ำได้ เพราะ MongoDB ตรวจ “ไม่พบ” แล้ว insert แบบแยกกันในแต่ละ request ก่อนที่อีกฝั่งจะเห็นผลลัพธ์ของกันและกัน
- ใส่ operation หลายพันตัวลงใน
bulkWriteเดียวโดยไม่แบ่ง batch — payload ใหญ่เกินไปอาจชนลิมิตของ driver/ server ควรแบ่งเป็นชุดที่ขนาดพอเหมาะแล้วยิงหลายรอบ - ใช้ ordered
bulkWrite(ค่าเริ่มต้น) เมื่อ operation ต่าง ๆ ไม่เกี่ยวข้องกัน — ถ้า operation ตัวที่ 3 ล้มเหลว operation ที่ 4-100 จะไม่ถูกรันเลย ทั้งที่ไม่มีอะไรเกี่ยวข้องกับตัวที่ล้มเหลว ทำให้งานที่ควรสำเร็จหายไปด้วย ควรใช้ unordered ถ้า operation เป็นอิสระต่อกันจริง ๆ
💡 ตัวอย่างจากของจริง
ระบบนับยอดสินค้าคงคลัง (inventory sync) — เมื่อซิงก์สต็อกจากคลังสินค้าจริงเข้าระบบทุกคืน ใช้
bulkWriteแบบ unordered รวมupdateOneหลายพันรายการ (แก้ยอด stock ต่อ SKU) เข้าเป็น round trip ไม่กี่ครั้ง แทนที่จะยิง update ทีละ SKUระบบนับยอด page view / analytics counter — ใช้ upsert กับ
updateOne({ page, date }, { $inc: { views: 1 } }, { upsert: true })เพื่อสร้างแถวนับยอดของวันนั้นถ้ายังไม่มี หรือบวกเพิ่มถ้ามีอยู่แล้ว โดยมี unique compound index บน(page, date)กันไม่ให้เกิดแถวซ้ำเวลามี request มาพร้อมกันจำนวนมาก