Skip to content

把数据放在该放的地方

另有 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)

Two identical drawers on a workbench, seen from above: one jammed with everything mixed together, one divided so each kind of thing has its own compartment.
图片由 AI 生成

第 4 个模块,共 6 个,属于为含义建模数据一章,即 Data Architect 课程的第 2 章。每个模块都可以独立阅读。 上一篇:构建逻辑模型 · 下一篇:设计到第三范式,然后适度回退

**本模块回答的问题:**规范化如何帮助我弄清数据真正属于哪里?

我们已经有了一个逻辑模型:实体、关系、属性和键。现在我想问数据建模中最重要的问题之一:这些数据真的属于这里吗?

这听起来很简单,其实不然。我见过很多系统,其中每个字段在有人创建它的时候都说得通。问题不在于字段荒唐,而在于事实存错了地方。一条配送记录里存着其实是在描述配送中心的信息。同一个地址出现在几百条记录上。一个字段里存着用逗号隔开的三个值。改动一个业务事实,就意味着要更新 10,000 条记录。这正是规范化帮我们解决的问题。在我们被数据库术语淹没之前,先把整个思路说清楚:**把事实放在它该在的地方。**本模块的其余内容,都是帮我们更系统地做到这一点。

规范化就是组织数据,让每个事实只存储一次,存在它该在的地方。当一张表承担了太多职责时,我们就把它拆成几张表,每张表只描述一样东西,再用键把它们连接起来。

何必这么麻烦?因为只存一次的事实,也只需要改一次。规范化能防止冗余,也就是同一个事实被复制到许多记录上,同时也防止一份副本更新了、其他副本没更新时悄悄出现的不一致。这就是为什么它适合 Salesforce 这样的交易系统:记录整天都在被创建和更新。

我们分步骤进行规范化,这些步骤叫作范式,每一步只问一个问题:

  • **第一范式(1NF):**每个字段一个值,并且有某样东西能唯一标识每一行。
  • **第二范式(2NF):**每个事实都描述标识这一行的全部内容,而不只是其中一部分。
  • **第三范式(3NF):**每个事实都描述这样东西本身,而不是与它相关的别的东西。

第三范式之后还有更多范式。我稍后会简单谈谈它们,以及为什么我们通常止步于第三范式。首先,我们来看看这三个步骤是怎么发生的。

我在现场授课时,不从配送讲起,而是从狗狗讲起。每个人都懂狗,所以没人会被业务分心,我们可以看着数据努力找到自己的家。然后,我们再把同样的步骤用到 Steve Trucking 上。

**起点:原始电子表格。**我会在屏幕上放几只狗,然后全班同学再加上他们自己的。几分钟之内,我们就有了一张大清单:狗的名字、出生年份、主人是谁、主人住在哪里、品种、繁育者是谁,以及它会哪些技能。

狗狗出生年份主人邮编城市品种繁育者技能
Bella2017Marcus80202Denver拉布拉多Maple Ridge坐下、别动、握手
Milo2019Marcus80202Denver贵宾犬Golden Meadow坐下、打滚
Max2022Ana64105Kansas City混种坐下、装死
Luna2020Kenji98101Seattle哈士奇Cascade坐下、别动、转圈
Rocky2024Priya30301Atlanta比格犬Maple Ridge坐下
Coco2014Ana64105Kansas City吉娃娃Little Paws握手、转圈

它能用。每张电子表格在第一天都能用。但看看已经出了什么问题。我要怎么找出所有会“握手”的狗?Ana 的邮编出现在两行上,Marcus 的也是:如果有人只更新了其中一行,会怎样?Maple Ridge 出现了两次,Kansas City 也是。还有,Max 是从收容所领养的,所以根本没有繁育者。

**第 1 步:第一范式。两条规则。每个字段只存一个值,所以用逗号隔开的技能清单必须拿掉。并且每一行都需要某样能唯一标识它的东西。**不是狗的名字,叫 Max 的狗多得是。而是一个 ID:一个自动生成的 ID,或者一个 GUID。技能被拆出来,放进它们自己的行里,每只狗的每项技能一行。而一旦一项技能有了自己的行,就有地方记录逗号清单里永远说不出来的东西:这项技能有多难,这只狗掌握得怎么样,以及它是什么时候学会的。

狗狗(按狗狗 ID 标识)

狗狗 ID名字出生年份主人邮编城市品种繁育者
D-001Bella2017Marcus80202Denver拉布拉多Maple Ridge
D-002Milo2019Marcus80202Denver贵宾犬Golden Meadow
D-003Max2022Ana64105Kansas City混种
D-004Luna2020Kenji98101Seattle哈士奇Cascade
D-005Rocky2024Priya30301Atlanta比格犬Maple Ridge
D-006Coco2014Ana64105Kansas 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-001T-01精通2017-11
D-001T-02精通2018-01
D-001T-03良好2018-06
D-002T-01精通2020-02
D-002T-04学习中2023-05
D-003T-01良好2022-06
D-003T-05学习中2024-03
D-004T-01精通2020-11
D-004T-02良好2021-01
D-004T-06精通2021-06
D-005T-01学习中2025-02
D-006T-03精通2015-06
D-006T-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 本来就横跨两个州,这很好地提醒我们:现实世界可不读我们的教科书。

这就是整个过程:一张电子表格,然后三个问题,每个事实最后都落在一个地方。退后一步,看看我们建了什么。一张杂乱的清单变成了六张表,每张描述一样东西,用键连接起来:

第三范式之后的狗狗模型 六个方框用连线相连。一位主人拥有一只或多只狗。一只狗有一位主人,属于一个品种,由零位或一位繁育者繁育(一位繁育者繁育一只或多只狗)。狗和技能之间是狗狗技能:一只狗会零项或多项狗狗技能,一项技能见于零条或多条狗狗技能,每条狗狗技能恰好属于一只狗和一项技能。 拥有 繁育 属于 会 见于 主人 繁育者 狗 品种 狗狗技能 技能 恰好一个 零个或一个 一个或多个 零个或多个 © 2026 Twinshark LLC · stevetecharc.com
Click to enlarge

跟上这个过程,不需要任何一个 Salesforce 术语。它只是狗、狗身边的人,以及它们会什么。

狗狗让这些步骤一目了然。而真实的业务让它们变得要紧,因为同样的错误在规模化之后要花真金白银。所以,我们把同样的三个问题带到 Steve Trucking。假设它是这样记录配送的:

配送零售合作伙伴配送中心配送中心城市配送小组卡车
1001KeystoneKeystone Phoenix DCPhoenix配送小组 12117
1002KeystoneKeystone Phoenix DCPhoenix配送小组 8122
1003KeystoneKeystone Phoenix DCPhoenix配送小组 12117

乍一看,这里没什么复杂的。但看看配送中心。我们在每一条从它出发的配送上重复写着“Keystone Phoenix DC, Phoenix”:Steve Trucking 每天在九个配送中心之间大约完成 3,300 次配送,所以每个配送中心每天有几百次。如果那个配送中心搬迁了怎么办?我们要改多少条记录?如果漏掉一条又会怎样?现在数据说这个配送中心同时位于两个城市。这就是更新异常。如果我们想在一个新配送中心的第一次配送之前就把它建立起来,怎么办?如果我们删除了某个配送中心的最后一次配送:我们是不是刚刚把唯一存放着它的信息的地方也删掉了?

这些是同一个问题的不同症状:**我们把关于不同事物的事实混在了一起。**配送中心的事实属于配送中心。配送的事实属于配送。配送小组的事实属于配送小组。这就是实践中的规范化。

我们从第一范式,也就是 1NF 开始。人们有时会在这里把规范化弄得比实际需要更难。

假设一次配送有个叫“项目”的字段:“冰箱、制冰机套件、旧机拉走。”我们把三条信息放进了一个字段。这看起来很方便,直到有人问:“上个月我们做了多少次旧机拉走?”或者想象有叫项目 1、项目 2 和项目 3 的字段。这也能用,直到出现有四个项目的配送。问题不在于我们选错了项目字段的数量,而在于我们没有认出这个重复结构。一次配送可以有好几个项目。这是我们应该建模的数据:也就是我们在模块 2 中发现的配送项目。

一次配送的项目:三种存法 三张卡片展示同一次配送的项目的三种存法。一个字段:配送 1001 有一个“项目”字段,内容是“冰箱、制冰机套件、拉走旧货”,它回答不了上个月拉走旧货多少次。重复字段:项目 1 冰箱、项目 2 制冰机套件、项目 3 拉走旧货,它装不下有四个项目的配送。一条子记录(突出显示):配送 1001 有三条配送项目,分别是冰箱、制冰机套件和拉走旧货,下一个项目是一条新记录,而不是一个新字段。下方的说明:如果某件事可以发生很多次,可能有另一个实体正想冒出来。 一个字段 三个事实藏在一个值里 配送 1001 项目:冰箱、制冰机套件、拉走旧货 它回答不了的问题 上个月拉走旧货多少次? 重复字段 项目 1、项目 2、项目 3 配送 1001 项目 1:冰箱 项目 2:制冰机套件 项目 3:拉走旧货 它装不下的配送 有四个项目的那一次 一条子记录 每个项目一条配送项目 配送 1001 配送项目:冰箱 配送项目:制冰机套件 配送项目:拉走旧货 下一个项目 一条新记录,而不是一个新字段 如果某件事可以发生很多次,可能有另一个实体正想冒出来。 © 2026 Twinshark LLC · stevetecharc.com
Click to enlarge

1NF 的实践经验是:**不要把重复结构藏在字段里。**如果业务告诉你某件事可以发生很多次,就要认真听。可能有另一个实体正想冒出来。

在真实的 org 里,你会经常看到这种情况。有人需要几种语言的描述,于是创建了一个叫 Description_English 的字段,然后是 Description_French,再然后是 Description_German。或者一项审批需要几个人,于是我们就有了审批人 1、审批人 2、审批人 3。我在评审中发现这种情况时,就是打一下手,去修你的数据模型。如果业务确实永远只有一两个,一两个字段也许还行。超过这个数,就应该是一个子对象:一个带有语言的翻译(Translation),一个带有审批人的审批步骤(Approval Step)。下一种语言,或者第四位审批人,就变成一条新记录,而不是一个新字段外加一次部署。

第二范式,也就是 2NF,在一条记录由多样东西的组合来标识时才有意义。以配送项目为例。它有自己的记录 ID,但每一条都按配送 + 产品唯一。假设我们存储配送、产品、数量和产品的描述。

数量在这里说得通:它描述的是这次配送上的这个产品。但产品描述描述的并不是这个组合,而是产品本身。把它放在每一条配送项目上,那么这个型号的冰箱每次装上另一辆卡车,我们都要重复一遍。这就是线索:这个事实属于别的地方。

所以 2NF 的问题是:**这个属性描述的是我正在标识的整个东西,还是只是其中一部分?**如果它只描述产品,它就归产品。如果它描述的是产品在这次特定配送中的情况(数量、是否需要安装),它就属于配送项目。很多管理员永远不会有意识地说:“我现在正在应用第二范式。”这没关系。我更在意的是,你学会问:这个事实实际上描述的是什么?

第三范式:不要存储关于其他事实的事实

Section titled “第三范式:不要存储关于其他事实的事实 ”

现在说说日常建模中我最看重的那个:第三范式,也就是 3NF。

每个配送中心在零售合作伙伴那边都有一位经理,这个人会使用合作伙伴门户。假设我们把配送中心经理的姓名直接放在每一次配送上。配送中心换了新经理时会怎样?我们要更新从那个配送中心出发的每一次配送。又是那股味道:我们在重复一个属于别处的事实。配送从一个配送中心出发;配送中心有一位经理。这是两段关系,经理的姓名属于配送中心。

经典的教科书例子是地址:邮编、城市和州一起存储在每条记录上,而城市和州通常可以由邮编推出来。同样的形状,同样的问题。

关于另一个事实的事实:配送中心经理 两个面板。每次配送上都有:一条配送记录,带有记录 ID、配送日期、配送中心,以及配送中心的城市和经理,后两项都标为重复。只存一次,存在配送中心上:配送保留它的记录 ID、配送日期和配送中心 ID,并从一个配送中心出发,配送中心存有自己的记录 ID、名称、城市和经理。下方的说明:换一位配送中心经理,左边要更新几百处,右边只要一处。 每次配送上都有 只存一次,存在配送中心上 出发自 配送 配送 ID 记录 ID 配送日期 配送中心 配送中心城市 重复 配送中心经理 重复 配送 配送 ID 记录 ID 配送日期 配送中心 ID 配送中心 配送中心 ID 记录 ID 名称 城市 经理 换一位配送中心经理:左边要更新几百处,右边只要一处。 © 2026 Twinshark LLC · stevetecharc.com
Click to enlarge

实用的 3NF 问题是:**这个事实描述的是这样东西,还是与这样东西相关的别的东西?**我希望你时刻都在问这个问题。

现在退后一步,就像我们对狗狗做的那样。我们一开始的那张配送表变成了五样东西,每样都存放自己的事实:配送项目在第一范式时被拆出来,产品在第二范式时被拆出来,而配送中心的城市和经理在第三范式时移到了配送中心。

规范化之后的 Steve Trucking 五个方框用连线相连。一个零售合作伙伴运营零个或多个配送中心。一次配送恰好从一个配送中心出发,并包含一个或多个配送项目。每个配送项目恰好对应一个产品,一个产品出现在零个或多个配送项目上。 运营 出发自 包含 出现在 零售合作伙伴 配送 配送项目 产品 配送中心 恰好一个 零个或一个 一个或多个 零个或多个 © 2026 Twinshark LLC · stevetecharc.com
Click to enlarge

同样的三个问题,同样的结果:每个事实都在一个地方。

你可以花一整门数据库课程来讲规范化。我们不打算这么做。在这门课里,我希望你有一个在职架构师的理解:

  • **1NF:**我是不是把多个值或者重复结构藏在了一个字段或一条记录里?
  • **2NF:**这个事实描述的是我正在标识的整个东西吗?
  • **3NF:**这个事实真的在描述这个实体,还是在描述与它相关的东西?

这些不是数据库教科书里的定义,而是我在设计时真正能用上的问题。

是的,规范化还能走得更远:**Boyce-Codd 范式(BCNF)**针对键更复杂的模型收紧了 3NF 周围的规则,此外还有第四、第五甚至第六范式。它们处理的是更少见的重复,这类重复更多来自不寻常的键结构,而不是日常的业务记录。理论很多,而根据我的经验,对于我们在 Salesforce 上构建的这类系统,不值得为它们头疼。在业务系统中,压力通常朝着相反的方向:出于用户、报表和性能的考虑,我们从第三范式适度回退的可能性,远远大于越过它。这是下一个模块的内容。知道还有更高的范式存在就好,但对于我们做的大多数事情,1NF 到 3NF 就给了我们所需的思考工具。

我的立场是这样的。当我试图理解一个业务并设计它的逻辑模型时,我想从干净的状态开始:配送的事实归配送,配送中心的事实归配送中心,卡车的事实归卡车,重复的概念作为概念来建模。我不想仅仅因为可能让某个界面或报表更方便,就把一个业务事实复制得满模型都是。至少现在还不要。

但规范化不是宗教。目标不是做出学术上最纯粹的模型。我见过一些架构师太专注于纯粹,结果模型让每个必须使用它的人都很痛苦。我们不是要赢得一场规范化比赛;我们是在构建一个系统,而这个系统有用户、报表、集成、性能需求、规模,以及越来越多读取数据的 AI。它们可能想要不同形状的数据。

所以我的起点是在可行的情况下设计到第三范式,然后在必要时适度回退:先弄清每个事实属于哪里。然后,如果我们要偏离它,我想知道为什么。如果答案是“因为我不知道这个字段还能放哪儿”,那就不够好。如果答案是“我们有意复制了这个值,因为它让合作伙伴记分卡变得简单,而且我们有可靠的方法让它保持同步”,那我们就是在进行一场架构对话了。

在对逻辑模型做规范化时,我会寻找:

  • 应该单独建模的重复值或重复结构。
  • 描述的实体与它们所存放的实体不同的属性。
  • 在许多记录上不必要地重复的事实,以及让它们变得不一致的依赖关系。
  • 藏在字段里的业务概念。
  • 一个在任何实现妥协之前就能呈现真相的干净模型。

这不是因为每个物理系统都必须保持完美的规范化。恰恰相反。在下一个模块中,我们会出于用户、报表、规模、分析和 AI 的考虑,有意偏离干净的模型,而且我们会清楚地知道自己在用什么做交换。