范式与 MongoDB
I’m happy to help translate the article, but I need the full text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source line exactly as you provided and translate the rest into Simplified Chinese while preserving all formatting.
正规形式与现代数据建模
当数据库最初设计时,关系模型专注于在任何访问模式已知之前定义的企业范围实体。其理念是创建一个稳定的、规范化的模式,以便许多未来的应用程序共享。
如今,我们为特定应用或受限领域设计数据库。我们不再一次性构建完整模型,而是逐步添加功能,收集反馈,让模式随应用演进。
关键点: 正规形式不仅是关系理论——它们描述了真实的数据依赖性。即使使用 MongoDB 的文档模型,你仍然需要考虑规范化;只是你在应用它时拥有了更多的灵活性。
MVP:一款披萨、一位经理、一种口味、一个地区
我们正在启动一家新业务:在多个地区拥有大量连锁披萨店,提供多种披萨。
MVP 假设
| 属性 | 值 |
|---|---|
| name | “A1 Pizza” |
| manager | “Bob” |
| variety | “Thick Crust” |
| area | “Springfield” |
{
"name": "A1 Pizza",
"manager": "Bob",
"variety": "Thick Crust",
"area": "Springfield"
}
没有重复组或多值属性 → 已经符合 First Normal Form (1NF)。
由于 MVP 数据模型非常简单——每个属性只有一个值且只有一个键——因此不存在会违反更高范式的依赖关系。
许多设计一开始就完全规范化,并不是因为设计者细致地遍历了每个范式,而是因为初始数据集过于简单,根本不存在复杂的依赖关系。
随着业务规则的演变以及新口味、地区和独立属性的加入,出现了需要更高范式来解决的依赖关系,规范化在以后就变得必要了。
添加多种品种 – 违反 1NF
当一家披萨店可以提供多种品种时,朴素的做法可能是:
{
"name": "A1 Pizza",
"manager": "Bob",
"varieties": "Thick Crust, Stuffed Crust",
"area": "Springfield"
}
为什么这会破坏 1NF
- Atomicity – 每个字段必须只包含单一、不可再分的数据。
- 逗号分隔的字符串无法像单独的品种那样高效查询、索引或更新。
符合 1NF 的正确表示方式
Relational view – 在单独的表中存储一对多关系。
Document view – 使用 array(数组)而不是分隔字符串。
{
"name": "A1 Pizza",
"manager": "Bob",
"email": "bob@a1-pizza.it",
"varieties": ["Thick Crust", "Stuffed Crust"]
}
- 每个数组元素都是原子且可独立寻址 → 满足文档型等价的 1NF。
- MongoDB 将相关数据放在一起,保证可预测的性能,而 SQL 则提供逻辑‑物理数据独立性。
引入 Prices – 向 2NF 迁移
现在我们想要存储每种披萨的基础价格。
嵌入的价格信息
{
"name": "A1 Pizza",
"manager": "Bob",
"email": "bob@a1-pizza.it",
"varieties": [
{ "name": "Thick Crust", "basePrice": 10 },
{ "name": "Stuffed Crust", "basePrice": 12 }
]
}
第二范式 (2NF) 在 1NF 的基础上进一步要求:每个非键属性必须依赖于整个主键,而不是仅依赖于它的一部分。这只有在存在复合键时才会产生影响。
- 每个数组元素的复合键:(
pizzeria,variety)。 - 如果每家披萨店都可以自行设定价格,
basePrice依赖于完整的复合键 → 满足 2NF。
当价格是统一标准时
如果相同的品种在所有地方的价格都相同,basePrice 只依赖于 variety。这属于部分依赖 → 违反 2NF。
对价格数据进行规范化
创建一个单独的集合(或表)来存放价格信息:
{ "variety": "Thick Crust", "basePrice": 10 }
{ "variety": "Stuffed Crust", "basePrice": 12 }
从披萨店文档中移除 basePrice,需要时通过查询进行关联获取。
示例:MongoDB 视图实现价格关联
db.createView(
"pizzeriasWithPrices",
"pizzerias",
[
{ $unwind: "$varieties" },
{
$lookup: {
from: "pricing",
localField: "varieties.name",
foreignField: "variety",
as: "priceInfo"
}
},
{ $unwind: "$priceInfo" },
{
$addFields: {
"varieties.basePrice": "$priceInfo.basePrice"
}
},
{ $project: { priceInfo: 0 } }
]
);
另一种做法是直接在 varieties 数组中引用 pricing 集合(使用品种名称作为外键),在查询时进行关联。
Source: …
回顾
| 正规形式 | 强制条件 | 对我们的披萨模型的应用 |
|---|---|---|
| 1NF | 原子值,且没有重复组 | 对多种类使用数组(或单独的表) |
| 2NF | 对复合键不存在部分依赖 | 当价格仅是品种属性时,将 basePrice 拆分出来 |
| 3NF(未涉及) | 没有传递依赖 | 若出现例如 variety → category → taxRate 的情况,则需要进一步拆分 |
通过逐步演进模式——从一个简单、符合 1NF 的文档开始,仅在业务规则需要时进行规范化——可以在披萨连锁网络扩展的过程中,使数据模型既 灵活 又 结构良好。
更新文档数据库中的价格
当应用程序获取价格时,它会将该价格存储在 pizzeria 文档中,以加快读取速度。
为避免更新异常,应用程序在某种口味的价格变化时会更新所有受影响的文档:
const session = db.getMongo().startSession();
const sessionDB = session.getDatabase(db.getName());
session.startTransaction();
sessionDB.getCollection("pricing").updateOne(
{ variety: "Thick Crust" },
{ $set: { basePrice: 11 } }
);
sessionDB.getCollection("pizzerias").updateMany(
{ "varieties.name": "Thick Crust" },
{ $set: { "varieties.$[v].basePrice": 11 } },
{ arrayFilters: [{ "v.name": "Thick Crust" }] }
);
session.commitTransaction();
SQL 数据库避免这种多次更新,因为它们设计用于直接面向终端用户访问,通常会绕过应用层。如果不进行规范化(将依赖拆分到单独的表中),重复的数据可能会被忽视。在文档数据库中,应用服务负责维护一致性。
虽然可以将数据规范化到 2NF,但在面向领域的设计中这并不总是最佳选择。将价格嵌入到每个 pizzeria 中:
- 允许异步更新。
- 支持未来的需求——某些 pizzeria 可能提供不同的价格——且不会破坏完整性,因为应用程序强制执行原子更新。
实际上,许多应用在价格变动不频繁时会接受这种受控的重复,并倾向于快速的单文档读取,而不是追求完美的规范化写入。
1NF → 2NF 示例:经理电子邮件
原始文档(每个披萨店单个电子邮件)
{
"name": "A1 Pizza",
"manager": "Bob",
"email": "bob@a1-pizza.it",
"varieties": [
{ "name": "Thick Crust", "basePrice": 10 },
{ "name": "Stuffed Crust", "basePrice": 12 }
]
}
3NF – 消除传递依赖
该电子邮件实际上属于 经理,而不是直接属于披萨店,形成了传递依赖:
pizzeria → manager → email
规范化文档:
{
"name": "A1 Pizza",
"manager": { "name": "Bob", "email": "bob@a1-pizza.it" },
"varieties": [
{ "name": "Thick Crust", "basePrice": 10 },
{ "name": "Stuffed Crust", "basePrice": 12 }
]
}
如果一家披萨店有多个经理,请使用子文档数组。
在关系模型中,这将变成独立的表(pizzeria、manager、contact),但在我们的领域中我们不在披萨店之外管理联系人,因此使用嵌入是合适的。
Source: …
4NF – 独立的多值依赖
期望的交付区域
{
"name": "A1 Pizza",
"manager": { "name": "Bob", "email": "bob@a1-pizza.it" },
"offerings": [
{ "variety": { "name": "Thick Crust", "basePrice": 10 }, "area": "Springfield" },
{ "variety": { "name": "Thick Crust", "basePrice": 10 }, "area": "Shelbyville" }
]
}
多值依赖指的是一个属性决定另一个属性的一组取值,且该关系独立于所有其他属性。
-
如果品种和地区之间存在依赖关系(例如,只有特定品种在特定地区可用),则
(variety, area)这一对会成为单一事实,4NF 不会被违反。 -
在我们的例子中,披萨店会把 所有 品种送到 所有 地区,这产生了两个独立的多值事实:
pizzeria →→ varietypizzeria →→ area
将每一种组合都存储会导致冗余。
规范化的 4NF 文档
{
"name": "A1 Pizza",
"manager": { "name": "Bob", "email": "bob@a1-pizza.it" },
"varieties": [
{ "name": "Thick Crust", "basePrice": 10 },
{ "name": "Stuffed Crust", "basePrice": 12 }
],
"deliveryAreas": ["Springfield", "Shelbyville"]
}
现在 varieties(品种)和 deliveryAreas(送货地区)是独立存储的,消除了 4NF 违规。
2NF 与 3NF – 基于地区的定价
当定价随送货地区而变化时:
{
"name": "A1 Pizza",
"manager": { "name": "Bob", "email": "bob@a1-pizza.it" },
"offerings": [
{ "variety": "Thick Crust", "area": "Springfield", "price": 10 },
{ "variety": "Thick Crust", "area": "Shelbyville", "price": 11 },
{ "variety": "Stuffed Crust", "area": "Springfield", "price": 12 },
{ "variety": "Stuffed Crust", "area": "Shelbyville", "price": 13 }
]
}
- 每个提供项的复合键:(pizzeria, variety, area)。
price依赖于完整键,满足 2NF(无部分依赖)和 3NF(无传递依赖)。
BCNF – 添加区域经理
现在每个区域都有一个单独的经理,独立于披萨店:
{
"name": "A1 Pizza",
"manager": { "name": "Bob", "email": "bob@a1-pizza.it" },
"offerings": [
{ "variety": "Thick Crust", "area": "Springfield", "price": 10, "areaManager": "Alice" },
{ "variety": "Stuffed Crust", "area": "Springfield", "price": 12, "areaManager": "Alice" },
{ "variety": "Thick Crust", "area": "Shelbyville", "price": 11, "areaManager": "Eve" },
{ "variety": "Stuffed Crust", "area": "Shelbyville", "price": 13, "areaManager": "Eve" }
]
}
Boyce‑Codd 正规形 (BCNF) 要求每个决定因素都是超键。
这里,area → areaManager 违反了 BCNF,因为 area 不是 offering 文档的超键。
BCNF 解决方案
将区域‑经理关系提取到自己的集合或子文档中:
{
"area": "Springfield",
"manager": "Alice"
}
并在每个 offering 中引用该区域。
关系模型 vs. 文档模型中的范式
BCNF 与 3NF
- BCNF:每个决定因素必须是超键。
- 3NF 允许决定因素是候选键属性,只要被依赖属性是候选键的一部分。
示例: 在 offerings 关系(pizzeria、variety、area)中,函数依赖 area → areaManager 违反了 BCNF,因为单独的 area 不是超键。
实际影响: 更改某个地区的经理需要更新该地区的所有 offering。在关系系统中,你会把地区经理抽取到单独的表中。
MongoDB 方法(嵌入结构):
db.pizzerias.updateMany(
{ "offerings.area": "Springfield" },
{ $set: { "offerings.$[o].areaManager": "Carol" } },
{ arrayFilters: [{ "o.area": "Springfield" }] }
);
权衡: 查询更简洁、读取更快,但牺牲了严格的 BCNF 合规性;一致性由应用程序来保证。
5NF(投影-连接范式)
当提供多个 sizes(Small、Medium、Large),且这些尺寸与品种和地区相互独立时,存储每一种组合会产生冗余。
符合 5NF 的设计: 将独立的事实分别存储。
{
"name": "A1 Pizza",
"varieties": ["Thick Crust", "Stuffed Crust"],
"sizes": ["Large", "Medium"],
"deliveryAreas": ["Springfield", "Shelbyville"]
}
应用程序可以按需生成有效组合,避免出现数百个显式文档。
6NF(第六范式)
用于审计级别的价格历史:
{
"offerings": [
{
"variety": "Thick Crust",
"area": "Springfield",
"currentPrice": 12,
"priceHistory": [
{ "price": 10, "effectiveDate": ISODate("2024-01-01") },
{ "price": 11, "effectiveDate": ISODate("2024-03-15") },
{ "price": 12, "effectiveDate": ISODate("2024-06-01") }
]
}
]
}
6NF 设计: 使用时间维度事实集合。
{ "pizzeria": "A1 Pizza", "variety": "Thick Crust", "area": "Springfield",
"price": 10, "effectiveDate": ISODate("2024-01-01") }
{ "pizzeria": "A1 Pizza", "variety": "Thick Crust", "area": "Springfield",
"price": 11, "effectiveDate": ISODate("2024-03-15") }
{ "pizzeria": "A1 Pizza", "variety": "Thick Crust", "area": "Springfield",
"price": 12, "effectiveDate": ISODate("2024-06-01") }
在审计、分析或“某日期的价格”查询时使用此方案。