1. 项目背景
业务场景:本地生活电商的 CTO 在周会上暴走——"用户表的 phone 字段一会儿是数字一会儿是字符串,同一个手机号查出来 3 条记录!金额字段有人存 9.9,有人存 '9.9元',还有人存 {value:9.9,currency:'CNY'},月底对账快疯了!" 运维也站出来补刀:"数据库硬盘 3 天爆了 200G,查了一下,每个订单里都嵌了一份完整的商品详情,一个商品卖了 10 万单就有 10 万份冗余数据。" 这些问题归根结底是团队对 BSON 类型体系、ObjectId 特性和文档建模边界缺乏统一认知。
痛点:缺乏对 MongoDB 数据层面的系统理解,将导致以下灾难:字段类型不统一使索引失效、查询结果不可预期;浮点数存储金额导致对账不平,差异累积成财务事故;ObjectId 的生成机制不清,导致多机房部署时主键冲突或排序混乱;文档内嵌数据无限制膨胀,写入变慢、内存压力增大、备份时间拉长;不了解 16MB 文档上限,线上突然报 BSONObjectTooLarge 错误,业务中断。
2. 项目设计
小胖(把手机一亮):大师你看,知乎上说 MongoDB 用 BSON 不用 JSON,是因为 JSON 太慢。那 BSON 到底快在哪?
大师:问得好。BSON 全称 Binary JSON,它在 JSON 的基础上做了三件事:增加了更多数据类型(如日期、二进制、Decimal128)、采用二进制编码(解析更快)、在文档头部记录长度(便于跳过不需要的内容)。
小胖:二进制编码?是不是就像压缩饼干——重量轻,吃起来费牙?
大师:不太一样。压缩饼干是"体积变小,吃之前要泡水(解压)"。BSON 是"用计算机能直接读懂的方式编排",不需要解压步骤,解析速度比 JSON 快,但体积可能比压缩后的 JSON 略大。它是一种时空权衡——用少许空间换解析速度。
技术映射:BSON 将 JSON 的字符串键值转为二进制标记,扫描时不需要逐字符解析,通过类型标记直接跳转,适合数据库内部遍历场景。
小白(从工位站起来):我看了 BSON 的类型列表,有个 Decimal128 很特别。为什么需要它?Double 不够吗?
大师:经典问题。Double 是 IEEE 754 64 位浮点数,它用二进制近似表示十进制小数——0.1 + 0.2 在 Double 下不等于 0.3,而是 0.30000000000000004。财务系统如果积累 10 万笔交易,这个误差足以让对账崩溃。
小白:那直接存整数分不就行了吗?乘以 100 存成 Long,查询时再除回来。
大师:这是一个广泛采用的方案,简单有效。但 Decimal128 的优势在于:它是 128 位十进制浮点数,精确到 34 位有效数字,天然符合金融计算的精度要求,而且不需要业务代码做乘除转换。用哪个取决于团队习惯和精度需求——小于 10 位金额用整数分足够,涉及汇率、利率等复杂小数用 Decimal128。
小胖:那 ObjectId 呢?为什么它长成 507f1f77bcf86cd799439011 这样?是随机生成的 UUID 吗?
大师:不完全是。ObjectId 是 12 字节的 BSON 类型,结构非常精巧:前 4 字节是自 Unix 纪元起的秒级时间戳;接下来 5 字节是随机值(在 mongod 进程启动时生成的随机数 + 机器标识组成);最后 3 字节是一个自增计数器。这意味着:
- 你可以从 ObjectId 中提取创建时间,不需要额外存 createdAt 字段(但不推荐依赖它做精确排序)。
- 同一进程内 ObjectId 严格递增,不同进程同一秒内大致按时间排,但不严格。
小胖:那为什么不用自增 ID?像 MySQL 那样 AUTO_INCREMENT 多直观。
大师:因为分布式。MongoDB 的设计目标是水平扩展,如果用一个全局自增计数器,它就成了单点瓶颈——所有写操作都得排队拿号。ObjectId 是客户端 Driver 本地生成的,不需要和服务器通信,天然适合分布式环境。
小白:但如果用 ObjectId 做主键,数据在索引中不是有序的?那不会导致 B-Tree 页分裂吗?
大师:会,这是 ObjectId 的一个已知代价。由于它的随机部分,按 _id 插入时数据在 B-Tree 索引中近乎随机分布,页分裂频繁,写入性能不如单调递增主键。但大部分业务不是按 _id 范围查询的,对读性能的影响通常可以接受。如果你的业务确实需要按 _id 排序查询,可以改用近似单调的主键策略(如 MongoDB 5.0+ 的 ObjectId 改为单调模式)。
技术映射:ObjectId 是分布式友好的无协调主键方案,UUID v4 也是类似思想但占 16 字节。两者都有 B-Tree 页分裂的写放大问题,但 ObjectId 可通过前 4 字节的时间戳做粗略范围查询,这是 UUID 不具备的能力。
大师(话锋一转):聊完了类型,我们来谈更重要的——文档结构设计。同样一个订单,有人把商品详情、用户地址、物流信息全塞进一个文档,有人拆成 5 个集合做聚合。怎么判断该嵌入还是该引用?
小胖:这不就跟寄快递一样吗——大件全装一个箱子省力但超重,拆成多个箱子麻烦但每个轻。
大师:对。嵌入(Embedding)是把关联数据放在同一个文档内,像用一个箱子装完所有东西;引用(Referencing)是只存 ID,需要时再去目标集合查,像在箱子里放取货码,自己到货架拿。
小白:那什么时候该嵌入,什么时候该引用?
大师:记住四个维度就够了:
- 访问频率:总是一起读的就嵌入。比如订单的收货地址,查询订单时几乎必定附带地址信息。
- 变更频率:独立频繁更新的就引用。比如商品详情,商品信息可能每天改,但历史订单里的快照不应跟着变。
- 数据规模:不会无限增长的就嵌入。比如收货地址最多 10 个,可以嵌在用户文档里。但一个用户的订单可能有数千条,绝不能嵌进去。
- 原子性要求:需要原子更新的就嵌入。单个文档的更新是原子的,跨文档需要事务。
小胖:那文档多大算大?我看新闻说有人把一个文档搞到 100MB 直接炸了。
大师:MongoDB 硬性上限是 16MB——超过直接报错。但实际使用中,单个文档超过 1MB 就应该警惕了。大文档会导致:写入时 WiredTiger 缓存压力大、复制时 Oplog 膨胀、查询时网络传输慢。
技术映射:Object.bsonsize(doc) 可查看文档的 BSON 存储大小。文档大小限制在 doc maxBsonObjectSize 中定义(默认 16MB)。
小白:那 schema 校验呢?你之前说 MongoDB 灵活但容易脏,有没有像 MySQL 那样约束字段类型的手段?
大师:有,JSON Schema Validator。你可以在创建集合时指定来自动拒绝不符合规则的文档。
3. 项目实战
3.1 环境准备
确认第 2 章的 MongoDB 容器正在运行:
docker compose -f mongodb-lab/docker-compose.yml ps
# 确保 STATUS = Up
3.2 分步实现
步骤一:探索 BSON 全部核心类型
目标:通过插入样例数据,直观理解每种 BSON 类型在 mongosh 中的表现形式。
// 连接后切换到 local_life 库
use local_life
// 插入一个涵盖核心 BSON 类型的演示文档
db.type_demo.insertOne({
// 字符串 String
title: "BSON 类型演示",
// 数字类型
age_int: 32, // Int32(32位有符号整数)
score_double: 98.5, // Double(64位浮点数)
price_decimal: NumberDecimal("199.99"), // Decimal128(精确十进制)
total_long: NumberLong("9999999999"), // Int64/Long
// 布尔
is_active: true,
is_deleted: false,
// 日期 Date(存储为 UTC 毫秒时间戳)
created_at: new Date(),
expired_at: ISODate("2026-12-31T23:59:59Z"),
// ObjectId
ref_user_id: new ObjectId(),
// 二进制 Binary(存储文件哈希等)
file_hash: new BinData(0, "VHJ5IHRvIGZpbmQgdGhlIGFuc3dlciE="),
// 数组 Array
tags: ["热门", "新品", "限时"],
// 嵌套对象 Object
address: {
province: "广东",
city: "深圳",
zip: "518000"
},
// Null
remark: null,
// 正则 RegExp
pattern: /^user_\d+$/,
// Timestamp(MongoDB 内部使用,一般不用)
internal_ts: new Timestamp(),
// MinKey / MaxKey(比较时始终最小/最大)
min_val: MinKey(),
max_val: MaxKey()
})
验证插入:
// 查看文档的 BSON 类型详情
const doc = db.type_demo.findOne({ title: "BSON 类型演示" })
printjson(doc)
// 查看文档的 BSON 存储字节数
Object.bsonsize(doc) // 输出约 400-600 字节
步骤二:ObjectId 深度解析
目标:理解 ObjectId 的生成机制、时间提取和排序特性。
// 观察 ObjectId 的结构
const oid = new ObjectId()
print("12 字节 HEX:", oid.str) // 24 个 16 进制字符
print("时间戳(前4字节):", oid.getTimestamp()) // ISODate
// 验证时间提取——从现有数据的 _id 中提取创建时间
const firstUser = db.users.findOne()
print("用户 _id:", firstUser._id)
print("创建时间:", firstUser._id.getTimestamp())
// 比较排序特性
// 快速连续插入 3 条记录(同一进程内)
for (let i = 0; i < 3; i++) {
db.oid_test.insertOne({ order: i })
}
// 按 _id 排序查看
db.oid_test.find().sort({ _id: 1 }).forEach(doc => {
print(`order=${doc.order}, _id=${doc._id}, time=${doc._id.getTimestamp()}`)
})
// 期望:_id 按 time 递增
可能遇到的坑:
getTimestamp()只在 ObjectId 上可用,自定义字符串主键无法提取时间。- 如果 Driver 或 mongosh 不显式传 _id,mongod 会检查 _id 是否存在,不存在才自动生成。如果 Driver 提前生成了 ObjectId 并传入,mongod 直接使用,不再生成。
步骤三:为本地生活电商设计文档结构
目标:为用户、地址、商品、订单设计第一版文档结构,并添加基础字段校验。
// ---- 用户集合(嵌入地址) ----
db.createCollection("users_new", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["name", "phone", "createdAt"],
properties: {
name: {
bsonType: "string",
description: "用户昵称,必填"
},
phone: {
bsonType: "string",
pattern: "^1[3-9]\\d{9}$",
description: "手机号,必填,正则校验"
},
age: {
bsonType: "int",
minimum: 0,
maximum: 150,
description: "年龄,0-150"
},
tags: {
bsonType: "array",
items: { bsonType: "string" },
description: "用户标签数组"
},
addresses: {
bsonType: "array",
description: "收货地址列表(嵌套)",
items: {
bsonType: "object",
required: ["province", "detail"],
properties: {
province: { bsonType: "string" },
city: { bsonType: "string" },
detail: { bsonType: "string" },
isDefault: { bsonType: "bool" }
}
}
},
createdAt: { bsonType: "date" }
}
}
}
})
// ---- 商品集合 ----
db.createCollection("products_new", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["name", "price"],
properties: {
name: { bsonType: "string" },
category: { bsonType: "string" },
price: {
bsonType: "decimal",
minimum: NumberDecimal("0"),
description: "价格,Decimal128,>= 0"
},
stock: {
bsonType: "int",
minimum: 0
},
tags: {
bsonType: "array",
items: { bsonType: "string" }
},
specs: {
bsonType: "object",
description: "商品规格(嵌套对象),如颜色、尺寸"
}
}
}
}
})
// ---- 订单集合(引用用户和商品) ----
db.createCollection("orders_new", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["userId", "items", "totalAmount", "status", "createdAt"],
properties: {
userId: { bsonType: "objectId" },
items: {
bsonType: "array",
items: {
bsonType: "object",
required: ["productId", "quantity", "price"],
properties: {
productId: { bsonType: "objectId" },
productName: { bsonType: "string" }, // 冗余商品名,防商品修改后历史订单看不到原始名称
quantity: { bsonType: "int", minimum: 1 },
price: { bsonType: "decimal", minimum: NumberDecimal("0") }
}
}
},
totalAmount: { bsonType: "decimal", minimum: NumberDecimal("0") },
address: {
bsonType: "object",
required: ["province", "detail"],
properties: {
province: { bsonType: "string" },
city: { bsonType: "string" },
detail: { bsonType: "string" }
}
},
status: { enum: ["待支付", "已支付", "已发货", "已完成", "已取消"] },
createdAt: { bsonType: "date" },
updatedAt: { bsonType: "date" }
}
}
}
})
设计决策说明:
| 决策 | 方案 | 理由 |
|---|---|---|
| 用户地址 | 嵌入在用户文档中 | 一起读写,数量小(<10个),变更频率低 |
| 订单-商品 | 引用(存 productId)但冗余 productName | 历史订单的商品名称不应随商品表修改而变化 |
| 订单-地址 | 嵌入(快照) | 历史订单的收货地址不可变,不应随用户修改而变化 |
| 金额 | Decimal128 | 防止浮点精度造成的对账偏差 |
| 状态 | 字符串枚举 | 可读性优先,后续可用代码层做枚举校验 |
步骤四:测试 Schema Validator
// 测试1:合法文档,应成功
db.users_new.insertOne({
name: "正常用户",
phone: "13800138000",
age: 25,
tags: ["新用户"],
addresses: [{ province: "广东", city: "深圳", detail: "科技园", isDefault: true }],
createdAt: new Date()
})
// 测试2:缺少必填字段 phone,应失败
try {
db.users_new.insertOne({ name: "无号码用户" })
} catch (e) {
print("校验失败(预期):", e.message)
// 输出示例:Document failed validation
}
// 测试3:phone 格式错误,应失败
try {
db.users_new.insertOne({
name: "错误号码",
phone: "1234567",
createdAt: new Date()
})
} catch (e) {
print("校验失败(预期):", e.message)
}
// 测试4:价格负数,应失败
try {
db.products_new.insertOne({
name: "负价商品",
price: NumberDecimal("-10")
})
} catch (e) {
print("校验失败(预期):", e.message)
}
// 测试5:跳过校验写入(仅管理员)
// 设置 bypassDocumentValidation 绕过校验器(需具备 bypassDocumentValidation 权限)
db.users_new.insertOne(
{ name: "脏数据", phone: "invalid" },
{ bypassDocumentValidation: true }
)
print("脏数据写入成功,但破坏了数据一致性!生产环境谨慎使用")
可能遇到的坑:
NumberDecimal("199.99")必须用字符串构造,NumberDecimal(199.99)会把 Double 的浮点误差带进去。- Schema Validator 在创建集合时指定,对后续 update 操作同样生效。
- Validator 只做插入/更新时的校验,已有脏数据不会自动修复,需单独写迁移脚本。
步骤五:文档大小诊断
// 模拟"文档膨胀"场景
// 创建一个大文档
let bigDoc = { _id: "size_test", content: "A" }
// 不断往数组里追加数据
for (let i = 0; i < 50000; i++) {
bigDoc["field_" + i] = "value_" + i.repeat(10)
}
try {
db.size_test.insertOne(bigDoc)
} catch (e) {
print("文档大小超限:", e.message)
}
// 查看实际大小的工具方法
print("当前 BSON 大小:", Object.bsonsize(bigDoc))
print("16MB 限制:", 16 * 1024 * 1024, "bytes")
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/init-scripts/02-schema-design.js |
创建带校验的集合,插入样例数据 |
mongodb-lab/init-scripts/03-type-demo.js |
BSON 类型演示与查询 |
3.4 测试验证
// 完整验证脚本
use local_life
// 1. 验证集合创建
show collections
// 期望包含:users_new, products_new, orders_new
// 2. 验证 Schema Validator
const userValid = db.users_new.findOne({ name: "正常用户" })
print("合法用户:", userValid !== null ? "PASS" : "FAIL")
// 3. 验证 ObjectId 时间提取
print("用户创建时间:", userValid._id.getTimestamp())
print("与 createdAt 对比:", userValid.createdAt)
// 4. 验证金额精度
const price = db.products_new.findOne({ name: { $exists: true } })
if (price) {
print("金额类型:", typeof price.price, "值:", price.price.toString())
}
// 5. 清理测试数据(可选)
db.type_demo.drop()
db.oid_test.drop()
db.users_new.drop()
db.products_new.drop()
db.orders_new.drop()
db.size_test.drop()
4. 项目总结
4.1 优缺点对比
| 维度 | MongoDB BSON 文档 | MySQL 行 | 分析 |
|---|---|---|---|
| Schema 灵活 | 同一集合不同字段 | 严格列定义 | MongoDB 快速迭代优势 |
| 嵌套支持 | 天然嵌套,一次查多级 | 需 JOIN | MongoDB 读优势 |
| 类型精度 | Decimal128 金融级精度 | DECIMAL 类型 | 两者均可 |
| 主键生成 | ObjectId(客户端生成) | AUTO_INCREMENT(服务端) | MongoDB 分布式友好 |
| 脏数据风险 | 高(除非用 Validator) | 低(列类型强约束) | MongoDB 需主动加校验 |
| 文档大小 | 16MB 上限 | 行大小由存储引擎决定 | 大字段用 GridFS |
4.2 适用场景
BSON + ObjectId 特别适合:
- 移动应用后端:JSON 原生于前端,ObjectId 客户端无协调生成。
- 多机房部署:网段可能不通,ObjectId 不依赖中心化 ID 服务。
- 快速原型验证:无需 DBA 审批建表,改字段设计零 downtime。
- 内容管理系统:数据结构多变,嵌套文档适合文章-评论-标签模型。
- 事件溯源(Event Sourcing):事件多样,每种事件结构不同,文档模型灵活。
不适用场景:
- 严格的财务核算系统(Decimal128 仍需额外验证机制)。
- 需要严格数据库级外键约束的系统(MongoDB 无 native 外键)。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
_id 可自定义 |
不一定非用 ObjectId,可用自增数、UUID、业务 ID,但需保证唯一 |
| 嵌入层次不要太深 | 嵌套超过 5-6 层后,BSON 遍历开销明显增加,代码可读性也变差 |
| 冗余字段是常规操作 | MongoDB 的常见反范式设计——宁可冗余 productName,也不要每次 $lookup |
Validator 可以用 validationLevel: "moderate" |
只对满足校验条件的文档做校验,允许忽略旧的不合规文档 |
NumberDecimal 序列化 |
JavaScript 原生不支持 Decimal128,需用 .toString() 获取字符串再解析 |
4.4 常见踩坑经验
故障案例一:浮点数金额雪崩
某电商促销活动,商品价格 9.99 元,优惠 9.90 元,用户实付 Double 类型存储。100 万笔订单累加发现实付总额比应收少了 2.38 元。根因:浮点数精度误差累积。解决:立即停止使用 Double 存金额,历史数据通过脚本转为 Decimal128。新表全部用 Decimal128 或整数分。
故障案例二:ObjectId 时间提取作为排序字段翻车
某项目因前端需要一个"创建时间"字段,开发嫌麻烦没用额外字段,直接 sort({ _id: -1 })。大促期间两台应用服务器同时写入订单,同一秒内 _id 逆序排列导致用户刷新页面时订单顺序乱跳。根因:ObjectId 的 5 字节随机值部分在不同机器上不同,同一秒内先后生成的 _id 间没有绝对顺序。解决:增加 createdAt 字段,排序改用它。
故障案例三:嵌套数组无限制增长
某社交应用在用户文档内嵌 notifications 数组存储所有通知,初期没设上限。半年后,活跃用户的文档超过了 15MB,每次打开通知中心都报 BSONObjectTooLarge。根因:无界数组嵌入。解决:拆出独立的 notifications 集合,按 userId 索引查询;用户文档只保留最近 20 条。旧数据通过数据迁移脚本分批搬运。
4.5 思考题
- 如果一个集合不设置任何 Schema Validator,如何在应用层防范脏数据写入?请列出至少三种策略。
- ObjectId 与 UUID v7 的共同点和区别是什么?在哪些场景下 ObjectId 优于 UUID,哪些场景反之?
(答案将在第 4 章末尾揭晓)
上一章思考题答案:
Spring Boot 和 MongoDB 在同一 docker-compose.yml 中通信,需将它们加入同一 Docker 网络(
networks段),Spring Boot 应用不用localhost:27017而是用服务名mongodb作为主机名(如mongodb://admin:admin123@mongodb:27017/?authSource=admin)。Docker Compose 的默认网络会自动做 DNS 解析,服务名即可相互访问。如果文档没有
phone字段,它在唯一索引中会被当作null处理——MongoDB 唯一索引允许缺失字段,但集合中只能有一个文档缺少该字段(第二个无 phone 的文档插入时会报 "E11000 duplicate key error")。这与 MySQL 不同:MySQL 唯一约束允许多行 NULL 值(因为 NULL ≠ NULL)。在 MongoDB 中需要这种语义时,要配合sparse: true创建稀疏索引——只对存在该字段的文档建索引,缺失字段的文档不受唯一约束。
延伸阅读与资源
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号