Aggregation ที่ scan ทั้ง collection จะ OOM: ลำดับ stage สำคัญกว่าที่คิด
`$addToSet used too much memory · limit 100MB` ไม่ใช่ bug ของ MongoDB แต่คือสัญญาณว่า pipeline ของคุณ group ก่อน filter จนต้องกวาดทั้ง collection — บทเรียนจริงจากงาน time-series ระดับ 14 ล้าน document ต่อ partition ว่าทำไมลำดับ stage คือตัวชี้เป็นชี้ตาย และทำไม group key ที่ผิด semantic ทำให้ได้คำตอบผิดแบบเงียบๆ
อ่าน ~8 นาที
เริ่มจากอาการ: $addToSet used too much memory
ผมเจอ error นี้ตอนเขียน aggregation บน collection time-series ก้อนหนึ่ง มันเก็บ log จากงานประมวลผลภาพเป็น grid — พูดง่ายๆ คือทุกรอบประมวลผลจะเขียนหลายร้อย document ลงไป สะสมจนบาง partition มีราวๆ 14 ล้าน document ตอน dev มันรันผ่านสบาย พอเอาขึ้น data จริง pipeline เดิมกลับพ่น error ออกมาตรงๆ ว่า $addToSet used too much memory and cannot spill to disk. Memory limit: 104857600 bytes
104857600 bytes คือ 100MB พอดี นั่นคือเพดานหน่วยความจำที่ MongoDB ให้ต่อ blocking stage หนึ่ง stage และ error นี้ไม่ได้แปลว่า data ของผมใหญ่เกินไป แต่แปลว่า ผมสั่งให้ stage นั้นทำงานกับ document มากกว่าที่จำเป็นมหาศาล บทความนี้คือสิ่งที่ผมกลั่นออกมาหลังแก้มัน
$group ทำงานยังไง และทำไมมัน buffer ในหน่วยความจำ
กุญแจของเรื่องนี้คือคำว่า blocking stage stage ส่วนใหญ่ใน aggregation เป็นแบบ streaming — document ไหลผ่านทีละตัว ประมวลผลแล้วปล่อยออกทันที เช่น $match, $project พวกนี้ไม่ต้องจำอะไรค้างไว้ แต่ $group กับ $sort (ที่ไม่มี index รองรับ) ทำแบบนั้นไม่ได้
$group ต้องเห็น document ให้ครบก่อนถึงจะรู้ว่าแต่ละ key มีสมาชิกครบหรือยัง มันเลยต้องกักทุก group ที่เจอไว้ในหน่วยความจำพร้อมกัน ยิ่ง accumulator เป็นแบบสะสมค่าอย่าง $addToSet หรือ $push ยิ่งหนัก เพราะแต่ละ group ไม่ได้เก็บแค่ตัวเลขตัวเดียว แต่เก็บ array ที่โตขึ้นเรื่อยๆ ตามจำนวน document ที่ไหลเข้ามา
ทีนี้ประเด็นสำคัญ: ปริมาณหน่วยความจำที่ $group ใช้ ไม่ได้ขึ้นกับจำนวน group ที่ได้ผลลัพธ์ แต่ขึ้นกับจำนวน document ที่ไหลเข้ามาถึงตัวมัน ถ้าคุณปล่อยให้ทั้ง collection 14 ล้าน document ไหลเข้า $group ต่อให้ผลลัพธ์สุดท้ายมีแค่ 3 group มันก็ระเบิด 100MB ระหว่างทางอยู่ดี
ลำดับ pipeline ที่ปลอดภัย: $match → $sort → $limit → $group
หลักการเดียวที่ผมยึดหลังจากนั้นคือ ทำให้ stream เล็กที่สุดก่อนถึง blocking stage เสมอ และวิธีทำให้เล็กที่มีประสิทธิภาพที่สุดคือใช้ index ซึ่งจะทำงานได้ก็ต่อเมื่อ $match และ $sort อยู่ต้น pipeline ก่อนที่ stage ใดจะแปลงรูป document
// แย่: group ก่อน filter -> ทั้ง collection ไหลเข้า $group -> OOM 100MB
db.rain_logs.aggregate([
{ $group: { // blocking: กัก 14M docs ในหน่วยความจำ
_id: '$image_file',
grids: { $addToSet: '$grid_id' }
} },
{ $match: { province: 'nan' } }, // filter สายเกินไป ไม่ได้ช่วยอะไร
]);
// ดี: narrow ด้วย index ให้เหลือน้อยที่สุด แล้วค่อย group
db.rain_logs.aggregate([
{ $match: { province: 'nan' } }, // ใช้ compound index (province, timestamp)
{ $sort: { timestamp: -1 } }, // align กับ index -> ไม่ sort ในหน่วยความจำ
{ $limit: 5000 }, // ตัด volume ก่อนถึง blocking stage
{ $group: { // group บน 5000 docs -> อยู่ใต้ 100MB สบาย
_id: '$image_file',
grids: { $addToSet: '$grid_id' }
} },
]);
สังเกตว่า 4 stage แรกทำหน้าที่ "หด" stream ทั้งหมด $match ตัดตาม partition, $sort เรียงโดยอาศัย index เดียวกัน (ไม่ต้อง buffer), $limit คือด่านสุดท้ายที่การันตีจำนวน document ที่จะไหลเข้า $group ให้อยู่ในระดับที่ควบคุมได้ พอถึง $group มันเลยไม่มีทางแตะ 100MB
กฎที่ต้องจำ: เฉพาะ $match/$sort ที่อยู่ต้น pipeline ก่อน stage แปลงรูปเท่านั้นที่ใช้ index ได้ ถ้าคุณเอา $group หรือ $project ที่สร้าง field ใหม่ไว้ข้างหน้า stage ถัดจากนั้นจะทำงานบน in-memory stream ที่ไม่มี index รองรับอีกเลย
group key ผิด semantic: group by timestamp ที่ drift ระดับ ms = ได้ผลผิด
แก้เรื่อง OOM แล้วยังมีกับดักที่ร้ายกว่า เพราะมันไม่ error แต่ให้คำตอบผิดเงียบๆ ในระบบผม แต่ละ document ถูกเขียนด้วย timestamp ของตัวเอง (เรียก utcnow() ตอน insert แต่ละแถว) หนึ่งรอบประมวลผลที่มี ~700 grid จึงได้ ~700 timestamp ที่ต่างกันระดับมิลลิวินาที ไม่ใช่ timestamp เดียวที่แชร์กันทั้งรอบ
ผมเผลอเขียนแบบนี้ตอนอยากได้ "4 รอบล่าสุด":
// ผิด semantic: timestamp ไม่ซ้ำต่อ record
// _id นี้จะแตกเป็นหลักร้อยกลุ่ม/รอบ -> limit 4 = "4 grid ล่าสุด" ไม่ใช่ 4 รอบ
db.rain_logs.aggregate([
{ $match: { province: 'nan' } },
{ $group: { _id: '$timestamp' } },
{ $sort: { _id: -1 } },
{ $limit: 4 },
]);
ผลที่ได้ไม่ error เลย มันคืน 4 แถวสวยงาม แต่ทั้ง 4 แถวคือ 4 grid สุดท้ายของรอบเดียวกัน ไม่ใช่ 4 รอบตามที่ต้องการ บั๊กแบบนี้แพงมากเพราะไม่มีอะไรเตือน — dashboard โชว์เลข query รันเร็ว ทุกอย่างดูปกติ จนกว่าจะมีคนสังเกตว่าตัวเลขมันแปลก
บทเรียนเชิงหลักการ: ก่อนเลือก group key ต้องถามว่า field นี้ "หนึ่งค่า = หนึ่งหน่วยเชิงธุรกิจ" จริงไหม timestamp ที่สร้างต่อ record ไม่ได้แทน "รอบ" มันแทน "ช่วงเวลาที่เขียนแถวนั้น" คนละความหมายกัน การ group ด้วย field ที่ semantic ผิดจะได้ผลลัพธ์ที่ syntax ถูกแต่ความหมายผิด
ใช้ bucket key ที่ stable แทน timestamp ต่อ record
ทางแก้คือหา key ที่ทุก record ในหน่วยเดียวกันแชร์ค่าเดียวกันจริงๆ ในเคสผม ทุก grid ในรอบเดียวกันอ้าง input ไฟล์เดียวกัน เช่น PHI_20260510_0900.png ผมจึง group ด้วย image_file แทน หรือดีกว่านั้นคือ extract "bucket" ของเวลา (รูปแบบ YYYYMMDD_HHMM) ออกมาจากชื่อไฟล์ เพื่อรวมหลาย source ของรอบเดียวกันเข้าด้วยกัน
// group ด้วย bucket ที่ derive จากชื่อไฟล์ -> stable ต่อรอบ
db.rain_logs.aggregate([
{ $match: { province: 'nan' } },
{ $sort: { timestamp: -1 } },
{ $limit: 5000 },
{ $addFields: { // ดึง 20260510_0900 ออกมาเป็น key รอบ
round: { $regexFind: { input: '$image_file',
regex: /\d{8}_\d{4}/ } }
} },
{ $group: { _id: '$round.match',
grids: { $addToSet: '$grid_id' } } },
{ $sort: { _id: -1 } },
{ $limit: 4 }, // ได้ "4 รอบล่าสุด" จริง
]);
หลักการที่ transferable: เมื่อต้อง group "ต่อ event / ต่อ batch / ต่อ transaction" ให้มองหา natural key ที่ต้นทางกำหนดร่วมกันทั้ง batch (batch id, correlation id, ชื่อไฟล์ต้นทาง) อย่าใช้ timestamp ที่แต่ละ record สร้างเองเป็น key ของหน่วยที่ใหญ่กว่า record
เมื่อไหร่ต้อง allowDiskUse และทำไมมันไม่ใช่คำตอบเสมอไป
พอเจอ 100MB limit หลายคนรีบเปิด allowDiskUse: true เพราะมันทำให้ error หายทันที มันบอก MongoDB ว่า "ถ้าเกิน 100MB ให้ spill ไปเขียน temp file บน disk แทน" error หายจริง แต่คุณต้องเข้าใจว่านั่นไม่ได้แก้ปัญหา มันแค่ยอมให้ query ที่กวาดทั้ง collection รันต่อไปได้ โดยแลกกับความเร็ว เพราะ blocking stage กระเด็นจาก RAM ไป disk ช้าลงเป็นหลักสิบเท่า
ผมมองมันเป็น safety valve ไม่ใช่ทางแก้ ลำดับที่ถูกคือ:
- ก่อนอื่นลด volume ให้ stage หนักทำงานใต้ 100MB ด้วย
$match/$sort/$limitที่ใช้ index — นี่คือของจริง - เปิด allowDiskUse เฉพาะงาน batch/analytics ที่จำเป็นต้องกวาดข้อมูลจริงๆ และยอมรับว่ามันช้าได้ (report รายวัน, ETL) ไม่ใช่ query ที่อยู่บน hot path ของ request ผู้ใช้
- ถ้าคุณต้องพึ่ง
allowDiskUseบน endpoint ที่คนกดบ่อยๆ นั่นคือสัญญาณว่า data model หรือ pipeline ผิด ไม่ใช่ MongoDB ขี้เหนียวหน่วยความจำ
Verify plan: อ่าน explain เพื่อดูว่า index ถูกใช้จริง
สุดท้ายและสำคัญที่สุด: อย่าเดาว่า pipeline ใช้ index หลังจากจัดลำดับ stage แล้ว ให้ยิง explain ดูของจริง เพราะการที่คุณเขียน $match ไว้บนสุด ไม่การันตีว่า MongoDB จะใช้ index ที่คุณคิด
// ขอ execution plan จริง แล้วอ่านว่ามันทำอะไร
db.rain_logs.aggregate(pipeline, { explain: true })
// สิ่งที่ต้องเห็นใน stage แรก:
// "stage": "IXSCAN" + ชื่อ index ที่ตั้งใจ (ไม่ใช่ COLLSCAN)
// สิ่งที่ต้องไม่เห็น:
// "SORT" แยกเป็น stage ของตัวเอง = MongoDB sort ในหน่วยความจำ
// (แปลว่า $sort ไม่ได้ align กับ index -> เสี่ยง OOM ที่ sort ด้วย)
ถ้าเห็น COLLSCAN แปลว่า $match ไม่ได้ใช้ index — อาจเพราะ field ที่ filter ไม่ตรง prefix ของ compound index หรือ index ที่คิดว่ามีจริงๆ ไม่เคยถูก build บน prod ถ้าเห็น SORT เป็น stage แยก แปลว่า $sort ของคุณไม่ align กับลำดับ field ใน index มันเลยต้องเรียงในหน่วยความจำเอง ซึ่งก็เป็น blocking stage ที่ชน 100MB ได้เหมือน $group explain คือหลักฐาน ไม่ใช่ความรู้สึก
สรุป: หลักการที่เอาไปใช้ต่อได้
$group/$sortคือ blocking stage ที่ buffer ในหน่วยความจำ และกินตามจำนวน document ที่ไหลเข้า ไม่ใช่จำนวนผลลัพธ์ — เพดาน 100MB ต่อ stage- narrow ก่อน group เสมอ: ลำดับปลอดภัยคือ
$match → $sort → $limit → $groupให้ index ทำงานและตัด volume ก่อนถึง stage หนัก - เฉพาะ
$match/$sortต้น pipeline ก่อน stage แปลงรูป เท่านั้นที่ใช้ index ได้ — อย่าเอา$group/$projectไว้ข้างหน้า - group key ต้องถูก semantic: ถามว่า "หนึ่งค่า = หนึ่งหน่วยเชิงธุรกิจ" จริงไหม — timestamp ที่สร้างต่อ record ไม่ใช่ key ของ "รอบ/batch"
- ใช้ natural key ที่ต้นทางกำหนดร่วมทั้ง batch (batch id, correlation id, ชื่อไฟล์ต้นทาง) แทน timestamp ต่อ record
allowDiskUseคือ safety valve ไม่ใช่ทางแก้ ใช้กับงาน batch/analytics ที่ยอมช้าได้ ถ้าต้องพึ่งมันบน hot path แปลว่า model/pipeline ผิด- verify ด้วย explain จริง: ต้องเห็น
IXSCANใน stage แรก และต้องไม่เห็นSORTเป็น stage แยก




