1. 转换的本质:从图形语言到表结构的直译坐过一段时间数据库设计的人应该都有这个感受:E-R图画起来挺顺手,实体画个方块,联系画个菱形,属性画个圈,连上线,逻辑非常清楚。可一旦到了动手写CREATE TABLE的时候,就开始犯难——图上一堆线和圈,究竟哪些该变成表,哪些该变成字段,外键又该挂在谁身上?这个环节就是E-R模型转关系模型,说白了,是把一张充满图形语义的概念设计图,用二维表的形态重新表达出来的过程。我在数据库笔记的前几篇里讲过概念模型是需求的抽象,逻辑模型是设计的骨架。这一篇就是骨架落地的关键一步。很多开发者在需求调研阶段做得不错,E-R图也画得像模像样,结果死在转换这一步,建出来的表要么多出一堆没必要的关联表,要么字段重复冗余,要么查询时发现外键方向反了。更麻烦的是,这种问题在早期往往看不出来,等系统上线、数据量大起来再回头改表结构,成本就完全不一样了。1.1 一张E-R图最终会变成什么先记住一个总纲:E-R模型中的三类对象,对应关系模型的三种表达方式。实体集,变成一张表。实体集的每个属性,变成表的列。联系集,大部分变成外键,少数需要单独建表。属性中的特殊类型——多值、复合、派生——需要做额外的规范化处理。这个对应关系并不复杂,难的是判断尺度。同一个联系,什么时候只需要在某一侧加外键,什么时候必须拆出一张独立的关系表,这不能靠死记规则,得理解背后的数据约束语义。我见过不少初学者把「联系必须建表」当成铁律,结果学生和课程之间明明只是个M:N选课关系,硬是给1:1的联系也建了关联表,查数据时绕来绕去。也见过另一类人,M:N联系却不建表,把多个商品ID用逗号拼在一起塞进订单表里,后面要统计销量时欲哭无泪。说到底,转换的本质是把图形上的连接关系翻译成数据库能强制执行的外键约束,而不是机械地数数。1.2 三类对象各自的映射方向从更细的角度看,可以这样理解映射策略:E-R模型元素关系模型表达关键决策点普通实体集独立表主键怎么定,属性怎么落列弱实体集独立表主键依赖强实体主键1:1联系任选一侧加外键+唯一约束要不要拆独立表,外键放哪边1:N联系N端加外键外键不能放在1端M:N联系独立关系表关系表主键是什么,联系属性放哪多值属性拆成单独的表主键设计要防重复复合属性拆成多个列拆几层取决于业务是否需要按子项检索派生属性能不存就不存存储要控更新时间这张表基本就是全文的索引。下面我会一个个展开,并给出实际设计中的取舍逻辑,而不是只讲教科书上的标准答案。2. 三种二元联系的取舍:外键放哪边、表建几张二元联系是E-R图里出现频率最高的结构,也是转换时最常踩坑的地方。很多人背了三句话:1:1任意一侧加外键,1:N在N端加外键,M:N建独立表。背是记住了,但遇到真实业务仍然不知道怎么灵活变通,主要是没搞明白「为什么」。2.1 1:1联系:加外键还是独立建表1:1联系在语义上意味着一个实体A最多对应一个实体B,反过来也一样。比如某个系统里一个订单对应一张发票,一张发票也只属于一个订单,这就是典型的1:1。标准做法是在其中一张表里加另一张表的主键作为外键,并加UNIQUE约束保证对方也不会重复。问题是:外键到底放在哪边?我的经验是看两个方向里哪个访问更频繁、哪个表的行数规模更稳定。假设订单日增几千条,发票可能因为对方单位要求单独开票,数量更少,那外键放订单表指向发票表,会让订单表频繁维护一个不太用的列,性价比不高。反过来,如果业务流程是从订单反查发票更多,发票表里存order_id加UNIQUE,用起来最顺手。这里没有绝对答案,只有业务主次。还有一种情况需要考虑独立建表,就是1:1联系本身带有属性,比如订单和发票关联时,还要记录「发票推送时间」「推送状态」这类属于联系本身的信息。此时单独建一张 invoice_mapping 表会更清晰。但若属性不多,也可以把属性直接并进发票表。大家不要在无关紧要的地方过度设计,一张两列的关联表未必比在一侧表里加三个字段更好。2.2 1:N联系:为什么外键必须落在N端1:N是最常见的联系,比如一个用户有多条订单,一个部门有多个员工,一个分类下挂着多个商品。转换规则非常固定:在N端表里加一个外键,指向1端的主键。为什么外键不能放在1端?假设部门表存了所有员工的主键列表,且不说要用集合类型存多值,就算勉强存进去,也没办法用数据库的约束保证「这个员工真的属于这个部门」,更新时需要应用层手动维护列表的一致性,一旦出现并发操作就很容易脏数据。而把部门外键放在员工表里,数据库自己就能保证每个员工指向的部门必须真实存在。实操中我建议对这个外键加上NOT NULL,除非业务明确存在「未分配部门」的过渡状态。很多系统允许员工表部门ID为空,结果统计报表时总要把NULL单独处理,非常烦人。如果确实存在未分配状态,我更倾向于用一个特殊的「待分配」部门来承载,而不是用NULL表示。2.3 M:N联系:独立关系表的标准形态M:N联系在E-R图上表现为两边都画着多。比如一个学生选多门课,一门课也有多个学生选。这里千万别试着在两个原实体表里互相加外键——那会变成「一个表里存一串ID」,所有SQL都写得很痛苦。标准解法是新建一张关系表,把两边的多对多拆成两个一对多。关系表里至少包含两个外键,分别指向两个原实体的主键。关系表的主键怎么定?两种常见的思路:使用两侧外键的组合作为复合主键,比如(student_id, course_id)。这样做的好处是天然防止重复选课,坏处是如果哪天业务改成同一个人可以重复报同一门课的多个班次,这个主键就失效了。额外增加一个自增代理主键,然后给两个外键单独加UNIQUE或联合索引。牺牲了一点严格性,但扩展弹性更好。我更推荐第二种在真实项目中使用,尤其是业务规则还不稳定的时候。复合主键在联表查询和后续引入新属性时,写起来都比分表更啰嗦。关键的一点:M:N联系如果带有自己的属性,比如选课时间、购买数量、评分记录,这些属性必须放在关系表里,不能放到任何一侧的实体表里。如果放到课程表,一个课程被一百个人选了,买课时间该记谁的?这就是数据语义的根本性错位。3. 属性特例的规范化:多值、复合、派生属性的处理实体属性看起来比联系简单,但真正动起手来,属性才是最容易产生「反范式」设计的地方。一张E-R图里客户有多个电话,地址由省市区加详细地址构成,这些在画图时都是一笔带过的细节,到了建模阶段就得分出几条路。3.1 多值属性:拆表而不是塞一列一个客户有多个联系电话,在E-R图里是客户实体下的一个多值属性。转换时如果直接给客户表加一个phone列,那么第二个号码就只能用逗号拼接,后续想按号码精确搜索就没法走索引,还容易踩脏数据的坑。正确做法是把多值属性独立成一张子表。子表至少包含两列:外键指向原实体主键,以及值本身。这里有个细节很多人没注意:主键到底设为「外键+值」的复合主键,还是额外加一个自增ID?设成复合主键省了一张索引,但如果同一个客户可能录入了两个相同号码(比如移动和联通各留一个但恰好号码完全相同),插入就会撞主键报错。虽然现实中少见,不过本着不给自己埋雷的原则,我一般会在子表里加一个自增主键,然后对外键加索引,对「外键+值」加联合唯一约束或普通索引,既能限重,又能在适当场景下放开限制。多值属性的拆表也会带来一个副作用:查询时需要多一次JOIN。这也是权衡的结果,因为遵守第一范式,用单独的列表达一值一列,换来的好处是数据更新独立、检索高效。设计时不用怕JOIN,怕的是没必要的JOIN。3.2 复合属性:拆列与业务语义复合属性指的是一个属性内部还有结构。最典型的就是「地址」,由省份、城市、区县、详细门牌组成;还有「姓名」,在国内业务里一般不用拆,但在某些场景下需要单独查「姓」,可能就要拆成姓和名。复合属性的处理原则很简单:拆到业务检索需要的最小粒度。如果只是邮寄场景,通常整个地址作为一个大字符串记录就够了,顶多为了发快递时打印一致,单独存省市区。但如果是用来做地区维度的统计分析,比如看某个省份的订单占比,那省市区这一列就必须拆出来,否则每次统计都要对字符串做模糊匹配,性能和数据准确率都不可控。拆分时注意层级顺序,地址拆分后有个天然的好处是可以用联合索引做前缀精确匹配。省市区三个列组合起来,对「某省」或「某省某市」的筛选都很快。目前也有一些做法是把地理信息做成独立的地区表,通过地区码外键关联,这就要看项目规模了,小项目拆列足够。3.3 派生属性:存还是不存派生属性是指可以由其他属性计算出来的属性,比如年龄可以从出生日期推算,订单总金额可以由明细行金额汇总,这些属性在E-R图上画出来很合理,但转关系模型时要多问一句:这个值真的需要落库吗?我的判断标准很简单:如果派生成本极低、数据量不大,就不存。年龄永远不落库,数据库里只存birth_date,需要时再算。如果派生计算代价高、查询频繁,则落库存储,同时同步维护。比较典型的是订单总金额。一个订单可能包含几十条明细,每次列表页展示都要实时SUM一遍,订单量大时非常拖垮性能。实际项目中订单表里通常会有total_amount列,在生成订单时统一计算并写入。这里要注意的坑是:一旦订单明细被修改,这个总金额必须同步重算。解决方案是给订单明细的修改操作套一层服务逻辑,或干脆禁止修改已支付订单的明细,这也是业务规则上的前置控制。落库的派生值如果允许修改,务必加上变更审查机制,避免出现「改了明细金额,订单总额忘记更新」的数据不一致问题。这个问题在真实运维里出得最多。4. 弱实体与IS-A结构:分层特殊映射普通实体转表很简单,真正让很多数据库学习者头疼的是两类「不普通」的结构:弱实体和IS-A继承结构。它们在E-R图上长得类似,但转关系模型时的策略完全不同,决策不当会导致数据访问逻辑极其别扭。4.1 弱实体集的双主键映射弱实体集的典型特征是「离开所属强实体,自身没有独立身份」。最经典的例子是订单明细:一条订单明细离开了它所属的订单号,就没有任何意义,你不能单独说「第3行明细」属于谁。弱实体转关系模型时,必须把它变成一张独立的明细表,但主键不是自身单独生成的,而是「所属强实体的主键+自身局部标识」。比如订单明细的主键可以是(order_id, line_no),其中order_id是订单表的主键,同时又是明细表的外键,line_no在同一个订单内部递增,实现跨订单唯一。这里有一个非常容易犯的错:有些人觉得弱实体既然依赖强实体,那就把弱实体的数据直接塞进强实体表里,用JSON字段存。这么做短期内写代码确实爽,但一旦要按明细维度统计、按商品筛选明细、甚至更新单行明细,SQL都会写得异常痛苦。弱实体独立建表的价值在于可以让数据库的约束体系真正生效,比如级联删除订单时自动清理明细,而不是靠应用层去循环删。实操里给弱实体建表时,建议在(order_id, line_no)上加复合主键,并给line_no建立自增逻辑。有些数据库支持在业务表上做触发器,或者通过应用层在创建订单时一次性插入所有明细并分配行号,都可以,看团队习惯。4.2 IS-A结构三种表方案怎么选IS-A结构描述的是父子类型关系,比如「员工」是一个超类,「技术人员」「销售人员」都是子类,子类继承父类的公共属性,又各有自己的专属字段。这种结构转关系模型时有三种主流方案,我把它们的适用场景和优缺点列出来:方案表结构优点缺点单表一张表包含所有公共属性+所有子类专属属性查询简单,无需JOIN列冗余严重,不满足部分子类的空值约束类表(按子类分表)超类一张表,每个子类一张表,子类表通过超类主键关联结构清晰,扩展性好查询公共属性要JOIN纯子类表只建子类各自的表,公共属性复制到各子类表查询单表,子类隔离好公共属性更新时多表同步实际项目中我见过最多的是第二种「类表」。它的典型做法:员工表存员工ID、姓名、部门等公共字段;技术人员表用员工ID作为主键兼外键,存技术等级、擅长方向;销售人员表同样用员工ID作为主键兼外键,存销售区域、业绩指标。查询某个具体员工的完整信息时,根据类型字段决定JOIN哪张子表。这里推荐一个细节:超类表里一定要有一个「类型」字段,用来区分当前记录属于哪个子类,否则应用层无法决定去查哪张子表。类型字段可以加CHECK约束,防止写入脏值。第三种「纯子类表」适用于子类间公共属性很少、且很少需要跨子类统一查询的场景。比如「宠物」这个超类下分「猫」和「狗」,公共属性只有名字和年龄,日常业务也不太需要一次性统计所有宠物,那拆成两张表反而更直接。5. 映射完成后的收尾审查:主键、范式与完整性约束把E-R图里的所有元素转换成表结构之后,整个设计不能立刻交付开发。映射只是把语义翻译成了表,但表的质量还需要经过一轮严格的审查。数据库设计领域有句老话:设计阶段改结构是改图,上线阶段改结构是改命。这话一点也不夸张。5.1 主键审查:业务唯一键还是代理主键映射过程中,每个实体都要选定主键。E-R图上实体可能已经标注了候选键,但真正落库之前,必须逐张表确认主键策略。我的原则很简单:能用代理主键就用代理主键。所谓代理主键,就是没有业务含义、只为了唯一标识一行记录而存在的字段,比如自增ID、雪花算法生成的Long整数。业务上那个真正唯一的键,比如身份证号、订单编号、手机号,则作为备用候选键,单独加UNIQUE约束。原因有两个。第一,业务唯一键常常会变。手机号可以改号,身份证在个别场景下也可能变更,一旦作为主键,级联更新链路非常可怕。而代理主键永远不变。第二,复合业务主键在作为外键被引用时,会让子表多出一大截冗余列。举个例子:如果一个订单明细以(order_id, line_no)作为主键,另一个表想引用这一行明细,外键就必须同时带两个字段,所有关联查询都会变冗长。这也是我在弱实体小节里提到加自增ID的原因之一。不过,代理主键并非银弹。像订单明细这种弱实体,业务上确实需要同一订单内行号唯一,那复合主键加代理主键可以并存,代理主键保证全局稳定引用,复合唯一约束保证业务语义。5.2 范式复查:部分依赖与传递依赖很多教科书把范式当成一种理论测验题来教,导致不少人觉得1NF、2NF、3NF这些概念只是为了应付考试。实际做表设计审查时,范式是极其好用的「体检工具」。映射完成后,我至少会跑两遍检查:第一遍看是否存在部分依赖。部分依赖是指非主键字段只依赖复合主键的一部分。最常见的场景就是M:N关系表里混入了只属于某一侧实体的属性。假设选课关系表的主键是(student_id, course_id),如果这个表里出现一门课程的名称course_name,那就是典型的2NF违规,因为course_name只依赖course_id,并不依赖student_id。一旦违反,改一门课的名字就需要更新几百行选课记录。第二遍看是否存在传递依赖。传递依赖是指一个非主键字段依赖另一个非主键字段。比如商品表里有category_id和category_name,其中category_name依赖于category_id,而category_id又不是主键,这就形成了传递依赖,违反3NF。解决方式是拆出独立的分类表,商品表只保留外键category_id。这两遍检查可以直接套用一条经验:凡是能从别的表查到的东西,就不要在当前表里重复存一份完整副本。只有明确的冗余设计(比如订单快照、刻意缓存派生值)才允许违反改写规则。5.3 外键约束与删除策略主键定好了,范式检查完了,接下来要把外键关系全面梳理一遍。E-R图上的每个联系都应该在表结构中落地为外键约束,否则数据库无法保证引用完整性。外键设计有三件事很容易忽视:外键列的类型必须与被引用主键完全一致。常见错误是主键用BIGINT,外键用INT,等到数据量超过INT上限时再发现已经晚了。删除策略要按业务语义选择,而不是统一用默认的RESTRICT。订单和订单明细之间应该用ON DELETE CASCADE,因为明细离开主单没有独立意义;而用户和订单之间就不能用CASCADE,用户误删不应该把所有历史订单一起干掉,更合理的做法是限制删除或逻辑删除。外键列是否需要NOT NULL。一个商品的分类如果允许为空,那么「未分类」和「分类信息待补充」两种语义就混在一起了。建议要么强制NOT NULL并设置默认值,要么设计专门的「未分类」记录。另外,不建议在早期项目里为了图方便把外键约束全部省略,只靠应用层保证一致性。应用层永远有漏网的时候,数据库的外键约束是最后一道防线,尤其涉及账务、订单这类数据,约束缺失引发的脏数据问题远比启动初期那点性能开销更昂贵。6. 综合案例复盘:从订单系统E-R图到可落地SQL前几节都在讲规则和原则,这一节我用一个完整流程把E-R模型转关系模型从头到尾走一遍,包含需求、E-R图语义、映射顺序和最终建表脚本,这样你能更直观地看到每一步是怎么落地的。6.1 需求与E-R图回顾假设我们要设计一个简化的销售订单系统,涉及几个我能想到的常见对象:客户:记录客户基础信息和多个联系电话。订单:每笔订单归属于一个客户,多个订单可以属于同一个客户。商品:系统内可销售的商品。订单明细:一笔订单包含多种商品,每种商品也可以出现在多笔订单中,并且记录下单数量和当时的成交单价。E-R图上的主要联系是:客户和订单之间是1:N联系;订单和商品之间是M:N联系,且这个联系带属性(数量、成交价);订单有一个发票关联,1:1,每笔订单对应一张发票。客户地址可以看成复合属性。6.2 映射过程分步演示第一步,先把所有普通实体转成基础表。客户表、商品表、订单表、发票表各是一张空壳,先不考虑外键,给每个实体定代理主键。客户和订单都是独立实体,用自增ID作为主键;订单编号作为业务唯一键单独加UNIQUE约束。第二步,处理1:N联系。客户和订单是1:N,所以把customer_id加进订单表作外键,加NOT NULL,强制每一张订单必须归属于某个客户。第三步,处理M:N联系。订单和商品之间需要建订单明细表order_item,里面至少包含order_id、product_id、quantity、unit_price,其中order_id和product_id分别外键指向两张父表。由于这个联系本身就带明细属性,这些属性理所当然放在关系表中。第四步,处理1:1联系。订单和发票之间是1:1,我在发票表里放order_id外键并加UNIQUE约束,这样既能保证一个订单只允许推一张发票,又不会让订单表在未开票阶段就预留发票字段。第五步,处理特殊属性。客户的多个联系电话单独拆表customer_phone;客户地址按省市区拆成三列;订单的总金额属于派生属性,为了列表页性能,我在订单表冗余一个total_amount字段,依据明细重新计算并写入。6.3 建表脚本缩略演练经过上述映射,建表脚本大致是这样的:-- 客户表 CREATE TABLE customer ( customer_id INT PRIMARY KEY AUTO_INCREMENT, full_name VARCHAR(50) NOT NULL, province VARCHAR(20), city VARCHAR(20), district VARCHAR(20), detail_address VARCHAR(100), UNIQUE KEY uk_customer_id_card (id_card_no) ); -- 客户联系电话多值属性拆表 CREATE TABLE customer_phone ( id INT PRIMARY KEY AUTO_INCREMENT, customer_id INT NOT NULL, phone VARCHAR(20) NOT NULL, INDEX idx_phone_customer (customer_id), CONSTRAINT fk_phone_customer FOREIGN KEY (customer_id) REFERENCES customer(customer_id) ON DELETE CASCADE ); -- 商品表 CREATE TABLE product ( product_id INT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(100) NOT NULL, category_id INT, list_price DECIMAL(10,2) NOT NULL ); -- 订单表 CREATE TABLE orders ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, customer_id INT NOT NULL, order_time DATETIME NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, UNIQUE KEY uk_order_no (order_no), CONSTRAINT fk_order_customer FOREIGN KEY (customer_id) REFERENCES customer(customer_id) ); -- 订单明细表:订单与商品的M:N关系表 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL CHECK (quantity > 0), unit_price DECIMAL(10,2) NOT NULL, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE, CONSTRAINT fk_item_product FOREIGN KEY (product_id) REFERENCES product(product_id), UNIQUE KEY uk_order_product (order_id, product_id) );这个脚本不是完整的生产级设计,但已经把转换过程中的关键决策全部体现出来了。你对照前面讲到的规则看,会发现每一个表、每一个外键、每一种约束都不是随手写的,而是从E-R图语义推导出来的。我在实际项目中有一个习惯:设计完表结构后,拿着E-R图重新走一遍,检查图上每个实体、每个联系、每个属性在SQL里有没有对应的落点。任何一条线在表结构中找不到对应物,或者一张表在图上找不到来源,都是映射遗漏或多余设计,需要回到需求里说清楚。这个来回比对的过程虽然琐碎,但特别能帮助自己发现设计中的盲点,推荐大家保持这个习惯。
1. 转换的本质:从图形语言到表结构的直译坐过一段时间数据库设计的人应该都有这个感受:E-R图画起来挺顺手,实体画个方块,联系画个菱形,属性画个圈,连上线,逻辑非常清楚。可一旦到了动手写CREATE TABLE的时候,就开始犯难——图上一堆线和圈,究竟哪些该变成表,哪些该变成字段,外键又该挂在谁身上?这个环节就是E-R模型转关系模型,说白了,是把一张充满图形语义的概念设计图,用二维表的形态重新表达出来的过程。我在数据库笔记的前几篇里讲过概念模型是需求的抽象,逻辑模型是设计的骨架。这一篇就是骨架落地的关键一步。很多开发者在需求调研阶段做得不错,E-R图也画得像模像样,结果死在转换这一步,建出来的表要么多出一堆没必要的关联表,要么字段重复冗余,要么查询时发现外键方向反了。更麻烦的是,这种问题在早期往往看不出来,等系统上线、数据量大起来再回头改表结构,成本就完全不一样了。1.1 一张E-R图最终会变成什么先记住一个总纲:E-R模型中的三类对象,对应关系模型的三种表达方式。实体集,变成一张表。实体集的每个属性,变成表的列。联系集,大部分变成外键,少数需要单独建表。属性中的特殊类型——多值、复合、派生——需要做额外的规范化处理。这个对应关系并不复杂,难的是判断尺度。同一个联系,什么时候只需要在某一侧加外键,什么时候必须拆出一张独立的关系表,这不能靠死记规则,得理解背后的数据约束语义。我见过不少初学者把「联系必须建表」当成铁律,结果学生和课程之间明明只是个M:N选课关系,硬是给1:1的联系也建了关联表,查数据时绕来绕去。也见过另一类人,M:N联系却不建表,把多个商品ID用逗号拼在一起塞进订单表里,后面要统计销量时欲哭无泪。说到底,转换的本质是把图形上的连接关系翻译成数据库能强制执行的外键约束,而不是机械地数数。1.2 三类对象各自的映射方向从更细的角度看,可以这样理解映射策略:E-R模型元素关系模型表达关键决策点普通实体集独立表主键怎么定,属性怎么落列弱实体集独立表主键依赖强实体主键1:1联系任选一侧加外键+唯一约束要不要拆独立表,外键放哪边1:N联系N端加外键外键不能放在1端M:N联系独立关系表关系表主键是什么,联系属性放哪多值属性拆成单独的表主键设计要防重复复合属性拆成多个列拆几层取决于业务是否需要按子项检索派生属性能不存就不存存储要控更新时间这张表基本就是全文的索引。下面我会一个个展开,并给出实际设计中的取舍逻辑,而不是只讲教科书上的标准答案。2. 三种二元联系的取舍:外键放哪边、表建几张二元联系是E-R图里出现频率最高的结构,也是转换时最常踩坑的地方。很多人背了三句话:1:1任意一侧加外键,1:N在N端加外键,M:N建独立表。背是记住了,但遇到真实业务仍然不知道怎么灵活变通,主要是没搞明白「为什么」。2.1 1:1联系:加外键还是独立建表1:1联系在语义上意味着一个实体A最多对应一个实体B,反过来也一样。比如某个系统里一个订单对应一张发票,一张发票也只属于一个订单,这就是典型的1:1。标准做法是在其中一张表里加另一张表的主键作为外键,并加UNIQUE约束保证对方也不会重复。问题是:外键到底放在哪边?我的经验是看两个方向里哪个访问更频繁、哪个表的行数规模更稳定。假设订单日增几千条,发票可能因为对方单位要求单独开票,数量更少,那外键放订单表指向发票表,会让订单表频繁维护一个不太用的列,性价比不高。反过来,如果业务流程是从订单反查发票更多,发票表里存order_id加UNIQUE,用起来最顺手。这里没有绝对答案,只有业务主次。还有一种情况需要考虑独立建表,就是1:1联系本身带有属性,比如订单和发票关联时,还要记录「发票推送时间」「推送状态」这类属于联系本身的信息。此时单独建一张 invoice_mapping 表会更清晰。但若属性不多,也可以把属性直接并进发票表。大家不要在无关紧要的地方过度设计,一张两列的关联表未必比在一侧表里加三个字段更好。2.2 1:N联系:为什么外键必须落在N端1:N是最常见的联系,比如一个用户有多条订单,一个部门有多个员工,一个分类下挂着多个商品。转换规则非常固定:在N端表里加一个外键,指向1端的主键。为什么外键不能放在1端?假设部门表存了所有员工的主键列表,且不说要用集合类型存多值,就算勉强存进去,也没办法用数据库的约束保证「这个员工真的属于这个部门」,更新时需要应用层手动维护列表的一致性,一旦出现并发操作就很容易脏数据。而把部门外键放在员工表里,数据库自己就能保证每个员工指向的部门必须真实存在。实操中我建议对这个外键加上NOT NULL,除非业务明确存在「未分配部门」的过渡状态。很多系统允许员工表部门ID为空,结果统计报表时总要把NULL单独处理,非常烦人。如果确实存在未分配状态,我更倾向于用一个特殊的「待分配」部门来承载,而不是用NULL表示。2.3 M:N联系:独立关系表的标准形态M:N联系在E-R图上表现为两边都画着多。比如一个学生选多门课,一门课也有多个学生选。这里千万别试着在两个原实体表里互相加外键——那会变成「一个表里存一串ID」,所有SQL都写得很痛苦。标准解法是新建一张关系表,把两边的多对多拆成两个一对多。关系表里至少包含两个外键,分别指向两个原实体的主键。关系表的主键怎么定?两种常见的思路:使用两侧外键的组合作为复合主键,比如(student_id, course_id)。这样做的好处是天然防止重复选课,坏处是如果哪天业务改成同一个人可以重复报同一门课的多个班次,这个主键就失效了。额外增加一个自增代理主键,然后给两个外键单独加UNIQUE或联合索引。牺牲了一点严格性,但扩展弹性更好。我更推荐第二种在真实项目中使用,尤其是业务规则还不稳定的时候。复合主键在联表查询和后续引入新属性时,写起来都比分表更啰嗦。关键的一点:M:N联系如果带有自己的属性,比如选课时间、购买数量、评分记录,这些属性必须放在关系表里,不能放到任何一侧的实体表里。如果放到课程表,一个课程被一百个人选了,买课时间该记谁的?这就是数据语义的根本性错位。3. 属性特例的规范化:多值、复合、派生属性的处理实体属性看起来比联系简单,但真正动起手来,属性才是最容易产生「反范式」设计的地方。一张E-R图里客户有多个电话,地址由省市区加详细地址构成,这些在画图时都是一笔带过的细节,到了建模阶段就得分出几条路。3.1 多值属性:拆表而不是塞一列一个客户有多个联系电话,在E-R图里是客户实体下的一个多值属性。转换时如果直接给客户表加一个phone列,那么第二个号码就只能用逗号拼接,后续想按号码精确搜索就没法走索引,还容易踩脏数据的坑。正确做法是把多值属性独立成一张子表。子表至少包含两列:外键指向原实体主键,以及值本身。这里有个细节很多人没注意:主键到底设为「外键+值」的复合主键,还是额外加一个自增ID?设成复合主键省了一张索引,但如果同一个客户可能录入了两个相同号码(比如移动和联通各留一个但恰好号码完全相同),插入就会撞主键报错。虽然现实中少见,不过本着不给自己埋雷的原则,我一般会在子表里加一个自增主键,然后对外键加索引,对「外键+值」加联合唯一约束或普通索引,既能限重,又能在适当场景下放开限制。多值属性的拆表也会带来一个副作用:查询时需要多一次JOIN。这也是权衡的结果,因为遵守第一范式,用单独的列表达一值一列,换来的好处是数据更新独立、检索高效。设计时不用怕JOIN,怕的是没必要的JOIN。3.2 复合属性:拆列与业务语义复合属性指的是一个属性内部还有结构。最典型的就是「地址」,由省份、城市、区县、详细门牌组成;还有「姓名」,在国内业务里一般不用拆,但在某些场景下需要单独查「姓」,可能就要拆成姓和名。复合属性的处理原则很简单:拆到业务检索需要的最小粒度。如果只是邮寄场景,通常整个地址作为一个大字符串记录就够了,顶多为了发快递时打印一致,单独存省市区。但如果是用来做地区维度的统计分析,比如看某个省份的订单占比,那省市区这一列就必须拆出来,否则每次统计都要对字符串做模糊匹配,性能和数据准确率都不可控。拆分时注意层级顺序,地址拆分后有个天然的好处是可以用联合索引做前缀精确匹配。省市区三个列组合起来,对「某省」或「某省某市」的筛选都很快。目前也有一些做法是把地理信息做成独立的地区表,通过地区码外键关联,这就要看项目规模了,小项目拆列足够。3.3 派生属性:存还是不存派生属性是指可以由其他属性计算出来的属性,比如年龄可以从出生日期推算,订单总金额可以由明细行金额汇总,这些属性在E-R图上画出来很合理,但转关系模型时要多问一句:这个值真的需要落库吗?我的判断标准很简单:如果派生成本极低、数据量不大,就不存。年龄永远不落库,数据库里只存birth_date,需要时再算。如果派生计算代价高、查询频繁,则落库存储,同时同步维护。比较典型的是订单总金额。一个订单可能包含几十条明细,每次列表页展示都要实时SUM一遍,订单量大时非常拖垮性能。实际项目中订单表里通常会有total_amount列,在生成订单时统一计算并写入。这里要注意的坑是:一旦订单明细被修改,这个总金额必须同步重算。解决方案是给订单明细的修改操作套一层服务逻辑,或干脆禁止修改已支付订单的明细,这也是业务规则上的前置控制。落库的派生值如果允许修改,务必加上变更审查机制,避免出现「改了明细金额,订单总额忘记更新」的数据不一致问题。这个问题在真实运维里出得最多。4. 弱实体与IS-A结构:分层特殊映射普通实体转表很简单,真正让很多数据库学习者头疼的是两类「不普通」的结构:弱实体和IS-A继承结构。它们在E-R图上长得类似,但转关系模型时的策略完全不同,决策不当会导致数据访问逻辑极其别扭。4.1 弱实体集的双主键映射弱实体集的典型特征是「离开所属强实体,自身没有独立身份」。最经典的例子是订单明细:一条订单明细离开了它所属的订单号,就没有任何意义,你不能单独说「第3行明细」属于谁。弱实体转关系模型时,必须把它变成一张独立的明细表,但主键不是自身单独生成的,而是「所属强实体的主键+自身局部标识」。比如订单明细的主键可以是(order_id, line_no),其中order_id是订单表的主键,同时又是明细表的外键,line_no在同一个订单内部递增,实现跨订单唯一。这里有一个非常容易犯的错:有些人觉得弱实体既然依赖强实体,那就把弱实体的数据直接塞进强实体表里,用JSON字段存。这么做短期内写代码确实爽,但一旦要按明细维度统计、按商品筛选明细、甚至更新单行明细,SQL都会写得异常痛苦。弱实体独立建表的价值在于可以让数据库的约束体系真正生效,比如级联删除订单时自动清理明细,而不是靠应用层去循环删。实操里给弱实体建表时,建议在(order_id, line_no)上加复合主键,并给line_no建立自增逻辑。有些数据库支持在业务表上做触发器,或者通过应用层在创建订单时一次性插入所有明细并分配行号,都可以,看团队习惯。4.2 IS-A结构三种表方案怎么选IS-A结构描述的是父子类型关系,比如「员工」是一个超类,「技术人员」「销售人员」都是子类,子类继承父类的公共属性,又各有自己的专属字段。这种结构转关系模型时有三种主流方案,我把它们的适用场景和优缺点列出来:方案表结构优点缺点单表一张表包含所有公共属性+所有子类专属属性查询简单,无需JOIN列冗余严重,不满足部分子类的空值约束类表(按子类分表)超类一张表,每个子类一张表,子类表通过超类主键关联结构清晰,扩展性好查询公共属性要JOIN纯子类表只建子类各自的表,公共属性复制到各子类表查询单表,子类隔离好公共属性更新时多表同步实际项目中我见过最多的是第二种「类表」。它的典型做法:员工表存员工ID、姓名、部门等公共字段;技术人员表用员工ID作为主键兼外键,存技术等级、擅长方向;销售人员表同样用员工ID作为主键兼外键,存销售区域、业绩指标。查询某个具体员工的完整信息时,根据类型字段决定JOIN哪张子表。这里推荐一个细节:超类表里一定要有一个「类型」字段,用来区分当前记录属于哪个子类,否则应用层无法决定去查哪张子表。类型字段可以加CHECK约束,防止写入脏值。第三种「纯子类表」适用于子类间公共属性很少、且很少需要跨子类统一查询的场景。比如「宠物」这个超类下分「猫」和「狗」,公共属性只有名字和年龄,日常业务也不太需要一次性统计所有宠物,那拆成两张表反而更直接。5. 映射完成后的收尾审查:主键、范式与完整性约束把E-R图里的所有元素转换成表结构之后,整个设计不能立刻交付开发。映射只是把语义翻译成了表,但表的质量还需要经过一轮严格的审查。数据库设计领域有句老话:设计阶段改结构是改图,上线阶段改结构是改命。这话一点也不夸张。5.1 主键审查:业务唯一键还是代理主键映射过程中,每个实体都要选定主键。E-R图上实体可能已经标注了候选键,但真正落库之前,必须逐张表确认主键策略。我的原则很简单:能用代理主键就用代理主键。所谓代理主键,就是没有业务含义、只为了唯一标识一行记录而存在的字段,比如自增ID、雪花算法生成的Long整数。业务上那个真正唯一的键,比如身份证号、订单编号、手机号,则作为备用候选键,单独加UNIQUE约束。原因有两个。第一,业务唯一键常常会变。手机号可以改号,身份证在个别场景下也可能变更,一旦作为主键,级联更新链路非常可怕。而代理主键永远不变。第二,复合业务主键在作为外键被引用时,会让子表多出一大截冗余列。举个例子:如果一个订单明细以(order_id, line_no)作为主键,另一个表想引用这一行明细,外键就必须同时带两个字段,所有关联查询都会变冗长。这也是我在弱实体小节里提到加自增ID的原因之一。不过,代理主键并非银弹。像订单明细这种弱实体,业务上确实需要同一订单内行号唯一,那复合主键加代理主键可以并存,代理主键保证全局稳定引用,复合唯一约束保证业务语义。5.2 范式复查:部分依赖与传递依赖很多教科书把范式当成一种理论测验题来教,导致不少人觉得1NF、2NF、3NF这些概念只是为了应付考试。实际做表设计审查时,范式是极其好用的「体检工具」。映射完成后,我至少会跑两遍检查:第一遍看是否存在部分依赖。部分依赖是指非主键字段只依赖复合主键的一部分。最常见的场景就是M:N关系表里混入了只属于某一侧实体的属性。假设选课关系表的主键是(student_id, course_id),如果这个表里出现一门课程的名称course_name,那就是典型的2NF违规,因为course_name只依赖course_id,并不依赖student_id。一旦违反,改一门课的名字就需要更新几百行选课记录。第二遍看是否存在传递依赖。传递依赖是指一个非主键字段依赖另一个非主键字段。比如商品表里有category_id和category_name,其中category_name依赖于category_id,而category_id又不是主键,这就形成了传递依赖,违反3NF。解决方式是拆出独立的分类表,商品表只保留外键category_id。这两遍检查可以直接套用一条经验:凡是能从别的表查到的东西,就不要在当前表里重复存一份完整副本。只有明确的冗余设计(比如订单快照、刻意缓存派生值)才允许违反改写规则。5.3 外键约束与删除策略主键定好了,范式检查完了,接下来要把外键关系全面梳理一遍。E-R图上的每个联系都应该在表结构中落地为外键约束,否则数据库无法保证引用完整性。外键设计有三件事很容易忽视:外键列的类型必须与被引用主键完全一致。常见错误是主键用BIGINT,外键用INT,等到数据量超过INT上限时再发现已经晚了。删除策略要按业务语义选择,而不是统一用默认的RESTRICT。订单和订单明细之间应该用ON DELETE CASCADE,因为明细离开主单没有独立意义;而用户和订单之间就不能用CASCADE,用户误删不应该把所有历史订单一起干掉,更合理的做法是限制删除或逻辑删除。外键列是否需要NOT NULL。一个商品的分类如果允许为空,那么「未分类」和「分类信息待补充」两种语义就混在一起了。建议要么强制NOT NULL并设置默认值,要么设计专门的「未分类」记录。另外,不建议在早期项目里为了图方便把外键约束全部省略,只靠应用层保证一致性。应用层永远有漏网的时候,数据库的外键约束是最后一道防线,尤其涉及账务、订单这类数据,约束缺失引发的脏数据问题远比启动初期那点性能开销更昂贵。6. 综合案例复盘:从订单系统E-R图到可落地SQL前几节都在讲规则和原则,这一节我用一个完整流程把E-R模型转关系模型从头到尾走一遍,包含需求、E-R图语义、映射顺序和最终建表脚本,这样你能更直观地看到每一步是怎么落地的。6.1 需求与E-R图回顾假设我们要设计一个简化的销售订单系统,涉及几个我能想到的常见对象:客户:记录客户基础信息和多个联系电话。订单:每笔订单归属于一个客户,多个订单可以属于同一个客户。商品:系统内可销售的商品。订单明细:一笔订单包含多种商品,每种商品也可以出现在多笔订单中,并且记录下单数量和当时的成交单价。E-R图上的主要联系是:客户和订单之间是1:N联系;订单和商品之间是M:N联系,且这个联系带属性(数量、成交价);订单有一个发票关联,1:1,每笔订单对应一张发票。客户地址可以看成复合属性。6.2 映射过程分步演示第一步,先把所有普通实体转成基础表。客户表、商品表、订单表、发票表各是一张空壳,先不考虑外键,给每个实体定代理主键。客户和订单都是独立实体,用自增ID作为主键;订单编号作为业务唯一键单独加UNIQUE约束。第二步,处理1:N联系。客户和订单是1:N,所以把customer_id加进订单表作外键,加NOT NULL,强制每一张订单必须归属于某个客户。第三步,处理M:N联系。订单和商品之间需要建订单明细表order_item,里面至少包含order_id、product_id、quantity、unit_price,其中order_id和product_id分别外键指向两张父表。由于这个联系本身就带明细属性,这些属性理所当然放在关系表中。第四步,处理1:1联系。订单和发票之间是1:1,我在发票表里放order_id外键并加UNIQUE约束,这样既能保证一个订单只允许推一张发票,又不会让订单表在未开票阶段就预留发票字段。第五步,处理特殊属性。客户的多个联系电话单独拆表customer_phone;客户地址按省市区拆成三列;订单的总金额属于派生属性,为了列表页性能,我在订单表冗余一个total_amount字段,依据明细重新计算并写入。6.3 建表脚本缩略演练经过上述映射,建表脚本大致是这样的:-- 客户表 CREATE TABLE customer ( customer_id INT PRIMARY KEY AUTO_INCREMENT, full_name VARCHAR(50) NOT NULL, province VARCHAR(20), city VARCHAR(20), district VARCHAR(20), detail_address VARCHAR(100), UNIQUE KEY uk_customer_id_card (id_card_no) ); -- 客户联系电话多值属性拆表 CREATE TABLE customer_phone ( id INT PRIMARY KEY AUTO_INCREMENT, customer_id INT NOT NULL, phone VARCHAR(20) NOT NULL, INDEX idx_phone_customer (customer_id), CONSTRAINT fk_phone_customer FOREIGN KEY (customer_id) REFERENCES customer(customer_id) ON DELETE CASCADE ); -- 商品表 CREATE TABLE product ( product_id INT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(100) NOT NULL, category_id INT, list_price DECIMAL(10,2) NOT NULL ); -- 订单表 CREATE TABLE orders ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, customer_id INT NOT NULL, order_time DATETIME NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, UNIQUE KEY uk_order_no (order_no), CONSTRAINT fk_order_customer FOREIGN KEY (customer_id) REFERENCES customer(customer_id) ); -- 订单明细表:订单与商品的M:N关系表 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL CHECK (quantity > 0), unit_price DECIMAL(10,2) NOT NULL, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE, CONSTRAINT fk_item_product FOREIGN KEY (product_id) REFERENCES product(product_id), UNIQUE KEY uk_order_product (order_id, product_id) );这个脚本不是完整的生产级设计,但已经把转换过程中的关键决策全部体现出来了。你对照前面讲到的规则看,会发现每一个表、每一个外键、每一种约束都不是随手写的,而是从E-R图语义推导出来的。我在实际项目中有一个习惯:设计完表结构后,拿着E-R图重新走一遍,检查图上每个实体、每个联系、每个属性在SQL里有没有对应的落点。任何一条线在表结构中找不到对应物,或者一张表在图上找不到来源,都是映射遗漏或多余设计,需要回到需求里说清楚。这个来回比对的过程虽然琐碎,但特别能帮助自己发现设计中的盲点,推荐大家保持这个习惯。