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 中。围绕主要查询建立复合索引,再用真实数据验证查询计划、文档大小和写入吞吐量。
只有在确认单文档的固定开销确实成为瓶颈,而且付款总是按固定时间窗口批量访问时,才需要考虑桶模式。对于高频统计,可以增加可重建的汇总字段或汇总集合,但原始付款记录仍应作为事实来源。
这样既能避免客户文档无限增长,也能高效支持”查询某客户有多少张卡”和”统计某个时间范围内的所有付款”这两类访问模式。
备注:内容仅供参考。
