教室管理系统数据库课程设计:从建表脚本到事务排错实战 简介这份《数据库应用课程设计》面向计算机相关专业学生及需要完成数据库课程大作业的学习者围绕教室管理系统展开覆盖数据库设计、Web前端与后端交互、MVC架构、SQL查询优化及用户认证与防注入等实践要点。压缩包共39个文件约1.94MB包含8个jsp页面、4个css与4个js前端资源以及Java源码、mdf与ldf数据库文件、docx说明文档和项目配置文件可据此还原系统结构、梳理表关系与页面流程。已有2113人学习适合用作课程设计参考、功能拆解、实验报告整理和答辩准备尤其适合中高年级本科生查漏补缺。读者可从中获取较完整的教室管理项目骨架、数据库建库思路与前后端协作样例并结合说明文档理解模块划分与关键实现。1. 课程设计最后一晚教室管理系统真正卡住人的往往是中间那几步转换答辩前一晚最常听到的一句话是“表建好了功能跑不起来”。数据库应用课程设计这一类 web大作业难住人的往往是三步之间的衔接把需求翻译成表结构、把表结构落成能重复执行的脚本、再让后端接口和脚本里的字段口径完全对得上。三次衔接里任何一处差一点页面就会出现查不到数据、插入报 1452、日期少八小时这类看似不大的问题。数据库应用课程设计.zip就是围绕“教室管理系统”这个最常见题目整理的一套完整工程需求说明、E-R 图、建表脚本、测试数据、后端 CRUD 与前端页面都在里面目录结构按“能重跑、能改”来组织。适合两类人正在赶课程设计、需要一套能讲清设计取舍的底稿以及表结构已经画完但索引、约束和事务还不知道往哪放的。下面按数据、脚本、接口、排错的顺序拆。2. 从需求到七张表角色、时段与容量字段怎么定才不返工建模阶段最浪费时间的操作是打开画图工具直接拖实体框。教室管理系统的实体其实不多但一旦漏掉“审批人”或者把“第几节”存成字符串后面接口层要花三倍时间补。这一章讲的是先定什么、后定什么以及每个字段为什么这么定。2.1 先列角色和动词再画 E-R我的习惯是先不碰 E-R拿一张纸横着写角色学生、教师、教务管理员、设备维修员。然后在每个角色下面写他能做的动作动词不超过十个查空教室、排课、借教室、审借用、报修、查使用记录。写完会发现每个动词要么对应一张表要么对应一张表上的状态字段——六七个动词表数量基本就锁死在七张上下不会再出现“画到一半发现还要加一张关联表”的情况。顺序上先定主干教室、用户、课程再定带时间维度的排课、借用最后定附属的报修、日志。带时间维度的表最容易返工因为时段字段的粒度一旦定错后面所有查询都要重写。常见做法是把“第几节课”拆成start_section和end_section两个 tinyint而不是存1-2这种字符串——存字符串时想查“第 3 节有没有空教室”只能写LIKE %3%索引直接失效而且在数据量上到几千条后查询会明显变慢。范式的取舍也很实际第三范式够用不必强求。把building单独抽一张楼宇表只有在需要统计“每栋楼的使用率”“每栋楼的教室类型分布”时才划算如果只是下拉框选一下直接在教学楼表里存VARCHAR(30)更省事也少一次 join。反过来room_type、status这类枚举值绝对不要用 ENUM 类型加一个值就要 ALTER TABLE而且 JDBC 读出来是字符串和前端传的 int 对不上时排查起来很麻烦。统一用TINYINT加COMMENT写清每个值的含义。2.2 七张核心表与字段口径表名作用关键字段索引 / 约束sys_user账号与角色user_id, username, password_hash, role, real_name唯一键uk_usernameclassroom教室主数据room_id, room_no, building, capacity, room_type, status唯一键uk_room_nocourse课程course_id, course_code, course_name, teacher_id, credit唯一键uk_course_codeschedule排课schedule_id, room_id, course_id, semester, week_day, start_section, end_section唯一键(room_id, semester, week_day, start_section)booking借用申请booking_id, room_id, user_id, book_date, start_time, end_time, status普通索引(room_id, book_date, status)repair设备报修repair_id, room_id, reporter_id, fault_desc, status普通索引(room_id, status)op_log操作日志log_id, user_id, action, target, created_at普通索引(user_id, created_at)字段规范上有几条是先定下来、后面不用返工的主键统一INT UNSIGNED AUTO_INCREMENT量级可能过千万的流水表借用、日志用BIGINT UNSIGNED。日期用DATE时刻用TIME具体时间点用DATETIME。不要为了省事全用VARCHAR(20)存2025-03-01那样BETWEEN查询会变成字符串比较2025-3-1和2025-03-01会被当成两个不同的值。手机号用VARCHAR(20)而不是CHAR(11)分机号和国际号都能容纳也不会因为定长补空格导致等值比较出问题。状态字段一律带COMMENT因为三个月后你自己也记不住status 2是“停用”还是“已取消”。2.3 外键加不加以及两处刻意留下的冗余课程设计阶段我建议外键都加上。FOREIGN KEY在答辩时是个能讲清“参照完整性”的抓手而且能挡掉一批脏数据——比如前端传了个不存在的room_id插入直接报 1452比写进库里再靠代码兜底要早发现问题。代价是删除主表数据时会受限需要先删子表或设ON DELETE RESTRICT这个在脚本里显式写出来比默认行为清楚。真正需要斟酌的是两处冗余字段我在多个类似项目里都保留它们第一处是booking.room_no。教室改名比如 A301 改成 A301-1之后历史借用记录如果只存room_id导出的报表会跟着一起变看起来像“去年借的是新名字”。存一份下单时的编号快照历史记录就不动了代价只是多 20 字节。第二处是schedule.teacher_id。教师本来就挂在course上按理说排课表不该再存一遍。但排课页面最高频的查询是“某教师这学期的课表”不冗余就得schedule join course两表关联冗余之后单表带索引就能出结果课程换老师时同步更新一下即可。这类冗余的判定标准很简单这个字段是不是高频查询条件的入口是就留。索引这边有一条容易漏schedule上的唯一键要是(room_id, semester, week_day, start_section)而不是只给room_id建普通索引。唯一键能直接在数据库层挡掉“同一间教室同一学期同一天同一节排两门课”这种数据错误比在 Java 里查一遍再插要可靠得多。3. 初始化脚本一次跑通建库、约束和 200 条测试数据的写法脚本能不能一把跑通直接决定了别人拿到这个包之后的第一印象。如果要手动改三处才能 import再好的表结构也会被搁在一边。这一章按“分几层、怎么写、数据怎么造”的顺序处理。3.1 脚本分四层重跑只跑其中一层我把 SQL 拆成四个文件靠文件名前缀保证source顺序文件内容什么时候重跑00_init.sql建库、建账号、授权换机器时10_schema.sql建表、约束、索引改表结构时20_seed.sql基础数据 批量测试数据清库重置时30_view.sql视图、统计语句改报表时命令行一次跑完# 字符集必须显式指定否则 Windows 控制台默认 GBK 会把中文注释写坏 mysql -uroot -p --default-character-setutf8mb4 00_init.sql for f in 10_schema.sql 20_seed.sql 30_view.sql; do mysql -uroot -p --default-character-setutf8mb4 classroom_db $f || exit 1 done--default-character-setutf8mb4这个参数不是可选项。在默认 GBK 的终端里不加它脚本里的中文注释会变成问号建出来的表COMMENT全是乱码答辩演示时非常难看。-p和密码之间不能有空格这是mysql客户端的固定行为写成-p 123456会被当成库名。00_init.sql里除了建库还要把应用账号和应用库绑在一起别让后端直接用 rootCREATE DATABASE IF NOT EXISTS classroom_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_0900_ai_ci; CREATE USER IF NOT EXISTS app_userlocalhost IDENTIFIED BY ChangeMe_2025; GRANT SELECT, INSERT, UPDATE, DELETE ON classroom_db.* TO app_userlocalhost; FLUSH PRIVILEGES;应用账号只给 DML 权限不给DROP。这样即使代码里拼接 SQL 出了问题最坏结果也就是数据被改坏不至于整库消失。课程设计报告里这条也能写成一条安全设计。3.2 建表 DDL约束写在哪一层USE classroom_db; CREATE TABLE classroom ( room_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, room_no VARCHAR(20) NOT NULL COMMENT 教室编号如 A301, building VARCHAR(30) NOT NULL COMMENT 教学楼, floor TINYINT NOT NULL DEFAULT 1, capacity SMALLINT NOT NULL DEFAULT 0 COMMENT 座位数, room_type TINYINT NOT NULL DEFAULT 0 COMMENT 0普通 1多媒体 2机房, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可用 1维修 2停用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_room_no (room_no), KEY idx_building_type (building, room_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT教室主数据; CREATE TABLE booking ( booking_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, room_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED NOT NULL, room_no VARCHAR(20) NOT NULL COMMENT 下单时的教室编号快照, book_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, purpose VARCHAR(200) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审 1通过 2驳回 3取消, reviewer_id INT UNSIGNED DEFAULT NULL, review_time DATETIME DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_room_date_status (room_id, book_date, status), KEY idx_user_created (user_id, created_at), CONSTRAINT fk_booking_room FOREIGN KEY (room_id) REFERENCES classroom (room_id), CONSTRAINT fk_booking_user FOREIGN KEY (user_id) REFERENCES sys_user (user_id), CONSTRAINT ck_booking_time CHECK (end_time start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT教室借用申请;几个点要说明。ENGINEInnoDB必须显式写MyISAM 既不支持事务也不支持外键用 MyISAM 建出来的借教室功能在并发下一定出错。CHECK约束在 MySQL 8.0.16 之后才真正生效之前的版本会解析但忽略如果你的环境是 5.7ck_booking_time这条写了也没用需要在应用层再判一次end_time start_time否则会出现结束时间早于开始时间的记录。idx_room_date_status的顺序也有讲究room_id在前是因为它选择性最高book_date次之status放最后用于过滤。如果反过来把status放第一位由于状态值只有四种索引基本退化成全表扫描。3.3 造数据手写 INSERT 和存储过程各管一段基础数据教学楼、教室、几个测试账号手写INSERT一眼能看懂也方便改。测试数据必须批量造否则分页功能根本测不出来。DROP PROCEDURE IF EXISTS gen_bookings; DELIMITER $$ CREATE PROCEDURE gen_bookings(IN n INT) BEGIN DECLARE i INT DEFAULT 0; WHILE i n DO INSERT INTO booking (room_id, user_id, room_no, book_date, start_time, end_time, purpose, status) SELECT c.room_id, 3 (i % 20), -- 在 20 个学生账号之间轮转 c.room_no, -- 快照和教室表当前值一致 DATE_ADD(2025-03-01, INTERVAL (i % 60) DAY), SEC_TO_TIME((8 (i % 10)) * 3600), -- 8:00 起每 10 条往后推 1 小时 SEC_TO_TIME((9 (i % 10)) * 3600), -- 与开始时间保持 1 小时间隔 CONCAT(第, i, 次临时借用), 0 -- 全部建成待审方便演示审批 FROM classroom c WHERE c.room_id 1 (i % 30); SET i i 1; END WHILE; END$$ DELIMITER ; CALL gen_bookings(200);DELIMITER $$是必须的存储过程体里的分号会被客户端提前截断。SEC_TO_TIME把秒数转成TIME类型比手写08:00:00更好调参数。全部造成status 0是故意的审批功能需要真实数据才能演示如果一开始就有一半是“已通过”冲突检测那一步就没有可复现的场景了。统计视图也要一起建它在验收时能直接体现设计水平CREATE OR REPLACE VIEW v_room_usage AS SELECT c.room_id, c.room_no, c.building, c.capacity, COUNT(b.booking_id) AS booking_cnt, SUM(CASE WHEN b.status 1 THEN 1 ELSE 0 END) AS approved_cnt FROM classroom c LEFT JOIN booking b ON b.room_id c.room_id AND b.book_date 2025-03-01 GROUP BY c.room_id, c.room_no, c.building, c.capacity;这里有一个不值得再重复第二次的问题统计次数要用COUNT(b.booking_id)而不是COUNT(*)。LEFT JOIN之后没有任何借用记录的教室会被补一行b.*全为 NULL 的记录COUNT(*)会把它算成 1导致“零借用”的教室在报表里显示借了 1 次。COUNT(具体列)会自动跳过 NULL这是LEFT JOIN加聚合时的固定写法。4. 接口层落地连接池参数、审批事务与分页 SQL 的三处细节表结构对了脚本能跑了剩下的问题都在接口层。教室管理系统的接口只有两类是真正需要认真写的审批借用有并发、有事务和分页筛选有条件拼接、有深分页。4.1 连接串和连接池五个参数决定服务能不能撑住答辩演示JDBC 连接串和连接池配置通常写在一个 properties 文件里由启动时读入jdbc.urljdbc:mysql://127.0.0.1:3306/classroom_db?useSSLfalsecharacterEncodingUTF-8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruerewriteBatchedStatementstrue jdbc.usernameapp_user jdbc.passwordChangeMe_2025 jdbc.maxPoolSize10 jdbc.minIdle2 jdbc.connectionTimeoutMs3000 jdbc.idleTimeoutMs60000 jdbc.maxLifetimeMs600000逐项说清楚因为这些参数写错时的表现都不一样characterEncodingUTF-8决定客户端和服务器之间用什么编码传输缺了它写入中文会报Incorrect string value: \xE4\xB8\xAD...。注意这里写UTF-8就够Connector/J 8 会自动映射到utf8mb4。serverTimezoneAsia/Shanghai缺了会在启动时直接抛The server time zone value is unrecognized或者在读写DATETIME时整体偏移 8 小时。这个是最容易被当成“玄学”的一个其实只是驱动不知道服务器时区。useSSLfalse只用于本地演示环境换成正式环境要开并配证书。maxPoolSize10对小规模演示足够10 个连接已经能满足同时几十个请求设太大反而会因为超出数据库max_connections直接连不上。maxLifetimeMs600000要比 MySQL 的wait_timeout默认 28800 秒小得多。否则连接被服务端单方面掐断、池子还以为是活的第一个请求就会拿到一个已关闭的连接报Communications link failure。4.2 审批借用一个必须用事务的地方审批这个动作看着简单实际要同时做三件事确认这条申请还是“待审”、确认同教室同日期没有已通过的重叠时段、把状态改成“通过”。三件事分开做两个管理员同时点“通过”就会双双成功同一间教室同一时段出现两条通过记录。public boolean approve(long bookingId, int reviewerId) { Connection conn null; try { conn DataSourceHolder.get().getConnection(); conn.setAutoCommit(false); conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ); // 1. 锁住这条申请挡住并发重复审批 long roomId; java.sql.Date day; java.sql.Time st, et; try (PreparedStatement ps conn.prepareStatement( SELECT room_id, book_date, start_time, end_time, status FROM booking WHERE booking_id ? FOR UPDATE)) { ps.setLong(1, bookingId); try (ResultSet rs ps.executeQuery()) { if (!rs.next()) { conn.rollback(); return false; } if (rs.getInt(status) ! 0) { conn.rollback(); return false; } // 已审过 roomId rs.getLong(room_id); day rs.getDate(book_date); st rs.getTime(start_time); et rs.getTime(end_time); } } // 2. 同教室同日期已通过的时间区间与本次重叠则拒绝 // 重叠判定已有start 本次end 且 已有end 本次start try (PreparedStatement ps conn.prepareStatement( SELECT COUNT(*) FROM booking WHERE room_id ? AND book_date ? AND status 1 AND start_time ? AND end_time ?)) { ps.setLong(1, roomId); ps.setDate(2, day); ps.setTime(3, et); ps.setTime(4, st); try (ResultSet rs ps.executeQuery()) { rs.next(); if (rs.getInt(1) 0) { conn.rollback(); return false; } } } // 3. 状态从 0 改到 1带 status 0 做乐观兜底 try (PreparedStatement ps conn.prepareStatement( UPDATE booking SET status 1, reviewer_id ?, review_time NOW() WHERE booking_id ? AND status 0)) { ps.setInt(1, reviewerId); ps.setLong(2, bookingId); if (ps.executeUpdate() ! 1) { conn.rollback(); return false; } } conn.commit(); return true; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignore) {} } throw new RuntimeException(审批失败 bookingId bookingId, e); } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignore) {} } } }重叠判定的公式是这段代码的核心已有开始 本次结束 AND 已有结束 本次开始。不能用BETWEEN因为BETWEEN是闭区间10:00–11:00 和 11:00–12:00 在闭区间语义下会被判成重叠导致相邻时段永远排不上也不能只判一端只判开始时间会漏掉“新申请完全包含已有申请”的情况。FOR UPDATE锁的是booking表里的那一行不是整个教室的间隙所以第 2 步的复查不可省。如果希望更严格一点可以在第 1 步之前对classroom表对应行加锁SELECT room_id FROM classroom WHERE room_id ? FOR UPDATE把互斥粒度提升到“一间教室”实现简单、逻辑清楚代价是并发降低。课程设计场景下这种保守做法反而更适合讲清楚。4.3 分页与筛选LIMIT OFFSET 的两个边界列表页的查询通常是条件拼接加排序分页SELECT b.booking_id, b.room_no, b.book_date, b.start_time, b.end_time, b.status, u.real_name FROM booking b JOIN sys_user u ON u.user_id b.user_id WHERE b.status ? AND b.book_date BETWEEN ? AND ? ORDER BY b.book_date DESC, b.start_time DESC, b.booking_id DESC LIMIT ? OFFSET ?;ORDER BY后面一定要带主键兜底。只按book_date和时间排序时同一天同一时段的多条记录顺序不稳定翻到第二页会看到第一页出现过的记录或者某条记录在两页里都查不到——这不是数据库出错是排序键不唯一导致的。加上b.booking_id DESC之后顺序确定翻页就稳了。LIMIT ? OFFSET ?在OFFSET上万之后会变慢因为要先扫描并丢弃前面所有行。课程设计的数据量通常到不了这一步但如果想多拿一点分可以把深分页改成游标式-- 上一页最后一条的 booking_id 作为游标翻页不再随页码变慢 SELECT booking_id, room_no, book_date, start_time, end_time, status FROM booking WHERE status 1 AND booking_id ? -- 上一页最后一条的主键 ORDER BY booking_id DESC LIMIT 20;这种写法要求前端传的是“上一页最后一条的 id”而不是页码代价是不能随意跳页但翻页性能恒定。答辩时把两种写法摆在一起对比能直接说明为什么大表分页不用OFFSET。5. 常见问题排查乱码、1452 外键错与时间段重叠的五个现场这一章的五条都是我在类似项目里真实遇到过、并且每次带人做课程设计都会再出现一遍的问题。按“现象、原因、解决”写照着对号入座就行。5.1 插入中文报 Incorrect string value现象执行INSERT时报Incorrect string value: \xE4\xB8\xAD\xE6\x96\x87 for column purpose或者插进去之后SELECT出来是一串问号。原因有三层得逐层排库或表的字符集不是utf8mb4老脚本里常见DEFAULT CHARSETutf8那是三字节的utf8mb3存不了四字节字符也存不全部分中文符号连接串没写characterEncoding命令行导入时没加--default-character-setutf8mb4。解决先查现状SHOW CREATE TABLE booking;看表尾的CHARSET。如果是utf8执行ALTER TABLE booking CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;注意这一步会重建表数据量大时慢。然后确认连接串里带了characterEncodingUTF-8。最后在导入脚本前加一句SET NAMES utf8mb4;把客户端、连接、结果集三处编码一次对齐。5.2 导入测试数据报 1452 Cannot add or update a child row现象跑到20_seed.sql一半报ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails。原因基本是插入顺序或者外键值对不上先插了booking再插classroom或者测试数据里写死了room_id 1..30而classroom表实际只造了 20 条第 21 条就找不到父行又或者sys_user的自增起点不是 1user_id 3指向的根本不是学生账号。解决脚本顺序固定为被引用表在前测试数据里的外键值不要手写常量用SELECT ... FROM classroom WHERE room_id ...的形式从已有数据取像 3.3 节那段存储过程一样。SET FOREIGN_KEY_CHECKS 0;只能作为临时绕过手段导完立刻改回 1否则后面所有脏数据都进得来等到答辩演示时才发现借了一条不存在的教室那时更难查。5.3 时间区间判断用 BETWEEN相邻时段全被算成冲突现象10:00–11:00 已经通过了再申请 11:00–12:00 却被判成冲突或者反过来9:00–10:00 和 9:30–10:30 两条都被通过了。原因BETWEEN是闭区间端点相等也算命中另一部分人只判一端写成start_time BETWEEN ? AND ?只检查开始时间是否落在已有区间内完全没管新申请比已有申请长的情况。解决统一用半开区间语义写成已有start 本次end AND 已有end 本次start两个条件缺一不可。这条规则对DATE和TIME都适用排课冲突检测按节次也可以照这个形式改写s1.start_section 本次end AND s1.end_section 本次start因为节次是整数端点相等要算冲突这里反而是闭区间。5.4 分页的总条数和列表对不上现象前端显示“共 57 条”但翻到第三页就空了或者同一条记录在第一页和第二页各出现一次。原因有两个总条数用的是SELECT COUNT(*) FROM booking b JOIN sys_user u ...而列表查询加了去重或者额外过滤条件两个 SQL 的WHERE没保持一致另一种是ORDER BY的字段有重复值数据库返回顺序不稳定。解决把WHERE条件抽成一个公共片段列表和计数复用同一段拼接逻辑ORDER BY末尾补上主键。另外注意COUNT(*)与COUNT(DISTINCT b.booking_id)的区别——如果 join 之后一对多把行数放大了COUNT(*)会偏大这种情况必须用DISTINCT。5.5 服务跑一夜之后第一个请求必然超时现象本地演示没问题第二天早上打开页面第一次查询卡十几秒然后报Communications link failure刷新一下又正常了。原因连接池里的空闲连接被 MySQL 按wait_timeout单方面关闭了池子并不知道仍然把这条死连接分配出去。默认wait_timeout是 28800 秒普通演示很难触发但很多人图省事把它调到 60 秒问题就天天出现。解决池的maxLifetime必须显著小于数据库的wait_timeout比如后者 600 秒、前者 300 秒同时打开connectionTestQuery或validationTimeout让池在借出前做一次探活。别图快去改数据库的wait_timeout来迁就代码那只是把问题推给下一个接手的人。6. 答辩前验一遍用 EXPLAIN 和双会话把索引与隔离级别坐实功能跑通只是及格线能说清“为什么这么设计”才是分差所在。答辩前我一般会花二十分钟做两件事都能直接留下截图放进报告。第一件是看执行计划。把借用的核心查询前面加EXPLAINEXPLAIN SELECT booking_id, room_no, book_date, start_time, end_time, status FROM booking WHERE room_id 12 AND book_date 2025-03-15 AND status 1 ORDER BY start_time;重点看三列type是ref或range说明走了索引是ALL就是全表扫描key显示实际用了哪个索引如果显示idx_user_created而不是idx_room_date_status说明优化器选错了通常是统计信息过期跑一次ANALYZE TABLE booking;再看rows是预估扫描行数和实际结果行数差一个数量级就说明索引选择性不好。把“加索引前 typeALL、加索引后 typeref”这两张截图放进报告比写十行“本系统做了索引优化”有说服力。第二件是验证隔离级别。开两个客户端连同一个库A 里SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT COUNT(*) FROM booking WHERE status 0;B 里插一条新的待审记录并提交回到 A 再查一次条数不变——这就是可重复读的快照读在起作用。然后在 A 里执行UPDATE booking SET status 1 WHERE booking_id 新插入的 id;再SELECT一次会发现这条新记录出现了因为更新用的是当前读。这个现象能解释清楚“为什么审批时要用FOR UPDATE而不是普通SELECT”答辩时是个很扎实的点。还有一个具体技巧把 3.3 节的存储过程参数改成 2000重新造一遍数据再打开列表页翻到最后一页。如果翻页明显卡顿就是你那段LIMIT OFFSET的问题如果一切顺畅说明索引和分页都到位了。数据量小的时候所有写法看起来都对只有把量堆上去问题才露头。血泪经验是我第一次做这类课程设计时把所有验证都留到答辩当天早上结果EXPLAIN一跑发现三个查询全是全表扫描临时加索引又忘了ANALYZE TABLE优化器根本没用上。从那以后我每次交作业前都强制走一遍“造 2000 条数据、跑 EXPLAIN、开双会话验隔离级别”这套动作二十分钟能省掉当天一整个上午的慌。希望帮到你。本文还有配套的精品资源点击获取