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 | 自增主键 + UNIQUE(worker_id, project_id) | 团队统一使用自增主键,或 ORM 框架需要单一主键字段 | 对 ORM 友好,主键查找快 | 需要额外索引,多一道唯一约束保障 |
| C | 自增主键,不设唯一约束 | 同一职工同一工程可按时间段多次参与 | 灵活记录流水 | 查询时需处理多条记录的聚合 |
如果业务上明确“同一职工同一工程至多一条参与记录”,我通常建议方案 A,因为联合主键本身就是天然防重,不用额外维护唯一索引。如果项目里所有表都约定用自增主键,或者框架对复合主键支持不好,就用方案 B,同时加一个 UNIQUE 约束兜底。方案 C 适用于“同一职工在不同时间多次参与同一个工程”的历史流水,这种情况下主键必须是(worker_id,project_id,start_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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有几个细节值得说明。
第一,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 图与真实表结构同步,团队协作效率会明显不一样。
我自己现在拿到一个需求,第一件事仍然是先在纸上画矩形和菱形,把实体和联系理清楚再动手建表。这个习惯帮我在过去避免了很多次返工。最后再分享一个小技巧:画完之后,拿每个联系做一次双向提问,比如“一个职工参与几个工程?一个工程能有几个职工?”,把答案写在图旁边,再去定主外键和索引。你会发现,很多复杂的问题在这一步就已经解决了大半。