跳到主要内容

基础与文档模型

概念映射​

MySQLMongoDB说明
databasedatabase一样
tablecollection(集合)集合不强制 schema
rowdocument(文档)BSON 格式
columnfield(字段)同一集合里各文档的字段可以不同
indexindex都是 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 的三个差异:

  1. 更多数据类型 —— JSON 只有 string / number / bool / null / array / object;BSON 额外有 ObjectId、Date、Binary、Decimal128、Timestamp、Int32/Int64/Double、Regex 等
  2. 可遍历(traversable) —— 每个元素都带长度前缀,所以可以跳过不关心的字段而无需解析,支持高效的部分读取和原地更新
  3. 更快的编解码 —— 二进制格式,不需要字符串解析

考点:

  • 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 通常意味着建模错了,而不是需要绕过限制。