Armstrong公理与函数依赖:从属性闭包到候选键的数据库设计实战

📅 发布时间:2026/10/21 23:16:38
Armstrong公理与函数依赖:从属性闭包到候选键的数据库设计实战
从接到需求到业务上线中间有一步特别容易被团队忽略就是把一堆字段的依赖关系理清楚。我见过不少开发同学背了一堆三大范式的概念但一落到自己负责的表上完全不理解为什么这个表要拆、那个冗余字段为什么不能留。到最后我只能搬出最基础的那套理论工具把函数依赖抽出来用Armstrong公理推一遍。这套公理听起来像是只有考试才用得到的东西但它对付的恰恰是最实际的问题这张表的主键选得对不对哪些字段其实能被其他字段推导出来表结构里藏着多少不必要的传递依赖哪怕你只做业务开发很少做数据库设计只要你有机会写SQL、审核表结构就绕不开这套规则。本文不绕弯子直接从三条公理讲到三个推论再带你把属性闭包、候选键、最小覆盖这些实操必备技能完整过一遍。1. Armstrong公理是什么函数依赖世界的推理引擎1.1 先聊清楚函数依赖这回事很多人一看到“函数依赖”四个字就头大其实它特别像数学里的函数关系给定一个x就能确定唯一的y。放到数据库里就是“属性集合X的值确定后属性集合Y的值也就唯一确定了”记作X → Y。举个例子在一个员工表里员工编号一旦确定员工的姓名、部门编号、入职日期就都确定了这就是员工编号 → 姓名、员工编号 → 部门编号这样的函数依赖。为什么要先把这个基础概念讲清楚因为后面所有公理、推论、闭包计算操作对象都是这种“规则”本身不是具体的某一行数据。函数依赖描述的是表结构层面的约束不是某个时刻的数据巧合。比如一张表中当前没有重复的部门名称你也不能直接说部门名称 → 部门编号成立除非业务规则明确规定“一个部门名称只能对应一个部门编号”。这个区分在实际工作中特别容易踩坑我后面会专门讲。函数依赖和集合论里的“函数”概念有一个很直观的类比Y f(X)给定XY是被唯一决定的。数据库里所谓的“X决定Y”本质就是X的值和Y的值之间存在这么一种确定性的对应关系。一个关系模式中可能同时存在很多函数依赖而Armstrong公理要干的事就是告诉你从这些已有的函数依赖出发能推出哪些新的函数依赖出来。1.2 为什么必须要有一套“公理系统”这里先抛一个问题假设你已经知道函数依赖A → B和B → C成立那么A → C成立吗凭直觉应该成立因为A确定BB又确定CA自然能确定C。这就是传递性。又比如你知道AB → C成立那A → C成立吗这就不好说了因为决定C的可能是AB这个整体单独一个A并不一定能决定C。问题来了一个数据库里有几十上百个函数依赖靠直觉去判断一个依赖能不能被推出很容易出错。Armstrong公理就是来解决这个问题的。它给函数依赖推理制定了一套形式化的操作规则就像几何学里的公理一样你只要按规则来推导结果就不会有歧义。这套公理系统还解决了另一个关键问题判断某一组函数依赖是否冗余。一个表结构里可能写了5个依赖但其中1个可以由另外4个推导出来这个冗余依赖删掉也不影响约束关系。靠人肉判断不可靠但用公理推一遍就能明确得出结论。日常进行表结构评审、做数据库逆向工程、甚至核对数据血缘关系都离不开这套推导能力。1.3 三条公理自反律、增广律、传递律Armstrong公理系统的核心是三条公理。虽然名字听起来正式但每条都特别符合直觉我分别说明一下。自反律如果Y是X的子集那么X → Y成立。比如X {A, B}Y {A}因为属性A属于属性集合X所以X能决定Y。这个定律说的是你手上有更多的信息自然能确定这些信息中的一部分。它表面上看起来像废话但它保证了“属性集合能决定自己的任何子集”这一基本事实后面求闭包的时候要反复用到。增广律如果X → Y成立那么在X和Y的两边同时加上一个属性集合ZXZ → YZ也成立。注意这里Z可以加到左侧也可以加到右侧两侧加的是同一个集合。比如员工编号 → 姓名那么员工编号 部门编号 → 姓名 部门编号。它的本质是在已知的约束基础上增加一些输入条件原来的决定关系依然成立。这条公理在执行“无损分解”判断时特别重要。传递律如果X → Y且Y → Z那么X → Z成立。这个最符合直觉相当于链条传动X决定YY决定Z于是X决定Z。它是判断传递依赖的核心工具后面在识别第三范式问题时用的就是这条公理。这三条公理本身不是说“所有函数依赖都必须满足它们”而是说“从已知函数依赖集合出发通过这些规则能推出所有被蕴含的函数依赖”。Armstrong公理系统最重要的价值在于由这三条公理推出的所有函数依赖都是正确的依赖反过来任何被已知依赖集合从逻辑上蕴含的函数依赖也一定能用这三条公理推出来。前者叫有效性后者叫完备性。这就意味着在这套规则下做推导既不担心推出错的东西也不担心漏掉该推的东西。2. 从公理到推论合并律、分解律、伪传递律三条公理是基础但直接用起来还是有点繁琐。做实际的推导时我们通常会用三个由公理推出的推论它们叫合并律、分解律、伪传递律。下面逐一说清楚。2.1 合并律与分解律同一左侧的依赖可以合右侧拆开不会错合并律说的是如果X → Y和X → Z都成立那么X → YZ成立。注意前提条件这两个函数依赖的左侧必须是同一个属性集合X。这就像是你知道员工编号能决定员工姓名也能决定员工部门ID那你自然可以认为员工编号能同时决定员工姓名和部门ID。当然这个推论实际是可以从增广律和传递律推出来的但记成独立规则的目的是方便快速使用。分解律正好反过来如果X → YZ成立那么X → Y和X → Z都成立。也就是说一个依赖右侧的多个属性可以拆成多个独立的依赖。这在求最小函数依赖集的时候是标准操作把所有依赖的右侧都拆成单属性。这两个推论特别容易在实际使用中被搞混。很多人看到X → YZ就想当然地认为X → Y和Z → Y也成立这是完全错误的。分解律只能把右侧拆开不能把左侧拆开。反过来合并律要求左右两侧的起始依赖左侧一致如果你有两个依赖A → B和C → B你不能合并成AC → B因为你不知道A和C合在一起是否还决定B事实上可能成立但需要从其他依赖推导不能直接合并。2.2 伪传递律函数依赖里的“中转站”伪传递律的形式是如果X → Y且WY → Z那么WX → Z成立。这条很多人记不住但它的实际应用场景非常常见。举个例子员工编号 → 部门编号同时部门编号和岗位类型 → 岗位工资等级。那么员工编号加岗位类型 → 岗位工资等级吗根据伪传递律答案是肯定的令X 员工编号Y 部门编号W 岗位类型Z 岗位工资等级则WX 员工编号 岗位类型能推出Z 岗位工资等级。伪传递律本质上是传递律的推广。传递律要求中间的Y要完整地作为左侧条件出现在第二个依赖里但实际情况往往是“Y只是第二个依赖左侧的一个子集”得先把W加上才能用上Y。伪传递律就是把这种带附加条件的传递场景形式化了。做复杂依赖推导时这条规则能省很多中间步骤。为什么说这条推论重要因为在真实业务表里函数依赖很少干干净净地一条搭着一条经常是A决定B但只有B结合C才能决定D。如果你只记传递律遇到这种场景就不知道怎么推导了。伪传递律就是为了覆盖这种“带附加条件的链条推导”而存在的。用的时候多留意不要漏掉附加条件W。2.3 有效性与完备性为什么我们敢放心推导我在前面已经提到了有效性和完备性这里不妨再展开一点因为它直接关系到你推导出来的结论能不能用在真实工程里。有效性简单说就是“推出的都对”。从任意一个函数依赖集合F出发使用Armstrong公理推出的每一个新依赖都确实被F从逻辑上蕴含也就是说这些新依赖在F成立的所有关系实例中都成立。这就保证了我们不会推出错误结论。完备性则是反向保证“该推的全都能推出来”凡是能被F蕴含的依赖一定能通过有限次公理推导得到。这两点合起来意味着Armstrong公理系统是一个完美的推理工具不多推也不少推。完备性的证明思路其实很有意思它绕了一个弯要证明一个依赖X → Y能被F推出可以先去计算属性集合X在F下的闭包X然后检查Y是否包含在X中。如果包含那么X → Y就一定能被推导出来。这个结论在数据库理论里是核心也是我后面要讲的“属性闭包”算法的理论依据。理解这一点之后你再看下文就会明白为什么求闭包几乎是所有函数依赖推理的基础操作了。3. 实操核心属性闭包与候选键的完整求解过程到这里理论部分基本讲完了。但如果你想在真实项目中应用这些规则真正天天要用到的工具其实是属性闭包。它对解决问题非常有帮助判断一个依赖是否成立、求候选键、判断表是否满足范式以及设计合理的表分解几乎都离不开闭包计算。下面我用一个具体的例子带你走一遍完整流程。3.1 属性闭包那套“从已知推向未知”的手工算法所谓属性集合X在函数依赖集合F下的闭包记作X就是“仅凭F中的函数依赖能从X推导出的所有属性的集合”。它的算法极其简单我直接给出伪代码输入属性集合 X函数依赖集合 F 输出X 的闭包 X X X repeat for each 函数依赖 Y → Z in F: if Y ⊆ X and Z ⊄ X: X X ∪ Z until X 不再发生变化 return X这个算法的核心逻辑就是一个循环扩散的过程先把自己本身放进去然后不停地扫描每一个函数依赖一旦发现某个依赖的左侧属性已经全部在闭包里了就把它的右侧属性也加进来。一旦闭包开始扩大新加进来的属性又可能触发其他依赖所以需要反复循环直到闭包再也不能增长为止。这个扩散过程很像在一张地图上做“领地扩张”你一开始只拥有X这块地盘检查每个规则看看自己是否已经满足条件满足就收编新地盘然后拿新地盘继续去解锁更多地盘直到所有规则都无法再扩张为止。这个过程手算起来很繁琐但好在循环次数非常有限因为属性是有限的最多扩张到全体属性就停了。3.2 实例演示一个完整依赖集合的闭包求解空讲理论没有感觉我直接给一个具体的依赖集合来推一遍。假设有关系模式R(U)属性集合U {A, B, C, D, E, G}函数依赖集合F为A → BC B → D CD → E E → G现在我们来求属性A的闭包A。第一步初始化A {A}。第二步扫描依赖。A → BC左侧A已经在闭包中右侧BC不在闭包中于是A变为{A, B, C}。第三步继续扫描。B → D左侧B已经在闭包中将D加入A变为{A, B, C, D}。第四步继续扫描。CD → E左侧C和D都已经在闭包中了将E加入A变为{A, B, C, D, E}。第五步继续扫描。E → G左侧E已经在闭包中将G加入A变为{A, B, C, D, E, G}。再次扫描已经没有新的依赖可以触发循环结束。最终得到A {A, B, C, D, E, G}等于整个属性集合U。这个结果意味着单凭属性A就能确定这个关系中所有其他属性。换句话说A是一个超键。再求一下属性B的闭包练手。初始化B {B}。扫描B → D将D加入B {B, D}。其他依赖没有触发。于是B {B, D}。这个结果说明B只能决定D无法决定其他任何属性。这种手算过程虽然看起来简单但真到复杂依赖集合还是容易出错。我的建议是先按依赖的扫描顺序逐条检查不要跳每次闭包更新后都从第一条依赖重新开始扫直到一整轮扫描都没有变化为止。所有手工推错的情况几乎都是因为闭包更新到一半就停手过早下了结论。3.3 候选键与主键的判断思路求闭包最典型的应用就是找候选键。候选键就是能唯一确定元组的最小属性集合。说“最小”是要求它的任意真子集都不再具有这种唯一确定能力。在实际操作里我有一套固定的排查顺序第一先找出那些没有出现在任何函数依赖右侧的属性。这些属性称得上“必选属性”因为它们没法由其他属性推导出来所以任何候选键都必须包含这些属性。在上面的例子中F右侧出现过的属性是B、C、D、E、G没出现的只有A。所以A一定是任何候选键的一部分。第二算了算这些必选属性的闭包。如果闭包已经是全部属性U那它们就是唯一的候选键。本例中A U所以A就是唯一候选键。第三如果必选属性闭包不是U那就要通过组合探索了在剩余属性中挑选属性加到必选属性集合上分别求闭包找到能够覆盖全部属性的最小组合。这一步就是在多个组合之间做排查找到所有符合条件的组合。比如一个关系里有X、Y两个属性都没出现在依赖右侧那可以先算X再算Y再算XY看看哪些组合能达到全属性。候选键可能不唯一要全部找出来。候选键找出来之后主键只是在候选键里选一个做代表。不过设计表结构时建议把各个候选键都找全不要只盯着一个。因为后续的范式判断、索引选择、数据仓库维度建模都可能用到候选键集合。很多开发者习惯只看主键忽略其他候选键这在分析传递依赖时容易漏掉问题。4. 理论研究在实际建模中的典型应用4.1 判断冗余依赖简化依赖集合实际工作中我们很难一开始就拿到一个干净的函数依赖集合。业务文档里的依赖描述经常互相重叠a → b写了a → c也写了但还写了一条a → bc。后面这条明显可以由前两条通过合并律得到它是冗余依赖。怎么系统性地判断一条依赖是不是冗余方法还是求闭包把待检查的依赖X → Y从集合F中临时去掉得到F然后在F下重新求X的闭包X。如果Y ⊆ X说明这条依赖能被其他依赖推出它就是冗余依赖可以删掉。我把这个操作步骤整理一下选定要检查的依赖X → Y构造F F - {X → Y}在F下计算X的闭包如果Y是X闭包的子集X → Y冗余否则保留对每个依赖重复以上步骤。需要注意一个细节依赖删除的顺序会影响最终结果。你把A → B删了再检查B → C时由于A → B不在了B → C可能就不再冗余。所以最小化依赖集合的过程需要反复迭代直到再没有任何一条依赖可删为止。工程上这一步也可以交给工具去做但手工排查逻辑一定要理解否则工具报出结果你连结果对错都无法判断。4.2 通过传递依赖识别范式缺陷判断一个表是不是符合第三范式核心工具就是传递律。这里用一个非常经典的表设计场景来演示。假设有一张“员工信息表”设计如下字段员工编号部门编号部门名称部门地址存在的函数依赖有员工编号 → 部门编号、部门编号 → 部门名称、部门编号 → 部门地址。表面上看起来没什么问题字段都是业务需要的。但用传递律一推就会发现端倪员工编号 → 部门编号部门编号 → 部门名称于是得出员工编号 → 部门名称。这就是一条传递依赖意味着部门名称实际上被员工编号间接决定了。问题出在哪部门名称、部门地址本质上只依赖部门编号不直接依赖员工编号。把部门信息放进员工表中会导致每多一个员工就重复一遍部门名称和部门地址。如果某部门暂时没有员工这个部门的名称和地址连存都没地方存。正规的建模思路是把部门信息拆出去员工表保留员工编号、部门编号部门表的字段是部门编号、部门名称、部门地址。这样消除传递依赖数据不再重复。我很理解有人会说“反范式也是设计选择员工表直接带部门名称方便查询”。这个说法没错但你要清楚反范式的代价是什么。使用Armstrong公理的价值就是让你清楚地知道冗余从何而来然后在“查询性能”和“一致性维护成本”之间做出有意识的选择而不是根本不知道有传递依赖这回事。4.3 最小函数依赖集最小覆盖的求解最小函数依赖集也叫最小覆盖是表结构优化和数据模型评审时经常用到的一个概念。它的要求有三条右侧属性都是单属性左侧没有冗余属性集合中没有冗余依赖。满足这三条的依赖集合就叫F的最小覆盖。求解过程一般分三步。第一步把每个依赖的右侧拆成单属性。这一步用分解律。比如把A → BC拆成A → B和A → C。第二步消除左侧冗余属性。对每个依赖X → Y检查左侧是否有多余属性。方法是假设依赖的左侧是多个属性组成如AB → C就试着把A去掉在剩余依赖集合下求B的闭包如果C已经在B里说明A冗余可以从左侧删掉。这个操作的本质也是利用闭包判断逻辑蕴含。第三步消除冗余依赖。和4.1节讲的方法一样逐条检查把能被其他依赖推出的依赖删掉。以上文的F {A → BC, B → D, CD → E, E → G}为例简单走一遍。先把右侧拆单得到A → B、A → C、B → D、CD → E、E → G。接着检查CD → E左侧有没有冗余去掉C看D是否包含ED {D}不包含E所以C不能删去掉D看C是否包含EC {C}不包含E所以D也不能删。左侧没有冗余。最后逐条检查冗余依赖结果发现没有哪条依赖能被其他依赖推出。于是上面这个集合本身就是最小覆盖。这个结果看起来很顺利但实际业务里的依赖集合会比这复杂得多。做最小覆盖的时候最容易翻车的地方就是步骤顺序有些人上来就删依赖最后再拆右侧结果拆完之后又产生新的冗余依赖不得不重来。我建议严格按“拆右侧 → 删左侧冗余 → 删冗余依赖”的顺序执行每一步做完重新检查上一步的成果是否被破坏这样结果才可靠。5. 常见问题与排查经验实录5.1 自反律方向搞反闭包越求越错自反律说的是“如果Y是X的子集则X → Y成立”。但在实际计算闭包时很多人会搞反成“X是Y的子集则X → Y成立”。举个例子面对依赖AB → C时有人因为B是AB的子集就认为AB → B没问题这个当然没问题但另一个常见错误是从AB → C推出A → C或B → C这就错了。AB → C说的是A和B合在一起才能决定C单独一个A或B不一定有这个能力。这种错误在闭包计算中的典型表现是把“左侧子集”当成“可以推出的属性”然后直接把左侧拆开使用。记住一个口诀函数依赖的左侧只能整体使用右侧才可以根据分解律拆开。闭包算法里的判断条件是“依赖的整个左侧是否都已在闭包中”不是“左侧的某个属性是否在闭包中”。5.2 分解律被错误“合体”使用分解律只允许把右侧拆开合并律只允许合并同一个左侧的依赖。但实际中经常能见到这样的推导因为X → Y和X → Z所以X → YZ这是合并律没问题。但有些人会反过来因为Y → X和Z → X就推出YZ → X这是错误的必须通过其他依赖再验证。这种“逆用”很容易在不经意间混进手工推导过程让后续的候选键判断或者范式判断全盘出错。我在做评审的时候如果看到有人拿着一堆依赖直接写上结论我都会要求他把中间推导步骤补齐。倒不是为了流程规范而是因为一旦结果异常有完整推导步骤才能快速定位到是哪个环节出了问题。函数依赖推导和写代码一个道理每一步都依赖上一步的结果中间推理错了结论不可能对。5.3 候选键漏判左右侧属性特征没吃透找候选键时我有一个屡试不爽的经验先找“没出现在任何依赖右侧的属性”。这些属性不能被其他属性推导出来只能靠自己所以它们必须出现在每一个候选键中。这个特征帮我快速圈定必选集合大幅减少尝试组合的数量。反过来如果一个属性只出现在依赖右侧没出现在任何依赖左侧那么它绝不可能是候选键的一员。这种属性天生是“被决定的”加入候选键只会破坏候选键的最小性。举个例子员工编号 → 员工姓名那么员工姓名仅出现在右侧它不可能作为候选键的一部分因为员工编号已经能决定它了。实际排查时把这个特征用好能省不少力气。有一次我处理一个十几张表的依赖集合如果老老实实去组合验证组合数量爆炸到根本手算不了。但先用“右侧未出现属性”过滤一遍很多时候候选键的必选属性集合已经很小了再逐一补全剩余属性就好办得多。这个技巧是手工推导状态下最值得依赖的捷径。5.4 最小覆盖求解时顺序颠倒前面已经强调过最小覆盖求解最理想的操作顺序是先拆右侧再删左侧冗余属性最后删冗余依赖。但实际工作中我见过不少同事直接跳过第一步上来就对着多属性的右侧判断依赖是否冗余结果算出来的结论要么不对要么得反复返工。举个例子依赖A → BC和A → B。单看右侧不拆你可能会觉得A → BC不是冗余因为A → B只覆盖了BC中的B没有C。但把A → BC拆成A → B和A → C后在判断A → C是否冗余时就能更清晰。如果还存在其他依赖也能推出C那这条A → C就要被删掉。整个流程一步做错后面全乱。如果你要在一个大集合上做手工最小覆盖第一次做出来的结果很可能只是局部最优而不是全局最小。我的经验是做完后回过去再跑一遍看是否还能继续删。多跑一遍的成本很低但结论可靠度提升不少。这种反复打磨依赖集合的过程和代码重构非常像第一遍完成功能第二遍消除冗余第三遍检查边界。真正交付出去的依赖集合值得这样打磨。6. 写在最后的一点经验这套公理和推论我用了很多年每次带新人或者做表结构评审都会用到。很多人觉得它离实际开发远但只要遇到“这张表要不要冗余这个字段”“这个接口为什么要多查一张表”“这个字段明明在A表为什么业务却能从B表写数据”之类的问题往回追溯都能落到函数依赖上。我个人有个习惯拿到任何一张核心业务表的第一时间不是先看索引而是把主键写出来然后把非主键字段在主键下的闭包快速过一遍。凡是闭包之外还能被其他非主键字段决定的属性基本都是传递依赖或部分依赖值得单独留意。这个过程熟练之后只要几十秒就能完成但它帮我避开了大量因数据冗余而起的线上故障。另外工具能做的推导尽量用工具但工具给出来的结果我还是会用这套理论手工复核一遍关键的几条结论。原因很简单工具输出结果的黑盒子过程远没有自己用闭包推一遍来得踏实。这套理论教会我的不只是推导公式更是一种“任何结论都要能被推导链验证”的工程习惯。