บั๊กที่ query ไม่ error แต่ผลผิด: เมื่อ sort key ไม่สะท้อนลำดับเวลาจริง
query ไม่พัง ไม่มี exception แต่คำตอบผิด — เพราะเราเลือก sort/group key ที่ "หยิบง่าย" แทน key ที่สะท้อน "ลำดับที่แท้จริง" ของคำถาม บทเรียนจากบั๊กตระกูลเดียวกันที่โผล่ต่างหน้ากันข้ามหลายระบบ
อ่าน ~8 นาที
บั๊กที่แย่ที่สุดคือบั๊กที่ไม่ throw
บั๊กที่ทำให้ระบบพังจอแดงนั้นน่ากลัวน้อยกว่าบั๊กที่ query รันผ่านสวยงาม ไม่มี exception ไม่มี log สีแดง แต่คืน "คำตอบผิด" ออกมาหน้าตาเนียนเหมือนถูก ผมเจอบั๊กตระกูลนี้ซ้ำๆ ในหลายระบบที่ดูแล และหลังจากไล่แก้มันหลายครั้ง ผมพบว่าทุกเคสมี สาเหตุร่วมอันเดียวกัน:
เราเลือก sort key หรือ group key ที่ "หยิบง่ายที่สุด" แทนที่จะเป็น key ที่สะท้อน "ลำดับที่แท้จริง" ของสิ่งที่เรากำลังถาม
field ที่หยิบง่ายมักอยู่ตรงหน้า — createdAt มีทุก row, timestamp ก็มี, วันที่ก็แยกเป็นวันได้ทันที แต่ความหมายที่เราต้องการจริงๆ อาจไม่ตรงกับ field เหล่านั้นเลย บทความนี้ผมจะเดินผ่าน 3 เคสที่หน้าตาต่างกันสิ้นเชิง แต่เป็นบั๊กตัวเดียวกัน แล้วสรุปเป็นหลักการที่เอาไปใช้ได้กับทุกระบบ
เคส 1: group by timestamp ที่แต่ละ row สร้างเอง
ระบบหนึ่งประมวลผลข้อมูลเป็น batch batch หนึ่งมีประมาณ 700 row ที่ถูกเขียนเข้า DB พร้อมกัน ทีมต้องการ aggregate ผลลัพธ์ "ต่อ batch" ดังนั้นโค้ดเดิมจึง group by timestamp
// ผิด: แต่ละ row บันทึกเวลา insert ของตัวเอง ต่างกันระดับ millisecond
db.results.aggregate([
{ $group: { _id: "$timestamp", total: { $sum: "$value" } } }
])
ปัญหาคือ timestamp ของแต่ละ row ถูกสร้างตอน row นั้นถูกเขียน ไม่ใช่ค่าที่ทั้ง batch แชร์กัน 700 row ที่เขียนไล่กันในเสี้ยววินาทีจึงมี timestamp ต่างกันหมด ผลคือแทนที่จะได้ 1 กลุ่มต่อ batch เรากลับได้ 700 กลุ่ม กลุ่มละ 1 row query ไม่ error เลย มันคืน "กลุ่ม" ออกมาถูกต้องตาม key ที่เราสั่ง — แต่ key นั้นไม่ใช่สิ่งที่เราหมายถึง
ทางแก้คือหา business key ที่ทั้ง batch แชร์ร่วมกันจริงๆ ในเคสนี้คือชื่อไฟล์ต้นทางของ batch ผมดึง bucket ออกมาด้วย regex แล้ว group ด้วยค่านั้นแทน:
// ถูก: group ด้วยตัวระบุ batch ที่ทุก row แชร์ (เช่น bucket จากชื่อไฟล์)
db.results.aggregate([
{ $addFields: {
batchKey: { $regexFind: { input: "$sourceFile", regex: /^(.*?)_\d+\.dat$/ } }
}},
{ $group: { _id: "$batchKey.captures", total: { $sum: "$value" } } }
])
บทเรียน: ถ้าคุณต้องการ group "ตามเหตุการณ์ที่เกิดพร้อมกัน" อย่าใช้ timestamp ที่แต่ละ row ประทับเอง จง group ด้วย key ที่เป็นตัวแทนของเหตุการณ์นั้นตรงๆ
เคส 2: sort ledger ด้วย createdAt แทน commit time
ระบบการเงินตัวหนึ่งเก็บ ledger แบบ append-only เพื่อคำนวณยอดคงเหลือ ทุกรายการมี createdAt ตอนที่ผู้ใช้ "ตั้งใจ" จะทำรายการ และมี approvedAt ตอนที่รายการถูกอนุมัติจริง โค้ดเดิม sort ด้วย createdAt แล้ว running-sum ทีละแถว
// ผิด: createdAt = ลำดับ "เจตนา" ไม่ใช่ลำดับที่เงินเปลี่ยนมือจริง
const rows = await ledger.find().sort({ createdAt: 1 });
let balance = 0;
for (const r of rows) balance += r.amount;
ในโลกจริง รายการที่ถูกสร้างทีหลังอาจได้รับอนุมัติก่อน (คิวอนุมัติ, การรีวิว, การแก้ไข) ยอดคงเหลือของ ledger ต้องสะท้อนลำดับที่ รายการมีผลจริง ซึ่งคือ commit/approval time ไม่ใช่เวลาที่ตั้งใจสร้าง เมื่อ createdAt กับ approvedAt เรียงตรงกันทุกแถว บั๊กนี้จะซ่อนสนิท — มันโผล่เฉพาะตอนที่ลำดับสองอันสลับกัน
// ถูก: sort ด้วยเวลาที่รายการมีผล + regression test ที่สลับลำดับ
const rows = await ledger.find().sort({ approvedAt: 1 });
// test seed ต้องจงใจให้ createdAt กับ approvedAt สลับกัน
seed([
{ amount: 100, createdAt: t(1), approvedAt: t(5) },
{ amount: -30, createdAt: t(2), approvedAt: t(3) }, // สร้างทีหลัง อนุมัติก่อน
]);
// ถ้าเรียงผิด (ตาม createdAt) จะได้ running balance ที่ต่างออกไป
บทเรียน: field ที่มีคำว่า "time" หลาย field ในตารางเดียวกันคือสัญญาณอันตราย ต้องถามให้ชัดว่าคำถามทางธุรกิจอ้างถึง "เวลาอะไร" แล้ว sort ด้วย field นั้น พร้อมมี regression test ที่ seed ข้อมูลให้ลำดับสอง field ขัดกันเสมอ — ไม่งั้น test ก็มองไม่เห็นบั๊ก
เคส 3: sort วันที่ด้วย sub-field ที่ลอยจากบริบท
อีกระบบมีหน้าจอ "รายการล่าสุด" ที่ sort ด้วยเลขวันของเดือน (day-of-month) เพราะข้อมูลถูกเก็บแยก field ไว้แล้ว หยิบมาใช้ง่าย
// ผิด: sort ด้วย day ที่หลุดจาก month/year
records.sort((a, b) => b.day - a.day);
มันทำงานถูกทั้งเดือน จนกระทั่งถึง "ต้นเดือน" ลองนึกภาพรายการวันที่ 1 ของเดือนนี้ เทียบกับวันที่ 30 ของเดือนก่อน โค้ดจะเห็น 30 > 1 แล้วสรุปว่าวันที่ 30 (เดือนก่อน) คือรายการ "ล่าสุด" ทั้งที่จริงมันเก่ากว่า บั๊กนี้ยิงเฉพาะช่วงต้นเดือนไม่กี่วัน ทำให้หายากมากถ้าไม่จงใจทดสอบ
// ถูก: เทียบทั้ง tuple {ปี, เดือน, วัน} หรือใช้ timestamp เดียวไปเลย
records.sort((a, b) => {
if (a.year !== b.year) return b.year - a.year;
if (a.month !== b.month) return b.month - a.month;
return b.day - a.day;
});
// หรือดีที่สุด: เก็บ/เทียบเป็นค่าเดียวที่เรียงได้เชิงเส้น
records.sort((a, b) => b.epochMs - a.epochMs);
บทเรียน: อย่า sort ด้วย sub-component ของค่าที่มีหลายมิติ ถ้าคุณต้องการลำดับตามเวลา จง sort ด้วยค่าเดียวที่ monotonic (epoch/timestamp) หรือด้วย tuple ครบทุกมิติเรียงจากหยาบไปละเอียด
แกนร่วมที่มองข้ามบ่อย: timezone
เคสวันที่ข้างบนมีแฝดอันตรายคือเรื่อง timezone หลักปฏิบัติที่ดีคือ เก็บทุกอย่างเป็น UTC แต่ผู้ใช้และรายงานคิดใน business timezone (เช่น UTC+7) การ "แบ่งวัน" จึงต้องแปลงเข้า business timezone ก่อนเสมอ
// ผิด: group by วันด้วย UTC ทำให้ขอบวันเลื่อน
// 06:30 ของวันไทย = 23:30 UTC ของ "เมื่อวาน" → ตกไปอยู่วันผิด
// ถูก (เชิงแนวคิด): shift เข้า business tz ก่อนตัดวัน
// dayKey = toDate(ts + offset).floorToDay()
บั๊กนี้ซ่อนตัวที่ขอบวัน (day boundary) รอบเที่ยงคืนเท่านั้น ข้อมูลกลางวันดูถูกหมด อีกกับดักหนึ่งคือ WHERE ที่ห่อ column ด้วยฟังก์ชันแปลง timezone — มันมักทำให้ query ไม่ hit index แล้วกลายเป็น full scan เงียบๆ ทางที่ดีกว่าคือคำนวณช่วงเวลา (UTC boundary) ในโค้ดแล้วส่งเป็นช่วง >= / < ให้ index ทำงานได้
วิธีจับบั๊กตระกูลนี้
สิ่งที่บั๊กทั้งสามเคสมีร่วมกันคือ มันมองไม่เห็นด้วย test ปกติ เพราะข้อมูลตัวอย่างมักเรียงตรงกันพอดี ผมสรุปวิธีจับที่ใช้ได้จริงไว้ดังนี้:
- เขียน boundary-crossing test seed เสมอ — seed ข้อมูลที่จงใจข้ามขอบ: ต้นเดือน/สิ้นเดือน, ข้ามปี, รายการที่ createdAt กับ approvedAt สลับกัน, event รอบเที่ยงคืน ถ้า test ไม่ข้าม boundary มันจะไม่มีวันเห็นบั๊กพวกนี้
- ตั้งคำถามว่า "key นี้หมายถึงลำดับอะไรจริงๆ" — ก่อน sort/group ทุกครั้ง ถามว่า field ที่หยิบมาสะท้อนความหมายที่ต้องการ หรือแค่หยิบง่าย
- ระวังตารางที่มี "time" หลาย field — createdAt / updatedAt / approvedAt / effectiveAt คนละความหมาย เลือกให้ตรงคำถาม
- git blame บรรทัดที่พัง ไม่ใช่ commit ล่าสุด — บั๊กพวกนี้มักฝังอยู่ตั้งแต่เขียนครั้งแรกและเพิ่งโผล่เพราะข้อมูลบังเอิญเข้าเงื่อนไข อย่าเสียเวลาไล่ commit ล่าสุด ให้ blame ตรงบรรทัด logic แล้วอ่านเจตนาเดิม
- อย่าห่อ column ด้วยฟังก์ชันใน WHERE — คำนวณ boundary ใน application layer แล้วส่งเป็นช่วง เพื่อให้ index ยังทำงาน
สรุป
- บั๊กที่ query ไม่ error แต่ผลผิด มักมาจาก sort/group key ที่ "หยิบง่าย" ไม่ใช่ key ที่สะท้อนลำดับที่แท้จริงของคำถาม
- group ตามเหตุการณ์ที่เกิดพร้อมกัน → ใช้ business key ที่ทั้งชุดแชร์ ไม่ใช่ timestamp ที่แต่ละ row ประทับเอง
- ยอดคงเหลือ/ledger → sort ด้วยเวลาที่รายการมีผลจริง (commit/approval) ไม่ใช่เวลาที่ตั้งใจสร้าง
- เรียงตามเวลา → ใช้ค่า monotonic เดียว (epoch) หรือ tuple ครบมิติ ไม่ใช่ sub-field ลอยๆ อย่าง day-of-month
- timezone: เก็บ UTC เทียบใน business tz, ระวังขอบวันเที่ยงคืน และอย่าทำให้ WHERE หลุด index
- ป้องกันด้วย boundary-crossing test seed เสมอ — ไม่มี seed ที่ข้ามขอบ = ไม่มีวันเห็นบั๊ก




