简介:面向西北工业大学软件学院数据库课程实验五的配套资料包,聚焦电子商务数据库ER模式设计任务,适合正在完成概念模型建模、实体关系梳理与ER图绘制的学生对照参考。包内共20个文件,以14个gif操作演示截图、2个txt与2个doc说明文档为主,另含cdm概念数据模型和htm页面文件,整体约282KB,体积轻便,便于快速浏览建模过程与结果。已有911人学习,说明该实验在课程学习者中有一定参考需求。资料围绕E-Commerce Project Description展开,能帮助读者理解实体、联系、属性及基数的表达方式,并借助ER图与文字说明核对概念模型是否完整,适合用作实验五的建模思路参考与结果自查。
1. 一个死锁现场:这套数据库实验五到底练什么
两个会话对着同一行成绩记录各改各的,一边等锁超时,一边直接抛出Deadlock found when trying to get lock。很多人跑数据库实验五,第一次卡住不是 SQL 写错,而是根本没意识到自己写的是并发。这套软件学院数据库实验五的资源包,练的正是这块:事务边界怎么划、四种隔离级别差在哪、锁是怎么被拿住又放掉的,顺带把存储过程、触发器、索引优化串成一条线。适合两类人——正在交实验报告、需要一份能跑通的脚本对照的;以及 SQL 写过不少,但从没认真看过EXPLAIN和锁等待的。它不教你数据库概论,它给你一套能复现、能改参数看结果的最小工程。
2. 实验包结构:从目录看清每道题落在哪个文件
拿到压缩包先别急着双击 SQL 文件全选执行。这套资源按实验步骤拆成了若干脚本和一份报告模板,彼此之间是有执行顺序依赖的——先建库建表,再灌初始数据,然后才是事务、过程、索引这些针对性脚本。顺序错了,后面全部报Table doesn't exist。先把目录读明白,比盲目跑一遍省时间。
2.1 目录结构与文件职责
这类实验包常见的组织方式是"库脚本 + 分题脚本 + 报告"三层,命名上通常带题号。我在拆的时候习惯先按文件名排一遍序,把依赖关系画在纸上,再动手。
| 文件/目录 | 职责 | 执行时机 |
|---|---|---|
schema.sql | 建库、建表、主外键、基础约束 | 第一步,只跑一次 |
data.sql | 初始数据,学生/课程/选课若干行 | 紧接 schema,只跑一次 |
exp5_1_tx.sql | 事务与隔离级别对照实验 | 环境就绪后单独跑 |
exp5_2_proc.sql | 存储过程、游标、异常处理 | 依赖表结构 |
exp5_3_trigger.sql | 触发器与完整性校验 | 依赖表结构 |
exp5_4_index.sql | 索引创建与执行计划对比 | 需要在数据量足够时跑 |
report/ | 实验报告模板与截图要求 | 最后填 |
判断依赖有个笨办法但很准:打开每个脚本看它USE的是哪个库、FROM的是哪张表。所有脚本开头都应该是USE lab5;这类明确切库语句,没有的话就自己补一行,别指望客户端记得你上次连的是哪个库。
很多人会把exp5_1_tx.sql和exp5_2_proc.sql一起选中执行,结果报一堆语法错误。原因是存储过程里用了DELIMITER $$,这是客户端层面的分隔符指令,不是 SQL 语句,批量执行时客户端往往只认第一个DELIMITER之后就乱了。血泪经验:凡是带DELIMITER的脚本,一定单独开一个查询窗口跑,一次只跑一个过程。
2.2 环境对齐:版本、字符集与连接参数
实验脚本能不能一次跑通,八成取决于环境是否对齐。这几个参数不对,症状都很怪:中文变问号、ONLY_FULL_GROUP_BY报错、时间字段自动补零。
-- 连接后先确认三件事,避免后面玄学报错 SELECT VERSION(); -- 8.0.x 与 5.7 在窗口函数、CTE 上差异明显 SHOW VARIABLES LIKE 'character_set%';-- 期望 utf8mb4,不是 latin1 SHOW VARIABLES LIKE 'sql_mode'; -- 关注 ONLY_FULL_GROUP_BY / STRICT_TRANS_TABLESVERSION()决定了你能不能用窗口函数。实验五里如果有"按课程分组取最高分"这类题,8.0 可以直接ROW_NUMBER() OVER (PARTITION BY cno ORDER BY score DESC),5.7 就得老老实实写关联子查询。两条路都对,但报告里要说明你用的是哪条,别混着写。
character_set_server如果不是utf8mb4,学生姓名里的生僻字会变成?,而且是在插入阶段就丢了,事后改字符集救不回来。建库语句里显式写死DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci,别依赖服务器默认值。
sql_mode里的ONLY_FULL_GROUP_BY是最容易翻车的一项。实验里那种SELECT sno, sname, AVG(score) FROM sc GROUP BY sno的写法,在这个模式下会直接报错,因为sname不在GROUP BY里也不在聚合函数里。正确做法是把sname从sc表里去掉,改成关联student表后按需要分组,而不是简单粗暴地SET sql_mode=''把检查关掉——关了之后你的分组结果在语义上就是不确定的,报告里也说不圆。
提示:改
sql_mode前先SELECT @@sql_mode记下原值,改完记得改回去,别把整个实例的模式改花了。
3. 核心实验拆解:事务、存储过程与触发器的可复现代码
中间这几道题是整份实验的重心,也是最容易在报告里写出"我觉得应该是这样"的地方。下面按建表、事务、过程与触发器三段拆,每段给出可跑的脚本和关键参数说明,照着改表名和字段就能套到自己的库里。
3.1 建表脚本与初始数据:约束先行
先在表结构层面把能约束的都约束住,后面的事务和触发器实验才有意义。如果主键、外键、CHECK全靠应用层保证,那你测出来的并发问题根本反映不了数据库层面的行为。
-- 实验五沿用的三张核心表:学生、课程、选课 CREATE TABLE student ( sno CHAR(8) NOT NULL, -- 学号,定长 8 位 sname VARCHAR(20) NOT NULL, ssex CHAR(2) DEFAULT '男', sage TINYINT DEFAULT 20, sdept VARCHAR(30), PRIMARY KEY (sno), KEY idx_sdept (sdept) -- 为系别过滤预埋索引 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE course ( cno CHAR(4) NOT NULL, cname VARCHAR(40) NOT NULL, credit TINYINT NOT NULL DEFAULT 2, PRIMARY KEY (cno) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sc ( sno CHAR(8) NOT NULL, cno CHAR(4) NOT NULL, score DECIMAL(4,1) DEFAULT NULL, -- 允许 NULL 表示未录入 PRIMARY KEY (sno, cno), -- 复合主键天然防重复选课 KEY idx_cno_score (cno, score), -- 支撑"某课成绩排名" CONSTRAINT fk_sc_student FOREIGN KEY (sno) REFERENCES student(sno) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_sc_course FOREIGN KEY (cno) REFERENCES course(cno) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三张表的关键决策有三个。第一,sc用(sno, cno)复合主键,比自增 ID 加唯一索引更直接,重复选课在插入阶段就被拦住,不用等应用层判断。第二,score用DECIMAL(4,1)而不是FLOAT,成绩这种要比较、要排序、要算平均值的字段用浮点会出现89.99999这种排序错位。第三,外键写了ON DELETE CASCADE,删学生时选课记录跟着走——这在实验里方便,在真实业务里要慎重,删错一条学生记录连带成绩全没,后悔药是没有的。
ENGINE=InnoDB不能省。默认引擎如果是 MyISAM,事务和行锁统统不生效,你后面写的START TRANSACTION和ROLLBACK全都是摆设,数据照样改进去。这是排查"为什么我回滚了数据还在"时的第一个检查项。
初始数据一般每个学生配 3 到 5 条选课记录,总量控制在几百到几千行。太少的话执行计划里type一栏永远是const或ALL,看不出索引差别;太多的话实验环境扛不住,也没必要。我一般灌 500 行左右的学生、50 门课、2000 条选课,够跑索引对比了。
3.2 事务与隔离级别的对照实验
这道题的核心不是背四种隔离级别分别解决什么问题,而是让两个会话同时跑,亲眼看到脏读、不可重复读、幻读长什么样。建议开两个查询窗口,左边 A、右边 B,按顺序交替执行。
-- 会话 A:设置隔离级别并开启事务 SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT sno, score FROM sc WHERE cno = 'C01' AND sno = '20210001'; -- ↑ 此时读到的是 B 尚未提交的脏数据(如果隔离级别是 RU)-- 会话 B:修改同一行但先不提交 START TRANSACTION; UPDATE sc SET score = 59.0 WHERE cno = 'C01' AND sno = '20210001'; -- 先别 COMMIT,切回 A 再查一次A 在READ UNCOMMITTED下会读到 59.0,这就是脏读。把 A 的隔离级别换成READ COMMITTED重来一遍,A 读到的是改前的值,脏读消失,但 B 提交后 A 在同一事务里再查,值又变了,这就是不可重复读。再升到REPEATABLE READ,A 整个事务里两次查询结果一致——InnoDB 靠的是 MVCC,读的是事务开始时的快照。
SERIALIZABLE下的行为差异更明显:A 查询时会对扫描到的行加共享锁,B 的更新直接阻塞,直到 A 提交。这时候 B 的窗口会卡住,别以为程序挂了,去SHOW PROCESSLIST里能看到Waiting for table metadata lock或者锁等待状态。
参数上有两个点要注意。SET SESSION只影响当前连接,SET GLOBAL影响新连接但不断开已有连接,实验里用SESSION就够了,别去动全局。另外START TRANSACTION和BEGIN等价,但START TRANSACTION READ ONLY能显式声明只读事务,InnoDB 会做优化,报告里提一句能加分。
想验证 MVCC 快照,可以在 A 的REPEATABLE READ事务里插入一条新记录,然后 B 插入同主键的记录并提交,A 再插入会报主键冲突——说明 A 的快照里看不见 B 的行,但唯一索引是当前读。这个现象叫"快照读与当前读不一致",是很多人写报告时说不清的地方。
3.3 存储过程与触发器:把业务规则下沉到库里
存储过程这道题通常要求带输入输出参数和异常处理,触发器要求做完整性校验。这两块合起来,本质是在练"数据库自己能不能兜住业务规则"。
-- 按课程统一调分,带事务和异常回滚 DELIMITER $$ CREATE PROCEDURE sp_adjust_score( IN p_cno CHAR(4), -- 目标课程号 IN p_delta DECIMAL(4,1), -- 调整幅度,可为负 OUT p_rows INT -- 返回受影响行数,-1 表示失败 ) BEGIN -- 任何 SQL 异常都回滚并返回 -1,避免半个事务落地 DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_rows = -1; END; START TRANSACTION; UPDATE sc SET score = LEAST(100, GREATEST(0, score + p_delta)) WHERE cno = p_cno AND score IS NOT NULL; SET p_rows = ROW_COUNT(); COMMIT; END$$ DELIMITER ;调用的方式是CALL sp_adjust_score('C01', 5, @n); SELECT @n;。三个参数里p_delta是IN,过程内部改它不影响调用方;p_rows是OUT,调用方靠会话变量@n接。LEAST(100, GREATEST(0, ...))把结果夹在 0 到 100 之间,比在应用层判断更省事,也避免了超范围值被CHECK拦住导致整个过程失败。
DECLARE EXIT HANDLER这一段是很多人的盲区。没有它,UPDATE中途报错(比如外键冲突)时事务会悬着不提交也不回滚,连接不断就一直占着锁,后面的实验全被堵住。加上EXIT HANDLER之后,异常路径统一走ROLLBACK,p_rows给个-1让调用方知道失败了。注意HANDLER必须紧跟在BEGIN之后、其他声明之前,位置错了直接语法报错。
-- 插入成绩前做范围校验,越界直接抛错中断 DELIMITER $$ CREATE TRIGGER trg_sc_before_insert BEFORE INSERT ON sc FOR EACH ROW BEGIN IF NEW.score IS NOT NULL AND (NEW.score < 0 OR NEW.score > 100) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'score must be between 0 and 100'; END IF; END$$ DELIMITER ;触发器里用SIGNAL SQLSTATE '45000'而不是简单SELECT 'error'。前者是标准做法,会中断当前语句并把错误抛回客户端,应用层能捕获到;后者只是返回一个结果集,插入照样执行。NEW指代待插入的行,BEFORE INSERT能改NEW.score,AFTER INSERT不能,选哪个取决于你是要修正值还是要拦截。
触发器写多了会互相打架,这是常见的性能陷阱。一张表上挂五六个触发器,每次插入都要依次执行,出问题时根本不知道是哪个触发的。实验里一张表一个触发器足够,别炫技。
4. 索引与查询优化:EXPLAIN 到底该看哪几列
索引这道题最容易写成"加了索引就快了"的流水账。要写出东西,得拿执行计划说话:加索引前后的type、rows、Extra三列变化,比任何文字描述都有说服力。
4.1 用 EXPLAIN 定位全表扫描
先跑一条典型的联表查询,看它在没索引时怎么执行。
-- 查某系某门课的成绩排名,典型的联表 + 过滤 + 排序 EXPLAIN SELECT s.sname, sc.score FROM student s JOIN sc ON s.sno = sc.sno WHERE s.sdept = '计算机系' AND sc.cno = 'C01' ORDER BY sc.score DESC;输出里重点看三列。type一列从上到下依次是system > const > eq_ref > ref > range > index > ALL,看到ALL就是全表扫描,必须处理。rows是预估扫描行数,估算值不准没关系,但相对大小有意义。Extra里出现Using filesort说明排序没走索引,出现Using temporary说明用了临时表,两个都是优化点。
这条查询最可能的执行路径是:student表全扫(type=ALL),因为sdept上的索引idx_sdept可能没被选中——优化器判断按系别过滤后行数还是太多,不如直接扫。sc表走idx_cno_score的ref,因为cno是索引前缀。
想让它更快,可以把过滤条件往前推,先缩小sc的候选集再联表,或者干脆建一个覆盖索引把sname也带上。但顺序不能反:先确认数据量和选择率,再决定加不加索引,别一上来就三张表各加五个索引。
EXPLAIN还有EXPLAIN ANALYZE(8.0.18 起支持)这个进阶用法,它会真正执行语句并给出实际耗时和实际行数,跟预估行数对比能看出统计信息是否过期。跑ANALYZE TABLE可以刷新统计信息,但别在生产高峰做,会锁表。
4.2 复合索引的最左前缀与覆盖索引
这道题的另一个考点是最左前缀。建一个(sdept, cno, score)的复合索引,然后分别用sdept、sdept+cno、cno做条件去查,看哪些能走索引、哪些不能。
-- 复合索引:系别 -> 课程 -> 成绩 ALTER TABLE sc ADD INDEX idx_cover (cno, score, sno); -- 能走索引:cno 是最左列 EXPLAIN SELECT sno, score FROM sc WHERE cno = 'C01' AND score > 80; -- 走不了:score 单独做条件,跳过了最左列 cno EXPLAIN SELECT sno FROM sc WHERE score > 80;第二条查询的type一定是ALL。这不是优化器笨,是 B+ 树的结构决定的:索引按(cno, score)排序,score在每一棵cno子树里才有顺序,全局看是无序的,没法二分查找。这也是"为什么我加了索引还是慢"的头号原因。
覆盖索引的判定看Extra列有没有Using index。上面第一条查询只取了sno和score,两个字段都在idx_cover里,理论上可以只读索引不回表。但sno是主键的一部分,InnoDB 二级索引叶子节点自带主键值,所以即使不显式加sno也能覆盖。这个细节在报告里写出来,能明显看出你是不是真的理解 InnoDB 的索引结构。
还有个坑:WHERE score > 80里的范围条件会让索引在score这一列之后失效。如果索引是(cno, score, sno),那sno还能用于覆盖但不能再用于过滤。范围条件放最后,这是建索引的基本纪律。
5. 常见问题排查:从连接报错到锁等待的血泪记录
这部分是我拆这类实验包时踩过的坑,按现象、原因、解决三栏记,遇到对得上的直接照方抓药。
5.1 连接与字符集问题:中文变问号、连不上 3306
现象:学生姓名插入后显示成???,或者SELECT出来是乱码,但表结构和数据看着都正常。
原因:客户端连接字符集和服务器不一致。常见于客户端默认latin1,而库是utf8mb4,插入时在连接层就被转码丢了。
解决:连接串里显式指定charset=utf8mb4,JDBC 是?useUnicode=true&characterEncoding=utf8,命令行是mysql --default-character-set=utf8mb4。已经写坏的数据救不回来,只能重灌data.sql。建库语句里也把CHARSET写死,别靠默认值。
现象:ERROR 2003 (HY000): Can't connect to MySQL server on '127.0.0.1:3306'。
原因:服务没起,或者端口被别的实例占了。装了多个版本时特别容易,一个跑在 3306,新装的那个改到 3307 却还在连 3306。
解决:先netstat -ano | findstr 3306(Windows)或lsof -i:3306(类 Unix)看谁占着。服务没起就启服务,端口冲突就改连接串里的端口,别去杀进程——那个进程可能是你另一个实验在用的实例。
5.2 锁等待与死锁:事务不提交的连锁反应
现象:某个UPDATE一直不返回,客户端转圈,过一阵报Lock wait timeout exceeded。
原因:另一个会话开了事务改了同一行但没COMMIT也没ROLLBACK,行锁一直挂着。实验里最常见的是在上一个窗口跑完START TRANSACTION就切走了,忘了收尾。
解决:另开一个窗口跑SELECT * FROM information_schema.innodb_trx\G,看trx_state是RUNNING且trx_started很久的那条,拿到trx_mysql_thread_id后KILL掉。养成习惯:开事务的脚本末尾一定跟COMMIT或ROLLBACK,或者用SET SESSION innodb_lock_wait_timeout = 5把等待时间调短,让它早点报错而不是干等 50 秒。
现象:报Deadlock found when trying to get lock; try restarting transaction。
原因:两个事务以相反顺序锁两行,A 锁了行 1 等行 2,B 锁了行 2 等行 1,环形成立,InnoDB 检测到后主动回滚代价小的那个。这在"互相转账"式的实验里几乎必现。
解决:这不是 bug,是设计。业务上保证所有事务按同一顺序访问资源(比如都按sno升序更新),死锁概率会大幅下降。实验里则要观察:被回滚的那个事务,它的p_rows或返回值应该能体现失败,代码里要有重试逻辑,不能默认一次成功。
5.3 触发器与存储过程:看不见的执行路径
现象:插入一条成绩,报错信息是一串SQLSTATE码,完全看不出哪张表出的问题。
原因:触发器里SIGNAL的MESSAGE_TEXT写得太笼统,或者外层调用存储过程时异常被HANDLER吞掉,只返回了一个-1。
解决:调试期在HANDLER里先SELECT SQLSTATE, MESSAGE_TEXT把原始错误打出来,确认后再改成静默返回。触发器里SIGNAL的消息写清是哪张表哪个字段,别只写 "invalid input"。另外SHOW TRIGGERS能看到当前库里所有触发器,排除"我明明没写这个触发器"的疑惑。
现象:存储过程里DELIMITER之后整段脚本报语法错误,或者只创建了第一个过程。
原因:DELIMITER是客户端指令,很多图形化客户端在批量执行时不认,或者把$$当成普通字符串。
解决:一次只跑一个CREATE PROCEDURE,跑完再跑下一个。实在要用批量,就改用客户端支持的写法,或者干脆在命令行mysql -u root -p lab5 < exp5_2_proc.sql走文件导入,命令行对DELIMITER的支持是最可靠的。
注意:
DROP PROCEDURE IF EXISTS和DROP TRIGGER IF EXISTS放在每个创建语句前面,重复执行脚本时才不会报"已存在"。
6. 二次开发:把实验包改成能写进简历的脚手架
实验交完就扔,太浪费。这套脚本稍微改改,能变成一个像样的课程设计底座,甚至是你面试时能拿出来讲的项目。我的改法分三步。
第一步是把"实验脚本"变成"可重复执行的迁移脚本"。现在的schema.sql是DROP TABLE再CREATE,跑一次数据全没。改成带版本号的形式:V1__init_schema.sql、V2__add_index.sql,配一个简单的执行记录表,每次只跑没跑过的。这样你能演示"数据库结构是怎么演进的",而不是"我删库重来了一次"。表可以这么建:
-- 迁移记录表,记录每个版本脚本是否已执行 CREATE TABLE schema_version ( version VARCHAR(20) NOT NULL PRIMARY KEY, -- 如 V1、V2 description VARCHAR(100), applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, success TINYINT(1) DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;第二步是把存储过程和触发器里的硬编码抽出来。sp_adjust_score里的0和100是写死的,改成从一张config表读,或者做成参数。这样别人问你"分数上限变了怎么办",你的回答不是"改存储过程重编译",而是"改配置行"。差别不大,但体现的是工程思维。
第三步是补一层最薄的验证。不用上完整测试框架,写几个 SQL 断言就够:插入越界分数应该失败、并发调分后总分不变、索引加完后EXPLAIN的type应该是ref而不是ALL。把这些断言写进一个verify.sql,每次改完跑一遍,绿了就说明没改坏。
| 实验版做法 | 工程版改法 | 收益 |
|---|---|---|
DROP后重建 | 版本化迁移脚本 | 数据可保留,结构变更可追溯 |
| 阈值写死在过程里 | 配置表或参数化 | 改规则不用重编译 |
| 跑通即完成 | verify.sql断言 | 改动后能快速回归 |
| 单会话演示 | 双会话并发脚本 | 能讲清锁和隔离级别 |
最后说个习惯。我早些年交这类实验,跑通就截图,报告里的结论全是"经测试符合预期"。后来被问"你这个隔离级别下幻读到底有没有发生,截图里看不出来"的时候,整个人是懵的。从那以后我每次做并发实验,都强制把两个会话的完整输出按时间顺序贴进报告,包括阻塞的那几秒。难看,但能自证。
这套脚本的价值不在答案本身,在于它给了你一个能反复折腾的最小环境。把参数改一改、把会话顺序换一换、把索引删了再加回来,观察结果怎么变——这比抄十份报告都有用。资源包里的脚本和模板照着改就能跑,需要的话按标题找一下。希望帮到你。
本文还有配套的精品资源,点击获取