Go 基础体系 · 第 65/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go MongoDB 驱动实战:文档建模、查询、事务与索引

本文以 Go 1.26.4、MongoDB Server 8.0、MongoDB Go Driver v2 为基线。v2 驱动的导入路径为 go.mongodb.org/mongo-driver/v2/...,不要把 v1 示例直接复制过来。MongoDB 以 BSON 文档为原子读写边界,适合围绕聚合整体读取的数据;“字段灵活”不代表无需 schema,也不代表关系表逐张改成 collection 就能得到合理模型。

1. 文档、collection 与聚合边界

BSON 文档由有序字段、标量、子文档和数组组成,单文档最大 16 MiB。collection 类似文档集合,但默认不强制所有文档具有相同字段。建模先从业务原子性开始:文章正文、状态和当前版本若总是一起读取和更新,可以放在一个文档;无限增长的评论、审计事件应拆出,否则数组不断搬移并逼近大小上限。

嵌入适合共同读取、生命周期一致、数量有界的数据;引用适合独立更新、被多处共享或数量无界的数据。引用没有自动 join,$lookup 虽可服务端关联,但会增加内存、网络和执行计划成本。重复少量展示字段是可接受的反规范化,但必须定义谁负责更新以及允许多旧。

2. BSON 类型与 Go 模型边界

BSON 有 ObjectID、Decimal128、日期、二进制等 JSON 没有的类型。持久层模型与 HTTP DTO 分开,避免把 ObjectID 的二进制语义、Decimal128 或内部 _id 泄露到外部契约。时间以 BSON UTC datetime 存储,业务时区另存标识;金额使用最小货币单位整数或 Decimal128,不用 float64 表示精确小数。

type ArticleDocument struct {
	ID        bson.ObjectID `bson:"_id,omitempty"`
	ArticleID string        `bson:"article_id"`
	TenantID  string        `bson:"tenant_id"`
	Title     string        `bson:"title"`
	Tags      []string      `bson:"tags"`
	Status    string        `bson:"status"`
	Version   int64         `bson:"version"`
	UpdatedAt time.Time     `bson:"updated_at"`
}

所有序列化字段显式写 bson 标签。omitempty 会改变零值是否落库,使用前要确认缺失字段与零值的迁移语义。动态字段可用 bson.Raw 延迟解析,但稳定领域模型优先强类型结构,避免把拼写错误变成新字段。

3. Client 生命周期、连接与启动探活

mongo.Client 并发安全并管理每个服务器的连接池,应在进程启动时连接、Ping 后长期复用,关闭阶段 Disconnect。连接 URI 解析成功不代表服务器可达。v2 驱动的 Connect 不接收 context,网络选择和 Ping 等操作仍由调用 context 控制。

client, err := mongo.Connect(options.Client().
	ApplyURI(uri).
	SetMaxPoolSize(64).
	SetMinPoolSize(4).
	SetMaxConnIdleTime(5 * time.Minute).
	SetServerSelectionTimeout(2 * time.Second))
if err != nil {
	return fmt.Errorf("connect mongodb: %w", err)
}

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := client.Ping(ctx, readpref.Primary()); err != nil {
	client.Disconnect(context.Background())
	return fmt.Errorf("ping mongodb: %w", err)
}

池上限乘实例数不得超过集群连接预算。池等待升高可能源于慢查询、事务过长或 cursor 未关闭,不应直接扩池。生产关闭应给 Disconnect 独立短预算,因为请求 context 此时通常已取消。

4. Context、服务器选择与超时层次

一次操作包括服务器选择、等待池连接、建连、发送、服务端执行和读取结果。context deadline 限制整条路径;客户端 socket timeout、server selection timeout 与服务端 maxTimeMS 分别控制不同阶段。只配 context 仍需验证驱动与服务端在取消后何时停止工作。

不要在 repository 中用 context.Background() 丢掉请求取消。批量任务应从作业 context 派生每批预算。超时后的写入结果可能未知:服务器可能已提交,但响应未到达。幂等业务键、唯一索引和写后核对比盲目重试安全;重复 InsertOne 可通过唯一键识别已完成状态。

5. Insert、FindOne 与错误分类

查询过滤器中的租户条件不能由调用方可选,否则容易越权。FindOne().Decode 才返回查询/解码错误;mongo.ErrNoDocuments 是正常未找到,应转换为领域错误。其他错误保留操作上下文并允许上层用 errors.Is/As 分类。

func (r *Repository) FindPublished(
	ctx context.Context, tenantID, articleID string,
) (ArticleDocument, error) {
	filter := bson.D{
		{Key: "tenant_id", Value: tenantID},
		{Key: "article_id", Value: articleID},
		{Key: "status", Value: "published"},
	}
	var article ArticleDocument
	err := r.articles.FindOne(ctx, filter).Decode(&article)
	if errors.Is(err, mongo.ErrNoDocuments) {
		return ArticleDocument{}, ErrNotFound
	}
	if err != nil {
		return ArticleDocument{}, fmt.Errorf("find article %q: %w", articleID, err)
	}
	return article, nil
}

写入前做领域校验,数据库端再用 JSON Schema validator 防止绕过应用的坏数据。重复键错误应映射为冲突,而不是字符串匹配错误消息;使用驱动导出的错误检查能力并为目标驱动版本写集成测试。

6. Cursor 生命周期、批量读取与背压

Find 返回 cursor,持有服务端游标和连接相关资源。成功后立即 defer cursor.Close(ctx),循环 Next、逐项 Decode,最后检查 cursor.Err()cursor.All 简洁但会把全部结果装入内存,只用于有严格结果上限的查询。

cursor, err := collection.Find(ctx, filter,
	options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetLimit(100))
if err != nil {
	return nil, fmt.Errorf("find articles: %w", err)
}
defer cursor.Close(ctx)

articles := make([]ArticleDocument, 0, 100)
for cursor.Next(ctx) {
	var article ArticleDocument
	if err := cursor.Decode(&article); err != nil {
		return nil, fmt.Errorf("decode article: %w", err)
	}
	articles = append(articles, article)
}
if err := cursor.Err(); err != nil {
	return nil, fmt.Errorf("iterate articles: %w", err)
}

流式消费时不要在 cursor 循环内执行无界 HTTP 调用,否则服务端游标长期存活。可分批读入有界缓冲后处理,并让下游背压决定是否继续。projection 只取所需字段,能减少网络与解码,但覆盖查询是否成立取决于索引和 explain。

7. 单文档原子更新与乐观并发

单文档写操作原子。用 $set$inc$unset 等更新特定字段,避免先读后整文档 Replace 覆盖并发修改。把期望版本加入过滤器,并在同一更新中 $inc version,可实现乐观并发控制。

filter := bson.D{
	{Key: "tenant_id", Value: tenantID},
	{Key: "article_id", Value: articleID},
	{Key: "version", Value: expectedVersion},
}
update := bson.D{{Key: "$set", Value: bson.D{
	{Key: "title", Value: title},
	{Key: "updated_at", Value: time.Now().UTC()},
}}, {Key: "$inc", Value: bson.D{{Key: "version", Value: 1}}}}
result, err := collection.UpdateOne(ctx, filter, update)
if err != nil {
	return fmt.Errorf("update article %q: %w", articleID, err)
}
if result.MatchedCount == 0 {
	return ErrConcurrentUpdate
}

数组更新用 positional operator、array filters 或管道更新,并为数组长度设业务上限。upsert 在并发下仍需唯一索引兜底,否则两个请求可能创建重复逻辑实体。

8. Read Concern、Write Concern 与读偏好

write concern 决定写入等待多少节点确认,read concern 决定读取隔离/持久化视图,read preference 决定从主还是从节点读。默认设置并非所有业务都合适。关键写通常要求 majority;从 secondary 读取可能看到旧数据,适合报表或允许陈旧的页面,不适合刚写后立刻读取的强会话假设。

majority 也不等于跨地域线性一致,网络分区和读偏好仍影响观察结果。把这些选项集中配置或按明确用例设置,禁止 repository 随意更改。降低 concern 换吞吐前必须量化可丢写窗口、恢复流程和用户影响。

9. 多文档事务的生命周期与成本

事务需要副本集或分片集群。它提供多文档原子性,但会延长快照、锁和连接占用,并可能因写冲突、主节点切换而重试。优先把必须原子的状态放进单个聚合文档;事务用于真正跨文档不变量,不用于模仿任意关系模型。

session, err := client.StartSession()
if err != nil {
	return fmt.Errorf("start mongodb session: %w", err)
}
defer session.EndSession(context.Background())

_, err = session.WithTransaction(ctx, func(txCtx context.Context) (any, error) {
	if _, err := articles.UpdateOne(txCtx, articleFilter, articleUpdate); err != nil {
		return nil, fmt.Errorf("update article: %w", err)
	}
	if _, err := outbox.InsertOne(txCtx, event); err != nil {
		return nil, fmt.Errorf("insert outbox event: %w", err)
	}
	return nil, nil
})
if err != nil {
	return fmt.Errorf("commit article transaction: %w", err)
}

驱动可能重试带特定标签的事务回调,因此回调必须可重放,不能直接发送邮件或调用支付。事务提交超时可能结果未知,使用业务 ID 核对。限制事务时长、操作数和文档规模,并监控 abort 原因。

10. 索引结构、前缀与 ESR 原则

MongoDB 常用 B-tree 索引,索引条目按字段值排序并指向文档。复合索引字段顺序决定可支持的查询前缀。经验 ESR 是 Equality、Sort、Range:等值字段通常在前,随后排序,再放范围;但应以真实选择性与 explain 验证。

db.articles.createIndex(
  {tenant_id: 1, status: 1, updated_at: -1},
  {name: "tenant_status_updated"}
)
db.articles.createIndex(
  {tenant_id: 1, article_id: 1},
  {name: "uniq_tenant_article", unique: true}
)
db.articles.createIndex(
  {tenant_id: 1, slug: 1},
  {partialFilterExpression: {status: "published"}}
)

每个索引增加写放大、内存和磁盘。数组字段生成 multikey 索引,组合多个数组会受限制并可能产生大量索引项。唯一索引对缺失/null 的语义要实测;partial index 只有查询包含相容条件时才可用。TTL index 由后台线程近似删除,不是准时任务。

11. Explain 与查询形状诊断

不要以“建了索引”代替执行计划。explain("executionStats") 观察获胜计划、totalKeysExaminedtotalDocsExamined、返回数、排序阶段和执行时间。扫描文档远大于返回数通常表示选择性或索引顺序不匹配;内存排序可能需要调整索引或限制结果。

db.articles.find({
  tenant_id: "t-42",
  status: "published",
  updated_at: {$lt: ISODate("2026-08-31T00:00:00Z")}
}).sort({updated_at: -1}).limit(50).explain("executionStats")

计划缓存按查询形状复用,数据分布变化后旧计划可能不理想。诊断同时看 profiler/慢查询日志、currentOp、锁、WT cache、磁盘延迟与连接池等待。生产 explain 避免高成本 allPlansExecution 滥用,先在副本或采样流量验证。

12. 聚合管道、分页和资源边界

聚合管道逐阶段变换文档。尽早 $match,尽早 $project 去掉大字段,在可用索引上 $sort$group$sort$lookup 可能成为阻塞阶段并占大量内存;allowDiskUse 只是避免失败,会转为磁盘 I/O,不是性能修复。

深分页不要用巨大 skip,因为服务器仍需扫描跳过。使用稳定排序键游标,例如 (updated_at, _id),下一页过滤“小于上一页最后组合键”。排序必须包含唯一 tie-breaker,页面间并发写入仍可能造成业务上可接受的移动;需要一致快照则付出会话/事务成本。

13. Schema 校验、迁移与测试

collection validator 对关键字段类型、必填和枚举做最低保护;应用仍负责复杂业务校验。schema 演进采用 expand/contract:先让读取兼容旧新格式,再写新格式,后台有界回填,最后收紧 validator 和删除旧字段。文档保存 schema_version 可让迁移显式,但不要让每次读取永久承担无限历史分支。

单元测试可验证 filter/update 构造与 DTO 转换;事务、唯一索引、read concern、cursor 取消必须在真实副本集集成测试。测试容器固定 MongoDB 8.0 镜像,等待探活并在测试后关闭。故障测试覆盖主节点切换、重复键、网络超时和未知提交结果。

go test -count=1 ./...
go test -race ./...
docker run --rm -p 27017:27017 mongo:8.0 --replSet rs0 --bind_ip_all
mongosh --eval 'rs.initiate()'
mongodump --uri "$MONGODB_URI" --archive=backup.archive --gzip
mongorestore --uri "$RESTORE_URI" --archive=backup.archive --gzip

14. 性能、可观测性与安全

指标包括命令类型延迟、server selection、池 checkout、错误标签、返回文档数和解码失败;服务端观察 opcounters、WiredTiger cache、page fault、复制延迟、磁盘队列和慢查询。日志记录 query shape、collection 与 trace ID,不记录完整过滤值、URI 或文档正文。

启用 TLS,使用 SCRAM 或平台身份认证,按 database/collection 分配最小角色。应用账号不应拥有建用户、关闭服务器或任意管理权限。网络仅向应用子网开放,秘密从管理系统注入并轮换。字段级加密能保护特定数据,但会限制查询和索引能力,必须在建模阶段决定。

15. 备份、复制与生产部署边界

副本集提供可用性,不是备份:误删和坏写会复制。备份方案可用云快照、文件系统快照或 MongoDB 工具,必须保证时间点一致性并记录 oplog 窗口。分片集群备份需协调 config server 和各 shard。定期恢复到隔离环境,验证数据、索引、用户权限以及 RPO/RTO。

生产至少三个投票节点,跨故障域布置并监控复制延迟;仲裁节点不保存数据,不能提高读取容量。分片只在单副本集容量或吞吐证明确有需要时引入,shard key 必须兼顾分布、路由和单调写热点。上线前检查连接总预算、索引构建影响、磁盘水位、备份恢复和版本兼容矩阵。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。