MongoDB 基础语法与数据结构:从 BSON 到 CRUD 与查询操作符

前言

上一篇我们把 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?两个原因:

  1. 更快:二进制格式,解析和遍历比文本 JSON 高效。
  2. 类型更丰富:JSON 只有字符串、数字、布尔、数组、对象、null 这几种,而 BSON 扩展出了日期、ObjectId、二进制、精确的整型/浮点型等,能表达的数据类型多得多。

常用的 BSON 类型:

类型示例说明
String"Tom"字符串,UTF-8
Int32 / Int64NumberInt(18) / NumberLong(100)整型
Double3.14双精度浮点(数字默认是这个)
Decimal128NumberDecimal("9.99")高精度小数,存金额务必用它
Booleantrue / false布尔
Datenew Date() / ISODate(...)日期时间
Array[1, 2, 3]数组
Object{ a: 1 }内嵌文档
ObjectIdObjectId("...")唯一 ID,_id 默认类型
Nullnull空值

一个踩坑提醒:在 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,配合 OneMany 后缀区分单条和多条。

先准备一个练手集合:

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 会返回生成的 _idinsertOne 返回:

{
  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 时新插入的文档数
}

留意 matchedCountmodifiedCount 的区别:如果新值和原值一样(比如 stock 本来就是 80,又 $set 成 80),会 matchedCount: 1modifiedCount: 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")
更新漏 $setupdateOne(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 morefind 返回游标,用 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 文档模型设计:从关系型思维到文档型思维的转变》,我们就正式进入"设计"的领域,聊聊内嵌与引用如何取舍——这也是整个专栏从"会用"迈向"用好"的转折点。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Leighteen

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值