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

Upsert และ bulk write

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

ทั้งคู่ไม่ได้เพิ่ม operator ใหม่ แต่เอา method ที่คุณรู้อยู่แล้วมาประกอบใหม่ให้คุ้มขึ้น

เราจบด้วย collection members ของเราอีกครั้งหนึ่ง

เพิ่ม 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 }
)

เพราะไม่มี 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 แล้วส่งไปพร้อมกัน แต่ละรายการระบุ 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" } } }
])

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"]
bulkWrite อัด operation หลายรายการเข้าไปใน round trip เดียวและคืนผลลัพธ์ที่รวมกัน

โดยปริยาย 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 }
)

ใน 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 ชุดนี้ไม่ได้เป็นแบบทั้งหมดหรือไม่ทำเลย บางรายการสำเร็จ ส่วนบางรายการล้มเหลวได้
ตัวเลือกBenefitCost
bulkWriteรวมหลาย operation เป็น round trip เดียว เร็วกว่าการเรียกแยกมากผลลัพธ์รวมเป็นก้อนเดียว ต้องแกะ error ของแต่ละ operation เองเวลามีบางตัวล้มเหลว
upsertround 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 มาพร้อมกันจำนวนมาก

รัน upsert แล้ว filter ไม่ match document ที่มีอยู่เลย จะเกิดอะไรขึ้น?
ประโยชน์หลักของ bulkWrite เหนือการเรียกแยกกันคืออะไร?
ordered bulkWrite ทำตัวอย่างไรเมื่อ operation หนึ่งล้มเหลว?
หลังจาก upsert ผลลัพธ์ใดเป็นสัญญาณว่ามี document ใหม่ถูกสร้างขึ้น?