把数据放在该放的地方
另有 28 种语言版本
English · Español · 日本語 · Português (Brasil) · Français · Deutsch · Italiano · 한국어 · Português (Portugal) · 繁體中文 · Bahasa Indonesia · Türkçe · Polski · Tiếng Việt · ไทย · Nederlands · العربية · עברית · Українська · हिन्दी · Русский · Svenska · Dansk · Norsk bokmål · Suomi · Español (México) · Français (Canada) · Deutsch (Schweiz)

第 4 个模块,共 6 个,属于为含义建模数据一章,即 Data Architect 课程的第 2 章。每个模块都可以独立阅读。 上一篇:构建逻辑模型 · 下一篇:设计到第三范式,然后适度回退
**本模块回答的问题:**规范化如何帮助我弄清数据真正属于哪里?
我们已经有了一个逻辑模型:实体、关系、属性和键。现在我想问数据建模中最重要的问题之一:这些数据真的属于这里吗?
这听起来很简单,其实不然。我见过很多系统,其中每个字段在有人创建它的时候都说得通。问题不在于字段荒唐,而在于事实存错了地方。一条配送记录里存着其实是在描述配送中心的信息。同一个地址出现在几百条记录上。一个字段里存着用逗号隔开的三个值。改动一个业务事实,就意味着要更新 10,000 条记录。这正是规范化帮我们解决的问题。在我们被数据库术语淹没之前,先把整个思路说清楚:**把事实放在它该在的地方。**本模块的其余内容,都是帮我们更系统地做到这一点。
什么是规范化?
Section titled “什么是规范化? ”规范化就是组织数据,让每个事实只存储一次,存在它该在的地方。当一张表承担了太多职责时,我们就把它拆成几张表,每张表只描述一样东西,再用键把它们连接起来。
何必这么麻烦?因为只存一次的事实,也只需要改一次。规范化能防止冗余,也就是同一个事实被复制到许多记录上,同时也防止一份副本更新了、其他副本没更新时悄悄出现的不一致。这就是为什么它适合 Salesforce 这样的交易系统:记录整天都在被创建和更新。
我们分步骤进行规范化,这些步骤叫作范式,每一步只问一个问题:
- **第一范式(1NF):**每个字段一个值,并且有某样东西能唯一标识每一行。
- **第二范式(2NF):**每个事实都描述标识这一行的全部内容,而不只是其中一部分。
- **第三范式(3NF):**每个事实都描述这样东西本身,而不是与它相关的别的东西。
第三范式之后还有更多范式。我稍后会简单谈谈它们,以及为什么我们通常止步于第三范式。首先,我们来看看这三个步骤是怎么发生的。
为什么要规范化?
Section titled “为什么要规范化? ”我在现场授课时,不从配送讲起,而是从狗狗讲起。每个人都懂狗,所以没人会被业务分心,我们可以看着数据努力找到自己的家。然后,我们再把同样的步骤用到 Steve Trucking 上。
坐下、别动、建模
Section titled “坐下、别动、建模 ”**起点:原始电子表格。**我会在屏幕上放几只狗,然后全班同学再加上他们自己的。几分钟之内,我们就有了一张大清单:狗的名字、出生年份、主人是谁、主人住在哪里、品种、繁育者是谁,以及它会哪些技能。
| 狗狗 | 出生年份 | 主人 | 邮编 | 城市 | 品种 | 繁育者 | 技能 |
|---|---|---|---|---|---|---|---|
| Bella | 2017 | Marcus | 80202 | Denver | 拉布拉多 | Maple Ridge | 坐下、别动、握手 |
| Milo | 2019 | Marcus | 80202 | Denver | 贵宾犬 | Golden Meadow | 坐下、打滚 |
| Max | 2022 | Ana | 64105 | Kansas City | 混种 | 坐下、装死 | |
| Luna | 2020 | Kenji | 98101 | Seattle | 哈士奇 | Cascade | 坐下、别动、转圈 |
| Rocky | 2024 | Priya | 30301 | Atlanta | 比格犬 | Maple Ridge | 坐下 |
| Coco | 2014 | Ana | 64105 | Kansas City | 吉娃娃 | Little Paws | 握手、转圈 |
它能用。每张电子表格在第一天都能用。但看看已经出了什么问题。我要怎么找出所有会“握手”的狗?Ana 的邮编出现在两行上,Marcus 的也是:如果有人只更新了其中一行,会怎样?Maple Ridge 出现了两次,Kansas City 也是。还有,Max 是从收容所领养的,所以根本没有繁育者。
**第 1 步:第一范式。两条规则。每个字段只存一个值,所以用逗号隔开的技能清单必须拿掉。并且每一行都需要某样能唯一标识它的东西。**不是狗的名字,叫 Max 的狗多得是。而是一个 ID:一个自动生成的 ID,或者一个 GUID。技能被拆出来,放进它们自己的行里,每只狗的每项技能一行。而一旦一项技能有了自己的行,就有地方记录逗号清单里永远说不出来的东西:这项技能有多难,这只狗掌握得怎么样,以及它是什么时候学会的。
狗狗(按狗狗 ID 标识)
| 狗狗 ID | 名字 | 出生年份 | 主人 | 邮编 | 城市 | 品种 | 繁育者 |
|---|---|---|---|---|---|---|---|
| D-001 | Bella | 2017 | Marcus | 80202 | Denver | 拉布拉多 | Maple Ridge |
| D-002 | Milo | 2019 | Marcus | 80202 | Denver | 贵宾犬 | Golden Meadow |
| D-003 | Max | 2022 | Ana | 64105 | Kansas City | 混种 | |
| D-004 | Luna | 2020 | Kenji | 98101 | Seattle | 哈士奇 | Cascade |
| D-005 | Rocky | 2024 | Priya | 30301 | Atlanta | 比格犬 | Maple Ridge |
| D-006 | Coco | 2014 | Ana | 64105 | Kansas City | 吉娃娃 | Little Paws |
狗狗技能(Dog Trick)(按狗狗 ID + 技能标识)
| 狗狗 ID | 技能 | 难度 | 熟练程度 | 学会时间 |
|---|---|---|---|---|
| D-001 | 坐下 | 简单 | 精通 | 2017-11 |
| D-001 | 别动 | 简单 | 精通 | 2018-01 |
| D-001 | 握手 | 中等 | 良好 | 2018-06 |
| D-002 | 坐下 | 简单 | 精通 | 2020-02 |
| D-002 | 打滚 | 困难 | 学习中 | 2023-05 |
| D-003 | 坐下 | 简单 | 良好 | 2022-06 |
| D-003 | 装死 | 困难 | 学习中 | 2024-03 |
| D-004 | 坐下 | 简单 | 精通 | 2020-11 |
| D-004 | 别动 | 简单 | 良好 | 2021-01 |
| D-004 | 转圈 | 中等 | 精通 | 2021-06 |
| D-005 | 坐下 | 简单 | 学习中 | 2025-02 |
| D-006 | 握手 | 中等 | 精通 | 2015-06 |
| D-006 | 转圈 | 中等 | 学习中 | 2025-04 |
现在,“所有会握手的狗”就是一个简单的问题了。但请注意狗狗技能是靠什么标识的:不是一样东西,而是两样东西合在一起,狗和技能。先记住这一点。
**第 2 步:第二范式。**只有当一行是由一个组合来标识时,第二范式才有意义,而狗狗技能正是如此。所以我对每一列都问:**它描述的是整个组合,还是只是其中一部分?**熟练程度描述的是这只狗做这项技能:Bella 的“坐下”已经精通,Rocky 还在学。学会时间也一样。但难度呢?不管哪只狗做,“坐下”都很简单。它描述的是技能,只是键的一部分,这就是为什么“简单”在每一行“坐下”上都重复出现。所以技能有了自己的表,剩下的就是这一对组合。
技能
| 技能 ID | 技能 | 难度 |
|---|---|---|
| T-01 | 坐下 | 简单 |
| T-02 | 别动 | 简单 |
| T-03 | 握手 | 中等 |
| T-04 | 打滚 | 困难 |
| T-05 | 装死 | 困难 |
| T-06 | 转圈 | 中等 |
狗狗技能(狗狗 ID + 技能 ID)
| 狗狗 ID | 技能 ID | 熟练程度 | 学会时间 |
|---|---|---|---|
| D-001 | T-01 | 精通 | 2017-11 |
| D-001 | T-02 | 精通 | 2018-01 |
| D-001 | T-03 | 良好 | 2018-06 |
| D-002 | T-01 | 精通 | 2020-02 |
| D-002 | T-04 | 学习中 | 2023-05 |
| D-003 | T-01 | 良好 | 2022-06 |
| D-003 | T-05 | 学习中 | 2024-03 |
| D-004 | T-01 | 精通 | 2020-11 |
| D-004 | T-02 | 良好 | 2021-01 |
| D-004 | T-06 | 精通 | 2021-06 |
| D-005 | T-01 | 学习中 | 2025-02 |
| D-006 | T-03 | 精通 | 2015-06 |
| D-006 | T-06 | 学习中 | 2025-04 |
这张狗狗技能表,就是我们在模块 2 中发现的那张,它在数据建模中有个名字:联接,或者照我的叫法,逻辑联接对象。一只狗会很多项技能;一项技能也有很多只狗会。联接对象位于两者之间,而且它不是空洞的管道:它存放着属于这段关系本身的事实,也就是熟练程度和日期。我们会在模块 6 中再次遇到它;在第 3 章中我们还会看到,Salesforce 对“联接对象”的用法更窄:**Salesforce 联接对象(Junction Object)**是一个带有两个主从关系的自定义对象(第 3 章,模块 7)。
**第 3 步:第三范式。**现在回到狗狗表。表上的一切都由狗狗 ID 标识,所以第二范式没什么可说的。第三范式问的是另一个问题:**这个事实描述的是这只狗,还是这只狗相关的别的东西?**品种是一个独立的东西:拉布拉多就是拉布拉多,不管是哪只狗,而关于它的事实,比如体型,属于品种,而不属于 Bella。邮编和城市描述的是主人,而不是狗:那是 Ana 住的地方,不管我们看的是她的哪只狗。繁育者的详细信息描述的是繁育者。这些都是关于另一个事实的事实,所以每一项都移到它自己的表里,狗用一个外键指向它:
- **狗狗:**狗狗 ID、名字、出生年份、主人 ID、品种 ID、繁育者 ID
- **主人:**主人 ID、姓名、邮编、城市
- **品种:**品种 ID、品种、体型
- **繁育者:**繁育者 ID、名称、城市
- 技能和狗狗技能,来自第 2 步
现在 Ana 只存储一次,她的两只狗都指向她。Maple Ridge 只存储一次,而品种表为每个品种的事实(比如体型)提供了一个家,这些事实在电子表格里根本没地方放。外键可以为空:Max 没有繁育者 ID,这是一个诚实的答案,而不是缺漏。然后我会给全班出一道陷阱题:主人是什么时候得到这只狗的,这个日期放在哪里?放在狗上。在这个模型里,一只狗同一时间只有一个主人,所以这个日期描述的是这只狗与它主人之间的关系。
你还可以再进一步,把城市从邮编中拆出来;教科书就是这么做的。但在实践中,一个地址会保持在一起,况且 Kansas City 本来就横跨两个州,这很好地提醒我们:现实世界可不读我们的教科书。
这就是整个过程:一张电子表格,然后三个问题,每个事实最后都落在一个地方。退后一步,看看我们建了什么。一张杂乱的清单变成了六张表,每张描述一样东西,用键连接起来:
跟上这个过程,不需要任何一个 Salesforce 术语。它只是狗、狗身边的人,以及它们会什么。
现在把它用到 Steve Trucking 上
Section titled “现在把它用到 Steve Trucking 上 ”狗狗让这些步骤一目了然。而真实的业务让它们变得要紧,因为同样的错误在规模化之后要花真金白银。所以,我们把同样的三个问题带到 Steve Trucking。假设它是这样记录配送的:
| 配送 | 零售合作伙伴 | 配送中心 | 配送中心城市 | 配送小组 | 卡车 |
|---|---|---|---|---|---|
| 1001 | Keystone | Keystone Phoenix DC | Phoenix | 配送小组 12 | 117 |
| 1002 | Keystone | Keystone Phoenix DC | Phoenix | 配送小组 8 | 122 |
| 1003 | Keystone | Keystone Phoenix DC | Phoenix | 配送小组 12 | 117 |
乍一看,这里没什么复杂的。但看看配送中心。我们在每一条从它出发的配送上重复写着“Keystone Phoenix DC, Phoenix”:Steve Trucking 每天在九个配送中心之间大约完成 3,300 次配送,所以每个配送中心每天有几百次。如果那个配送中心搬迁了怎么办?我们要改多少条记录?如果漏掉一条又会怎样?现在数据说这个配送中心同时位于两个城市。这就是更新异常。如果我们想在一个新配送中心的第一次配送之前就把它建立起来,怎么办?如果我们删除了某个配送中心的最后一次配送:我们是不是刚刚把唯一存放着它的信息的地方也删掉了?
这些是同一个问题的不同症状:**我们把关于不同事物的事实混在了一起。**配送中心的事实属于配送中心。配送的事实属于配送。配送小组的事实属于配送小组。这就是实践中的规范化。
第一范式:不要隐藏重复结构
Section titled “第一范式:不要隐藏重复结构 ”我们从第一范式,也就是 1NF 开始。人们有时会在这里把规范化弄得比实际需要更难。
假设一次配送有个叫“项目”的字段:“冰箱、制冰机套件、旧机拉走。”我们把三条信息放进了一个字段。这看起来很方便,直到有人问:“上个月我们做了多少次旧机拉走?”或者想象有叫项目 1、项目 2 和项目 3 的字段。这也能用,直到出现有四个项目的配送。问题不在于我们选错了项目字段的数量,而在于我们没有认出这个重复结构。一次配送可以有好几个项目。这是我们应该建模的数据:也就是我们在模块 2 中发现的配送项目。
1NF 的实践经验是:**不要把重复结构藏在字段里。**如果业务告诉你某件事可以发生很多次,就要认真听。可能有另一个实体正想冒出来。
在真实的 org 里,你会经常看到这种情况。有人需要几种语言的描述,于是创建了一个叫 Description_English 的字段,然后是 Description_French,再然后是 Description_German。或者一项审批需要几个人,于是我们就有了审批人 1、审批人 2、审批人 3。我在评审中发现这种情况时,就是打一下手,去修你的数据模型。如果业务确实永远只有一两个,一两个字段也许还行。超过这个数,就应该是一个子对象:一个带有语言的翻译(Translation),一个带有审批人的审批步骤(Approval Step)。下一种语言,或者第四位审批人,就变成一条新记录,而不是一个新字段外加一次部署。
第二范式:描述整个东西
Section titled “第二范式:描述整个东西 ”第二范式,也就是 2NF,在一条记录由多样东西的组合来标识时才有意义。以配送项目为例。它有自己的记录 ID,但每一条都按配送 + 产品唯一。假设我们存储配送、产品、数量和产品的描述。
数量在这里说得通:它描述的是这次配送上的这个产品。但产品描述描述的并不是这个组合,而是产品本身。把它放在每一条配送项目上,那么这个型号的冰箱每次装上另一辆卡车,我们都要重复一遍。这就是线索:这个事实属于别的地方。
所以 2NF 的问题是:**这个属性描述的是我正在标识的整个东西,还是只是其中一部分?**如果它只描述产品,它就归产品。如果它描述的是产品在这次特定配送中的情况(数量、是否需要安装),它就属于配送项目。很多管理员永远不会有意识地说:“我现在正在应用第二范式。”这没关系。我更在意的是,你学会问:这个事实实际上描述的是什么?
第三范式:不要存储关于其他事实的事实
Section titled “第三范式:不要存储关于其他事实的事实 ”现在说说日常建模中我最看重的那个:第三范式,也就是 3NF。
每个配送中心在零售合作伙伴那边都有一位经理,这个人会使用合作伙伴门户。假设我们把配送中心经理的姓名直接放在每一次配送上。配送中心换了新经理时会怎样?我们要更新从那个配送中心出发的每一次配送。又是那股味道:我们在重复一个属于别处的事实。配送从一个配送中心出发;配送中心有一位经理。这是两段关系,经理的姓名属于配送中心。
经典的教科书例子是地址:邮编、城市和州一起存储在每条记录上,而城市和州通常可以由邮编推出来。同样的形状,同样的问题。
实用的 3NF 问题是:**这个事实描述的是这样东西,还是与这样东西相关的别的东西?**我希望你时刻都在问这个问题。
现在退后一步,就像我们对狗狗做的那样。我们一开始的那张配送表变成了五样东西,每样都存放自己的事实:配送项目在第一范式时被拆出来,产品在第二范式时被拆出来,而配送中心的城市和经理在第三范式时移到了配送中心。
同样的三个问题,同样的结果:每个事实都在一个地方。
理解 1NF、2NF 和 3NF 的简单方法
Section titled “理解 1NF、2NF 和 3NF 的简单方法 ”你可以花一整门数据库课程来讲规范化。我们不打算这么做。在这门课里,我希望你有一个在职架构师的理解:
- **1NF:**我是不是把多个值或者重复结构藏在了一个字段或一条记录里?
- **2NF:**这个事实描述的是我正在标识的整个东西吗?
- **3NF:**这个事实真的在描述这个实体,还是在描述与它相关的东西?
这些不是数据库教科书里的定义,而是我在设计时真正能用上的问题。
是的,规范化还能走得更远:**Boyce-Codd 范式(BCNF)**针对键更复杂的模型收紧了 3NF 周围的规则,此外还有第四、第五甚至第六范式。它们处理的是更少见的重复,这类重复更多来自不寻常的键结构,而不是日常的业务记录。理论很多,而根据我的经验,对于我们在 Salesforce 上构建的这类系统,不值得为它们头疼。在业务系统中,压力通常朝着相反的方向:出于用户、报表和性能的考虑,我们从第三范式适度回退的可能性,远远大于越过它。这是下一个模块的内容。知道还有更高的范式存在就好,但对于我们做的大多数事情,1NF 到 3NF 就给了我们所需的思考工具。
按第三范式来设计
Section titled “按第三范式来设计 ”我的立场是这样的。当我试图理解一个业务并设计它的逻辑模型时,我想从干净的状态开始:配送的事实归配送,配送中心的事实归配送中心,卡车的事实归卡车,重复的概念作为概念来建模。我不想仅仅因为可能让某个界面或报表更方便,就把一个业务事实复制得满模型都是。至少现在还不要。
但规范化不是宗教。目标不是做出学术上最纯粹的模型。我见过一些架构师太专注于纯粹,结果模型让每个必须使用它的人都很痛苦。我们不是要赢得一场规范化比赛;我们是在构建一个系统,而这个系统有用户、报表、集成、性能需求、规模,以及越来越多读取数据的 AI。它们可能想要不同形状的数据。
所以我的起点是在可行的情况下设计到第三范式,然后在必要时适度回退:先弄清每个事实属于哪里。然后,如果我们要偏离它,我想知道为什么。如果答案是“因为我不知道这个字段还能放哪儿”,那就不够好。如果答案是“我们有意复制了这个值,因为它让合作伙伴记分卡变得简单,而且我们有可靠的方法让它保持同步”,那我们就是在进行一场架构对话了。
架构师在这里决定什么
Section titled “架构师在这里决定什么 ”在对逻辑模型做规范化时,我会寻找:
- 应该单独建模的重复值或重复结构。
- 描述的实体与它们所存放的实体不同的属性。
- 在许多记录上不必要地重复的事实,以及让它们变得不一致的依赖关系。
- 藏在字段里的业务概念。
- 一个在任何实现妥协之前就能呈现真相的干净模型。
这不是因为每个物理系统都必须保持完美的规范化。恰恰相反。在下一个模块中,我们会出于用户、报表、规模、分析和 AI 的考虑,有意偏离干净的模型,而且我们会清楚地知道自己在用什么做交换。
