简介:这份资源面向西北工业大学软件学院数据库课程学习者,聚焦实验五的E-Commerce数据库ER模式设计任务,适合正在完成实验、复习概念模型建模或需要对照示例查漏补缺的同学。压缩包共20个文件,约282KB,以14个GIF操作演示、2个TXT文本和2个DOC说明文档为主,并配有CDM概念模型文件与HTM实验说明页,能较完整地呈现从需求理解、实体联系分析到ER图绘制的参考过程。已有911人学习,说明该实验在课程学习中具有较高参考需求。读者可结合ER图、文字说明与分步界面演示,理解用户注册、商品浏览、购物车、结算登录、配送信息、支付与订单确认等业务对象之间的实体、联系、属性和基数约束,核对实验五的建模思路与结果,也可将其作为E-Commerce项目概念设计的示例材料,提升数据库ER建模与结果校验能力。
1. 从一份 SQL 脚本包说起:数据库实验五到底在练什么
解压压缩包,双击那个建库脚本,屏幕上刷出一屏红色报错——这是很多人跑数据库实验五时的第一幕。它不是能双击运行的软件,而是一套围绕「学生选课」场景组织起来的 SQL 脚本集合,把完整性约束、触发器、存储过程、事务与并发这几件事串成一条线。它适合两类人:正在做数据库实验课作业、需要一份能跑通的参照实现的学生,以及想把业务规则下沉到数据库层、又不愿意啃官方手册的开发者。下面按「是什么 → 怎么跑 → 怎么改」的顺序,把这套脚本拆开看。
2. 环境与目录摸底:把压缩包还原成一个能跑的实验工程
2.1 目录里都有什么,哪些能直接执行
这类实验包的组织方式大同小异,展开之后基本是「建库建表 → 逻辑对象 → 验证脚本 → 文档」四层。我一般先不急着执行,而是把文件夹按序号排一遍,确认哪些脚本是幂等的、哪些必须在前面跑完才能跑。下面是我拆包时会先画出来的一张目录地图,文件名可能带不同前缀,层级和依赖顺序基本不会变:
db-lab5/ ├── 01_schema/ │ ├── 01_database.sql -- 建库 + 字符集、排序规则 │ ├── 02_tables.sql -- 四张核心表 + 主外键 + CHECK │ ├── 03_index.sql -- 辅助索引、唯一索引 │ └── 04_seed.sql -- 种子数据,几十行 INSERT ├── 02_logic/ │ ├── 05_triggers.sql -- 成绩校验触发器 + 审计日志触发器 │ ├── 06_procedures.sql -- 选课、退课、成绩统计存储过程 │ └── 07_views.sql -- 成绩单视图、课表视图 ├── 03_verify/ │ ├── 08_concurrency_a.sql -- 并发会话 A │ ├── 09_concurrency_b.sql -- 并发会话 B │ └── 10_check_result.sql -- 结果校验查询 ├── docs/ │ └── ER 图与实验报告模板 └── README.md01_schema下的四个脚本是纯 DDL 加 DML,彼此只依赖顺序,不依赖环境,属于「扔进任何一台装好 MySQL 的机器都能跑」的部分。02_logic里的触发器、存储过程、视图必须等表建完才能创建,顺序反了会直接报「表不存在」。03_verify是给并发实验准备的,里面通常是两个会话分别执行的语句块,不能一次性全选执行,必须开两个客户端窗口交替跑。
提示:先把
01_schema整套跑通再动后面的脚本。建表阶段的问题最好在种子数据还没灌进去的时候暴露出来,否则清库重来要多花一倍时间。
2.2 建库建表脚本的执行顺序与字符集设置
执行顺序上,我习惯用命令行按文件名循环跑,比在图形化工具里一个个点开靠谱得多。跑之前先确认一件事:字符集到底在哪一层被定死。MySQL 的字符集有四层——服务端默认、库、表、列,上层没写就继承下层,继承链一断,中文姓名就会变成问号。所以01_database.sql里最该看的是这几行:
-- 建库时一次性把字符集钉死,避免后面建表继承到默认的 latin1 DROP DATABASE IF EXISTS lab5; CREATE DATABASE lab5 DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_0900_ai_ci; USE lab5;CHARACTER SET utf8mb4决定这层能存哪些字符,中文、生僻字、emoji 都能进;COLLATE utf8mb4_0900_ai_ci决定比较和排序规则,ai是重音不敏感、ci是大小写不敏感。MySQL 8.0 默认就是这套,写出来是给自己留后悔药;如果目标机是 5.7,把排序规则换成utf8mb4_general_ci,5.7 根本没有0900这个排序规则,照抄会报 1273 未知排序规则。
然后按顺序灌进去:
# 按文件名顺序执行建库、建表、索引、种子数据 for f in 01_schema/0*.sql; do echo ">>> running $f" mysql -h 127.0.0.1 -P 3306 -u root -p \ --default-character-set=utf8mb4 \ --show-warnings \ < "$f" || { echo "failed at $f"; break; } done--default-character-set=utf8mb4管的是客户端这一层,漏了它,哪怕库里是utf8mb4,终端里的中文照样可能变成乱码。--show-warnings是个血泪经验:MySQL 很多语句失败是「警告级」的,比如字段被截断、字符串超长,不加这个参数,你会以为脚本跑成功了,直到查数据才发现少了几条。
2.3 客户端连接:命令行、图形化工具与连接串
跑实验五至少要准备两个能独立提交事务的会话。图形化工具里开两个查询窗口未必是两个连接,有些工具的同一个标签页会复用连接,这会让并发实验的结论完全失真。稳妥的做法是一个命令行窗口加一个图形化工具窗口,或者两个独立的终端。
如果是用代码去调存储过程验证,连接参数要比平时多留意几项:
# PyMySQL 连接参数,并发实验必须关掉自动提交 import pymysql conn = pymysql.connect( host="127.0.0.1", port=3306, user="lab5", password="******", database="lab5", charset="utf8mb4", autocommit=False, # 关键:手动控制事务边界 cursorclass=pymysql.cursors.DictCursor, )autocommit=False是并发验证的前提,开成True的话每条语句自己就提交了,你根本构造不出「事务 A 还没提交、事务 B 已经在读」的场景,跑出来的结果会让人以为隔离级别设置没生效。charset="utf8mb4"必须和库一致,DictCursor只是让结果按字典取,省得记列下标。
3. 表结构与约束设计:把选课场景落成一组能跑的 DDL
3.1 四张核心表的主外键与级联策略
整个实验包的地基是学生、课程、选课三张表加一张教师表,选课表是全篇的枢纽——触发器挂它身上,存储过程改它,并发实验锁它。建表的第一个决策是存储引擎,必须显式写ENGINE=InnoDB,MyISAM 不支持事务和外键,后面的事务与并发实验会直接作废。
-- 教师表:被课程表弱引用,删除教师不应连带删课 CREATE TABLE teacher ( tno CHAR(10) NOT NULL COMMENT '教师工号', tname VARCHAR(20) NOT NULL COMMENT '姓名', title VARCHAR(20) NULL COMMENT '职称', PRIMARY KEY (tno) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课程表:容量字段给存储过程判满用 CREATE TABLE course ( cno CHAR(8) NOT NULL, cname VARCHAR(40) NOT NULL, credit DECIMAL(3,1) NOT NULL DEFAULT 2.0, tno CHAR(10) NULL, capacity SMALLINT UNSIGNED NOT NULL DEFAULT 60, PRIMARY KEY (cno), CONSTRAINT ck_course_credit CHECK (credit > 0 AND credit <= 10), CONSTRAINT fk_course_teacher FOREIGN KEY (tno) REFERENCES teacher(tno) ON DELETE SET NULL ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;ON DELETE SET NULL和ON DELETE CASCADE的选择反映的是业务语义:教师调走,课程还在,所以把tno置空;学生退学,他的选课记录没有保留价值,直接级联删掉。很多同学在选课表上无脑写CASCADE,结果删一门课把所有人的成绩一起带走,这种翻车在验收时特别难看。
-- 选课表:成绩允许为空(先选课后录成绩) CREATE TABLE sc ( sno CHAR(10) NOT NULL, cno CHAR(8) NOT NULL, term CHAR(11) NOT NULL DEFAULT '2024-2025-1', grade DECIMAL(5,2) NULL, PRIMARY KEY (sno, cno, term), KEY idx_sc_cno (cno), 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) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT ck_sc_grade CHECK (grade IS NULL OR (grade >= 0 AND grade <= 100)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;课程这一侧用RESTRICT而不是CASCADE,效果是「有成绩的课程删不掉」,等于给数据加了一道保险。这个约束在实验报告里经常被老师追问,答不出来就说明没理解外键动作的实际含义。
3.2 CHECK、UNIQUE、DEFAULT 与索引的配合
CHECK约束是实验五里最容易被忽略的一个点。它在 MySQL 8.0.16 之前只是被解析然后默默丢掉,写上去不报错但也不生效,插一条 200 分的成绩照样成功。如果目标环境的版本低于 8.0.16,就得靠BEFORE触发器兜底,这个坑放在第 5 章细说。
索引这块,联合主键(sno, cno, term)自带一个索引,但遵循最左前缀:按sno查、按sno + cno查都能走索引,单独按cno查就走不了了。而实验里「查某门课有多少人选」是高频操作,所以idx_sc_cno不能省。同理student表上按院系统计的查询很多,sdept上补一个普通索引,几十万行数据时差别很明显。
DEFAULT用得多的是时间和院系。created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP这类默认值能省掉应用层赋值,但要注意:DEFAULT CURRENT_TIMESTAMP在一张表里可以有多个,ON UPDATE CURRENT_TIMESTAMP最好只给真正需要自动更新的字段,否则一次无关的更新会把审计时间冲掉。
3.3 建表脚本逐段拆解与执行验证
表建完不能只看「Query OK」,要主动构造几个非法数据把约束试出来。我习惯用三段式验证:约束拦不拦得住、索引走不走得上、级联删得对不对。
-- 1) 越界成绩,看 CHECK 有没有真生效 INSERT INTO sc (sno, cno, grade) VALUES ('2021000001', 'C0001', 200); -- 预期:ERROR 3819: Check constraint 'ck_sc_grade' is violated. -- 2) 主键重复,看联合主键是否起作用 INSERT INTO sc (sno, cno, term) VALUES ('2021000001', 'C0001', '2024-2025-1'); INSERT INTO sc (sno, cno, term) VALUES ('2021000001', 'C0001', '2024-2025-1'); -- 预期:ERROR 1062: Duplicate entry -- 3) 索引验证:看 type 是不是 ref、key 是不是 idx_sc_cno EXPLAIN SELECT sno FROM sc WHERE cno = 'C0001';第 3 段里EXPLAIN的type列是关键观察点:ALL是全表扫,ref是走了普通索引,range是范围扫描,const是主键或唯一索引等值命中。如果按cno查出来是ALL,说明索引没建上或者被优化器放弃了,这时候先SHOW INDEX FROM sc确认索引存在,再考虑是不是数据量太小导致优化器认为全表扫更便宜——后者在种子数据只有几十行时非常常见,属于正常现象,不用急着改语句。
4. 触发器与存储过程:把业务规则下沉到数据库层
4.1 BEFORE 触发器做数据校验,AFTER 触发器写审计日志
触发器分两个时机用:BEFORE用来改数据或拦住数据,AFTER用来记录既成事实。成绩越界这种事,BEFORE里直接抛异常最干净,抛出去之后应用层拿到的是一条明确的错误信息,而不是静默失败。
DELIMITER $$ CREATE TRIGGER trg_sc_grade_bi BEFORE INSERT ON sc FOR EACH ROW BEGIN -- 成绩允许为空,但一旦有值就必须落在 0-100 IF NEW.grade IS NOT NULL AND (NEW.grade < 0 OR NEW.grade > 100) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '成绩必须落在 0-100 之间'; END IF; END$$ DELIMITER ;DELIMITER $$是客户端指令,不是 SQL 语句,作用是临时把语句结束符从分号改成$$,否则 MySQL 读到触发器体里第一个分号就以为语句结束了,直接 1064 语法错误。NEW.grade指代即将写入的那行数据,SIGNAL SQLSTATE '45000'是用户自定义异常的通用写法,45000表示「未处理的用户定义异常」,应用层捕获后能拿到MESSAGE_TEXT里的中文提示。
审计日志要记录「谁在什么时候把成绩从多少改成了多少」,这类信息在UPDATE之后才成立,所以用AFTER:
CREATE TABLE sc_audit_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, sno CHAR(10) NOT NULL, cno CHAR(8) NOT NULL, old_grade DECIMAL(5,2) NULL, new_grade DECIMAL(5,2) NULL, changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_audit_sno (sno) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; DELIMITER $$ CREATE TRIGGER trg_sc_grade_au AFTER UPDATE ON sc FOR EACH ROW BEGIN -- <=> 是 NULL 安全等于,NULL 改成 60 这种也要记一笔 IF NOT (OLD.grade <=> NEW.grade) THEN INSERT INTO sc_audit_log (sno, cno, old_grade, new_grade) VALUES (OLD.sno, OLD.cno, OLD.grade, NEW.grade); END IF; END$$ DELIMITER ;这里的<=>值得多说一句。普通等号=在任一侧为NULL时结果是NULL,IF NULL THEN走的是「假」分支,于是「成绩从空改成 60」这条最需要审计的变更反而被漏掉了。<=>是 NULL 安全等于,两侧都为NULL时返回真,一侧为空一侧有值时返回假,正好符合「值真的变了才记录」的语义。
4.2 存储过程的参数设计、事务边界与异常处理
选课逻辑是这批脚本里唯一同时涉及读、判断、写的地方,也是事务边界最该画清楚的地方。我一般把它做成三个输入参数加两个输出参数:输入是学号、课程号、学期,输出是一个状态码和一句人话提示,调用方不需要解析错误文本。
DELIMITER $$ CREATE PROCEDURE sp_enroll( IN p_sno CHAR(10), IN p_cno CHAR(8), IN p_term CHAR(11), OUT p_code INT, OUT p_msg VARCHAR(128) ) BEGIN DECLARE v_capacity SMALLINT UNSIGNED DEFAULT 0; DECLARE v_taken INT DEFAULT 0; DECLARE v_exists INT DEFAULT 0; -- 任何未捕获异常统一回滚,并把原始错误信息带回调用方 DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; GET DIAGNOSTICS CONDITION 1 p_msg = MESSAGE_TEXT; SET p_code = -1; END; START TRANSACTION; SELECT COUNT(*) INTO v_exists FROM sc WHERE sno = p_sno AND cno = p_cno AND term = p_term; IF v_exists > 0 THEN ROLLBACK; SET p_code = 1, p_msg = '已选过该课程'; ELSE -- FOR UPDATE 锁住课程行,防止两个会话读到同一个余量 SELECT capacity INTO v_capacity FROM course WHERE cno = p_cno FOR UPDATE; SELECT COUNT(*) INTO v_taken FROM sc WHERE cno = p_cno AND term = p_term; IF v_taken >= v_capacity THEN ROLLBACK; SET p_code = 2, p_msg = '课程已满'; ELSE INSERT INTO sc (sno, cno, term) VALUES (p_sno, p_cno, p_term); COMMIT; SET p_code = 0, p_msg = '选课成功'; END IF; END IF; END$$ DELIMITER ;几个参数的含义要提前约定死:p_code取0成功、1重复选课、2课程已满、-1未知异常,这样应用层只判断数字就行。DECLARE EXIT HANDLER FOR SQLEXCEPTION必须写在START TRANSACTION之前,它是声明不是语句,写后面会报语法错误。GET DIAGNOSTICS CONDITION 1取的是最近一次异常的详细信息,比手写「操作失败」有用得多。
真正防超选的是那句FOR UPDATE。不加它,两个会话可能同时读到「已选 59 人、容量 60」,然后各自插入一条,最终 61 人。加上行锁后,后到的会话会阻塞在SELECT ... FOR UPDATE上,等前一个事务提交再读,读到的是 60,走「课程已满」分支。
调用方式:
CALL sp_enroll('2021000001', 'C0001', '2024-2025-1', @code, @msg); SELECT @code AS code, @msg AS msg;4.3 事务与并发验证:两个会话同时选同一门课
并发实验是这套脚本里最容易被跑成「看起来对但其实没验证」的部分。核心是要分清快照读和当前读。开两个会话,都设成可重复读:
-- 会话 A SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT COUNT(*) FROM sc WHERE cno = 'C0001'; -- 假设得到 10-- 会话 B:插入一条并提交 INSERT INTO sc (sno, cno, term) VALUES ('2021000002', 'C0001', '2024-2025-1'); COMMIT;此时回到会话 A 再查一次,结果仍然是 10——这是快照读,事务开始时的 Read View 已经固定,别急着怀疑隔离级别没生效。换一种读法:
-- 会话 A:当前读,会看到会话 B 已提交的数据 SELECT COUNT(*) FROM sc WHERE cno = 'C0001' FOR UPDATE; -- 得到 11这就是快照读和当前读的区别。做并发实验时必须在报告里写清楚用的是哪种读,只贴一个数字说明不了任何问题。真正想验证隔离级别,可以再跑一遍「不可重复读」:把会话 A 换成READ COMMITTED,同样两条SELECT,第二条就能看到 11,差异一目了然。
5. 排查与避坑:实验五最容易翻车的五个地方
5.1 成绩插到 200 分也成功,CHECK 约束形同虚设
现象:建表时明明写了CHECK (grade BETWEEN 0 AND 100),INSERT一条 200 分的记录照样成功,SHOW CREATE TABLE里约束还在。
原因:MySQL 在 8.0.16 之前会解析CHECK子句但直接忽略,不报错也不执行;部分 MariaDB 版本行为也不一致。用图形化工具连的时候更容易踩到,因为工具本身不提示版本差异。
解决:先SELECT VERSION();确认版本。8.0.16 及以上直接可用;低于这个版本必须用BEFORE INSERT和BEFORE UPDATE两个触发器补上,并且注意触发器里的SIGNAL要写清楚提示语,否则调试时只能看到一个 1644 错误码。跨版本交付的脚本,最稳的做法是「CHECK + 触发器」双写,升级后把触发器删掉即可。
5.2 触发器里更新本表,报 1442 错误
现象:想写一个「成绩更新后自动重算班级平均分并写回sc表」的触发器,创建成功,一执行UPDATE就报ERROR 1442: Can't update table 'sc' in stored function/trigger because it is already used by statement which invoked this stored function/trigger。
原因:MySQL 不允许在触发器里对本表做INSERT/UPDATE/DELETE,因为这会引发递归和不可预期的执行顺序,属于引擎层的硬限制,不是权限问题。
解决:改数据的场景用BEFORE触发器加SET NEW.字段 = ...,只改当前行的新值,不触发额外 DML;需要落到别的表的场景,写到独立的统计表或日志表里,再由定时任务或应用层汇总。真要做级联统计,交给存储过程显式调用,别塞进触发器。
5.3 DELIMITER 没改,存储过程报 1064 语法错误
现象:把整段CREATE PROCEDURE粘进客户端执行,报ERROR 1064 (42000): You have an error in your SQL syntax ... near '' at line N,指向的位置往往在BEGIN之后第一行。
原因:MySQL 默认用分号作为语句结束符,存储过程和触发器体内大量使用分号,服务端读到第一个分号就认为语句结束,剩下的部分自然成了语法错误。
解决:创建前用DELIMITER $$改结束符,创建完再DELIMITER ;改回来。用图形化工具时要注意,有些工具的「执行当前语句」按钮会自己切分语句,把DELIMITER指令吃掉,表现是命令行能跑、工具里跑不了。这种情况改用「执行脚本」而不是「执行语句」,或者干脆回到命令行。
5.4 外键建不上,报 3780 或 1215
现象:ALTER TABLE sc ADD CONSTRAINT fk_sc_course FOREIGN KEY (cno) REFERENCES course(cno);执行失败,8.0 报ERROR 3780: Referencing column 'cno' and referenced column 'cno' in foreign key constraint are incompatible,5.7 报ERROR 1215: Cannot add foreign key constraint。
原因:1215是个大黑匣子,背后可能是四种情况——两边字段类型不完全一致(CHAR(8)对VARCHAR(8)算不一致)、字符集或排序规则不同、被引用列上没有索引、存储引擎不是 InnoDB。3780是 8.0 对类型不兼容的更精确报错。
解决:按顺序排查。SHOW CREATE TABLE对比两边字段定义,字符集必须一致;SHOW INDEX FROM course确认cno是主键或唯一索引;SHOW TABLE STATUS看引擎。还有一种隐蔽情况:表里已经有脏数据,子表某行的cno在父表里不存在,加外键时校验失败,报错信息和类型问题长得一样,先SELECT cno FROM sc WHERE cno NOT IN (SELECT cno FROM course)清一遍再试。
5.5 中文姓名变成问号,字符集三层不一致
现象:INSERT一个中文姓名,查出来是???或者一串乱码,SHOW CREATE TABLE显示utf8mb4,看起来完全正常。
原因:字符集有四层,服务端默认、库、表、列,另外还有连接层三个变量character_set_client、character_set_connection、character_set_results。表和列对了,但客户端连进来时用的是latin1,数据在传输阶段就已经损坏,存进库里的本来就是错的。
解决:先SHOW VARIABLES LIKE 'character_set%';看六个变量。连接层不一致就在连接串里加charset=utf8mb4,命令行加--default-character-set=utf8mb4。已经存进去的乱码改不回来,只能清表重灌,所以种子数据的导入脚本一定要带上字符集参数。另外utf8和utf8mb4在 MySQL 里不是一回事,前者最多三字节,存不了 emoji 和部分生僻字,建库时别偷懒。
6. 二次开发:把实验脚本改造成可演示的选课服务
6.1 用 Compose 固化环境,脚本一键重建
实验脚本最大的问题是「换台机器就跑不起来」,版本、字符集、排序规则任何一个对不上都要重来。我一般会加一个docker-compose.yml把环境钉死,顺便让建库脚本在容器首次启动时自动执行:
services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: lab5root MYSQL_DATABASE: lab5 TZ: Asia/Shanghai command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_0900_ai_ci - --transaction-isolation=REPEATABLE-READ ports: - "3306:3306" volumes: # 该目录下的 .sql 会按文件名排序自动执行,序号前缀在这里很关键 - ./01_schema:/docker-entrypoint-initdb.d:rodocker-entrypoint-initdb.d只在数据目录为空时执行一次,想重跑必须docker compose down -v把卷删掉。这个机制天然适合实验场景:每次改完建表脚本,删卷重启就是一次干净重建,比手动DROP DATABASE可靠。
6.2 把存储过程包成接口,补一条冒烟测试
存储过程本身不方便被前端调用,我一般会用一层薄薄的应用代码包一下,把CALL的四个参数转成 JSON 返回:
# 调用选课存储过程,把 OUT 参数翻译成结构化结果 def enroll(cur, sno, cno, term): cur.execute( "CALL sp_enroll(%s, %s, %s, @code, @msg)", (sno, cno, term), ) cur.execute("SELECT @code AS code, @msg AS msg") row = cur.fetchone() return {"ok": row["code"] == 0, "code": row["code"], "msg": row["msg"]}配合一条冒烟测试:先给某门课把容量改成 1,连续调两次enroll,第一次必须返回code=0,第二次必须返回code=2。这条用例能同时验证存储过程的分支逻辑和FOR UPDATE的行锁是否生效,比手工点界面快得多,也是改完脚本后我必跑的第一条。
从那以后我每次改完这套脚本,都会先删卷重建、跑一遍约束验证、再跑这条选课冒烟测试,三步全绿才敢提交。数据库实验里那些看起来玄学的报错,九成都能归结到版本、字符集、事务边界这三件事上,提前把环境固化住,能省掉大半天排查。希望帮到你。
本文还有配套的精品资源,点击获取