MongoDB 电商实战 02|从 API 反推数据模型:先做一个商品列表

运营在使用后台时反馈:商品列表需要显示商品名称、主图、最低售价和上下架状态,每页 20 条,按创建时间倒序。
这句话看起来只是一个查询需求,实际上已经包含了多个数据建模问题:
- 哪些字段属于商品本身?
- 最低售价来自商品还是 SKU?
- 商品没有有效 SKU 时显示什么?
- 上下架状态由商品决定,还是由 SKU 汇总决定?
- 第二页如何稳定翻页?
- 价格修改后,列表摘要什么时候更新?
- 列表中的价格能不能直接用于下单?
如果直接查询商品,再逐个查询 SKU 计算最低价格,第一页可能没有问题。商品数量和 SKU 数量增加后,一个列表请求就可能变成大量数据库操作。
问题不是 MongoDB 不能关联,而是列表读路径与数据维护方式没有一起设计。
先确定接口合同
第一版接口先限制查询范围:
status必填categoryId可选- 默认按
createdAt倒序 _id作为同一时间下的稳定排序字段- 每页最多 100 条
- 使用游标分页
- 列表只返回展示所需字段
- 详情页再获取长描述、属性和完整 SKU
- 列表价格只用于展示,不能作为下单依据
一个简单的响应结构可以是:
{
"items": [
{
"id": "66f3...",
"name": "无线降噪耳机",
"coverImage": "https://cdn.example.com/a.jpg",
"minPriceFen": 19900,
"priceStatus": "available",
"status": "active",
"createdAt": "2026-10-09T02:00:00.000Z"
}
],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTEwLTA5VDAyOjAwOjAwLjAwMFoiLCJpZCI6IjY2ZjMifQ=="
}nextCursor 应该由服务端生成,客户端不需要理解其中的具体内容。
为查询准备索引
指定状态和分类的第一页查询可以使用以下索引:
db.products.createIndex({
status: 1,
categoryId: 1,
createdAt: -1,
_id: -1
})查询示例:
db.products.find(
{
status: "active",
categoryId: "audio"
},
{
name: 1,
images: { $slice: 1 },
minPriceFen: 1,
priceStatus: 1,
status: 1,
createdAt: 1
}
)
.sort({ createdAt: -1, _id: -1 })
.limit(20)这个索引适合同时指定 status 和 categoryId 的查询。
如果允许不指定分类,还需要根据实际查询计划评估另一条索引:
db.products.createIndex({
status: 1,
createdAt: -1,
_id: -1
})不能因为一个联合索引包含了所有字段,就认为它能同时高效支持所有过滤条件和排序方式。
索引是否有效,最终要通过执行计划确认:
db.products.find({
status: "active",
categoryId: "audio"
})
.sort({ createdAt: -1, _id: -1 })
.limit(20)
.explain("executionStats")重点观察:
- 是否出现
COLLSCAN totalKeysExaminedtotalDocsExaminednReturned- 实际使用的索引是否符合预期
索引也会增加写入、更新、磁盘和内存成本。索引应服务于高频查询,而不是覆盖所有可能的字段组合。
使用游标分页保持稳定顺序
skip + limit 写起来简单:
db.products.find(filter)
.sort({ createdAt: -1, _id: -1 })
.skip(1000)
.limit(20)但页码越大,数据库通常需要跳过越多记录。列表数据在翻页过程中发生新增或删除时,用户还可能看到重复数据或漏掉数据。
更适合大列表的是基于排序字段的游标分页。
假设上一页最后一条记录是:
{
"createdAt": "2026-10-09T02:00:00.000Z",
"_id": "66f3..."
}下一页可以使用:
db.products.find({
status: "active",
categoryId: "audio",
$or: [
{ createdAt: { $lt: ISODate("2026-10-09T02:00:00.000Z") } },
{
createdAt: ISODate("2026-10-09T02:00:00.000Z"),
_id: { $lt: ObjectId("66f3...") }
}
]
})
.sort({ createdAt: -1, _id: -1 })
.limit(20)createdAt 和 _id 一起排序,是为了避免多个商品拥有相同创建时间时出现顺序不稳定。
列表模型与交易模型可以不同
商品列表每次展示 20 条,用户主要是判断商品是否值得点进去。
下单时则必须重新确认:
- 当前 SKU 是否有效
- 当前价格是否适用于该用户
- 当前库存是否足够
- 活动、限购和运费规则是否满足
- 商品是否仍然允许销售
因此,列表可以保留少量重复摘要字段,交易过程仍然读取权威的价格和库存数据。
一个商品文档可以包含以下展示摘要:
{
_id: ObjectId("66f3..."),
name: "无线降噪耳机",
images: [
"https://cdn.example.com/a.jpg",
"https://cdn.example.com/b.jpg"
],
status: "active",
categoryId: "audio",
createdAt: ISODate("2026-10-09T02:00:00Z"),
minPriceFen: 19900,
priceStatus: "available",
hasAvailableSku: true,
paidUnitsSummary: 260,
summarySourceVersion: 12,
summaryUpdatedAt: ISODate("2026-10-09T02:00:00Z")
}这些字段属于展示摘要:
minPriceFen:展示用的最低价格hasAvailableSku:是否存在可售 SKUpaidUnitsSummary:按照明确统计口径计算的销量summarySourceVersion:摘要对应的来源版本summaryUpdatedAt:摘要最后更新时间
创建订单时不能直接信任这些字段。
优惠、会员价、渠道价、限购、运费和实时库存都可能需要重新校验。列表和下单之间出现合理变化时,应通过明确的接口错误码和前端提示解释给用户。
最低价格必须有明确口径
minPriceFen 不能只表示“所有 SKU 中最小的数字”。
系统至少要定义:
- 哪些 SKU 算作有效 SKU
- 下架 SKU 是否排除
- 删除 SKU 是否排除
- 库存为零的 SKU 是否仍然参与最低价计算
- 是否使用原价、活动价或渠道价
- 金额是否统一使用人民币分
- 没有有效 SKU 时返回什么
例如,可以规定最低价只从满足以下条件的 SKU 中计算:
{
productId: ObjectId("66f3..."),
status: "active",
sellable: true,
priceFen: 19900
}如果商品没有有效 SKU,不能把最低价格默认成 0。
推荐使用明确的状态:
{
minPriceFen: null,
priceStatus: "unavailable",
hasAvailableSku: false
}可以使用以下状态:
available:有有效 SKU 和展示价格unavailable:没有可展示价格refreshing:摘要正在更新invalid:商品配置不完整,需要运营处理
状态数量不宜无限增加。第一版只保留能帮助用户做出判断的状态即可。
摘要字段是一项维护责任
SKU 价格修改后,商品最低价必须同步变化。
假设商品有两个有效 SKU:
SKU A:199 元
SKU B:219 元此时商品最低价为 199 元。
如果运营将 SKU A 下架,最低价应该变成 219 元。
简单执行:
db.products.updateOne(
{ _id: productId },
{ $min: { minPriceFen: 21900 } }
)无法处理最低价上涨的情况,因为 $min 只能让数值变小,不能恢复被下架 SKU 贡献的旧最低价。
因此,可靠的重算方式是:
- 找到商品当前有效的 SKU
- 重新计算当前最低价
- 重新计算
hasAvailableSku - 根据来源版本写回商品摘要
- 如果版本已经变化,则丢弃旧结果并重试
重算应该基于当前有效 SKU 集合,而不是根据某一次修改简单推导结果。
处理摘要更新的并发问题
如果两个 SKU 同时发生修改,可能出现以下情况:
- 任务 A 读取到旧 SKU 集合
- 任务 B 读取到新 SKU 集合
- 任务 B 先写入新摘要
- 任务 A 后写入旧摘要
- 商品最终又回到了错误状态
可以为商品维护一个来源版本:
{
_id: ObjectId("66f3..."),
summarySourceVersion: 12
}SKU 修改时,在同一个事务中:
- 更新 SKU
- 递增商品的
summarySourceVersion - 写入摘要重算事件
重算任务读取版本 13,计算完成后只允许在版本仍然是 13 时写回:
db.products.updateOne(
{
_id: productId,
summarySourceVersion: 13
},
{
$set: {
minPriceFen: 21900,
priceStatus: "available",
hasAvailableSku: true,
summaryUpdatedAt: new Date()
}
}
)如果更新结果为 matchedCount: 0,说明商品已经发生新的变化,当前重算结果过期,应重新读取并重算。
另一种方式是为每个商品串行处理任务,但仍然需要幂等和失败重试。
同步重算和异步重算
同步重算的特点:
- 修改后容易立即看到结果
- 实现简单
- 管理操作耗时增加
- 计算失败会直接影响请求
异步重算的特点:
- 管理接口响应更快
- 可以批量处理和重试
- 摘要会短暂延迟
- 需要处理重复消费、任务失败和旧结果覆盖
小系统可以先采用清晰的同步实现。当 SKU 数量或写入频率增长后,再切换到异步任务。
如果使用异步模式,至少需要考虑:
- 事件是否可靠产生
- 任务是否可以重复执行
- 消费失败后如何重试
- 是否存在旧任务覆盖新任务
- 任务积压时如何监控
- 是否有定期补偿扫描
不能只在管理后台代码中触发重算,因为批量导入、数据迁移、定时任务和其他服务也可能修改 SKU。
所有写入都经过统一服务时,可以由服务产生事件;存在多条写入路径时,可以考虑 outbox、Change Streams 或定期对账任务。
直接使用 $lookup 可以吗
可以。
如果商品数量较少、每个商品的 SKU 数量可控、关联字段有合适索引,而且列表确实需要从 SKU 计算结果,$lookup 是合理方案。
例如:
db.products.aggregate([
{
$match: {
status: "active",
categoryId: "audio"
}
},
{
$lookup: {
from: "skus",
let: { productId: "$_id" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ["$productId", "$$productId"] },
{ $eq: ["$status", "active"] },
{ $eq: ["$sellable", true] }
]
}
}
},
{
$group: {
_id: null,
minPriceFen: { $min: "$priceFen" }
}
}
],
as: "skuSummary"
}
}
])SKU 集合至少需要评估以下索引:
db.skus.createIndex({
productId: 1,
status: 1,
sellable: 1,
priceFen: 1
})但最终仍然要用 explain("executionStats") 验证。
需要比较的不是“$lookup 好不好”,而是:
- 每次请求需要做多少工作
- 每个商品关联多少 SKU
- 计算结果能否复用
- 读请求和写请求哪个更频繁
- 业务允许多长时间的摘要延迟
- 数据量扩大后执行计划是否稳定
如果列表每次只需要一个最低价,而 SKU 价格变化远少于列表读取,摘要字段通常更合适。它把成本从每次读取转移到了数据变化时。
为什么不返回整个商品文档
完整商品文档可能包含:
- 长描述
- 多张图片
- 商品属性
- 内部采购字段
- 成本价
- 供应商信息
- 未来新增的敏感字段
接口返回对象应当是稳定的业务协议,不能直接等同于数据库文档。
应用层应显式映射返回字段:
function toProductListItem(product) {
return {
id: product._id.toString(),
name: product.name,
coverImage: product.images?.[0] ?? null,
minPriceFen: product.minPriceFen ?? null,
priceStatus: product.priceStatus ?? "unavailable",
status: product.status,
createdAt: product.createdAt.toISOString()
};
}这样可以:
- 控制响应体积
- 隐藏内部字段
- 统一日期格式
- 统一金额格式
- 处理缺失摘要
- 避免数据库字段新增后意外暴露给客户端
不要因为当前文档里还没有敏感字段,就默认未来所有字段都可以公开。
Schema Validation 只负责约束,不负责迁移
商品名称、状态、金额和日期字段可以通过应用校验与 MongoDB Schema Validation 双重约束。
如果系统统一使用 MongoDB 默认的 ObjectId,可以这样定义:
db.createCollection("products", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["name", "status", "createdAt"],
properties: {
name: {
bsonType: "string",
minLength: 1,
maxLength: 120
},
status: {
bsonType: "string",
enum: ["draft", "active", "inactive"]
},
categoryId: {
bsonType: ["string", "null"]
},
images: {
bsonType: "array",
items: {
bsonType: "string"
}
},
createdAt: {
bsonType: "date"
},
minPriceFen: {
bsonType: ["long", "int", "null"],
minimum: 0
},
priceStatus: {
bsonType: "string",
enum: [
"available",
"unavailable",
"refreshing",
"invalid"
]
}
}
}
},
validationLevel: "strict",
validationAction: "error"
})_id 不需要写入 required,MongoDB 会自动保证每条文档拥有 _id。
如果项目明确使用字符串 ID,则应在文章和代码中说明:
required: ["_id", "name", "status", "createdAt"],
properties: {
_id: { bsonType: "string" }
}并保证应用始终显式生成字符串 ID。
Schema Validation 不会自动清理旧数据。给已有集合增加校验前,应先检查:
db.products.find({
$or: [
{ name: { $not: { $type: "string" } } },
{ status: { $nin: ["draft", "active", "inactive"] } },
{ createdAt: { $not: { $type: "date" } } }
]
})上线时还需要考虑:
- 旧应用是否仍在写旧字段
- 滚动发布期间是否存在新旧版本混合
- 是否先兼容读取两种结构
- 是否完成数据回填
- 何时切换到严格校验
- 不合规数据如何修复、隔离或暂时容忍
不要先收紧校验,再期待旧应用继续正常运行。
Schema 演进要兼顾接口兼容
假设第一版只有一张主图:
{
coverImage: "https://cdn.example.com/a.jpg"
}后来需要支持多图:
{
images: [
"https://cdn.example.com/a.jpg",
"https://cdn.example.com/b.jpg"
]
}滚动发布时,可以采用以下步骤:
- 读代码同时兼容
images和coverImage - 新版本开始只写
images - 批量回填旧文档
- 确认所有消费者都已切换
- 最后移除旧字段兼容逻辑
- 再收紧 Schema Validation
这样可以避免新应用认识新字段,而旧实例仍然写入旧字段时产生混合记录。
没有价格不等于零元
商品刚创建但还没有配置 SKU,与允许零元领取的商品,是两种不同业务。
如果没有有效 SKU,最低价应该返回:
{
minPriceFen: null,
priceStatus: "unavailable"
}不要返回:
{
minPriceFen: 0
}否则前端可能显示“0 元起”,运营也可能误以为商品可以正常销售。
同样,摘要过期和库存不足也不应该都显示为“售罄”:
- 摘要过期:
refreshing - 没有有效 SKU:
unavailable - 有 SKU 但库存不足:由库存服务计算销售状态
- 商品配置错误:
invalid
列表摘要只能表达它真正知道的事实,不能把不同原因混成一个状态。
商品删除与下架不是一回事
下架通常只改变商品销售状态:
db.products.updateOne(
{ _id: productId },
{
$set: {
status: "inactive",
updatedAt: new Date()
}
}
)历史订单仍然需要保留商品名称、SKU 名称、成交价格和图片快照。
物理删除还可能影响:
- 历史订单关联
- 搜索索引
- 缓存
- 摘要重算任务
- 未处理事件
- 审计日志
- 后台报表
因此,常用管理流程可以优先采用下架或软删除。确需物理删除时,应明确关联引用、未完成任务和审计记录如何处理。
真实需求经常藏在第二句话里
“做一个商品列表”之后,运营通常还会补充:
- 可以按分类查看
- 也能查看全部分类
- 可以切换上架和下架
- 可以按价格排序
- 最好显示销量
- 还要支持名称搜索
如果把第一句话当成完整需求,接口上线后就会不断追加查询条件,每次新增条件都临时猜一个索引。
第一版可以先明确范围:
- 创建时间倒序作为默认排序
- 分类和状态作为筛选条件
- 最低价和销量作为展示摘要
- 价格排序单独评估
- 名称搜索单独评估
- 多维组合筛选单独评估
范围收紧不是拒绝需求,而是让每条查询都有明确的性能和维护责任。
如果价格、销量、更新时间都支持排序,再和多个筛选条件组合,索引数量会迅速膨胀。低频大范围筛选可以限制时间和数量,或者转为异步导出。
不要让一个列表接口成为任意数据库查询器。
从页面到接口,至少经过四次转换
第一次是字段清单:
- 页面显示什么
- 是否需要完整描述
- 是否需要全部图片
- 是否需要实时库存
第二次是语义清单:
- “库存状态”是任一 SKU 可售,还是库存总数
- “销量”是累计支付件数,还是扣除退款后的净销量
- “最低价”是原价、活动价,还是用户最终价格
第三次是数据来源:
- 哪些字段来自商品
- 哪些字段来自 SKU
- 哪些字段来自订单统计
- 哪些字段来自独立的库存或价格服务
第四次才是查询条件、索引和返回协议。
同一个“销量”字段,如果详情页显示累计支付件数,报表却显示扣除退款后的净销量,用户看到差异时就会以为系统出错。
字段名称最好直接反映统计口径,例如:
{
paidUnitsSummary: 260,
paidUnitsSummaryUpdatedAt: ISODate("2026-10-09T02:00:00Z")
}不要使用含糊的 sales,再让不同模块自行解释。
怎样验收这套设计
准备以下测试数据:
- 没有 SKU 的商品
- 只有一个 SKU 的商品
- 有多个 SKU 的商品
- 最低价 SKU 被下架的商品
- 所有 SKU 都下架的商品
- 摘要已经过期的商品
- 多个商品拥有相同
createdAt的数据 - 旧结构和新结构混合的数据
接口需要验证:
- 没有价格时不会显示为零元
- 游标翻页不会重复或漏掉数据
- 同一时间创建的商品排序稳定
- 指定分类和不指定分类都使用合理索引
- 列表不会返回内部字段
- 日期和金额格式符合接口约定
- 摘要状态与实际数据含义一致
并发测试需要验证:
- 同时修改多个 SKU 后,最终摘要与当前有效 SKU 集合一致
- 旧重算结果不能覆盖新结果
- 重复消费同一个摘要任务不会产生错误
- 任务失败后能够重试
- 补偿任务能够修复人为注入的不一致
性能验收至少记录:
- 接口返回字节数
- 数据库调用次数
totalKeysExaminedtotalDocsExaminednReturned- 管理更新耗时
- 摘要刷新延迟
- 任务积压数量
- 摘要不一致数量
一个好的商品列表 API,不只是第一页返回得快,还应该能解释:
- 每个字段从哪里来
- 字段采用什么业务口径
- 数据什么时候更新
- 数据是否允许延迟
- 数据能否用于交易判断
- 数据不一致时如何恢复
最后的模型评审
可以用一件空白商品完整演示以下生命周期:
- 创建商品
- 配置 SKU
- 设置价格
- 商品上架
- 修改最低价 SKU
- 将最低价 SKU 下架
- 全部 SKU 下架
- 商品重新上架
- 删除或软删除商品
- 创建订单并重新校验价格和库存
每一步都检查:
- 商品列表
- 商品详情
- SKU 列表
- 摘要字段
- 下单结果
- 异步任务状态
- 审计记录
生命周期演示通常比只检查一个字段齐全的 JSON 更容易发现字段含义、维护触发器和接口兼容性的问题。
商品列表不是“把商品表查出来”这么简单。
它是一个由接口合同、查询索引、摘要维护、数据校验、分页协议和交易边界共同组成的读模型。MongoDB 是否适合这个场景,取决于这些边界是否被明确设计,而不是取决于某一条查询语句是否足够短。
参考: