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 ละเอียดอ่อนทั้งหมวด โดยเฉพาะเรื่องตัวเลขและวันที่
type ของ BSON ที่พบบ่อย
หัวข้อที่มีชื่อว่า “type ของ BSON ที่พบบ่อย”field ของ document เก็บอันใดอันหนึ่งในนี้ได้ อันที่ใช้ประจำวันมาก่อน:
- String — ข้อความ UTF-8 เช่นชื่อหรือชื่อเรื่อง
- Int32 — จำนวนเต็ม 32 บิต เป็นค่าเริ่มต้นสำหรับจำนวนเต็มใน driver หลายตัว
- Int64 — จำนวนเต็ม 64 บิต สำหรับจำนวนเต็มที่ใหญ่เกินกว่า 32 บิต
- Double — เลขทศนิยม floating-point 64 บิต เป็นค่าเริ่มต้นสำหรับตัวเลขที่มีจุดทศนิยม
- Boolean —
trueหรือ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 ได้อย่างอิสระ
type ซ้อนกันอย่างไร
หัวข้อที่มีชื่อว่า “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"] field _id และ ObjectId
หัวข้อที่มีชื่อว่า “field _id และ ObjectId”ทุก 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 สามารถแยกแยะสองอย่างนี้ได้ ดังนั้นเลือกอย่างตั้งใจ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| 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 ที่ไม่ต้องการความแม่นยำระดับมิลลิวินาที