☰
数据库第二次作业复盘:从E-R设计到死锁排查的MySQL实战
2026/10/7 17:17:03 网站建设 项目流程

1. 拿到"数据库第二次作业"之后,我重新理解了这门课

坦白说,看到作业标题的瞬间,我以为又是老师随手布置的一次增删改查练习——毕竟第一次作业就是建几张表、插入几行数据,然后写几条简单的SELECT。但当我登录远程实验平台,看到完整的作业要求时,我意识到这次完全不是一个量级:它要求我们在一周内独立完成一个贴近真实业务场景的数据库设计与实现,涵盖从E-R模型设计、建库建表、数据导入导出,到事务并发控制、数据库死锁排查、索引调优、视图创建,甚至还要用Python脚本模拟并发访问来验证隔离级别对数据一致性的影响。

这个作业几乎把数据库课程的半本教材压缩在了一个项目里。但事后回看,正是这种"压缩式"的安排,让我第一次把教科书上的概念和几十上百行的实际操作真正串联起来。这篇博客没有任何讲道理的成分,纯粹是我做完这次作业之后的完整复盘:题目怎么拆解、每一步怎么做、中途踩了什么坑、遇到死锁和并发脏读时我的排查链路是什么,全都在这里。如果你也正在做类似的数据库作业,或者是初学MySQL/SQLite想找一个能直接照着做的完整案例,这篇内容应该能帮你省下不少绕圈子的时间。

需要先说明的是,我这次作业的主要环境是MySQL 8.0,部分并发测试用了SQLite来对比(因为SQLite部署成本低,适合快速验证事务行为),后面涉及增删改查、索引、触发器、视图的代码都有完整的可执行语句。因为每个人的作业要求可能有差异,我只讲一套"通用性最强、经得起推敲"的做法,而不是某个老师出的某道独占题目。

2. 题目拆解与整体方案选型:先别急着写SQL

说实话,我见过太多同学一拿到作业就开始敲CREATE TABLE,结果做到一半发现要么字段类型不对、要么表关系缺失、要么数据导入格式完全对不上。数据库作业和写业务代码一样,最关键的一步不是动手,而是把题目要求重新翻译成数据结构。

第二次数作业里隐藏的核心需求其实汇总为四块:

  1. 设计层面——需要根据需求画出E-R图,建立合理的关系模式,完成规范化处理。
  2. 实现层面——建库建表、增删改查、索引、视图、触发器/存储过程、用户权限管理。
  3. 数据层面——批量数据导入导出、Excel/CSV与数据库之间的往返同步。
  4. 并发与调优层面——事务隔离级别、数据库并发锁、数据库死锁、连接池、查询性能分析。

我把作业要求逐条拆解后,决定采用MySQL作为主力数据库,理由非常简单:MySQL是目前高校和企业使用范围最广的关系型数据库之一,它的事务、锁机制、索引实现都能在真实环境中找到对应场景。SQLite则被我拿来当"对照组",因为它的锁粒度较粗、并发行为相对直接,能让学生在对比中更快理解隔离级别的差异。另外我从始至终坚持"每个表都设置主键、所有外键必须显式声明、所有字符集统一utf8mb4"这三个基本原则,后面事实也证明,这些约束帮我避免了好几次因隐式类型转换和编码不一致导致的数据错乱问题。

2.1 E-R设计与关系模式:我踩过的最远的一弯

很多同学画E-R图时就犯了一个错误:把"用户表""订单表""商品表"画得极其复杂,恨不得把每一个业务字段都塞进实体里。但实际做作业时,我发现自己最初最大的问题不是实体太多,而是关系模式的冗余分散。

我拿到的模拟场景是"学生选课系统",涉及学生、课程、教师、选课记录四个核心实体。最初我设计了五张表:student、course、teacher、student_course、course_teacher。看起来合理,但做完之后发现course_teacher这张表其实是多余的一对一个关系,完全可以放到course表里加一个teacher_id外键。这样一张表少做了,后续所有联表查询的JOIN条件也少了一层。

这个教训很直接:画完E-R图之后,一定要对照范式的定义做一轮手动审核。我的审核顺序是:

  • 是否存在重复组或数组字段(违反第一范式)?比如把多个教师ID放一个字段里,直接拆表。
  • 是否存在部分依赖(违反第二范式)?比如选课表里放课程名称,名称只依赖课程ID而不依赖学生ID。
  • 是否存在传递依赖(违反第三范式)?比如学生表里存班级名称,而班级名称由班级编号唯一的决定。

实际项目里大家并不会强求每一张表都达到BCNF,但至少第三范式是最基本的底线,否则数据更新时会产生非常隐蔽的异常。

2.2 建库建表的实操细节:从字符集到存储引擎

建库语句我最终长这样:

CREATE DATABASE IF NOT EXISTS school_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;

这里特别提一下utf8mb4。MySQL的utf8字符集最多支持3个字节的字符,而emoji和一些生僻汉字需要4字节,作业里如果某天往数据库写入了一个表情符号(用户昵称真的什么都可能输),就会直接报"Incorrect string value"错误。utf8mb4才是完整的Unicode支持,这是我在一次实际导入之前被真实教育过的。

建表的时候我用了InnoDB引擎,不用MyISAM。老师如果不强制要求,IMyISAM尽量不要碰,因为MyISAM不支持事务、不支持外键、只支持表级锁。对于作业里"并发访问一致性和死锁"的部分,没有事务能力就没有任何可玩的空间,InnoDB的行级锁和MVCC才是这些操作的根基。核心表结构示例:

CREATE TABLE student ( student_id VARCHAR(20) PRIMARY KEY, student_name VARCHAR(50) NOT NULL, gender CHAR(1) DEFAULT 'M', birth_date DATE, major VARCHAR(100), phone VARCHAR(20), INDEX idx_student_major (major) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE course ( course_id VARCHAR(20) PRIMARY KEY, course_name VARCHAR(100) NOT NULL, credit DECIMAL(3,1), teacher_id VARCHAR(20), CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE student_course ( id INT AUTO_INCREMENT PRIMARY KEY, student_id VARCHAR(20) NOT NULL, course_id VARCHAR(20) NOT NULL, score DECIMAL(5,2), enroll_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id), CONSTRAINT fk_sc_student FOREIGN KEY (student_id) REFERENCES student(student_id) ON DELETE CASCADE, CONSTRAINT fk_sc_course FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里我刻意对student_course表加了联合唯一索引。为什么?因为真实业务里同一个学生不能重复选修同一门课,如果应用层不判重、数据库又不做约束,后面做数据导入时两条一模一样的选课记录很可能把后续的成绩统计搞坏。这个联合唯一索引,是作业验收时一个加分细节。

2.3 为什么我选择用脚本生成测试数据而不是手动插50行

真实作业里很多同学手敲INSERT,几十行还能接受,但一旦导入几千行就会彻底崩溃。而且手工数据很难构造出符合真实分布的脏数据形态:比如成绩的边界值(0分、100分)、重复选课记录、超大ID、空字符串与NULL的混用。

我这次采用了一个更稳妥的方案:直接用Python脚本生成CSV文件,然后通过MySQL的LOAD DATA INFILE导入。因为作业环境不一定允许直接访问本机文件路径,我建议也可以使用Source命令执行.sql脚本,或者通过Navicat/DBeaver的导入向导完成。我个人倾向于CSV+LOAD DATA,因为它是纯文本操作,不需要依赖于图形界面,遇到乱码问题也更方便定位。生成脚本的核心就一行逻辑:

for i in range(1000): sid = f"S{20240000 + i}" name = f"student_{i}" gender = random.choice(["M", "F"]) major = random.choice(MAJORS) print(f"{sid},{name},{gender},{major}")

另一个要点是导入前先用小批次验证结构。我先把表结构建好、插入5行样例数据,然后全字段SELECT检查一遍,确认中文没乱码、日期字段格式正确、外键都能关联上,再执行大批量导入。这比一口气导完再查错高效得多——如果1000行里有几十行因为日期格式错误被跳过,回头定位比想象中麻烦得多。

3. 数据库增删改查:看似简单,实际上精度决定成败

第二个大头就是基础操作。可能有人会觉得,四条SQL有什么好说的?但这次作业加了一堆约束条件,每一条都在考验对SQL语义的精确理解。

3.1 每类操作对应的"隐藏考点"

INSERT考点在两种场景:一是批量多行插入的语法;二是INSERT ... ON DUPLICATE KEY UPDATE这样的幂等写法。拿选课表来说,如果程序里已经提前做了判重,直接INSERT就行;如果并发场景下两个请求同时插入了同一条选课记录,联合唯一索引会让其中一个失败并返回Duplicate entry错误,但现实中更优雅的做法是改成:

INSERT INTO student_course (student_id, course_id, score) VALUES ('S20240001', 'C001', 88) ON DUPLICATE KEY UPDATE score = VALUES(score);

UPDATE经常被忽略的点是范围条件和安全模式。MySQL客户端默认开启safe-update模式,如果UPDATE或DELETE不带主键条件,会直接报错拒绝执行。作业里想模拟"批量更新某专业学生成绩",最稳妥的写法是:

UPDATE student_course sc JOIN student s ON sc.student_id = s.student_id SET sc.score = sc.score + 5 WHERE s.major = '计算机科学';

DELETE的考点更狠——级联删除和外键约束的相互作用。我在student表上声明了ON DELETE CASCADE,这下删一个学生,选课记录也会自动删除,天然防止了"孤儿记录"的产生。但如果是软删除场景(保留数据但标记不可见),就需要加一个is_deleted字段:

ALTER TABLE student ADD COLUMN is_deleted TINYINT NOT NULL DEFAULT 0;

SELECT的排列组合太多,这里只提一个在作业里很加分的操作:用多层聚合加窗口函数做"各科成绩排名",比如找出每门课分数前3名的学生。MySQL 8.0以上支持窗口函数,这个在旧版可能要写一堆子查询关联,新版一条语句解决问题:

SELECT course_name, student_name, score, RANK() OVER (PARTITION BY course_id ORDER BY score DESC) AS rk FROM student_course sc JOIN course c ON sc.course_id = c.course_id JOIN student s ON sc.student_id = s.student_id HAVING rk <= 3;

为什么说这个点容易拿分?因为作业要求里如果一句带过"并尝试分析每个课程的成绩排名",用RANK()能写出的结果在观感和性能上,都比嵌套子查询清爽得多。

3.2 视图与索引:一个是为了方便,一个是为了命

视图这个东西,很多同学以为就是"保存了一条SELECT语句",实际操作后我发现它比想象中有用得多。作业我创建了学生成绩总评视图、教师授课工作量视图,以及一个"学生不及格科目清单视图"。视图的好处在于:下游的报表SQL变短了,权限控制也更灵活——我可以把一个视图授权给某个模拟账号,该账号只能看不及格学生的数据,核心表完全不暴露。

创建视图时千万注意,视图里尽量不要放ORDER BY和一堆冗余聚合,逻辑上视图更像一个"带名字的子查询",它的核心价值是可复用和安全性。下面是我印象深刻的一个案例:

CREATE VIEW v_failed_students AS SELECT s.student_id, s.student_name, c.course_name, sc.score FROM student_course sc JOIN student s ON sc.student_id = s.student_id JOIN course c ON sc.course_id = c.course_id WHERE sc.score < 60 WITH CHECK OPTION;

关于WITH CHECK OPTION,它意味着通过视图做数据修改时必须满足视图定义的条件,这个细节往往被忽略,但它是数据库视图最安全的使用习惯。

索引部分,我没有只停留在"老师让建索引我就建"这个程度。我更关心的是索引为什么能加速。InnoDB的索引本质是B+树,主键索引的叶子节点存整行数据,二级索引的叶子节点存主键值。所以一张表如果没有主键,InnoDB会隐式生成内部列,那所有基于二级索引的查询都会多一次回表。这就是我坚持每个业务表都要有明确主键的原因。

有一个测试让我印象很深:在没有索引的字段上执行关联查询,比如WHERE enroll_time > '2024-09-01',EXPLAIN的结果显示type=ALL,也就是全表扫描,rows估计值动辄几千。加上一个普通索引之后,type变成了range或者ref,rows降了一个数量级。但更值得反思的是另一件事:过多的索引并不一定是好事。为了做性能演示,我对student_course表一次性加了五个单列索引,结果导致批量INSERT的耗时反而上升了不少,因为每插入一行,所有索引的B+树都要同步维护。这让我真正理解了"索引加快查询、拖慢写入"这个老生常谈的道理。

4. 并发、锁与死锁:作业中最有生产价值的那部分

如果作业只是做到"增删改查+索引",它顶多是把SQL技能练了一遍,但谈不上理解数据库内核。这第二次数作业最精华的部分,在于它强制要求处理并发访问下的一致性、数据库并发锁和数据库死锁。这块是我花了最多时间摸索,也最想写出来分享的。

4.1 事务隔离级别到底解决什么问题

我直接用一个实验来说明。先设置会话A和会话B,隔离级别默认是REPEATABLE READ(MySQL的默认),然后在A会话里开启事务,查询学生王五的成绩:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT score FROM student_course WHERE student_id = 'S20240005';

同时B会话执行:

UPDATE student_course SET score = 100 WHERE student_id = 'S20240005'; COMMIT;

在REPEATABLE READ下,A会话再次执行同样的SELECT,看到的结果仍然是旧值。这是因为InnoDB的MVCC机制生成了事务开始时的快照,读操作读的是旧版本数据,不受后来提交的影响。这是教科书上"可重复读"隔离级别的含义。而不加BEGIN直接跑SELECT,那就是自动提交,每次语句都是独立事务,每次看到的就是最新值。

当作业要求用两个进程模拟"抢同一个课程名额"的场景时,就出现了经典问题:两个事务同时读到剩余名额为3,都执行了减法,然后都提交,数据库最终剩余名额是2,而不是理论上的1。这个丢失更新问题,仅在隔离级别为READ COMMITTED时如果配合悲观锁或乐观锁处理也能解决。在InnoDB里用SELECT ... FOR UPDATE就能把行锁拿到手,另一个事务就会阻塞等待:

START TRANSACTION; SELECT capacity FROM course WHERE course_id = 'C001' FOR UPDATE; -- 检查容量,若大于0,插入选课记录并UPDATE capacity减1 INSERT INTO student_course ... UPDATE course SET capacity = capacity - 1 WHERE course_id = 'C001'; COMMIT;

FOR UPDATE是行级排他锁。作业里如果没有这一行,并发模拟下大概率会出现超售或者最后插入失败。但加上之后,我在并发模拟中看到的事务成功序列就正常了。这里有个细节值得注意:FOR UPDATE条件一定要能命中索引,如果WHERE条件无法用索引,InnoDB会对大量行加锁,极端情况下造成锁表,性能和并发都会崩。

4.2 数据库死锁的完整排查链路

死锁部分是整个作业里我卡得最久的地方。为了让程序主动触发死锁,我故意写了一个交换函数:

-- 事务1 UPDATE student SET major = '软件工程' WHERE student_id = 'S001'; UPDATE student SET major = '人工智能' WHERE student_id = 'S002'; COMMIT; -- 事务2 UPDATE student SET major = '大数据' WHERE student_id = 'S002'; UPDATE student SET major = '网络安全' WHERE student_id = 'S001'; COMMIT;

如果两个事务并发执行,事务1锁住了S001想要S002,事务2锁住了S002想要S001,两边互不相让,InnoDB会检测到死锁并让其中一个事务回滚,并抛出如下错误:

Deadlock found when trying to get lock; try restarting transaction

但作业要求不只是"看到报错",而是要把排查链路走完。我总结出来的排查步骤如下:

第一步,先打开InnoDB状态输出:

SHOW ENGINE INNODB STATUS\G

在输出内容里找到LATEST DETECTED DEADLOCK段落,里面会列出两个事务正在等待的锁和持有锁的表/行信息。这里能看到事务1的WAITING FOR是S002的X锁,事务2的WAITING FOR是S001的X锁,这就是死锁环路的直接证据。

第二步,用SHOW PROCESSLIST看当前所有连接的运行状态,确认哪些事务处于SLEEP还是RUNNING。

第三步,检查隔离级别和正在执行的具体SQL。死锁和隔离级别密切相关,比如在READ COMMITTED下,由于间隙锁减少,死锁概率会下降,但并发控制仍然需要FOR UPDATE的配合。如果作业里允许调整隔离级别,可以在README里记录为"在READ COMMITTED下重复实验,死锁出现的频率明显降低,但丢失更新的风险提升"。这样写进实验报告才叫有深度。

第四步,真正的解法不只是靠数据库回滚降级。合理的办法是让所有并发事务按照同一个顺序访问资源,比如都先更新student_id=‘S001’再更新S002,就会变成串行化,死锁不再出现。这在生产环境里就是"资源访问顺序一致性"原则,虽然不能完全杜绝死锁,但能显著降低概率。

4.3 MySQL连接池:为什么必须考虑数据库连接复用

热搜词里反复出现"mysql的数据库连接池",这确实是个很接近生产套路的概念。作业里如果没要求用连接池,那直接用Python的pymysql连接,模拟100个并发请求,可能还未输出结果,数据库连接数就已经用完了,而且频繁建立和销毁连接会让响应时间大幅增加。我的处理方式是用DBUtils里的PooledDB来管理连接池:

from dbutils.pooled_db import PooledDB import pymysql pool = PooledDB( creator=pymysql, maxconnections=20, mincached=2, maxcached=5, blocking=True, host='localhost', user='root', password='123456', database='school_db', charset='utf8mb4' )

这样做之后,100个并发请求共用最多20个连接,而不是为每个请求新建连接,数据库端再也不会出现too many connections。这个点直接对接生产环境中的常见故障,老师看到会知道你已经超越了课程基础作业的范畴。

5. SQLite、Excel导入导出与数据库同步工具

5.1 SQLite做对比实验的意义在哪里

作业要求里允许选用SQLite做的小数据库实验,我觉得这是个不错的选择,但前提是你要清楚它和MySQL的本质区别。SQLite本质上是一个嵌入式的文件型数据库,整个库就是一个文件,它不支持网络访问,没有独立的数据库服务进程。它的锁粒度非常粗,写事务需要持有整个数据库文件的锁,多线程并发写时会频繁报database is locked。

我用它来做的实验是:导入一份300MB左右的CSV数据(做成了约80万行的营业流水表),验证单行查询和范围查询的速度差异。在SQLite里建索引和不建索引的差距特别直观,同样的模糊查询,没有索引跑了0.8秒,有索引只花0.02秒,这让"索引为什么有用"变得更可信。重要的是,不要在作业里把SQLite和MySQL混为一谈,要明确说明它们面向的场景不同,SQLite适合本地单机的小型应用,不适合高并发在线业务。

5.2 Excel导入数据库的两种姿势

"excel导入数据库"这个热搜词背后,是大家在作业里最关注的"怎么把Excel表导入MySQL"。我试过两种方式:

方式一:用Navicat/DBeaver的图形化导入向导。选中表后右键-导入向导,选择Excel文件,映射每一列的字段名,然后开始导入。优点是很直观,缺点是Excel文件的表头如果和数据库列名不一致,导出前必须手工调整,非常费时间。

方式二:先把Excel另存为CSV,然后通过LOAD DATA INFILE完成导入。推荐这种方式,因为做作业和日常迁移场景中,CSV是最标准的文本交换格式,加载顺序、字段分隔符、编码格式都完全可控:

LOAD DATA INFILE '/tmp/students.csv' INTO TABLE student FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (student_id, student_name, gender, birth_date, major, phone);

这里有两个绕不过去的坑。第一个坑是字符集乱码:如果CSV文件是Excel默认的GBK/GB2312编码,导入MySQL的utf8mb4表就会乱。最快捷的解决办法是用Notepad++或VS Code把CSV转为UTF-8编码,再执行LOAD DATA。第二种方式则是在LOAD DATA语法中指定字符集,比如CHARACTER SET utf8mb4,但前提是文件本身已经被转成utf-8。第二个坑是日期格式,Excel里的日期是形如2024/9/1的格式,而MySQL接受的是2024-09-01或2024-09-01 00:00:00。所以我一般在生成CSV之前,先在Excel里把日期列设成文本格式并替换成标准格式,这能避免一半以上的导入错误。

5.3 数据库同步软件和数据迁移的通用思路

热搜词中出现"数据库同步软件",可能需要我在这里讲一个贴近生产但不算太超纲的内容。做作业时,如果你的同事/队友各自改了一个库的结构,需要同步表结构;或者是生产环境有主从架构,需要把主库的变更同步到从库。真实场景下一般会用到mysqldump(逻辑备份工具)、DataX(阿里开源的数据同步框架)、或者MySQL主从复制。

作为课程作业,最安全的一个同步方案是用mysqldump导出结构+数据,再导入目标库:

mysqldump -uroot -p --single-transaction --default-character-set=utf8mb4 school_db > school_db_backup.sql mysql -uroot -p school_db_new < school_db_backup.sql

--single-transaction非常重要,它通过InnoDB的MVCC机制在导出时获得一致性快照,不会锁表,也不会影响线上业务。如果不加这个参数,导出期间表会被锁住,这在真实数据库备份里是不可接受的。作业如果涉及"把本机的库同步到服务器上",这一条命令能够应对。

6. 从作业走向实战:关于数据库设计的最后几点感悟

写到这里,其实专业内容已经讲完了,但我想把这次作业里"数据库并发锁、死锁、连接池"这些生产级概念放到一起,说说我的整体体会。

数据库设计从来不是"画表、写SQL"这种静态工程。你会发现,一旦进入并发场景,一个不合理的表结构、一个缺失的外键约束、一个没有索引的查询条件,都会以极其痛苦的方式反噬你。在作业里,这些反噬只是报错;在生产环境里,可能就是线上故障、数据不一致、超卖损失。这也是为什么我反复强调建表三原则:主键必须显式、外键必须声明、字符集必须统一。这三条算不上什么高深的优化,但它们把设计的底线兜住了。

另外,做这次作业时让我收获最大的一个习惯是:每一步都用EXPLAIN验证SQL的执行计划。过去我凭直觉优化SQL,觉得子查询一定比联合查询慢,结果EXPLAIN之后发现完全相反的例子比比皆是。现在的习惯是任何重要查询都先跑一遍:

EXPLAIN SELECT s.student_name, sc.score FROM student_course sc JOIN student s ON sc.student_id = s.student_id WHERE sc.score < 60;

看type字段、看possible_keys、看rows估算,再针对性加索引。这个习惯救了我好几次,也让我在排查慢查询时不再像无头苍蝇。

我们平时写业务代码时,数据库往往被当成一个"黑盒存储",好像INSERT、SELECT就完事了。但真正把它当作一个需要精心设计和调优的独立系统去对待时,作业里的那些概念——事务、隔离级别、锁、死锁、MVCC、索引结构——才会真正从课本上"活"过来。这也是我认为这次"数据库第二次作业"最大的价值所在:它不是一次知识检验,而是一次从学生思维切换到工程师思维的预演。如果正在读这篇博客的你也需要完成类似的作业,我建议一定多花点时间在并发和死锁的部分,把这个核心理解透了,整个课程项目的格局就不一样了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询