这是《MongoDB 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 本章设计一个“商品与订单中心”的 MongoDB 方案,覆盖 Schema、索引、CRUD、状态机、聚合、Change Stream、分片和监控。示例以学习为主,生产地址和密钥需按环境替换。
29.1 业务需求
核心用例:
- 按分类和关键词查询商品;
- 查看商品详情;
- 用户查看订单列表;
- 订单支付状态流转;
- 统计商品销量;
- 同步商品到搜索系统;
- 未来支持租户扩展。
数据特点:
- 商品读取多、更新中频;
- 订单按用户增长;
- 商品详情较大;
- 订单明细有限;
- 搜索同步要求增量。
29.2 集合设计
商品主表:
{
_id: "sku_10001",
tenant_id: "t_100",
title: "轻薄笔记本",
category_id: "c_notebook",
price: NumberDecimal("5999.00"),
status: "ON_SALE",
summary: "16 核 32GB",
keywords: ["轻薄", "笔记本"],
attrs: {
cpu: "16核",
memory: "32GB"
},
sales_count: 1200,
version: 3,
created_at: new Date(),
updated_at: new Date()
}
商品详情表:
{
_id: "sku_10001",
content: "<html>...</html>",
images: ["a.jpg", "b.jpg"]
}
订单表:
{
_id: "o_202608250001",
tenant_id: "t_100",
user_id: "u_1001",
status: "WAIT_PAY",
total_amount: NumberDecimal("5999.00"),
item_count: 1,
items: [
{
sku_id: "sku_10001",
title: "轻薄笔记本",
price: NumberDecimal("5999.00"),
quantity: 1
}
],
shipping_address: {
city: "Shanghai",
street: "南京西路 100 号"
},
version: 1,
created_at: new Date(),
updated_at: new Date()
}
29.3 索引设计
商品:
db.products.createIndex({ tenant_id: 1, category_id: 1, status: 1, sales_count: -1 })
db.products.createIndex({ tenant_id: 1, keywords: 1 })
db.products.createIndex({ tenant_id: 1, title: 1 })
订单:
db.orders.createIndex({ tenant_id: 1, user_id: 1, created_at: -1, _id: -1 })
db.orders.createIndex({ tenant_id: 1, status: 1, created_at: -1 })
设计说明:
- 查询都带租户前缀;
- 列表排序进入索引;
_id保证分页边界唯一;- 避免无关字段进入索引;
- 写入热点可后期评估分片。
29.4 创建订单
function createOrder(order) {
const result = db.orders.insertOne({
_id: order.orderId,
tenant_id: order.tenantId,
user_id: order.userId,
status: "CREATED",
total_amount: order.totalAmount,
item_count: order.items.length,
items: order.items,
shipping_address: order.address,
version: 1,
created_at: new Date(),
updated_at: new Date()
}, {
writeConcern: { w: "majority", j: true, wtimeout: 3000 }
});
return result;
}
订单号唯一,重复提交由 _id 唯一约束拦截。
29.5 支付状态机
function markPaid(orderId, version) {
const result = db.orders.updateOne(
{
_id: orderId,
status: "WAIT_PAY",
version: version
},
{
$set: {
status: "PAID",
paid_at: new Date()
},
$inc: { version: 1 },
$currentDate: { updated_at: true }
}
);
if (result.matchedCount === 0) {
throw new Error("order state changed");
}
return result;
}
状态机条件防止重复回调、旧请求和并发覆盖。
29.6 用户订单分页
第一页:
db.orders.find(
{
tenant_id: "t_100",
user_id: "u_1001"
},
{
status: 1,
total_amount: 1,
item_count: 1,
created_at: 1
}
).sort({ created_at: -1, _id: -1 }).limit(20)
下一页:
db.orders.find({
tenant_id: "t_100",
user_id: "u_1001",
$or: [
{ created_at: { $lt: lastCreatedAt } },
{ created_at: lastCreatedAt, _id: { $lt: lastId } }
]
}).sort({ created_at: -1, _id: -1 }).limit(20)
29.7 商品销量统计
db.orders.aggregate([
{
$match: {
tenant_id: "t_100",
status: "PAID",
created_at: {
$gte: new Date("2026-08-01T00:00:00Z"),
$lt: new Date("2026-09-01T00:00:00Z")
}
}
},
{ $unwind: "$items" },
{
$group: {
_id: "$items.sku_id",
quantity: { $sum: "$items.quantity" },
amount: { $sum: "$items.price" },
order_count: { $sum: 1 }
}
},
{ $sort: { quantity: -1 } },
{ $limit: 50 }
])
重统计建议移到 Hidden Secondary 或分析库。
29.8 搜索同步
全量初始化:
db.products.find({
tenant_id: "t_100",
status: "ON_SALE"
}, {
title: 1,
keywords: 1,
category_id: 1,
price: 1
})
增量监听:
const stream = db.products.watch(
[
{
$match: {
operationType: { $in: ["insert", "update", "replace", "delete"] }
}
}
],
{
fullDocument: "updateLookup"
}
)
同步程序保存 resume token,并对下游搜索文档执行幂等 upsert。
29.9 分片方案
当订单规模超过单 shard 容量:
sh.enableSharding("shop")
sh.shardCollection(
"shop.orders",
{ tenant_id: 1, user_id: 1 }
)
理由:
- 高频查询带
tenant_id和user_id; - 租户内部用户基数高;
- 写入按租户和用户分布;
- 每个租户事务多落在同一 shard;
- 大租户需要单独监控。
29.10 监控与验收
指标:
product_query_latency_p99
order_create_latency_p99
order_state_conflict_total
order_page_query_p99
search_sync_lag_seconds
change_stream_resume_errors
disk_usage
cache_hit_ratio
replication_lag
验收用例:
- 重复订单号不会重复创建;
- 重复支付回调只生效一次;
- 订单分页不重不漏;
- 商品列表查询走索引;
- 详情读取不影响列表延迟;
- 搜索同步可恢复;
- 分页查询带租户前缀;
- 副本切换后应用可恢复。
本章小结
项目实践把文档边界、索引、状态机、游标分页、聚合、Change Stream 和分片键串联起来。商品与订单中心的关键是列表轻量化、详情拆分、状态条件更新和搜索增量同步。
思考题
- 为什么商品详情要拆集合?
- 订单状态机为什么需要版本?
- 分页为什么要
created_at + _id? - 搜索同步如何处理 token 过期?
- 订单分片键为什么选择租户和用户?