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 เกือบทั้งหมดพร้อมกัน insert จริง แล้วตามด้วยคำตอบของ server เองต่อคำถาม “แต่ละ field เป็น type อะไร?” (ตัวดำเนินการ $type โดยใช้ alias จาก BSON types reference):
db.members.insertOne({ _id: ObjectId("65f0b3d4e4b0a1c2e4b0a1d1"), name: "Ada", active: true, fines: 0, balance: Decimal128("12.50"), visits: Long("4815162342"), joined: ISODate("2023-04-12T09:30:00Z"), borrowed: ["Compilers", "Algorithms"], address: { city: "Cambridge", zip: "02139" }, nickname: null,});console.log(db.members.findOne());
const types = db.members.aggregate([{ $project: { _id: 0, t: { $arrayToObject: { $map: { input: { $objectToArray: "$$ROOT" }, in: { k: "$$this.k", v: { $type: "$$this.v" } },} } } } }]).toArray()[0].t;console.log(types);assert.strictEqual(types.fines, "int");assert.strictEqual(types.visits, "long");assert.strictEqual(types.balance, "decimal");{ _id: ObjectId('65f0b3d4e4b0a1c2e4b0a1d1'), name: 'Ada', active: true, fines: 0, balance: Decimal128('12.50'), visits: Long('4815162342'), joined: ISODate('2023-04-12T09:30:00.000Z'), borrowed: [ 'Compilers', 'Algorithms' ], address: { city: 'Cambridge', zip: '02139' }, nickname: null}{ _id: 'objectId', name: 'string', active: 'bool', fines: 'int', balance: 'decimal', visits: 'long', joined: 'date', borrowed: 'array', address: 'object', nickname: 'null'}name เป็น string, active เป็น boolean, fines เป็น int32 ('int'), balance เป็น Decimal128, visits เป็น int64 ('long'), joined เป็น Date, borrowed เป็น array, address เป็น embedded document ('object') และ nickname เป็น null ที่ระบุชัด สังเกตว่า mongosh พิมพ์ Decimal128('12.50'), Long('4815162342') และ ISODate(...) — ไม่ใช่ string ธรรมดาอย่างที่ JSON dump จะแสดง นั่นคือ BSON ไม่ใช่ JSON
แต่ละ type กินกี่ไบต์?
หัวข้อที่มีชื่อว่า “แต่ละ type กินกี่ไบต์?”bsonsize() คืนขนาดที่เข้ารหัสของ document document ว่างคือ 5 ไบต์; document หนึ่ง field แต่ละอันด้านล่างเพิ่มไบต์ type ชื่อ field และค่า:
const sizes = { empty: bsonsize({}), int32: bsonsize({ a: 1 }), double: bsonsize({ a: 1.5 }), int64: bsonsize({ a: Long("1") }), decimal128: bsonsize({ a: Decimal128("1") }), string_abc: bsonsize({ a: "abc" }), date: bsonsize({ a: new Date() }), objectId: bsonsize({ a: new ObjectId() }), bool: bsonsize({ a: true }),};console.log(sizes);assert.strictEqual(sizes.int32, 12);assert.strictEqual(sizes.double, 16);{ empty: 5, int32: 12, double: 16, int64: 16, decimal128: 24, string_abc: 16, date: 16, objectId: 20, bool: 9}field int32 เสียค่า 4 ไบต์, double หรือ int64 เสีย 8, Decimal128 เสีย 16 และ ObjectId เสีย 12 (บวก 1 ไบต์ type ชื่อ a และตัวปิด) การเลือก int32 เมื่อใส่ได้ประหยัดเล็กน้อยแต่ฟรีเมื่อคูณกับหลายล้าน document
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"] ตัวเลขคือจุดที่ type กัด
หัวข้อที่มีชื่อว่า “ตัวเลขคือจุดที่ type กัด”shell และ driver ตัดสิน BSON type ของตัวเลขจากค่าในภาษา และไม่ได้ตัดสินเหมือนกันทั้งหมด เรา insert ตัวเลขชนิดเดียวกันจาก mongosh และ Node.js 24 กับ driver 7.7.0 แล้วถาม server หา $type ที่เก็บจริงของแต่ละอัน:
| ค่าที่เขียน | mongosh | Node driver |
|---|---|---|
1 หรือ 1.0 | int | int (JavaScript แยก 1.0 จาก 1 ไม่ได้) |
Double(1) | double | - (ไม่มี type แยก; ใช้ค่าที่มีเศษ) |
1.5 | double | double |
2**31 (2 147 483 648) หรือ 3000000000 | double | double |
2**53 | double | double |
Long("2147483648") (NumberLong) | long | Long.fromNumber(2**31) -> long |
BigInt(4815162342) / 4815162342n | long | long |
const c = db.collection("n");await c.insertOne({ i: 1, whole: 1.0, half: 1.5, big: 2 ** 31, safe: 2 ** 53, l: Long.fromNumber(2 ** 31), bi: 4815162342n, dec: Decimal128.fromString("12.50") });const t = await c.aggregate([{ $project: { _id: 0, i: { $type: "$i" }, whole: { $type: "$whole" }, half: { $type: "$half" }, big: { $type: "$big" }, safe: { $type: "$safe" }, l: { $type: "$l" }, bi: { $type: "$bi" }, dec: { $type: "$dec" } } }]).next();console.log(t);console.log(await c.findOne({}, { projection: { _id: 0, l: 1, bi: 1, dec: 1 } }));console.log(await c.findOne({}, { projection: { _id: 0, l: 1 }, promoteLongs: false }));if (t.whole !== "int" || t.big !== "double" || t.l !== "long") throw new Error("unexpected types");{ i: 'int', whole: 'int', half: 'double', big: 'double', safe: 'double', l: 'long', bi: 'long', dec: 'decimal'}{ l: 2147483648, bi: 4815162342, dec: new Decimal128('12.50') }{ l: new Long('2147483648') }สามผลที่ลงมือทำได้: (1) JavaScript number ที่เกิน 2^31 ถูกเก็บเป็น double ดังนั้นใช้ Long หรือ BigInt สำหรับตัวนับ int64 จริง; (2) การอ่าน long กลับมาให้ JavaScript number โดยค่าเริ่มต้น (promoteLongs) ซึ่งสูญเสียความแม่นยำเงียบ ๆ เกิน 2^53 — ส่ง promoteLongs: false (หรือ useBigInt64: true) เพื่อรับ Long กลับมา; (3) validator ที่ใช้ bsonType: "int" จะปฏิเสธ 2**31 จาก Node เพราะมาถึงเป็น double
Double เทียบ Decimal128 สำหรับเงิน
หัวข้อที่มีชื่อว่า “Double เทียบ Decimal128 สำหรับเงิน”console.log(0.1 + 0.2);db.prices_double.insertMany([{ v: 0.1 }, { v: 0.2 }]);db.prices_dec.insertMany([{ v: Decimal128("0.1") }, { v: Decimal128("0.2") }]);const sum = (c) => db[c].aggregate([{ $group: { _id: null, sum: { $sum: "$v" } } }]).toArray()[0].sum;console.log(sum("prices_double"), sum("prices_dec"));assert.strictEqual(sum("prices_double") === 0.3, false);assert.strictEqual(sum("prices_dec").toString(), "0.3");0.300000000000000040.30000000000000004 Decimal128('0.3')$sum ของ server เองบนสอง double ให้ 0.30000000000000004; ตัวเลขชุดเดียวกันเป็น Decimal128 ให้ 0.3 พอดี (ความแม่นยำ 34 หลักทศนิยม ตาม BSON types reference)
field _id และ ObjectId
หัวข้อที่มีชื่อว่า “field _id และ ObjectId”ทุก document มี field _id ซึ่งคือ primary key ของ document และห้ามซ้ำภายใน collection เดียวกัน ถ้าคุณไม่ใส่มาตอน insert MongoDB จะสร้างให้เอง โดย type เริ่มต้นคือ ObjectId: ค่าขนาด 12 ไบต์ที่ออกแบบให้ไม่ซ้ำกันโดยไม่ต้องประสานงานกับ server
12 ไบต์เหล่านั้นไม่ใช่ค่าสุ่ม ตาม BSON types reference ObjectId คือ:
- timestamp 4 ไบต์ — วินาทีนับจาก Unix epoch ตอนสร้าง id
- ค่าสุ่ม 5 ไบต์ สร้างครั้งเดียวต่อ process ฝั่ง client (ไม่ซ้ำต่อเครื่องและ process; สร้างใหม่เมื่อ process restart)
- ตัวนับที่เพิ่มขึ้น 3 ไบต์ ต่อ process ฝั่ง client เริ่มจากค่าสุ่ม
คุณแกะมันเองได้ นี่คือการถอดรหัส id จริงใน mongosh:
const id = ObjectId("65f0b3d4e4b0a1c2e4b0a1d1");const hex = id.toHexString();console.log(id.getTimestamp());console.log(hex.slice(0, 8), parseInt(hex.slice(0, 8), 16), new Date(parseInt(hex.slice(0, 8), 16) * 1000).toISOString());console.log("random:", hex.slice(8, 18), "counter:", hex.slice(18));assert.strictEqual(hex.length, 24); // 12 bytes = 24 hex charactersassert.strictEqual(id.getTimestamp().toISOString(), "2024-03-12T19:58:12.000Z");ISODate('2024-03-12T19:58:12.000Z')65f0b3d4 1710273492 2024-03-12T19:58:12.000Zrandom: e4b0a1c2e4 counter: b0a1d1และข้ออ้างเรื่อง “counter” กับ “random” พิสูจน์ด้วยสาม id ที่ process Node เดียวสร้างติดกัน (driver 7.7.0):
const ids = [new ObjectId(), new ObjectId(), new ObjectId()];for (const id of ids) { const h = id.toHexString(); console.log(h, "ts", h.slice(0, 8), "rand", h.slice(8, 18), "counter", h.slice(18), parseInt(h.slice(18), 16));}await db.collection("ord").insertMany(Array.from({ length: 1000 }, (_, i) => ({ seq: i })));const bySeq = await db.collection("ord").find({}).sort({ _id: 1 }).project({ seq: 1, _id: 0 }).toArray();console.log("sorted by _id equals insertion order:", bySeq.every((d, i) => d.seq === i));if (!bySeq.every((d, i) => d.seq === i)) throw new Error("_id order differs from insertion order");6abca5dd3c98eeca8695c79f ts 6abca5dd rand 3c98eeca86 counter 95c79f 98159676abca5dd3c98eeca8695c7a0 ts 6abca5dd rand 3c98eeca86 counter 95c7a0 98159686abca5dd3c98eeca8695c7a1 ts 6abca5dd rand 3c98eeca86 counter 95c7a1 9815969sorted by _id equals insertion order: truetimestamp เดียวกัน ส่วนสุ่มเดียวกัน counter +1 ทุกครั้ง: นั่นคือเหตุผลที่ id ที่สร้างในวินาทีเดียวกันโดย process เดียวจึงต่างกัน และ เรียงตามลำดับการสร้าง สอง process ต่างกันในวินาทีเดียวกันใช้ร่วมกันแค่ timestamp ดังนั้นข้าม process ลำดับจึงเป็นเพียง “คร่าว ๆ” ตามเวลา
คุณตั้ง _id เองเป็นค่าไม่ซ้ำใดก็ได้ — string, ตัวเลข, อีเมล — เมื่อข้อมูลมี natural key อยู่แล้ว MongoDB ต้องการเพียงว่ามีและไม่ซ้ำ; ไม่จำเป็นต้องเป็น ObjectId แต่ type จะมีผลต่อทุกการค้นหา ดังที่ fence ถัดไปแสดง:
db.s.insertOne({ _id: "65f0b3d4e4b0a1c2e4b0a1d1", name: "string-id" });console.log(db.s.find({ _id: ObjectId("65f0b3d4e4b0a1c2e4b0a1d1") }).toArray());console.log(db.s.find({ _id: "65f0b3d4e4b0a1c2e4b0a1d1" }).toArray());console.log(db.s.aggregate([{ $project: { t: { $type: "$_id" } } }]).toArray());assert.strictEqual(db.s.countDocuments({ _id: ObjectId("65f0b3d4e4b0a1c2e4b0a1d1") }), 0);[][ { _id: '65f0b3d4e4b0a1c2e4b0a1d1', name: 'string-id' } ][ { _id: '65f0b3d4e4b0a1c2e4b0a1d1', t: 'string' } ]hex 24 ตัวเดียวกันที่เก็บเป็น string ไม่ถูกพบ โดย query แบบ ObjectId (และกลับกัน)
Null ไม่ใช่ absent
หัวข้อที่มีชื่อว่า “Null ไม่ใช่ absent”db.z.insertMany([{ _id: 1, nickname: null }, { _id: 2 }, { _id: 3, nickname: "Al" }]);console.log("nickname: null ", db.z.find({ nickname: null }, { _id: 1 }).toArray());console.log("$exists: false ", db.z.find({ nickname: { $exists: false } }, { _id: 1 }).toArray());console.log("$type: 'null' ", db.z.find({ nickname: { $type: "null" } }, { _id: 1 }).toArray());console.log("$exists: true, $eq: null", db.z.find({ nickname: { $exists: true, $eq: null } }, { _id: 1 }).toArray());assert.strictEqual(db.z.countDocuments({ nickname: null }), 2);nickname: null [ { _id: 1 }, { _id: 2 } ]$exists: false [ { _id: 2 } ]$type: 'null' [ { _id: 1 } ]$exists: true, $eq: null [ { _id: 1 } ]{ nickname: null } ตรงกับ ทั้ง null ที่ระบุชัด (_id: 1) และ field ที่ไม่มี (_id: 2) หากต้องการแยกให้ใช้ $exists หรือ $type: "null"
ขนาด: เพดาน 16 MiB
หัวข้อที่มีชื่อว่า “ขนาด: เพดาน 16 MiB”หนึ่ง document จำกัดที่ 16 MiB (hello.maxBsonObjectSize = 16777216) เรา insert string ขนาด 16 MiB จาก Node:
try { await db.collection("big").insertOne({ blob: "x".repeat(16 * 1024 * 1024) });} catch (e) { console.log(e.name + ":", e.message); }const ok = await db.collection("big").insertOne({ blob: "x".repeat(16 * 1024 * 1024 - 100) });console.log("just under the limit:", ok.acknowledged);if (!ok.acknowledged) throw new Error("expected the near-limit insert to succeed");MongoServerError: object to insert too large. size in bytes: 16777249, max size: 16777216just under the limit: trueขีดจำกัดนับทั้ง document ที่เข้ารหัสแล้ว (ชื่อ field ไบต์ type _id) ไม่ใช่แค่ payload ของคุณ — string 16 MiB อย่างเดียวก็เกินไป 33 ไบต์
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- ObjectId ถูกสร้างฝั่ง client โดย driver ไม่ใช่ server (การรัน Node ด้านบนสร้าง id โดยไม่เรียก server) แอปพลิเคชันของคุณรู้
_idใหม่ทันทีที่สร้าง document ก่อนที่ insert จะถึง database - Date ไม่ใช่ BSON Timestamp type BSON reference ระบุว่า Timestamp type “สำหรับการใช้ ภายใน ของ MongoDB” และโค้ดแอปพลิเคชันควรใช้ Date type
- ใช้ Decimal128 สำหรับเงิน พิสูจน์แล้วด้านบน:
$sumของ double0.1และ0.2คือ0.30000000000000004; เป็น Decimal128 คือ0.3พอดี - Null ไม่ใช่ absent
{ field: null }ตรงกับทั้งสองอย่าง แต่$existsและ$type: "null"แยกได้ (พิสูจน์ด้านบน)
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”ลองขอผู้ช่วย AI ว่า “collection ใบแจ้งหนี้พร้อมยอดรวม” ยอดรวมมักเป็นตัวเลข JavaScript ธรรมดา:
🐛 หา bug ในโค้ดนี้
// AI comment: "store totals as numbers, sum them on the server"await db.collection("invoices").insertMany([{ total: 0.1 }, { total: 0.2 }]);const [{ sum }] = await db.collection("invoices").aggregate([{ $group: { _id: null, sum: { $sum: "$total" } } }]).toArray();if (sum === 0.3) markLedgerBalanced(); // never runsเฉลย
field ถูกเก็บเป็น BSON double ดังนั้น $sum ของ server คืน 0.30000000000000004 และการเช็คเท่ากับเป็น false — output จริงบน mongo 8.3.11 (การรันใน shell ด้านบนพิมพ์ตัวเลขเดียวกัน) บัญชีจะ “ไม่มีวันสมดุล” โดยไม่มีใครเห็นสาเหตุ วิธีแก้คือเก็บเงินเป็น Decimal128 (หรือเป็น integer cents แบบ int64) แล้วเทียบตรง ๆ fence นี้รันบน server เดียวกัน:
await db.collection("invoices").insertMany([ { total: Decimal128.fromString("0.1") }, { total: Decimal128.fromString("0.2") },]);const [{ sum }] = await db.collection("invoices") .aggregate([{ $group: { _id: null, sum: { $sum: "$total" } } }]) .toArray();console.log(sum, sum.toString() === "0.3");if (sum.toString() !== "0.3") throw new Error("expected exact 0.3");new Decimal128('0.3') trueMental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุก field คือ (ชื่อ, type, ค่า) ไม่ใช่แค่ชื่อกับค่า JSON ซ่อน type ไว้หลังไวยากรณ์; BSON เขียนมันลงไป bug ส่วนใหญ่ในบทนี้คือ bug เดียวกัน: ค่าดูถูก (0.1, 1.0, string hex 24 ตัว) แต่ type ที่ driver เลือกไม่ใช่ที่คุณตั้งใจ และ query, sort และ $sum ล้วนเคารพ type ไม่ใช่หน้าตา
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| BSON (type เพิ่มเติมเช่น Date, ObjectId, Decimal128, Binary) | เก็บค่าตาม type จริง; query และ sort ทำงานถูกต้องตาม type เช่นเรียงตามวันที่จริงหรือคำนวณเงินแม่นด้วย Decimal128 | พกพาไม่ทันทีเท่า JSON ธรรมดา — ระบบภายนอกที่รับเฉพาะ JSON text ต้องแปลง type เหล่านี้ก่อน ซึ่งอาจเสียความแม่นยำถ้าทำสะเพร่า |
| JSON ธรรมดา | พกพาข้ามระบบได้ทันที อ่านง่าย tooling รองรับทุกที่ | มีเพียง type พื้นฐานไม่กี่ตัว (string, number, boolean, null, array, object) — ต้อง encode Date และตัวเลขความแม่นยำสูงเป็น string เอง |
| Double สำหรับเงิน | เป็น JavaScript number ธรรมชาติ เขียนเร็วที่สุด | 0.1 + 0.2 -> 0.30000000000000004; ยอดรวมคลาดเคลื่อน |
| Decimal128 สำหรับเงิน | เลขคณิตฐาน 10 แม่นยำ 34 หลัก | 16 ไบต์ต่อค่า เทียบ 8 และต้องสร้างอย่างชัดเจนในทุก driver |
_id แบบ ObjectId | 12 ไบต์ สร้างฝั่ง client เรียงตามลำดับการสร้างต่อ process | อ่านไม่ออก; มีเวลาสร้างที่คุณอาจไม่อยากเปิดเผย |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “BSON กับ JSON คือสิ่งเดียวกัน” — คุณ เขียน และ อ่าน ค่าคล้าย JSON แต่ server เก็บ BSON ที่มี type
Long('4815162342')และDecimal128('12.50')ที่พิมพ์ด้านบนไม่ใช่ JSON - “ตัวเลขก็คือตัวเลข” —
1และ1.0เป็นintทั้งคู่จาก Node,2**31กลายเป็นdoubleและpromoteLongsส่งlongกลับมาเป็น JavaScript number ที่เสียความแม่นยำเกิน 2^53 - “ObjectId ก็แค่ string hex” — เก็บเป็น string จะ ไม่ถูกพบ โดย query แบบ ObjectId (
[]ด้านบน) และกลับกัน - “
{ field: null }หา document ที่ field เป็น null” — มันหา document ที่ไม่มี field ด้วย - “ใช้ Double เก็บเงินเพราะดูเหมือนทศนิยม” — ดู SpotTheBug ด้านบน
- “ไม่มีขีดจำกัดขนาด” — 16 MiB ต่อ document นับจากไบต์ที่เข้ารหัส
ตัวอย่างจากของจริง
BSON reference ของ MongoDB เองแนะนำ
decimal128สำหรับ “แอปพลิเคชันความแม่นยำสูงเช่นการเงินและธนาคาร” และเรียก Timestamp type ว่า “สำหรับการใช้ภายในของ MongoDB” (BSON types, เรียกอ่านเมื่อ 2026-09-30)
บทนี้ไม่ใช้ Python/Go/Rust; fence รันใน mongosh และ Node driver