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

Documents และ BSON

คุณเขียน document เป็น JSON แต่ MongoDB ไม่ได้เก็บเป็น JSON text จริง ๆ แล้วเก็บเป็น BSON ย่อมาจาก Binary JSON เป็นรูปแบบไบนารีกระชับที่เติมสิ่งที่ JSON ขาดเข้ามา ทั้ง type ตัวเลขจริง วันที่ ข้อมูลไบนารี และ type เฉพาะตัวอีกสองสามอย่าง BSON คือสิ่งที่ทำให้ document สแกนได้เร็ว แม่นยำเรื่องตัวเลข และสามารถพก type ที่ JSON text ธรรมดาแทนค่าไม่ได้

คุณแทบไม่เคยเห็น BSON ดิบ ๆ shell และ driver แปลระหว่าง BSON และ object ตามธรรมชาติของภาษาคุณให้เอง แต่การรู้ว่า type ใดมีให้ใช้บ้าง — และอันไหนที่คุณกำลังเก็บอยู่จริง ๆ — ช่วยคุณให้พ้นจาก bug ละเอียดอ่อนทั้งหมวด โดยเฉพาะเรื่องตัวเลขและวันที่

field ของ document เก็บอันใดอันหนึ่งในนี้ได้ อันที่ใช้ประจำวันมาก่อน:

  • String — ข้อความ UTF-8 เช่นชื่อหรือชื่อเรื่อง
  • Int32 — จำนวนเต็ม 32 บิต เป็นค่าเริ่มต้นสำหรับจำนวนเต็มใน driver หลายตัว
  • Int64 — จำนวนเต็ม 64 บิต สำหรับจำนวนเต็มที่ใหญ่เกินกว่า 32 บิต
  • Double — เลขทศนิยม floating-point 64 บิต เป็นค่าเริ่มต้นสำหรับตัวเลขที่มีจุดทศนิยม
  • Booleantrue หรือ false
  • Date — ช่วงเวลาหนึ่ง เก็บเป็นมิลลิวินาทีนับจาก Unix epoch
  • Array — รายการค่าที่มีลำดับ ซึ่งแต่ละค่าเป็น BSON type อะไรก็ได้
  • Embedded document — document เต็มที่ซ้อนอยู่ภายใน field โดยมี field ของตัวเอง
  • ObjectId — identifier ขนาด 12 ไบต์ที่กระชับ เป็น type เริ่มต้นของ field _id
  • Null — ค่าว่างที่ระบุชัด (ต่างจากการที่ field ไม่มีอยู่)
  • Decimal128 — เลขทศนิยม 128 บิตสำหรับเลขคณิตฐาน 10 ที่แม่นยำ เป็น type ที่ถูกต้องสำหรับเงิน

document ก็แค่ type เหล่านี้ประกอบกัน ตัวอย่างข้างล่างนี้ใช้ type เกือบทั้งหมดพร้อมกันในก้อนเดียว:

{
"_id": "65f0b3d4e4b0a1c2e4b0a1d1",
"name": "Ada",
"active": true,
"fines": 0,
"balance": "12.50",
"visits": 4815162342,
"joined": "2023-04-12T09:30:00Z",
"borrowed": ["Compilers", "Algorithms"],
"address": {
"city": "Cambridge",
"zip": "02139"
},
"nickname": null
}

ใน document นั้น name เป็น string, active เป็น boolean, fines เป็น int32, balance เป็น Decimal128 (เขียนเป็น string เพื่อรักษาความแม่นยำไว้), visits เป็น int64, joined เป็น Date, borrowed เป็น array, address เป็น embedded document, และ nickname เป็น null ที่ระบุชัด หนึ่ง field ต่อหนึ่ง type แต่ document เดียวผสม type ได้อย่างอิสระ

พลังของโมเดล document คือ array และ embedded document สามารถบรรจุ array และ document เพิ่มได้อีก ลึกลงไปได้ตามที่ข้อมูลของคุณต้องการ

flowchart TD
  Doc["Document"] --> Scalars["Scalar fields
 string, int32, double, bool, date"]
  Doc --> Id["_id: ObjectId"]
  Doc --> Arr["Array field
 borrowed[ ]"]
  Doc --> Emb["Embedded document
 address { }"]
  Emb --> EmbF["city, zip"]
document คือ scalar field บวกกับ array และ embedded document ที่ซ้อนกันลึกเท่าใดก็ได้

ทุก document มี field _id ซึ่งคือ primary key ของ document และห้ามซ้ำภายใน collection เดียวกัน ถ้าคุณไม่ใส่มาตอน insert MongoDB จะสร้างให้เอง โดย type เริ่มต้นคือ ObjectId: ค่าขนาด 12 ไบต์ที่ออกแบบให้ไม่ซ้ำกันโดยไม่ต้องประสานงานกับ server

12 ไบต์เหล่านั้นไม่ใช่ค่าสุ่ม ObjectId เข้ารหัสตามลำดับดังนี้:

  • timestamp 4 ไบต์ — วินาทีที่ id ถูกสร้าง นี่คือเหตุผลที่ ObjectId เรียงลำดับคร่าว ๆ ตามเวลาที่สร้าง และเป็นเหตุผลที่คุณดึงวันที่สร้างคร่าว ๆ ออกมาจาก ObjectId ได้
  • ค่าสุ่ม 5 ไบต์ — ไม่ซ้ำกันต่อเครื่องและ process ดังนั้นสอง process จึงไม่ชนกัน
  • counter 3 ไบต์ — เพิ่มขึ้นต่อ process ดังนั้นแม้แต่ id ที่สร้างในวินาทีเดียวกันก็ต่างกัน

เพราะ timestamp มาก่อน การเรียง collection ตาม _id จึงเกือบเหมือนการเรียงตามเวลาที่ insert — ผลพลอยได้ฟรีที่สะดวก

{ "_id": "65f0b3d4e4b0a1c2e4b0a1d1", "name": "Ada" }

คุณสามารถตั้ง _id เองเป็นค่าใดก็ได้ที่ไม่ซ้ำกัน — string, ตัวเลข, อีเมล — เมื่อข้อมูลของคุณมี key ตามธรรมชาติอยู่แล้ว MongoDB ขอแค่ให้ _id มีอยู่และไม่ซ้ำ ไม่จำเป็นต้องเป็น ObjectId

  • ObjectId ถูกสร้างฝั่ง client โดย driver ไม่ใช่ server แปลว่าแอปของคุณรู้ _id ใหม่ทันทีที่สร้าง document ก่อนที่การ insert จะไปถึง database ด้วยซ้ำ
  • Date ไม่เหมือนกับ BSON Timestamp type ใช้ Date สำหรับเวลาที่คุณสนใจ — เมื่อมีบางสิ่งเกิดขึ้น ส่วน Timestamp ที่แยกออกไปเป็นค่าภายในที่ MongoDB ใช้สำหรับการจัดลำดับ replication และแทบไม่ใช่สิ่งที่คุณต้องการในข้อมูลของคุณเอง
  • ใช้ Decimal128 สำหรับเงิน Double ไม่สามารถแทนค่าเช่น 0.10 ได้อย่างแม่นยำ ซึ่งนำไปสู่ข้อผิดพลาดในการปัดเศษเวลารวมยอด Decimal128 ทำเลขคณิตฐาน 10 ได้อย่างแม่นยำ
  • Null ไม่ใช่การไม่มีอยู่ field ที่ตั้งเป็น null มีอยู่ด้วยค่า null; field ที่ไม่เคยถูกตั้งค่าเลยจะไม่มีอยู่เลย query สามารถแยกแยะสองอย่างนี้ได้ ดังนั้นเลือกอย่างตั้งใจ
ตัวเลือกBenefitCost
BSON (type เพิ่มเติมอย่าง Date, ObjectId, Decimal128, Binary)เก็บค่าตรง type จริง query และ sort ได้ถูกต้องตาม type เช่นเรียงตามวันที่จริงหรือคำนวณเงินแม่นยำด้วย Decimal128ไม่ portable เท่า JSON ทันที ระบบภายนอกที่รับเฉพาะ JSON text ต้องแปลง type พวกนี้ก่อน ซึ่งอาจสูญเสียความแม่นยำถ้าทำไม่ถูกวิธี
JSON ธรรมดาportable ข้ามระบบได้ทันที อ่านง่าย เครื่องมือรองรับทุกที่มี type พื้นฐานน้อย (string, number, boolean, null, array, object) เท่านั้น ต้องเข้ารหัส Date และเลขแม่นยำสูงเป็น string เอง แล้วเสี่ยงตีความผิดฝั่ง
  • เก็บ ObjectId เป็น string ธรรมดาแทนที่จะเป็น BSON type ObjectId — ทำให้เสีย type-aware querying และการเรียงลำดับตามเวลาที่เป็นผลพลอยได้ของ ObjectId ไปฟรี ๆ ถ้าเก็บเป็น string การเปรียบเทียบจะเป็นการเทียบตัวอักษร ไม่ใช่เทียบค่าจริง
  • คิดว่า BSON กับ JSON เป็นสิ่งเดียวกัน — คุณเขียนและอ่านเป็น JSON ผ่าน shell/driver แต่สิ่งที่เก็บบนดิสก์คือ BSON การไม่รู้ความต่างนี้ทำให้งงว่าทำไม type ตัวเลขหรือวันที่ถึงหน้าตาเปลี่ยนไปตอนย้ายข้อมูลผ่านระบบที่ใช้ JSON text ล้วน
  • ใช้ Double เก็บเงินเพราะคิดว่าเป็น “ตัวเลขทศนิยม” — Double แทนค่าเช่น 0.10 ไม่แม่นยำ ทำให้ยอดรวมคลาดเคลื่อนสะสม ต้องใช้ Decimal128 สำหรับจำนวนเงิน

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

ระบบ fintech / e-commerce ที่ต้องคำนวณยอดเงิน — มักบังคับใช้ Decimal128 สำหรับทุก field ที่เป็นเงิน เพื่อไม่ให้เกิดข้อผิดพลาดสะสมจากการปัดเศษแบบ floating-point เวลารวมยอด invoice จำนวนมาก

eBay — ใช้ ObjectId เป็นทั้ง primary key และตัวช่วยเรียงลำดับคร่าว ๆ ตามเวลาสร้างของ metadata document จำนวนมหาศาล โดยไม่ต้องเก็บ field created_at แยกต่างหากสำหรับ use case ที่ไม่ต้องการความแม่นยำระดับมิลลิวินาที

BSON ย่อมาจากอะไร และทำไม MongoDB ถึงใช้?
ส่วนแรกของ ObjectId เข้ารหัสอะไรไว้?
ควรใช้ BSON type ไหนเก็บจำนวนเงินให้แม่นยำ?