基础与文档模型
概念映射
| MySQL | MongoDB | 说明 |
|---|---|---|
| database | database | 一样 |
| table | collection(集合) | 集合不强制 schema |
| row | document(文档) | BSON 格式 |
| column | field(字段) | 同一集合里各文档的字段可以不同 |
| index | index | 都是 B+ 树 |
| primary key | _id | 自动创建且不可删除的唯一索引 |
| JOIN | $lookup | 功能弱得多,见聚合管道 |
| schema | 无强制(可加 schema validation) | 3.6+ 支持 JSON Schema 校验 |
注意「无 schema」是个常被误解的说法。MongoDB 的准确表述是 schema-less 存储 + schema-on-read —— 数据库不强制,但你的应用代码一定隐含了某个 schema。所以设计 schema 的工作没消失,只是从 DDL 转移到了应用层和文档约定。3.6+ 可以用 $jsonSchema 在集合上声明校验规则,把约束加回数据库层。
BSON
Binary JSON,MongoDB 的存储和传输格式。相对 JSON 的三个差异:
- 更多数据类型 —— JSON 只有 string / number / bool / null / array / object;BSON 额外有
ObjectId、Date、Binary、Decimal128、Timestamp、Int32/Int64/Double、Regex等 - 可遍历(traversable) —— 每个元素都带长度前缀,所以可以跳过不关心的字段而无需解析,支持高效的部分读取和原地更新
- 更快的编解码 —— 二进制格式,不需要字符串解析
考点:
Decimal128—— 金额必须用它,不能用Double(浮点精度问题,和 MySQL 的float一样的坑)Date存的是毫秒级 UTC 时间戳(int64),无时区信息,时区转换是驱动层做的- 整数会被驱动「自动」选型 —— JS 驱动默认把数字当
Double,可能导致$inc累加出现精度问题,需要显式用NumberLong/NumberInt
_id 与 ObjectId
每个文档必须有 _id,不指定时由驱动(通常在客户端)生成一个 ObjectId。
ObjectId 是 12 字节(24 个十六进制字符):
| 4 字节 | 5 字节 | 3 字节 |
| 秒级时间戳 | 随机值 | 自增计数器 |
↑ 大端存储 ↑ 每进程唯一 ↑ 每次递增
早期版本是「4 字节时间戳 + 3 字节机器标识 + 2 字节进程 ID + 3 字节计数器」,现在改成「时间戳 + 5 字节随机 + 3 字节计数」。
考点:
- 近似单调递增 —— 前 4 字节是时间戳,所以 ObjectId 天然按创建时间粗略有序,可以用
_id排序代替createTime排序(精度只到秒,同一秒内的顺序由计数器决定) - 可以从中提取创建时间 ——
ObjectId.getTimestamp(),所以很多集合不需要额外存createTime - 在客户端生成 —— 不需要往返数据库拿 ID,这是相对 MySQL 自增主键的一个优势(插入前就知道 ID)
- 12 字节比 bigint 的 8 字节大 —— 所有二级索引都存
_id,所以索引更大。高吞吐场景可以自己用 int64 做_id
:::warning ObjectId 作为分片键是个经典错误 因为它单调递增,哈希之前的范围分片会导致所有新写入都落到最后一个分片(热点)。必须用哈希分片,或者换分片键。详见分片集群。 :::
CRUD 基础
// 插入
db.users.insertOne({name: "张三", age: 25, tags: ["vip"]})
db.users.insertMany([{...}, {...}])
// 查询
db.users.find({age: {$gte: 18}}) // 范围
db.users.find({tags: "vip"}) // 数组包含(自动匹配元素)
db.users.find({"addr.city": "深圳"}) // 嵌套字段用点号
db.users.find({}, {name: 1, _id: 0}) // 投影:只返回 name
db.users.find().sort({age: -1}).skip(10).limit(10) // 排序分页
// 更新
db.users.updateOne({_id: id}, {$set: {age: 26}})
db.users.updateMany({}, {$inc: {score: 1}})
db.users.updateOne({_id: id}, {$push: {tags: "new"}})
db.users.updateOne({name: "李四"}, {$set: {...}}, {upsert: true}) // 不存在则插入
// 删除
db.users.deleteOne({_id: id})
必须知道的运算符:
| 类别 | 运算符 |
|---|---|
| 比较 | $eq $ne $gt $gte $lt $lte $in $nin |
| 逻辑 | $and $or $not $nor |
| 元素 | $exists(字段是否存在)$type |
| 数组 | $all $elemMatch(数组元素同时满足多条件)$size |
| 更新 | $set $unset $inc $mul $push $pull $addToSet $pop |
$elemMatch 是高频考点。查「数组里有一个元素同时满足 score>80 且 subject='math'」:
// ❌ 错:两个条件可以由不同元素分别满足
db.t.find({"scores.score": {$gt: 80}, "scores.subject": "math"})
// ✅ 对
db.t.find({scores: {$elemMatch: {score: {$gt: 80}, subject: "math"}}})
写操作的原子性
单文档操作是原子的 —— 包括对嵌套子文档和数组的修改。这是文档模型最重要的实用性质:把需要一起更新的数据嵌入同一个文档,就天然获得了原子性,不需要事务。
跨文档不原子 —— 需要多文档事务。
findAndModify / findOneAndUpdate 是原子的「读-改-写」,用于实现计数器、队列取任务、乐观锁等。
两个容易混淆的点
update 不写 $set 会整体替换文档:
db.users.updateOne({_id: id}, {name: "新名字"}) // ❌ 报错(现代驱动已禁止)
db.users.replaceOne({_id: id}, {name: "新名字"}) // ⚠️ 其他字段全丢
db.users.updateOne({_id: id}, {$set: {name: "新"}}) // ✅ 只改一个字段
null 和「字段不存在」在查询里不等价但会互相命中:{field: null} 会同时匹配「值为 null」和「字段不存在」的文档。要精确区分得用 $exists。
容量限制
| 限制 | 值 |
|---|---|
| 单文档最大 | 16 MB |
| 嵌套深度 | 100 层 |
| 单集合索引数 | 64 |
| 索引键最大 | 1024 字节 |
| 集合名 + 库名 | 255 字节 |
| 单个数据库的集合数 | 受命名空间文件限制,WiredTiger 下基本无限 |
16MB 怎么绕(高频):用 GridFS(把大文件切成 255KB 的 chunk 分块存储);或者改建模 —— 把无界增长的数组拆成独立集合(见数据建模的「分桶模式」)。真正该记住的是:撞到 16MB 通常意味着建模错了,而不是需要绕过限制。