MongoDB 文档数据库入门
MongoDB 文档数据库入门
前言
MongoDB 是面向文档(document)的数据库。它把数据保存为 BSON 文档,并以集合(collection)组织文档。BSON 是 JSON 的二进制扩展:它保留 JSON 的对象和数组表达方式,同时支持 ObjectId、日期、二进制数据、整数类型等更适合数据库存储的类型。
MongoDB 的灵活文档结构适合嵌套数据较多、字段会逐步演进,或读取时希望一次取回完整聚合对象的场景,例如内容系统、商品目录、事件记录和用户配置。但“文档灵活”不等于“不需要模型设计”:查询方式、索引、字段约束、数据增长和一致性需求仍然决定了集合应如何设计。
本文以 mongosh 为交互式命令行为主线,目标是建立数据库、集合、文档、CRUD、索引和聚合的基础能力;最后给出 Go 服务接入 MongoDB 的最小边界。复制集、分片、变更流、Atlas Search 和复杂事务属于后续专题。
先学会读 mongosh 命令语法
mongosh 使用 JavaScript 风格的对象和方法调用。后文的语法模板不是可以原样复制的文本,而是描述“在哪个对象上调用什么方法、每个参数是什么结构”。
| 写法 | 含义 | 实际书写方式 |
|---|---|---|
db.collection.method(...) | 在当前数据库的一个集合上调用方法 | 把 collection 换为实际集合名,例如 db.users.find(...) |
filter、document、options | 参数占位符 | 替换为 JavaScript/BSON 对象,例如 { age: { $gte: 18 } } |
{ field: value } | 文档或条件对象 | 冒号左边是字段名,右边是字段值或运算符对象 |
[ ... ] | 数组 | 方括号中的元素按顺序组成列表,例如批量文档或聚合阶段 |
$operator | MongoDB 操作符 | $ 不是变量插值;它标识 $gte、$set、$match 这类数据库操作符 |
"nested.field" | 点号路径 | 用字符串指向嵌套对象中的字段,例如 "profile.city" |
例如 db.users.find({ age: { $gte: 18 } }, { name: 1, _id: 0 }) 有三个层次:db.users 是集合,find 是查询方法,第一个对象是筛选条件,第二个对象是投影。age 是字段,$gte 是“大于等于”操作符,18 是比较值;第二个对象表示只返回 name,并排除默认的 _id。
阅读结构
| 部分 | 要解决的问题 |
|---|---|
| 基础对象 | 数据库、集合、文档之间是什么关系? |
| CRUD | 如何插入、查询、更新和删除文档? |
| 索引与聚合 | 如何让查询可扩展,并完成统计转换? |
| 建模 | 何时嵌入文档,何时保存引用? |
| Go 接入 | 如何复用 Client、使用 Context 并处理结果? |
一、连接 MongoDB 与 mongosh
1. 确认服务与 Shell
本地服务端进程通常是 mongod,交互式 Shell 是 mongosh:
mongod --version
mongosh --version本地默认连接字符串通常为:
mongodb://127.0.0.1:27017连接本地实例:
mongosh "mongodb://127.0.0.1:27017"若目标启用了认证,应使用应用专用账户和连接字符串中的认证信息;不要把真实密码直接写进 Git 仓库或长期保留在 shell 历史中。云数据库、Docker 和本地安装的启动方式不同,但 MongoDB 客户端使用的核心都是连接字符串。
2. Shell 中的数据库与集合命令
mongosh 的数据库选择与查看命令语法如下:
show dbs
use databaseName
db
show collectionsuse 后跟数据库名称,不需要引号;show dbs 和 show collections 是 Shell 辅助命令,而 db 表示当前数据库对象。下面以 tutorial_db 为例:
show dbs
use tutorial_db
db
show collections| 命令 | 含义 |
|---|---|
show dbs | 查看当前有数据的数据库 |
use tutorial_db | 切换当前数据库;数据库尚不存在时只切换上下文 |
db | 显示当前数据库对象 |
show collections | 查看当前数据库的集合 |
MongoDB 通常在第一次向集合写入文档时创建集合,并在首次实际写入数据时创建数据库。因此执行 use tutorial_db 后,尚未插入数据时,show dbs 未必能看到它。
二、先建立 MongoDB 的数据模型
MongoDB 的对象层次可以理解为:
MongoDB 部署
└── Database:例如 tutorial_db
└── Collection:例如 users、articles
└── Document:一条 BSON 记录
├── _id
├── 普通字段
├── 嵌套对象
└── 数组示例文档:
{
_id: ObjectId("..."),
name: "张三",
email: "zhangsan@example.com",
profile: {
city: "上海",
level: 3
},
tags: ["go", "mongodb"],
createdAt: ISODate("2026-08-16T00:00:00Z")
}1. 文档、集合与关系型表的对应
| MongoDB | 关系型数据库中的近似概念 | 说明 |
|---|---|---|
| Database | Database | 数据库命名空间 |
| Collection | Table | 同类文档集合,但字段不必完全一致 |
| Document | Row | 一条 BSON 记录,可包含嵌套对象和数组 |
| Field | Column | 文档字段,可按需增加 |
_id | Primary Key | 每个文档必须唯一;默认使用 ObjectId |
| Index | Index | 用于加速查询、排序和唯一性约束 |
“近似”不表示两者完全相同。关系型表通常先定义列和约束;MongoDB 集合允许不同文档拥有不同字段。应用仍应维护清晰的数据契约,例如使用 Go 结构体、JSON Schema 校验或写入层校验来避免字段无限漂移。
2. _id 与 ObjectId
每个文档都必须有唯一的 _id。未显式提供时,MongoDB 会自动生成 ObjectId:
db.users.insertOne({ name: "张三" })返回结果中的 insertedId 就是新文档的 _id。也可以由应用提供字符串、UUID 或业务编号作为 _id,但必须保证唯一性。使用默认 ObjectId 时,查询单个文档应构造相同类型:
db.users.findOne({ _id: ObjectId("64f000000000000000000001") })把字符串直接与 ObjectId 比较不会自动转换,因此会查不到数据。
3. 文档灵活不等于没有约束
以下两个文档可以存在于同一个集合:
{ name: "张三", city: "上海" }
{ name: "李四", phones: ["13800000000"], vip: true }这使逐步演进字段变得方便,但也意味着查询、索引和业务代码必须处理字段缺失、类型不一致和历史数据兼容。稳定的核心字段应尽早统一命名、类型和含义;对于必须满足的写入规则,可使用集合校验或在服务层进行验证。
三、插入文档:Create
1. 插入一条文档
插入命令的语法为:
db.collection.insertOne(document, options)
db.collection.insertMany([document1, document2, ...], options)db.collection 的 db 是当前数据库对象,collection 是目标集合名称,insertOne 只接收一个待写入文档。文档由若干 字段: 值 对构成,逗号分隔;键是字段名,值可为字符串、数字、布尔值、数组、嵌套对象、日期等 BSON 类型。未提供 _id 时,服务端会自动生成 ObjectId;显式提供时必须保证唯一。
options 是可选的控制参数,不写就是使用默认行为。insertOne 成功后返回确认状态和 insertedId;insertMany 的第一个参数则必须是文档数组,每个数组元素都是一条独立文档。它不是“一个包含多个字段的大对象”。
下面再向 users 集合插入一条用户文档:
use tutorial_db
db.users.insertOne({
name: "张三",
email: "zhangsan@example.com",
age: 28,
profile: { city: "上海", level: 3 },
tags: ["go", "mongodb"],
createdAt: new Date()
})insertOne 返回确认结果和 insertedId。如果 users 集合不存在,首次插入会创建它。
2. 批量插入
insertMany 的第一个参数必须是文档数组;默认 ordered: true,遇到错误时会停止后续插入。写成 { ordered: false } 时,ordered 是选项字段,false 是布尔值,表示遇到某条失败后继续尝试后续文档;调用仍会报告失败项,因此导入程序必须处理错误明细。
db.users.insertMany([
{ name: "李四", email: "lisi@example.com", age: 25, tags: ["go"] },
{ name: "王五", email: "wangwu@example.com", age: 32, tags: ["redis", "mongodb"] }
])批量插入适合导入已验证的数据。若集合存在唯一索引,其中一条文档冲突时,具体行为还受 ordered 选项影响;导入任务应记录失败项,而不是只依赖终端输出。
四、查询文档:Read
1. findOne 与 find
查询命令的语法为:
db.collection.findOne(filter, options)
db.collection.find(filter, projection)
.sort(sortSpec)
.skip(offset)
.limit(count)filter 是“字段名到匹配规则”的对象,空对象 {} 表示匹配全部文档;projection 决定返回字段;sortSpec 中 1 表示升序,-1 表示降序。findOne 返回一个文档或 null,find 返回可继续调用排序和分页方法的游标。
find(filter, projection) 的两个参数位置不能调换。第一个对象决定“找哪些文档”,第二个对象决定“每个命中文档带回哪些字段”。sort、skip、limit 是对 find 返回的游标继续调用的方法:sort 接收“字段到方向”的对象,skip 接收非负跳过数量,limit 接收最多返回数量。通常先排序,再跳过,再限制,才能得到稳定页面。
示例:
db.users.findOne({ email: "zhangsan@example.com" })
db.users.find({ age: { $gte: 18 } })
db.users.find({ tags: "go" })findOne 返回第一个匹配文档或 null;find 返回游标(cursor),Shell 会分批读取结果。数组字段使用等值条件时,只要数组包含该值即可匹配,因此 { tags: "go" } 能匹配 ["go", "mongodb"]。
2. 筛选、投影、排序和分页
筛选文档使用“字段名到条件表达式”的对象。常用形式为:
{ field: value }
{ field: { $operator: value } }
{ "nested.field": value }
{ $and: [condition1, condition2] }
{ $or: [condition1, condition2] }{ field: value } 是等值条件;{ field: { $operator: value } } 把字段的规则写进一个操作符对象;{ "nested.field": value } 通过点号路径进入嵌套文档。$and、$or 的值必须是条件对象数组,而不是单个对象。
投影字段为 1 表示包含、为 0 表示排除;除 _id 外,不能在同一投影中混用包含和排除。排序和限制属于游标操作,因此应在 find 后链式调用。
示例:
db.users
.find(
{ age: { $gte: 18, $lt: 30 } },
{ name: 1, email: 1, age: 1 }
)
.sort({ age: -1, _id: 1 })
.limit(20)第二个参数是投影(projection)。1 表示返回字段;除非显式写 _id: 0,_id 默认仍会返回:
db.users.find({}, { name: 1, _id: 0 })常见筛选运算符:
| 目标 | 示例 |
|---|---|
| 大于、范围 | { age: { $gt: 18, $lte: 30 } } |
| 包含任一候选值 | { city: { $in: ["上海", "北京"] } } |
| 不等于 | { status: { $ne: "deleted" } } |
| 嵌套字段 | { "profile.city": "上海" } |
| 同时满足多个条件 | { $and: [{ age: { $gte: 18 } }, { tags: "go" }] } |
| 至少满足一个条件 | { $or: [{ city: "上海" }, { city: "北京" }] } |
skip 可以实现简单页码分页:
db.users.find({}).sort({ _id: 1 }).skip(40).limit(20)但页码越深,skip 需要跳过的记录越多。大数据量列表通常使用基于有序字段或 _id 的游标分页,并配合相应索引:
db.users
.find({ _id: { $gt: ObjectId("64f000000000000000000001") } })
.sort({ _id: 1 })
.limit(20)五、更新文档:Update
更新操作由过滤条件和更新表达式组成。不要把普通对象直接作为第二个参数传给 updateOne,除非明确要使用替换语义;常规局部更新应使用 $set、$inc、$push 等更新操作符。
更新命令的语法为:
db.collection.updateOne(filter, update, options)
db.collection.updateMany(filter, update, options)
db.collection.replaceOne(filter, replacement, options)filter 决定目标文档,update 通常由 $set、$inc 等更新操作符组成;replaceOne 则以完整 replacement 替换旧文档内容,使用时需格外谨慎。第三个参数写成 { upsert: true } 时,表示无匹配则插入。
updateOne 只更新一条匹配文档,updateMany 更新所有匹配文档;两者的第一个参数都是过滤条件,第二个参数都是“怎么改”。例如 $set: { "profile.city": "杭州" } 只设置这个嵌套字段,不会替换整个 profile 对象;$inc: { level: 1 } 在旧数值上加 1。常规更新参数必须以 $set 等更新操作符开头;若想用普通对象整体替换,应明确调用 replaceOne。
replaceOne(filter, replacement, options) 的 replacement 必须是一份完整的新文档:旧文档中没有出现在 replacement 里的字段会被移除,而 _id 仍必须保持兼容。因此,字段级修改优先使用更新操作符。
示例:
db.users.updateOne(
{ email: "zhangsan@example.com" },
{
$set: { "profile.city": "杭州" },
$inc: { "profile.level": 1 },
$addToSet: { tags: "backend" }
}
)| 操作符 | 作用 |
|---|---|
$set | 设置或覆盖指定字段 |
$unset | 删除字段 |
$inc | 对数值字段增减 |
$push | 向数组追加元素,可重复 |
$addToSet | 向数组添加元素,但避免重复 |
$pull | 从数组移除匹配元素 |
更新多条文档:
db.users.updateMany(
{ tags: "go" },
{ $set: { active: true } }
)需要“没有则创建,有则更新”时使用 upsert:
db.settings.updateOne(
{ key: "site_name" },
{ $set: { value: "Go Tutorials", updatedAt: new Date() } },
{ upsert: true }
)六、删除文档:Delete
删除命令的语法为:
db.collection.deleteOne(filter, options)
db.collection.deleteMany(filter, options)
db.collection.drop()deleteOne 只删除第一条匹配文档,deleteMany 删除全部匹配文档;二者第一个参数 filter 的写法与 find 完全相同。deleteMany({}) 的空条件表示“匹配所有文档”。drop() 没有过滤条件,因为它作用于整个集合,同时移除集合数据和索引。先用同一个 filter 执行 find 确认范围,再执行删除命令。
示例:
db.users.deleteOne({ email: "wangwu@example.com" })
db.users.deleteMany({ active: false })deleteMany({}) 会删除集合中的全部文档,适合可丢弃的本地测试数据,不应在未确认过滤条件的环境执行。删除文档不会删除集合本身;若确实需要删除整个集合:
db.users.drop()drop 同时删除集合的数据和索引,通常只用于测试、重建或明确的数据迁移流程。
七、索引:让查询条件与排序可以扩展
MongoDB 会自动为 _id 创建唯一索引。其他高频筛选、排序、关联查询字段通常需要应用显式建立索引。
索引命令的语法为:
db.collection.createIndex({ field1: 1, field2: -1 }, options)
db.collection.getIndexes()
db.collection.dropIndex(indexNameOrKeySpec)
db.collection.find(filter).explain("executionStats")createIndex 的第一个对象是“索引键规格”:对象中每个字段都是索引键,字段出现的先后顺序就是复合索引的键顺序。{ "profile.city": 1, age: -1 } 表示先按城市、再按年龄建立索引;1 与 -1 分别是正向与反向索引方向。第二个 options 对象可写 { unique: true } 或 { name: "idx_users_city_age" }。
getIndexes() 没有参数,返回集合索引定义;dropIndex(indexNameOrKeySpec) 接收索引名称字符串或同样的键规格;find(filter).explain("executionStats") 是先构造查询、再请求其执行计划。executionStats 会执行查询并返回统计信息,适合验证索引是否与查询模式匹配。
示例:
db.users.createIndex({ email: 1 }, { unique: true })
db.users.createIndex({ "profile.city": 1, age: -1 })
db.users.getIndexes()第一个索引保证 email 唯一;第二个是复合索引,适合首先按城市筛选、再按年龄排序的查询。索引字段顺序很重要:复合索引不是任意字段组合都同样高效,应从真实查询的过滤、排序与选择性出发设计。
使用 explain 查看查询计划:
db.users
.find({ "profile.city": "上海" })
.explain("executionStats")重点关注:
| 指标或阶段 | 含义 |
|---|---|
IXSCAN | 使用索引扫描 |
COLLSCAN | 全集合扫描;大集合中应重点检查 |
totalKeysExamined | 扫描的索引键数量 |
totalDocsExamined | 读取的文档数量 |
nReturned | 最终返回的文档数量 |
索引能提升读性能和唯一性检查,但会增加写入维护成本、占用内存和磁盘。不要为每个字段都建立索引;应先根据慢查询、关键接口与执行计划验证需求。
八、聚合管道:在数据库中完成分组和转换
aggregate 接收一个由阶段组成的数组。语法为:
db.collection.aggregate([
{ $stage1: { ... } },
{ $stage2: { ... } }
], options)aggregate 的第一个参数是数组,数组中的每个元素都是一个阶段对象;数组顺序就是执行顺序,不能交换。阶段对象通常只有一个以 $ 开头的键,例如 { $match: { status: "published" } }:$match 是阶段名,其值是该阶段的配置对象。options 是可选的第二个参数。
每一阶段接收上阶段输出的文档流并继续转换。常见阶段的输入输出关系如下:
假设文章文档包含 status、authorId 和 views,统计已发布文章的作者阅读量:
db.articles.aggregate([
{ $match: { status: "published" } },
{
$group: {
_id: "$authorId",
articleCount: { $sum: 1 },
totalViews: { $sum: "$views" }
}
},
{ $sort: { totalViews: -1 } },
{
$project: {
_id: 0,
authorId: "$_id",
articleCount: 1,
totalViews: 1
}
},
{ $limit: 10 }
])| 阶段 | 作用 |
|---|---|
$match | 尽早筛选文档,通常应尽可能放在管道前部 |
$group | 按字段或表达式分组,并计算 $sum、$avg、$max 等 |
$sort | 对中间结果排序 |
$project | 控制输出字段,重命名或计算字段 |
$limit | 限制结果量 |
$group 中的 _id 不是原文档的主键含义,而是“本次聚合用什么作为分组键”。_id: "$authorId" 中的 "$authorId" 是字段引用,意思是取每条输入文档的 authorId 值来分组;articleCount: { $sum: 1 } 中的 1 表示每个输入文档计一次;totalViews: { $sum: "$views" } 则累加每条文档的 views 字段。$project 的 1 表示保留字段,0 表示排除字段,authorId: "$_id" 则把前一阶段的分组键改名输出。
聚合不是免成本的“数据库内循环”。$match、$sort、$lookup 等阶段是否能利用索引,会直接影响性能;应先减少参与管道的文档,再进行分组或复杂转换。
九、数据建模:嵌入还是引用
MongoDB 的关键设计决策不是“是否建表”,而是相关数据应嵌入同一文档,还是使用不同集合并保存引用。
1. 适合嵌入的情况
{
_id: ObjectId("..."),
title: "MongoDB 入门",
author: {
id: "user-1001",
name: "张三"
},
tags: ["database", "mongodb"]
}当嵌入数据与主文档总是一起读取、数量有明确上限、更新频率低时,嵌入通常能减少一次额外查询。
2. 适合引用的情况
{
_id: ObjectId("..."),
title: "MongoDB 入门",
authorId: ObjectId("...")
}用户、订单、评论等独立增长且会被多个对象复用的数据,更适合存入独立集合,通过 authorId 等字段引用。引用不代表必须每次使用 $lookup;许多服务会按访问模式拆分查询,或保留少量冗余的展示字段。
| 判断问题 | 倾向嵌入 | 倾向引用 |
|---|---|---|
| 是否总是与主文档一起读取 | 是 | 否 |
| 子数据是否有固定且较小的上限 | 是 | 否,可能无限增长 |
| 子数据是否被多个主文档共享 | 否 | 是 |
| 子数据是否需要独立频繁更新 | 否 | 是 |
文档不能无限增长。无上限评论数组、日志数组、历史记录数组不应持续追加到同一文档;应拆分为独立集合或按时间分桶。
3. 用集合校验维持写入契约
MongoDB 不要求预先声明固定表结构,但可以为关键集合设置校验。集合创建的语法为:
db.createCollection("collectionName", {
validator: {
$jsonSchema: { ... }
}
})validator 指定写入校验规则,$jsonSchema 使用 BSON 类型和字段约束描述文档契约。以下示例再为 articles 集合声明必填字段:
db.createCollection("articles", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["title", "authorId", "createdAt"],
properties: {
title: { bsonType: "string", minLength: 1 },
authorId: { bsonType: "objectId" },
createdAt: { bsonType: "date" }
}
}
}
})校验适合防止明显错误进入数据库,但不能代替服务层的权限校验、业务规则和跨集合一致性控制。
十、事务与一致性边界
单个文档的写入和更新是原子的。将彼此强相关的数据设计在同一文档中,通常可以用单文档原子性解决一致性问题。
跨多个文档或集合需要“全部成功或全部失败”时,MongoDB 支持多文档事务,但事务依赖复制集或分片集群,单机独立 mongod 环境不具备完整条件。事务会增加锁定、重试和运行时间成本,应先考虑是否能通过数据模型把强一致更新收敛到单个文档。
十一、Go 服务中的连接提示
MongoDB 官方 Go Driver 当前主版本使用 go.mongodb.org/mongo-driver/v2。安装依赖:
go get go.mongodb.org/mongo-driver/v2/mongo应用应在启动阶段创建一个 mongo.Client 并长期复用,而不是为每个 HTTP 请求重新连接:
package data
import (
"context"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
)
func Connect(uri string) (*mongo.Client, error) {
client, err := mongo.Connect(options.Client().ApplyURI(uri))
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx, readpref.Primary()); err != nil {
_ = client.Disconnect(context.Background())
return nil, err
}
return client, nil
}
func FindUser(ctx context.Context, collection *mongo.Collection, email string) (bson.M, error) {
var user bson.M
err := collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
return user, err
}| 原则 | 原因 |
|---|---|
复用 mongo.Client | Client 管理连接池,重复创建会增加连接开销 |
每次数据库调用传递 context.Context | 让超时、取消和链路信息向下传播 |
区分 mongo.ErrNoDocuments | FindOne 未找到不是普通内部错误 |
启动时 Ping | 尽早发现连接字符串、认证或网络配置错误 |
关闭服务时 Disconnect | 有序释放客户端资源 |
业务层不应直接把数据库文档原样作为 HTTP 响应。通常由 Repository 负责 MongoDB 查询与 BSON 映射,Service 负责业务规则,Controller 负责 HTTP 输入和输出。
十二、日常排查命令
show dbs
use tutorial_db
show collections
db.users.countDocuments({})
db.users.findOne()
db.users.getIndexes()
db.users.find({ "profile.city": "上海" }).explain("executionStats")
db.stats()排查时先确认连接的数据库和集合,再确认过滤条件的字段类型是否正确,最后查看索引与执行计划。最常见的问题不是 MongoDB “没有数据”,而是连接到了错误数据库、ObjectId 与字符串类型不一致、字段路径写错,或查询条件没有索引。
总结
MongoDB 以 BSON 文档保存数据,以集合组织文档。学习路径可以概括为:
- 使用
mongosh连接实例,理解数据库、集合、文档和_id; - 用
insertOne、find、updateOne、deleteOne完成 CRUD; - 用筛选条件、投影、排序和限制控制读取结果;
- 为高频过滤与排序建立经过验证的索引,并通过
explain("executionStats")检查执行计划; - 使用聚合管道完成筛选、分组、排序和结果转换;
- 按访问模式选择嵌入或引用,并为关键集合维护数据契约;
- 在 Go 服务中复用 Client,并把 Context 传给每次数据库调用。
MongoDB 的灵活性来自文档模型,但性能和可维护性仍来自明确的查询模式、受控的数据增长、合适的索引和清晰的应用层边界。
