PAYATHON 2026

MongoDB Schema Design:大量小文档还是少量大文档?

支付阿杰

结论

MongoDB 并不天然偏好”大量小文档”或”少量大文档”。设计 Schema 时,主要看以下问题:

  • 数据是否总是一起读取和更新;
  • 子数据量是否有明确上限;
  • 写入的数据是否会持续增加;
  • 主要查询会按哪个维度筛选、排序和聚合;
  • 是否需要跨分片扩展。

对于 Customer → CreditCard → Payment 这类模型,通常可以这样设计:

  • 将 Customer 和数量有限的 CreditCard 信息嵌入同一个文档;
  • 将持续增加且数量可能很大的 Payment 单独存放;
  • 通过 customerId 和 creditCardId 关联付款、客户与信用卡;
  • 如果客户页面经常显示付款汇总,可以在 Customer 或单独的汇总集合中保存计数、累计金额等派生数据。

简而言之,有限的数据适合嵌入,持续增长的数据适合引用。没有必要在两个极端之间二选一。

为什么不建议把所有 Payment 嵌入 Customer

假设每张信用卡有数百笔付款,而客户可能长期使用系统,payments 数组会一直增长,随之产生一系列问题。

文档大小有硬限制

MongoDB 单个 BSON 文档最大为 16 MiB。即使当前数据远未达到上限,也要估算客户几年后的付款数量,不能只参考原型阶段的数据。

可以按下面的方式粗略估算:

预计文档大小
≈ 客户基础数据
+ 信用卡数量 × 单张卡基础数据
+ 付款数量 × 单笔付款的平均 BSON 大小

BSON 除了字段值,还会保存字段名和类型标记等信息,因此不能只计算业务数据本身的字节数。

无界数组会增加写入和索引成本

如果每笔付款都通过 $push 写入客户文档,同一客户的付款会集中修改同一个文档:

db.customers.updateOne(
  { _id: customerId },
  {
    $push: {
      "creditCards.$[card].payments": {
        payDate: ISODate("2026-08-15T10:00:00Z"),
        payAmount: Decimal128("199.90")
      }
    }
  },
  {
    arrayFilters: [
      { "card._id": creditCardId }
    ]
  }
)

这种设计可能带来以下问题:

  • 热门客户的写入集中在同一个文档;
  • 文档会随时间不断膨胀;
  • 查询和更新大型数组更加复杂;
  • 为数组字段建立多键索引后,索引项可能迅速增加;
  • 按日期查询所有客户的付款记录比较别扭;
  • 分片后,一个客户文档仍然不能拆到多个分片中。

另外,读取客户基本资料时,如果没有使用 projection 排除付款数组,系统可能传输大量并不需要的数据。

跨客户聚合不适合使用深层嵌套的大数组

例如,要统计上个月所有客户的平均支付金额,如果付款嵌套在客户和信用卡之下,通常需要执行多次 $unwind:

db.customers.aggregate([
  { $unwind: "$creditCards" },
  { $unwind: "$creditCards.payments" },
  {
    $match: {
      "creditCards.payments.payDate": {
        $gte: ISODate("2026-08-01T00:00:00Z"),
        $lt: ISODate("2026-09-01T00:00:00Z")
      }
    }
  },
  {
    $group: {
      _id: null,
      averageAmount: {
        $avg: "$creditCards.payments.payAmount"
      }
    }
  }
])

这类查询可以执行,但付款记录较多时,独立的 payments 集合更容易建立合适的索引,也更方便按时间、客户或信用卡聚合。

推荐的数据模型

customers 集合

客户资料和数量有明确上限的信用卡可以嵌入同一文档:

{
  _id: ObjectId("..."),
  name: "Customer X",
  address: {
    province: "Shanghai",
    city: "Shanghai",
    detail: "..."
  },
  creditCards: [
    {
      _id: ObjectId("..."),
      issuer: "Bank A",
      last4: "1234",
      status: "active"
    },
    {
      _id: ObjectId("..."),
      issuer: "Bank B",
      last4: "5678",
      status: "active"
    }
  ]
}

这种设计有一个前提:每个客户的信用卡数量必须在业务上有合理上限。如果信用卡数量也会无限增加,或者信用卡有复杂且独立的生命周期,就应该将它们拆到单独的 creditCards 集合中。

文档中不要保存完整卡号、CVV 等敏感支付数据。示例里的 last4 只用于展示,实际系统还需遵守适用的安全规范。

payments 集合

每笔付款单独保存:

{
  _id: ObjectId("..."),
  customerId: ObjectId("..."),
  creditCardId: ObjectId("..."),
  payDate: ISODate("2026-08-15T10:00:00Z"),
  payAmount: Decimal128("199.90"),
  currency: "CNY",
  status: "settled"
}

这样便可以分别查询某位客户、某张信用卡或某个时间范围内的付款。

如果必须保留交易发生时的客户或信用卡属性,可以把必要的快照写入付款文档,而不是每次都查询当前的客户资料:

{
  _id: ObjectId("..."),
  customerId: ObjectId("..."),
  creditCardId: ObjectId("..."),
  cardSnapshot: {
    issuer: "Bank A",
    last4: "1234"
  },
  payDate: ISODate("2026-08-15T10:00:00Z"),
  payAmount: Decimal128("199.90"),
  currency: "CNY"
}

这种适度的数据重复是有意的反规范化,可以保留历史状态,并减少部分查询对 $lookup 的依赖。

根据查询模式设计索引

Schema 和索引需要结合实际查询来设计,不能只讨论文档大小。

查询客户有多少张信用卡

将信用卡嵌入客户文档后,可以直接计算数组长度:

db.customers.aggregate([
  { $match: { _id: customerId } },
  {
    $project: {
      _id: 0,
      creditCardCount: {
        $size: { $ifNull: ["$creditCards", []] }
      }
    }
  }
])

按 _id 查询会使用默认索引,不需要扫描付款记录。

查询某客户最近的付款

先建立复合索引:

db.payments.createIndex({
  customerId: 1,
  payDate: -1
})

查询方式如下:

db.payments.find({
  customerId: customerId
})
.sort({
  payDate: -1
})
.limit(50)

查询某张信用卡的付款记录

db.payments.createIndex({
  creditCardId: 1,
  payDate: -1
})

统计上个月所有付款的平均值

如果查询主要按时间范围过滤,可以建立日期索引:

db.payments.createIndex({
  payDate: 1
})

聚合查询如下:

db.payments.aggregate([
  {
    $match: {
      payDate: {
        $gte: ISODate("2026-08-01T00:00:00Z"),
        $lt: ISODate("2026-09-01T00:00:00Z")
      },
      status: "settled"
    }
  },
  {
    $group: {
      _id: null,
      averageAmount: {
        $avg: "$payAmount"
      },
      paymentCount: {
        $sum: 1
      }
    }
  }
])

如果经常同时按 status 和时间查询,可以根据实际过滤选择性评估复合索引,例如:

db.payments.createIndex({
  status: 1,
  payDate: 1
})

索引字段的顺序不能只根据示例确定,需要用真实数据和 explain("executionStats") 验证:

db.payments.explain("executionStats").aggregate([
  {
    $match: {
      status: "settled",
      payDate: {
        $gte: ISODate("2026-08-01T00:00:00Z"),
        $lt: ISODate("2026-09-01T00:00:00Z")
      }
    }
  },
  {
    $group: {
      _id: null,
      averageAmount: { $avg: "$payAmount" }
    }
  }
])

是否需要保存汇总字段

如果系统经常展示以下信息:

  • 客户累计付款金额;
  • 客户付款次数;
  • 最近一次付款时间;
  • 每月付款总额;
  • 每张卡的付款统计;

每次扫描原始付款记录的成本可能较高。这时可以维护汇总数据,例如:

{
  _id: ObjectId("..."),
  name: "Customer X",
  creditCards: [ /* ... */ ],
  paymentSummary: {
    count: NumberLong("842"),
    totalAmount: Decimal128("128600.50"),
    lastPaymentAt: ISODate("2026-08-31T18:20:00Z")
  }
}

这种计算模式增加了写入和一致性处理的成本,但可以加快读取。

设计时要先确定汇总数据的一致性要求:

  • 强一致:把付款写入和汇总更新放在同一个事务中;
  • 最终一致:先保存付款,再通过事件、队列或定时任务更新汇总;
  • 可重建:以 payments 为事实数据源,定期校验或重新计算汇总。

不要只保留汇总而删除原始付款记录,否则审计、纠错和重新计算都会更加困难。

数据量很大时的进一步选择

数百万个文档对 MongoDB 来说并不罕见。更需要关注的是:

  • 工作集能否有效使用内存;
  • 查询是否有匹配的索引;
  • 单次查询需要扫描多少文档和索引项;
  • 写入是否集中在少数客户;
  • 数据需要保留多久;
  • 是否需要分片;
  • 分片键能否支持主要查询并避开写入热点。

如果付款数据继续增长,可以考虑下面的策略。

按时间归档或分层存储

将近期付款留在主要集合中,把历史数据转移到归档集合或其他存储层。是否采用这种方式,取决于历史付款的访问频率和合规要求。

使用桶模式

如果单笔付款文档的固定开销已经成为明显问题,而且访问通常按客户和时间段批量进行,可以把有限时间窗口内的付款放入有上限的桶中:

{
  customerId: ObjectId("..."),
  bucketStart: ISODate("2026-08-01T00:00:00Z"),
  bucketEnd: ISODate("2026-09-01T00:00:00Z"),
  count: 2,
  payments: [
    {
      creditCardId: ObjectId("..."),
      payDate: ISODate("2026-08-03T10:00:00Z"),
      payAmount: Decimal128("99.90")
    },
    {
      creditCardId: ObjectId("..."),
      payDate: ISODate("2026-08-15T10:00:00Z"),
      payAmount: Decimal128("199.90")
    }
  ]
}

桶必须有清晰的边界,例如按月分桶,并限制最大记录数或最大估算大小。否则,无界数组的问题只是从 Customer 转移到了桶文档。

桶模式会增加单笔更新、去重和并发控制的复杂度。没有经过容量测试,也没有确认查询收益之前,不宜提前采用。

设计时的实用判断规则

适合嵌入的数据通常有以下特点:

  • 几乎总是与父对象一起读取;
  • 生命周期与父对象相同;
  • 数量较少且有明确上限;
  • 不需要频繁单独查询;
  • 不会形成持续增长的数组;
  • 需要通过单文档原子操作保持一致性。

适合单独建立集合的数据通常有以下特点:

  • 数量很大或没有可靠上限;
  • 写入频繁;
  • 需要单独筛选、排序、分页或聚合;
  • 多个对象需要引用同一份数据;
  • 生命周期独立;
  • 可能需要单独归档、分片或设置保留策略。

在这个场景里,Address 通常适合嵌入。数量有限的 CreditCard 也可以嵌入,而持续产生的 Payment 更适合放在独立集合中。

需要特别注意的问题

不要使用 float 存储金额

float 和 JavaScript 的普通二进制浮点数无法精确表示许多十进制金额。支付系统应根据业务要求使用:

  • MongoDB Decimal128;
  • 或者用最小货币单位保存整数,例如分。

例如:

{
  payAmountMinor: NumberLong("19990"),
  currency: "CNY"
}

这表示 199.90 CNY。如果使用整数最小单位,还要明确不同货币的小数位规则。

不要把 MongoDB 当成自动执行关联约束的 RDBMS

使用 customerId 和 creditCardId 引用其他文档时,MongoDB 不会自动提供传统的外键约束。应用需要处理:

  • 被引用的目标是否存在;
  • 删除客户或信用卡后,付款记录如何保留;
  • 历史快照与当前资料之间是什么关系;
  • 多集合写入失败后如何恢复;
  • 是否需要使用事务。

对于支付记录,通常不应在删除客户资料时级联删除历史交易。

分页不要长期依赖很大的 skip

查询大量付款记录时,应优先使用基于排序键的游标分页,例如以 payDate 和 _id 作为稳定边界,而不是使用不断增大的 skip:

db.payments.createIndex({
  customerId: 1,
  payDate: -1,
  _id: -1
})

获取后续页面时,根据上一页最后一条记录构造范围条件,可以避免跳过大量索引项。

最终建议

先将 payments 设计为独立的事实集合,把客户资料和数量有限的信用卡保存在 customers 中。围绕主要查询建立复合索引,再用真实数据验证查询计划、文档大小和写入吞吐量。

只有在确认单文档的固定开销确实成为瓶颈,而且付款总是按固定时间窗口批量访问时,才需要考虑桶模式。对于高频统计,可以增加可重建的汇总字段或汇总集合,但原始付款记录仍应作为事实来源。

这样既能避免客户文档无限增长,也能高效支持”查询某客户有多少张卡”和”统计某个时间范围内的所有付款”这两类访问模式。

备注:内容仅供参考。