Skip to content

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

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

运营在使用后台时反馈:商品列表需要显示商品名称、主图、最低售价和上下架状态,每页 20 条,按创建时间倒序。

这句话看起来只是一个查询需求,实际上已经包含了多个数据建模问题:

  • 哪些字段属于商品本身?
  • 最低售价来自商品还是 SKU?
  • 商品没有有效 SKU 时显示什么?
  • 上下架状态由商品决定,还是由 SKU 汇总决定?
  • 第二页如何稳定翻页?
  • 价格修改后,列表摘要什么时候更新?
  • 列表中的价格能不能直接用于下单?

如果直接查询商品,再逐个查询 SKU 计算最低价格,第一页可能没有问题。商品数量和 SKU 数量增加后,一个列表请求就可能变成大量数据库操作。

问题不是 MongoDB 不能关联,而是列表读路径与数据维护方式没有一起设计。

先确定接口合同 ​

第一版接口先限制查询范围:

  • status 必填
  • categoryId 可选
  • 默认按 createdAt 倒序
  • _id 作为同一时间下的稳定排序字段
  • 每页最多 100 条
  • 使用游标分页
  • 列表只返回展示所需字段
  • 详情页再获取长描述、属性和完整 SKU
  • 列表价格只用于展示,不能作为下单依据

一个简单的响应结构可以是:

json
{
  "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 应该由服务端生成,客户端不需要理解其中的具体内容。

为查询准备索引 ​

指定状态和分类的第一页查询可以使用以下索引:

javascript
db.products.createIndex({
  status: 1,
  categoryId: 1,
  createdAt: -1,
  _id: -1
})

查询示例:

javascript
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 的查询。

如果允许不指定分类,还需要根据实际查询计划评估另一条索引:

javascript
db.products.createIndex({
  status: 1,
  createdAt: -1,
  _id: -1
})

不能因为一个联合索引包含了所有字段,就认为它能同时高效支持所有过滤条件和排序方式。

索引是否有效,最终要通过执行计划确认:

javascript
db.products.find({
  status: "active",
  categoryId: "audio"
})
.sort({ createdAt: -1, _id: -1 })
.limit(20)
.explain("executionStats")

重点观察:

  • 是否出现 COLLSCAN
  • totalKeysExamined
  • totalDocsExamined
  • nReturned
  • 实际使用的索引是否符合预期

索引也会增加写入、更新、磁盘和内存成本。索引应服务于高频查询,而不是覆盖所有可能的字段组合。

使用游标分页保持稳定顺序 ​

skip + limit 写起来简单:

javascript
db.products.find(filter)
  .sort({ createdAt: -1, _id: -1 })
  .skip(1000)
  .limit(20)

但页码越大,数据库通常需要跳过越多记录。列表数据在翻页过程中发生新增或删除时,用户还可能看到重复数据或漏掉数据。

更适合大列表的是基于排序字段的游标分页。

假设上一页最后一条记录是:

json
{
  "createdAt": "2026-10-09T02:00:00.000Z",
  "_id": "66f3..."
}

下一页可以使用:

javascript
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 是否有效
  • 当前价格是否适用于该用户
  • 当前库存是否足够
  • 活动、限购和运费规则是否满足
  • 商品是否仍然允许销售

因此,列表可以保留少量重复摘要字段,交易过程仍然读取权威的价格和库存数据。

一个商品文档可以包含以下展示摘要:

javascript
{
  _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:是否存在可售 SKU
  • paidUnitsSummary:按照明确统计口径计算的销量
  • summarySourceVersion:摘要对应的来源版本
  • summaryUpdatedAt:摘要最后更新时间

创建订单时不能直接信任这些字段。

优惠、会员价、渠道价、限购、运费和实时库存都可能需要重新校验。列表和下单之间出现合理变化时,应通过明确的接口错误码和前端提示解释给用户。

最低价格必须有明确口径 ​

minPriceFen 不能只表示“所有 SKU 中最小的数字”。

系统至少要定义:

  • 哪些 SKU 算作有效 SKU
  • 下架 SKU 是否排除
  • 删除 SKU 是否排除
  • 库存为零的 SKU 是否仍然参与最低价计算
  • 是否使用原价、活动价或渠道价
  • 金额是否统一使用人民币分
  • 没有有效 SKU 时返回什么

例如,可以规定最低价只从满足以下条件的 SKU 中计算:

javascript
{
  productId: ObjectId("66f3..."),
  status: "active",
  sellable: true,
  priceFen: 19900
}

如果商品没有有效 SKU,不能把最低价格默认成 0。

推荐使用明确的状态:

javascript
{
  minPriceFen: null,
  priceStatus: "unavailable",
  hasAvailableSku: false
}

可以使用以下状态:

  • available:有有效 SKU 和展示价格
  • unavailable:没有可展示价格
  • refreshing:摘要正在更新
  • invalid:商品配置不完整,需要运营处理

状态数量不宜无限增加。第一版只保留能帮助用户做出判断的状态即可。

摘要字段是一项维护责任 ​

SKU 价格修改后,商品最低价必须同步变化。

假设商品有两个有效 SKU:

text
SKU A:199 元
SKU B:219 元

此时商品最低价为 199 元。

如果运营将 SKU A 下架,最低价应该变成 219 元。

简单执行:

javascript
db.products.updateOne(
  { _id: productId },
  { $min: { minPriceFen: 21900 } }
)

无法处理最低价上涨的情况,因为 $min 只能让数值变小,不能恢复被下架 SKU 贡献的旧最低价。

因此,可靠的重算方式是:

  1. 找到商品当前有效的 SKU
  2. 重新计算当前最低价
  3. 重新计算 hasAvailableSku
  4. 根据来源版本写回商品摘要
  5. 如果版本已经变化,则丢弃旧结果并重试

重算应该基于当前有效 SKU 集合,而不是根据某一次修改简单推导结果。

处理摘要更新的并发问题 ​

如果两个 SKU 同时发生修改,可能出现以下情况:

  1. 任务 A 读取到旧 SKU 集合
  2. 任务 B 读取到新 SKU 集合
  3. 任务 B 先写入新摘要
  4. 任务 A 后写入旧摘要
  5. 商品最终又回到了错误状态

可以为商品维护一个来源版本:

javascript
{
  _id: ObjectId("66f3..."),
  summarySourceVersion: 12
}

SKU 修改时,在同一个事务中:

  • 更新 SKU
  • 递增商品的 summarySourceVersion
  • 写入摘要重算事件

重算任务读取版本 13,计算完成后只允许在版本仍然是 13 时写回:

javascript
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 是合理方案。

例如:

javascript
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 集合至少需要评估以下索引:

javascript
db.skus.createIndex({
  productId: 1,
  status: 1,
  sellable: 1,
  priceFen: 1
})

但最终仍然要用 explain("executionStats") 验证。

需要比较的不是“$lookup 好不好”,而是:

  • 每次请求需要做多少工作
  • 每个商品关联多少 SKU
  • 计算结果能否复用
  • 读请求和写请求哪个更频繁
  • 业务允许多长时间的摘要延迟
  • 数据量扩大后执行计划是否稳定

如果列表每次只需要一个最低价,而 SKU 价格变化远少于列表读取,摘要字段通常更合适。它把成本从每次读取转移到了数据变化时。

为什么不返回整个商品文档 ​

完整商品文档可能包含:

  • 长描述
  • 多张图片
  • 商品属性
  • 内部采购字段
  • 成本价
  • 供应商信息
  • 未来新增的敏感字段

接口返回对象应当是稳定的业务协议,不能直接等同于数据库文档。

应用层应显式映射返回字段:

javascript
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,可以这样定义:

javascript
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,则应在文章和代码中说明:

javascript
required: ["_id", "name", "status", "createdAt"],
properties: {
  _id: { bsonType: "string" }
}

并保证应用始终显式生成字符串 ID。

Schema Validation 不会自动清理旧数据。给已有集合增加校验前,应先检查:

javascript
db.products.find({
  $or: [
    { name: { $not: { $type: "string" } } },
    { status: { $nin: ["draft", "active", "inactive"] } },
    { createdAt: { $not: { $type: "date" } } }
  ]
})

上线时还需要考虑:

  • 旧应用是否仍在写旧字段
  • 滚动发布期间是否存在新旧版本混合
  • 是否先兼容读取两种结构
  • 是否完成数据回填
  • 何时切换到严格校验
  • 不合规数据如何修复、隔离或暂时容忍

不要先收紧校验,再期待旧应用继续正常运行。

Schema 演进要兼顾接口兼容 ​

假设第一版只有一张主图:

javascript
{
  coverImage: "https://cdn.example.com/a.jpg"
}

后来需要支持多图:

javascript
{
  images: [
    "https://cdn.example.com/a.jpg",
    "https://cdn.example.com/b.jpg"
  ]
}

滚动发布时,可以采用以下步骤:

  1. 读代码同时兼容 images 和 coverImage
  2. 新版本开始只写 images
  3. 批量回填旧文档
  4. 确认所有消费者都已切换
  5. 最后移除旧字段兼容逻辑
  6. 再收紧 Schema Validation

这样可以避免新应用认识新字段,而旧实例仍然写入旧字段时产生混合记录。

没有价格不等于零元 ​

商品刚创建但还没有配置 SKU,与允许零元领取的商品,是两种不同业务。

如果没有有效 SKU,最低价应该返回:

javascript
{
  minPriceFen: null,
  priceStatus: "unavailable"
}

不要返回:

javascript
{
  minPriceFen: 0
}

否则前端可能显示“0 元起”,运营也可能误以为商品可以正常销售。

同样,摘要过期和库存不足也不应该都显示为“售罄”:

  • 摘要过期:refreshing
  • 没有有效 SKU:unavailable
  • 有 SKU 但库存不足:由库存服务计算销售状态
  • 商品配置错误:invalid

列表摘要只能表达它真正知道的事实,不能把不同原因混成一个状态。

商品删除与下架不是一回事 ​

下架通常只改变商品销售状态:

javascript
db.products.updateOne(
  { _id: productId },
  {
    $set: {
      status: "inactive",
      updatedAt: new Date()
    }
  }
)

历史订单仍然需要保留商品名称、SKU 名称、成交价格和图片快照。

物理删除还可能影响:

  • 历史订单关联
  • 搜索索引
  • 缓存
  • 摘要重算任务
  • 未处理事件
  • 审计日志
  • 后台报表

因此,常用管理流程可以优先采用下架或软删除。确需物理删除时,应明确关联引用、未完成任务和审计记录如何处理。

真实需求经常藏在第二句话里 ​

“做一个商品列表”之后,运营通常还会补充:

  • 可以按分类查看
  • 也能查看全部分类
  • 可以切换上架和下架
  • 可以按价格排序
  • 最好显示销量
  • 还要支持名称搜索

如果把第一句话当成完整需求,接口上线后就会不断追加查询条件,每次新增条件都临时猜一个索引。

第一版可以先明确范围:

  • 创建时间倒序作为默认排序
  • 分类和状态作为筛选条件
  • 最低价和销量作为展示摘要
  • 价格排序单独评估
  • 名称搜索单独评估
  • 多维组合筛选单独评估

范围收紧不是拒绝需求,而是让每条查询都有明确的性能和维护责任。

如果价格、销量、更新时间都支持排序,再和多个筛选条件组合,索引数量会迅速膨胀。低频大范围筛选可以限制时间和数量,或者转为异步导出。

不要让一个列表接口成为任意数据库查询器。

从页面到接口,至少经过四次转换 ​

第一次是字段清单:

  • 页面显示什么
  • 是否需要完整描述
  • 是否需要全部图片
  • 是否需要实时库存

第二次是语义清单:

  • “库存状态”是任一 SKU 可售,还是库存总数
  • “销量”是累计支付件数,还是扣除退款后的净销量
  • “最低价”是原价、活动价,还是用户最终价格

第三次是数据来源:

  • 哪些字段来自商品
  • 哪些字段来自 SKU
  • 哪些字段来自订单统计
  • 哪些字段来自独立的库存或价格服务

第四次才是查询条件、索引和返回协议。

同一个“销量”字段,如果详情页显示累计支付件数,报表却显示扣除退款后的净销量,用户看到差异时就会以为系统出错。

字段名称最好直接反映统计口径,例如:

javascript
{
  paidUnitsSummary: 260,
  paidUnitsSummaryUpdatedAt: ISODate("2026-10-09T02:00:00Z")
}

不要使用含糊的 sales,再让不同模块自行解释。

怎样验收这套设计 ​

准备以下测试数据:

  • 没有 SKU 的商品
  • 只有一个 SKU 的商品
  • 有多个 SKU 的商品
  • 最低价 SKU 被下架的商品
  • 所有 SKU 都下架的商品
  • 摘要已经过期的商品
  • 多个商品拥有相同 createdAt 的数据
  • 旧结构和新结构混合的数据

接口需要验证:

  • 没有价格时不会显示为零元
  • 游标翻页不会重复或漏掉数据
  • 同一时间创建的商品排序稳定
  • 指定分类和不指定分类都使用合理索引
  • 列表不会返回内部字段
  • 日期和金额格式符合接口约定
  • 摘要状态与实际数据含义一致

并发测试需要验证:

  • 同时修改多个 SKU 后,最终摘要与当前有效 SKU 集合一致
  • 旧重算结果不能覆盖新结果
  • 重复消费同一个摘要任务不会产生错误
  • 任务失败后能够重试
  • 补偿任务能够修复人为注入的不一致

性能验收至少记录:

  • 接口返回字节数
  • 数据库调用次数
  • totalKeysExamined
  • totalDocsExamined
  • nReturned
  • 管理更新耗时
  • 摘要刷新延迟
  • 任务积压数量
  • 摘要不一致数量

一个好的商品列表 API,不只是第一页返回得快,还应该能解释:

  • 每个字段从哪里来
  • 字段采用什么业务口径
  • 数据什么时候更新
  • 数据是否允许延迟
  • 数据能否用于交易判断
  • 数据不一致时如何恢复

最后的模型评审 ​

可以用一件空白商品完整演示以下生命周期:

  1. 创建商品
  2. 配置 SKU
  3. 设置价格
  4. 商品上架
  5. 修改最低价 SKU
  6. 将最低价 SKU 下架
  7. 全部 SKU 下架
  8. 商品重新上架
  9. 删除或软删除商品
  10. 创建订单并重新校验价格和库存

每一步都检查:

  • 商品列表
  • 商品详情
  • SKU 列表
  • 摘要字段
  • 下单结果
  • 异步任务状态
  • 审计记录

生命周期演示通常比只检查一个字段齐全的 JSON 更容易发现字段含义、维护触发器和接口兼容性的问题。

商品列表不是“把商品表查出来”这么简单。

它是一个由接口合同、查询索引、摘要维护、数据校验、分页协议和交易边界共同组成的读模型。MongoDB 是否适合这个场景,取决于这些边界是否被明确设计,而不是取决于某一条查询语句是否足够短。


参考:

上次更新于: