嘿,我是 Agnes。今天咱们不聊那些枯燥的教科书理论,我跟你聊聊最近有个朋友跟我吐槽的事儿——“孩子问钱去哪了”。
这标题听起来像家庭伦理剧,对吧?但在我这个做数据架构师的耳朵里,这其实是一个典型的 MongoDB 多对多关系设计翻车现场。
想象一下:你家里有个记账APP(假设底层用的是 MongoDB),孩子每次问“我的零花钱去哪了”,你得查:
- 这笔钱在哪个账户?
- 这笔钱花在了哪个分类(零食?玩具?游戏?)?
- 这笔钱是谁花的(孩子?还是孩子帮妈妈买的)?
这就是经典的多对多(Many-to-Many)关系:
- 一个用户(账户)可以有多笔交易
- 一笔交易可以属于多个分类
- 一个分类可以包含多笔交易
如果这时候你像写 MySQL 一样,搞三张表(Users, Transactions, Categories),再搞个中间表(User_Transaction_Category),然后 JOIN 来 JOIN 去……孩子还没问完,你已经因为查询慢到卡顿而被赶出客厅了。
别急,今天我就把这事儿掰开揉碎,用大白话给你讲清楚:MongoDB 里到底该怎么设计多对多?为什么我劝你别用 DBRef?什么是反范式?还有,怎么让查询快得像闪电?
第一坑:你以为的多对多,其实是“关系型思维”在作祟
很多人从 MySQL 转 MongoDB,第一步就是试图“翻译”表结构。
典型的错误写法(MySQL 思维)
// 1. 用户集合
db.users.insertOne({
_id: ObjectId("user1"),
name: "小明",
balance: 100
})
// 2. 分类集合
db.categories.insertOne({
_id: ObjectId("cat1"),
name: "零食"
})
// 3. 交易集合(中间表思维)
db.transactions.insertOne({
_id: ObjectId("txn1"),
userId: ObjectId("user1"),
amount: -10,
categoryIds: [ObjectId("cat1"), ObjectId("cat2")] // 多对多?不,这是数组引用
})
表面上看,categoryIds 是个数组,好像挺 MongoDB 的?但问题来了:
孩子问:“你上个月买零食花了多少钱?”
你得:
- 查
transactions找到所有userId为“小明”且包含“零食”ID 的记录 - 把这些
_id拿回来 - 再回
categories集合去查这些_id对应的名字是不是真的叫“零食”
这一步还没完,因为 MongoDB 的 ObjectId 不好过滤,你得用 $in:
db.transactions.find({
userId: ObjectId("user1"),
categoryIds: { $in: [ObjectId("cat1")] }
})
看起来没问题?但如果你要统计“所有买过零食的用户”……
// 先查 transactions 拿到所有 categoryIds 包含 cat1 的交易
// 再取 userId 去重
// 再查 users 集合
三次查询,三次网络往返。 孩子问完“钱去哪了”第三遍,你已经崩溃了。
第二坑:DBRef 是“陷阱”,不是“捷径”
你可能听说过 MongoDB 有个 DBRef 类型,专门用来做引用关系。
db.transactions.insertOne({
_id: ObjectId("txn1"),
userId: new DBRef("users", ObjectId("user1")),
category: new DBRef("categories", ObjectId("cat1"))
})
别用!千万别用!
为什么?因为 DBRef 不会自动 JOIN。你存进去的是个引用,但查出来的时候,它还是个引用,你得手动 $lookup 或者在代码里再查一次。
更可怕的是,DBRef 打破了 MongoDB 的“文档内自包含”原则。你存的时候觉得优雅,查的时候觉得自己像个笨蛋。
真相是:MongoDB 的设计哲学是“嵌入式文档”,不是“引用型关系”。
第三坑:反范式?听起来很玄乎,其实是“为了快”
什么叫做反范式(Denormalization)?
简单说:把数据存两份,甚至多份,为了少查几次数据库。
在关系型数据库里,这是“坏味道”;在 MongoDB 里,这是最佳实践。
举几个真实的例子
场景1:交易记录里直接存分类名
别只存 categoryId,直接把 categoryName 也存进去!
db.transactions.insertOne({
_id: ObjectId("txn1"),
userId: ObjectId("user1"),
userName: "小明", // 冗余!但查询时不用 JOIN
amount: -10,
categories: [
{
_id: ObjectId("cat1"),
name: "零食" // 冗余!但筛选时直接 $eq
},
{
_id: ObjectId("cat2"),
name: "游戏"
}
],
createdAt: ISODate("2024-01-15T10:00:00Z")
})
现在,孩子问“零食花了多少钱”,查询变成:
db.transactions.find({
userId: ObjectId("user1"),
"categories.name": "零食"
}).project({
amount: 1,
"categories.name": 1
})
一次查询,直接拿到结果。 不用 JOIN,不用二次查询。
场景2:用户账户里存“最近10笔交易”的摘要
如果孩子总问“最近花了多少钱”,你可以在用户文档里维护一个快照:
db.users.insertOne({
_id: ObjectId("user1"),
name: "小明",
balance: 90,
recentTransactions: [
{ amount: -10, category: "零食", date: ISODate("2024-01-15") },
{ amount: -5, category: "饮料", date: ISODate("2024-01-16") },
// ... 最近10条
],
monthlySpending: {
snacks: 100,
games: 50,
drinks: 20
}
})
这样,查“小明这个月零食花了多少”,直接读用户文档的 monthlySpending.snacks 字段。
代价是什么? 每次交易写的时候,要同时更新用户文档。但这在现代 SSD 和 MongoDB 的更新性能下,完全不是问题。
第四坑:什么时候该用嵌入?什么时候该用引用?
这是最核心的问题。很多开发者在这里纠结。
规则1:如果父文档和子文档是“整体-部分”关系,且部分数据量可控,就嵌入。
比如:订单和订单详情。一个订单通常也就几行商品,嵌入进去完全没问题。
规则2:如果子文档可能无限增长,或者子文档会被多个父文档共享,就引用。
比如:评论和帖子。一个帖子可能有几万条评论,嵌入进去会让帖子文档爆炸。这时候,评论单独存一个集合,通过 postId 引用。
规则3:多对多关系,90% 的情况应该用数组嵌入 + 反范式字段。
回到我们的记账例子:
- 交易和分类是多对多
- 分类数据量小(就十几个:零食、游戏、饮料、交通……)
- 每笔交易最多挂 3-5 个分类
答案很明确:把分类信息嵌入到交易文档里。
// 正确的多对多设计:嵌入分类数组
db.transactions.insertOne({
_id: ObjectId("txn1"),
userId: ObjectId("user1"),
amount: -10,
categories: [
{ id: ObjectId("cat1"), name: "零食", tag: "日常开销" },
{ id: ObjectId("cat2"), name: "游戏", tag: "娱乐" }
],
tags: ["零食", "游戏"] // 再冗余一个纯字符串数组,方便模糊搜索
})
第五坑:聚合管道(Aggregation Pipeline)是你的好朋友,但不是万能药
你可能会说:“那我可以用 $lookup 做 JOIN 啊!”
对,你可以。但 $lookup 在大数据量下性能很差。
对比实验
假设你有 100 万条交易记录,每条记录引用了 2 个分类。
方案A:嵌入式(反范式)
// 查询:小明上个月零食花费
db.transactions.find({
userId: ObjectId("user1"),
createdAt: { $gte: ISODate("2024-01-01"), $lt: ISODate("2024-02-01") },
"categories.name": "零食"
}).project({
amount: 1
})
执行计划: 索引扫描 → 返回结果。毫秒级。
方案B:引用式 + $lookup
db.transactions.aggregate([
{ $match: { userId: ObjectId("user1"), createdAt: { $gte: ISODate("2024-01-01") } } },
{ $lookup: {
from: "categories",
localField: "categoryIds",
foreignField: "_id",
as: "categories"
}},
{ $unwind: "$categories" },
{ $match: { "categories.name": "零食" } },
{ $group: { _id: null, total: { $sum: "$amount" } } }
])
执行计划: 扫描交易 → 每个交易都要去 categories 集合查 → 合并 → 过滤。
时间: 可能几秒,甚至更久。而且 $lookup 无法充分利用索引。
第六坑:数据一致性怎么办?反范式会不会导致数据不一致?
这是最常被问到的问题。
“你把分类名存在交易里,万一后来把‘零食’改成‘健康零食’,旧的交易记录不会变吗?”
答:不会变。而且你希望它不变。
正确的理解:历史数据 vs 当前数据
- 交易记录 是历史记录,反映的是当时的分类。如果分类改名了,历史交易不应该跟着变。这就像银行流水,你把“工资”改成“薪水”,以前的工资条不会变。
- 用户文档里的
monthlySpending是聚合数据,每次写交易时同步更新。这才是你需要的“当前视图”。
如何保证一致性?
用事务(Transactions)!
MongoDB 4.0+ 支持多文档事务。你可以在一次事务里:
- 插入交易文档
- 更新用户文档的
recentTransactions和monthlySpending
const session = db.getSiblingDB("accounts").startSession();
try {
session.withTransaction(async () => {
// 1. 插入交易(含分类信息)
await session.getDatabase("accounts").transactions.insertOne({
_id: ObjectId(),
userId: userId,
amount: -10,
categories: [
{ _id: snackCatId, name: "零食" }
],
createdAt: new Date()
}, { session });
// 2. 更新用户统计
await session.getDatabase("accounts").users.updateOne(
{ _id: userId },
{
$inc: { "monthlySpending.snacks": 10 },
$push: {
recentTransactions: {
amount: -10,
category: "零食",
date: new Date()
}
},
$set: { balance: { $multiply: ["$balance", 1] } } // 假设你要扣款
},
{ session }
);
});
} catch (e) {
console.error("事务失败", e);
} finally {
session.endSession();
}
这样,要么全部成功,要么全部回滚。数据一致性有了保障。
第七坑:索引怎么建?
嵌入和引用,索引策略完全不同。
嵌入式索引
// 复合索引:用户 + 时间 + 分类名
db.transactions.createIndex(
{ userId: 1, createdAt: -1, "categories.name": 1 }
)
// 或者,如果你经常按 category name 过滤
db.transactions.createIndex(
{ userId: 1, "categories.name": 1 }
)
引用式索引(不推荐,但如果你非要用)
db.transactions.createIndex({ userId: 1, categoryIds: 1 })
db.categories.createIndex({ name: 1 })
但别忘了,$lookup 不会用 categoryIds 索引,它用的是 foreignField(即 categories._id)。所以你还是得在 categories._id 上建索引,然后在 transactions 上建 userId 索引,最后在代码里合并结果。
复杂度直线上升。
第八坑:实战代码——完整的多对多记账系统
我给你一个完整的最小可行系统(MVP),用嵌入式 + 反范式设计。
1. 集合定义
// categories 集合:分类定义
db.categories.insertMany([
{ _id: ObjectId("cat1"), name: "零食", parent: null },
{ _id: ObjectId("cat2"), name: "游戏", parent: null },
{ _id: ObjectId("cat3"), name: "饮料", parent: "零食" } // 饮料是零食的子类
])
// users 集合:用户账户
db.users.insertOne({
_id: ObjectId("user1"),
name: "小明",
balance: 500,
monthlySpending: {
snacks: 0,
games: 0,
drinks: 0
},
recentTransactions: []
})
2. 写交易(带事务)
async function createTransaction(userId, amount, categoryIds, description) {
const session = db.getSiblingDB("accounts").startSession();
try {
await session.withTransaction(async () => {
// 1. 获取分类信息(用于嵌入)
const categories = await db.categories.find(
{ _id: { $in: categoryIds } },
{ session }
).toArray();
if (categories.length !== categoryIds.length) {
throw new Error("部分分类不存在");
}
// 2. 构建分类嵌入数组
const categoriesEmbed = categories.map(c => ({
_id: c._id,
name: c.name,
parent: c.parent
}));
// 3. 插入交易
const txnId = ObjectId();
await db.transactions.insertOne({
_id: txnId,
userId,
amount,
categories: categoriesEmbed,
description,
createdAt: new Date()
}, { session });
// 4. 更新用户统计
const categoryNames = categories.map(c => c.name.toLowerCase());
const updateOps = categoryNames.map(name => ({
updateOne: {
filter: { _id: userId },
update: {
$inc: { [`monthlySpending.${name}`]: Math.abs(amount) },
$push: {
recentTransactions: {
amount,
category: categoryNames.find(n => n === name) || "其他",
date: new Date()
}
}
}
}
}));
if (updateOps.length > 0) {
await db.users.bulkWrite(updateOps, { session });
}
});
return { success: true, txnId };
} catch (e) {
console.error("交易失败", e);
return { success: false, error: e.message };
} finally {
session.endSession();
}
}
3. 查询“小明上个月零食花了多少钱”
// 纯嵌入式查询,极快
const result = await db.transactions.aggregate([
{
$match: {
userId: ObjectId("user1"),
createdAt: {
$gte: ISODate("2024-01-01"),
$lt: ISODate("2024-02-01")
},
"categories.name": "零食"
}
},
{
$group: {
_id: null,
total: { $sum: { $abs: "$amount" } },
count: { $sum: 1 }
}
}
]);
console.log(`零食总花费: ${result[0]?.total || 0} 元,共 ${result[0]?.count || 0} 笔`);
4. 查询“所有买过游戏的孩子”
”`javascript // 这里还是有点麻烦,因为多对多反向查询 // 方案:在 users 集合里也嵌入“用户消费过的分类” // 或者,接受这种查询的性能损耗,用 $lookup(数据量小时可接受)
db.users.aggregate([ {
$lookup: {
from: "transactions",
localField: "_id",
foreignField: "userId",
as: "transactions"
}
}, {
$match: {
"transactions.categories.name": "游戏"
}
