前言
上一篇我们把 MongoDB 环境搭好了。这一篇正式开始动手,讲清楚三件事:数据长什么样(数据结构)、怎么增删改查(CRUD)、怎么按条件查(查询操作符)。
这是整个专栏的地基。把这一篇的语法吃透了,后面讲文档模型设计、索引、聚合管道时才不会卡在"这个命令什么意思"上。
本文所有命令都在 mongosh 里执行,你可以边看边跟着敲。
一、先理清几个核心概念
从关系型数据库过来的同学,先做一个概念映射,很多东西一下就通了:
| 关系型数据库(MySQL) | MongoDB | 说明 |
|---|---|---|
| Database(库) | Database(库) | 一样 |
| Table(表) | Collection(集合) | 不需要预先定义结构 |
| Row(行) | Document(文档) | 一条 JSON 风格的数据 |
| Column(列) | Field(字段) | 键值对 |
| 主键(Primary Key) | _id | 每个文档自带,默认自动生成 |
| JOIN 连表 | $lookup / 内嵌 | 后续文档模型篇详解 |
最核心的区别只有一个:MongoDB 是"文档型"数据库,一条数据就是一个文档,长得像 JSON。 一个集合里的不同文档,字段可以完全不一样(这叫 Schema-less,无固定结构)。
一个文档长这样:
{
"_id": ObjectId("65f1a2b3c4d5e6f7a8b9c0d1"),
"name": "Tom",
"age": 18,
"tags": ["java", "mongodb"],
"address": {
"city": "Beijing",
"zip": "100000"
}
}
可以看到,字段值可以是字符串、数字、数组,甚至是内嵌的另一个对象。这种表达能力,正是文档模型的威力所在。
二、BSON:MongoDB 的数据类型
我们平时看到的文档像 JSON,但 MongoDB 底层存储用的是 BSON(Binary JSON)——JSON 的二进制版本。
为什么不用纯 JSON,要搞个 BSON?两个原因:
- 更快:二进制格式,解析和遍历比文本 JSON 高效。
- 类型更丰富:JSON 只有字符串、数字、布尔、数组、对象、null 这几种,而 BSON 扩展出了日期、ObjectId、二进制、精确的整型/浮点型等,能表达的数据类型多得多。
常用的 BSON 类型:
| 类型 | 示例 | 说明 |
|---|---|---|
| String | "Tom" | 字符串,UTF-8 |
| Int32 / Int64 | NumberInt(18) / NumberLong(100) | 整型 |
| Double | 3.14 | 双精度浮点(数字默认是这个) |
| Decimal128 | NumberDecimal("9.99") | 高精度小数,存金额务必用它 |
| Boolean | true / false | 布尔 |
| Date | new Date() / ISODate(...) | 日期时间 |
| Array | [1, 2, 3] | 数组 |
| Object | { a: 1 } | 内嵌文档 |
| ObjectId | ObjectId("...") | 唯一 ID,_id 默认类型 |
| Null | null | 空值 |
一个踩坑提醒:在 mongosh 里直接写数字
9.99会被当成 Double(浮点),存金额、价格这类对精度敏感的数据时,浮点会有精度误差。正确做法是用NumberDecimal("9.99")。这个坑在电商、金融场景尤其致命。
关于 _id 和 ObjectId
每个文档都有一个 _id 字段作为主键,唯一且不可变。如果你插入时不指定,MongoDB 会自动生成一个 ObjectId。
ObjectId 是一个 12 字节的值,不是随机的,它由几部分组成:

因为开头 4 字节是时间戳,所以 ObjectId 本身大致是按生成时间递增的——这意味着按 _id 排序,约等于按插入时间排序,这是个很实用的特性。你甚至可以直接从一个 ObjectId 反推出它的创建时间:
ObjectId("65f1a2b3c4d5e6f7a8b9c0d1").getTimestamp()
// 返回 ISODate,即这条文档大致的创建时间
三、CRUD:增删改查
CRUD 是所有数据库操作的核心。MongoDB 的方法命名很直观,insert / find / update / delete,配合 One 和 Many 后缀区分单条和多条。
先准备一个练手集合:
use shop
3.1 增(Create)
// 插入单条
db.products.insertOne({
name: "iPhone 15",
price: NumberDecimal("5999"),
stock: 100,
tags: ["phone", "apple"]
})
// 插入多条
db.products.insertMany([
{ name: "MacBook", price: NumberDecimal("9999"), stock: 50 },
{ name: "AirPods", price: NumberDecimal("1299"), stock: 200 }
])
插入成功后,mongosh 会返回生成的 _id。insertOne 返回:
{
acknowledged: true, // 写入是否被确认
insertedId: ObjectId('66a12f3e8b4c2d1a9f0e7c55') // 自动生成的 _id
}
insertMany 则返回一个 insertedIds 映射(下标 → _id):
{
acknowledged: true,
insertedIds: {
'0': ObjectId('66a12f...'),
'1': ObjectId('66a130...')
}
}
insertMany的一个坑:ordered参数。 默认ordered: true,表示按顺序插入,一旦中间某条失败(比如_id重复),后面的全部停止、不再插入。如果你希望"跳过失败的、能插的都插进去",要显式传{ ordered: false }:db.products.insertMany([...], { ordered: false })批量导入数据时这个参数很关键,选错了要么中途卡死,要么以为全成功了其实漏了一半。
3.2 查(Read)
find 是用得最多的方法。
// 查全部
db.products.find()
// 按条件查(价格等于 5999)
db.products.find({ price: NumberDecimal("5999") })
// 只查一条
db.products.findOne({ name: "MacBook" })
// 投影:只返回 name 和 price 字段(1 表示要,0 表示不要)
// 注意第一个参数是查询条件,全查就传空对象 {},不能省略
db.products.find({}, { name: 1, price: 1, _id: 0 })
// 排序(1 升序,-1 降序)+ 限制条数 + 跳过
db.products.find().sort({ price: -1 }).limit(2).skip(1)
// 统计数量
db.products.countDocuments({ stock: { $gt: 50 } })
find 会把匹配的文档打印出来,形如:
[
{
_id: ObjectId('66a12f3e8b4c2d1a9f0e7c55'),
name: 'iPhone 15',
price: Decimal128('5999'),
stock: 100,
tags: [ 'phone', 'apple' ]
}
]
投影里
_id是个特例:默认总是返回,如果不想要它,必须显式写_id: 0。其他字段则是"不写就不返回"。
投影的一个限制:
1(要)和0(不要)不能混用,{ name: 1, stock: 0 }会报错。唯一的例外就是上面的_id: 0——它可以和一堆1一起写,用来"要这些字段、但别带 _id"。
find 返回的是"游标",不是数组
这是新手一个高频困惑点:在 mongosh 里 db.products.find() 一个大集合,明明有几千条,却只显示了 20 条,最后还多出一行 Type "it" for more。
原因是:find() 返回的不是一次性把所有数据装进内存的数组,而是一个游标(cursor)。 游标是一个指向结果集的"指针",你要多少它给多少,避免一次拉取百万条数据把内存打爆。
// mongosh 默认一批显示 20 条,输入 it(iterate)翻下一批
it
// 把游标结果转成真正的数组(数据量大时慎用,会全部加载进内存)
db.products.find().toArray()
// 遍历游标,对每条做处理
db.products.find().forEach(doc => print(doc.name))
// 游标也可以链式调用
db.products.find().sort({ price: -1 }).limit(5)
理解游标很重要,它解释了
sort/limit/skip为什么能链在find()后面——它们都是作用在游标上的方法,而不是find的参数。真正的数据库查询,是在你开始遍历游标(或toArray)时才执行的。
3.3 改(Update)
更新是新手最容易写错的地方,核心要记住:更新操作要配合更新操作符(如 $set),不能直接传整个对象。
// 正确:用 $set 更新指定字段
db.products.updateOne(
{ name: "iPhone 15" }, // 条件
{ $set: { stock: 80 } } // 更新内容
)
// 更新多条
db.products.updateMany(
{ stock: { $lt: 100 } },
{ $set: { status: "low_stock" } }
)
// $inc 自增/自减(库存减 1)
db.products.updateOne(
{ name: "iPhone 15" },
{ $inc: { stock: -1 } }
)
// upsert:存在就更新,不存在就插入
db.products.updateOne(
{ name: "iPad" },
{ $set: { price: NumberDecimal("3999") } },
{ upsert: true }
)
更新操作返回的是"匹配了几条、实际改了几条"的统计:
{
acknowledged: true,
matchedCount: 1, // 条件匹配到的文档数
modifiedCount: 1, // 实际被修改的文档数
upsertedCount: 0 // upsert 时新插入的文档数
}
留意
matchedCount和modifiedCount的区别:如果新值和原值一样(比如 stock 本来就是 80,又$set成 80),会matchedCount: 1但modifiedCount: 0——匹配到了,但没实际改动。排查"为什么更新没生效"时,这两个数字是第一手线索。
高频踩坑:如果你写成
db.products.updateOne({name:"iPhone 15"}, {stock: 80}),漏掉了$set,MongoDB 会把整个文档替换成只有stock一个字段的文档,其他字段全没了!一定要用$set。
常用更新操作符:
| 操作符 | 作用 |
|---|---|
$set | 设置字段值 |
$unset | 删除字段 |
$inc | 数值增减 |
$push | 向数组追加元素 |
$pull | 从数组移除元素 |
$addToSet | 向数组添加(去重) |
更新数组里的某个元素:定位符 $
前面 $push/$pull 是整体增删元素,但如果要修改数组里满足条件的那个元素,需要用定位符 $。它代表"查询条件匹配到的那个数组元素的下标"。
假设订单的 items 是对象数组,要把其中 product 为 iPhone 的那一项数量改成 3:
db.orders.updateOne(
{ "items.product": "iPhone" }, // 条件里定位到数组元素
{ $set: { "items.$.qty": 3 } } // $ 就指向刚匹配到的那个元素
)
如果要更新数组里所有满足条件的元素,用 $[](全部)或 $[标识符] + arrayFilters(按条件筛选):
// 把所有 qty 小于 2 的商品项,统一加个赠品标记
db.orders.updateOne(
{ _id: ObjectId("...") },
{ $set: { "items.$[elem].gift": true } },
{ arrayFilters: [ { "elem.qty": { $lt: 2 } } ] }
)
$只会更新第一个匹配的元素,$[]更新全部,$[elem]+arrayFilters更新符合条件的那些。这三者的区别是操作对象数组时的高频考点,也是实际业务里改嵌套数据绕不开的工具。
3.4 删(Delete)
// 删除单条
db.products.deleteOne({ name: "AirPods" })
// 删除多条
db.products.deleteMany({ stock: { $lt: 10 } })
// 清空集合(保留集合结构)
db.products.deleteMany({})
// 直接删掉整个集合
db.products.drop()
删除返回被删掉的条数:
{ acknowledged: true, deletedCount: 1 }
deleteMany({})传空条件表示匹配所有文档,会清空整个集合,生产环境执行前务必想清楚。另外deleteMany({})和drop()有个区别:前者逐条删文档、保留集合和索引定义,后者直接把整个集合(连同索引)删掉,drop()快得多但更"狠"。
四、查询操作符:按条件精确筛选
上面 find 里那些 $gt、$lt 就是查询操作符。它们是 MongoDB 查询能力的核心,分几类来记。
4.1 比较操作符
| 操作符 | 含义 | 示例 |
|---|---|---|
$eq | 等于 | { price: { $eq: 5999 } } |
$ne | 不等于 | { status: { $ne: "sold" } } |
$gt | 大于 | { stock: { $gt: 50 } } |
$gte | 大于等于 | { stock: { $gte: 50 } } |
$lt | 小于 | { price: { $lt: 1000 } } |
$lte | 小于等于 | { price: { $lte: 1000 } } |
$in | 在列表中 | { tags: { $in: ["apple", "phone"] } } |
$nin | 不在列表中 | { tags: { $nin: ["old"] } } |
// 价格在 1000 到 6000 之间
db.products.find({ price: { $gte: 1000, $lte: 6000 } })
4.2 逻辑操作符
| 操作符 | 含义 |
|---|---|
$and | 且(多条件默认就是 and) |
$or | 或 |
$not | 非 |
$nor | 都不满足 |
// 价格小于 2000 或 库存大于 100
db.products.find({
$or: [
{ price: { $lt: NumberDecimal("2000") } },
{ stock: { $gt: 100 } }
]
})
多个条件写在同一个
{}里,默认是 AND 关系。$or才需要显式写出来。
4.3 元素与字段操作符
// $exists:查有 status 字段的文档
db.products.find({ status: { $exists: true } })
// $type:按字段类型查
db.products.find({ price: { $type: "decimal" } })
4.4 $regex:模糊查询
按关键字模糊匹配(相当于 SQL 的 LIKE),用 $regex:
// 名字包含 "iPhone"
db.products.find({ name: { $regex: "iPhone" } })
// 以 "Mac" 开头(^ 锚定开头)
db.products.find({ name: { $regex: "^Mac" } })
// 忽略大小写:加 $options: "i"
db.products.find({ name: { $regex: "iphone", $options: "i" } })
// 也可以直接用 JS 正则字面量,更简洁
db.products.find({ name: /^Mac/i })
一个必须知道的性能坑:
$regex只有在以^开头的前缀匹配(如/^Mac/)时才能用上索引;一旦是"包含"式的中间匹配(如/iPhone/),索引就失效了,会退化成全表扫描。数据量大时,name: /关键字/这种写法是慢查询的常见元凶——真要做全文搜索,应该用文本索引或 Elasticsearch,而不是$regex硬扫。这个坑我们在索引篇会再深入。
4.5 数组查询(重点)
数组是文档模型的一大特色,查询也有专门的操作符。
// 数组包含某个元素(tags 里有 "apple")
db.products.find({ tags: "apple" })
// $all:同时包含多个元素
db.products.find({ tags: { $all: ["apple", "phone"] } })
// $size:数组长度等于 2
db.products.find({ tags: { $size: 2 } })
// $elemMatch:数组元素同时满足多个条件
db.orders.find({
items: { $elemMatch: { product: "iPhone", qty: { $gte: 2 } } }
})
一个容易混淆的点:
db.products.find({ tags: "apple" })不是"tags 等于字符串 apple",而是"tags 这个数组里包含 apple"。MongoDB 对数组字段的匹配会自动"下钻"到元素级别,这是新手常困惑的地方。
$elemMatch的必要性——一个真实会踩的陷阱:假设 orders 的 items 是对象数组,你想查"有一件商品是 iPhone 且数量 ≥ 2"。如果不用$elemMatch,直接写:// ❌ 错误:这两个条件会分别匹配数组里的"任意元素" db.orders.find({ "items.product": "iPhone", "items.qty": { $gte: 2 } })这条查询的真实语义是:“数组里存在某个元素 product 是 iPhone” 且 “数组里存在某个元素 qty ≥ 2”——这两个元素可以是不同的!也就是说,一个买了 1 台 iPhone、又买了 2 个 AirPods 的订单,会被错误地匹配上。要表达"同一个元素同时满足两个条件",必须用
$elemMatch把条件包起来:// ✅ 正确:两个条件必须落在同一个数组元素上 db.orders.find({ items: { $elemMatch: { product: "iPhone", qty: { $gte: 2 } } } })
4.6 内嵌文档查询
对前言里那个带 address 内嵌对象的文档,用点号访问嵌套字段:
// 查 address.city 为 Beijing 的文档
db.users.find({ "address.city": "Beijing" })
注意点号写法必须加引号:
"address.city",不能写成address.city。
五、动手串一遍
把上面的知识点串成一个小练习,加深印象:
use shop
// 1. 插入几条商品
db.products.insertMany([
{ name: "iPhone 15", price: NumberDecimal("5999"), stock: 100, tags: ["phone", "apple"] },
{ name: "MacBook", price: NumberDecimal("9999"), stock: 50, tags: ["laptop", "apple"] },
{ name: "小米手机", price: NumberDecimal("1999"), stock: 300, tags: ["phone", "xiaomi"] }
])
// 2. 查所有 apple 品牌、价格降序
db.products.find({ tags: "apple" }).sort({ price: -1 })
// 3. 给所有手机加个分类标记
db.products.updateMany(
{ tags: "phone" },
{ $set: { category: "手机" } }
)
// 4. 库存大于 100 的商品,只看名字和库存
db.products.find(
{ stock: { $gt: 100 } },
{ name: 1, stock: 1, _id: 0 }
)
// 5. 卖出一台 iPhone(库存减 1)
db.products.updateOne(
{ name: "iPhone 15" },
{ $inc: { stock: -1 } }
)
跑完这几条,你就已经掌握了 MongoDB 日常开发 80% 的基础操作。
六、新手易错点速查
把全篇散落的坑汇总成一张表,方便你回头查阅。这些几乎都是我或身边人真实踩过的:
| 易错点 | 错误写法 / 现象 | 正确姿势 |
|---|---|---|
| 金额用浮点 | price: 9.99(Double,有精度误差) | price: NumberDecimal("9.99") |
更新漏 $set | updateOne(cond, { stock: 80 }) 整个文档被替换 | updateOne(cond, { $set: { stock: 80 } }) |
| 投影第一个参数省略 | find(, { name: 1 }) 语法错误 | find({}, { name: 1 }) |
| 投影混用 1 和 0 | { name: 1, stock: 0 } 报错 | 只留一种;_id: 0 可与 1 共存 |
| 以为 find 返回数组 | 只显示 20 条、Type "it" for more | find 返回游标,用 it / toArray() |
数组多条件漏 $elemMatch | 条件落到了不同元素上,匹配错 | 用 $elemMatch 包住同元素的多条件 |
$regex 中间匹配 | name: /关键字/ 全表扫描 | 前缀 ^ 匹配才走索引;全文搜索另用方案 |
| 点号字段没加引号 | find({ address.city: ... }) 报错 | find({ "address.city": ... }) |
insertMany 中途失败 | 默认 ordered:true,后续全停 | 需要容错传 { ordered: false } |
| 误删整个集合 | deleteMany({}) 清空 | 执行前确认条件,别传空对象 |
七、小结
这一篇的核心,用几句话收一下:
- MongoDB 存的是文档(像 JSON),底层用 BSON,比 JSON 类型更丰富、更高效;存金额记得用
NumberDecimal。 - CRUD 记住
insert / find / update / delete+One / Many;更新一定要配$set,否则会整个替换文档。 - 查询靠操作符:比较(
$gt等)、逻辑($or等)、数组($elemMatch等),内嵌字段用点号访问。
到这里,“会用” MongoDB 的基本功已经具备了。但你可能已经隐约感觉到一个问题:一条数据到底该拆成多个集合,还是内嵌成一个大文档? 这正是文档型数据库最核心、也最考验功力的地方。
下一篇《MongoDB 文档模型设计:从关系型思维到文档型思维的转变》,我们就正式进入"设计"的领域,聊聊内嵌与引用如何取舍——这也是整个专栏从"会用"迈向"用好"的转折点。

978

被折叠的 条评论
为什么被折叠?



