最近给团队做内部培训,我又把一套用了很多年的教学示例库脚本翻了出来——SchoolDB。场景很简单:一个学校管理系统,只有4张表:学生、教师、课程、选课。很多人觉得写DDL语句没什么技术含量,敲几行CREATE TABLE谁不会?但真正落到这4张表上,字段类型、约束、索引、外键、字符集、引擎,几乎把数据库设计的基础知识全串起来了。这篇我直接把论4个表的DDL语句完整拆开讲,且明确是“仅有结构”,不掺数据。同时把我踩过的坑、改过的设计、为什么这么选型都交代清楚。适合刚学完SQL语法、准备动手做第一个正经项目的开发者,也适合被表结构坑过、想回头把基础补扎实的人。
1. 设计这4张表的整体思路
1.1 为什么是4张表而不是5张、6张
SchoolDB对应的业务很直观:一个学校要排课、学生要选课、老师要上课、期末要出成绩。经典教学案例一般就是这4个表,再多加一个“班级表”“专业表”都会把核心链路搅浑,对初学者来说概念负担太重。
4张表各司其职:
- 学生表:人的基本信息,是业务里的“主语”
- 教师表:授课主体,课程要挂在他下面
- 课程表:被选课的主体,承载课程号、学分、开课学期
- 选课表:学生和课程的关联,顺带记成绩和选课状态
这几个动作合在一起就是一套最小可运行的教学闭环。倒不是学校真的只需要这4张表,而是用4张表就能把“一对多”和“多对多”这两种关系讲透:老师和课程是一对多,学生和课程是多对多。等搞清楚了这两个关系,后面加班级表、加院系表都是顺手的事。
1.2 表间关系与范式权衡
先说关系。课程表里放一个teacher_id,引用教师表主键,这就是一对多。学生和课程之间没有直接归属关系,靠一张选课表作为中间表把它们关联起来,这就是多对多。选课表里同时放着学生表主键和课程表主键,一个学生可以选多门课,一门课也可以被多个学生选,是标准的桥接表设计。
再说范式。为了演示效果,我特意没有把专业、院系这些字段拆出去,而是直接作为冗余字段放在学生表和教师表里。严格按第三范式来看,专业应该拆成一张专业表,学生表只放major_id。但在教学场景里,把“专业名”直接存进学生表,避免了连表查询,读写都更直观。
做这个取舍我会先问自己三个问题:这个字段要参加统计查询吗?会被多个表共享吗?后续会变成实体表吗?如果都不会,那就可以先冗余着,不必急着为假想需求建表。这里的原则是“够用即合理”,等数据量大、专业字段出现增删改需求时再拆也不迟。这比一开始就堆出七八张表要健康得多。
1.3 建表前的环境与命名约定
这套DDL我按MySQL 8.0来写,也能兼容5.7大部分语法。原因很简单:InnoDB引擎是MySQL默认支持事务的引擎,MySQL 8.0的字符集默认utf8mb4,对中文和表情符号的支持都比5.7的老库友好得多。如果你还在用MyISAM,赶紧换掉,外键和行级锁都在InnoDB上才有。
表名我统一用单数小写,比如student而不是students。很多人争论这个,我的习惯是Java实体类对表名时单数更顺手,而且不同同事写SQL时不用纠结加不加s,团队约定比“标准答案”重要。字段名统一小写+下划线,比如course_no、teacher_id,避免驼峰风格在大小写敏感的系统上惹出麻烦。
库名就叫school_db,创建语句顺手放出来,毕竟表总不能建在半空里:
CREATE DATABASE IF NOT EXISTS school_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;字符集选utf8mb4而不是utf8mb3,是因为老版utf8不支持四字节字符,万一出现个生僻字或者表情符号,数据写入时直接报错或变乱码,这个坑我早年踩过不止一次。
2. 每张表的DDL拆解
2.1 学生表 student:主身份与学籍信息
学生表是整库的核心,其他表都绕着他转。先看建表语句:
CREATE TABLE student ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', student_no VARCHAR(20) NOT NULL COMMENT '学号', name VARCHAR(30) NOT NULL COMMENT '姓名', gender TINYINT NOT NULL DEFAULT 1 COMMENT '性别:1-男 2-女 0-未知', birth_date DATE NOT NULL COMMENT '出生日期', major VARCHAR(50) DEFAULT NULL COMMENT '专业', enrolled_year SMALLINT UNSIGNED NOT NULL COMMENT '入学年份', status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '学籍状态:1-在读 0-休学 2-毕业', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_student_no (student_no), KEY idx_major (major), KEY idx_enrolled_year (enrolled_year) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '学生信息表';这里有几个设计点我单独展开说。
先说主键。我用的自增id,目的很简单:表里不需要关心学号怎么生成、是否会有学校合并导致学号冲突,主键就是一个无意义标识符。但学号字段student_no加了UNIQUE KEY,保证业务上不能有两个学生共用学号。主键管物理标识,唯一键管业务约束,两者不冲突。新手最容易犯的错误是直接用学号当主键,我理解这种简化思路,但学号一旦出现重号、变更或格式调整,牵连所有外键引用,改起来非常痛苦。所以定位要清楚:主键只为稳定存在,学号只是“看起来像主键”的业务字段。
性别字段我用了TINYINT而不是ENUM('男','女')。虽然ENUM可读性更好,但后续枚举值增加时要改表结构,而且在某些框架的映射处理里ENUM特别别扭。用TINYINT加注释,0、1、2三个值已经覆盖常见情况,程序端用常量或枚举类去对应,数据库层只负责存数字。这不是唯一答案,但对我来说是改动成本最低的方案。
birth_date用DATE类型,这是我想重点强调的。有的人习惯用VARCHAR(10)存“1990-01-01”,看着没毛病,但排序、范围查询、算年龄全都得手动转格式,数据库的日期函数全部失效。日期时间就该用日期时间类型,这个思维我在很多项目里反复纠正过,宁可写SQL时多花几秒做格式化,也别为了“显示方便”牺牲存储语义。
入学年份是SMALLINT UNSIGNED。年份可以用字符串,也可以用INT,我用SMALLINT是因为它的范围0到65535,存年份绰绰有余,比INT少两个字节,同时保留了数值比较能力。如果你迷信“反正差不了几个字节”,那这个思维放在千万级表上会被打脸。字段类型能小一点是一点,索引和存储都会受益。
AUTO_INCREMENT和DATETIME默认值我写成了DEFAULT CURRENT_TIMESTAMP,这样插入时不用手动给时间,MySQL自动填。更新时间的ON UPDATE CURRENT_TIMESTAMP会在任何字段更新时自动刷新,省掉业务层手工维护updated_at的重复代码。注意:ON UPDATE只在InnoDB下稳定生效,这个表引擎选了InnoDB,这个默认值才靠谱。
2.2 教师表 teacher:师资档案
教师表和学生表同构度很高,但角色定位不同。课程表要引用教师,所以教师表必须有稳定主键和教师工号约束:
CREATE TABLE teacher ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', teacher_no VARCHAR(20) NOT NULL COMMENT '工号', name VARCHAR(30) NOT NULL COMMENT '姓名', gender TINYINT NOT NULL DEFAULT 1 COMMENT '性别:1-男 2-女 0-未知', title VARCHAR(30) DEFAULT NULL COMMENT '职称', dept VARCHAR(50) DEFAULT NULL COMMENT '所属院系', phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话', email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', hire_date DATE DEFAULT NULL COMMENT '入职日期', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_teacher_no (teacher_no), KEY idx_dept (dept) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '教师信息表';teacher_no同样使用UNIQUE KEY而不是主键,道理跟学生表一致。title用来存职称,比如“教授”“讲师”,设计上我保留为普通VARCHAR。你也可以改成字典表的引用,但对教学演示来说,直接存字符串的可读性更强,也不影响后续查询。
phone和email注意都用VARCHAR,电话号码前面可能有+86这类前缀,用数值类型会把开头的0吃掉,而且涉及国际区号运算毫无意义。email看似长度不长,但留到100基本是行业习惯,不必抠得太紧。这两个字段都允许NULL,因为不是每个老师都必须登记邮箱,字段空的业务含义是“没有填写”,这个语义很重要。所有允许为空的字段都显式DEFAULT NULL,防止建表后出现隐式默认值导致的意外行为。
教师表我加了idx_dept普通索引。dept字段典型用法是“查计算机学院的老师名单”,属于高频查询条件,但区分度不算特别高。这种情况下单列索引足够,不需要复合索引,也不会走到ALL全表扫描。
2.3 课程表 course:教学单元
课程表是四张表里最容易出设计分歧的表,因为课程和老师的关系、课程自身的属性都挤在这里:
CREATE TABLE course ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', course_no VARCHAR(20) NOT NULL COMMENT '课程编号', course_name VARCHAR(60) NOT NULL COMMENT '课程名称', credit DECIMAL(2,1) NOT NULL COMMENT '学分', teacher_id INT UNSIGNED DEFAULT NULL COMMENT '授课教师ID', semester VARCHAR(20) DEFAULT NULL COMMENT '开课学期,如2025-2026-1', capacity INT UNSIGNED NOT NULL DEFAULT 100 COMMENT '人数上限', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_course_no (course_no), KEY idx_teacher_id (teacher_id), KEY idx_semester (semester), CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher (id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '课程表';credit是学分的重中之重。常见的学分有3.0、2.5、1.5,所以我用DECIMAL(2,1),整体精度2位、小数1位,最大能存9.9。有些学校出现过10学分的课,那是极端情况,真遇到了再改成DECIMAL(3,1)也不费劲。绝对不要用FLOAT存学分,浮点比较时容易冒出0.1+0.2不等于0.3的诡异问题,金额、学分这类需要精确比较的小数,一律用定点数DECIMAL。
teacher_id允许为空,DEFAULT NULL。有人会问,既然是外键,为什么不NOT NULL?因为现实中确实存在“课程已经排好、老师还没定”的过渡状态。外键列允许NULL,语义上是“还未关联”,比硬填一个假老师要诚实的多。CONSTRAINT fk_course_teacher就是外键约束,它保证你只能填进teacher表里真实存在的ID。有人为了性能故意省略外键约束,我只建议在数据量极大、对写入吞吐要求极高的系统里考虑,教学系统请老老实实加上,这是数据完整性的底线保障。
semester用的是VARCHAR(20),存“2025-2026-1”这种字符串。为什么不拆成年份和学期两个字段?因为学校内部对学期的表达已经足够规范,拆开反而增加查询拼接成本。如果你要按学期统计开课数,VARCHAR也能直接做等值匹配。但如果你想做范围查询“2023年之后开过的课”,拆成两个字段会更好。我保留字符串方案,是考虑教学过程里这个查询场景很少,轻一点是一点。
capacity默认给100,INT UNSIGNED足够。注意UNSIGNED的应用场景:任何不允许出现负数且只做加法不做减法的数值字段,都可以加UNSIGNED,相当于免费把正数范围翻倍,还能避免业务上出现负数脏数据。你能想象选课人数上限是负数吗?不能,所以加它就对了。
2.4 选课表 course_selection:核心关联
选课表可以说是全库“含金量”最高的表,多对多关系、成绩、选课状态全在这里体现:
CREATE TABLE course_selection ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', student_id INT UNSIGNED NOT NULL COMMENT '学生ID', course_id INT UNSIGNED NOT NULL COMMENT '课程ID', score DECIMAL(4,1) DEFAULT NULL COMMENT '成绩', semester VARCHAR(20) NOT NULL COMMENT '选课学期', status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态:1-正常 2-退课 3-缓考', selected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间', PRIMARY KEY (id), UNIQUE KEY uk_student_course_semester (student_id, course_id, semester), KEY idx_course_id (course_id), KEY idx_semester (semester), CONSTRAINT fk_selection_student FOREIGN KEY (student_id) REFERENCES student (id), CONSTRAINT fk_selection_course FOREIGN KEY (course_id) REFERENCES course (id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '选课记录表';这里我做了两个关键选择,逐个解释。
第一个是主键用了BIGINT UNSIGNED。选课表的记录量天然比前3张大,学生人数乘以课程数量就是它最基础的规模量级。在一个几万学生的学校里,选课记录达到百万甚至千万是常态。INT UNSIGNED上限约42亿,理论上够用,但我习惯在桥接表、流水表上直接上BIGINT,因为它不仅要承载关系,还可能是未来审计、统计的底表,多几个字节换一个“永远不用为自增上限发愁”的安心,很划算。
第二个是唯一键uk_student_course_semester。联合唯一索引把问题说得很清楚:同一个学生同一学期选同一门课,只能出现一次。这是数据库层面的防重约束,比程序里先SELECT再INSERT要可靠得多。并发场景下,两个请求同时发现“没有记录”,然后同时插入,如果不靠唯一键兜底,重复记录就产生了。有了它,第二个插入直接报错,业务层捕获异常做提示即可。顺便说一下,这个联合唯一索引本身也直接服务了“按学生和学期查选课记录”的查询,属于一箭双雕。
score用DECIMAL(4,1),理解方式跟学分一样。成绩常见到99.5、88.0,带一位小数,总位数4位,最大999.9,存百分制成绩绰绰有余,同时保留NULL表示“还没考试或没出分”。NULL在成绩这里有明确业务含义,不能为了好看填个0,那会把“0分”和“暂无成绩”混成一团。
status我设计了3个状态:正常、退课、缓考。为什么要这个字段?因为“退课”之后,唯一索引uk_student_course_semester里的那条记录到底删不删?如果直接删除,学生和课程之间的历史关系就丢了;如果不删,正常选课的学生再插一条同课程记录又会冲突。加状态字段就是最佳解:“退课”只是把status改为2,主键还在,记录还在,同课程重新选课时可以把记录更新回正常状态,或者另插一条新记录。这种设计在真实教务系统里很常见,属于用状态代替删除的典型做法。
3. 完整DDL落地与执行要点
3.1 完整DDL脚本
前面是拆开讲,如果你不想读碎料,可以直接抄这份完整脚本。注意执行顺序是由依赖关系决定的,不能乱排:
CREATE DATABASE IF NOT EXISTS school_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; USE school_db; -- 第1张:教师表(无外键依赖) CREATE TABLE teacher ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', teacher_no VARCHAR(20) NOT NULL COMMENT '工号', name VARCHAR(30) NOT NULL COMMENT '姓名', gender TINYINT NOT NULL DEFAULT 1 COMMENT '性别:1-男 2-女 0-未知', title VARCHAR(30) DEFAULT NULL COMMENT '职称', dept VARCHAR(50) DEFAULT NULL COMMENT '所属院系', phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话', email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', hire_date DATE DEFAULT NULL COMMENT '入职日期', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_teacher_no (teacher_no), KEY idx_dept (dept) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '教师信息表'; -- 第2张:学生表(无外键依赖) CREATE TABLE student ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', student_no VARCHAR(20) NOT NULL COMMENT '学号', name VARCHAR(30) NOT NULL COMMENT '姓名', gender TINYINT NOT NULL DEFAULT 1 COMMENT '性别:1-男 2-女 0-未知', birth_date DATE NOT NULL COMMENT '出生日期', major VARCHAR(50) DEFAULT NULL COMMENT '专业', enrolled_year SMALLINT UNSIGNED NOT NULL COMMENT '入学年份', status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '学籍状态:1-在读 0-休学 2-毕业', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_student_no (student_no), KEY idx_major (major), KEY idx_enrolled_year (enrolled_year) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '学生信息表'; -- 第3张:课程表(依赖教师表) CREATE TABLE course ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', course_no VARCHAR(20) NOT NULL COMMENT '课程编号', course_name VARCHAR(60) NOT NULL COMMENT '课程名称', credit DECIMAL(2,1) NOT NULL COMMENT '学分', teacher_id INT UNSIGNED DEFAULT NULL COMMENT '授课教师ID', semester VARCHAR(20) DEFAULT NULL COMMENT '开课学期,如2025-2026-1', capacity INT UNSIGNED NOT NULL DEFAULT 100 COMMENT '人数上限', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_course_no (course_no), KEY idx_teacher_id (teacher_id), KEY idx_semester (semester), CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher (id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '课程表'; -- 第4张:选课表(依赖学生表和课程表) CREATE TABLE course_selection ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', student_id INT UNSIGNED NOT NULL COMMENT '学生ID', course_id INT UNSIGNED NOT NULL COMMENT '课程ID', score DECIMAL(4,1) DEFAULT NULL COMMENT '成绩', semester VARCHAR(20) NOT NULL COMMENT '选课学期', status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态:1-正常 2-退课 3-缓考', selected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间', PRIMARY KEY (id), UNIQUE KEY uk_student_course_semester (student_id, course_id, semester), KEY idx_course_id (course_id), KEY idx_semester (semester), CONSTRAINT fk_selection_student FOREIGN KEY (student_id) REFERENCES student (id), CONSTRAINT fk_selection_course FOREIGN KEY (course_id) REFERENCES course (id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '选课记录表';执行顺序方面,首要原则是“先建被引用的表,再建引用别人的表”。teacher和student不依赖任何表,可以先建。course依赖teacher,必须等teacher建完。course_selection依赖student和course,放最后。如果你把顺序写反了,MySQL会直接报“无法创建外键约束”的错误。原因很简单:外键指向的目标表还不存在,约束当然无从谈起。
3.2 字段类型与约束的小结论
4个表建下来,有一组选型可以总结成一张速查表,以后设计类似表时直接对照参考:
| 业务数据 | 推荐类型 | 原因 |
|---|---|---|
| 主键 | INT UNSIGNED或BIGINT UNSIGNED自增 | 稳定、无业务含义、自动生成 |
| 业务编号(学号/工号/课程号) | VARCHAR(20)+唯一索引 | 可能有前导0、前缀字母,不适合数值类型 |
| 名称姓名 | VARCHAR(30)到VARCHAR(60) | 中文UTF-8下足够 |
| 性别/状态 | TINYINT+注释 | 可扩展、映射简单 |
| 日期 | DATE | 保留日期运算能力 |
| 日期时间 | DATETIME+默认当前时间 | 避免程序层手写时间 |
| 精确小数 | DECIMAL(p, s) | 避免浮点误差 |
| 关联外键 | 与被引用表主键完全同类型 | 外键类型不一致会导致建表失败 |
这张表不是我发明的什么教条,而是这些年写DDL时摔出来的实用经验。例如业务编号用VARCHAR,很多人第一反应是“学号不是数字吗,用INT不行吗?”真不行,学号可能以0开头,INT直接抹掉;学号可能包含年级前缀字母,INT直接报错。类似问题现场遇一次就记住了。
3.3 执行DDL时的环境检查
拿到脚本别急着全选一键执行,先做两个检查。
第一,确认你连进的库是对的。USE school_db;就是防止你手滑把表建到别的库里去,执行后先跑一句SELECT DATABASE();验证当前库名。
第二,确认MySQL版本。8.0以上执行没有任何问题,5.7执行时DATETIME默认值CURRENT_TIMESTAMP也支持,但如果你用的是5.5之类的老版本,多个DATETIME字段同时使用DEFAULT CURRENT_TIMESTAMP可能会被限制,需要去掉updated_at的ON UPDATE部分。执行完毕后用SHOW CREATE TABLE course;检查一下建表结果,这样能及时发现没生效的约束和默认值。
4. 常见问题与排查技巧实录
4.1 主键竟然不是业务编号,行不行
很多人第一版设计会把学号当主键,理由是这个系统里学号本来就不重复。但我想说:业务编号做唯一键完全没问题,但它和主键是两个纬度。主键是给存储引擎用的聚簇索引入口,它最好是自增的、单调的、无含义的。为什么?因为InnoDB的二级索引叶子节点还存着主键值,如果主键是一个又长又有前缀规则的学号,二级索引会变得又大又慢。学号作为主键最大的风险是:一旦学号规则变动或者出现合并学籍的场景,你要改的不是一个字段,而是所有外键引用的值。
讲个我经历过的情况:有个项目把员工工号当主键,后来公司组织调整,工号规则变了,需要给部分员工换新工号。结果所有关联表的外键都要跟着改,关联到工单、审批、绩效,团队花了两天写迁移脚本,期间业务全靠停服窗口顶着。从那以后我设计任何表都坚持“稳定ID+业务唯一键”双轨制,多一个字段,少一次大事故。
4.2 外键到底该不该加
外键约束在业内一直有争议。性能派的观点是:外键在插入、更新时都要做引用存在性检查,影响写入性能,所以干脆不加,把一致性交给应用层。我承认在超高并发写入的场景下这是合理选项,但教学库、中小业务系统必须加。原因很现实:应用层写代码难免漏判,一旦插入一个指向不存在教师的课程记录,后面查关联表只会得到一片混乱。外键是数据库给的“安全带”,日常感觉不到它的存在,但事故发生时它能拦住大量脏数据。
外键加完之后还要想清楚删除策略。我的建议是核心业务表之间默认RESTRICT,也就是被引用的行不允许直接删除。比如某门课被选课表引用,课程就不能被物理删掉,只能改状态下线。如果直接CASCADE级联删除,学生选课记录会跟着一起消失,这个损失无法接受。DDL里的外键我四个方向都没有写ON DELETE CASCADE,默认就是RESTRICT,这是有意为之。
4.3 字符集和排序规则选错的典型事故
字符集这个坑,我在早期项目里摔得很惨。当时建库用默认latin1,存中文全变成问号,数据一旦写入就再也救不回来,只能清表重导。后来统一utf8mb4之后,遇到普通中文、生僻字、表情符号都没有任何问题。
这里有个细节:排序规则也值得选对。utf8mb4_unicode_ci对比utf8mb4_general_ci更准,特别是处理西文重音字符时表现更好。两个规则在普通中文查询上的差异不大,但对排序一致性要求高的场景,unicode_ci是更稳的选择。
另外一个问题是字段级别和表级别的字符集必须一致。如果你在表定义里写了DEFAULT CHARSET=utf8mb4,但有某个字段单独CHARACTER SET latin1,写入中文时照样乱码。我建议所有CREATE TABLE语句里都显式写上字符集,宁可啰嗦一点,也不去依赖全局默认值。
4.4 用TINYINT存状态被忽视的扩展性
用TINYINT存性别和状态,经常遇到一种质疑:只有两个值,我用BIT或者BOOLEAN不就行了?这里我强烈建议别用BOOLEAN。BOOLEAN在MySQL里本质就是TINYINT(1),看似省事,却容易在代码里混淆“布尔逻辑”和“业务枚举值”。
性别和状态天然是未来可能扩展的枚举类型。比如性别可能新增“保密”等选项,状态可以从“在读/毕业”扩展到“休学/开除/交换生”。如果当初用BOOLEAN,扩展时必须先做数据迁移,把0/1改成其他数字,那才是真正的麻烦。TINYINT加注释配合程序端枚举定义,改造成本低到可以忽略,完全是为未来留了一扇窗。
4.5 关于“仅有结构”这件事想说点题外话
这4个表的DDL从头到尾只有建表语句,没带任何INSERT样例。我理解有人希望连初始数据一起给,但“仅有结构”恰恰是这个练习最精华的部分。结构定完之后,数据随时可以填;结构本身有硬伤,填充的数据越多,后期修正代价越大。建表阶段多花半小时把类型、约束、索引理清楚,比以后拿着千万行数据做ALTER TABLE要划算无数倍。
如果你现在正要动手做类似的SchoolDB,我的建议是先把这4张表照着敲一遍,然后故意写几条违反约束的SQL试试,比如插入重复学号、插入不存在的教师ID、把成绩填成负数。让数据库用报错告诉你它建这些约束是为了什么。这个过程比背十遍理论都管用。
最后再分享一个我个人的偏好:设计完表结构后,我习惯把DDL文件放进项目的版本管理里,当作数据库结构的唯一事实来源。这样每次有同事问“某张表有哪些字段”,不用去翻远程数据库,直接看这个文件就够。等这套4张表的DDL真的跑顺了,再往里面加班级表、院系表、考试表的时候,你会发现,所谓数据库设计熟练度,就是在这些不起眼的字段选择里积累出来的敏感度。