说实话,数据库设计这件事,很多刚接触后端的人把它想复杂了。一上来就考虑分库分表、读写分离、分布式事务,结果连最基础的几张表都建得七扭八歪。我之前带实习生的时候,经常让他们先写一个最简单的学校管理系统的建表SQL,也就是“SchoolDB对应的四个表的DDL——仅结构”,学生表、教师表、课程表、选课表,就这么四张。能把这几张表的字段类型、主外键关系、索引设计讲明白,数据库基础基本就过关了。
这篇文章不聊别的,就是把这套DDL从头到尾拆开揉碎。我会把所有建表语句直接贴出来,然后逐字段解释为什么这么写,外键的删除策略怎么选,索引到底解决什么问题,字符集和引擎为什么不能随意改。适合准备面试的应届生、刚入门写业务代码的后端开发,以及所有需要快速搭建一个教务类系统原型的同学。保证你照着看完,能理解背后的设计逻辑,而不是死记硬背一份SQL。
1. 先看整体:四个表的关系模型是怎么定下来的
1.1 为什么是这四个表
学校管理系统再复杂,也跳不出“人”和“事”这两个维度。学生和教师是两类核心人员,课程是核心业务对象,而选课则是连接学生和课程的业务动作。这就是四张表的意义:用最少的表覆盖最核心的教务流程。
有人会问,那班级表、院系表、成绩表、考勤表呢?都加上那就不叫“四个表”了。四表模型的价值在于它是一个可扩展的底座。班级信息可以挂在学生表的一个字段上先顶着,等业务复杂了再拆成独立表;成绩也可以先作为选课表的一个字段存着,后续再拆成绩明细。如果一开始就设计二三十张表,反而会把人劝退。
我见过很多初学者一上来就把表设计得异常复杂,各种冗余字段堆在一起,看起来“功能齐全”,实际上连最基本的范式都违反。四表设计的第一原则是克制:每个表只表达一种实体,每个字段只存储一个原子值。
1.2 主外键与表间依赖
四张表的关系并不复杂,但一定要理清楚:
- students 学生表和 course_selections 选课表:一对多,一个学生可以有多条选课记录。
- courses 课程表和 course_selections 选课表:一对多,一门课程可以被多个学生选择。
- teachers 教师表和 courses 课程表:一对多,一个教师可以承担多门课程。
也就是说,选课表在这里承担的是“中间表”角色,用来实现学生和课程之间的多对多关系。而且我在课程表里直接加了 teacher_id 外键,关联教师表,这样就能查“哪位老师教哪门课”,避免在选课表里重复存教师信息。
因为存在外键依赖,建表顺序必须严格遵守:先建不依赖外表的表,再建有外键引用的表。正确顺序是 students、teachers、courses、course_selections。如果顺序反了,MySQL 会直接报 ERROR 1215: Cannot add foreign key constraint。
我还习惯给外键约定一套命名规则。主键统一用 pk_表名_字段名,唯一键用 uk_表名_字段名,普通索引用 idx_字段名,外键用 fk_表名_引用表名。这样做的好处是后期排查问题的时候,光看索引名就知道它是干什么用的,不用一条条去翻建表语句。
-- 建表顺序:学生表 -> 教师表 -> 课程表 -> 选课表2. 四份DDL逐表拆解
2.1 students 学生表:基础信息的字段取舍
先看具体语句。
CREATE TABLE students ( student_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '学生ID,主键', student_no CHAR(10) NOT NULL COMMENT '学号,唯一', student_name VARCHAR(50) NOT NULL COMMENT '姓名', gender ENUM('M', 'F', 'x') DEFAULT 'x' COMMENT '性别:M男 F女 x未知', birth_date DATE DEFAULT NULL COMMENT '出生日期', class_name VARCHAR(50) DEFAULT NULL COMMENT '班级', phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话', email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', enrollment_year SMALLINT UNSIGNED NOT NULL COMMENT '入学年份', status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态:1在读 0离校', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (student_id), UNIQUE KEY uk_student_no (student_no), KEY idx_class (class_name), KEY idx_enrollment_year (enrollment_year) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学生信息表';这里有几个字段类型的选择非常关键。
学号我用的 CHAR(10) 而不是 VARCHAR(10)。很多人不理解,觉得 VARCHAR 省空间。但学号通常是固定长度的数字字符串,像“202400001”这种,用 CHAR 存储时定长读取效率更高,而且在等值查询场景下不会因为尾部空格产生任何歧义。如果学校学号长度有变化,再改成 VARCHAR 也不迟,但绝大多数情况定长是更优解。
gender 字段我用了 ENUM 而不是 TINYINT 或 VARCHAR。用 TINYINT 的话,1 和 2 代表什么意思还得去翻文档;用 VARCHAR 又太浪费空间。ENUM 的好处是数据字典直接写在表结构里,查询结果可读性高,底层又是整数存储,性能和空间都兼顾。
但 ENUM 也有坑,后面常见问题部分会专门讲。
status 字段用的是 TINYINT UNSIGNED,默认 1 表示在读。我见过有人用 BIT、有人用 CHAR(1) 存 'Y'/'N',都不如 TINYINT 直观。TINYINT 还能随意扩展状态值,比如 2 表示休学、3 表示毕业,比布尔类型灵活得多。
created_at 和 updated_at 这两个字段是现代建表的基本操作。DATETIME 搭配 DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE CURRENT_TIMESTAMP,插入时自动写当前时间,更新时自动刷新。这个能力依赖 MySQL 5.6 及以上版本,现在基本没有低于这个版本的环境了。
索引方面,主键自然走聚集索引,学号上建唯一索引保证不重复。class_name 和 enrollment_year 是我后加的普通索引,主要用于按班级筛选、按年份统计。索引不是越多越好,像 phone、email 这种查询频率相对较低的字段,就没必要单独建索引,避免索引维护开销超过收益。
2.2 teachers 教师表:看似相似,细节不同
CREATE TABLE teachers ( teacher_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '教师ID,主键', teacher_no CHAR(8) NOT NULL COMMENT '教师工号,唯一', teacher_name VARCHAR(50) NOT NULL COMMENT '姓名', gender ENUM('M', 'F', 'x') DEFAULT 'x' COMMENT '性别:M男 F女 x未知', title VARCHAR(30) DEFAULT NULL COMMENT '职称,如副教授', department VARCHAR(50) DEFAULT NULL COMMENT '所属院系', phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话', email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', hire_date DATE DEFAULT NULL COMMENT '入职日期', status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态:1在职 0离职', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (teacher_id), UNIQUE KEY uk_teacher_no (teacher_no), KEY idx_department (department) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教师信息表';教师表的结构和学生表高度相似,但有几个细节值得注意。
teacher_no 工号我用的 CHAR(8),因为工号通常比学号短,但同样是定长字符串。department 这个字段,很多人可能会纠结要不要单独拆一张院系表。在四表模型的范围内我选择不拆,直接用 VARCHAR 存院系名称。原因很简单:院系数量少,几乎不会产生数据一致性问题,拆表反而增加联表查询的复杂度。等真到了“一个院系要挂几十个属性”的程度,再拆不迟。
title 职称字段我留了比较大的冗余空间,VARCHAR(30)。因为国内高校职称序列并不短,“教授”、“副教授”、“讲师”、“助教”,加引号后字符长度也就那样,30 绰绰有余。但如果考虑外教、客座教授等特殊头衔,留多点没坏处。
教师们通常按院系做统计和筛选,所以 department 上建了一个普通索引。这里没有建职称索引,因为职称取值集合太小,区分度不高,索引未必能带来性能提升。
2.3 courses 课程表:承载业务逻辑的核心表
CREATE TABLE courses ( course_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '课程ID,主键', course_code VARCHAR(20) NOT NULL COMMENT '课程代码,唯一', course_name VARCHAR(100) NOT NULL COMMENT '课程名称', credit DECIMAL(3,1) UNSIGNED NOT NULL DEFAULT 0.0 COMMENT '学分', teacher_id INT UNSIGNED DEFAULT NULL COMMENT '授课教师ID,外键关联teachers.teacher_id', course_hours SMALLINT UNSIGNED DEFAULT NULL COMMENT '学时', semester VARCHAR(20) DEFAULT NULL COMMENT '开设学期,如2024-2025-1', status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (course_id), UNIQUE KEY uk_course_code (course_code), KEY idx_teacher_id (teacher_id), CONSTRAINT fk_courses_teacher FOREIGN KEY (teacher_id) REFERENCES teachers(teacher_id) ON DELETE SET NULL ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程信息表';credit 学分是 DECIMAL(3,1),这个类型选择有讲究。学分可能是 1.0、1.5、2.0、3.0,可能有小数位,但最多一位小数。DECIMAL(3,1) 能存最大 99.9,足够覆盖绝大多数课程的学分范围。用 DECIMAL 而不是 FLOAT 或 DOUBLE,是为了精确计算,浮点数会有精度损失,涉及成绩、学分这种数据绝对不能用浮点存。
course_code 课程代码是一个很有用的唯一标识。比如“CS101”、“MATH202”。它和自增主键 course_id 是不同的东西:主键是给数据库用的代理键,与业务无关;课程代码是给业务人员看的自然键。两张表都保留,既有自增主键的稳定性和执行效率,又有课程代码的直观性。
teacher_id 外键这里有一个重要的设计决策:我把它设为 DEFAULT NULL,删除策略用 ON DELETE SET NULL。
为什么要 SET NULL 而不是 CASCADE?教师离职了,课程不应该被删除,课程历史记录需要保留。教师信息没了,就把课程表里的 teacher_id 置为 NULL,相当于“该课程暂无对应教师”,不影响课程本身的完整性。但如果选课表里的学生信息被删除,对应的选课记录就没意义了,所以选课表用 CASCADE。
update 级联用 ON UPDATE CASCADE,意思是如果教师表的 teacher_id 更新了,课程表里引用它的字段也自动跟着更新。这样就可以避免因为主键变动导致数据挂空。
course_hours 学时用的是 SMALLINT UNSIGNED,最大能存 65535,课时数不可能超过这个范围,而且比 INT 省 2 个字节。
2.4 course_selections 选课表:中间表的设计精髓
CREATE TABLE course_selections ( selection_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '选课记录ID,主键', student_id INT UNSIGNED NOT NULL COMMENT '学生ID,外键关联students.student_id', course_id INT UNSIGNED NOT NULL COMMENT '课程ID,外键关联courses.course_id', score DECIMAL(5,2) DEFAULT NULL COMMENT '考试成绩,100分制', selected_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间', status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态:1已选 0退选', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (selection_id), UNIQUE KEY uk_student_course (student_id, course_id), KEY idx_course_id (course_id), CONSTRAINT fk_cs_student FOREIGN KEY (student_id) REFERENCES students(student_id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_cs_course FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学生选课记录表';这张表是整个四表模型中最见功力的一张,很多人在这里犯错。
首先要说的是,既然 (student_id, course_id) 已经有了唯一索引,为什么还要单独搞一个自增主键 selection_id?直接拿这两个字段当联合主键不行吗?
联合主键在功能上没问题,但有一个实际痛点:成绩表、考试表后续要关联选课记录时,如果把两个字段都装进去,关联条件会变得很长。一个自增单主键就可以作为被引用方,简化后续扩展。而且 InnoDB 的聚集索引就是主键,如果使用两个大字段做联合主键,所有二级索引都要携带这两个字段,索引体积偏大。用一个 INT 自增主键是更经济的方案。
score 成绩字段用 DECIMAL(5,2),虽然四表“仅有结构”,但这个字段是为了表达选课和成绩的绑定关系。单科成绩最高 100 分,DECIMAL(5,2) 表示最多 3 位整数和 2 位小数,足够。允许 NULL 表示尚未考试。
UNIQUE KEY uk_student_course (student_id, course_id) 是防重选课的关键。数据库层面上禁止同一学生重复选同一门课程,无论应用层代码写没写判断,这里都兜住了。这是数据完整性设计里最典型的“数据库防线”。
这个唯一索引同时还能充当查询优化索引。业务里最常见的查询就是“某个学生选了哪些课”,查询条件是 WHERE student_id = ?,正好命中这个复合索引的最左前缀。所以这个唯一索引不是额外开销,它一箭双雕。
两张外键表都用了 ON DELETE CASCADE:学生退学,选课记录一起清掉;课程取消,选课记录也没了。这是合理的,因为选课记录的生命周期完全依附于学生和课程这两个主实体。
3. 建库到执行:实操过程与细节
3.1 建库语句与字符集选择
表建好了,但库这一层的配置也不能马虎。完整的初始化语句通常长这样:
CREATE DATABASE IF NOT EXISTS school_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; USE school_db;为什么用 utf8mb4 而不是 utf8?这是经典问题。MySQL 里的 utf8 实际上是 utf8mb3,只支持基本多语言平面,存 emoji 和部分生僻字会报错或变成乱码。utf8mb4 是 utf8 的超集,兼容完整的 Unicode,能存 emoji、生僻汉字。项目里只要涉及用户输入,就该无脑选 utf8mb4,字符集兼容性的坑能在源头少一大半。
Collation 排序规则,我用的 utf8mb4_unicode_ci。这个排序规则基于 Unicode 排序算法,对各国语言字符的排序更准确。虽然比 utf8mb4_general_ci 稍微慢一点点,但对现代硬件来说这点性能差异可以忽略。
在指定表 SEE 语句里,我也显式写了 ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci。写 DDL 一定要显式,不要依赖 MySQL 全局默认值。万一哪天全局变量被改成别的,你的表结构就会在不知情的情况下发生变化。DDL 是会影响生产环境稳定性的,显式声明所有关键选项是基本功。
3.2 引擎 InnoDB 与外键约束
为什么表引擎选择 InnoDB?这里必须把外键约束的关系讲透。MySQL 的老引擎 MyISAM 不支持外键约束、不支持事务、不支持行级锁。如果你用了 MyISAM,写再漂亮的外键定义,MySQL 也会故意忽略它,根本不会生效。
InnoDB 是 MySQL 8.0 的默认引擎,支持 ACID 事务、行级锁和崩溃恢复。学校系统这种典型的事务密集型业务,比如选课时同时扣减名额、记录选课日志,必须保证要么全部成功要么全部回滚。没有事务,选课到一半断电,数据就花了。
外键约束属于数据库层面的“最后一道防线”。有人觉得外键会影响性能,干脆在应用层做逻辑外键,表结构里根本不写 CONSTRAINT。这种方案在超高并发场景下有道理,但对绝大多数中小型系统来说,外键带来的数据一致性保障远大于性能损耗。我的建议是:先用数据库外键保证不出脏数据,等真正成为瓶颈时再考虑去掉。
3.3 执行顺序与验证方法
DDL 的执行顺序已经强调过了:先 students、teachers,再 courses,最后 course_selections。为了确保一次性成功,除了顺序,还要注意每一个字段类型和外键字段类型完全一致,比如 students.student_id 是 INT UNSIGNED,那 course_selections.student_id 就必须也是 INT UNSIGNED,少一个 UNSIGNED 都会导致外键创建失败。
建完表之后,一定要做验证,别以为没有报错就完事了。在 MySQL 8.0 中可以用下面几条命令:
SHOW CREATE TABLE students\G SHOW CREATE TABLE course_selections\G SELECT TABLE_NAME, ENGINE, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'school_db';SHOW CREATE TABLE 能把你实际执行成功后的表结构完整打出来,包括各种默认值、注释、约束。用它来检查有没有字段定义被 MySQL 静默修改。information_schema 里能查出每张表的引擎和字符集,确认建表语句里的配置真的生效了。我见过有人建表后不验证,结果字段类型被截断、注释丢失,过了几个月才发现,那时候已经积累了生产数据,改起来非常痛苦。
4. 常见问题与排查实录
4.1 外键创建失败:ERROR 1215
这是所有 DDL 执行时最常遇到的报错,没有之一。完整报错信息是 Cannot add foreign key constraint。根据我多年实操经验,原因基本逃不出这几类:
| 可能原因 | 排查方法 |
|---|---|
| 被引用字段不是主键或唯一键 | 查被引用表结构,确认字段有 PRIMARY KEY 或 UNIQUE KEY |
| 字段类型不一致 | 对比两表字段类型,特别注意 UNSIGNED、长度、字符集 |
| 引擎不是 InnoDB | 检查两表 ENGINE 字段,MyISAM 不支持外键 |
| 字符集或排序规则不一致 | 检查表级和字段级的 CHARSET 和 COLLATE 是否相同 |
| 被引用表里存在不满足约束的数据 | 比如外键字段的值在被引用表中找不到,需要先清理数据 |
我自己遇到过最隐蔽的一种:两个字段都是 INT,但一个带 UNSIGNED,一个不带。在 MySQL 里,INT 和 INT UNSIGNED 被认为是不同类型,外键直接报错。遇到 1215 不要慌,按表格顺序排查,90% 能在两分钟内定位问题。
4.2 时间字段默认值报错
如果用的是 MySQL 5.5 及更早版本,会碰到 DATETIME 不支持 DEFAULT CURRENT_TIMESTAMP 的问题。报错信息类似 Incorrect table definition; there can be only one TIMESTAMP column with CURRENT_TIMESTAMP in DEFAULT or ON UPDATE clause。
MySQL 5.6 开始 DATETIME 才支持默认当前时间。现在的数据库基本都是 5.7 或 8.0,这个问题不大。但如果是在老系统上迁移,就得注意把时间字段改成 TIMESTAMP 或由应用层写入时间。我在 DDL 中统一用 DATETIME,范围比 TIMESTAMP 大得多,不会出现 2038 年问题。
另一个关于时间戳的细节是:MySQL 8.0.19 之后,DATETIME 支持小数秒,比如 DATETIME(3)。如果业务需要毫秒级精度,可以在声明时加上精度参数,但学校管理系统没有这个必要,默认精度反而节省空间。
4.3 ENUM 的隐藏坑
gender 字段用了 ENUM,但在 MySQL 的严格模式(默认开启)下,如果插入 'Male' 这种不在枚举列表里的值,会直接报错 Data truncated for column 'gender'。不算坏事儿,至少能提醒你数据有问题。但如果关闭了严格模式,MySQL 会静默地把它改成空字符串或 0,数据就这样脏了。
另一个 ENUM 的痛点是修改枚举列表。ALTER TABLE students MODIFY COLUMN gender ENUM('M','F','x','unknown'),在数据量大的时候会锁表,而且需要重建表。相比之下,TINYINT 加一张字典表,扩展性会更好。所以 ENUM 适合取值集合稳定、几乎不变化的字段。性别是典型场景,但如果你预感到枚举值会频繁变动,建议慎用 ENUM。
我个人的取舍是:性别这种稳定字段用 ENUM,状态、类型这种可能扩展的字段用 TINYINT 加注释。这样兼顾读写的直观性和未来扩展的灵活性。
4.4 索引设计中的性能陷阱
复合索引和单列索引的选择是一个很容易出错的地方。我在选课表里建了 UNIQUE KEY uk_student_course (student_id, course_id),这个复合索引能同时服务于两个查询方向:查“某个学生选了哪些课”和(配合第二个索引)查“某门课被哪些学生选了”。
但要注意复合索引的最左前缀原则。如果查询条件是 WHERE course_id = ? AND student_id = ?,只要不改变两个字段的先后顺序,MySQL 优化器也能用上这个索引。真正要命的是只查 course_id 而不带 student_id,此时 uk_student_course 完全无法使用。所以我又补了一个 KEY idx_course_id (course_id),专门给面向课程的查询用。
4.5 表注释与字段注释别偷懒
很多新手图省事,不写 COMMENT。第一天可能还记得每个字段什么意思,一个月后再看,完全想不起来。这就像代码不写注释,自己都维护不了。项目里切换人维护时,没有注释的表结构就是灾难。
COMMENT 除了给人看,还能被工具自动读取生成接口文档、数据字典。在我的工作经验里,加注释和补文档往往是项目后期最耗精力的环节,前面写 DDL 时顺手带上,后面至少省一半功夫。四张表的字段不算多,写清注释是性价比极高的一件事。
5. 建完这四张表之后,还能怎么扩展
这套四表结构虽然简单,但扩展路径其实是清晰的。
如果你要给系统加上考试维度,可以在 course_selections 基础上增加 exam_records 表,主键自增,外加 student_id、course_id、exam_date、score 字段,逻辑完全一致。你要加教材管理,可以在 courses 表上增加 textbook 字段,或者拆一个 book_infos 表再跟 courses 关联。你要做毕业审核,就是在 students 表上增加 graduate_date,或者再建一个毕业要求配置表。四表模型的价值不在于一劳永逸,而在于它把基础关系搭得足够稳,后续怎么加桌子都有抓手。
再讲一个我没写进 DDL 但实际上很重要的点:软删除。现在很多系统不喜欢物理 DELETE,而是用一个 is_deleted 字段标记删除。这套四表结构如果要做软删除,建议在每个表上增加 is_deleted TINYINT(1) NOT NULL DEFAULT 0,查询时统一带上 WHERE is_deleted = 0。但要注意,唯一索引要考虑软删除的影响:学号这一行如果被软删了,另一个同学能不能再用这个学号?这些业务层面的问题没有统一答案,完全取决于你的规则,但数据结构上建议提前留出这个位置。
写在实际创建之前,我还想补一句。我以前带团队时,要求所有 DDL 必须走版本控制,不能让人直接在数据库上执行一条改动就不管了。四张表的建表脚本也好,后面的 ALTER TABLE 也好,都放到 Git 仓库里,注释写明变更原因。这样出了问题才能溯源。这个习惯比任何教科书上的范式理论都更能在实战里救你命。数据库结构是软件的骨架,骨架歪了,后面长出来的全是畸形的肉。你可以直接把这四张表的 DDL 抄走用,但更希望你在动手建下一个库的时候,能想起这里面的每一个选择为什么这么做。