数据库基础体系 · 第 44/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

MongoDB 文档建模:嵌入与引用、Schema、事务和一致性

MongoDB 的数据建模,不是把关系数据库中的表直接改名为集合、把行改名为文档。它要求先回答一个更接近业务的问题:

哪些数据总是一起读取、一起修改,并且必须作为一个一致整体存在?

这个问题决定了文档边界,也决定了应该选择嵌入、引用,还是在不同访问场景下同时保留两种表示。事务、Schema 验证和读写一致性则分别解决另外几类问题:

  • 嵌入与引用:数据放在哪里,以及如何表达关联;
  • Schema:如何约束文档结构,如何演进字段;
  • 事务:多个文档或多个集合的修改如何作为一个原子操作提交;
  • 一致性:写入何时算成功、读取看到哪个版本、故障后可能读到什么。

这些概念相互关联,但不能互相替代。嵌入可以减少事务需求,却不能让跨文档操作自动原子化;Schema 验证可以阻止错误结构,却不能保证两个集合之间的引用一定有效;writeConcern: "majority" 可以提高持久性保证,却不等于所有读请求都立即看到最新数据。


一、先建立文档模型的基本语义

1.1 文档、集合和 BSON

MongoDB 将数据组织为:

  • 数据库(database)
  • 集合(collection)
  • 文档(document)

文档使用 BSON 表示。BSON 是 JSON 风格的二进制文档格式,但类型比 JSON 更丰富,例如:

  • ObjectId
  • Date
  • Decimal128
  • 二进制数据
  • 数组
  • 嵌套文档

示例:

{
  _id: ObjectId("65f000000000000000000001"),
  customerId: ObjectId("65f000000000000000000010"),
  status: "paid",
  createdAt: ISODate("2025-03-01T10:00:00Z"),
  items: [
    {
      productId: ObjectId("65f000000000000000000100"),
      name: "机械键盘",
      unitPrice: NumberDecimal("299.00"),
      quantity: 2
    }
  ],
  shippingAddress: {
    recipient: "张三",
    province: "浙江省",
    city: "杭州市",
    detail: "西湖区某路 1 号"
  }
}

MongoDB 的文档具有两个重要特征:

  1. 一个文档可以包含嵌套文档和数组;
  2. MongoDB 不要求同一集合中的所有文档具有完全相同的字段集合和字段类型。

因此,MongoDB 默认是灵活 Schema,而不是无 Schema。

“无 Schema”是一个容易造成误解的说法。MongoDB 不强制你在创建集合时声明类似关系数据库 CREATE TABLE 的完整结构,但应用仍然具有事实上的 Schema:

  • 程序读取哪些字段;
  • 字段期望是什么类型;
  • 字段之间有什么依赖;
  • 哪些状态允许转换;
  • 哪些引用必须指向存在的对象。

如果这些约束没有写入数据库验证器,就只能依赖应用代码和测试来维护。


1.2 文档边界和聚合边界

文档建模的核心不是“字段如何排列”,而是确定聚合边界

这里的“聚合”不是 MongoDB 聚合管道中的 $group,而是业务上应当被视为一个一致整体的数据单元。一个聚合通常具有以下特征:

  • 经常一起读取;
  • 经常一起修改;
  • 修改时需要单文档原子性;
  • 生命周期大致一致;
  • 总体大小和数组增长可控。

例如,订单中的收货地址通常属于订单聚合。订单创建后,即使用户修改个人资料中的默认地址,历史订单的收货地址也不应被改变。因此,订单中保存一份地址快照往往比只保存 addressId 更符合业务语义。

相反,商品详情通常由很多订单共同引用,商品价格、库存、描述可能独立变化,且商品不会随着某个订单被删除。这类数据通常具有独立生命周期,更适合单独存储。


二、嵌入:把强关联数据放进同一文档

2.1 嵌入的机制

嵌入是指把相关数据作为子文档或数组直接存入父文档。

例如,将订单明细嵌入订单:

{
  _id: ObjectId("65f000000000000000000001"),
  customerId: ObjectId("65f000000000000000000010"),
  status: "paid",
  items: [
    {
      productId: ObjectId("65f000000000000000000100"),
      productName: "机械键盘",
      unitPrice: NumberDecimal("299.00"),
      quantity: 2
    },
    {
      productId: ObjectId("65f000000000000000000101"),
      productName: "鼠标",
      unitPrice: NumberDecimal("99.00"),
      quantity: 1
    }
  ]
}

读取订单及其明细只需要读取一个文档:

db.orders.findOne({
  _id: ObjectId("65f000000000000000000001")
})

更新某一条明细也可以在一个文档写操作内完成:

db.orders.updateOne(
  {
    _id: ObjectId("65f000000000000000000001"),
    "items.productId": ObjectId("65f000000000000000000100")
  },
  {
    $set: {
      "items.$.quantity": 3
    }
  }
)

单文档写操作具有原子性:该次更新不会表现为“只修改了一半”。这也是嵌入最重要的价值之一。


2.2 什么时候适合嵌入

嵌入通常适用于以下条件同时或大部分成立的情况。

条件一:读取模式稳定地要求一起返回

如果页面每次展示订单都需要订单明细,那么把明细嵌入订单可以避免额外查询或 $lookup

条件二:子数据属于父数据

例如:

  • 订单与订单明细;
  • 博客文章与有限数量的摘要信息;
  • 用户与少量地址;
  • 设备与固定数量的配置项。

子数据离开父数据后通常没有独立意义,或者生命周期跟随父数据。

条件三:子数组有明确上界

嵌入并不意味着数组可以无限增长。MongoDB 单个 BSON 文档有大小上限,当前上限为 16 MiB。这不仅是容量问题,也会影响更新成本、内存使用和复制流量。

因此,下面这种设计有风险:

{
  _id: ObjectId("..."),
  comments: [
    // 无限增长的评论数组
  ]
}

如果评论数量可能持续增长,应将评论单独放入集合:

{
  _id: ObjectId("comment-id"),
  postId: ObjectId("post-id"),
  authorId: ObjectId("user-id"),
  content: "...",
  createdAt: ISODate("...")
}

并建立索引:

db.comments.createIndex({
  postId: 1,
  createdAt: -1
})

这样查询某篇文章的最新评论时,可以按 postId 定位并按时间排序,而不会不断重写一个越来越大的文章文档。

条件四:更新需要在一个原子边界内完成

假设账户文档中的余额和版本号必须同时变化:

{
  _id: ObjectId("..."),
  balance: NumberDecimal("100.00"),
  version: 7
}

如果业务要求“扣款成功时余额和版本号必须同时更新”,将它们放在同一文档中,可以利用单文档原子更新:

db.accounts.updateOne(
  {
    _id: accountId,
    balance: { $gte: NumberDecimal("30.00") },
    version: 7
  },
  {
    $inc: {
      balance: NumberDecimal("-30.00"),
      version: 1
    }
  }
)

匹配条件中的 version: 7 还实现了乐观并发控制:如果另一个请求已经修改过该账户,当前更新将匹配不到文档,应用即可检测冲突并重试或返回错误。


2.3 嵌入的代价

嵌入不是“性能更好”的普遍规则,它把代价转移到了其他地方。

代价一:重复数据带来的更新异常

如果订单明细只保存商品引用,商品名称从商品集合读取;如果订单明细保存了商品名称快照,那么商品改名后历史订单仍显示旧名称。

两种行为都可能正确,但必须明确区分:

  • 当前值:应该引用并实时读取;
  • 历史事实:应该嵌入快照,避免后来变化影响历史记录。

错误的设计是既复制商品名称,又把这份复制值当作实时主数据,却没有定义同步规则。此时会出现:

  • 商品集合已经改名;
  • 订单中的 productName 仍是旧值;
  • 不同页面显示不一致;
  • 不知道哪个字段才是权威来源。

代价二:父文档写热点

如果大量请求频繁更新同一个巨大文档,例如把所有实时设备事件都嵌入设备文档,那么:

  • 单个文档可能接近 16 MiB;
  • 更新会带来更大的写入和复制负担;
  • 并发更新更容易竞争同一个文档;
  • 分片时难以按数组中的子数据分布。

代价三:无法直接对数组元素建立独立生命周期

嵌入数组中的元素不是独立集合文档。你可以使用数组查询、数组更新和多键索引,但不能像独立集合那样直接对单个子项进行独立的生命周期管理、分片和权限控制。


三、引用:把独立实体拆成多个文档

3.1 引用的机制

引用是指在一个文档中保存另一个文档的标识符:

{
  _id: ObjectId("65f000000000000000000001"),
  customerId: ObjectId("65f000000000000000000010"),
  status: "paid",
  items: [
    {
      productId: ObjectId("65f000000000000000000100"),
      quantity: 2
    }
  ]
}

商品存储在另一个集合:

{
  _id: ObjectId("65f000000000000000000100"),
  name: "机械键盘",
  currentPrice: NumberDecimal("299.00"),
  stock: 80
}

MongoDB 不会像关系数据库外键那样自动保证 productId 一定存在,也不会在删除商品时自动级联删除订单明细。引用完整性通常由以下方式之一维护:

  • 应用层校验;
  • 事务内检查;
  • 数据库任务或定期校验;
  • 允许悬挂引用,并在读取时处理缺失对象。

MongoDB 的 $lookup 可以在聚合管道中执行类似连接的操作:

db.orders.aggregate([
  { $match: { _id: orderId } },
  {
    $unwind: "$items"
  },
  {
    $lookup: {
      from: "products",
      localField: "items.productId",
      foreignField: "_id",
      as: "product"
    }
  },
  {
    $unwind: {
      path: "$product",
      preserveNullAndEmptyArrays: true
    }
  }
])

$lookup 返回的是聚合结果,不会自动把查询结果写回订单,也不会自动提供跨集合写入的原子性。


3.2 什么时候适合引用

独立生命周期

用户、商品、组织、权限等实体通常被多个业务对象共同使用。它们不应随着某个父对象一起复制或删除。

多对多关系

例如用户和角色:

{
  _id: ObjectId("user-id"),
  roleIds: [
    ObjectId("role-admin"),
    ObjectId("role-auditor")
  ]
}

当两边都可能大量增长,或者需要查询“所有包含某角色的用户”时,可以使用独立关系集合:

{
  _id: ObjectId("membership-id"),
  userId: ObjectId("user-id"),
  roleId: ObjectId("role-auditor"),
  grantedAt: ISODate("...")
}

并建立唯一复合索引:

db.userRoles.createIndex(
  { userId: 1, roleId: 1 },
  { unique: true }
)

该索引至少保证同一个用户和角色的关系不重复。

子数据数量不可控

评论、日志、消息、事件和操作记录往往是无限增长或高频写入数据。将其放在独立集合可以按时间、父 ID 或分片键管理。

子数据需要独立查询

如果业务既需要“查询订单”,又需要“按商品查询所有订单”,那么完全嵌入可能使后者复杂化。此时可以:

  • 保留订单明细嵌入,建立数组字段索引;
  • 或将订单明细作为独立集合;
  • 或根据两种访问模式维护专门的读模型。

选择取决于查询、更新和一致性要求,而不是 MongoDB 是否支持 $lookup


3.3 一对多关系的三种常见形态

一:少量子对象嵌入父文档

{
  _id: ObjectId("user-id"),
  name: "张三",
  addresses: [
    {
      label: "home",
      city: "杭州",
      detail: "..."
    }
  ]
}

适用于地址数量有限,且通常随用户一起读取的情况。

二:大量子对象引用父对象

{
  _id: ObjectId("comment-id"),
  postId: ObjectId("post-id"),
  createdAt: ISODate("...")
}

评论集合应对 postId 建索引。父文档不保存完整评论数组。

三:双向引用

父文档保存子 ID,子文档也保存父 ID:

// departments
{
  _id: ObjectId("department-id"),
  employeeIds: [ObjectId("employee-1"), ObjectId("employee-2")]
}

// employees
{
  _id: ObjectId("employee-1"),
  departmentId: ObjectId("department-id")
}

双向引用能支持两种方向的查询,但引入了同步问题:

  • 新增员工时两个文档都要更新;
  • 删除员工时要清理另一侧;
  • 并发请求可能只更新了一侧;
  • 数据修复需要额外任务。

除非两种查询都很重要,否则通常只保留一个权威方向更简单。


四、嵌入与引用的选择:从访问模式和不变量推导

可以把一次业务操作抽象为:

  • 读取集合 RR
  • 修改集合 WW
  • 必须同时成立的不变量集合 II
  • 数据增长上界 GG
  • 访问频率和并发度 QQ

如果一个业务不变量只涉及同一个文档 dd,则单文档原子写可以保证:

write(d)I(d) 在提交后成立\text{write}(d) \Rightarrow I(d)\text{ 在提交后成立}

如果一个不变量涉及两个文档 d1,d2d_1,d_2,例如:

库存(p)0订单明细(o,p) 已创建\text{库存}(p) \geq 0 \quad \land \quad \text{订单明细}(o,p)\text{ 已创建}

那么单独修改两个文档不能自动保证整体原子性。此时有三种可能:

  1. 重构模型,把需要原子更新的状态嵌入同一文档;
  2. 使用事务;
  3. 接受最终一致性,用状态机、补偿任务或事件重试修复中间状态。

例如,订单创建时需要扣减库存并写入订单:

库存:100
请求购买:2
订单:不存在

如果先写订单,再扣库存,进程在两步之间崩溃,可能得到:

订单:已创建
库存:100

如果先扣库存,再写订单,也可能得到:

订单:不存在
库存:98

问题不是“先做哪一步”,而是两个文档之间缺少共同的原子提交边界。若业务不允许任何中间结果被持久化,就需要事务,或者重新设计数据模型。


五、Schema:灵活结构不等于没有约束

5.1 Schema 的三个层次

MongoDB 中的 Schema 至少有三层含义。

第一层:文档结构

例如订单必须包含:

  • _id
  • customerId
  • status
  • items
  • createdAt

第二层:字段类型和取值域

例如:

  • status 只能是 "pending""paid""cancelled"
  • items 必须是数组;
  • quantity 必须是正整数;
  • unitPrice 应使用 Decimal128,而不是二进制浮点数。

第三层:跨字段和跨文档业务不变量

例如:

  • paid 状态必须有 paidAt
  • 订单总额必须等于明细金额之和;
  • productId 必须引用存在的商品;
  • 库存不能小于零。

MongoDB 集合验证器主要适合前两层。复杂的跨字段、跨文档不变量通常仍需要应用逻辑、事务或专门的数据校验流程。


5.2 使用 $jsonSchema 验证集合

下面创建一个订单集合验证器:

db.createCollection("orders", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: [
        "_id",
        "customerId",
        "status",
        "items",
        "createdAt"
      ],
      properties: {
        customerId: {
          bsonType: "objectId"
        },
        status: {
          enum: ["pending", "paid", "cancelled"]
        },
        items: {
          bsonType: "array",
          minItems: 1,
          items: {
            bsonType: "object",
            required: [
              "productId",
              "unitPrice",
              "quantity"
            ],
            properties: {
              productId: {
                bsonType: "objectId"
              },
              unitPrice: {
                bsonType: "decimal"
              },
              quantity: {
                bsonType: "int",
                minimum: 1
              }
            }
          }
        },
        createdAt: {
          bsonType: "date"
        }
      }
    }
  },
  validationLevel: "strict",
  validationAction: "error"
})

插入正确文档:

db.orders.insertOne({
  customerId: ObjectId("65f000000000000000000010"),
  status: "pending",
  items: [
    {
      productId: ObjectId("65f000000000000000000100"),
      unitPrice: NumberDecimal("299.00"),
      quantity: NumberInt(2)
    }
  ],
  createdAt: new Date()
})

插入错误文档:

db.orders.insertOne({
  customerId: "not-an-objectid",
  status: "unknown",
  items: [],
  createdAt: "2025-03-01"
})

validationAction: "error" 下,该写入会被拒绝,并返回文档验证失败错误。验证器只检查本次插入或更新涉及的目标文档;它不会自动扫描并修复集合中已有的旧数据,也不会自动验证 productId 对应的商品是否存在。


5.3 strictmoderatewarn

常见配置语义如下:

  • validationLevel: "strict":对所有插入和更新严格应用验证;
  • validationLevel: "moderate":对符合验证规则的已有文档应用验证,对原本不符合规则的旧文档允许部分更新;
  • validationAction: "error":验证失败时拒绝写入;
  • validationAction: "warn":验证失败时记录警告,但允许写入。

向已有集合引入新规则时,可以先使用较宽松的策略观察遗留数据,再迁移旧文档,最后切换为严格拒绝。这里的关键是理解:验证器是写入路径上的门禁,不是一次性数据清洗工具。


5.4 Schema 演进

生产系统通常不能一次性修改所有历史文档,因此 Schema 演进需要兼容策略。

例如,旧版本地址只有:

{
  city: "杭州",
  detail: "西湖区某路 1 号"
}

新版本增加 postalCode

{
  city: "杭州",
  detail: "西湖区某路 1 号",
  postalCode: "310000"
}

安全的演进步骤通常是:

  1. 新代码先能够读取旧文档;
  2. 新写入使用新字段;
  3. 后台任务逐步回填旧文档;
  4. 确认所有文档满足新约束;
  5. 再收紧验证规则;
  6. 删除旧字段前确认没有旧版本程序仍在写入。

另一种方式是显式保存版本:

{
  _id: ObjectId("..."),
  schemaVersion: 2,
  ...
}

读取时根据 schemaVersion 解释结构。版本字段不应被当作万能方案;它真正有用的前提是程序确实对不同版本定义了明确的转换和兼容行为。


六、事务:把多个文档操作纳入一次提交

6.1 单文档原子性与多文档事务的区别

MongoDB 对单个文档的写操作提供原子性。下面的更新要么整体成功,要么不产生部分更新:

db.accounts.updateOne(
  { _id: accountId },
  {
    $inc: {
      balance: NumberDecimal("-30.00")
    }
  }
)

但以下两个操作不是天然原子的:

db.accounts.updateOne(...)
db.ledger.insertOne(...)

它们分别是两个写操作。第一个成功、第二个失败时,账户余额和流水可能不一致。

多文档事务将多个读写操作放进一个事务中:

开始事务
  更新账户余额
  写入扣款流水
提交事务

成功提交后,其他事务不会看到只完成其中一步的最终结果;如果事务回滚,则其中的写入都不会生效。


6.2 一个端到端的 mongosh 示例

多文档事务需要支持事务的部署环境,例如副本集或分片集群。单机 standalone 部署不具备事务所需的会话和复制语义,因此不能直接按副本集事务示例运行。

先准备数据:

use demo

db.accounts.deleteMany({})
db.ledger.deleteMany({})

const accountId = ObjectId()
db.accounts.insertOne({
  _id: accountId,
  owner: "张三",
  balance: NumberDecimal("100.00")
})

mongosh 中执行事务:

const session = db.getMongo().startSession()
const sessionDb = session.getDatabase("demo")

try {
  session.startTransaction({
    readConcern: { level: "snapshot" },
    writeConcern: { w: "majority" }
  })

  const debitResult = sessionDb.accounts.updateOne(
    {
      _id: accountId,
      balance: { $gte: NumberDecimal("30.00") }
    },
    {
      $inc: {
        balance: NumberDecimal("-30.00")
      }
    }
  )

  if (debitResult.modifiedCount !== 1) {
    throw new Error("余额不足,或账户不存在")
  }

  sessionDb.ledger.insertOne({
    accountId,
    type: "debit",
    amount: NumberDecimal("30.00"),
    createdAt: new Date()
  })

  session.commitTransaction()
  print("transaction committed")
} catch (error) {
  if (session.transaction.isInProgress()) {
    session.abortTransaction()
  }
  print(`transaction aborted: ${error}`)
} finally {
  session.endSession()
}

成功后检查:

db.accounts.findOne({ _id: accountId })
db.ledger.find({ accountId }).toArray()

预期结果是:

{
  _id: accountId,
  owner: "张三",
  balance: NumberDecimal("70.00")
}

并且流水集合中有一条 30.00 的扣款记录。

每一步的逻辑是:

  1. startSession() 创建会话;
  2. startTransaction() 建立事务边界;
  3. 更新账户时用 balance: { $gte: ... } 防止余额变负;
  4. 如果更新没有修改一个文档,主动抛错;
  5. 写入流水;
  6. 只有 commitTransaction() 成功后,两个写入才对外成为事务结果;
  7. 任意一步失败则调用 abortTransaction()

这里使用 Decimal128 是为了避免金额计算中的二进制浮点误差。金额字段的类型选择属于 Schema 设计的一部分,不是事务自动解决的问题。


6.3 事务中的读写和快照

事务的读一致性依赖读关注级别(read concern)。使用:

readConcern: { level: "snapshot" }

时,事务内的读取可以基于一致的快照,从而避免同一事务中的多次读取看到互相不匹配的已提交版本。

writeConcern: { w: "majority" } 要表达的是:事务提交需要等待多数数据承载成员确认写入。它主要影响写入确认和故障持久性语义,不等于“读请求一定从最新节点读取”。

例如,一个写操作已经以多数确认提交,但随后使用默认读偏好从某个延迟中的 secondary 读取,仍可能暂时读不到该数据。若业务需要读取刚刚写入的结果,可以:

  • 在同一会话中使用因果一致性语义;
  • 从 primary 读取;
  • 使用适当的读关注级别和读偏好;
  • 根据业务接受的延迟选择一致性与可用性。

6.4 事务失败、冲突和重试

事务可能因以下原因失败:

  • 写冲突;
  • 主节点选举;
  • 网络中断;
  • 事务超时;
  • 事务超过大小或时间限制;
  • 分片事务涉及的协调过程失败。

驱动通常会根据事务错误标签对整个事务或提交动作进行重试。生产代码应使用官方驱动提供的事务 API,例如 withTransaction,并遵循驱动对 TransientTransactionErrorUnknownTransactionCommitResult 等错误标签的处理方式。

需要区分两种重试:

  1. 事务主体重试:事务可能完全没有提交,也可能因瞬时错误失败,需要重新执行事务;
  2. 提交结果未知:客户端没有收到提交结果,但服务器可能已经提交。此时不能简单地把业务操作当作必然失败。

因此,事务中的业务写入最好具备幂等设计。例如,流水记录使用业务唯一键:

{
  _id: "payment-order-123-attempt-1",
  orderId: "order-123",
  type: "debit",
  amount: NumberDecimal("30.00")
}

然后使用唯一索引避免重试产生重复流水:

db.ledger.createIndex(
  { _id: 1 },
  { unique: true }
)

_id 本身已经唯一;这里强调的是应设计稳定的业务幂等键,而不是每次重试都随机生成新 ID。


6.5 什么时候不应使用事务

事务解决的是原子性和一致性边界,不是所有数据建模问题的默认答案。

如果可以把状态放入一个文档,并通过条件更新完成,那么单文档原子写通常更简单:

db.orders.updateOne(
  {
    _id: orderId,
    status: "pending"
  },
  {
    $set: {
      status: "paid",
      paidAt: new Date()
    }
  }
)

这同时实现了状态机的条件转换:只有 pending 订单能转换为 paid。重复请求不会把已经支付的订单再次转换。

如果每次订单查询都需要商品名称、价格快照和收货地址,那么将这些信息嵌入订单,往往比每次查询都执行跨集合读取更直接。事务不应被用来掩盖一个本来可以合理建模为单文档的问题。


七、一致性:写入成功、读取可见和数据约束是三件事

7.1 一致性的不同含义

在 MongoDB 中,至少要区分:

单文档原子性

一次针对单文档的写操作不会只完成部分修改。

事务原子性

多个文档的操作作为一个事务提交或回滚。

写入确认和持久性

writeConcern 控制。例如:

{ w: "majority" }

表示等待多数数据承载成员确认。它影响客户端何时收到成功,以及故障后写入保留的保证。

读取版本

readConcern 和读偏好共同影响。例如,localmajoritysnapshotlinearizable 解决的问题并不相同,且适用条件也不同。

因果一致性

因果一致性要求有因果关系的操作按正确顺序可见。例如:

客户端写入订单
客户端随后读取订单

如果两步使用同一会话并启用适当的因果一致性语义,读取不会观察到“写入之前”的因果状态。它并不意味着整个系统所有客户端的所有读写都表现为一个全局线性化时间线。


7.2 readConcernwriteConcern 的组合

可以用下面的抽象理解:

客户端观察到的结果=f(提交位置, 读取节点, readConcern, readPreference)\text{客户端观察到的结果} = f(\text{提交位置},\ \text{读取节点},\ \text{readConcern},\ \text{readPreference})

其中:

  • 提交位置:写入在 primary 或多数成员上的确认状态;
  • 读取节点:primary 或 secondary;
  • readConcern:允许读取哪个提交阶段的数据;
  • readPreference:优先从哪类节点读取。

常见组合的直觉如下:

  • w: 1:primary 确认写入后返回,确认速度较快,但故障窗口内持久性保证弱于多数确认;
  • w: "majority":等待多数确认,通常更适合重要写入;
  • readConcern: "local":读取本地节点当前可见的数据,可能包含尚未被多数确认的数据;
  • readConcern: "majority":读取多数提交的数据;
  • readConcern: "snapshot":主要用于事务中的一致快照读取;
  • readPreference: "primary":从 primary 读取,通常用于要求较新数据的场景;
  • readPreference: "secondary":可分散读取压力,但可能读取到延迟数据。

不能把 majority 理解为“强一致性开关”。它需要和读取节点、复制延迟、故障场景一起分析。


7.3 一个可观察的读写场景

设副本集包含 primary、secondary-1、secondary-2。

客户端执行:

db.orders.insertOne(
  { _id: orderId, status: "paid" },
  { writeConcern: { w: "majority" } }
)

若返回成功,表示写入已达到该写关注要求。随后客户端执行:

db.orders.findOne(
  { _id: orderId },
  {
    readPreference: "secondary"
  }
)

结果仍可能暂时为空或返回旧版本,因为被选中的 secondary 可能还没有应用该 oplog 操作。

这不是前一个写入“丢失”,而是:

  • 写入已经满足了某种确认条件;
  • 读取选择了一个复制尚未追上的节点;
  • 读取语义允许观察到较旧状态。

诊断这类问题时,应同时检查:

  • 客户端的 readPreference
  • readConcern
  • 写入使用的 writeConcern
  • secondary replication lag;
  • 是否复用了同一个会话;
  • 应用是否把“未读到”错误地解释成“写入失败”。

八、跨文档一致性:强一致、最终一致和可修复设计

不是所有关联数据都必须在同一时刻强一致。关键在于定义不变量和允许的中间状态。

8.1 强一致场景

以下场景通常要求事务或单文档原子更新:

  • 钱包余额与扣款流水必须同时成立;
  • 库存扣减不能与订单创建分离;
  • 唯一业务状态不能出现两个并行生效版本;
  • 转账不能只扣一方而未加另一方。

8.2 可接受最终一致的场景

例如商品主数据变更后,搜索索引、推荐缓存和报表数据稍后更新。此时可以采用:

写入商品主文档
记录变更事件或变更日志
异步消费者更新搜索索引
失败后重试
通过版本号或时间戳丢弃旧事件

这要求系统明确:

  • 哪个集合是权威数据;
  • 哪些数据是派生数据;
  • 消费失败如何重试;
  • 重试是否幂等;
  • 如何检测和修复长期不一致。

“最终一致”不是忽略错误,而是把一致性从同步事务转移为可观测、可重试、可修复的流程。


九、完整建模示例:订单、商品和库存

考虑一个电商订单模型。

9.1 需求

订单创建时需要:

  1. 检查商品存在;
  2. 检查库存;
  3. 扣减库存;
  4. 创建订单;
  5. 保存下单时的商品名称和价格;
  6. 多次重复请求不能重复扣库存。

这里包含两类信息:

  • 商品当前状态:商品名称、当前价格、库存;
  • 订单历史事实:下单时名称、成交价格、数量。

商品当前状态不应被完整复制为订单的实时数据,但订单需要保存历史快照。

订单可以设计为:

{
  _id: ObjectId("order-id"),
  requestId: "req-20250301-0001",
  customerId: ObjectId("customer-id"),
  status: "paid",
  items: [
    {
      productId: ObjectId("product-id"),
      productName: "机械键盘",
      unitPrice: NumberDecimal("299.00"),
      quantity: 2
    }
  ],
  totalAmount: NumberDecimal("598.00"),
  shippingAddress: {
    recipient: "张三",
    city: "杭州",
    detail: "西湖区某路 1 号"
  },
  createdAt: ISODate("...")
}

商品文档:

{
  _id: ObjectId("product-id"),
  name: "机械键盘",
  stock: 100,
  currentPrice: NumberDecimal("299.00"),
  status: "active"
}

这里的选择是:

  • productId 是引用,用于关联当前商品;
  • productNameunitPrice 是订单快照;
  • shippingAddress 是订单快照;
  • 库存仍由商品文档或专门库存文档维护。

9.2 幂等键

为防止同一请求重试导致重复下单,可以建立唯一索引:

db.orders.createIndex(
  { customerId: 1, requestId: 1 },
  { unique: true }
)

应用为每个逻辑下单请求生成稳定的 requestId。重试时必须复用该 ID,而不是重新生成。

9.3 事务过程

在支持事务的副本集或分片集群中,可以按以下逻辑执行:

session.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
})

// 1. 读取商品
// 2. 条件扣库存:stock >= quantity
// 3. 插入带价格和名称快照的订单
// 4. 提交事务

扣库存的关键更新:

const result = sessionDb.products.updateOne(
  {
    _id: productId,
    status: "active",
    stock: { $gte: quantity }
  },
  {
    $inc: { stock: -quantity }
  }
)

如果 modifiedCount 不是 1,说明商品不存在、已下架或库存不足。此时应中止事务,而不是继续插入订单。

事务成功解决了“库存已扣但订单不存在”这类原子性问题,但它仍然不能自动解决:

  • 商品价格变化时应该以哪个价格成交;
  • 重试产生重复订单;
  • 订单创建后支付回调重复;
  • 订单和搜索索引之间的异步同步。

这些需要快照、唯一索引、状态机和幂等处理共同解决。


十、更新语义:避免把文档整体覆盖当作普通修改

MongoDB 更新有两类语义:

10.1 替换文档

db.orders.replaceOne(
  { _id: orderId },
  {
    _id: orderId,
    status: "paid"
  }
)

这会用新文档替换旧文档,未出现在新文档中的字段会被删除。若旧订单还有 itemscreatedAt 等字段,它们可能因此丢失。

10.2 更新操作符

db.orders.updateOne(
  { _id: orderId },
  {
    $set: { status: "paid" },
    $currentDate: { paidAt: true }
  }
)

更新操作符只修改指定字段,更适合部分更新。

生产代码还应注意并发覆盖问题。两个请求都先读出同一个文档,然后分别整体替换,后写入者可能覆盖先写入者的字段。可以采用:

  • 使用 $set$inc 等字段级更新;
  • 使用版本号条件;
  • 在必要时使用事务;
  • 明确哪些字段由哪个写入者负责。

十一、索引、模型和分片键必须一起设计

索引不是建模之后随手添加的独立步骤。模型决定查询字段,查询模式又决定索引和分片策略。

嵌入数组上的索引会产生多键索引。例如:

db.orders.createIndex({
  "items.productId": 1,
  createdAt: -1
})

它可以支持按明细中的商品查询订单,但数组字段会影响索引键数量和查询规划。数组无限增长不仅影响文档大小,也可能造成索引膨胀。

引用模型则常见:

db.comments.createIndex({
  postId: 1,
  createdAt: -1
})

该索引支持按文章查询评论并按时间排序。

如果集合需要分片,分片键还要满足数据分布、查询路由和写入热点等要求。一个只在文档中嵌入、无法单独路由的子对象,不能像独立集合中的文档一样自然地按子对象维度分布。因此,在分片场景下需要提前确认:

  • 主要查询是否包含分片键;
  • 写入是否集中到少数分片;
  • 单个父文档是否成为热点;
  • 关联数据是否需要跨分片访问;
  • 事务是否需要协调多个分片。

不能仅因为 $lookup 可用,就认为引用模型在分片环境中没有额外代价;也不能仅因为事务支持跨集合,就忽略跨分片事务的协调成本和故障路径。


十二、常见误解和失败表现

12.1 “MongoDB 没有 Schema”

实际含义是数据库默认不强制统一结构,而不是数据可以随意变化。

失败表现:

{ quantity: 1 }
{ quantity: "1" }
{ quantity: null }

查询、排序、聚合和程序反序列化都可能因此产生不同结果。应通过代码约束、集合验证器、迁移和监控共同维护事实 Schema。


12.2 “引用就是外键”

引用只是一个普通字段:

{ productId: ObjectId("...") }

MongoDB 不自动验证目标文档存在,也不自动级联删除。若业务要求引用完整性,就必须明确由应用、事务或后台修复机制负责。


12.3 “用了事务就不会有重复操作”

事务保证一组操作的原子提交,不保证客户端重试不会重复执行业务意图。

例如支付请求超时后,客户端无法确认事务是否提交。如果重新生成一个新的支付记录,仍可能重复扣款。需要:

  • 稳定业务幂等键;
  • 唯一索引;
  • 可重试的状态机;
  • 正确处理提交结果未知的错误。

12.4 “多数写入后,所有读取都是最新的”

多数写关注描述写入确认,不会强制所有读取都从最新 primary 读取。secondary 延迟、读偏好和读关注会影响可见性。

失败表现通常是:

写入接口返回成功
紧接着查询接口返回旧状态

诊断时先检查读请求是否走了 secondary,以及复制延迟,而不要立即判定写入丢失。


12.5 “所有关联都应嵌入,避免连接”

嵌入可以避免查询关联,但可能带来:

  • 文档过大;
  • 数组无限增长;
  • 更新热点;
  • 重复数据不一致;
  • 无法独立分片和管理子对象。

真正的判断依据是数据关系、生命周期、访问模式、增长上界和原子性需求。


十三、建模时可执行的推导流程

对一个新业务,可以按以下顺序推导,而不是先决定“用嵌入还是引用”。

第一步:列出业务对象和生命周期

例如:

订单:创建、支付、取消、完成
商品:上架、改价、下架
评论:创建、删除,数量持续增长

生命周期独立且被多个对象共享的数据,天然更偏向独立集合。

第二步:列出主要读写路径

例如:

按订单 ID 查看订单和明细
按商品 ID 查询当前库存
按文章 ID 分页查询评论
按用户 ID 查询最近订单

每个读路径都要标出需要返回哪些字段,以及是否要求历史快照。

第三步:列出必须同时成立的不变量

例如:

订单状态为 paid 时必须存在 paidAt
库存不能小于 0
同一 requestId 不能创建两个订单
扣款必须有对应流水

只涉及一个文档的不变量,优先尝试单文档原子更新。跨文档不变量再判断是否需要事务或最终一致性。

第四步:估算增长和热点

确认:

  • 数组是否有硬上限;
  • 文档是否会接近 16 MiB;
  • 某个父文档是否会被高频并发更新;
  • 子数据是否需要分页;
  • 查询是否需要按子对象独立排序和过滤。

第五步:决定权威数据和快照数据

明确:

商品当前价格:products.currentPrice
订单成交价格:orders.items.unitPrice

两者看起来重复,但语义不同。只要权威来源和快照用途清楚,复制并不必然是错误。

第六步:最后设计验证器、索引和事务

验证器约束结构和类型,索引服务访问路径,事务保护跨文档原子性。三者分别解决不同问题,不能互相替代。


结语

MongoDB 文档建模的核心,是把数据划分为正确的原子边界:

  • 需要一起读取、一起修改、一起保持一致的数据,优先考虑嵌入;
  • 具有独立生命周期、数量不可控、需要独立查询的数据,优先考虑引用;
  • 历史事实与当前主数据语义不同,可以保留快照;
  • 单文档原子性足够时,不要无条件引入事务;
  • 跨文档不变量无法由单次写入保证时,才使用事务或明确设计最终一致性流程;
  • 灵活 Schema 仍需要类型、状态、版本和迁移策略;
  • writeConcernreadConcern、读偏好和会话共同决定客户端实际观察到的数据;
  • 唯一索引、幂等键和状态条件更新,是事务重试和并发控制的重要组成部分。

一个可维护的 MongoDB 模型,最终应能清楚回答四个问题:

  1. 哪个文档是某项事实的权威来源?
  2. 哪些字段只是历史快照或派生数据?
  3. 哪些更新必须在同一个原子边界内完成?
  4. 在节点延迟、重试、选举和部分失败之后,系统如何保持或恢复不变量?

系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。