MongoDB
一、是什么
先判断文档数据库是不是正确答案
MongoDB 最有价值的地方,不是“字段可以随便加”,而是把一个经常一起读取、一起修改、一起演进的业务聚合保存成 BSON 文档。订单概要、收货地址快照、有限数量的商品项可以在一次读取中返回;单文档修改又天然原子,不需要应用在多张表之间拼接半成品状态。文档模型一旦顺着查询和写入边界设计,迭代速度和局部一致性都很好。
如果核心工作负载依赖大量临时组合的多表关联、跨聚合强约束、复杂报表 SQL,或者每次查询都要从任意维度扫描全量数据,关系数据库与分析型数据库通常更直接。MongoDB 的引用和 $lookup 能表达关系,却不会自动补上外键约束、成熟的关系优化经验和低成本任意联接。把几十个微服务的数据塞进同一库,也不会因为换成文档就消除所有权问题。
另一个错误入口是把 MongoDB 当对象存储。BSON 单文档上限是 16 MiB。GridFS 能把大文件分块写入两个 collection,但不支持多文档事务,也不适合“整文件内容必须原子替换”的场景。大量图片、视频和归档文件通常仍应进入对象存储,MongoDB 保存元数据、权限、状态与对象地址。只有确实需要数据库复制、范围读取或文件与元数据共同部署时,GridFS 才有优势。
当业务不能接受最终一致的跨文档更新、写热点集中在少数超大文档、数组会无界增长,或者节点、网络、备份能力不足以承担复制与恢复时,先修模型或选别的存储。Replica Set 提供高可用基础,Sharding 提供水平容量;它们不会修复错误的数据归属、缺失的幂等语义和未经验证的恢复流程。
文档、集合与 BSON 先建立最小心智模型
MongoDB 的 database 是 collection 的命名空间,collection 保存 document,document 再由字段、子文档和数组组成。它和关系数据库最大的差别,不是“可以不建表”,而是一次查询常常直接取回一个完整业务聚合。字段可以逐步演进,但同一字段若在不同文档里混用字符串、数字和日期,查询、排序与索引都会变得不可预测,所以 schema 仍然存在,只是由应用、validator 和迁移共同维护。
BSON 比 JSON 多出 ObjectId、Date、Decimal128、二进制和多种数值宽度。"date-as-text" 是字符串,new Date(...) 才是日期;"199.00" 是字符串,Decimal128("199.00") 才适合十进制定点金额。类型不同会影响比较、排序、聚合和索引命中,不能只看终端里打印出来“长得差不多”。
下面这条命令把当前 database、collection 和文档类型一次展示出来:
db = db.getSiblingDB("te_app")
db.orders.insertOne({
tenantId: "tenant-a",
orderNo: "ORD-T0-0001",
buyerId: ObjectId(),
status: "CREATED",
amount: { currency: "CNY", value: Decimal128("199.00") },
items: [{
productId: ObjectId(),
nameSnapshot: "机械键盘",
quantity: NumberInt(1),
unitPrice: Decimal128("199.00")
}],
tags: ["new-user", "campaign-a"],
address: { province: "Fujian", city: "Fuzhou" },
createdAt: new Date()
})
db.orders.findOne(
{ tenantId: "tenant-a", orderNo: "ORD-T0-0001" },
{ _id: 0, tenantId: 1, orderNo: 1, status: 1, amount: 1, items: 1, createdAt: 1 }
)读者先记住三个规则:一起读取并共同演进的数据优先嵌入;独立增长、被大量对象共享或有独立生命周期的数据优先引用;数组必须有明确上限。后面的订单模型、索引、事务和分片都从这三个规则展开。
一次写请求究竟穿过了什么
应用通常只看见 insertOne 返回一个 acknowledged 标志,真正的路径远比一次函数调用长。MongoClient 先通过种子地址建立拓扑视图,监控连接持续发送 hello,识别 standalone、Replica Set 或 mongos,并记录成员角色、RTT、wire version、setName 与拓扑变化。业务操作根据 read preference、事务状态和拓扑选择候选 server;server selection 超时表示“在期限内找不到满足条件的节点”,不是单纯的 TCP 连接超时。
选中 server 后,驱动从该 server 对应的连接池借 socket。一个 MongoClient 对拓扑中的每个 server 都有独立池和监控连接,所以进程数乘节点数才是连接预算。新 socket 先受 connectTimeoutMS 约束完成 TCP;启用 TLS 时还要验证证书链、主机名、有效用途和协议。随后通过 SCRAM 或 X.509 完成认证。已连接 socket 上的等待受 socketTimeoutMS 约束;池内没有可用连接时受 waitQueueTimeoutMS 约束。四种超时指向四个不同阶段,不能只把数值统一调大。
命令以 BSON 编码,通过 TCP 上的 OP_MSG 请求送到 mongod 或 mongos。服务端网络层将 socket 表示为 Transport Session,ServiceEntryPoint 接收消息并把一次命令变成 OperationContext。OperationContext 从网络调度到执行结束保存截止时间、中断状态、会话与事务资源,并关联 RecoveryUnit。命令解析后先做身份与 action 授权,再进入 namespace 解析、schema validation、查询规划和执行。
查询优化器根据过滤、排序、投影、collation、hint 和可用索引生成候选计划。执行器从 Collection Catalog 找到 Collection;普通 collection 在存储层表现为一个 RecordStore 和若干索引接口。文档写入与所有相关索引键变化必须处于同一个 WriteUnitOfWork,否则会出现“文档已改、索引未改”的不可接受状态。RecoveryUnit 把 MongoDB 的工作单元映射为 WiredTiger transaction,提供快照读取、提交、回滚和时间戳能力。
WiredTiger 在内存页上完成修改,脏页进入 cache。eviction 负责把可逐出的页协调到磁盘结构,checkpoint 周期性把一致快照落入数据文件,journal 保存 checkpoint 之后的恢复信息。journal 已落盘只回答单节点崩溃恢复问题,不代表复制多数已确认。对副本集写入,业务数据变更与对应 oplog 记录在同一 storage transaction 中提交;成功修改才产生 oplog,失败或未改变数据的操作不产生。
Secondary 从同步源拉取 local.oplog.rs 中的新条目,写入自己的 oplog,再按顺序应用到本地数据。Primary 计算 majority commit point。MongoDB 8.0 起,w: "majority" 在多数有数据投票成员把 oplog 条目持久写入本地 oplog 后即可确认,secondary 的数据应用可以稍后完成。因此,刚收到 majority 写成功就去 secondary 读,仍可能读不到最新业务文档;需要这个因果关系时要使用 causally consistent session,而不是把 readPreference 设为 secondary 后期待同步复制。
最终返回还要同时满足操作执行结果、write concern 与客户端等待期限。wtimeout 只表示等待复制确认超时,不会撤销已经在 primary 上完成的写;它可能稍后达到多数,也可能在故障后回滚。应用必须按“结果不确定”处理,用业务幂等键或读回确认收敛,不能看到超时就盲目再插一条。
这些对象解释了为什么 server selection、pool wait、命令执行、journal 和 majority acknowledgement 必须分别观测。实现细节会随版本重构,生产依赖的稳定边界仍是 Wire Protocol、命令结果、write concern 与 Server Manual 公布的行为。
用一个订单聚合决定嵌入、引用与演进
假设交易服务需要保存订单。订单号、买家快照、币种、总额、收货地址和有限数量的商品项总是一起读取,并在结算后冻结。把地址快照和商品项嵌入订单,可以用一个文档完成创建与状态迁移;商品当前名称或用户当前地址以后变化,也不会改写历史订单。这里的冗余是业务快照,不是错误重复。
商品详情、卖家资料和支付尝试则不适合无条件嵌入。它们有独立生命周期,可能被大量订单共享,支付尝试还会持续增长。订单只保存 productId、sellerId、paymentId 与必要快照,详情由应用批量读取或在受控查询中使用 $lookup。如果每次订单列表都触发逐条引用读取,N+1 会把低延迟文档查询重新变成网络瀑布;解决方式是重划聚合、批量查询或建立读取模型,不是给每个引用都加一次 lookup。
无界数组是最危险的“方便”。把所有状态事件、评论或设备采样 append 到一个订单文档,会让文档持续搬移、更新冲突集中、multikey 索引膨胀,并最终逼近 16 MiB。可控做法是只内嵌有明确上限的摘要,例如最近若干状态;完整事件进入 order_events collection,以 orderId 加序号建立唯一复合索引。单文档原子性保住当前状态,事件以幂等键保证只写一次;真正需要跨文档同步时再使用事务。
下面的 collection 同时表达类型、枚举、必填和数组项约束。命令以 mongosh 连接到开发副本集后执行,账号至少需要 te_app 上的 dbAdmin 与 readWrite;生产变更应先在影子 collection 验证,再用 collMod 提升 validationLevel。直接把 validationAction 改成 warn 会让坏数据继续进入,只适合短期观测迁移,不是修复。
db = db.getSiblingDB("te_app")
db.createCollection("orders", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["tenantId", "orderNo", "buyerId", "status", "amount", "items", "createdAt"],
properties: {
tenantId: { bsonType: "string", minLength: 1 },
orderNo: { bsonType: "string", minLength: 8 },
buyerId: { bsonType: "objectId" },
status: { enum: ["CREATED", "PAID", "CANCELLED"] },
amount: {
bsonType: "object",
required: ["currency", "value"],
properties: {
currency: { enum: ["CNY", "USD"] },
value: { bsonType: "decimal" }
}
},
items: {
bsonType: "array",
minItems: 1,
maxItems: 200,
items: {
bsonType: "object",
required: ["productId", "nameSnapshot", "quantity", "unitPrice"],
properties: {
productId: { bsonType: "objectId" },
nameSnapshot: { bsonType: "string" },
quantity: { bsonType: "int", minimum: 1 },
unitPrice: { bsonType: "decimal" }
}
}
},
createdAt: { bsonType: "date" }
}
}
},
validationLevel: "strict",
validationAction: "error"
})
db.createCollection("payment_records", {
validator: {
$jsonSchema: {
bsonType: "object",
required: [
"tenantId", "paymentId", "orderNo", "orderFingerprint",
"paymentSemantic", "businessFingerprint", "state", "createdAt"
],
properties: {
tenantId: { bsonType: "string", minLength: 1 },
paymentId: { bsonType: "string", minLength: 8 },
orderNo: { bsonType: "string", minLength: 8 },
orderFingerprint: { bsonType: "string", pattern: "^[0-9a-f]{64}$" },
businessFingerprint: { bsonType: "string", pattern: "^[0-9a-f]{64}$" },
paymentSemantic: {
bsonType: "object",
required: ["provider", "intentId", "amount"],
properties: {
provider: { bsonType: "string" },
intentId: { bsonType: "string" },
amount: {
bsonType: "object",
required: ["currency", "value"],
properties: {
currency: { enum: ["CNY", "USD"] },
value: { bsonType: "decimal" }
}
}
}
},
state: { enum: ["CAPTURED", "REFUNDED"] },
createdAt: { bsonType: "date" }
}
}
},
validationLevel: "strict",
validationAction: "error"
})预期返回 ok: 1。再做一正一反两次写入,错误用应用异常摘要留存。反例应得到 Document failed validation,若成功说明 collection 已存在但 validator 不同,先执行 getCollectionInfos 查实际 options,不能直接删库重来。
db.orders.insertOne({
tenantId: "tenant-a",
orderNo: "ORD-T0-0001",
buyerId: ObjectId(),
status: "CREATED",
amount: { currency: "CNY", value: Decimal128("199.00") },
items: [{
productId: ObjectId(),
nameSnapshot: "机械键盘",
quantity: NumberInt(1),
unitPrice: Decimal128("199.00")
}],
createdAt: new Date()
})
db.orders.insertOne({
tenantId: "tenant-a",
orderNo: "BAD-T0-0001",
buyerId: "not-an-object-id",
status: "UNKNOWN",
items: []
})Schema evolution 应先支持“旧读新写”:应用读取时兼容旧字段,写入新字段但不立即设为 required;后台迁移用 _id 范围分批更新并记录断点;统计缺失与类型异常归零后,再用 collMod 收紧 validator。validator 不会自动修复历史文档,moderate 也不是版本迁移器。字段改名、类型改变和数组拆表都要有双读或回退窗口。
超过 16 MiB 的内容会在服务端拒绝。GridFS 将文件拆为 chunks 与 files 两个 collection,适合流式范围读取和跟随 MongoDB 复制的文件;它不支持多文档事务,也不能原子覆盖整个大文件。对象存储通常有更好的成本、CDN、生命周期和大对象吞吐,订单只保存对象标识、摘要、大小与校验和会更稳妥。
索引是查询契约,也是写入税
订单后台常见查询是 tenantId 等值过滤、status 等值过滤、createdAt 倒序和 _id 稳定翻页。复合索引按 ESR 思路排列:Equality 在前,Sort 其次,Range 最后。这里的索引可以是 tenantId、status、createdAt、_id;如果 createdAt 本身也是范围条件,真实选择要用 explain 对候选顺序压测,不能把口诀当证明。
db.orders.createIndex(
{ tenantId: 1, orderNo: 1 },
{ name: "tenant_order_unique", unique: true }
)
db.payment_records.createIndex(
{ tenantId: 1, paymentId: 1 },
{ name: "tenant_payment_unique", unique: true }
)
db.orders.createIndex(
{ tenantId: 1, status: 1, createdAt: -1, _id: -1 },
{ name: "tenant_status_created_id" }
)
db.orders.find(
{
tenantId: "tenant-a",
status: "PAID",
"items.nameSnapshot": { $in: ["鼠标", "显示器"] }
},
{ _id: 1, orderNo: 1, createdAt: 1 }
).sort({ createdAt: -1, _id: -1 }).limit(50).explain("executionStats")成功计划应出现 IXSCAN,totalDocsExamined 与 nReturned 接近,且没有额外 SORT。索引前缀意味着这个索引能有效支持 tenantId,或 tenantId 加 status 的左前缀查询;只查 status 不能跳过第一个前缀仍期待同样选择性。covered query 要求过滤与投影字段都能从索引满足,并且计划无需 FETCH;数组字段生成 multikey 后,覆盖能力与组合限制会变化,两个数组字段也不能任意组成同一个 compound multikey index。
Partial index 只索引满足 filter 的文档,适合只查活跃订单;查询条件必须能推出 partialFilterExpression 才可安全使用。Wildcard index 适合字段路径未知、变化快的子文档探索,但不能替代高频查询的精确复合索引,也不支持 unique 与 TTL 等组合。Hidden index 让规划器暂时忽略索引,是删除前观察回退的低风险入口;它仍会接收写入并消耗磁盘。TTL index 由后台线程异步清理,过期时刻不等于立即删除;TTL 不承担审计删除与确定性任务调度。
db.orders.createIndex(
{ tenantId: 1, createdAt: -1 },
{
name: "active_orders",
partialFilterExpression: { status: { $in: ["CREATED", "PAID"] } }
}
)
db.orders.hideIndex("active_orders")
db.orders.unhideIndex("active_orders")
db.order_events.createIndex(
{ expireAt: 1 },
{ expireAfterSeconds: 0, name: "expire_events" }
)每个索引都会增加写入时的键生成、WiredTiger cache 占用、磁盘页、checkpoint 与复制流量。索引越多,insert/update 越慢,初始同步和恢复越久。生产删索引前先 hidden,覆盖一个完整业务峰值并比对 query shape、COLLSCAN、延迟和 CPU;删除后只能重建,重建需要额外磁盘、内存和 I/O。
副本集上的索引构建会在各数据节点同时进行,并以 commit quorum 协调完成。开始和结束阶段需要独占 collection 锁,中间允许读写但会维护 side writes。空间不足、唯一键冲突或节点迟迟不能投票都会阻塞完成;Primary 选举或 rollback 可能暂停并恢复构建。不要把 background 旧参数当作现代版本的免影响开关。
自管部署的 text index 适合语法简单、数据量可控的站内文本检索。下面把租户作为复合前缀,标题权重高于描述;查询必须对 tenantId 使用等值条件,否则无法有效使用这个复合前缀。
db.products.createIndex(
{ tenantId: 1, name: "text", description: "text" },
{
name: "tenant_product_text",
default_language: "none",
weights: { name: 8, description: 1 }
}
)
db.products.find(
{ tenantId: "tenant-a", $text: { $search: "机械 键盘" } },
{ name: 1, score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } })每个 collection 最多一个 text index,但该索引可包含多个 text 字段并分配 weights。$text 不接受 hint(),text index 也不能加速普通字段排序;复合 text index 的前置普通键必须在查询中等值匹配,所有 text 键必须在索引规格中相邻。default_language、文档级 language_override、分词、词干与停用词都会改变命中,中英混合内容必须用真实语料验证。这是服务端自管 text index 与 $text 能力,不是使用独立 Search 索引和 $search 阶段的 MongoDB Search;两者的索引、执行面、计费与运维边界要分开评估。
聚合能省应用往返,也会把压力推回数据库
过滤越早、投影越窄,后续 group、sort 与 lookup 处理的数据越少。能由索引提供排序时,不应先制造大结果再内存排序。现代 MongoDB 默认允许需要超过 100 MB 内存的部分阶段写临时文件;usedDisk 会出现在 profiler 或诊断日志里。spill 避免了立即失败,却把压力转成 dbPath 下临时 I/O 和磁盘容量,不能把 allowDiskUse 当性能优化。
db.orders.aggregate([
{ $match: {
tenantId: "tenant-a",
status: "PAID",
createdAt: { $gte: new Date(Date.now() - 60 * 60 * 1000) }
}},
{ $group: {
_id: "$amount.currency",
orderCount: { $sum: 1 },
gross: { $sum: "$amount.value" }
}},
{ $sort: { gross: -1 } }
], { allowDiskUse: false, maxTimeMS: 3000 })示例中的 T0 是语义占位符,执行时由应用传入 BSON Date,不能把字符串原样作为生产查询。反向实验可先在测试数据上禁止 allowDiskUse 并移除支撑索引;若返回内存限制错误或 explain 出现 SORT,说明计划依赖内存。随后恢复索引并比较 executionStats,而不是在共享环境直接放开无限时长。
$lookup 的 foreignField 应有索引,localField 基数与输入行数决定总探测量。对分片 collection,lookup 还可能跨 shard。explain 需要同时看外层扫描、返回行数、usedDisk 和目标 collection 的索引;一个“聚合成功”结果不能证明它能承受峰值。
二、为什么
从业务聚合推导产品与交付边界
商品目录、内容页面和设备事件的答案并不相同
商品目录常把规格、展示属性和有限数量的图片元数据放进一个文档,因为页面读取时需要整体返回,字段又会按品类变化。文章与页面组件也适合文档模型:正文、作者快照、标签和发布状态形成清晰聚合。订单则要更克制,收货地址和商品名称适合保存历史快照,支付尝试、退款记录和状态事件却会独立增长,通常应拆到单独 collection。
设备遥测看起来也是 JSON,但高频追加、长时间范围聚合和压缩扫描往往更适合时序或列式系统。MongoDB time series collection 能覆盖一部分时序场景,却不能仅凭“数据是 JSON”就替代 ClickHouse。日志全文检索、相关性排序与复杂分词通常应比较 Elasticsearch 或 OpenSearch;大文件正文则优先进入对象存储。选型看访问路径和生命周期,不看输入格式。
当约束和联接成为主角时,关系数据库通常更自然
如果一个业务动作必须同时改变多个聚合并受外键、唯一约束和复杂联接保护,MongoDB 的多文档事务虽然能完成,却会失去文档模型原本的简单性。报表需要任意维度 JOIN、财务口径依赖成熟 SQL 约束、团队的恢复与审计体系都围绕关系数据库建立时,PostgreSQL、MySQL 或 SQL Server 往往是更稳的主库。
反过来,如果读取总是围绕一个聚合、字段随业务快速演进、嵌套结构能减少跨表拼装,而且团队能治理 validator、索引和数组增长,MongoDB 会让模型更贴近应用。好的决定不是“是否需要事务”,而是大多数请求能否在单文档原子边界内完成;偶尔的跨文档事务是补充,长期每次都跨十几个 collection 则是模型警报。
版本、产品与许可必须拆开看
MongoDB Server 使用 X.Y.Z 版本。X.Y 是 release series,Z 是 patch。按照当前 MongoDB Versioning 规则,Major Release 提供可预测的五年生命周期和手工升级窗口;Minor Release 在 major 周期内连续交付增量能力,稳定性定位同样面向生产,但要求更频繁维护。Minor 只能按相邻系列顺序升级,不能从 8.1 直接跳到 8.3;新的 minor 发布后,前一个 minor 不再继续获得 patch,留在 minor 轨道就必须跟进当前系列。Patch 按需修复缺陷与安全问题,同一系列应使用最新稳定 patch。
8.3 Release Notes 已正式发布 8.3.8,适合愿意连续跟进 minor 的团队;8.0 Changelog 对应生命周期更可预测的 major 轨道。下面的开发容器和生产包安装统一固定 8.0.29,避免读者在第一条链路里混用两条升级节奏。固定值只用于复现,不能代替维护窗口前重新核对 release notes、安全公告与下载页。需要 Atlas Live Migration 或 mongosync 等 minor 可能不支持的能力时,也应先核对当前 major 系列的最新 patch 与功能矩阵。
旧资料中的 Rapid/LTS 分类不再适合描述 8.2 之后的当前轨道。现在的决策是 Major 与 Minor:前者换取可预测生命周期和更强的升级控制,后者换取更早的新能力并承担连续升级。每次 major 或 minor 跨系列变化都包含 binary 与 FCV 两个阶段;patch 升级通常不改变 FCV,但仍要逐节点滚动、观察和保留恢复点。
服务端、工具和交付责任也不能混成“MongoDB 官方”一个盒子。
| 对象 | 获得什么 | 责任和限制 |
|---|---|---|
| Community Server | 可下载、可自管的服务端与源码 | Community Server 使用 SSPL v1;源码可用,但 SSPL 未获 OSI 批准,不能表述为 OSI 开源 |
| Enterprise Advanced | 商业许可、自管支持和企业能力 | 审计、原生静态加密、部分身份与运维能力属于商业边界,需按订阅和版本核对 |
| Atlas | MongoDB 托管服务 | 平台负责约定范围内的基础设施、升级、备份和控制面;用户仍负责模型、索引、访问、KMS、网络和恢复验证 |
| 官方驱动 | 应用协议、拓扑、池、会话与 BSON 实现 | MongoDB 支持的驱动通常使用 Apache License 2.0,版本与 Server 独立 |
| mongo Docker Official Image | Docker 社区维护的标准镜像入口 | 初始化变量和 entrypoint 由镜像仓库负责;Server 缺陷、安全公告和生命周期仍看 MongoDB |
| mongodb/mongodb-community-server | MongoDB 维护的 Community 容器入口 | 与 Docker Official Image 的 tag、初始化约定和维护链不同,不能互换脚本后假定行为一致 |
MongoDB Licensing 明确区分 Community Server 的 SSPL、官方驱动的 Apache 2.0 与商业许可。普通应用把 MongoDB 当内部数据库,不等于向第三方提供 MongoDB 托管服务;修改、分发、嵌入交付或 DBaaS 场景应由法务按 SSPL 第 13 节和商业条款确认。代码在 GitHub 可读也不等于 OSI 意义上的开源。
日常入口应保留为可追溯链路:产品信息看 MongoDB 官网,机制与命令看 Database Manual,源码看 mongodb/mongo,二进制和包看 Community Download,具体修复看 Server Release Notes,支持期限看 Software Lifecycle,漏洞处置看 Security Bulletins。驱动、mongosh、Compass 与 Database Tools 独立发版,不能用服务端版本号推断它们的兼容性。
原子性、一致性和超时不是一个开关
单文档写入包含文档及其索引键,天然原子。updateOne 的过滤条件也是并发控制工具:把预期状态或 version 放进 filter,matchedCount 为 0 就表示别人已经修改,不必先读后写制造竞态。多文档事务只在聚合边界确实跨文档时使用;它会保留快照和旧版本、延长锁与 cache 压力,并增加 write conflict 重试。
Client Session 提供逻辑会话与事务编号。retryable writes 用 session 和 txnNumber 让支持的单文档写在网络重试时去重;它不能替代业务幂等,因为请求可能跨客户端、跨服务或超出重试历史。业务唯一键仍是恢复不确定结果的最后依据。
readConcern 决定一次读允许看见哪种状态。local 延迟低,但数据可能尚未 majority 提交;majority 读取多数提交快照,避免读到之后 rollback 的数据;snapshot 为事务提供一致快照。writeConcern 决定写到哪个复制与持久阶段才确认。w:1 只确认 Primary 接受,故障时可能 rollback;w:"majority" 默认要求多数有数据投票成员持久写入 oplog。j:true 关注 journal,不能单独保证不回滚。
readPreference 决定读目标。primary 保持最直接的 read-after-write;secondary 会承受复制延迟,并可能把分析查询的 cache 压力转移到 HA 节点。secondaryPreferred 不是免费容灾,因为回退 Primary 时负载会突然改变。因果一致 session 配合 majority read/write concern 才能让依赖关系跨 secondary 收敛;它不把整个集群变成线性一致系统。
wtimeout 与客户端 socket timeout 都可能留下未知结果。安全做法是返回“结果核验中”,用 orderNo、paymentId 等幂等键读回状态,再决定继续或补偿。把所有异常都重试会放大选举、网络分区和过载;只有错误标签、操作幂等性和剩余业务期限同时允许时才重试。
容量从工作集、磁盘和连接一起算
WiredTiger 默认内部 cache 是 max(0.5 × (RAM − 1 GB), 0.256 GB)。这只是 cache,不是 mongod 总内存上限。连接、聚合、排序、索引构建、压缩、会话与进程本身还要用内存;未被 WiredTiger 占用的内存又会成为文件系统 cache,缓存压缩后的数据文件。把 WiredTiger cache 调到接近容器上限,通常会失去文件系统 cache 并触发 OOM。
容器场景先执行 hostInfo,确认 mongod 看见的 memLimitMB,再读取 cgroup limit 与编排 limits。某些环境不会按预期识别容器限制,需要显式设置 cacheSizeGB 或 cacheSizePct,且应低于容器可用内存,为非 cache 消费者留预算。一个主机运行多个 mongod 时,默认公式也不再成立。
db.hostInfo().system
db.serverStatus().mem
db.serverStatus().wiredTiger.cache
db.serverStatus().queues
db.serverStatus().connections观测 cache 不只看当前字节。pages read into cache 持续高、application threads page read from disk 时间增长,说明工作集频繁落盘;modified pages evicted、tracked dirty bytes 与 checkpoint 时间一起上升,说明写入与磁盘不能同步;eviction server candidate queue empty 与 application threads page for eviction 增长,说明业务线程被迫帮助 eviction。恢复标准是延迟和吞吐回到基线,dirty 比例不继续积累,checkpoint 周期稳定,磁盘队列不饱和,而不是 RSS 下降。
磁盘容量至少包含压缩后 collection、所有索引、oplog、journal、诊断日志、临时 spill、索引构建临时空间、initial sync 临时 oplog、备份 staging 和增长余量。分片时还要为 chunk migration 与 resharding 的临时副本、接收端写入、oplog 追赶和失败重试留空间。只按 db.stats().dataSize 采购会漏掉大量真实占用。
oplog window 由 local.oplog.rs 的时间跨度决定,不是只看固定 GB。窗口必须覆盖计划维护、峰值写入、最慢可接受复制延迟、initial sync 数据复制和备份/PITR 需求。写流量越大,同样 oplog 大小覆盖时间越短。每个 secondary 的网络要同时承受 oplog 拉取、initial sync 与客户端读;跨地域复制还要预算 RTT 和出口流量。
rs.printReplicationInfo()
rs.printSecondaryReplicationInfo()
db.getSiblingDB("local").oplog.rs.stats(1024 * 1024 * 1024)连接预算按“应用进程数 × 每个 server 的 maxPoolSize”估算,再加每进程每节点监控连接、mongos、运维、备份和故障余量。滚动发布期间新旧实例并存,自动扩容会再乘一层。服务端 connections.current 接近 available,或驱动 wait queue 上升时,先查慢操作与泄漏,不能直接把池翻倍。
压测必须使用脱敏、分布相似的数据和真实索引。固定并发逐级提升,记录吞吐、P50/P95/P99、server selection、pool wait、docs/keys examined、cache read、dirty、checkpoint、磁盘延迟、复制 lag、oplog window 与 CPU。容量点取自 SLO 首次失守前的稳定区,并同时做 Primary stepdown,验证故障期间的客户端收敛和重试放大。
安全要覆盖身份、链路、磁盘和使用中数据
SCRAM-SHA-256 适合数据库用户口令,密码放 secret manager 并定期轮换;X.509 用证书 DN 认证,适合机器身份,但证书映射、吊销、有效期和私钥保护必须自动化。keyfile 只适合副本集或分片内部共享密钥的基本方案,所有节点拿到同一秘密,一处泄漏影响整个集群;生产可用内部 mTLS 将成员身份落到独立证书。
RBAC 从应用实际 action 反推。运行账号通常只需目标数据库的 readWrite,迁移账号按窗口增加 createIndex、collMod 等能力,备份与恢复使用独立账号。root、clusterAdmin、restore 不能进入应用。认证只证明身份,授权才决定能做什么;connectionStatus 与 usersInfo 是验权入口。
db.runCommand({ connectionStatus: 1, showPrivileges: true })
db.getSiblingDB("admin").runCommand({
usersInfo: { user: "te_app_runtime", db: "te_app" },
showPrivileges: true
})生产网络使用 net.tls.mode: requireTLS,客户端必须验证 CA 与主机名。allowInvalidCertificates、allowInvalidHostnames 和 tlsInsecure 只能用于隔离诊断,不能成为永久配置。轮换时先让 trust store 同时信任新旧 CA,再逐节点换服务证书并验证 Replica Set,最后换客户端证书、移除旧 CA;直接覆盖唯一证书会造成成员互不信任或客户端全断。
net:
bindIp: 127.0.0.1,10.0.0.12
tls:
mode: requireTLS
certificateKeyFile: /etc/mongodb/tls/server.pem
CAFile: /etc/mongodb/tls/ca-chain.pem
security:
authorization: enabled
clusterAuthMode: x509Enterprise Advanced 的原生静态加密、审计、部分企业身份能力不能写成 Community 默认能力。Atlas 在平台层提供静态和传输加密,但客户管理密钥、项目权限、网络入口、备份保留和审计导出仍由用户配置。备份文件本身也要加密、限权、校验并有销毁策略;数据库加密不自动保护已导出的 dump。
CSFLE 在客户端加密字段,Community 可使用显式加密;自动加密组件和能力要按产品核对。Queryable Encryption 让驱动在客户端分析并加密查询,服务端处理密文,需要维护 key vault、内部元数据 collection 与 safeContent。DEK 存在 key vault,CMK 留在 AWS KMS、Azure Key Vault、Google Cloud KMS 或 KMIP 系统。备份时数据、key vault 与 KMS 恢复权限必须共同存在,只有 ciphertext 而没有 CMK 不可恢复。
日志必须脱敏 URI、SASL、证书 DN 中的敏感标识、查询值与文档内容;comment 只放稳定操作名和请求追踪 ID。Enterprise log redaction 与 audit 能增强控制,但也会降低排障细节,应通过受限、安全的调试窗口补证据。LDAP 认证与授权从 MongoDB 8.0 开始已弃用,在 MongoDB 8 生命周期内仍可工作,但未来 major 会移除;自管 Enterprise 应规划 OIDC 的 workforce/workload identity,Atlas 程序身份优先工作负载联合、AWS IAM 或 X.509。
三、怎么做
十分钟跑通 collection、CRUD 和索引
先用一个只监听本机的临时容器学习日常操作。这里选择 8.0 major 的当前补丁;用户名和密码只用于可删除的本机实验,不能进入共享环境。
export MONGO_INITDB_ROOT_PASSWORD='replace-with-a-local-password'
docker run -d --name mongodb-learning \
-p 127.0.0.1:27018:27017 \
-e MONGO_INITDB_ROOT_USERNAME=lab_admin \
-e MONGO_INITDB_ROOT_PASSWORD \
-v mongodb-learning-data:/data/db \
mongo:8.0.29
docker exec mongodb-learning mongosh \
--username lab_admin \
--password "$MONGO_INITDB_ROOT_PASSWORD" \
--authenticationDatabase admin \
--quiet --eval \
'print(EJSON.stringify(db.adminCommand({ping:1})))'预期返回 ok: 1。端口可达但 ping 失败时先看 docker logs mongodb-learning,再检查认证库和初始化变量;不要通过关闭认证换取一次成功连接。
进入 mongosh 后先确认自己连接到哪个服务和 database:
docker exec -it mongodb-learning mongosh \
--username lab_admin \
--password "$MONGO_INITDB_ROOT_PASSWORD" \
--authenticationDatabase admindb = db.getSiblingDB("te_app")
db.getName()
db.runCommand({ connectionStatus: 1, showPrivileges: false })
db.createCollection("orders")
db.getCollectionNames()db.getName() 应返回 te_app,collection 列表中应出现 orders。MongoDB 默认会在首次写入时隐式创建 collection,但显式创建更适合添加 validator、collation 等约束,也能避免拼错名称后静默生成新 collection。后文的 validator、Node.js、Replica Set、分片与恢复都继续使用这个 database 和订单聚合。
写入单个文档和一批文档
db.orders.insertOne({
tenantId: "tenant-a",
orderNo: "ORD-T0-0001",
buyerId: ObjectId(),
status: "CREATED",
amount: { currency: "CNY", value: Decimal128("199.00") },
items: [
{
productId: ObjectId(), nameSnapshot: "机械键盘",
quantity: NumberInt(1), unitPrice: Decimal128("199.00")
}
],
shippingAddress: { province: "Fujian", city: "Fuzhou" },
createdAt: new Date()
})
db.orders.insertMany([
{
tenantId: "tenant-a",
orderNo: "ORD-T0-0002",
buyerId: ObjectId(),
status: "PAID",
amount: { currency: "CNY", value: Decimal128("88.50") },
items: [{ productId: ObjectId(), nameSnapshot: "鼠标", quantity: NumberInt(1), unitPrice: Decimal128("88.50") }],
createdAt: new Date()
},
{
tenantId: "tenant-a",
orderNo: "ORD-T0-0003",
buyerId: ObjectId(),
status: "PAID",
amount: { currency: "CNY", value: Decimal128("299.00") },
items: [{ productId: ObjectId(), nameSnapshot: "显示器", quantity: NumberInt(1), unitPrice: Decimal128("299.00") }],
createdAt: new Date()
}
], { ordered: true })insertOne 返回一个 insertedId,insertMany 返回两个 ID。ordered: true 表示批次遇到第一条错误后停止;高吞吐导入若改为 false,必须逐条处理 writeErrors,不能把“命令返回过”当成全部成功。
过滤、投影、排序和稳定翻页
db.orders.find(
{ tenantId: "tenant-a", status: "PAID" },
{ _id: 0, orderNo: 1, status: 1, amount: 1, createdAt: 1 }
).sort({ createdAt: -1, _id: -1 }).limit(20)过滤条件直接访问子文档和数组元素;投影只返回页面真正需要的字段。数据增长后不要使用很大的 skip 做深翻页,应该把上一页最后一条的 createdAt 与 _id 带入下一次范围条件,并建立相同顺序的复合索引。
使用更新操作符并检查 matchedCount
db.orders.updateOne(
{ tenantId: "tenant-a", orderNo: "ORD-T0-0001", status: "CREATED" },
{
$set: { status: "PAID", paidAt: new Date() },
$inc: { version: NumberInt(1) }
}
)
db.orders.findOne(
{ tenantId: "tenant-a", orderNo: "ORD-T0-0001" },
{ _id: 0, orderNo: 1, status: 1, version: 1, paidAt: 1 }
)第一次更新应得到 matchedCount: 1 和 modifiedCount: 1。再次执行相同条件会得到 matchedCount: 0,因为状态已经不是 CREATED;这正是用过滤条件做乐观并发控制的入口。replaceOne 会替换除 _id 外的整个文档,普通字段更新优先使用 $set、$inc、$push、$pull 等操作符,避免遗漏字段。
聚合结果要能解释每个阶段
db.orders.aggregate([
{ $match: { tenantId: "tenant-a", status: "PAID" } },
{ $group: {
_id: "$status",
orders: { $sum: 1 },
revenue: { $sum: "$amount.value" }
} },
{ $project: { _id: 0, status: "$_id", orders: 1, revenue: 1 } }
])$match 先缩小输入,$group 再累计订单数和金额,$project 整理输出。复杂 pipeline 要从左到右观察中间数据形状;把 $match 放到能使用索引的位置,避免先展开巨大数组再过滤。
建立唯一索引并观察正反结果
db.orders.createIndex(
{ tenantId: 1, orderNo: 1 },
{ name: "tenant_order_unique", unique: true }
)
db.orders.createIndex(
{ tenantId: 1, status: 1, createdAt: -1, _id: -1 },
{ name: "tenant_status_created_id" }
)
db.orders.find({ tenantId: "tenant-a", status: "PAID" })
.sort({ createdAt: -1, _id: -1 })
.limit(20)
.explain("executionStats")执行计划应出现 IXSCAN,并且 totalDocsExamined 与返回数量处于可解释范围。随后故意再次写入 ORD-T0-0001,预期得到 E11000 duplicate key error。这个反例证明唯一性由数据库索引保护,不依赖某个应用实例先查询。
删除操作必须从精确过滤开始:
db.orders.deleteOne({ tenantId: "tenant-a", orderNo: "ORD-T0-0003" })
db.orders.countDocuments({})确认实验完成后退出 mongosh,删除容器和 volume:
docker rm -f mongodb-learning
docker volume rm mongodb-learning-data
unset MONGO_INITDB_ROOT_PASSWORD在生产主机上安装受支持的 Community Server
后面的服务端安装、证书、备份与升级以 Linux 为准。密码、私钥和完整连接串不进入仓库、命令参数或 shell 历史;涉及成员身份和数据恢复的操作要先在隔离拓扑演练。
在 Ubuntu 上固定软件包与 systemd 服务
生产基线选择 MongoDB Community 8.0 系列的当前稳定补丁 8.0.29。8.3.8 是当前 minor 轨道的稳定补丁,适合需要新能力并能连续跟进 minor 的团队;自管主线选择 8.0 major 是为了获得更可预测的维护窗口。固定值只保证本次交付可复现,维护窗口仍要重新阅读 8.0 release notes 与安全公告。
下面以官方支持的 Ubuntu 24.04 noble 与 x86-64/arm64 为例。先下载 Server 8.0 仓库公钥,输出 fingerprint 并与官方安装页和变更单中的批准值比对;命令故意不把未知 fingerprint 写成“自动通过”。比对完成后再 dearmor,包版本必须从 apt-cache madison 精确存在。
set -euo pipefail
test "$(. /etc/os-release; printf '%s' "$ID:$VERSION_CODENAME")" = 'ubuntu:noble'
case "$(uname -m)" in x86_64|aarch64) ;; *) exit 64 ;; esac
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
curl --fail --proto '=https' --tlsv1.2 \
-o /tmp/mongodb-server-8.0.asc \
https://pgp.mongodb.com/server-8.0.asc
gpg --show-keys --with-fingerprint --keyid-format long \
/tmp/mongodb-server-8.0.asc
read -r -p 'type approved MongoDB 8.0 fingerprint: ' APPROVED_FINGERPRINT
ACTUAL_FINGERPRINT="$(gpg --show-keys --with-colons /tmp/mongodb-server-8.0.asc \
| awk -F: '$1=="fpr"{print $10;exit}')"
test -n "$APPROVED_FINGERPRINT"
test "$ACTUAL_FINGERPRINT" = "${APPROVED_FINGERPRINT// /}"
sudo gpg --dearmor --yes \
-o /usr/share/keyrings/mongodb-server-8.0.gpg \
/tmp/mongodb-server-8.0.asc
echo 'deb [arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg] https://repo.mongodb.org/apt/ubuntu noble/mongodb-org/8.0 multiverse' \
| sudo tee /etc/apt/sources.list.d/mongodb-org-8.0.list
sudo apt-get update
apt-cache madison mongodb-org mongodb-org-server mongodb-mongosh
MONGODB_VERSION='8.0.29'
sudo apt-get install -y \
mongodb-org="$MONGODB_VERSION" \
mongodb-org-database="$MONGODB_VERSION" \
mongodb-org-server="$MONGODB_VERSION" \
mongodb-org-mongos="$MONGODB_VERSION" \
mongodb-org-shell="$MONGODB_VERSION" \
mongodb-org-tools="$MONGODB_VERSION" \
mongodb-org-database-tools-extra="$MONGODB_VERSION" \
mongodb-mongosh
sudo apt-mark hold mongodb-org mongodb-org-database \
mongodb-org-server mongodb-org-mongos mongodb-org-shell mongodb-org-tools \
mongodb-org-database-tools-extra mongodb-mongosh
mongod --version
mongosh --version
sudo systemctl enable mongodmongod --version 必须返回 8.0.29,包不存在、fingerprint 不一致或发行版不在支持矩阵时立即停止。apt-mark hold 防止无人值守升级跨过验证窗口,补丁发布仍由变更流程解除 hold、安装精确版本并重新 hold,不能永久停在旧安全补丁。
数据目录和日志目录由包创建并归 mongodb 服务账号。数据盘单独挂载时先用空目录验证属主、文件系统、discard 与备份能力,不能把一个已有文件的路径直接当 dbPath。启动前记录磁盘、inode、内存、NUMA、ulimit 和 transparent huge pages 状态;是否调整内核按目标版本官方 production notes 与压测决定,不复制旧时代的 sysctl 清单。
sudo install -d -o mongodb -g mongodb -m 0700 /srv/mongodb/data
sudo install -d -o mongodb -g mongodb -m 0750 /srv/mongodb/log
sudo -u mongodb test -w /srv/mongodb/data
df -hT /srv/mongodb/data
df -i /srv/mongodb/data
systemctl show mongod -p LimitNOFILE -p MemoryMax -p TasksMax
numactl --hardware 2>/dev/null || true用 TLS、SCRAM、keyfile 与最小角色完成安全引导
三个副本集成员使用同一内部认证 keyfile,但每台主机使用自己的 TLS 私钥与包含真实 FQDN 的证书。keyfile 只证明成员共享秘密,TLS 仍负责链路加密和主机身份;生产更强的内部 X.509 认证可以替代 keyfile,但需要完整证书生命周期。示例证书由企业 CA/secret manager 放到 /run/secrets,不会在数据库主机上临时自签。
先证明证书、配置和单成员拓扑成立
set -euo pipefail
sudo systemctl stop mongod
sudo install -d -o mongodb -g mongodb -m 0700 /etc/mongodb/tls
sudo install -o mongodb -g mongodb -m 0600 \
/run/secrets/mongodb-server.pem /etc/mongodb/tls/server.pem
sudo install -o mongodb -g mongodb -m 0644 \
/run/secrets/mongodb-ca-chain.pem /etc/mongodb/tls/ca-chain.pem
openssl verify -purpose sslserver \
-CAfile /etc/mongodb/tls/ca-chain.pem /etc/mongodb/tls/server.pem
openssl verify -purpose sslclient \
-CAfile /etc/mongodb/tls/ca-chain.pem /etc/mongodb/tls/server.pem
openssl x509 -in /etc/mongodb/tls/server.pem -noout \
-subject -issuer -dates -ext subjectAltName,extendedKeyUsage
sudo install -o mongodb -g mongodb -m 0400 \
/run/secrets/mongodb-replica-set-keyfile /etc/mongodb/keyfile把三台主机各自的私网地址、证书和目录渲染进 /etc/mongod.conf。同一 PEM 被 mongod 同时用于对外服务和成员间出站 TLS,因此证书 EKU 必须同时允许 serverAuth 与 clientAuth,上面的两次 purpose 验证都要成功。第一台从启动开始就同时绑定 loopback 与真实私网地址,防火墙在首用户建立前只允许主机自身访问;证书 SAN 同时包含真实 FQDN 和 IP:127.0.0.1。MongoDB 5.0 及以上版本的 Replica Set member 必须使用可解析 hostname,不能把 127.0.0.1 写入生产成员配置。localhost exception 只在实例尚无任何 user/role 时存在,一旦创建首个用户就关闭,不能依赖它长期运维。
storage:
dbPath: /srv/mongodb/data
systemLog:
destination: file
path: /srv/mongodb/log/mongod.log
logAppend: true
processManagement:
timeZoneInfo: /usr/share/zoneinfo
net:
port: 27017
bindIp: 127.0.0.1,10.0.0.12
tls:
mode: requireTLS
certificateKeyFile: /etc/mongodb/tls/server.pem
CAFile: /etc/mongodb/tls/ca-chain.pem
security:
authorization: enabled
keyFile: /etc/mongodb/keyfile
replication:
replSetName: rs-app
setParameter:
diagnosticDataCollectionEnabled: truesudo mongod --config /etc/mongod.conf --configExpand none --outputConfig \
> /var/tmp/mongod-effective.yaml
sudo systemctl start mongod
sudo systemctl --no-pager --full status mongod
sudo journalctl -u mongod -n 100 --no-pager
openssl s_client -connect 127.0.0.1:27017 \
-CAfile /etc/mongodb/tls/ca-chain.pem \
-verify_ip 127.0.0.1 \
-verify_return_error </dev/null
getent ahostsv4 mongo-01.internal.example
openssl s_client -connect mongo-01.internal.example:27017 \
-servername mongo-01.internal.example \
-CAfile /etc/mongodb/tls/ca-chain.pem \
-verify_hostname mongo-01.internal.example \
-verify_return_error </dev/null
mongosh 'mongodb://127.0.0.1:27017/admin?directConnection=true&tls=true' \
--tlsCAFile /etc/mongodb/tls/ca-chain.pem --norc --quiet --eval '
assert.commandWorked(rs.initiate({
_id:"rs-app",
members:[{_id:0,host:"mongo-01.internal.example:27017"}]
}))'
for attempt in $(seq 1 60); do
state="$(mongosh 'mongodb://127.0.0.1:27017/admin?directConnection=true&tls=true' \
--tlsCAFile /etc/mongodb/tls/ca-chain.pem --norc --quiet \
--eval 'print(db.hello().isWritablePrimary)' 2>/dev/null || true)"
test "$state" = 'true' && break
test "$attempt" -lt 60 || exit 1
sleep 2
done--outputConfig 会展开有效配置,文件按敏感配置处理并在核对后安全清理。TLS 必须返回 verify code 0,-verify_ip 还要证明首次 mongosh 使用的 127.0.0.1 与证书 IP SAN 匹配;证书 SAN、CA、EKU 或时间错误不能用 tlsAllowInvalidCertificates 绕过。
创建首个管理员并关闭 localhost exception
节点先通过 localhost exception 初始化成单成员 Replica Set,成为 Primary 后才能写入首个用户。首个管理员继续通过 loopback 与受信 TLS 创建;脚本用 passwordPrompt() 读取隐藏输入,密码不在文件中。root 角色只保留给紧急平台管理员;应用、迁移和备份使用独立身份。
// /srv/mongodb/bootstrap/first-admin.js
const admin = db.getSiblingDB("admin")
admin.createUser({
user: "platform_admin",
pwd: passwordPrompt(),
roles: [{ role: "root", db: "admin" }]
})
print("platform_admin created; verify in a new authenticated session")mongosh 'mongodb://127.0.0.1:27017/admin?directConnection=true&tls=true' \
--tlsCAFile /etc/mongodb/tls/ca-chain.pem \
--norc --quiet --file /srv/mongodb/bootstrap/first-admin.js
mongosh 'mongodb://127.0.0.1:27017/admin?directConnection=true&tls=true' \
--tlsCAFile /etc/mongodb/tls/ca-chain.pem \
--username platform_admin --authenticationDatabase admin --password \
--norc --quiet --eval \
'print(EJSON.stringify(db.getUser("platform_admin"),null,2)); exit(0)'将订单运行、迁移与备份身份分开
管理员随后创建只允许目标 database 的角色。运行身份获得 orders 和 order_events 的 find/insert/update,不能 drop collection、管理用户或读 admin;迁移身份获得 dbAdmin 与索引/validator 变更;备份身份按官方 Database Tools 所需最小权限单独创建。实际密码继续通过 passwordPrompt() 或 secret-backed identity runner 设置。
const admin = db.getSiblingDB("admin")
const app = db.getSiblingDB("te_app")
app.createRole({
role: "order_runtime",
privileges: [
{
resource: { db: "te_app", collection: "orders" },
actions: ["find", "insert", "update"]
},
{
resource: { db: "te_app", collection: "order_events" },
actions: ["find", "insert"]
},
{
resource: { db: "te_app", collection: "outbox" },
actions: ["find", "insert", "update"]
},
{
resource: { db: "te_app", collection: "payment_records" },
actions: ["find", "insert", "update"]
}
],
roles: []
})
app.createUser({
user: "te_app_runtime",
pwd: passwordPrompt(),
roles: [{ role: "order_runtime", db: "te_app" }]
})
admin.createUser({
user: "te_backup",
pwd: passwordPrompt(),
roles: [{ role: "backup", db: "admin" }]
})
app.createUser({
user: "te_app_migrate",
pwd: passwordPrompt(),
roles: [
{ role: "readWrite", db: "te_app" },
{ role: "dbAdmin", db: "te_app" }
]
})
print(EJSON.stringify({
runtime: app.getUser("te_app_runtime"),
migrate: app.getUser("te_app_migrate"),
backup: admin.getUser("te_backup")
}, null, 2))角色验证必须包含反例:runtime 能按 tenantId 插入、更新、查询订单,但 drop、createIndex、读取 admin.system.users 都返回 Unauthorized。若反例成功,立即禁用错误身份并检查直接角色、继承角色与认证库,不能只在应用层隐藏管理入口。
Docker Compose 只承担本地副本集复现
事务、retryable writes、change stream 和选主都依赖 Replica Set 语义,单个 standalone 容器不能验证这些行为。下面的 Compose 只为开发机提供三成员副本集,三个端口全部绑定回环地址;它不具备生产证书、故障域、持久盘、监控和备份能力。镜像同时固定 tag 与多架构 digest,升级时由维护者重新核验 digest,而不是把 latest 当版本策略。
services:
mongo1:
image: mongo:8.0.29-noble@sha256:021b2d5ae9d253f2cca17491cc8d03aed8df3c840f3252066aa62d3277fc406e
command: ["mongod", "--replSet", "rs-dev", "--bind_ip_all"]
ports: ["127.0.0.1:27018:27017"]
volumes: ["mongo1:/data/db"]
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand({ping:1}).ok ? 0 : 2)"]
interval: 5s
timeout: 3s
retries: 30
mongo2:
image: mongo:8.0.29-noble@sha256:021b2d5ae9d253f2cca17491cc8d03aed8df3c840f3252066aa62d3277fc406e
command: ["mongod", "--replSet", "rs-dev", "--bind_ip_all"]
ports: ["127.0.0.1:27019:27017"]
volumes: ["mongo2:/data/db"]
mongo3:
image: mongo:8.0.29-noble@sha256:021b2d5ae9d253f2cca17491cc8d03aed8df3c840f3252066aa62d3277fc406e
command: ["mongod", "--replSet", "rs-dev", "--bind_ip_all"]
ports: ["127.0.0.1:27020:27017"]
volumes: ["mongo3:/data/db"]
volumes:
mongo1:
mongo2:
mongo3:启动后只初始化一次。成员地址必须使用 Compose 网络内可解析的服务名;如果写成 localhost,其他成员会把 localhost 解释成自己。初始化返回 ok: 1 后,等待一个 PRIMARY 和两个 SECONDARY,再在同一 Compose 网络内用种子列表连接。Replica Set 会向客户端公布 mongo1:27017 等成员地址,宿主机上的驱动若不能解析这些名字就不能完成拓扑发现;宿主映射端口只用于 directConnection=true 的单节点诊断,应用联调应作为 Compose service 加入同一网络。开发实例没有认证,只能存在于个人隔离环境,不能通过删除回环端口绑定“临时共享”。
set -euo pipefail
docker compose up -d --wait
docker compose exec -T mongo1 mongosh --quiet --eval '
rs.initiate({_id:"rs-dev",members:[
{_id:0,host:"mongo1:27017",priority:2},
{_id:1,host:"mongo2:27017",priority:1},
{_id:2,host:"mongo3:27017",priority:1}
]})'
for attempt in $(seq 1 60); do
state="$(docker compose exec -T mongo1 mongosh --quiet --eval \
'print(rs.status().members.filter(m => m.stateStr === "PRIMARY").length + ":" + rs.status().members.filter(m => m.stateStr === "SECONDARY").length)' \
2>/dev/null || true)"
test "$state" = '1:2' && break
test "$attempt" -lt 60 || exit 1
sleep 2
done
docker compose exec -T mongo1 mongosh \
'mongodb://mongo1:27017,mongo2:27017,mongo3:27017/te_app?replicaSet=rs-dev' \
--quiet --eval 'print(EJSON.stringify(db.hello(), null, 2))'清理前先确认当前目录和 Compose project name 都是这个实验,再执行 docker compose down。只有明确要丢弃本地测试数据时才加 --volumes;volume 删除不可恢复,不能把它写进普通停机命令。生产恢复演练必须使用隔离主机和独立备份,不以重建开发容器代替。
用迁移脚本建立 validator、索引与正反数据
应用启动不应自动创建或修改 collection。DDL 由独立迁移身份执行,迁移脚本先读实际状态,再决定 createCollection、collMod 或 createIndex;名字相同但 key、unique、partialFilterExpression 不同必须失败,不能静默认为“已经存在”。下面的骨架把迁移记录和业务变更放在同一数据库,记录只保存版本、摘要与完成时间,不保存密码和连接串。
按实际状态收敛订单约束和索引
// migrations/001-orders.js
const app = db.getSiblingDB("te_app")
const version = "001-orders-v1"
const checksum = process.env.MIGRATION_CHECKSUM
if (!/^sha256:[0-9a-f]{64}$/.test(checksum ?? "")) {
throw new Error("MIGRATION_CHECKSUM must contain the reviewed script digest")
}
app.schema_migrations.createIndex({ version: 1 }, { unique: true, name: "migration_version" })
const applied = app.schema_migrations.findOne({ version })
if (applied) {
if (applied.checksum !== checksum) throw new Error("migration checksum changed")
print(`already applied: ${version}`)
exit(0)
}
const orderValidator = {
$jsonSchema: {
bsonType: "object",
required: [
"tenantId", "orderNo", "buyerId", "status", "amount", "items",
"businessFingerprint", "createdAt", "updatedAt"
],
properties: {
tenantId: { bsonType: "string", pattern: "^[a-z0-9-]{2,40}$" },
orderNo: { bsonType: "string", pattern: "^ORD-[A-Z0-9-]{6,48}$" },
buyerId: { bsonType: "objectId" },
status: { enum: ["CREATED", "PAID", "CANCELLED"] },
amount: {
bsonType: "object",
required: ["currency", "value"],
properties: {
currency: { enum: ["CNY", "USD"] },
value: { bsonType: "decimal" }
}
},
items: {
bsonType: "array", minItems: 1, maxItems: 200,
items: {
bsonType: "object",
required: ["productId", "nameSnapshot", "quantity", "unitPrice"],
properties: {
productId: { bsonType: "objectId" },
nameSnapshot: { bsonType: "string", minLength: 1, maxLength: 200 },
quantity: { bsonType: "int", minimum: 1 },
unitPrice: { bsonType: "decimal" }
}
}
},
businessFingerprint: { bsonType: "string", pattern: "^[0-9a-f]{64}$" },
createdAt: { bsonType: "date" },
updatedAt: { bsonType: "date" }
}
}
}
const existing = app.getCollectionInfos({ name: "orders" })[0]
if (!existing) {
assert.commandWorked(app.createCollection("orders", {
validator: orderValidator,
validationLevel: "strict",
validationAction: "error"
}))
} else {
assert.commandWorked(app.runCommand({
collMod: "orders",
validator: orderValidator,
validationLevel: "strict",
validationAction: "error"
}))
}
const requiredIndexes = [
{ key: { tenantId: 1, orderNo: 1 }, name: "tenant_order_unique", unique: true },
{ key: { tenantId: 1, status: 1, createdAt: -1, _id: -1 }, name: "tenant_status_created_id" }
]
for (const spec of requiredIndexes) {
const actual = app.orders.getIndexes().find(index => index.name === spec.name)
if (!actual) {
app.orders.createIndex(spec.key, { name: spec.name, unique: spec.unique === true })
continue
}
if (EJSON.stringify(actual.key) !== EJSON.stringify(spec.key) ||
Boolean(actual.unique) !== Boolean(spec.unique)) {
throw new Error(`index drift: ${spec.name}`)
}
}
const recorded = app.schema_migrations.insertOne({
version,
checksum,
appliedAt: new Date()
})
assert.eq(recorded.acknowledged, true)用脚本摘要、数据反例和权限反例验收
执行前由 shell 计算脚本真实 SHA-256,并通过非敏感环境变量交给 mongosh 记录,再通过迁移账号运行。正例必须包含与 Node.js 相同的 businessFingerprint、items 子项和两个时间字段,并能按 tenantId 与 orderNo 唯一读回;反例缺少 amount、伪造 fingerprint 或使用字符串 quantity 时必须得到 Document failed validation,重复复合业务键必须得到 E11000。随后以运行账号尝试 createIndex,必须返回 Unauthorized。只有正向数据、约束反例和权限反例同时成立,迁移才完成。
set -euo pipefail
digest="$(sha256sum migrations/001-orders.js | awk '{print $1}')"
export MIGRATION_CHECKSUM="sha256:$digest"
mongosh "$MONGODB_SEED_URI" \
--username te_app_migrate --authenticationDatabase te_app --password \
--tls --tlsCAFile /etc/ssl/certs/company-mongodb-ca.pem \
--norc --quiet --file migrations/001-orders.js
unset MIGRATION_CHECKSUM大 collection 收紧 validator 前要先查询不合规数量并修复存量。索引构建前用目标数据副本估算空间、耗时和 commit quorum,构建后核对 getIndexes() 与 explain。迁移失败应保留已完成的幂等步骤并修正脚本继续,不要自动 drop collection 或删除索引“回到起点”。
完成 Node.js 驱动接入与未知结果收敛
示例使用 Node.js 20.19 及以上版本和官方 mongodb@7.6.0。进程只创建一个 MongoClient,连接池由该实例长期复用;每个请求新建 client 会同时放大握手、认证、监控连接和 server selection。用户名、密码作为构造选项传入,URI 只保存拓扑地址和数据库名,避免密码中的保留字符、日志脱敏遗漏和进程参数泄漏。
固定驱动、连接池和超时边界
{
"name": "mongodb-order-service",
"private": true,
"type": "module",
"engines": { "node": ">=20.19.0" },
"scripts": {
"start": "node src/server.mjs",
"smoke": "node scripts/smoke.mjs"
},
"dependencies": {
"mongodb": "7.6.0"
}
}连接配置把拓扑选择、建连、连接池等待和单次操作期限分开。socketTimeoutMS 保持为 0,让每个业务操作用 timeoutMS 或 AbortSignal 给出真实期限,避免一个全局 socket 读超时误杀长短不同的操作。maxPoolSize 必须和实例副本数、Replica Set 成员数、服务端连接预算一起计算。
// src/mongo.mjs
import { MongoClient, ServerApiVersion } from "mongodb"
const required = name => {
const value = process.env[name]
if (!value) throw new Error(`missing environment variable: ${name}`)
return value
}
const client = new MongoClient(required("MONGODB_URI"), {
auth: {
username: required("MONGODB_USERNAME"),
password: required("MONGODB_PASSWORD")
},
authSource: "te_app",
tls: true,
tlsCAFile: required("MONGODB_CA_FILE"),
serverApi: { version: ServerApiVersion.v1, strict: true, deprecationErrors: true },
appName: "order-api",
retryReads: true,
retryWrites: true,
maxPoolSize: 40,
minPoolSize: 0,
maxConnecting: 2,
waitQueueTimeoutMS: 1500,
serverSelectionTimeoutMS: 3000,
connectTimeoutMS: 3000,
socketTimeoutMS: 0
})
let connectPromise
export async function mongo() {
connectPromise ??= client.connect().catch(error => {
connectPromise = undefined
throw error
})
await connectPromise
return client.db("te_app")
}
export async function mongoReadiness() {
const database = await mongo()
return database.command({ ping: 1 }, { timeoutMS: 2000 })
}
export async function closeMongo() {
await client.close()
}用复合业务键收敛未知写入结果
订单创建以 tenantId + orderNo 唯一索引作为幂等边界。第一次请求用 $setOnInsert 写入完整语义;重复请求先比较业务 fingerprint。相同 fingerprint 返回原订单,不同 fingerprint 返回冲突,不能因为 orderNo 相同就把两次不同购买合并。网络中断、not primary 和 write concern timeout 都可能让客户端不知道写入是否已提交,捕获异常后必须按幂等键读回确认。
// src/orders.mjs
import crypto from "node:crypto"
import { Decimal128, EJSON, ObjectId } from "mongodb"
import { mongo } from "./mongo.mjs"
function canonicalOrder(input) {
if (!/^[a-z0-9-]{2,40}$/.test(input.tenantId ?? "")) throw new TypeError("invalid tenantId")
if (!/^ORD-[A-Z0-9-]{6,48}$/.test(input.orderNo ?? "")) throw new TypeError("invalid orderNo")
if (!["CNY", "USD"].includes(input.currency)) throw new TypeError("invalid currency")
if (!/^(0|[1-9]\d{0,11})(\.\d{1,2})?$/.test(String(input.amount))) {
throw new TypeError("invalid amount")
}
if (!Array.isArray(input.items) || input.items.length < 1 || input.items.length > 200) {
throw new TypeError("items must contain 1..200 entries")
}
const items = input.items.map(item => ({
productId: new ObjectId(item.productId),
nameSnapshot: String(item.nameSnapshot).slice(0, 200),
quantity: Number.parseInt(item.quantity, 10),
unitPrice: Decimal128.fromString(String(item.unitPrice))
}))
if (items.some(item => !Number.isSafeInteger(item.quantity) || item.quantity < 1)) {
throw new TypeError("invalid quantity")
}
return {
tenantId: input.tenantId,
orderNo: input.orderNo,
buyerId: new ObjectId(input.buyerId),
status: "CREATED",
amount: { currency: input.currency, value: Decimal128.fromString(String(input.amount)) },
items
}
}
function fingerprint(order) {
const semantic = EJSON.stringify(order, { relaxed: false })
return crypto.createHash("sha256").update(semantic).digest("hex")
}
export async function createOrder(input) {
const database = await mongo()
const orders = database.collection("orders")
const order = canonicalOrder(input)
order.businessFingerprint = fingerprint(order)
const now = new Date()
try {
const result = await orders.findOneAndUpdate(
{ tenantId: order.tenantId, orderNo: order.orderNo },
{ $setOnInsert: { ...order, createdAt: now, updatedAt: now } },
{
upsert: true,
returnDocument: "after",
writeConcern: { w: "majority", j: true, wtimeoutMS: 3000 },
timeoutMS: 4500,
comment: "orders.create"
}
)
if (result.businessFingerprint !== order.businessFingerprint) {
const conflict = new Error("orderNo already represents different semantics")
conflict.code = "IDEMPOTENCY_CONFLICT"
throw conflict
}
return result
} catch (error) {
const uncertain = error.hasErrorLabel?.("RetryableWriteError") ||
error.hasErrorLabel?.("UnknownTransactionCommitResult") ||
error.codeName === "WriteConcernFailed" ||
error.name === "MongoOperationTimeoutError" ||
error.name === "MongoNetworkError"
if (!uncertain) throw error
const committed = await orders.findOne(
{ tenantId: order.tenantId, orderNo: order.orderNo },
{ readConcern: { level: "majority" }, timeoutMS: 2500, comment: "orders.create.verify" }
)
if (committed?.businessFingerprint === order.businessFingerprint) return committed
throw error
}
}在 HTTP 边界完成 readiness 与优雅关闭
健康检查只证明进程活着;readiness 要在短期限内完成 ping,并在服务停止接流量后等待请求排空再关闭 MongoClient。SIGTERM 不能立刻 process.exit(),否则连接中的写会被本地中断,客户端更容易得到未知结果。
// src/server.mjs
import http from "node:http"
import { closeMongo, mongoReadiness } from "./mongo.mjs"
import { createOrder } from "./orders.mjs"
let draining = false
const server = http.createServer(async (request, response) => {
try {
if (request.url === "/live") return response.end("ok")
if (request.url === "/ready") {
if (draining) throw new Error("draining")
await mongoReadiness()
return response.end("ready")
}
if (request.method !== "POST" || request.url !== "/orders") {
response.writeHead(404).end()
return
}
let body = ""
for await (const chunk of request) {
body += chunk
if (body.length > 1_000_000) throw new Error("request too large")
}
const order = await createOrder(JSON.parse(body))
response.writeHead(200, { "content-type": "application/json" })
response.end(JSON.stringify({ orderNo: order.orderNo, status: order.status }))
} catch (error) {
const status = error.code === "IDEMPOTENCY_CONFLICT"
? 409
: error instanceof SyntaxError || error instanceof TypeError
? 400
: 503
response.writeHead(status).end()
}
})
server.listen(8080, "127.0.0.1")
async function shutdown() {
if (draining) return
draining = true
const forceTimer = setTimeout(async () => {
server.closeAllConnections()
await closeMongo().catch(() => {})
process.exit(1)
}, 25_000)
server.close(async () => {
clearTimeout(forceTimer)
await closeMongo()
process.exitCode = 0
})
}
process.on("SIGTERM", shutdown)
process.on("SIGINT", shutdown)用重复请求、语义冲突和选主验证闭环
smoke 脚本用随机业务号发送同一请求两次,证明只产生一个订单;再用同一 orderNo 改 amount,必须返回 409,并从数据库读回唯一文档和 fingerprint。脚本不删除验证数据,因为运行账号没有 remove 权限;测试租户的数据由明确的保留策略异步清理。
// scripts/smoke.mjs
import assert from "node:assert/strict"
import crypto from "node:crypto"
import { ObjectId } from "mongodb"
import { closeMongo, mongo } from "../src/mongo.mjs"
const baseUrl = process.env.ORDER_API_BASE_URL ?? "http://127.0.0.1:8080"
const orderNo = `ORD-SMOKE-${crypto.randomBytes(8).toString("hex").toUpperCase()}`
const payload = {
tenantId: "smoke-only",
orderNo,
buyerId: new ObjectId().toHexString(),
currency: "CNY",
amount: "199.00",
items: [{
productId: new ObjectId().toHexString(),
nameSnapshot: "smoke keyboard",
quantity: 1,
unitPrice: "199.00"
}]
}
async function post(body) {
const response = await fetch(`${baseUrl}/orders`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(7000)
})
return { status: response.status, text: await response.text() }
}
try {
const first = await post(payload)
assert.equal(first.status, 200, first.text)
const repeated = await post(payload)
assert.equal(repeated.status, 200, repeated.text)
const conflict = await post({ ...payload, amount: "299.00" })
assert.equal(conflict.status, 409, conflict.text)
const database = await mongo()
const documents = await database.collection("orders").find(
{ tenantId: payload.tenantId, orderNo },
{ projection: { businessFingerprint: 1, status: 1 }, timeoutMS: 3000 }
).toArray()
assert.equal(documents.length, 1)
assert.match(documents[0].businessFingerprint, /^[0-9a-f]{64}$/)
assert.equal(documents[0].status, "CREATED")
process.stdout.write(`${JSON.stringify({ ok: true, orderNo })}\n`)
} finally {
await closeMongo()
}运行环境由 secret manager 注入运行身份,URI 本身不含密码。先等 readiness,再执行 package.json 已声明的 smoke;任何断言失败都会让 Node 返回非零退出码。
set -euo pipefail
: "${MONGODB_URI:?seed URI without password}"
: "${MONGODB_USERNAME:?runtime username}"
: "${MONGODB_PASSWORD:?injected secret}"
: "${MONGODB_CA_FILE:?trusted CA path}"
npm start &
app_pid=$!
trap 'kill "$app_pid" 2>/dev/null || true; wait "$app_pid" 2>/dev/null || true' EXIT
for attempt in $(seq 1 30); do
curl --fail --silent http://127.0.0.1:8080/ready >/dev/null && break
test "$attempt" -lt 30 || exit 1
sleep 1
done
npm run smoke基础 smoke 通过后再停掉当前 Primary。新订单调用应在驱动重新选择 Primary 后成功,或者返回可由幂等键读回核验的错误;验证同时检查数据库文档、应用日志、选主时间和连接池恢复,不能只看 HTTP 200。
mongosh 把交互排障和可重复自动化分开
mongosh 是 MongoDB Shell 的命令行入口,它与 Server、Database Tools 和 Compass 分别发版。先用 mongosh --build-info 核对 shell 版本、运行时和平台;连接成功后再用 db.version() 读服务端版本,不要把两者混为一个号。人工登录只在命令行给出 username 和 authenticationDatabase,把无参数 --password 放在最后触发隐藏提示;安全自动化应改用驱动和 secret manager,不得把密码写进 URI、进程参数或 shell 历史。
set -euo pipefail
mongosh --build-info
timeout --signal=TERM --kill-after=5s 60s \
mongosh "mongodb://127.0.0.1:27018/te_app?directConnection=true" \
--username te_app_runtime --authenticationDatabase te_app --password可重复诊断放进版本化的 JavaScript 文件,使用 --file;短小只读探针才使用 --eval。--quiet 只抑制启动噪声,不会隐藏脚本自己打印的敏感字段。机器可读输出用 EJSON.stringify,才能无损表达 ObjectId、Decimal128 和 Date。脚本用 exit( 明确设置 0 为成功、非 0 为失败;操作系统随后检查 $?,不能只搜索输出中是否出现 ok 字样。
// scripts/mongo-readiness.js:只读,可重复执行
const hello = db.hello()
print(EJSON.stringify({
ok: hello.ok,
setName: hello.setName,
isWritablePrimary: hello.isWritablePrimary,
hosts: hello.hosts
}, null, 2, { relaxed: false }))
exit(hello.ok === 1 && hello.isWritablePrimary === true ? 0 : 4)set -euo pipefail
timeout --signal=TERM --kill-after=5s 30s \
mongosh "mongodb://127.0.0.1:27018/admin?directConnection=true" \
--quiet --norc --file ./scripts/mongo-readiness.js
timeout --signal=TERM --kill-after=5s 30s \
mongosh "mongodb://127.0.0.1:27018/admin?directConnection=true" \
--quiet --norc --eval \
'print(EJSON.stringify(db.adminCommand({ping:1}), null, 0, {relaxed:false})); exit(0)'交互会话启动时会读用户主目录的 .mongoshrc.js;它只适合设置提示、显示和无副作用辅助函数,不应包含凭证、自动写入或依赖当前数据库的逻辑。--eval 和脚本执行也会加载它,所以生产自动化使用 --norc 隔离个人配置。Linux 用户历史位于 ~/.mongodb/mongosh/mongosh_repl_history,必须限制文件权限并按终端数据保留规则清理。
config.set("redactHistory", "remove-redact")
disableTelemetry()redactHistory 只是对已知敏感模式的 best-effort 删除或脱敏,不能作为将 secret 输入交互历史的许可。disableTelemetry() 用于关闭 shell 的匿名使用数据上报,安全基线应明确固定选择并审计配置。需要可视化浏览、聚合搭建和 explain 界面时,请转到独立的 MongoDB Compass文章;Compass 不代替可版本化脚本和退出码门禁。
建立三个故障域的 Replica Set 并演练选主
生产最小拓扑是三个保存数据且可投票的成员,分别放在独立故障域。arbiter 不保存数据,不能提高数据冗余,还会改变多数写的持久性边界;除非已有明确约束和故障证明,不把它当“省一台数据节点”的默认方案。成员之间使用稳定 FQDN 与私网地址,证书 SAN 包含对应 FQDN,DNS、时钟和防火墙在初始化前先验证。
将单成员引导拓扑扩成三个数据成员
三台主机分别让 net.bindIp 包含 loopback 和自己的私网地址,保持同一个 replSetName 和同一 keyfile。前一节已用 mongo-01 的真实 FQDN 建立单成员 Replica Set;此处使用平台管理员保留成员 0 的 hostname,并把另外两台加入同一配置。成员优先级表达首选 Primary,不是强制角色,所有数据节点仍需承受完整业务写入、索引和 oplog。
// 在 mongo-01 上使用平台管理员执行一次
const config = rs.conf()
config.version += 1
config.members = [
{ _id: 0, host: "mongo-01.internal.example:27017", priority: 2, votes: 1 },
{ _id: 1, host: "mongo-02.internal.example:27017", priority: 1, votes: 1 },
{ _id: 2, host: "mongo-03.internal.example:27017", priority: 1, votes: 1 }
]
const result = rs.reconfig(config)
print(EJSON.stringify(result, null, 2))等到一个 PRIMARY、两个 SECONDARY 和零 RECOVERING/ROLLBACK 后,检查 config version、term、optime、lastCommittedOpTime、sync source 与 oplog window。初始化成功不等于可投产;先建 schema 和索引,再从应用网段以运行账号验证 TLS、认证、majority 写和读回。
const status = rs.status()
print(EJSON.stringify(status.members.map(member => ({
name: member.name,
state: member.stateStr,
health: member.health,
optimeDate: member.optimeDate,
syncSourceHost: member.syncSourceHost
})), null, 2))
rs.printReplicationInfo()
rs.printSecondaryReplicationInfo()
db.adminCommand({ getDefaultRWConcern: 1 })应用 URI 至少包含两个种子地址、replicaSet=rs-app、tls=true 和目标 database。驱动会从 hello 发现完整拓扑,种子不是永久 Primary 地址。默认读保持 primary;报表读若使用 secondaryPreferred,必须接受延迟、故障时回退 Primary 的负载变化,以及索引在 secondary 同样占用资源。依赖刚写结果的读使用 causally consistent session 和 majority concerns,不用 sleep 猜复制完成。
用 stepDown 证明驱动和幂等写能够收敛
选主演练在受控窗口执行。先记录当前 Primary 和应用基线,再对 Primary 执行 rs.stepDown(60);预期旧 Primary 转为 SECONDARY,另一个合格成员当选,应用在 serverSelectionTimeoutMS 内恢复,幂等写没有重复。不能通过关闭两个成员来证明“高可用”,因为三投票成员失去多数时就必须停止写入,这是防止双主写的安全行为。
const beforeHello = db.hello()
const beforeStatus = rs.status()
print(EJSON.stringify({
primary: beforeHello.primary,
term: beforeStatus.term,
topologyVersion: beforeHello.topologyVersion
}, null, 2))
rs.stepDown(60)演练完成后确认旧 Primary 重新加入、复制 lag 回到基线、majority commit point 前进、应用错误率和连接池恢复。若旧节点落后超过 oplog window,它不能通过普通复制追上,只能 initial sync 或从可信备份重建;重建前保留日志和数据目录快照,确认没有尚未核对的 rollback 数据。
用事务与 Change Streams 验证副本集语义
事务和 Change Streams 都要求 Replica Set 或 Sharded Cluster。它们不适用于前面的 standalone 学习容器;完成三成员副本集后,再从应用使用的 URI 运行下面实验。
多文档事务必须整体重试
订单状态与 outbox 事件需要同步提交时,可以使用事务。回调里不能发送支付、邮件或 HTTP 请求,因为驱动可能在 TransientTransactionError 后重跑整个回调。
const session = db.getMongo().startSession({ causalConsistency: true })
const app = session.getDatabase("te_app")
session.withTransaction(() => {
const changed = app.orders.updateOne(
{ tenantId: "tenant-a", orderNo: "ORD-T0-0001", status: "CREATED" },
{ $set: { status: "PAID", paidAt: new Date() } }
)
if (changed.matchedCount !== 1) {
throw new Error("order is not in CREATED state")
}
app.outbox.insertOne({
eventId: "EVT-T0-0001",
type: "ORDER_PAID",
tenantId: "tenant-a",
orderNo: "ORD-T0-0001",
createdAt: new Date()
})
}, {
readConcern: { level: "snapshot" },
writeConcern: { w: "majority", wtimeout: 3000 },
maxCommitTimeMS: 3000
})
session.endSession()正向结果是订单变成 PAID,outbox 只出现一个 eventId。反向实验把订单状态预先改为 CANCELLED,回调会抛错,随后确认 outbox 中没有 EVT-T0-0001。如果事务内的任一步已经调用外部系统,即使数据库回滚,外部副作用也不会自动撤销;生产通常由 outbox 消费者在提交后发送消息,并用 eventId 去重。
Change Stream 从 resume token 继续,而不是从“刚才”猜测
Change Stream 读取 oplog 上的变更事件。先在一个终端打开监听:
db = db.getSiblingDB("te_app")
const stream = db.orders.watch(
[{ $match: { operationType: { $in: ["insert", "update", "replace"] } } }],
{ fullDocument: "updateLookup", maxAwaitTimeMS: 2000 }
)
while (stream.hasNext()) {
const event = stream.next()
print(EJSON.stringify({
resumeToken: event._id,
operationType: event.operationType,
documentKey: event.documentKey,
fullDocument: event.fullDocument
}, null, 2, { relaxed: false }))
}在另一个终端更新一笔订单,监听端应收到 update 和对应文档。消费者必须在业务处理成功后持久保存 event._id 这个 resume token;重启时用 resumeAfter 继续。token 已超出 oplog window 时会失败,不能悄悄从当前时刻继续,否则中间事件会丢失。此时要从业务快照重建读取模型,再从新的 token 接续。
只有容量与路由证据成立才引入 Sharding
Sharding 解决单个 Replica Set 无法承载的数据量或写入吞吐,不是“以后可能变大”就提前引入的默认层。生产 Sharded Cluster 至少包含三成员 Config Server Replica Set、两个以上各自三成员的数据 shard,以及两个以上 mongos 路由;config server 不存业务 collection,mongos 不持久保存业务数据。每个组件都要独立 TLS、认证、监控、备份和滚动升级。
先建立 Config Server 与两个数据 Shard
先在九台 mongod 主机和两台 mongos 主机安装同一批准的 Server 补丁,并分发同一内部认证 keyfile、各主机自己的双 EKU TLS 证书和受信 CA。证书在首次 localhost 初始化时还要包含 IP:127.0.0.1 SAN。Config Server 成员使用 clusterRole: configsvr 与专用端口 27019;每个数据 shard 自身是 Replica Set,使用 clusterRole: shardsvr 与端口 27018。下面分别是 config-01 和 shard-a1 的配置,其余成员只替换 dbPath、私网 bindIp 与证书,Shard B 把 replSetName 改为 rs-shard-b。
# config-01:/etc/mongod.conf
storage:
dbPath: /srv/mongodb/config
systemLog:
destination: file
path: /srv/mongodb/log/configsvr.log
logAppend: true
net:
port: 27019
bindIp: 127.0.0.1,10.0.10.11
tls:
mode: requireTLS
certificateKeyFile: /etc/mongodb/tls/server.pem
CAFile: /etc/mongodb/tls/ca-chain.pem
security:
authorization: enabled
keyFile: /etc/mongodb/keyfile
replication:
replSetName: csrs
sharding:
clusterRole: configsvr# shard-a1:/etc/mongod.conf
storage:
dbPath: /srv/mongodb/shard-a
systemLog:
destination: file
path: /srv/mongodb/log/shard-a.log
logAppend: true
net:
port: 27018
bindIp: 127.0.0.1,10.0.20.11
tls:
mode: requireTLS
certificateKeyFile: /etc/mongodb/tls/server.pem
CAFile: /etc/mongodb/tls/ca-chain.pem
security:
authorization: enabled
keyFile: /etc/mongodb/keyfile
replication:
replSetName: rs-shard-a
sharding:
clusterRole: shardsvr每台主机先建立对应目录、检查服务账号读写和端口防火墙,再由 systemd 启动 mongod。只在各 Replica Set 的第一台主机通过 localhost exception 执行一次 initiate;Config Server 配置必须带 configsvr: true。三个集合分别等到一个 PRIMARY、两个 SECONDARY 后再启动 mongos。Sharded Cluster 的首个平台管理员通过 mongos 创建并存储在 CSRS,不在每个 shard 复制一套应用用户。
// config-01:27019
rs.initiate({
_id: "csrs",
configsvr: true,
members: [
{ _id: 0, host: "config-01.internal.example:27019" },
{ _id: 1, host: "config-02.internal.example:27019" },
{ _id: 2, host: "config-03.internal.example:27019" }
]
})
// shard-a1:27018;Shard B 使用 rs-shard-b 与 shard-b1..b3
rs.initiate({
_id: "rs-shard-a",
members: [
{ _id: 0, host: "shard-a1.internal.example:27018", priority: 2 },
{ _id: 1, host: "shard-a2.internal.example:27018", priority: 1 },
{ _id: 2, host: "shard-a3.internal.example:27018", priority: 1 }
]
})分别关闭每个 Shard 的 localhost exception
mongos 上的集群用户不会关闭每个 shard 自己的 localhost exception。为避免主机本地执行权被升级成无认证数据库 root,Shard A 与 Shard B 成为 Primary 后,要分别在 shard-a1 和 shard-b1 的 27018 loopback 端口运行一次 first-admin.js,为两个 shard 创建密码分别托管的 shard-local 平台管理员。随后用已认证会话执行 connectionStatus;未经认证的 createUser 必须变成 Unauthorized。这个身份不进入应用连接,也不代替通过 mongos 管理集群。
# 分别在 shard-a1 与 shard-b1 执行;两次密码由 secret manager 分开保存
mongosh 'mongodb://127.0.0.1:27018/admin?directConnection=true&tls=true' \
--tlsCAFile /etc/mongodb/tls/ca-chain.pem \
--quiet --norc --file /srv/mongodb/bootstrap/first-admin.js
mongosh 'mongodb://127.0.0.1:27018/admin?directConnection=true&tls=true' \
--tlsCAFile /etc/mongodb/tls/ca-chain.pem \
--username platform_admin --authenticationDatabase admin --password \
--quiet --norc --eval \
'print(EJSON.stringify(db.runCommand({connectionStatus:1}),null,2))'
set +e
mongosh 'mongodb://127.0.0.1:27018/admin?directConnection=true&tls=true' \
--tlsCAFile /etc/mongodb/tls/ca-chain.pem --quiet --norc --eval '
const admin = db.getSiblingDB("admin")
try {
admin.createUser({
user:"localhost_bypass_probe",
pwd:"intentionally-unused-probe",
roles:[]
})
print("unexpected: unauthenticated createUser succeeded")
exit(42)
} catch (error) {
if (error.code !== 13 && error.codeName !== "Unauthorized") throw error
print("expected: unauthenticated createUser is Unauthorized")
exit(0)
}'
probe_status=$?
set -e
if test "$probe_status" -eq 42; then
mongosh 'mongodb://127.0.0.1:27018/admin?directConnection=true&tls=true' \
--tlsCAFile /etc/mongodb/tls/ca-chain.pem \
--username platform_admin --authenticationDatabase admin --password \
--quiet --norc --eval \
'db.getSiblingDB("admin").dropUser("localhost_bypass_probe")'
exit 42
fi
test "$probe_status" -eq 0
mongosh 'mongodb://127.0.0.1:27018/admin?directConnection=true&tls=true' \
--tlsCAFile /etc/mongodb/tls/ca-chain.pem \
--username platform_admin --authenticationDatabase admin --password \
--quiet --norc --eval '
const admin = db.getSiblingDB("admin")
assert.eq(admin.getUser("localhost_bypass_probe"), null)
exit(0)'让两个 mongos 成为唯一业务入口
两台路由主机的 mongos 指向同一 CSRS,不配置 dbPath。首个 mongos 启动后通过它的 localhost exception 复用前文 first-admin.js 创建平台管理员;应用用户随后也只通过 mongos 创建,身份元数据才由整个 Sharded Cluster 统一使用。自定义 systemd unit 只负责进程生命周期,配置仍由受审查的 /etc/mongos.conf 提供。
# route-01:/etc/mongos.conf;route-02 只替换 bindIp 与证书
systemLog:
destination: file
path: /srv/mongodb/log/mongos.log
logAppend: true
net:
port: 27017
bindIp: 127.0.0.1,10.0.30.11
tls:
mode: requireTLS
certificateKeyFile: /etc/mongodb/tls/server.pem
CAFile: /etc/mongodb/tls/ca-chain.pem
security:
keyFile: /etc/mongodb/keyfile
sharding:
configDB: csrs/config-01.internal.example:27019,config-02.internal.example:27019,config-03.internal.example:27019# /etc/systemd/system/mongos.service
[Unit]
Description=MongoDB Sharded Cluster Router
After=network-online.target
Wants=network-online.target
[Service]
User=mongodb
Group=mongodb
ExecStart=/usr/bin/mongos --config /etc/mongos.conf
Restart=on-failure
RestartSec=5
LimitNOFILE=64000
[Install]
WantedBy=multi-user.targetset -euo pipefail
sudo install -d -o mongodb -g mongodb -m 0750 /srv/mongodb/log
sudo mongos --config /etc/mongos.conf --configExpand none --outputConfig \
>/var/tmp/mongos-effective.yaml
sudo systemctl daemon-reload
sudo systemctl enable --now mongos
sudo systemctl --no-pager --full status mongos
mongosh 'mongodb://127.0.0.1:27017/admin?tls=true' \
--tlsCAFile /etc/ssl/certs/company-mongodb-ca.pem \
--quiet --norc --file /srv/mongodb/bootstrap/first-admin.js
mongosh 'mongodb://route-01.internal.example:27017,route-02.internal.example:27017/admin?tls=true' \
--tlsCAFile /etc/ssl/certs/company-mongodb-ca.pem \
--username platform_admin --authenticationDatabase admin --password \
--quiet --eval \
'print(EJSON.stringify(db.getUser("platform_admin"),null,2)); sh.status()'mongos 必须同时发现 CSRS 三成员,两个路由都能认证且没有直接暴露公网,才进入 addShard。此时从 mongos 执行的用户、DDL 和业务命令会被统一路由;应用和普通运维不直接连接 shard,节点级恢复使用隔离网络与专用恢复身份。
用同一订单键证明定向路由,再决定切流
shard key 决定写入分布、查询定向、chunk split 和 migration 成本。低基数字段会形成巨型 chunk,单调递增键会把新写集中到尾部,纯 hashed key 均匀却失去范围定向。前面的订单模型已有 {tenantId: 1, orderNo: 1} 唯一业务约束,因此先把同一键作为候选 shard key,确保唯一索引以完整 shard key 为前缀;orderNo 必须使用非单调、高熵业务编号,避免大租户的新写长期落在单一尾部 chunk。若压测仍显示热点,需要重新评估数据模型或唯一约束,不能直接把 orderNo 改成 hashed 后期待原唯一索引继续合法。候选键仍需用真实租户倾斜、query sampling、读写比例和 zone 需求验证。
先在影子数据上执行 analyzeShardKey,观察 cardinality、frequency、monotonicity 和 read/write distribution。命令的结论依赖采样覆盖度;没有业务峰值样本时,漂亮分数不能证明生产分布。只有容量阈值、热点证据、选定 key 和恢复方案均成立,才在维护窗口把 Replica Set 加为 shard。
// 通过 mongos 使用 clusterManager 权限执行
sh.addShard("rs-shard-a/shard-a1.internal.example:27018,shard-a2.internal.example:27018,shard-a3.internal.example:27018")
sh.addShard("rs-shard-b/shard-b1.internal.example:27018,shard-b2.internal.example:27018,shard-b3.internal.example:27018")
sh.enableSharding("te_app")
db = db.getSiblingDB("te_app")
const shardKeyIndex = db.orders.getIndexes().find(
index => index.name === "tenant_order_unique"
)
assert(shardKeyIndex?.unique === true, "tenant_order_unique must remain unique")
assert.eq(
EJSON.stringify(shardKeyIndex.key),
EJSON.stringify({ tenantId: 1, orderNo: 1 })
)
db.adminCommand({
analyzeShardKey: "te_app.orders",
key: { tenantId: 1, orderNo: 1 },
keyCharacteristics: true,
readWriteDistribution: true
})
sh.shardCollection("te_app.orders", { tenantId: 1, orderNo: 1 })切流后用 explain 验证关键查询。包含完整 shard key 等值条件的订单查询应定向到一个 shard;只有 status 的后台查询会 scatter-gather,返回正确也可能在 shard 增长后失控。检查 sh.status()、config.collections、config.chunks 和 profiler 中目标 shard 数,不能只看 collection 已显示 sharded。
db.orders.find({ tenantId: "tenant-a", orderNo: "ORD-T0-0001" })
.explain("executionStats")
db.orders.find({ status: "PAID" }).limit(50)
.explain("executionStats")
sh.status()balancer 的 chunk migration 会在接收端复制数据、追赶修改、进入临界区并删除孤儿。业务高峰出现迁移延迟时,先确认目标 shard 容量、网络和磁盘,再决定短暂停止 balancer;长期关闭会积累不平衡。jumbo chunk、zone 范围重叠、stale routing 和 shard key 热点各有不同根因,不能用手工 moveChunk 无限搬运掩盖错误 key。reshardCollection 是高成本在线重写,必须预算临时磁盘、oplog、失败恢复和全链路压测。
GridFS 只在文件确实需要跟随 MongoDB 时使用
单个 BSON 文档不能超过 16 MiB。GridFS 会把文件元数据写入 fs.files,内容分块写入 fs.chunks,适合范围读取、文件需要跟随 MongoDB 复制和统一访问控制的场景。普通图片、视频、安装包和归档文件通常更适合对象存储,因为对象存储在 CDN、生命周期、成本和大对象吞吐上更成熟。
安装与 Server 兼容的 MongoDB Database Tools 后,先确认 mongofiles --version。敏感 URI 与密码写入权限为 0600 的工具配置,不放在进程参数中:
# /run/mongodb/mongofiles.yml
uri: mongodb://mongo-01.internal.example:27017/te_app?replicaSet=rs-app&tls=true
password: <injected-by-secret-manager>chmod 0600 /run/mongodb/mongofiles.yml
printf 'gridfs-smoke\n' >/tmp/gridfs-smoke.txt
sha256sum /tmp/gridfs-smoke.txt
mongofiles --config /run/mongodb/mongofiles.yml \
--db te_app --local /tmp/gridfs-smoke.txt \
put gridfs-smoke.txt
mongofiles --config /run/mongodb/mongofiles.yml \
--db te_app list gridfs-smoke
mongofiles --config /run/mongodb/mongofiles.yml \
--db te_app --local /tmp/gridfs-restored.txt \
get gridfs-smoke.txt
sha256sum /tmp/gridfs-restored.txt上传前后的 SHA-256 应一致,fs.files 中应只有一个预期文件。默认 put 不会覆盖同名对象;确实需要替换时也要先理解 --replace 的可见性和失败语义,不能把 GridFS 当成支持整文件原子覆盖的文件系统。实验结束执行 mongofiles ... delete gridfs-smoke.txt,再删除两个临时文件。
备份以隔离恢复通过为完成标准
备份策略从 RPO、RTO、数据规模和拓扑反推。小中型自管 Replica Set 可以用 Database Tools 做逻辑备份;大型部署更适合文件系统或云卷快照、Ops Manager/Cloud Manager 持续备份,Atlas 使用平台快照与 PITR。单独复制 dbPath、只备 Primary 某几个 collection 或在分片集群逐 shard 随机 dump,都不能自然得到一致恢复点。
生成带 oplog、校验和与完成标记的归档
Database Tools 与 Server 独立发版,先记录 mongodump --version。对 Replica Set 使用包含多个种子的 URI,并用 --oplog 捕获备份期间变化;--oplog 不能和限制 database、collection、query 等不兼容选项混用,所以下例生成整个 Replica Set 的逻辑归档,再在恢复验收中单独检查 te_app。凭证放权限为 0600 的 YAML 配置或 secret provider,日志和进程参数不出现密码。归档写到专用挂载点,完成后立即生成 SHA-256,再送往加密、不可变且与数据库账号隔离的存储。
# /run/secrets/mongodump.yml:--config 只接收 password、uri、sslPEMKeyPassword
uri: mongodb://te_backup@mongo-01.internal.example:27017,mongo-02.internal.example:27017,mongo-03.internal.example:27017/?replicaSet=rs-app&authSource=admin&tls=true
password: injected-by-secret-managerset -euo pipefail
umask 077
backup_root='/srv/mongodb-backup/published'
test -d "$backup_root"
test "$(stat -c '%a' /run/secrets/mongodump.yml)" = '600'
run_id="$(date -u +%Y%m%dT%H%M%SZ)-$(openssl rand -hex 4)"
staging="$backup_root/.${run_id}.part"
published="$backup_root/$run_id"
test ! -e "$staging"
test ! -e "$published"
mkdir "$staging"
mongodump --config /run/secrets/mongodump.yml \
--sslCAFile=/etc/ssl/certs/company-mongodb-ca.pem \
--archive="$staging/rs-app-full.archive.gz.part" \
--gzip --oplog --numParallelCollections=4
test -s "$staging/rs-app-full.archive.gz.part"
mv "$staging/rs-app-full.archive.gz.part" \
"$staging/rs-app-full.archive.gz"
mongodump --version > "$staging/database-tools-version.txt.part"
test -s "$staging/database-tools-version.txt.part"
mv "$staging/database-tools-version.txt.part" \
"$staging/database-tools-version.txt"
(cd "$staging" && \
sha256sum rs-app-full.archive.gz > rs-app-full.archive.gz.sha256.part && \
sha256sum --check rs-app-full.archive.gz.sha256.part)
mv "$staging/rs-app-full.archive.gz.sha256.part" \
"$staging/rs-app-full.archive.gz.sha256"
printf 'complete\n' > "$staging/COMPLETE.part"
mv "$staging/COMPLETE.part" "$staging/COMPLETE"
mv "$staging" "$published"
printf 'published=%s\n' "$published"唯一 run 目录和最后一次目录 rename 让发布者不会覆盖上一份有效归档。失败会留下以点开头、以 .part 结尾的 staging 目录,消费端只接受同时存在 archive、checksum、工具版本和 COMPLETE 的非隐藏目录。逻辑备份完成仍只证明 archive 可读取,不证明业务可恢复。
在隔离 Replica Set 回放 oplog
恢复演练使用与生产隔离的三成员 Replica Set,先核对目标 database 名称、可用磁盘和目标版本,再用 mongorestore --oplogReplay 应用备份窗口 oplog。官方要求 oplog replay 身份具备 anyAction on anyResource;这是接近 root 的临时能力,只能在无业务流量、网络隔离的新恢复集群创建。下面的专用角色与用户在恢复后立即删除,不能复制到生产集群。
// 只在隔离恢复 Replica Set 的 admin database 执行
const admin = db.getSiblingDB("admin")
admin.createRole({
role: "isolated_oplog_restore",
privileges: [{
resource: { anyResource: true },
actions: ["anyAction"]
}],
roles: [{ role: "restore", db: "admin" }]
})
admin.createUser({
user: "te_restore",
pwd: passwordPrompt(),
roles: [{ role: "isolated_oplog_restore", db: "admin" }]
})# /run/secrets/mongorestore.yml;仅位于隔离恢复主机,权限 0600
uri: mongodb://te_restore@restore-01.internal.example:27017,restore-02.internal.example:27017,restore-03.internal.example:27017/?replicaSet=rs-restore&authSource=admin&tls=true
password: injected-by-recovery-secret-manager--drop 会删除目标 collection,只有新建的隔离恢复库允许使用,不能把演练连接串指向共享环境。恢复命令先验证配置权限和归档 checksum,再执行 oplog replay。
set -euo pipefail
test "$(stat -c '%a' /run/secrets/mongorestore.yml)" = '600'
(cd /restore && sha256sum --check rs-app-full.archive.gz.sha256)
mongorestore --config /run/secrets/mongorestore.yml \
--sslCAFile=/etc/ssl/certs/company-mongodb-ca.pem \
--archive=/restore/rs-app-full.archive.gz \
--gzip --oplogReplay --drop --stopOnError用订单口径验收并撤销临时高权身份
业务验收通过后,由恢复控制面的平台管理员撤销临时高权身份。删除前保存 usersInfo 与恢复任务证据,删除后再次查询必须返回用户和角色不存在;隔离集群若仍保留用于调查,也不能继续持有这个账号。
const admin = db.getSiblingDB("admin")
print(EJSON.stringify(admin.getUser("te_restore"), null, 2))
assert(admin.dropUser("te_restore"))
assert(admin.dropRole("isolated_oplog_restore"))
assert.eq(admin.getUser("te_restore"), null)验收必须核对 collection 与 index 规格、validator、用户与角色策略、关键文档数、金额聚合、最大 createdAt、随机样本业务 fingerprint,并运行应用 smoke。还要用运行账号证明管理命令被拒绝,用故意错误的 CA 证明 TLS 验证失败。RPO 取备份一致点或 PITR 时间与事故时间之差,RTO 从宣布恢复开始到应用验收通过结束;“文件存在”没有 RPO/RTO 证据。
db = db.getSiblingDB("te_app")
print(EJSON.stringify({
collections: db.getCollectionInfos().map(item => ({ name: item.name, options: item.options })),
orderIndexes: db.orders.getIndexes(),
orderCount: db.orders.countDocuments({}),
newestOrder: db.orders.find().sort({ createdAt: -1 }).limit(1).toArray(),
paidGross: db.orders.aggregate([
{ $match: { status: "PAID" } },
{ $group: { _id: "$amount.currency", value: { $sum: "$amount.value" } } }
]).toArray()
}, null, 2, { relaxed: false }))分片集群的逻辑备份还要处理 balancer、跨 shard 事务和 config metadata 的一致性,普通逐 shard mongodump 不是通用在线一致备份。优先使用官方协调能力或经过验证的快照/PITR 产品,并演练恢复 config server、各 shard 和 mongos 路由。任何方案都要把 KMS 密钥、TLS CA、key vault 与数据库备份作为同一恢复依赖清单,但分权保存,避免一个凭证同时解锁全部资产。
按 Secondary、Primary 与 FCV 三阶段滚动升级
升级前先检查目标版本支持矩阵、驱动兼容、release notes、弃用项、当前 FCV、Replica Set 健康、oplog window、备份恢复演练和磁盘余量。FCV 控制可持久化的新特性,不等同于 binary 版本;所有 binary 升级且稳定观察前不能提升 FCV。跨 major 必须遵守官方允许的相邻升级路径,minor 轨道也按文档要求连续推进。
先按 Secondary 到 Primary 升级 binary
db.adminCommand({ getParameter: 1, featureCompatibilityVersion: 1 })
db.adminCommand({ getDefaultRWConcern: 1 })
rs.status()
rs.printReplicationInfo()
db.adminCommand({ getDiagnosticData: 1 })先解除一个 Secondary 的包 hold,停服务,安装所有 Server 组件的同一精确补丁,重新 hold 并启动。该节点回到 SECONDARY、lag 归零、日志没有降级或 storage 错误后,再处理下一个 Secondary。一次只动一个投票成员,整个过程持续保持多数;节点无法追上时停止升级并调查,不能继续消耗剩余冗余。
set -euo pipefail
target="${APPROVED_MONGODB_VERSION:?set the reviewed repository version}"
sudo apt-mark unhold mongodb-org mongodb-org-database mongodb-org-server \
mongodb-org-mongos mongodb-org-shell mongodb-org-tools mongodb-org-database-tools-extra
sudo systemctl stop mongod
sudo apt-get install -y \
mongodb-org="$target" \
mongodb-org-database="$target" \
mongodb-org-server="$target" \
mongodb-org-mongos="$target" \
mongodb-org-shell="$target" \
mongodb-org-tools="$target" \
mongodb-org-database-tools-extra="$target"
sudo apt-mark hold mongodb-org mongodb-org-database mongodb-org-server \
mongodb-org-mongos mongodb-org-shell mongodb-org-tools mongodb-org-database-tools-extra
sudo systemctl start mongod
sudo systemctl --no-pager --full status mongod
journalctl -u mongod -n 200 --no-pagerAPPROVED_MONGODB_VERSION 必须来自仓库实际存在且变更单已批准的版本;不能根据版本号规律猜下一个补丁。Primary 最后处理:先确认一个 Secondary 可选且追平,再执行 stepDown,让应用完成重新选主,随后升级旧 Primary。应用 smoke 要覆盖读、幂等写、事务或 change stream 等真实使用能力,并观察连接重建、错误标签和延迟。
再独立提升 FCV 并关闭回退窗口
只有所有成员运行目标 binary、复制稳定、备份兼容和回归通过,才在独立变更窗口提升 FCV。提升 FCV 可能启用不可由旧 binary 读取的持久化特性,回退不再只是安装旧包;命令必须采用目标版本 release notes 给出的 confirm 语义。提升后再观察一个完整业务峰值,再关闭回退窗口。
// TARGET_FCV 必须替换为已批准且所有成员支持的值
const TARGET_FCV = "8.0"
const result = db.adminCommand({
setFeatureCompatibilityVersion: TARGET_FCV,
confirm: true
})
print(EJSON.stringify(result, null, 2))若新 binary 阶段出现问题且 FCV 尚未提升,可按目标版本文档评估滚回旧 binary;一旦 FCV 提升或新格式被使用,先执行官方允许的 FCV 降级与兼容清理,再决定 binary 回退。任何不确定都以隔离恢复到已验证备份为最后边界,不对生产 dbPath 直接覆盖旧二进制试错。
四、问题处理
MongoDB 客户端错误经常把多个阶段压成一句超时。排障时先回答四个问题:驱动发现了哪些节点,选中了哪个节点,命令有没有进入服务端,写入结果是否已经确定。把 DNS、TLS、认证、连接池、复制和查询计划混在一起调整,只会让真正的故障阶段更难看见。
MongoServerSelectionError:先看驱动发现了什么
MongoServerSelectionError 表示在 serverSelectionTimeoutMS 内没有找到满足拓扑、读偏好和会话条件的 server,不等于“MongoDB 一定宕机”。先保存错误里的 topologyDescription 和每个 server 的状态,再从应用所在机器逐层检查:
getent ahosts mongo-01.internal.example
nc -vz mongo-01.internal.example 27017
openssl s_client \
-connect mongo-01.internal.example:27017 \
-servername mongo-01.internal.example \
-CAfile /etc/company-ca/mongodb-ca.pem \
-verify_hostname mongo-01.internal.example \
-verify_return_error </dev/null所有节点都是 Unknown 时,优先检查 DNS、路由、防火墙和 TLS。能发现多个 SECONDARY 却没有 PRIMARY 时,进入副本集多数和选主排障。网络正常后,用直连只读命令核对目标节点返回的 setName、hosts 和角色:
mongosh 'mongodb://mongo-01.internal.example:27017/admin?directConnection=true&tls=true' \
--tlsCAFile /etc/company-ca/mongodb-ca.pem \
--username observer --authenticationDatabase admin --password \
--quiet --norc --eval \
'print(EJSON.stringify(db.hello(),null,2))'URI 中的 replicaSet 必须与 setName 一致,hosts 里的名称也必须能从应用网络解析。修复 DNS、证书或成员配置后,让应用重新通过完整 Replica Set URI 发现拓扑;单节点 directConnection=true 成功,只能证明这一台可达。
Authentication failed 与 Unauthorized 要分开
Authentication failed 表示身份没有建立,常见于密码、证书、认证库或 mechanism 错误;Unauthorized 表示身份已经建立,但缺少目标 action。先从管理员会话确认用户到底建在哪个 database:
db.getSiblingDB("admin").runCommand({
usersInfo: { user: "te_app_runtime", db: "te_app" },
showPrivileges: true,
showCredentials: false
})
db.runCommand({ connectionStatus: 1, showPrivileges: true })SCRAM 用户建在 te_app 时,客户端必须使用 authSource=te_app;X.509 则检查客户端证书 EKU、DN 映射、CA 与有效期。不要因为一次 DDL 被拒绝就把应用升为 root。修复后用运行身份证明允许的 find、insert、update 成功,同时证明 drop、createUser 和集群管理命令仍被拒绝。
Document failed validation:先找类型和值违反了哪条规则
错误码 121 表示服务端 validator 拒绝写入。先读取当前 collection 的实际 options:
db.getCollectionInfos({ name: "orders" })[0].options
db.runCommand({
insert: "orders",
documents: [{
tenantId: "tenant-a",
orderNo: "BAD-T0-0001",
buyerId: "string-is-not-object-id",
status: "UNKNOWN",
items: []
}]
})重点比较 ObjectId 与字符串、Date 与日期字符串、Decimal128 与普通浮点数、必填字段、枚举和数组上限。应用日志只记录业务键和失败规则摘要,不把完整文档和敏感 validator details 长期落盘。
如果新 validator 错误阻断正常写,回滚上一版规则,或在明确迁移窗口临时使用 moderate;validationAction: "warn" 只能帮助观察,不能成为永久修复。最终要同时看到正常文档成功、每类坏文档仍被拒绝、历史不合规数据已经迁移。
E11000 与写入结果未知:靠同一个业务键收敛
E11000 先看错误中的 index name 和 keyValue。如果冲突的是预期业务唯一索引,读回原文档并比较业务指纹;如果是遗留或错误索引,则停止写入并修正索引设计,不能通过生成随机 orderNo 绕开。
db.orders.findOne(
{ tenantId: "tenant-a", orderNo: "ORD-T0-0001" },
{ _id: 0, businessFingerprint: 1, status: 1, updatedAt: 1 }
)write concern timeout、连接重置或选主可能发生在服务端已经提交之后。指纹一致就返回原业务结果;不存在且错误标签、幂等性和剩余期限都允许时才重试;存在但指纹不同则进入冲突处理。deleteMany、没有稳定过滤条件的 update,以及包含外部副作用的事务不能盲目重放。
连接池等待升高:不要先把 maxPoolSize 翻倍
一个 MongoClient 会为拓扑中的每个 server 维护连接池和监控连接。总量要按应用进程数、节点数和滚动发布的新旧实例重叠计算。
const status = db.serverStatus()
print(EJSON.stringify({
connections: status.connections,
queues: status.queues,
activeClients: status.globalLock?.activeClients
}, null, 2))
db.currentOp({ active: true, secs_running: { $gte: 2 } })wait queue 上升时检查每请求创建 client、cursor 未消费、事务没有结束、慢查询长期占用连接和服务发现抖动。短期限制入口并发、暂停非关键批处理;长期让应用进程复用 client,设置 checkout 超时并按数据库容量分配池预算。池等待、服务端连接数和业务延迟都回到基线,才说明排队没有被搬到别处。
慢查询:用 query shape、explain 和 query_id 找同一件事
从应用的稳定 comment 或 profiler 找到 filter、sort、projection、collation 与读偏好,不把真实敏感值复制到工单。随后在相同数据分布上执行:
db.orders.find(
{ tenantId: "tenant-a", status: "PAID" },
{ orderNo: 1, createdAt: 1 }
).sort({ createdAt: -1, _id: -1 })
.limit(50)
.explain("executionStats")COLLSCAN、额外 SORT、totalDocsExamined 远大于 nReturned、目标 shard 过多和聚合 spill 指向不同问题。需要取消失控操作时,先从 currentOp 确认 namespace、comment、client 和精确 opid,再执行 db.killOp(opid);不能按用户或 collection 模糊批量取消。
修复复合索引、keyset pagination、projection 或 pipeline 后,用相同 query shape 比较 P95/P99、keys/docs examined、CPU、磁盘和写放大。只看到 explain 出现索引,还不足以证明峰值负载已经恢复。
WiredTiger cache 压力与磁盘增长要放在一起看
WiredTiger cache 高并不等于内存泄漏。要同时观察 cache 读入、dirty page、eviction、checkpoint 和宿主磁盘:
const wt = db.serverStatus().wiredTiger
print(EJSON.stringify({
cache: wt.cache,
transaction: wt.transaction,
concurrentTransactions: wt.concurrentTransactions
}, null, 2))df -hT /srv/mongodb/data /srv/mongodb/log
df -i /srv/mongodb/data /srv/mongodb/log
sudo du -xhd1 /srv/mongodb/data | sort -h磁盘接近满时暂停会制造临时文件的索引构建、备份和批处理,为 journal 与 checkpoint 留空间。不要删除运行中 dbPath 里的 WiredTiger*、collection、index 或 journal 文件。扩容、数据保留、无界数组、日志轮转和索引数量才是长期修复入口。
确认恢复时,要看到 checkpoint 持续推进、dirty bytes 不再单调积累、业务线程帮助 eviction 的时间回落、磁盘和 inode 重新获得安全余量,并完成一次备份校验。
Secondary 延迟、没有 Primary 与 rollback 要连起来判断
先保存每个成员的状态、optime、同步源和 oplog window:
rs.status().members.map(member => ({
name: member.name,
state: member.stateStr,
health: member.health,
optimeDate: member.optimeDate,
syncSourceHost: member.syncSourceHost,
lastHeartbeatMessage: member.lastHeartbeatMessage
}))
rs.printReplicationInfo()
rs.printSecondaryReplicationInfo()网络慢会影响拉取,磁盘和 checkpoint 会影响写入与应用,大索引构建、initial sync 与报表读会竞争资源。节点仍在 oplog window 内时,修复资源后让它自然追平;已经越过窗口时,需要 initial sync 或从受信快照重建。
三投票成员只剩一个可达时,没有 Primary 是安全表现。优先恢复原成员之间的网络或服务,让原配置重新获得多数;不能在分区两侧分别 force reconfig。只有多数成员永久丢失、团队接受未提交写入可能丢失,并且已经保存幸存节点的 term、config version、optime 与 rollback 信息时,才进入灾难恢复流程。
旧 Primary 重新加入后可能产生 rollback。订单、支付和消息要按业务幂等键比较新 Primary、外部系统和 rollback BSON;需要补写时走正常补偿接口,不把 rollback 文件直接复制回 collection。一个 Primary、足够多数、所有成员追平且未知业务写已经对账,才算恢复。
事务冲突:重跑整个回调,外部副作用留在事务外
TransientTransactionError 表示整个事务回调可以在满足期限时重试;UnknownTransactionCommitResult 则只重试 commit。驱动的 withTransaction 会识别这些标签,但回调必须可重复。
await session.withTransaction(async () => {
const changed = await db.collection("orders").updateOne(
{ tenantId, orderNo, status: "CREATED" },
{ $set: { status: "PAID", updatedAt: new Date() } },
{ session }
)
if (changed.matchedCount !== 1) throw new Error("state changed")
await db.collection("outbox").insertOne(
{ eventId, type: "ORDER_PAID", tenantId, orderNo, createdAt: new Date() },
{ session }
)
}, {
readConcern: { level: "snapshot" },
writeConcern: { w: "majority", wtimeoutMS: 3000 },
maxCommitTimeMS: 3000
})支付、邮件和消息发送不放进回调;提交后由 outbox 消费者使用 eventId 去重。冲突升高时缩短事务、限制热点键并发,并检查模型是否把过多写集中在同一个聚合。事务重试率、时长和 outbox 重复副作用都恢复正常,才说明问题解决。
分片查询变慢:先确认是否真正定向
从 mongos 执行 explain,观察目标 shard 数、每个 shard 的 keys/docs examined 和 merge 阶段:
db.orders.find({ tenantId: "tenant-a", orderNo: "ORD-T0-0001" })
.explain("executionStats")
sh.status()
db.getSiblingDB("config").chunks.aggregate([
{ $group: { _id: "$shard", chunks: { $sum: 1 } } },
{ $sort: { chunks: -1 } }
])缺少 shard key 的查询会广播;chunk 分布不均、jumbo 或 migration backlog 则要检查 balancer 和元数据。短期暂停昂贵的全局报表或把它路由到离线分析系统,长期让高频请求携带 shard key、调整索引和分片设计。不要手工修改 config collection,也不要频繁 flushRouterConfig 掩盖根因。
备份任务成功,但 mongorestore 失败
先保存 Database Tools 版本、archive SHA-256、备份选项、目标 Server 版本和第一条错误。checksum 不匹配说明介质已经损坏;namespace 冲突说明恢复目标不干净;oplog replay 失败通常与缺失 oplog、时间窗不完整、拓扑不支持或工具版本不匹配有关。
恢复目标应该是新的隔离 Replica Set。先恢复基础 archive,再 replay oplog;不要为让命令继续而跳过错误、移除 validator 或覆盖生产 collection。恢复后核对 collection、index、validator、关键文档数、金额聚合、最大业务时间和随机业务指纹,再用运行账号完成读写与权限反例。
连续多份备份都不能恢复时,要立即保护最后的好恢复点并重新计算实际 RPO,而不是继续轮转覆盖旧备份。
mongod 启动或升级失败
先读取 systemd、日志和有效配置:
mongod --version
sudo systemctl --no-pager --full status mongod
sudo journalctl -u mongod -n 250 --no-pager
sudo mongod --config /etc/mongod.conf --configExpand none --outputConfig \
>/var/tmp/mongod-effective.yaml
sudo -u mongodb test -w /srv/mongodb/data
sudo -u mongodb test -r /etc/mongodb/keyfile常见原因是 YAML、目录属主、keyfile 权限、证书私钥、端口、hostname/SAN、replSetName 或被移除的配置项。修复后只启动一个受影响 Secondary,观察它以原 member id 追平;不要删除 dbPath、重新 rs.initiate() 或改一个新的 Replica Set 名称绕过错误。
升级过程中如果只有一个 Secondary 使用新 binary 且 FCV 尚未提升,可以修复配置或按官方支持路径回到上一批准 binary;Primary 已切换或 FCV 已提升时,停止继续滚动并按对应 release 的 downgrade 要求处理。最终需要所有成员版本一致、FCV 明确、复制追平、应用回归和备份恢复再次通过。
