MongoDB CRUD 基础与 Shell 操作

MongoDB CRUD 操作与 Shell 命令,覆盖 insert/find/update/delete、投影、排序、分页、批量操作

MongoDB 是最流行的文档型 NoSQL 数据库,以灵活的 BSON 文档模型、丰富的查询语言和水平扩展能力著称。与传统关系型数据库(如 MySQL、PostgreSQL)不同,MongoDB 以 JSON 风格的 BSON 文档组织数据,无需预先定义严格的表结构,这使得它在快速迭代的业务场景、海量非结构化数据存储以及微服务架构中具备天然优势。本文从 Shell 实战出发,系统讲解 CRUD(Create/Read/Update/Delete)核心操作,涵盖插入、查询、更新、删除、投影、排序、分页、批量操作与游标管理等完整内容,帮助开发者从入门到熟练应对生产环境的各类数据操作需求。

Shell 连接与配置

MongoDB 提供两个 Shell 客户端工具:mongo(旧版,随 MongoDB 4.4 及以前版本安装)和 mongosh(新版,自 MongoDB 5.0 起成为默认 Shell 工具)。mongosh 基于现代 Node.js 引擎构建,全面支持 ES6+ 语法特性(如箭头函数、解构赋值、Promise/async-await),并内置了更完善的自动补全和语法高亮功能,强烈推荐在所有新项目中使用 mongosh

连接本地实例

安装 MongoDB 社区版或 MongoDB Atlas CLI 后,可直接在终端中启动 Shell:

// 连接本地默认端口(27017)上的 MongoDB 实例
mongosh

// 连接并直接切换到指定数据库
mongosh "mongodb://localhost:27017/mydb"

// 使用带有用户名和密码验证的完整连接字符串
mongosh "mongodb://username:password@localhost:27017/mydb?authSource=admin"

authSource=admin 参数指定认证凭证存储在 admin 数据库中,这是 MongoDB 的默认行为。如果用户是在特定业务数据库中创建的,则需将 authSource 改为该数据库名。

远程与集群连接

在生产环境中,MongoDB 通常以副本集(Replica Set)或分片集群(Sharded Cluster)形式部署,以提供高可用性和水平扩展能力。此时连接字符串需要包含多个节点地址。

// 连接三节点副本集(Replica Set),驱动会自动发现主节点
mongosh "mongodb://user:pass@node1:27017,node2:27017,node3:27017/mydb?replicaSet=rs0"

// 连接分片集群(通过 mongos 路由节点访问)
mongosh "mongodb://mongos1:27017,mongos2:27017/mydb"

// 连接 MongoDB Atlas 云托管服务(使用 SRV 记录自动解析集群节点)
mongosh "mongodb+srv://user:pass@cluster0.mongodb.net/mydb?retryWrites=true&w=majority"

副本集连接的关键在于 replicaSet 参数,它告知驱动程序当前连接的是一个副本集而非单节点,驱动会据此自动执行主节点发现(Server Discovery)和故障转移。分片集群则需要连接到 mongos 进程,应用程序无需关心数据具体分布在哪个分片上。

Shell 常用命令速查

进入 mongosh 后,以下命令是日常开发和运维中最频繁使用的:

// 查看当前正在使用的数据库名称
db

// 列出当前实例中所有数据库(执行此命令需要 listDatabases 权限)
show dbs

// 切换到指定数据库,若不存在则创建(MongoDB 采用惰性创建策略,首次写入时才真正创建)
use mydb

// 列出当前数据库下所有集合(Collections 类比关系型数据库的"表")
show collections

// 查看当前服务器的运行状态,包括内存、连接数、操作计数器、锁状态等
db.serverStatus()

// 查看当前数据库的统计概览:文档数、索引数、数据大小、存储大小等
db.stats()

// 查看特定集合的统计信息
db.users.stats()

// 获取数据库级别的帮助文档
db.help()

// 获取集合级别的帮助文档
db.users.help()

// 查看集合的索引信息
db.users.getIndexes()

// 清空当前数据库中的所有集合(危险操作,生产环境禁用)
db.dropDatabase()

// 删除指定集合
db.users.drop()

配置 Shell 环境

通过 mongosh 的起点配置文件 ~/.mongoshrc.js(Windows 下为 %USERPROFILE%\.mongoshrc.js),可以自定义 Shell 的行为表现和提示符样式:

// ~/.mongoshrc.js
// 开启慢查询分析,自动记录执行超过 100 毫秒的操作
db.setProfilingLevel(1, { slowms: 100 })

// 定义自定义提示符,显示当前数据库名和 ISO 格式时间
prompt = function() {
  return db.getName() + " [" + new Date().toISOString() + "] > ";
};

// 设置默认打印文档数量,防止意外输出百万级数据
db.getMongo().setReadPref("primaryPreferred")

Heredoc 文件在 mongosh 启动时自动执行,可以用于预加载常用工具函数、设置连接选项或注入业务相关的便捷方法。

BSON 数据类型速览

MongoDB 使用 BSON(Binary JSON)作为底层存储格式,它是 JSON 的扩展,支持 JSON 标准之外更丰富和精确的数据类型。在 Shell 中操作时,正确选择和使用 BSON 类型对数据准确性和查询效率至关重要。

BSON 类型说明与适用场景Shell 示例
ObjectId默认 _id 类型,12 字节,含时间戳/机器标识/进程ID/计数器ObjectId("64a1b2c3d4e5f6a7b8c9d0e1")
Date毫秒级 UTC 时间戳,用于业务时间字段new Date()ISODate("2026-08-13")
Timestamp内部专用,用于 Oplog 等内部集合Timestamp(1, 2)
NumberInt32 位有符号整数NumberInt(42)
NumberLong64 位有符号整数NumberLong(9007199254740992)
NumberDecimal128 位 IEEE 754-2008 十进制浮点数,金融场景必备NumberDecimal("99.99")
ISODateDate 的别名函数,输入字符串更优雅ISODate("2026-08-13T10:00:00Z")
Binary二进制数据,如图片、文件切片BinData(0, "base64string")
RegExpJavaScript 正则表达式/^abc/i
MaxKey / MinKey用于比较时的极值边界MaxKeyMinKey

类型使用陷阱:在 Shell 中输入 new Date() 和在 JSON 字符串中写 "new Date()" 是完全不同的语义。前者生成 BSON Date 类型,后者只是普通字符串。开发中应始终在代码中使用语言原生的日期类型,避免字符串化后重新解析的性能损耗和时区错误。

// 正确:插入标准的 Date 类型
db.events.insertOne({ createdAt: new Date(), eventName: "user_login" })

// 错误:插入的是普通字符串 "new Date()"
db.events.insertOne({ createdAt: "new Date()", eventName: "user_login" })

至此,Shell 连接、环境配置和基础 BSON 类型认知已经建立,接下来进入数据写入的核心操作。

insertOne 与 insertMany

MongoDB 的插入操作用于向集合中添加新文档。与关系型数据库要求事先定义表结构不同,MongoDB 采用 Schema-less(无模式)设计,同一个集合中的文档字段可以各不相同,这为业务快速迭代提供了极大便利。不过,在需要数据一致性和可预测性的场景中,MongoDB 也支持 Schema Validation(JSON Schema 验证),在数据库层约束文档结构、字段类型和必填规则。

insertOne 插入单条文档

insertOne 是最基础的插入方法,用于向集合中添加单个文档:

// 向 users 集合插入一条典型用户文档
db.users.insertOne({
  name: "张三",
  email: "zhangsan@example.com",
  age: 28,
  tags: ["developer", "mongodb", "backend"],
  address: {
    city: "北京",
    district: "海淀区",
    zipCode: "100089"
  },
  isVip: false,
  balance: NumberDecimal("1280.50"),
  createdAt: new Date(),
  updatedAt: new Date()
})

执行成功后,MongoDB 返回操作确认信息:

{
  acknowledged: true,
  insertedId: ObjectId("64a1b2c3d4e5f6a7b8c9d0e1")
}

关键细节说明:

  • 如果文档中未包含 _id 字段,MongoDB 服务器会自动生成一个 ObjectId 作为唯一标识符。ObjectId 由 12 字节组成,前 4 字节是时间戳(大致按时间有序),接着 3 字节是机器标识,2 字节是进程 ID,最后 3 字节是随机递增计数器。这种设计使得 ObjectId 在分布式系统中天然具备良好的唯一性和时间局部性。
  • acknowledged: true 表示服务器已确认接收到并处理了该写操作。如果在 Write Concern 配置为 w: 0(即不等待任何确认)时执行,则可能返回 acknowledged: false
  • 集合( Collection )在这个阶段不需要预先创建,MongoDB 会在首次插入时自动创建该集合,并根据初始文档的字段建立隐式的集合元信息。

insertMany 批量插入

对于需要一次性导入多条记录的场景,insertMany 比循环调用 insertOne 高效得多,因为它将多条文档打包到单次网络请求中,极大地减少了网络往返开销(Round-Trip Time, RTT)。

// 批量插入 3 条用户文档
db.users.insertMany([
  { name: "李四", email: "lisi@example.com", age: 32, status: "active", createdAt: new Date() },
  { name: "王五", email: "wangwu@example.com", age: 25, status: "pending", createdAt: new Date() },
  { name: "赵六", email: "zhaoliu@example.com", age: 45, status: "active", createdAt: new Date() },
  { name: "孙七", email: "sunqi@example.com", age: 19, status: "inactive", createdAt: new Date() }
])

返回结果包含所有插入文档对应的 _id 映射:

{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId("64a1b2c3d4e5f6a7b8c9d0e2"),
    '1': ObjectId("64a1b2c3d4e5f6a7b8c9d0e3"),
    '2': ObjectId("64a1b2c3d4e5f6a7b8c9d0e4"),
    '3': ObjectId("64a1b2c3d4e5f6a7b8c9d0e5")
  }
}

有序插入与无序插入

insertMany 默认使用有序插入(ordered: true 为默认值),这意味着文档严格按照数组中的顺序逐条插入。一旦某条文档插入失败(例如因为违反了唯一索引约束),后续的所有文档都不再被处理,MongoDB 会直接返回错误并终止操作。

// 有序插入示例:第三条因 email 重复而失败,第四条不会被处理
db.users.insertMany(
  [
    { name: "张三", email: "unique_a@example.com" },
    { name: "李四", email: "unique_b@example.com" },
    { name: "重复用户", email: "unique_a@example.com" },  // 违反唯一索引
    { name: "王五", email: "unique_c@example.com" }       // 不会被执行
  ],
  { ordered: true }
)
// 结果:前两条插入成功,第三条失败报错,操作终止

然而,在数据迁移、日志导入等大批量写入场景中,使用无序插入(ordered: false)更为合适。此时 MongoDB 会并行地或乐观地尝试插入所有文档,只有实际失败的文档会被记录到 writeErrors 数组中,不影响其他文档的插入结果。

// 无序插入:失败不会中断,最大化数据导入量
db.users.insertMany(
  [
    { name: "张三", email: "batch01@example.com" },
    { name: "李四", email: "batch02@example.com" },
    { name: "重复A", email: "batch01@example.com" },   // 重复,失败
    { name: "重复B", email: "batch02@example.com" },   // 重复,失败
    { name: "王五", email: "batch03@example.com" }     // 仍然会被插入
  ],
  { ordered: false }
)
// 结果:第一条、第二条、第五条插入成功;第三条、第四条失败写入 writeErrors

在实际应用中,无序插入配合重试机制是数据同步脚本的通用模式:先批量无序导入,然后对 writeErrors 中标记的文档(通常是重复键、格式错误等)单独处理或记录到死信队列中。

手动指定与自定义 _id

MongoDB 的 _id 字段不仅是主键,还承担分片键(Shard Key)和唯一索引的核心角色。默认自动生成的 ObjectId 在绝大多数场景下表现优秀,但在以下场景中需要手动指定 _id

// 场景一:使用业务自然键作为主键,减少冗余索引
db.products.insertOne({
  _id: "SKU-2026-8848",
  name: "机械键盘 K8",
  category: "数码外设",
  price: NumberDecimal("498.00"),
  stock: 120
})

// 场景二:与 MySQL 等外部关系型数据库的主键对齐
db.orders.insertOne({
  _id: NumberLong(1000001),
  mysqlOrderId: 1000001,
  userId: 5043,
  totalAmount: NumberDecimal("2599.00"),
  createdAt: new Date()
})

// 场景三:使用 UUID 保证跨数据中心的全局唯一性
db.logs.insertOne({
  _id: UUID("550e8400-e29b-41d4-a716-446655440000"),
  level: "ERROR",
  message: "Connection timeout",
  timestamp: new Date()
})

如果需要数据库级别的自增整数 ID(类似 MySQL 的 AUTO_INCREMENT),MongoDB 并不原生支持,不过可以通过原子性的 findAndModify 实现一个计数器集合:

// 初始化计数器
db.counters.insertOne({ _id: "order_seq", seq: 0 })

// 获取下一个序列值的辅助函数
function getNextSequence(name) {
  var ret = db.counters.findOneAndUpdate(
    { _id: name },
    { $inc: { seq: 1 } },
    { returnDocument: "after", upsert: true }
  )
  return ret.seq
}

// 插入时使用自增 ID
db.orders.insertOne({
  _id: getNextSequence("order_seq"),
  customerName: "张三",
  total: 580,
  createdAt: new Date()
})

注意:自增 ID 在高并发或分片集群环境下容易成为性能瓶颈(写入热点),因此在大规模分布式场景中,仍然推荐使用 ObjectId 或雪花算法 ID。

Write Concern 写入安全级别

MongoDB 允许通过 Write Concern 控制写入操作的持久性和确认级别,这是一个在生产环境中必须根据数据重要性进行权衡配置的核心参数。

// 最高安全级别:等待大多数副本节点写入磁盘日志后再返回
// 适用于金融交易、订单状态变更等关键业务
db.users.insertOne(
  { name: "重要用户", balance: NumberDecimal("10000.00") },
  { writeConcern: { w: "majority", j: true, wtimeout: 5000 } }
)

// 默认安全级别:等待主节点(Primary)确认即可
// 适用于一般业务数据,兼顾性能与一致性
db.users.insertOne(
  { name: "普通用户", tags: ["standard"] },
  { writeConcern: { w: 1 } }
)

// 最低安全级别:发后即忘(Fire and Forget)
// 适用于日志、监控指标等非关键数据,追求极致写入吞吐量
db.logs.insertOne(
  { level: "INFO", message: "Page visited", timestamp: new Date() },
  { writeConcern: { w: 0 } }
)

Write Concern 参数释义:

  • w:确认级别。"majority" 表示超过半数的副本集成员确认写入;1 表示主节点确认;0 表示不等待确认;还可以指定具体数字(如 w: 3 表示等待 3 个节点确认)。
  • j(journal):是否要求写入到磁盘上的日志文件。j: true 可以在服务器意外宕机时防止内存中未刷盘的数据丢失。wtimeout:等待确认的最大毫秒数。如果超时,MongoDB 会返回 WriteConcernError,但不会回滚已经完成的写入,这意味着数据可能已持久化但客户端未收到确认,应用需具备幂等性或重试校验机制。

find 查询语法

find 是 MongoDB 中最核心的读取方法,它接收两个参数:查询过滤器(query filter)和投影(projection)。过滤器的语法是一个 BSON 对象,键为字段名,值为匹配条件或操作符。MongoDB 的查询语言极其丰富,覆盖从简单的精确匹配到复杂的嵌套文档查询、数组操作和地理空间计算。

等值匹配与比较操作符

最简单的查询是等值匹配,直接传入 { field: value } 即可:

// 精确匹配:查找姓名为 "张三" 的用户
db.users.find({ name: "张三" })

// 精确匹配布尔值
db.users.find({ isVip: true })

// 精确匹配内嵌文档(注意:内嵌文档必须完全匹配,包括字段顺序)
db.users.find({ address: { city: "北京", district: "海淀区" } })

// 匹配内嵌文档的某个字段(更推荐的方式)
db.users.find({ "address.city": "北京" })

比较操作符允许在数值、日期、字符串等类型上执行范围查询:

// $eq :等于(equal,可省略不写)
db.users.find({ age: { $eq: 28 } })
db.users.find({ age: 28 }) // 二者等价

// $ne :不等于(not equal)
db.users.find({ status: { $ne: "deleted" } })

// $gt / $gte :大于 / 大于等于(greater than / greater than or equal)
db.users.find({ age: { $gt: 25 } })
db.users.find({ age: { $gte: 18 } })

// $lt / $lte :小于 / 小于等于(less than / less than or equal)
db.users.find({ age: { $lt: 30 } })
db.products.find({ price: { $lte: NumberDecimal("500.00") } })

// 组合范围:18 <= age < 35
db.users.find({ age: { $gte: 18, $lt: 35 } })

// 日期范围查询:查找最近 7 天内创建的文档
// 注意 $gt 在 Date 类型上的行为是大于指定时间戳
db.events.find({
  createdAt: {
    $gte: new Date(new Date() - 7 * 24 * 60 * 60 * 1000)
  }
})

比较操作符不仅适用于数值和日期,也适用于字符串(按字典序比较)和数组(按元素逐一比较)。

集合操作符 $in 与 $nin

当需要匹配多个可能值时,使用 $in$nin(not in)操作符:

// $in :字段值等于数组中任意一个元素即可匹配
db.users.find({ status: { $in: ["active", "pending"] } })

// 等效于 SQL:WHERE status IN ('active', 'pending')

// $in 对数组字段同样有效:查找 tags 中包含 "developer" 或 "designer" 的文档
db.users.find({ tags: { $in: ["developer", "designer"] } })

// $nin :字段值不等于数组中任何一个元素
db.users.find({ status: { $nin: ["banned", "deleted", "suspended"] } })

$in 在底层使用索引时,MongoDB 会分别获取每个值对应的索引条目然后合并结果。如果 $in 数组中的值过多(如超过数千个),查询性能会显著下降,此时应考虑重构查询逻辑或其他数据模型。

逻辑操作符 $and、$or、$not、$nor

逻辑操作符用于组合多个查询条件,构建复杂的过滤逻辑。

// $and :所有条件同时满足(隐式 and)
// 以下三种写法等价
db.users.find({ age: { $gte: 18 }, status: "active" })
db.users.find({ $and: [{ age: { $gte: 18 } }, { status: "active" }] })

// $or :任一条件满足即可
// 查找状态为 active 或 age 大于 60 的用户
db.users.find({
  $or: [
    { status: "active" },
    { age: { $gt: 60 } }
  ]
})

// $and 与 $or 组合使用
// 查找 "active 且在北京" 或 "age > 60" 的用户
db.users.find({
  $or: [
    { $and: [{ status: "active" }, { "address.city": "北京" }] },
    { age: { $gt: 60 } }
  ]
})

// $not :对条件取反(作用于表达式或正则)
db.users.find({ age: { $not: { $gte: 18 } } })  // age < 18
db.users.find({ name: { $not: /^张/ } })         // 名字不以"张"开头

// $nor :所有条件都不满足(相当于 NOT (A OR B))
db.users.find({
  $nor: [
    { status: "deleted" },
    { isVip: false }
  ]
})

需要注意的逻辑陷阱:

  • $and$or$nor 都接收数组参数。$and 在多数场景下可以省略(逗号分隔的键值对自动转为隐式 AND),但当需要对同一个字段施加多个条件(如同一字段的 $gt$lt)时,显式 $and 或直接使用范围语法是必要的。
  • $not 只能作用于表达式,不能单独与一个值搭配(如 { age: { $not: 18 } } 是非法的)。

数组查询操作符

MongoDB 的数组查询非常灵活,支持按元素存在性、元素值、元素数量和数组整体匹配。

// 查找 tags 字段中包含 "developer" 的文档(至少一个元素匹配即可)
db.users.find({ tags: "developer" })

// $all :要求数组同时包含所有指定元素(顺序无关)
db.users.find({ tags: { $all: ["developer", "mongodb"] } })

// $size :精确匹配数组长度
db.users.find({ tags: { $size: 3 } })

// $elemMatch :数组中至少有一个元素满足所有指定条件
// 查找订单中至少有一件商品的价格高于 500 且数量大于 2
db.orders.find({
  items: {
    $elemMatch: {
      price: { $gt: 500 },
      quantity: { $gt: 2 }
    }
  }
})

$elemMatch 是处理数组中嵌套文档查询时最关键的操作符。假如不使用它,MongoDB 可能分别检查数组的不同元素来满足不同条件,结果不符合业务预期。

// 危险:不使用 $elemMatch 的写法
// 这个查询的含义是:数组中有一个元素的 price > 500,且数组中有一个元素的 quantity > 2
// 但这两个条件可能分别由不同元素满足!
db.orders.find({
  "items.price": { $gt: 500 },
  "items.quantity": { $gt: 2 }
})

// 正确:要求同一元素同时满足 price > 500 和 quantity > 2
db.orders.find({
  items: {
    $elemMatch: {
      price: { $gt: 500 },
      quantity: { $gt: 2 }
    }
  }
})

Type Bracketing 类型匹配技巧

由于 BSON 的分层类型系统,同一个字段在不同文档中可能存储不同类型的值(这是 Schema-less 带来的灵活性,也可能成为隐患)。$type 操作符用于限定字段的数据类型。

// 查找 age 字段类型为 double(即 JavaScript Number)的文档
db.users.find({ age: { $type: "double" } })

// 查找 phone 字段存储为字符串的文档(可能其他文档是 NumberLong)
db.users.find({ phone: { $type: "string" } })

// 配合 $exists 检查字段是否缺失
db.users.find({ middleName: { $exists: false } })

$type 的值可以是数字代号(1 为 double,2 为 string,10 为 null 等)或字符串别名("double""string""null" 等)。在数据清洗场景下,这种类型感知查询极为有用,例如找出价格字段被误存为字符串而非数值的所有文档。

// 数据清洗:找出 price 为字符串类型(需要数值化)的商品
db.products.find({ price: { $type: "string" } }).forEach(function(doc) {
  db.products.updateOne(
    { _id: doc._id },
    { $set: { price: NumberDecimal(doc.price) } }
  )
})

投影与排序

默认情况下,find 返回文档的所有字段。对于字段较多或嵌套层级较深的文档,全部返回会造成不必要的网络传输和内存占用。投影(Projection)允许精确控制返回哪些字段;排序(Sort)、分页(Skip/Limit)则控制结果集的呈现顺序和范围。

投影控制返回字段

投影是 find 的第二个参数:db.collection.find(filter, projection)。值为 1 表示包含(include),为 0 表示排除(exclude),但 _id 可以单独与 include/exclude 组合(唯一例外)。

// 仅返回 name 和 email 字段(_id 默认返回,可显式排除)
db.users.find(
  { status: "active" },
  { name: 1, email: 1, _id: 0 }
)

// 排除密码字段和内部元数据,返回其余所有字段
db.users.find(
  { status: "active" },
  { passwordHash: 0, internalNotes: 0, auditLog: 0 }
)

// 投影嵌套文档中的特定字段
db.users.find(
  { status: "active" },
  { "address.city": 1, "address.zipCode": 1, name: 1 }
)

投影规则:

  • 不能在同一投影中混合使用 01(除了 _id),即要么全是指定包含的字段,要么全是指定排除的字段。
  • 排除模式适合字段很多的场景(如只排除敏感字段),包含模式适合字段较少的场景(如只取名字和邮箱)。
  • 投影是在服务器端完成的,未返回的字段不会占用网络带宽,对于减少流量开销效果显著。

sort 排序

sort 方法对结果集进行排序,参数是一个 BSON 对象,值为 1 表示升序,-1 表示降序。

// 按年龄升序排列
db.users.find().sort({ age: 1 })

// 先按状态降序(active 在 pending 之前,因为字符串比较),再按创建时间降序
db.users.find().sort({ status: -1, createdAt: -1 })

// 多字段排序:先按城市升序,同城市内按年龄降序
db.users.find().sort({ "address.city": 1, age: -1 })

排序字段最好有索引支撑。如果没有合适的索引,MongoDB 必须在内存中对结果集进行排序。当排序结果超过 100MB(可通过 allowDiskUse 选项调整)时,会报错。生产中应避免无索引排序大数据量查询。

skip 与 limit 分页

传统分页模式使用 skip + limit 组合:

// 第 1 页,每页 10 条
db.users.find().sort({ createdAt: -1 }).skip(0).limit(10)

// 第 2 页,跳过前 10 条
db.users.find().sort({ createdAt: -1 }).skip(10).limit(10)

// 第 3 页
db.users.find().sort({ createdAt: -1 }).skip(20).limit(10)

这种模式的优点是直观易用,但存在严重性能问题:skip(N) 需要数据库遍历并丢弃前 N 条记录,当 N 达到数万甚至数百万时,查询延迟会线性增长。因此在大数据量分页场景中,应改用范围查询分页(游标/seek 分页)。

// 基于上一页最后一条记录的游标时间戳进行分页(Keyset Pagination)
// 假设上一页最后一条记录的 createdAt 是 2026-08-01T10:00:00Z
var lastCreatedAt = ISODate("2026-08-01T10:00:00Z")
db.users.find({ createdAt: { $lt: lastCreatedAt } })
  .sort({ createdAt: -1 })
  .limit(10)

范围查询分页的性能是 O(log n)(通过索引直接定位),与页码深度无关。唯一要求是排序字段具有唯一性,或者联合多个字段形成唯一的排序键(如 { createdAt: -1, _id: -1 }),避免踩到相同时间戳导致的数据遗漏或重复。

updateOne 与 updateMany

MongoDB 的更新操作通过一系列更新操作符(Update Operators)实现字段级别的精确修改,避免了"读取-修改-写回"整个文档的低效模式。更新操作默认以原子方式在文档级别执行,单个文档的更新在 WiredTiger 存储引擎中是事务性的。

$set 局部字段更新

$set 是最常用的更新操作符,用于设置(新增或修改)指定字段的值,不影响文档中其他字段。

// 更新单条文档:将张三的手机号和更新时间修改
db.users.updateOne(
  { name: "张三" },
  {
    $set: {
      phone: "13800138000",
      "address.zipCode": "100190",
      updatedAt: new Date()
    }
  }
)

// 返回结果
{
  acknowledged: true,
  matchedCount: 1,
  modifiedCount: 1
}

// 批量更新:将所有 pending 状态用户改为 active
db.users.updateMany(
  { status: "pending" },
  {
    $set: {
      status: "active",
      activatedAt: new Date(),
      updatedAt: new Date()
    }
  }
)

updateOne 只更新满足过滤条件的第一条文档,updateMany 更新所有满足条件的文档。返回结果中的 matchedCount 是匹配到的文档数,modifiedCount 是实际被修改的文档数(如果新值与旧值相同则不计入修改)。

$inc 数字字段原子增减

$inc 用于对数值字段进行原子性的加减操作,是计数器、库存扣减等场景的利器。由于 WiredTiger 在同一文档上的操作是原子的,两个并发请求同时 $inc 同一个字段不会导致竞态条件。

// 给用户的积分余额增加 100
db.users.updateOne(
  { name: "张三" },
  { $inc: { points: 100 } }
)

// 给商品库存扣减 3 件(库存扣减配合条件检查防止超卖)
db.products.updateOne(
  { _id: "SKU-2026-8848", stock: { $gte: 3 } },
  {
    $inc: { stock: -3 },
    $set: { updatedAt: new Date() }
  }
)

// 同时处理多个数值字段
db.orders.updateOne(
  { _id: 1000001 },
  {
    $inc: { totalAmount: -50, discountUsed: 50 }
  }
)

注意:$inc 只能用于数值类型字段,如果字段不存在则初始化为 0 后再执行增减。$inc 的更新是原地进行的,不需要重新分配文档存储空间。

$push、$addToSet、$pull 数组维护

数组是 MongoDB 文档模型的核心能力之一,提供了丰富的操作符来增删改查数组元素。

// $push :向数组尾部追加一个元素
db.users.updateOne(
  { name: "张三" },
  { $push: { tags: "golang" } }
)

// $push 配合 $each 一次性追加多个元素
db.users.updateOne(
  { name: "张三" },
  { $push: { tags: { $each: ["rust", "zig"] } } }
)

// $push + $each + $slice 保留数组最近 N 个元素(环形缓冲区模式)
db.users.updateOne(
  { name: "张三" },
  {
    $push: {
      loginHistory: {
        $each: [{ ip: "192.168.1.1", time: new Date() }],
        $slice: -10  // 只保留最近 10 条登录记录
      }
    }
  }
)

// $addToSet :追加元素,但仅当元素不存在时才添加(数组版 Set 去重)
db.users.updateOne(
  { name: "张三" },
  { $addToSet: { tags: "mongodb" } }  // 若已有则不重复添加
)

// $addToSet 配合 $each 批量去重添加
db.users.updateOne(
  { name: "张三" },
  { $addToSet: { tags: { $each: ["vue", "react", "mongodb"] } } }
)

// $pull :移除数组中所有匹配指定条件的元素
db.users.updateOne(
  { name: "张三" },
  { $pull: { tags: "mongodb" } }
)

// $pullAll :移除数组中所有与指定值列表相等的元素
db.users.updateOne(
  { name: "张三" },
  { $pullAll: { tags: ["old_tag1", "old_tag2"] } }
)

数组操作符在实际业务中应用极为广泛:用户标签管理、$push$slice 组合实现评论/消息的最近 N 条列表、$addToSet 维护用户收藏夹并以集合语义自动去重。

$unset 删除字段

// 删除用户的临时字段
db.users.updateOne(
  { name: "张三" },
  { $unset: { temporaryToken: "", sessionCache: "" } }
)

$unset 的参数值可以是任意内容(通常写成 1 或空字符串),MongoDB 只关心键名。删除字段后,该文档的物理存储空间不会立即释放,但后续写入时 WiredTiger 会进行压缩整理。

$rename 字段重命名

// 将旧字段名替换为新字段名(保留数据)
db.users.updateMany(
  {},
  { $rename: { "nickname": "displayName" } }
)

$rename 可用于 Schema 迁移场景,将旧字段批量重命名为新字段名,配合应用端的双读策略(先读旧字段,fallback 到新字段)实现零停机迁移。

upsert 存在则更新、不存在则插入

upsert: true 是 MongoDB 更新操作中最实用的选项之一。当查询条件未匹配到任何文档时,MongoDB 会自动基于查询条件和更新内容创建一条新文档,而不是返回 “matchedCount: 0”。

// 示例:用户签到系统,若当日记录存在则增加积分,不存在则初始化
db.dailyCheckins.updateOne(
  {
    userId: 5043,
    date: "2026-08-13"
  },
  {
    $inc: { points: 10, checkinCount: 1 },
    $setOnInsert: { firstCheckinAt: new Date() },
    $set: { lastCheckinAt: new Date() }
  },
  { upsert: true }
)

$setOnInsertupsert 是天作之合:它只在文档被插入时生效,在文档已存在时不会覆盖已有值。上例中,firstCheckinAt 只在首次签到时设置,lastCheckinAt 每次都会更新,而 pointscheckinCount 则在已有基础上累加。

replaceOne 完整替换文档

updateOne 不同,replaceOne 会用第二个参数中的全新文档完全替换匹配到的旧文档(保留 _id)。这适用于文档结构发生颠覆性变化的场景。

// 完全替换文档(_id 保持不变)
db.users.replaceOne(
  { name: "张三" },
  {
    name: "张三",
    email: "zhangsan@example.com",
    age: 29,
    profile: { bio: "" }
  }
)

注意:replaceOne 的新文档中不能包含 $ 开头的更新操作符,它必须是纯文档结构。

deleteOne 与 deleteMany

MongoDB 的删除操作分为 deleteOne(删除匹配到的第一条文档)和 deleteMany(删除匹配到的所有文档)。与更新操作类似,删除操作默认在单个文档级别上是原子的。然而,在生产环境中,“删除"决策通常需要极为谨慎,因为数据一旦物理删除便不可恢复。

硬删除操作

// 删除指定 _id 的文档(最安全的删除方式)
db.users.deleteOne({ _id: ObjectId("64a1b2c3d4e5f6a7b8c9d0e1") })

// 删除满足条件的第一条文档
db.logs.deleteOne({ level: "DEBUG", createdAt: { $lt: ISODate("2026-01-01") } })

// 删除满足条件的所有文档
// ⚠️ 危险操作:执行前务必先用 find() 验证过滤条件
db.sessions.deleteMany({ expiredAt: { $lt: new Date() } })

返回结果:

{
  acknowledged: true,
  deletedCount: 5
}

安全删除实践

  1. 删除任何数据前,先用相同的过滤条件执行 find() 检查预期影响范围。
  2. deleteOne 时优先使用唯一标识符(_id 或业务唯一键),避免误删多条记录。
  3. deleteMany 的执行会在 WiredTiger 中产生大量数据删除,如果删除数据量极大(如清几千万条过期日志),应分批次执行以避免对数据库性能造成冲击。

软删除设计

在生产系统中,绝大多数"删除"操作应该设计为软删除(Soft Delete),即在文档中设置一个标记字段(如 isDeletedstatusdeletedAt),而不是物理移除数据。软删除的优势包括:

  • 数据可恢复:误操作或用户反悔时可撤销删除。
  • 审计追踪:保留完整的历史记录以满足合规要求。
  • 关联数据完整性:外键或引用不会因为目标文档的消失而悬空。
// 软删除实现:添加删除标记和时间戳
db.users.updateOne(
  { _id: ObjectId("64a1b2c3d4e5f6a7b8c9d0e1") },
  {
    $set: {
      isDeleted: true,
      deletedAt: new Date(),
      deletedBy: "admin_001"
    },
    $unset: { sensitiveData: "" }  // 软删除时同步清理敏感字段
  }
)

// 查询时过滤掉已软删除的文档
db.users.find({ isDeleted: { $ne: true } })

TTL 索引自动清理

对于确实需要物理清理的过期数据(如验证码、临时会话、日志归档后),最佳实践是给软删除标记字段创建 TTL 索引(Time-To-Live Index),让 MongoDB 自动按设定时间清理。

// 创建 TTL 索引:deletedAt 字段后 30 天自动物理删除
db.users.createIndex(
  { deletedAt: 1 },
  { expireAfterSeconds: 2592000 }
)

// 这样软删除的文档在 30 天后会被 MongoDB 后台任务自动清理

TTL 索引的机制是 MongoDB 后台线程每 60 秒扫描一次,因此文档的实际删除时间会有一定延迟。TTL 索引只能建立在 Date 类型字段上,且每个集合可以有多个 TTL 索引。对于不活跃的集合(长期无写入),TTL 清理可能不够及时,可结合定时任务补充清理。

批量操作 BulkWrite

当需要对数据进行批量插入、更新、删除的混合操作时,逐一调用 insertOne / updateOne / deleteOne 会产生大量的网络往返时间(RTT),严重影响吞吐量。bulkWrite 方法将多种操作打包到单次网络请求中发送给 MongoDB 服务器,极大提升了写入效率,性能提升通常可达 10 到 100 倍。

基础语法

db.collection.bulkWrite(
  [
    { insertOne: { document: { ... } } },
    { updateOne: { filter: { ... }, update: { ... }, upsert: true } },
    { updateMany: { filter: { ... }, update: { ... } } },
    { deleteOne: { filter: { ... } } },
    { deleteMany: { filter: { ... } } },
    { replaceOne: { filter: { ... }, replacement: { ... }, upsert: false } }
  ],
  { ordered: false } // 可选:同 insertMany 的有序/无序策略
)

实战示例

// 订单批量处理:插入新订单、更新库存、删除购物车商品,一次完成
db.products.bulkWrite([
  {
    insertOne: {
      document: {
        _id: "ORDER-2026-0813-001",
        userId: 5043,
        items: [
          { sku: "SKU-8848", name: "机械键盘 K8", price: 498, qty: 1 },
          { sku: "SKU-9921", name: "人体工学椅", price: 1299, qty: 1 }
        ],
        total: 1797,
        status: "paid",
        createdAt: new Date()
      }
    }
  },
  {
    updateOne: {
      filter: { _id: "SKU-8848" },
      update: { $inc: { stock: -1 } }
    }
  },
  {
    updateOne: {
      filter: { _id: "SKU-9921" },
      update: { $inc: { stock: -1 } }
    }
  },
  {
    deleteMany: {
      filter: { userId: 5043, status: "cart" }
    }
  }
])

返回结果包含每种操作类型的详细统计:

{
  acknowledged: true,
  insertedCount: 1,
  matchedCount: 2,
  modifiedCount: 2,
  deletedCount: 3,
  upsertedCount: 0,
  writeErrors: [],
  writeConcernErrors: []
}

有序与无序批量操作

insertMany 类似,bulkWrite 也支持 ordered 选项。

// 无序批量操作:单条失败不影响其他操作,最大化吞吐量
db.users.bulkWrite(
  [
    { updateOne: { filter: { email: "user1@example.com" }, update: { $set: { status: "active" } } } },
    { updateOne: { filter: { email: "user2@example.com" }, update: { $set: { status: "active" } } } },
    { deleteOne: { filter: { email: "user999@example.com" } } } // 假设不存在,失败但不终止
  ],
  { ordered: false }
)

在大规模数据迁移(ETL)、批量同步(从消息队列消费)、批量修复等场景中,bulkWrite 是标准做法。建议在每次 bulkWrite 前将操作按 ordered: false 分批(如每批 1000 条),以平衡内存占用和执行效率。

性能对比示例

// 低效:N 次网络往返
var docs = Array.from({ length: 10000 }, (_, i) => ({ index: i, value: Math.random() }))
docs.forEach(doc => db.benchmark.insertOne(doc)) // 约 10-30 秒

// 高效:单次 bulkWrite(或分批 bulkWrite)
var ops = docs.map(doc => ({ insertOne: { document: doc } }))
db.benchmark.bulkWrite(ops, { ordered: false }) // 约 0.5-2 秒

游标管理 Cursor

MongoDB 的 find 方法返回的是一个游标(Cursor),而非立即将全部结果加载到内存中。这一设计让 MongoDB 在处理海量查询结果时,不会消耗客户端或服务器端的巨量内存。理解游标的生命周期、批处理行为和迭代方式,是编写高性能数据读取代码的基础。

游标的基本遍历

mongosh 中,游标提供了多种遍历方法,适合不同的使用场景:

// 方法 1:forEach 逐条处理
var cursor = db.orders.find({ status: "shipped" })
cursor.forEach(function(doc) {
  printjson(doc.totalAmount)
})

// 方法 2:hasNext / next 手动控制迭代
var cursor = db.users.find()
while (cursor.hasNext()) {
  var doc = cursor.next()
  print(doc.name)
}

// 方法 3:toArray 转换为数组(仅推荐小结果集)
var smallResult = db.users.find({ status: "pending" }).limit(100).toArray()
smallResult.forEach(doc => print(doc.name))

toArray() 会一次性将所有匹配文档加载到客户端内存中,对于结果集未知的查询极具风险,可能导致客户端内存溢出。在处理可能返回大量文档的查询时,应始终使用 forEachhasNext/next 进行流式消费。

batchSize 控制网络包大小

游标默认以 101 条文档为初始批次向客户端返回数据(第一次 getMore 后批次大小增加至约 4MB 限制)。可以通过 batchSize 调整这个行为:

// 增大每批返回数量,减少网络往返(适合网络延迟高、单次处理量大的场景)
db.logs.find({ level: "INFO" }).batchSize(1000).forEach(function(doc) {
  // 处理每条日志
})

// 减小每批大小,降低单次内存占用(适合内存敏感的嵌入式客户端)
db.events.find().batchSize(10)

调整 batchSize 时的注意事项:

  • 批次过大会增加 MongoDB 服务器游标的内存占用,因为游标需要在服务器端缓存完整的批次。
  • 批次过小会增加网络往返次数,在网络延迟高(如跨机房、公网访问)时影响吞吐。
  • 对于分片集群,游标需要从多个分片节点拉取数据再合并,批次大小的影响更为复杂。

游标超时与 noCursorTimeout

默认情况下,MongoDB 游标在客户端不活动的 10 分钟后会被服务器自动关闭,这是为了防止服务器端长期持有内存和文件句柄。但在某些长时间批处理任务中,处理单条文档的时间可能超过 10 分钟,此时需要设置 noCursorTimeout

// 设置游标永不超时(需要尽快消费完成,处理完后必须手动关闭)
var cursor = db.largeCollection.find().noCursorTimeout()
try {
  while (cursor.hasNext()) {
    processDocument(cursor.next())  // 可能每条耗时很久
  }
} finally {
  cursor.close()  // 必须手动关闭,否则会长期占用服务器资源
}

警告noCursorTimeout 游标不会自动释放,如果客户端崩溃或忘记调用 cursor.close(),游标将持续耗尽服务器资源。在生产环境中,更推荐使用基于 _id 的范围查询分批处理(如下),而非依赖永不超时的游标。

基于 _id 的分批读取模式

对于超大数据表的遍历,最健壮的做法是按 _id 范围分页读取,每次记录最后一条 _id,下一次从该 _id 之后继续:

var lastId = null
var batchSize = 1000

while (true) {
  var query = lastId ? { _id: { $gt: lastId } } : {}
  var docs = db.users.find(query).sort({ _id: 1 }).limit(batchSize).toArray()

  if (docs.length === 0) break

  docs.forEach(function(doc) {
    processDocument(doc)
  })

  lastId = docs[docs.length - 1]._id
}

这种方式不依赖服务器端游标状态,天然支持断点续传(程序崩溃后从 lastId 重启即可),也避免了游标超时问题。

null 查询陷阱

null 是 MongoDB 查询中最容易引发困惑的数据类型之一,因为 { field: null } 这个看似简单直接的查询条件,实际上匹配了两种完全不同语义的情况:字段值明确为 null 的文档,以及字段根本不存在的文档。

问题复现

假设 users 集合有以下文档:

db.users.insertMany([
  { name: "张三", phone: "13800138000" },
  { name: "李四", phone: null },
  { name: "王五" }  // 注意:没有 phone 字段
])

执行以下查询:

// 预期:只查到 phone 为 null 的李四
// 实际:查到了李四和王五!
db.users.find({ phone: null })

输出结果:

[
  { _id: ObjectId("..."), name: "李四", phone: null },
  { _id: ObjectId("..."), name: "王五" }
]

这个行为源于 MongoDB 的底层存储规则:BSON 中的 null 类型($type: 10)与字段缺失是不等价的,但 { field: null } 这个查询条件在语义上同时覆盖了"值为 null"和"字段不存在”。

精确查询 null 的正确写法

要精确匹配字段值为 null 且该字段确实存在的文档,需要组合 $type 操作符:

// 只查 phone 字段存在且值为 null 的文档(排除字段不存在的)
db.users.find({ phone: { $type: "null" } })

// 等效写法:同时要求字段存在且值为 null
db.users.find({ phone: { $eq: null, $exists: true } })

精确查询字段不存在的正确写法

// 查 phone 字段完全不存在的文档
db.users.find({ phone: { $exists: false } })

排除 null 和缺失字段

// 查 phone 有实际值的文档(排除 null 和字段缺失)
db.users.find({ phone: { $ne: null, $exists: true } })

// 更简洁的写法($ne null 隐式排除了字段不存在的文档)
db.users.find({ phone: { $ne: null } })

值得注意的是,$ne: null 的语义与 null 刚好相反:它匹配字段存在且值不为 null 的文档,字段不存在的文档会被排除。

实际业务中的最佳实践

在数据库设计阶段就规避 null 的歧义:

// 方案一:Schema Validation 在插入时校验字段必须存在
db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "phone"],
      properties: {
        name: { bsonType: "string" },
        phone: { bsonType: ["string", "null"] }
      }
    }
  }
})

// 方案二:使用更明确的哨兵值替代 null
// 如 phone 未知时使用空字符串 "" 或 "UNKNOWN",而非 null
db.users.updateMany(
  { phone: null },
  { $set: { phone: "" } }
)

在查询代码中,养成对可能为 null 的字段显式检查类型的习惯,避免数据泄漏或统计偏差。

总结

本文系统覆盖了 MongoDB CRUD 核心操作,从 Shell 连接到复杂查询,从单条写入到批量操作,从游标管理到软删除设计,共涵盖十个完整主题。以下是关键要点的系统性回顾,可作为日常开发和问题排查的快速参考手册。

Shell 与连接:优先使用 mongosh,它是基于 Node.js 的现代 Shell 客户端,支持 ES6+ 语法和完善的自动补全。掌握连接字符串的格式,尤其是副本集(replicaSet 参数)和 MongoDB Atlas(mongodb+srv 协议)的连接方式。熟悉 useshow collectionsdb.stats() 等基本命令,是日常开发的起点。

插入操作insertOne 用于单条记录写入,insertMany 用于批量导入。insertManyordered: false 选项在数据同步场景中至关重要,它能保证单条失败不中断整体写入。_id 可以手动指定(业务自然键、UUID、自增序列等),也可以由 MongoDB 自动生成 ObjectId。根据数据重要性选择 Write Concern:"majority" 适用于金融场景,1 是默认通用级别,0 仅用于日志等非关键数据。

查询语法find 配合丰富的操作符可以满足绝大多数查询需求。比较操作符($eq$gt$lt$in)处理范围与集合匹配;逻辑操作符($and$or$not$nor)构建复杂条件组合;数组操作符($all$elemMatch)解决嵌套文档和数组的精准匹配。$elemMatch 是避免数组跨元素匹配陷阱的关键工具。$type 结合 $exists 可处理 Schema-less 下的数据清洗和类型验证。

投影与排序分页:投影在服务器端过滤字段,节省网络带宽;sort 控制结果顺序,需配合索引避免内存排序超限;skip + limit 适合小数据量分页,大数据量务必改用基于 _id 或唯一键的范围查询分页(Keyset Pagination),以保证 O(log n) 的查询性能。

更新操作$set 局部更新、$inc 原子增减、$push/$addToSet/$pull 数组维护、$unset/$rename 字段管理,构成了更新操作的完整工具箱。upsert: true 配合 $setOnInsert 是实现"存在则更新、不存在则创建"业务场景的利器,广泛应用于计数器、签到、配置表等模型。replaceOne 则在文档结构发生颠覆性变化时派上用场。

删除设计:生产环境优先采用软删除(isDeleted 标记 + deletedAt 时间戳),保留数据可恢复性和审计追踪能力。物理删除仅用于明确过期的数据,且应配合 TTL 索引让 MongoDB 自动清理。deleteMany 执行前务必将相同条件先用 find 验证,避免误删范围过大。大批量删除应分批次执行,减轻数据库压力。

批量操作bulkWrite 将多种操作(insert/update/delete/replace)打包为单次网络请求,相比独立调用性能提升 10-100 倍。数据迁移、同步、修复脚本的通用模式是:分批读取数据、在客户端组装 bulkWrite 操作数组、无序方式批量提交。每批建议控制在数百到数千条,平衡内存与吞吐。

游标管理find 返回的是游标而非结果集,流式消费(forEachhasNext/next)避免内存溢出。batchSize 根据网络延迟和内存预算调整。10 分钟默认超时的游标需要通过 noCursorTimeout 处理长耗时任务,但更安全的方式是采用基于 _id 的范围分批读取,天然支持断点续传,不依赖服务器游标状态。

null 陷阱{ field: null } 同时匹配字段值为 null 和字段不存在的文档。精确查询 null 应使用 $type: "null"{ $eq: null, $exists: true }。查询"有实际值的文档"使用 { $ne: null }。Schema Validation 是预防 null 歧义的最佳工程实践。

掌握这些 CRUD 基础后,建议继续深入 MongoDB 的聚合管道(Aggregation Pipeline)、复合索引与覆盖查询、多文档 ACID 事务(Replica Set 中的多文档事务,4.0+ 支持)、以及分片集群的设计与运维。CRUD 是所有高级功能的基石,对其底层行为和性能特征的透彻理解,是构建高性能、可扩展、可维护的 MongoDB 应用的前提。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. 缓存架构演进之路:从单机 Redis 到亿级分布式多级缓存体系
  2. Redis 7.x 重大新特性与架构升级深度解析
  3. Redis 消息队列深度对比:Pub/Sub、Streams 与 Kafka/RabbitMQ 选型指南