MongoDB 数据建模与企业落地
内嵌与引用怎么选、常见建模模式、副本集高可用与事务取舍,从示例项目到生产部署。
0. 一句话理解
建模的核心只有一道选择题:这条数据是”和父文档一起读”还是”独立存在、被多处引用”?前者内嵌,后者引用——选错是 MongoDB 项目最常见的性能事故来源。
1. 内嵌(Embedding)
// 内嵌:订单直接包含地址,读订单时一次拿到全部
db.orders.insertOne({
orderNo: "A001",
user: "小明",
items: [
{ sku: "m-001", name: "鼠标", price: 99, qty: 2 },
{ sku: "k-002", name: "键盘", price: 299, qty: 1 }
],
total: 497
})
讲解:
- 订单条目与订单几乎总是”一起读、一起写”,内嵌让一次查询拿到整份订单,不需要 JOIN。
- 数组大小有上限(单文档 16MB),购物车、订单明细这类有限条目非常适合内嵌。
- 内嵌的代价:条目更新必须走整份文档的更新,且无法单独查询”所有订单里的鼠标”(需要聚合
$unwind)。
2. 引用(Referencing)
// 商品被所有订单共享,独立成集合,订单里只存 _id 或冗余快照
db.products.insertOne({ _id: "m-001", name: "鼠标", price: 99 })
db.orders.insertOne({
orderNo: "A002",
user: "小红",
items: [{ productId: "m-001", name: "鼠标", price: 99, qty: 1 }]
})
讲解:
- 商品是”主数据”,被成千上万订单引用,必须独立集合;订单条目里既存
productId,也冗余存name/price快照。 - 快照的意义:商品改名、涨价不影响历史订单展示;这是”读时一致性”的工程取舍。
- 需要跨集合组合数据时用
$lookup,但高频查询路径应尽量通过冗余设计避免$lookup。
3. 选择决策表
| 场景 | 推荐 |
|---|---|
| 订单 + 明细,一起读一起写 | 内嵌 |
| 用户 + 地址簿,地址独立管理 | 引用(或内嵌数组,看规模) |
| 商品 + 订单,共享主数据 | 引用 + 快照 |
| 评论 + 文章,评论无限增长 | 引用(文章内嵌最近 3 条 + 评论集合) |
| 日志、事件流 | 独立集合 + TTL 索引 |
| 标签、分类 | 内嵌数组 |
4. 常用建模模式
- 子集模式:文章详情页内嵌”最近评论”,完整评论放独立集合,兼顾速度与无限增长。
- 桶模式:物联网传感器按”每小时一条桶文档”聚合 60 条采样,减少文档数量与索引体积。
- 版本字段模式:文档加
schemaVersion字段,未来结构变更时按版本迁移。 - 扩展引用模式:把引用字段(如作者名)冗余到文档里,避免高频
$lookup。
5. 生产落地:副本集与事务
# docker-compose.yml 片段:一主两从副本集
services:
mongo-primary:
image: mongo:8.3
command: ["mongod", "--replSet", "rs0"]
mongo-secondary-1:
image: mongo:8.3
command: ["mongod", "--replSet", "rs0"]
mongo-secondary-2:
image: mongo:8.3
command: ["mongod", "--replSet", "rs0"]
讲解:
- 副本集是 MongoDB 高可用的基本单位:主节点写、从节点同步,主节点故障时自动选举新主。
- 生产环境至少要一主两从(3 个数据副本),单机
mongod只适合学习。 - MongoDB 4.0+ 支持多文档事务,但事务有性能成本;能用单文档原子更新解决的(
$inc、$set本身就是原子的)就不要开事务。
// 事务示例:转账(需要副本集环境)
const session = db.getMongo().startSession()
session.startTransaction()
try {
session.getDatabase("bank").accounts.updateOne(
{ user: "A" }, { $inc: { balance: -100 } }, { session }
)
session.getDatabase("bank").accounts.updateOne(
{ user: "B" }, { $inc: { balance: 100 } }, { session }
)
session.commitTransaction()
} catch (err) {
session.abortTransaction()
throw err
} finally {
session.endSession()
}
讲解:
startSession()创建会话,startTransaction()开启事务;所有读写都要显式传{ session },否则不在事务内。- 全部成功
commitTransaction()提交;任何一步抛错abortTransaction()回滚,保证 A 扣钱与 B 加钱同时生效或同时取消。 finally里endSession()释放会话资源,防止连接泄漏。
6. 动手试试
- 设计一个”博客”模型:文章、作者、评论。用内嵌还是引用?写出来并说明理由。
- 用 Docker 起一个三节点副本集(或使用官方文档的单机副本集初始化),执行上面的转账事务。
- 为你的模型写一份”读写路径表”:每个页面读哪些集合、需要几次查询。
7. 一句话记住
一起读一起写的就内嵌,被共享、被独立管理的就引用;高频路径用快照换速度,跨文档强一致才开事务。