做数据库这行十来年,我最怕接手的不是那种几百万行的大表,而是那种"没人说得清为什么这么设计"的库。字段名像拼音缩写,外键一半靠代码兜着,中间表里堆着一堆没人敢删的历史列。往下追根问底,十有八九会发现同一个问题:当初压根没画 ER 图,或者画了但只是交作业时贴进文档里,之后就和真实表结构彻底脱节了。
ER 图(Entity-Relationship Diagram,实体-联系图)说白了就是数据库设计阶段的"施工图"。它不写具体字段类型,也不管你用 MySQL 还是 Oracle,只回答三件事:这个系统里有哪些"东西"(实体)、这些东西各自有哪些特征(属性)、它们之间怎么产生关系(联系)。听起来简单,但真正画过教学管理系统 ER 图、银行储蓄系统 ER 图的人都知道,难的不是画框框和菱形,难的是在"要不要把它拆成独立实体""这个属性挂在哪张表上""这条线该是 1:N 还是 M:N"这几个岔路口做对选择。选错一次,后面建表、写 SQL、做数据同步的时候就得花十倍时间往回改。
这篇内容我按自己实际做项目的顺序来写:先从 ER 图在整个设计链路里的位置讲起,再拆实体、属性、联系、主键这些核心元素的表示法与判定标准,然后重点落在"ER 图怎么变成能跑的建表语句"这个最容易被卡住的环节,最后把从 SQL 反推 ER 图、工具选型、结构变更后的图同步维护、以及课程设计和面试里被追问的高频点一起讲透。不管你是在做数据库课程设计的学生,还是刚接手一套老库需要梳理结构的开发,或者要准备计算机三级数据库、数据库相关考试的复习,下面这些内容都能直接拿去用:有对照表、有能跑的脚本、有 DDL 示例,也有我踩过的坑。
1. ER图在数据库设计链路里到底站哪个位置
很多人把 ER 图当成一门"考试要考的画图技能",实际项目里能不能用上无所谓。这个认知偏差是后面所有混乱的源头。ER 图不是文档装饰品,它是需求语言和 SQL 建表语句之间唯一的翻译层,缺了它,需求里一句"一个学生可以选多门课,一门课可以被多个学生选",就直接跳到建表,中间那步"M:N 必须拆成中间表、中间表要不要带成绩和学期"就全靠个人经验硬猜。
1.1 三层数据模型的分工,别混着用
数据库设计行业里通常把建模分成三层:概念模型、逻辑模型、物理模型。三层回答的问题完全不同,混在一起画,图就会变成谁都看不懂的"四不像"。
| 层次 | 关注什么 | 典型元素 | 是否绑定具体DBMS |
|---|---|---|---|
| 概念模型 | 业务里有什么、彼此什么关系 | 实体、属性、联系、基数 | 不绑定 |
| 逻辑模型 | 主键、外键、规范化程度、索引策略 | 表、列、PK/FK、唯一约束 | 基本不绑定,但受范型影响 |
| 物理模型 | 字段类型、字符集、分区、存储引擎 | VARCHAR(50)、utf8mb4、InnoDB | 强绑定 |
概念 ER 图就是给业务方看的,里面出现varchar(50)这种字样是错的,出现"选课记录"这种业务名词是对的。逻辑 ER 图是给开发看的,要标出主键、外键和中间表。物理模型一般交给建模工具从逻辑模型生成,或者直接手写 DDL。
我的习惯是:概念图和逻辑图分两个文件存,概念图用中文业务名词,逻辑图用英文表名列名,并和 DDL 保持一致。这样业务方评审时不会被stu_no这种字段搞晕,开发看逻辑图时也不会被"学生"这种中文名卡住。
1.2 跳过 ER 图直接建表,会付出什么代价
我经手过一套内部审批系统的重构,原始状态是这样的:需求方说"一个申请单可以有多个附件,附件也可能被多个申请单引用"。当初的开发直接建了application表和attachment表,然后在attachment表里加了一个application_id字段。这个设计把"多对多"硬压成了"一对多",后半个需求彻底丢失。上线半年后产品要加"附件复用"功能,才发现历史数据里同一个物理文件被重复上传了上千次,硬盘占用翻了三倍,改起来还要写数据清洗脚本。
这类问题不是个案,它有三个稳定的表现形式。第一是关系被降级:M:N 被写成 1:N,1:N 被写成字段冗余,短期能跑,长期数据一致性全崩。第二是属性挂错表:把"课程学分"挂在选课记录上而不是课程上,导致同一门课不同学生的学分能不一致。第三是主键选错:用业务可变的字段做主键,比如手机号,用户一换号,所有关联表的引用全断。
这三类问题的修复成本,大概是设计阶段多花两个小时画图的三到十倍,而且往往伴随着停机迁移。所以我在任何项目里都坚持一件事:建表前必须先有一张能让业务方点头的 ER 图,哪怕是手绘拍照也行。
1.3 ER 图真正的价值是"逼你把话说清楚"
画 ER 图最累的不是画,是问。我以前做教务系统的时候,追着一个需求方问了三个问题,直接把后面三张表的结构改了:
第一个问题:"一个学生能不能同时属于两个班级?"回答是不行,于是 学生→班级 是 N:1,班级主键可以直接放进学生表当外键。第二个问题:"一个老师能不能带多个班的同一门课?"回答是可以,于是出现了"授课任务"这个独立实体,而不是把老师直接挂在课程上。第三个问题:"选课之后成绩是谁录的?"回答是任课老师,于是"选课记录"和"授课任务"之间又出现了一条依赖关系。
这三个问题如果只在脑子里过一遍,很可能得出"差不多就行"的结论。一旦必须画成图,你就必须为每条线选一个基数符号,选不出来就说明需求还没想清楚。这就是我常跟新人说的一句话:ER 图的价值不在图纸本身,在于它强迫你把模糊需求变成明确的、必须二选一的判断。
另外,ER 图是唯一一份业务方、开发、测试三方都能看懂的中间产物。测试同学拿它设计用例特别顺手:每个 M:N 关系至少测三个方向(新增、删除中间记录、两端删除时的级联行为)。这一点在后面第 5 节的排查清单里我还会展开。
2. 实体、属性、联系、主键:核心元素怎么判定和表示
画图之前先把符号体系搞清楚。ER 图的符号主要分两派:陈氏(Chen)符号和鸦爪(Crow's Foot)符号。国内教材、课程设计、计算机三级数据库里基本都用陈氏符号,矩形表示实体、椭圆表示属性、菱形表示联系、线上标 1 或 N;工程界用鸦爪符号多,因为信息密度高,一张 A4 能画下几十张表。
| 元素 | 陈氏符号 | 鸦爪符号 | 常见误区 |
|---|---|---|---|
| 实体 | 矩形 | 表框 | 把"订单明细"当属性而不是实体 |
| 属性 | 椭圆 | 表内列 | 把可计算的派生值画成属性 |
| 主键 | 属性名加下划线 | 列前标 PK | 复合主键只标一列 |
| 联系 | 菱形 | 表间连线 | 把外键关系画成实体 |
| 基数 | 线上标 1/N/M | 端点鸦爪/短线 | 1:N 和 N:1 方向画反 |
2.1 实体与属性的边界:什么时候必须拆表
判断一个名词是实体还是属性,我的经验标准有三条,命中任意一条就拆成实体。
第一条,它有没有自己的、和主体无关的属性。比如"地址",如果只需要存一个字符串,那是属性;但如果要存省市区编码、邮编、联系人、是否默认地址,而且一个人可以有多个地址,那就是独立的"地址"实体,学生表和地址表是 1:N。
第二条,它会不会被多个主体共享。比如"学院",学生属于学院,老师也属于学院,那就必须独立成实体,两边都放外键。如果你把它当成学生的属性写进学生表,老师的学院信息就得再抄一份,改一次名要改两处。
第三条,它有没有独立生命周期。比如"订单明细",它看起来像订单的一部分,但它有自己的数量、单价快照、折扣,而且要在订单之外被单独查询统计,那就必须独立成实体。把它塞成订单表里的 JSON 字段,后期做销售分析时会非常痛苦。
反过来,有几类东西不要画成实体:一是纯派生值,比如"年龄"由生日算出来,"总金额"由明细汇总出来,画进 ER 图会让图变得臃肿且容易失同步;二是纯技术字段,比如created_at、updated_at、is_deleted,概念图里一般不出现,逻辑图里统一加上就行;三是枚举型的小字典,比如性别,除非业务要求可维护(比如要支持自定义性别选项并多语言),否则直接作为属性。
2.2 主键怎么表示,以及选主键的四个硬条件
"ER 图主键怎么表示"是搜索量很高的问题,说明很多人在这里被卡过。标准做法是:在陈氏符号里,主键属性名下方画一条实线下划线。如果是复合主键,参与主键的每一个属性都要加下划线,不是只标第一个。有些教材还会用虚线表示候选键(唯一但非主键),用浅色椭圆表示可空属性,这些属于加分项,不是必须。
在鸦爪符号或者逻辑 ER 图里,更常见的做法是在列名后标 PK,或者用加粗、加锁图标,外键标 FK。复合主键就在多列上分别标 PK,并在表级说明里写清楚列顺序。
提示:复合主键的列顺序在真实数据库里会影响索引可用性,画图时最好在旁边备注顺序,否则建表的人很容易按字母顺序建,导致最左前缀原则用不上。
选主键这件事,我给的标准是四个条件同时满足才可以用:值不为空、值全局唯一、值基本不变、值尽量短。能满足这四条的,现实中很少。所以我在生产项目里几乎都用两类主键:一是无意义自增整数或分布式 ID(BIGINT UNSIGNED AUTO_INCREMENT、雪花 ID、UUID 转二进制),二是天然稳定且短的业务码,比如学号、组织机构编码。
学号这类业务码要不要当主键?我的做法是:在概念图里把学号标为主键,因为它在业务语义上确实是标识符;在逻辑图和物理表里,用自增 ID 当主键,学号加唯一索引。这样业务方看到的图和数据库实际结构略有差异,但我会在图注里写清楚"业务主键 vs 物理主键"的映射,避免评审时产生误解。
2.3 联系的基数、参与度,以及最容易画反的地方
基数(Cardinality)表示一个实体实例能关联多少个另一端的实例。1:1、1:N、M:N 这三种说法大家都熟,但实际画图时错得最多的是"方向"和"参与度"。
方向问题:学生 N —— 属于 —— 1 班级,这条线上靠近学生端标 N,靠近班级端标 1。很多人习惯在中间菱形两侧都写 N 和 1,但不写清楚哪边对哪边,导致后面建表时把外键放错表。我的规矩是:外键总是放在"多"的那一端,画图时在"多"的一端线上标 N,并顺手标注FK→指向"一"端的主键。
参与度(Participation)分全参与和部分参与,用双线或单线区分。全参与的意思是每个实例都必须参与这条联系。比如"选课记录必须属于某个学生",那么从选课记录看学生是全参与;反过来"学生必须选课吗",如果允许一个新生还没选课,那就是部分参与。参与度直接影响建表时的外键能不能为 NULL,以及要不要写触发器或者应用层校验。
| 基数组合 | 外键放哪里 | 是否可为空 | 典型场景 |
|---|---|---|---|
| 1:1 | 任一端,选参与度全的一端 | 取决于参与度 | 用户与其实名认证信息 |
| 1:N | 放在 N 端 | N 端全参与则非空 | 班级与学生 |
| M:N | 新建中间表 | 两端都非空 | 学生与课程 |
还有一个高频疑问:"多对多联系能不能带属性?"能,而且经常必须带。选课这个 M:N 联系上就挂着"成绩""学期""选课时间"三个属性。在陈氏符号里,这些属性的椭圆直接连到菱形上,而不是连到学生或课程的矩形上。这个画法很多人做错,把成绩连到学生上,结果一门课只能有一个成绩。我建议画图时养成一个习惯:凡是"只有在两个东西同时存在时才有意义"的属性,一律挂到菱形上。
2.4 弱实体与依赖关系:什么时候实体不能独立存在
弱实体是必须依赖另一个实体才能存在的实体,陈氏符号里用双线矩形表示,联系用双线菱形。典型例子是"订单明细"依赖"订单","交易流水"依赖"账户"。弱实体的主键通常是"所属实体主键 + 局部键"的组合,在图上把所属实体的主键作为虚线或标记词(部分键)加上下划线。
这里有个坑我踩过:弱实体的标识符不要用自增 ID 的思维去理解。订单明细的主键在业务上其实是(order_id, line_no),line_no是明细在订单里的序号。如果图里只标一个自增 ID 当主键,业务方根本看不出"同一订单里明细序号不能重复"这条规则,等到数据出问题了才回头补唯一约束。
对应的两个概念还要区分清楚:依赖联系(Identifying Relationship,父实体删除时子实体随之删除)和非依赖联系(Non-identifying,父删除时子可以保留,只是外键置空或受限)。在鸦爪符号里前者用实线,后者用虚线。这个区分不是学术洁癖,它直接决定你建表时ON DELETE写CASCADE还是RESTRICT。银行流水这种数据,绝对不能级联删除,写了CASCADE就等于给自己埋一颗定时炸弹。
3. 从ER图落到建表语句:转换规则与可直接抄的DDL
图画得再漂亮,最后要落地成 DDL。这中间的转换规则是有明确定义的,但很多教学材料只讲结论不讲理由,导致照着画没问题、自己想就出错。这一节我按"规则+为什么+真实 SQL"来写,示例统一用教学管理系统,因为它同时包含了 1:N、M:N 和分类继承三种情况。
3.1 一对一和一对多:外键放哪、要不要建唯一索引
一对一(1:1)的转换有两种做法:外键法(把一端主键放到另一端当外键,并加唯一约束)和共享主键法(两张表共用同一个主键值)。我一般选共享主键法,因为省一个字段,而且天然保证唯一。典型场景是"用户"和"用户实名认证信息",认证信息表的user_id既是主键又是外键。
一对多(1:N)的规则很简单:把"一"端的主键作为外键,放到"多"端的表里。这里有两个容易忽略的点。第一,外键列必须建索引,MySQL 在建外键时会自动建,但如果业务上经常按这个外键过滤,最好显式建一个复合索引,比如(class_id, name)。第二,如果"多"端是全参与,外键列要写NOT NULL,这比在应用层校验靠谱得多。
-- 班级表:一端 CREATE TABLE class ( class_id INT UNSIGNED NOT NULL AUTO_INCREMENT, class_name VARCHAR(60) NOT NULL, grade_year SMALLINT NOT NULL COMMENT '入学年份', major_id INT UNSIGNED NOT NULL, PRIMARY KEY (class_id), UNIQUE KEY uk_class_name (class_name), KEY idx_major (major_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 学生表:多端,class_id 是外键 CREATE TABLE student ( student_id CHAR(10) NOT NULL COMMENT '学号,业务主键', id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '物理主键', name VARCHAR(50) NOT NULL, gender CHAR(1) NOT NULL DEFAULT 'M', birth_date DATE NULL, class_id INT UNSIGNED NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_student_no (student_id), KEY idx_class_name (class_id, name), CONSTRAINT fk_student_class FOREIGN KEY (class_id) REFERENCES class (class_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;注意上面这个处理:业务主键student_id加了唯一索引而不是主键,物理主键用自增。这是我前面说的"概念图业务主键、物理表代理主键"的落法。唯一索引必须加,否则学号重复了数据库不会拦你。
3.2 多对多的中间表:三个必须加的字段和两个必须建的索引
M:N 联系必须拆成中间表,这是最没有争议的规则。但中间表怎么建,细节很多。我把必须做的事列成清单:
- 两端外键列都要有,且都
NOT NULL。 - 如果联系本身有属性(成绩、学期、数量),就作为中间表的普通列。
- 如果联系是弱实体性质(比如选课记录在学期内唯一),主键用
(端1, 端2, 附加维度)复合主键,而不是自增 ID 加一个唯一索引——复合主键本身就能拦重复,成本更低。 - 两个方向都要能查,所以除复合主键外的另一端要单独建索引,否则"查某门课所有学生"会走全表扫描。
- 加
created_at记录写入时间,后面做数据同步、增量抽取、审计都用得上。
-- 课程表 CREATE TABLE course ( course_id INT UNSIGNED NOT NULL AUTO_INCREMENT, course_code VARCHAR(20) NOT NULL COMMENT '课程号', course_name VARCHAR(80) NOT NULL, credit DECIMAL(3,1) NOT NULL DEFAULT 0.0 COMMENT '学分', PRIMARY KEY (course_id), UNIQUE KEY uk_course_code (course_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选课记录:M:N 拆出来的中间表,主键是复合主键 CREATE TABLE course_selection ( student_id CHAR(10) NOT NULL, course_id INT UNSIGNED NOT NULL, term VARCHAR(12) NOT NULL COMMENT '学期,如 2024-2025-1', score DECIMAL(5,1) NULL COMMENT '成绩,允许为空表示未录入', selected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (student_id, course_id, term), KEY idx_course_term (course_id, term), KEY idx_student_term (student_id, term), CONSTRAINT fk_cs_student FOREIGN KEY (student_id) REFERENCES student (student_id), CONSTRAINT fk_cs_course FOREIGN KEY (course_id) REFERENCES course (course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个真实教训。早期我建的选课表主键是(student_id, course_id),没带学期。结果遇到重修场景,同一学生同一门课第二次选课直接主键冲突。后来加了term字段才解决,但老数据里已经有几千条被INSERT IGNORE静默丢掉的记录,只能靠日志回捞。这件事之后,我在任何"看起来两端就够了"的中间表上都会多问一句:会不会有时间维度或者状态维度上的重复?
3.3 分类与继承怎么落:三种策略的取舍
教学管理系统里常有"学生"和"教师"都是"人员"的情况,或者"账户"下面分"储蓄账户""信用卡账户"。这类继承关系在关系数据库里有三种落地方式,各有取舍。
| 策略 | 表结构 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 单表继承 | 一张表加类型字段,子类专属列可空 | 查询简单、无 JOIN | 列数膨胀、无法加非空约束 | 子类差异小 |
| 类表继承 | 父表存公共列,每个子类一张表存专属列 | 约束完整、结构清晰 | 查询要 JOIN、写入要事务 | 子类差异大且字段多 |
| 具体表继承 | 每个子类一张独立表,各自含公共列 | 单表查询快 | 公共列重复、跨类型查询要 UNION | 子类之间几乎不联查 |
我做过一个银行储蓄系统 ER 的梳理,账户体系用的是类表继承:account存账号、余额、状态、开户网点,deposit_account存存期、利率、到期日,credit_account存额度、账单日。这么设计的原因是储蓄和信用卡的字段差异太大,塞一张表里会有十几个长期为空的列,而且监管报表要按类型分别汇总,分开查更清晰。反过来说,如果只是"个人客户"和"企业客户"这种只有三四个字段差异的,我就直接单表加类型字段,省掉无谓的 JOIN。
选择策略的判断标准我给三个:子类间差异列占全部列的比例超过 40% 就用类表继承;跨类型查询频率高就用单表继承;子类之间几乎不联查就用具体表继承。这不是硬规定,但能覆盖八成场景。
4. 工具选型与实操:怎么画,以及怎么从现有SQL反推ER图
前面讲的都是"应该怎么做",这一节讲"用什么做"。实际工作里,ER 图的需求分成两类:新项目从零画,老项目从已有的 SQL 反推。这两类需求适合的工具完全不同,很多人用错工具,导致效率极低。
4.1 三类工具对比:手绘、在线建模、专业建模
| 工具类型 | 代表 | 上手成本 | 适合场景 | 主要短板 |
|---|---|---|---|---|
| 手绘/白板 | 纸笔、白板、通用画图工具 | 极低 | 需求讨论、课程设计初稿 | 无法生成 DDL、无法版本化 |
| 在线建模 | dbdiagram 类在线工具、各类可视化建模平台 | 低 | 中小项目、需要共享链接评审 | 大图卡顿、复杂约束表达弱 |
| 专业建模 | 各类桌面建模工具、数据库自带设计器 | 高 | 企业级项目、多数据库适配 | 学习曲线陡、界面偏重 |
| 面向代码 | 文本化建模(PlantUML 等) | 中 | 需要纳入 Git 版本管理 | 布局不直观,改完要重新渲染 |
我现在的组合是这样的:需求评审阶段用在线工具画一张概念图,改起来快,业务方能直接点开链接看;进入开发后,把逻辑模型写成文本化建模脚本,和 DDL 一起进 Git 仓库,每次评审都对着 diff 看;物理层直接用数据库自带的工具反向导出,保证图和线上表结构一致。
这里特别说一下文本化建模的价值。我维护过一个有八十多张表的系统,前期用图形工具画,每次改结构都要重新打开软件、拖动布局、导出图片、上传文档,改一次至少二十分钟,大家慢慢就懒得改了,半年后图和实际结构差了十几张表。换成文本脚本之后,改一行文本、提交一次 Git,图自动重新生成,成本降到一分钟,图纸再也没失同步过。
PlantUML 的 ER 语法大致是这样,我给出一个片段做示范:
@startuml entity "学生" as student { * student_id : CHAR(10) <<PK>> -- name : VARCHAR(50) gender : CHAR(1) class_id : INT <<FK>> } entity "课程" as course { * course_id : INT <<PK>> -- course_code : VARCHAR(20) <<UK>> credit : DECIMAL(3,1) } entity "选课记录" as cs { * student_id : CHAR(10) <<PK,FK>> * course_id : INT <<PK,FK>> * term : VARCHAR(12) <<PK>> -- score : DECIMAL(5,1) } student ||--o{ cs course ||--o{ cs @enduml这段文本可以直接被渲染工具转成图,配合 CI 就能做到"DDL 提交后图自动更新"。
4.2 从已有 SQL 反推:MySQL 导出 ER 关系的两条路径
接手老系统时最需要的就是"从 SQL 生成 ER 图"。MySQL 上有两条路,我按适用规模分开讲。
路径一是用图形客户端自带的反向工程功能。主流客户端基本都支持连接数据库后自动扫描表和约束生成图,优点是一键、直观,缺点是表超过五十张之后自动布局会乱成一团,需要手工整理,而且图不容易版本化。
路径二是直接查information_schema自己出图。这条路可控性最强,能精确控制哪些表参与、哪些约束要显示。核心是三个视图:TABLES拿表和注释,COLUMNS拿列和类型,KEY_COLUMN_USAGE拿主键、唯一键和外键。下面这段 SQL 是我常用的外键清单查询,直接跑就能看出"哪张表的哪一列指向了谁"。
SELECT kcu.TABLE_NAME AS child_table, kcu.COLUMN_NAME AS child_col, kcu.CONSTRAINT_NAME AS fk_name, kcu.REFERENCED_TABLE_NAME AS parent_table, kcu.REFERENCED_COLUMN_NAME AS parent_col, rc.DELETE_RULE AS on_delete, rc.UPDATE_RULE AS on_update FROM information_schema.KEY_COLUMN_USAGE kcu JOIN information_schema.REFERENTIAL_CONSTRAINTS rc ON rc.CONSTRAINT_SCHEMA = kcu.TABLE_SCHEMA AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME WHERE kcu.TABLE_SCHEMA = 'your_db_name' AND kcu.REFERENCED_TABLE_NAME IS NOT NULL ORDER BY kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION;顺手再补一个找主键和唯一键的查询,画图时用得上:
SELECT tc.TABLE_NAME, tc.CONSTRAINT_TYPE, -- PRIMARY KEY / UNIQUE / FOREIGN KEY kcu.COLUMN_NAME, kcu.ORDINAL_POSITION FROM information_schema.TABLE_CONSTRAINTS tc JOIN information_schema.KEY_COLUMN_USAGE kcu ON kcu.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME AND kcu.TABLE_NAME = tc.TABLE_NAME WHERE tc.TABLE_SCHEMA = 'your_db_name' AND tc.CONSTRAINT_TYPE IN ('PRIMARY KEY','UNIQUE') ORDER BY tc.TABLE_NAME, tc.CONSTRAINT_TYPE, kcu.ORDINAL_POSITION;注意:
information_schema里的表名和列名在不同数据库产品上大小写行为不一致。Oracle 里对象名默认大写,MySQL 在 Linux 下受lower_case_table_names影响。脚本里统一加UPPER()或者统一转小写再比对,能避免"明明有表却匹配不上"这种莫名其妙的空结果。
4.3 一个能跑的小脚本:把表结构直接转成建模文本
上面两条查询的结果,手工抄进图里还是很累。我写过一个几十行的 Python 脚本,查完直接输出建模文本文件,再交给渲染工具。核心逻辑就是把外键关系和主键信息拼成文本,代码不长但很实用。
import pymysql DB = "your_db_name" conn = pymysql.connect(host="127.0.0.1", port=3306, user="readonly", password="your_password", database=DB, charset="utf8mb4") cols_sql = """ SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s ORDER BY TABLE_NAME, ORDINAL_POSITION """ fk_sql = """ SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = %s AND REFERENCED_TABLE_NAME IS NOT NULL """ with conn.cursor() as cur: cur.execute(cols_sql, (DB,)) cols = cur.fetchall() cur.execute(fk_sql, (DB,)) fks = cur.fetchall() fk_map = {(t, c): (pt, pc) for t, c, pt, pc in fks} tables = {} for t, c, ctype, nullable, key, comment in cols: tables.setdefault(t, []).append((c, ctype, nullable, key, comment)) lines = ["@startuml", "hide circle", "skinparam linetype ortho"] for t, cs in tables.items(): lines.append(f'entity "{t}" as {t} {{') for c, ctype, nullable, key, comment in cs: if key == "PRI": mark = "<<PK" elif key == "UNI": mark = "<<UK" else: mark = "" if (t, c) in fk_map: mark = (mark + ",FK>>") if mark else "<<FK>>" lines.append(f" {'*' if key == 'PRI' else ''} {c} : {ctype}{mark}") lines.append("}") lines.append("") for t, c, pt, pc in fks: lines.append(f"{pt} ||--o{{ {t}") lines.append("@enduml") open("er.puml", "w", encoding="utf-8").write("\n".join(lines)) print("已生成 er.puml,共", len(tables), "张表")这个脚本我拿一个 63 张表的库试过,生成的文本渲染出来结构基本正确,需要人工干预的主要是两处:一是布局(可以调linetype参数),二是某些明显是"历史遗留但没建外键"的关联需要手工补线。这两处正好是需要人判断的地方,交给脚本反而危险。
提示:用只读账号跑这类脚本,不要用业务账号。另外密码不要硬编码在脚本里,读环境变量传进去,我见过把带密码的脚本提交到代码仓库导致事故的案例。
4.4 银行储蓄系统 ER 拆解:一个典型的强约束场景
拿银行储蓄系统练手特别合适,因为它同时具备弱实体、M:N、严格的外键约束三个特征。我给一个我梳理过的精简模型,帮助你理解这类系统 ER 图的画法。
核心实体有五组:客户、账户、卡、交易流水、机构(网点)。关系是这样的:客户与账户是 M:N(支持联名账户),账户与卡是 1:N(一个账户可以挂多张卡),账户与交易流水是 1:N 且交易流水是弱实体(流水必须依附账户存在),机构与账户是 1:N(账户归属开户机构),机构与柜员是 1:N。
几个设计决策值得说。第一,联名账户必须用中间表customer_account,并且在这个中间表上挂"持卡关系类型"(主卡人、附属人)和"生效日期"。直接在一个表上放两个客户 ID 字段是不行的,因为联名人数可能超过两个。第二,交易流水表的主键我建议用(account_id, txn_seq)或者独立的流水号加唯一索引,ON DELETE必须是RESTRICT,绝对不能CASCADE——删一个账户把十年流水全删掉,这在任何金融场景里都是重大事故。第三,金额字段一律用DECIMAL(18,2)或者按分存整数BIGINT,不要用FLOAT/DOUBLE,浮点误差在金融场景里是硬伤,我之前见过对账差一分钱查了三天的案子,根源就是金额字段用了双精度。
-- 交易流水:弱实体,严格禁止级联删除 CREATE TABLE txn_flow ( account_id BIGINT UNSIGNED NOT NULL, txn_seq INT UNSIGNED NOT NULL COMMENT '账户内流水序号', txn_time DATETIME(3) NOT NULL, direction TINYINT NOT NULL COMMENT '1借 2贷', amount DECIMAL(18,2) NOT NULL, balance DECIMAL(18,2) NOT NULL COMMENT '交易后余额快照', remark VARCHAR(200) NULL, PRIMARY KEY (account_id, txn_seq), KEY idx_txn_time (txn_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ALTER TABLE txn_flow ADD CONSTRAINT fk_txn_account FOREIGN KEY (account_id) REFERENCES account (account_id) ON DELETE RESTRICT ON UPDATE RESTRICT;余额快照这一列是很多初学者会漏掉的。它属于派生数据,理论上可以由流水累加算出来,但在对账场景里必须有快照,否则一旦有流水缺失或者补录,余额就永远对不上。这里就体现了一个原则:ER 图里不画派生属性,但物理表里为了性能和可追溯性,经常要冗余派生列。
5. 常见问题与排查技巧实录
图会画了,脚本也能跑了,接下来是真实工程里最容易卡住的部分。这一节的问题都是我实际被问过或者亲身踩过的,按发生频率排序。
5.1 画完图建表报错:五个高频坑
第一坑是保留字。表名用order、group、desc、key这类词,MySQL 直接报语法错误。我在教务系统里见过有人建了class表,在 MySQL 里能跑但某些数据库产品上会出问题。规范做法是加前缀统一命名,比如t_order、sys_group,或者用反引号包起来——但反引号只在 MySQL 有效,跨库迁移时又会炸。
第二坑是字符集和排序规则不一致导致外键建不上。父表和子表的字符集、排序规则必须完全一致,utf8mb4_general_ci和utf8mb4_0900_ai_ci混用就会报 3780 错误。我的做法是整个库统一utf8mb4+ 一种排序规则,写在建库语句里,所有表继承默认值。
第三坑是唯一约束已经有重复数据。给老表加唯一索引时提示Duplicate entry,说明历史数据里已经有重复值。处理顺序是:先查重复(GROUP BY ... HAVING COUNT(*) > 1),再决定是清洗、合并还是改用普通索引。千万别直接删数据,我处理过一个案例,重复的客户手机号里有一半是真正的不同客户,只是手机号被填成了同一个客服电话。
第四坑是外键字段类型不匹配。父表主键是INT UNSIGNED,子表外键写成INT(有符号),类型不完全一致时外键创建会失败。这种错误信息很隐晦,报的往往是"无法创建外键约束",需要你去对比SHOW CREATE TABLE的输出才能发现。
第五坑是级联规则写错。默认是RESTRICT,有人为了"方便测试删除"改成CASCADE,结果测试环境的数据全被连锁删除。我的规矩是:任何CASCADE都要在评审时单独提出来说明理由,否则一律RESTRICT。
| 报错现象 | 大概率原因 | 排查指令 |
|---|---|---|
| 语法错误 | 表名/列名命中保留字 | SHOW CREATE TABLE t看是否需要转义 |
| 3780 外键字符集不一致 | 排序规则不统一 | SHOW TABLE STATUS LIKE 't'看 Collation |
| Duplicate entry | 已有重复数据 | SELECT c, COUNT(*) FROM t GROUP BY c HAVING COUNT(*) > 1 |
| 无法创建外键约束 | 类型或引擎不一致 | 对比SHOW CREATE TABLE两端输出 |
| 数据被误删 | ON DELETE CASCADE | 查information_schema.REFERENTIAL_CONSTRAINTS |
5.2 表结构变更之后,怎么让ER图不失效
这是最容易被忽略的问题:ER 图的价值随时间衰减。上线三个月后加了三张表、改了五个字段,图没更新,之后新人看到的就是一张假图,比没有图更危险,因为他会基于错误信息做判断。
我的做法是把图纳入版本管理,和数据库迁移脚本放在同一个目录,用同一次提交修改。具体来说:所有 DDL 变更走迁移脚本(哪怕是手写的 SQL 文件),变更提交时必须同步更新文本化建模文件,在 CI 里加一步对比——建模文件里的表结构哈希和从测试库反向导出的哈希不一致就报错。听起来有点重,但实现成本很低,一个脚本加一条流水线规则就够了。
对于已经维护了很多年的系统,一步到位不现实。我建议的做法是先做一次全量对齐:用第 4.3 节的脚本从生产库导出建模文本,作为基线提交;之后只维护这之后的变化,历史的差异标注在文件头部,注明"基线建立于某日期,此前历史差异未追溯"。这样至少能保证增量部分是准确的。
还有一个场景是数据库结构同步工具的使用。这类工具在多个环境(开发、测试、预发、生产)之间同步结构时很方便,但用之前一定先看它生成的差异 SQL。我见过工具生成DROP COLUMN直接在生产执行的情况,原因是两边列顺序不同导致比对错位。用同步工具的稳妥流程是:先在预发环境跑一次,人工审阅生成的 SQL,确认没有破坏性语句(DROP、TRUNCATE、类型收窄),再上生产,并且执行前做一次结构备份。
5.3 常见问题速查表
把上面这些整理成一张表,出问题时直接对照。
| 问题 | 判断方法 | 处理建议 |
|---|---|---|
| 1:N 还是 M:N 分不清 | 问"一端能不能对应多个另一端" 双向问 | 双向都问一遍,任一方向为多就是 M:N |
| 中间表要不要自增 ID | 看是否有第三维度会重复 | 有天然复合键就用复合主键,别加多余自增 |
| 主键要不要用业务字段 | 看值会不会变 | 会变就加代理主键 + 业务列唯一索引 |
| 外键要不要建 | 看是否强一致要求 | 核心交易表必建,日志类可只在应用层保证 |
| 图太大画不下 | 表数超过 30 张 | 按业务域拆成多张子图 + 一张全局概览图 |
| 循环依赖外键建不上 | A 依赖 B、B 依赖 A | 拆出一个中间表打破循环,或延后其中一条外键 |
| 历史库没外键怎么推图 | 靠命名规律(xx_id)猜 | 结合代码里的 JOIN 语句验证 |
最后一行值得展开说。老库里没建外键是常态,尤其是互联网业务。这时候推 ER 图的办法是拿代码里的 SQL 做数据挖掘:把mapper文件、ORM 注解、存储过程里的 JOIN 条件全提出来,统计a.xxx_id = b.id这类连接出现的频率,出现次数高的基本就是真实关系。我用这个方法梳理过一个完全没有外键的订单系统,二十多张表的关系基本猜对了,只有一张历史归档表判断错误,后来人工确认补上了。
5.4 课程设计和面试里被反复追问的点
如果你是在做数据库课程设计,或者准备数据库相关考试和面试,下面这几个问题被问到的概率极高,我把答题要点也一并写上。
"ER 图里的主键怎么表示?"——答:属性名加下划线,复合主键每个参与属性都要加下划线,候选键用虚线,外键在逻辑图里用 FK 标注。
"一个联系能不能有多条线连到同一个实体?"——能,这叫自反联系(递归联系),比如"员工与员工"的上下级关系,或者"课程与课程"的先修关系。自反联系建表时外键指向自己的主键,比如prerequisite_course_id指向course_id。
"多值属性和复合属性怎么处理?"——多值属性(比如一个人多个手机号)必须拆成独立表,不能塞在一列里用逗号分隔。复合属性(比如地址拆成省市区)看使用方式,需要单独查询和统计就拆列,只是展示用就整存。
"ER 图和关系模式的转换有几步?"——四步:实体转表、1:1 和 1:N 加外键、M:N 建中间表、多值属性拆表。把这句话背熟,基本能应对大部分笔试和面试。
还有一个常被追问的点是"范式"。第三范式要求非主属性不传递依赖于主键。选课记录里如果放课程名,就违反了 3NF,因为课程名依赖课程号,课程号依赖主键,形成传递依赖。图里怎么体现?就是"课程名"这个属性必须挂在课程实体上,不能挂在选课这个菱形上。这也是检查 ER 图是否规范化的一个快捷方法:看每个非主属性是不是只依赖它所属实体的主键。
6. 交付前我会走的检查清单和几个省时间的做法
一张 ER 图最终要交付给团队或者作为课程设计成果,交付前我会自己过一遍检查清单。这份清单是我被评审打回好几次之后总结出来的。
6.1 交付前的十二条自查
- 每个实体都有明确的主键,复合主键标全了下划线。
- 每条连线的两端基数都标了,没有出现两端都是 1 或者两端都是 N 却没说明的。
- 所有 M:N 联系都对应一个中间表,中间表在逻辑图里出现。
- 中间表上挂的联系属性在该在的位置,没有挂到实体上。
- 弱实体用双线矩形或明确的标识符标注,依赖联系的删除规则写清楚。
- 自反联系画出来了,并且标注了方向(上下级、先修关系)。
- 派生属性没有出现在概念图里,或者在图上标注了"派生"。
- 多值属性都拆成了独立实体或独立表。
- 命名统一:表名用单数还是复数、前缀规则、字段名风格(下划线)全库一致。
- 每个外键列都有索引(在逻辑图里标注)。
- 字符集、排序规则、存储引擎在建库层级统一声明。
- 图有版本号和最后更新时间,和当前迁移脚本版本对应。
第十二条最容易被跳过,但我认为它最重要。一个没写日期的 ER 图,三个月后就没人知道它对应哪个版本,也没人敢改。
6.2 几个能省大量时间的小技巧
第一个技巧是画图顺序倒过来。很多人从实体开始画,画到一半发现关系理不清。我的做法是先画联系,把业务里所有的"动词"列出来——选课、授课、属于、打卡、支付、退款——每个动词就是一个联系,然后问这个动词连接哪两个名词,最后才补实体的属性。这个方法能显著减少漏掉关系的情况,因为业务需求里动词的数量通常远少于名词,但关系才是容易漏的部分。
第二个技巧是给每张子图配一句业务描述。"这张图描述学生从入学到毕业的学籍流转"比"教务模块 ER 图"有用得多,评审时业务方一眼就知道该看什么,也更容易发现遗漏。
第三个技巧是保留一版手绘草稿。我习惯把最初的需求讨论草稿拍照存进项目文档,标注日期。项目后期出现"当初怎么定的"这类争议时,这份草稿往往比正式文档更有说服力,因为它记录了双方当场确认的原始信息。
第四个技巧是用真实数据抽样验证。图设计完成、表建好之后,灌一批接近真实的测试数据(比如一万条选课记录、三千个学生),跑几个典型查询看执行计划。有几个设计问题只有数据上量后才会暴露:中间表缺索引导致全表扫描、外键列选择性差、复合主键顺序不合理导致范围查询用不上索引。这些问题在空表上做测试永远发现不了,我踩过太多次,现在凡是涉及中间表的改动,都会灌数据跑一遍EXPLAIN。
我自己这些年最大的体会是,ER 图这件事上花的时间从来不会浪费。我见过为了赶进度跳过设计、两周建了四十张表的项目,上线三个月后花了两个月重构,原因就是关系设计错了导致数据对不上。也见过老老实实画了三天图、被业务方打回两次的项目,后面两年加功能都很顺,因为每次改动都能在图上找到位置。图省事和真省事,在数据库这一行往往是反义词。后面如果你要把这套流程继续往深里走,我建议的方向是补上"数据字典"和"字段级血缘"这两层,让 ER 图不只是关系图,而是能一路追到报表字段的完整链路,那时候它才真正变成一个团队的资产,而不只是某次设计的产出物。