ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

E-R模型入门:实体、联系与多对多中间表设计实战

E-R模型入门:实体、联系与多对多中间表设计实战 1. 为什么要老老实实做E-R模型先回答业务问题再碰表结构我见过不少人把工程编号直接塞进职工表字段名取current_project_id一遇到“一个职工参与三个工程”的需求就不得不把职工信息复制三份也见过反过来操作的在工程表里用逗号拼接职工编号查询时用LIKE %1001%硬扫慢到怀疑人生。这些坑的根源基本都一样没有先把业务关系想清楚就急着打开建表工具。今天这篇我想把 E-R 模型实体-联系模型中实体、属性、联系这三个最基础的概念理一遍然后拿“职工”和“工程”之间的多对多联系做完整案例从业务分析、E-R 图绘制、关系模式转换一直讲到中间表的 SQL 怎么写。适合数据库刚入门的同学也适合那些画过图但落不了地的开发以及准备复习数据库设计的毕业生。1.1 不建模直接建表的真实下场先展开说几种反面案例都是我在实际项目里见过的操作。第一种是在职工表上加current_project_id字段。这个设计刚上线时看着挺清爽因为当时业务确实只有“一个职工临时支援某个工程”。等到业务发展成“一个职工可以同时参与三个工程”代码就开始为难了要么给这个职工插三行记录职工号、姓名、职称全部重复存储主键都没法设置要么就只能记录“最近一次参与的工程”历史参与关系全部丢失。第二种是在工程表里加一个worker_ids字段存1001,1002,1003这种逗号分隔的字符串。这种设计在读取“这个工程有哪些人”时确实方便一个字段全出来了。但只要你想统计“某个职工参与了哪些工程”就必须全表扫一遍再把字符串拆开对比性能和可维护性都是一场灾难更别提外键约束完全失效。第三种是建一张“快照表”每次某个职工加入或退出工程就把整条参与关系复制一遍。这种方案错在把“当前状态”和“历史流水”混为一谈导致查当前参与关系时还得按时间去重逻辑复杂度成倍上升。这三种操作本质上都是没有区分“实体”和“联系”。E-R 模型要解决的恰恰就是这个问题。1.2 E-R模型在数据库设计流程中的位置数据库设计通常分四步需求分析、概念结构设计、逻辑结构设计、物理结构设计。需求分析阶段你只关心业务说什么比如“一个职工可以参与多个工程一个工程有多名职工”。概念结构设计阶段产出物就是 E-R 图它用实体、属性、联系来描述业务语义完全不涉及 MySQL、PostgreSQL 还是 Oracle 的具体差异。逻辑结构设计阶段把 E-R 图转换为关系模式也就是把“职工”“工程”“参与”这些概念翻译成具体的表名、字段名、主外键。物理结构设计阶段才考虑建索引、分区分表、存储引擎这些落地细节。很多人跳过了前两步直接从第三步开始建表结果就是前面说的那些坑。E-R 模型不是最终交付物但它是一张“翻译图”把现实世界的对象和关系翻译成数据库能理解的结构。E-R 图的提法最早可以追溯到 1976 年 Peter Chen 的论文几十年过去这套概念仍然是数据库建模的通用语言原因就在于它足够直观矩形代表实体菱形代表联系椭圆代表属性谁都能看懂谁都能画。1.3 这篇适合谁读法建议如果你是数据库初学者建议从头读到尾重点看第二节的概念辨析和第三节的案例分析这两部分是理解多对多联系的关键。如果你已经有一定基础只是想知道“职工—工程”多对多怎么建表、怎么写查询可以直接跳到第四节和第五节里面有完整的建表 SQL 和查询 SQL。如果你负责独立设计模块第六节的一些坑和技巧可以帮你少走弯路。2. 实体、属性、联系E-R模型三件套的判别方法很多教程一上来就定义实体、属性、联系背完概念还是不知道实战里怎么用。我这里换一种讲法结合设计“职工—工程”系统时的实际判断过程把三件套拆清楚。2.1 实体与实体集先分清楚“类”和“具体实例”实体是现实世界中可以区别于其他对象的“事物”比如“职工张三”“工程宿舍楼项目”。E-R 图里画的实体严格说是实体型也就是“职工”这个类型包含职工号、姓名、职称、所在部门这些属性而不是具体某个职工。实体集则是同一类实体的集合比如“全体职工”。用编程类比实体型类似一个类实体集类似这个类的对象列表某一个具体的职工就是对象实例。考试里经常区分这三者工作里你不需要咬文嚼字大家口语里都说“职工实体”但心里要知道图中画的其实是实体型。还有一个比较容易忽略的概念是弱实体。职工家属这类实体离开职工实体就不存在它在 E-R 图中用双矩形表示主键要依赖所依附的实体。在我们的案例里职工和工程都是强实体都有独立的主键不需要引入弱实体但遇到“参与记录的明细项”之类建模时弱实体就派得上用场了。2.2 属性分类单值、多值、派生直接影响建表策略属性是实体或联系的特征。比如职工有职工号、姓名、职称工程有工程号、工程名称、预算。按取值特征属性可以分四类单值属性一个实例只允许一个值比如一个职工的职工号只能有一个。多值属性一个实例可以有多个值比如一个职工有多个联系电话。复合属性可以被拆分成更小成分比如“姓名”可以拆成“姓”和“名”“通信地址”可以拆成“省”“市”“街道”。复合属性和多值属性不同复合属性拆完后整体还是一个值多值属性则是一组值。派生属性可以由其他属性计算得到比如“工作年限”可以由“入职日期”和当前日期算出。为什么建表时要关注属性分类因为多值属性不能直接在职工表上开多个字段比如phone1、phone2、phone3这种设计扩展性很差正确的做法是拆成单独的联系或子表。派生属性建表时通常不实际存储用的时候现算避免数据不一致。注意“冗余存储派生属性”不总是错误有些报表场景为了查询性能确实会冗余但必须明确这是有意的冗余设计要保证同步更新绝不是随手加的。2.3 联系与联系类型判断逻辑可以一句话说清联系是多个实体之间的关联。在 E-R 图里用菱形表示比如“职工”和“工程”之间存在联系“参与”。联系按参与的实体个数分为一元联系、二元联系、三元联系。按参与实体之间的数量对应关系分为一对一、一对多、多对多。判断方法就是那个几乎所有教材都会提到的标准问题你需要从两个方向各问一遍正着问一个职工最多可以参与多少个工程反着问一个工程最多可以有多少个职工参与两个答案都是“多个”所以“参与”是多对多联系。这里要特别注意一个容易混淆的地方实体对之间的联系类型不是“固定属性”而是业务规则决定的。拿“教师—课程”举例如果规定一门课只能一个教师主讲但一个教师可以讲多门课那是教师对课程的一对多如果允许多个教师合上一门课一门课也可以由多个教师分别讲授那就是多对多。同一个实体对业务规则变了联系类型就变了。所以做需求分析时一定要把业务规则问清楚而不是想当然套用历史经验。另外一个常见误区是把“属性”和“联系”搞混。比如“职工的职称”职称不是独立实体它是职工的一个属性不需要画一个“职称实体”再建“拥有”联系。判断标准很简单这个东西有没有自己独立的属性需不需要被其他实体引用如果都没有它就只是属性。3. “职工—工程”多对多案例从业务描述到E-R图再到关系模式概念讲完了下面用具体的“职工—工程”案例把这个过程完整走一遍。这个案例很典型原因在于它是教科书式的多对多联系——不是那种一眼就能看出答案的 1:1 或 1:n而是必须靠双向验证才能确定类型。3.1 业务描述与实体属性清单假设我们在设计一个建筑施工企业或设计院的管理系统。需求描述大致如下企业有多名职工需要记录职工号、姓名、职称初级、中级、高级、正高、所在部门。企业有多个工程项目需要记录工程号、工程名称、预算、计划开工日期。一名职工可以同时参与多个工程项目一个工程项目也可以由多名职工共同参与。职工参与工程时需要记录该职工在某个工程中的参与日期、担任的角色项目经理、设计、施工、监理等以及累计投入工时。从这段描述里我们可以抽出来的实体有两个职工和工程。联系有一个参与。联系“参与”带三个属性参与日期、角色、累计工时。实体属性清单如下实体/联系属性主码候选职工职工号、姓名、职称、所在部门职工号工程工程号、工程名称、预算、计划开工日期工程号参与参与日期、角色、累计工时职工号工程号这里有个小细节值得提醒姓名是绝对不能当主码的因为重名概率太高。职工号、工程号这类由系统编号分配的字段天然具备稳定性和唯一性是主码的合理选择。3.2 为什么结论必须是“多对多”双向语义验证现在用第二节的判断方法验证一次正着问一个职工可以参与多少个工程需求里说了可以同时参与多个。所以答案是“多个”。反着问一个工程可以有多少个职工参与需求里说了多名职工共同参与。所以答案也是“多个”。两边都是“多个”结论就是多对多。这个结论要写进需求文档里并且最好当面和业务确认一次。我遇到过一种情况开发想当然按多对多建模结果业务方实际要求“一个职工同一时间段只能参与一个工程只有切换工程后才允许参与下一个”。这就变成了一对多完全两种建模思路。万一搞错返工代价不小。如果业务规则变成“一个职工同一时期只能参加一个工程”那“参与”联系就是职工对工程的一对多关系模式转换时就不需要独立建中间表了直接在工程表上加一个外键字段current_worker_id即可。可见同一个案例在不同规则下会有完全不同的表结构设计这也是为什么我一直强调业务分析要先于建表。3.3 从E-R图到关系模式的转换规则E-R 图绘制完成后要转换为关系模式。转换规则是固定的每个实体集转换为一个关系表实体的属性成为表的字段实体主码成为表的主键。联系的转换要看基数一对一可以将任一方的主码放入另一方表作为外键也可以单独建表。一对多将“一”端的主码放入“多”端表作为外键不需要单独建联系表。多对多必须单独建立一个关系包含两端实体的主码并且以这两个主码的联合作为主键联系的属性也放在这个关系里。我们的案例是多对多所以转换产物有三个关系WORKER职工号姓名职称所在部门PROJECT工程号工程名称预算计划开工日期PARTICIPATION职工号工程号参与日期角色累计工时其中 PARTICIPATION 的主键是职工号工程号两个字段分别引用 WORKER 和 PROJECT 的主码。为什么多对多必须独立建关系因为如果不建中间关系任何一端表都无法存放多个“另一端”的主码。你既不能在一个职工记录里放多个工程号也不能在一个工程记录里放多个职工号——关系模式的第一范式要求字段不可再分解逗号拼接的做法违背了这个原则。4. 中间表落地从关系模式到建表语句和索引策略关系模式转换完成后还需要考虑物理表的实际设计。这一节讨论中间表的建表细节。4.1 三种建表方案的取舍针对 PARTICIPATION 这张中间表实际建模中至少有三套方案方案主键策略适用场景优点缺点A联合主键worker_id, project_id业务保证“同一职工同一工程至多一条参与记录”省索引空间天然防重ORM 对复合主键支持不够友好B自增主键 UNIQUEworker_id, project_id团队统一使用自增主键或 ORM 框架需要单一主键字段对 ORM 友好主键查找快需要额外索引多一道唯一约束保障C自增主键不设唯一约束同一职工同一工程可按时间段多次参与灵活记录流水查询时需处理多条记录的聚合如果业务上明确“同一职工同一工程至多一条参与记录”我通常建议方案 A因为联合主键本身就是天然防重不用额外维护唯一索引。如果项目里所有表都约定用自增主键或者框架对复合主键支持不好就用方案 B同时加一个 UNIQUE 约束兜底。方案 C 适用于“同一职工在不同时间多次参与同一个工程”的历史流水这种情况下主键必须是worker_idproject_idstart_date或自增主键加唯一约束。4.2 建表SQL与字段类型要点以 MySQL 为例三个表可以这样建CREATE TABLE worker ( worker_id VARCHAR(20) PRIMARY KEY, name VARCHAR(50) NOT NULL, title VARCHAR(20) NOT NULL, department VARCHAR(50) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE project ( project_id VARCHAR(20) PRIMARY KEY, project_name VARCHAR(100) NOT NULL, budget DECIMAL(14,2) NOT NULL, start_date DATE NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE participation ( worker_id VARCHAR(20) NOT NULL, project_id VARCHAR(20) NOT NULL, join_date DATE NOT NULL, role_name VARCHAR(30) NOT NULL, work_hours DECIMAL(8,2) DEFAULT 0, PRIMARY KEY (worker_id, project_id), CONSTRAINT fk_part_worker FOREIGN KEY (worker_id) REFERENCES worker (worker_id), CONSTRAINT fk_part_project FOREIGN KEY (project_id) REFERENCES project (project_id), KEY idx_part_project (project_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个细节值得说明。第一work_hours用DECIMAL(8,2)而不是浮点类型避免金额和工时的精度误差。工时按 0.5 小时粒度记录的话两位小数足够。第二索引设计上联合主键worker_id, project_id实际是一个以 worker_id 为最左前缀的联合索引所以“查某职工参与了哪些工程”的查询能直接命中这个主键索引。但“查某工程有哪些职工”的查询条件落在 project_id 上它不在最左前缀位置就需要额外那条KEY idx_part_project (project_id)辅助索引。这个细节非常容易漏漏了之后按工程查人就会全表扫描。第三外键约束要不要加如果团队规范允许加上能保证引用完整性防止出现一个中间表记录引用不存在的职工或工程。如果团队习惯在应用层维护一致性数据库层面可以不加物理外键但必须在worker_id和project_id上分别建索引否则关联查询的性能会很糟糕。4.3 中间表的信息承载能力能回答哪些业务问题中间表建好后整个“职工—工程”多对多关系能回答的问题远远不止“谁参加了哪个工程”。某个职工参与了哪些工程项目某个工程有哪些职工参加某职工在某工程中的角色是什么每个工程各有多少职工参与同时参与了两个指定工程的职工有哪些哪些职工目前没有参与任何工程这些问题在第五节的 SQL 示例中逐个给出。设计中间表时最好先把业务上需要回答的问题列出来再由问题推导索引和字段——先想问题再建表比建完表再补答案要靠谱得多。5. 多对多关联查询SQL、执行计划与常见做法关系模式落地成表后最关键的是会用 JOIN 正确查询。多对多查询绕不开中间表写 SQL 时稍有疏忽结果就会出错或变慢。5.1 准备一点模拟数据先插入几条演示数据方便验证后面的 SQL。INSERT INTO worker VALUES (W001, 张工, 高级工程师, 设计一部), (W002, 李工, 中级工程师, 设计二部), (W003, 王工, 初级工程师, 工程部); INSERT INTO project VALUES (P001, 宿舍楼改造, 3500000.00, 2024-05-01), (P002, 厂房加固, 8000000.00, 2024-06-15); INSERT INTO participation VALUES (W001, P001, 2024-05-10, 项目经理, 120.5), (W002, P001, 2024-05-12, 设计师, 80.0), (W003, P001, 2024-05-15, 施工员, 60.0), (W001, P002, 2024-06-20, 项目经理, 50.0), (W002, P002, 2024-06-22, 设计师, 40.0);5.2 基础关联查询职工维度与工程维度从职工维度出发查某个职工参与了哪些工程及其角色SELECT p.project_name, pa.role_name, pa.join_date FROM participation pa JOIN project p ON pa.project_id p.project_id WHERE pa.worker_id W001;从工程维度出发查某个工程有哪些职工参加SELECT w.name, w.title, pa.role_name, pa.work_hours FROM participation pa JOIN worker w ON pa.worker_id w.worker_id WHERE pa.project_id P001;两条查询结构完全对称区别只在 WHERE 条件落在中间表的哪个字段上。这也好理解多对多关系本身就是对称的。性能方面第一条靠中间表的联合主键worker_id 在最左前缀即可直接定位第二条靠idx_part_project (project_id)辅助索引。如果没有这个二级索引查询时中间表只能全表扫描。统计每个工程的参与人数时要注意两个细节一是用 LEFT JOIN 保留没有参与者的工程二是用 COUNT(DISTINCT pa.worker_id) 而不是 COUNT(pa.worker_id)防止中间表出现重复录入时统计失真SELECT p.project_name, COUNT(DISTINCT pa.worker_id) AS participant_cnt FROM project p LEFT JOIN participation pa ON p.project_id pa.project_id GROUP BY p.project_id, p.project_name ORDER BY participant_cnt DESC;查哪些职工没有参与任何工程是典型的“反连接”问题可以用 LEFT JOIN IS NULL也可以写 NOT EXISTS。两种写法如下SELECT w.name FROM worker w LEFT JOIN participation pa ON w.worker_id pa.worker_id WHERE pa.worker_id IS NULL;SELECT w.name FROM worker w WHERE NOT EXISTS ( SELECT 1 FROM participation pa WHERE pa.worker_id w.worker_id );两种写法对结果没有区别但执行计划可能不同。在百万级中间表上NOT EXISTS往往走物化或索引扫描更稳定不过具体还是要以 EXPLAIN 结果为准。养成写任何涉及多表关联查询都用 EXPLAIN 看一眼的习惯能帮你提前发现缺索引的问题。5.3 交集、差集与防重多对多查询的几个进阶场景“同时参与 P001 和 P002 两个工程的职工”这是一个交集查询。实现思路是让中间表自己和自己做一次 JOIN通过别名把两个工程的条件分别落在两条参与记录上SELECT w.name FROM participation pa1 JOIN participation pa2 ON pa1.worker_id pa2.worker_id JOIN worker w ON w.worker_id pa1.worker_id WHERE pa1.project_id P001 AND pa2.project_id P002;这里的核心思想是把一张参与记录表看成两个维度一份是“在 P001 里的参与记录”另一份是“在 P002 里的参与记录”两边的 worker_id 一致就说明这个职工在两边都有记录。理解了自连接这个套路类似问题都能举一反三。防重方面如果方案选用了联合主键数据库层面已经保证同一个职工和同一个工程只能出现一条记录如果中间表另加唯一约束则靠约束兜底。统计时仍建议使用COUNT(DISTINCT worker_id)的习惯万一某天约束被临时去掉或者历史数据有脏数据这个写法至少不会让你的统计结果离谱。6. 我在实际建模中反复遇到的坑和技巧最后这部分内容是我做数据库设计这几年踩坑踩出来的经验不按教科书顺序讲但每一条都对应真实事故。6.1 联系到底要不要画出来什么时候单独建表很多初学者会纠结“是不是所有实体之间都要画一个菱形联系”。我的判断步骤是先看两个东西是不是独立实体。如果“职称”只是职工的一个描述属性不画实体不建“拥有”联系。再看对应关系。一对一和一对多时联系不一定单独建表外键可以直接放在其中一端。多对多时必须单独建表。最后看联系有没有自身属性。比如“参与”联系带了参与日期、角色、工时这些属性没有地方放只能放到中间表里。“职工—工程”案例里最容易被人忽略的属性是“职工在工程中的角色”。初学者经常在职工表里加一个字段叫current_role但同一职工在不同工程中可以有不同的角色在 P001 当项目经理在 P002 可能当设计负责人。把这个字段放在职工表里马上就会发生信息覆盖。我自己刚接触数据库时就犯过类似的错误把“联系属性”误当成“实体属性”最后改表改到怀疑人生。6.2 保持历史记录中间表不一定只存“当前有效”记录业务上线一段时间后你会遇到这种需求“这个工程上个月有哪些人参加”如果参与记录在职工退场时就从中间表物理删除历史问题就永远回答不了。所以我建议在中间表上加一个status字段取值active或left或者直接加end_date。默认end_date为空表示仍在参与退出时填写退出日期。这种方式保留历史也不影响当前的关联查询只需要在查询里加上AND end_date IS NULL之类的过滤条件。更复杂一点的场景是同一职工在同一个工程可以参加不止一次中间隔了半年又回来了。这时主键不能再是简单的worker_id, project_id而是要扩展为worker_id, project_id, start_date把每一次参与区间当成独立记录。这种需求看似不常见在长期运维项目里却频繁出现。提前想清楚别等到上线半年后才发现主键不满足需求。6.3 三元联系与二元组合的差别如果业务描述变成“职工、工程、设备”三方之间发生关联比如“某职工在某工程上使用某台设备”你再把它拆成“职工—设备”“职工—工程”“工程—设备”三个二元联系很可能会丢失信息。举例来说“职工在工程 A 上使用设备 X”和“职工在工程 B 上使用设备 X”是两条不同的事实但在三个二元联系的表结构下你只知道职工和设备有关系、设备和工程有关系、职工和工程有关系无法回答“当时是在哪个工程用的这台设备”。这种场景就需要三元联系的中间表主键由三个外键共同组成。判断是否需要三元联系标准就一句话能不能由任意两个二元关系推导出唯一的三元事实如果不能就应该用三元联系或至少增加一条独立的关联记录。6.4 命名、工具与团队约定中间表的命名我建议直接用表达业务含义的动词或组合名比如participation、member、emp_proj_rel。同一个关系不要在不同模块里出现两个名字运维部门叫participation人力部门叫member对接的时候会产生没必要的麻烦。画 E-R 图的工具简单的可以用 draw.io白板沟通时直接手画即可团队正式文档可以考虑 dbdiagram.io 或 MySQL Workbench。工具选择不重要重要的是图的维护。很多项目上线后E-R 图文档就再也没人更新了新增字段、调整关系全靠口口相传最后新同事想理清业务只能去数据库里一列一列翻注释。如果项目里能保持 E-R 图与真实表结构同步团队协作效率会明显不一样。我自己现在拿到一个需求第一件事仍然是先在纸上画矩形和菱形把实体和联系理清楚再动手建表。这个习惯帮我在过去避免了很多次返工。最后再分享一个小技巧画完之后拿每个联系做一次双向提问比如“一个职工参与几个工程一个工程能有几个职工”把答案写在图旁边再去定主外键和索引。你会发现很多复杂的问题在这一步就已经解决了大半。
返回列表