教务管理系统实战:SpringBoot+Mybatis+MySQL核心难点解析
简介一份基于SpringBoot与Mybatis构建的教务管理系统完整项目面向Java Web课程设计、毕业设计及初学SpringBoot整合实战的开发者。系统内置管理员、教师、学生三种角色覆盖课程管理、学生选课退课、教师打分、密码修改等完整业务闭环并集成Shiro安全框架、PageHelper分页及Bootstrap前端界面代码结构清晰附带SQL脚本导入即可运行。包体共361个文件以xml配置与mapper映射、java源码、class编译文件、jsp页面为主辅以png图片、css/js样式脚本及SQL初始化脚本压缩包整体仅3.91MB适合快速下载与本地部署调试。已有474人学习下载无论是用于课程设计答辩、毕业设计参考还是学习SpringBootMysql的权限管理实践都具备较强的参考价值。资源附带在线预览地址和测试账号方便对照功能模块理解实现思路部署上手门槛低。1. 教务管理系统用 Java SpringBoot Mybatis Mysql 做核心难点在哪很多课程设计和毕业设计把教务管理系统做成“登录 几个 CRUD 页面”交上去也能跑但答辩时被问一句“选课怎么防止一个人选同一门课两次”就卡住。教务管理系统用 Java SpringBoot Mybatis Mysql 这套技术栈真正的门槛不在框架用法而在数据关系建模和事务边界学生、教师、课程、成绩之间是典型的多对多关系选课表要承载唯一约束成绩录入要保证学分和绩点统计的一致性。这篇文章按我自己做这类系统的顺序来写先立表模型和 Mybatis 映射再跑通登录与选课的最小链路然后处理事务和缓存坑最后是答辩前值得打磨的查询细节。新手能照着建表、写 Mapper、调通接口有经验的人可以在事务边界和缓存问题上看到可复用的判断标准。2. 教务管理系统的表模型与 Mybatis 数据映射设计2.1 教务管理系统的核心实体关系先画 ER 再建表教务管理系统最常见的核心实体是学生、教师、课程和选课。学生和课程是多对多关系一张选课表把这个关系拆成两个一对多成绩不要直接挂在学生表上而是挂在选课记录上这样同一门课重修、补考才有地方存多次成绩。下面 5 张表是课程设计里比较稳的起点。表名职责关键字段student学生基本信息id、stu_no、name、password、class_nameteacher教师基本信息id、tea_no、name、password、titlecourse课程信息id、course_no、course_name、credit、teacher_idcourse_selection选课记录成绩也放这里id、student_id、course_id、semester、scoreuser登录账号可选id、username、password、role我一般会把登录账号和业务表分开课程设计里偷懒把学号当用户名是可以的但分开写表在答辩时更好解释“为什么这么设计”。score 字段放在选课表里查某个学生的成绩单就变成“选课表 join 课程表”不需要额外再建成绩表。实际做的时候也遇到过把成绩单独建表导致统计平均分要 join 三张表的写法不是不能做是没必要。2.2 用 Mysql 建表时把约束写进 DDL教务管理系统里“数据不能错”比“接口能跑”更重要所以建表时把约束直接写进 DDL。下面是一组可以在本地 Mysql 直接执行的建表语句覆盖了选课唯一性这个最容易出问题的点。CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, stu_no VARCHAR(20) NOT NULL COMMENT 学号, name VARCHAR(50) NOT NULL COMMENT 姓名, password VARCHAR(64) NOT NULL COMMENT 密码建议存BCrypt哈希, class_name VARCHAR(50) DEFAULT NULL COMMENT 班级, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_no (stu_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生表; CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL COMMENT 课程编号, course_name VARCHAR(100) NOT NULL COMMENT 课程名称, credit DECIMAL(3,1) NOT NULL COMMENT 学分如2.5, teacher_id BIGINT DEFAULT NULL, UNIQUE KEY uk_course_no (course_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表; CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, semester VARCHAR(20) NOT NULL COMMENT 学期如2024-2025-1, score DECIMAL(5,2) DEFAULT NULL COMMENT 成绩NULL表示未录入, UNIQUE KEY uk_stu_course_semester (student_id, course_id, semester) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选课表;这三条 SQL 里的重点是uk_stu_course_semester这个联合唯一索引。它保证了“同一个学生在同一个学期不能选同一门课两次”即使你的代码忘了查重数据库也会在第二次插入时报 DuplicateKeyException。学分用DECIMAL(3,1)而不是 DOUBLE因为浮点数在 Mysql 里做求和会有精度问题而教务系统的绩点计算对精度很敏感。成绩用DECIMAL(5,2)也是同理。需要注意的是我建议在作业里用逻辑关联代替物理外键不写FOREIGN KEY约束只有普通索引。原因是物理外键在删除课程或批量导入时容易互相牵制而且 Mybatis 批量插入时外键检查会拖慢速度。带有经验的评审老师问“为什么没外键”可以说“用应用层事务保证一致性索引保证查询性能”这是实践中常见的取舍。2.3 Mybatis 的 resultMap 与驼峰映射从表到实体的两种写法表建好后Mybatis 的映射配置决定了 Java 代码里拿到的是“对的对象”。最省事的做法是开启驼峰映射让course_name自动对应courseName。在 SpringBoot 的 application.yml 或 mybatis-config.xml 里都可以开我习惯放在 application.yml因为配置集中。mybatis: configuration: map-underscore-to-camel-case: true然后实体类就可以只写字段、不写一堆 TableField 注解。但有一个点必须注意当查询结果包含“学生名 课程名 成绩”这种多表拼接时Java 对象里需要一个 VO 类不能把课程名塞进 Student 实体里。另一种更复杂的情况是用resultMap做嵌套结果映射这里用一个课程和它的选课学生的例子来说明。resultMap idCourseStudentMap typecom.example.demo.vo.CourseVO id propertyid columnc_id/ result propertycourseName columncourse_name/ collection propertystudents ofTypecom.example.demo.entity.Student id propertyid columns_id/ result propertystuNo columnstu_no/ result propertyname columnname/ /collection /resultMap select idselectCourseWithStudents resultMapCourseStudentMap SELECT c.id AS c_id, c.course_name, s.id AS s_id, s.stu_no, s.name FROM course c LEFT JOIN course_selection cs ON c.id cs.course_id LEFT JOIN student s ON cs.student_id s.id WHERE c.id #{courseId} /select这里的关键参数是resultMap里的id标签它告诉 Mybatis 哪一列是对象的唯一标识collection会根据这个 id 自动把多条学生记录合并到同一个课程对象里避免重复创建课程对象。用到一个比较典型的注意点如果列的别名不唯一比如课程表和学生表都有 name 字段务必要AS重命名否则 Mybatis 会拿到错误的列值。这种连表查询在“查看某门课的选课名单”里非常常用。3. SpringBoot 集成 Mybatis 跑通登录与选课的完整链路3.1 项目依赖与 yml 参数先解决“框架版本匹配”这个隐藏坑SpringBoot 集成 Mybatis 的第一步不是写代码是把依赖版本对齐。SpringBoot 3.x 需要 JDK 17对应的 mybatis-spring-boot-starter 要用 3.xSpringBoot 2.x 用 2.x 的 starter。总有人拿 SpringBoot 2.7 去配 mybatis-spring-boot-starter 3.2结果启动直接报Failed to auto-configure a DataSource原因不是数据库没启动而是 starter 与 SpringBoot 版本不匹配。下面这组依赖是 SpringBoot 2.7 项目的写法。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency注意 Mysql 8.x 的驱动包名是com.mysql.cj.jdbc.Driver老资料里写的com.mysql.jdbc.Driver在 8.x 会报警告或直接连不上。对应的 yml 配置里有一串很关键的 URL 参数。spring: datasource: url: jdbc:mysql://localhost:3306/edu?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplURL 里的serverTimezoneAsia/Shanghai要解释一句Mysql 8.x 默认时区与 JDBC 驱动不一致时会在日期字段上出现 8 小时偏移课程设计里表现成“选课时间比实际慢 8 小时”。useSSLfalse是本地开发减少握手开销的常规做法生产环境不要这么写。log-impl开启后 SQL 会直接打印在控制台这是排查 Mybatis 问题的第一手段比任何断点都快。3.2 登录接口用一个 Mapper 接口加一条 SQL 讲清楚 Mybatis 工作方式登录是教务管理系统里最容易被轻视的模块实际上它最能体现 Mybatis 的基本工作方式。流程是 Controller 接收用户名密码Service 调用 Mapper 查出一个用户对象再对比密码。注意这里只负责“按用户名查出用户”密码比对放在 Service 层不要写WHERE password #{password}这样的 SQL因为一旦后面引入 BCrypt 加密这条 SQL 就失效了。Mapper public interface UserMapper { User findByUsername(String username); }select idfindByUsername resultTypecom.example.demo.entity.User SELECT id, username, password, role FROM user WHERE username #{username} LIMIT 1 /select#{username}是 Mybatis 的预编译占位符底层用 PreparedStatement能防 SQL 注入。这里要特别提醒不要为了拼接动态条件写成${username}${}是字符串拼接用户输入admin OR 11直接就把你的登录绕过了。课程设计的答辩环节老师非常喜欢问这一句答“项目里我用 #{} 预编译不用 ${} 做参数拼接”是个很加分的点。如果你选择不用 XML也可以用注解Select(SELECT ... WHERE username #{username})写在接口上代码更少。但教务管理系统的 SQL 会随着业务变长动态条件会越来越多注解里的字符串拼 SQL 很难维护所以我的做法是单表简单查询用注解复杂连表查询用 XML两者可以混用。3.3 选课的最小实现把“查重 插入”放进一个事务选课是最典型的“先检查后写入”业务。一个学生点选课时程序要判断课程是否存在、是否已选过、是否超过人数上限然后插入选课记录。这里最大的问题是并发两个请求同时进来都查到“未选”然后都插入成功联合唯一索引会兜底挡住一个但抛出的 DuplicateKeyException 需要被正确捕获和处理。下面是最小实现。Service public class CourseSelectionService { Autowired private CourseSelectionMapper selectionMapper; Transactional(rollbackFor Exception.class) public void selectCourse(Long studentId, Long courseId, String semester) { int count selectionMapper.countByStudentAndCourse(studentId, courseId, semester); if (count 0) { throw new BusinessException(该课程本学期已选请勿重复选课); } int rows selectionMapper.insert(studentId, courseId, semester); if (rows ! 1) { throw new BusinessException(选课失败请稍后重试); } } }Transactional注解里有个容易忽略的参数是rollbackFor Exception.class。Spring 的事务默认只在 RuntimeException 上回滚如果你抛的是Exception的子类而没指定rollbackFor事务不会回滚成绩录入这类操作会留下半截数据。这里顺手把这个坑填上Transactional只对 public 方法生效同类内部调用 this.selectCourse() 不会走代理事务会失效所以事务方法不要写在私有方法里也不要从同类方法内部调用。insert返回的影响行数是另一个判断依据Mysql 插入成功返回 1失败抛异常返回 0通过rows ! 1判断可以让接口返回更友好的业务提示而不是让异常直接暴露给前端。4. 教务管理系统高频业务的事务控制与 Mybatis 缓存陷阱4.1 成绩录入的事务边界学分、绩点、统计要一起成功或一起失败成绩录入在教务管理系统里是另一个高频业务也是答辩时最容易展开聊的模块。录一个成绩通常要连带更新学生的已获学分、绩点、课程通过状态这些操作必须在一个事务里成绩表更新了但统计没更新期末成绩单就会和数据库对不上。下面用代码展示这个事务边界。Service public class ScoreService { Transactional(rollbackFor Exception.class) public void recordScore(Long selectionId, BigDecimal score) { ScoreRecord record scoreMapper.findByIdForUpdate(selectionId); if (record null) { throw new BusinessException(选课记录不存在); } if (score.compareTo(new BigDecimal(60)) 0) { scoreMapper.updateCredit(selectionId, Boolean.FALSE); } else { scoreMapper.updateCredit(selectionId, Boolean.TRUE); } scoreMapper.updateScore(selectionId, score); statsMapper.refreshStudentStats(record.getStudentId()); } }这段逻辑里值得展开的是findByIdForUpdate。SELECT ... FOR UPDATE会把这行选课记录锁住直到事务提交这样两个老师同时给同一个学生录成绩时后一个事务等前一个提交才能继续避免“最后一次写入覆盖前一次”的典型并发问题。离线事务里加这一条成本很低却是“从课程设计到生产思维”的一个明显分界。如果事务里还要做远程调用比如发通知邮件不要把远程调用放在整个事务的大方法里。合理边界是本地数据库操作放在事务内远程通知放到事务提交后的回调里比如 Spring 的TransactionSynchronizationManager.registerSynchronization不然网络慢的时候数据库连接一直被占着教务系统并发一高就出连接池耗尽。4.2 Mybatis 一级缓存、二级缓存在教务系统里的影响与配置Mybatis 有两级缓存但教务管理系统里第二级缓存默认是关闭的这也算一个知识盲区。一级缓存是 SqlSession 级别的SpringBoot 集成的 Mybatis 里SqlSession 默认每次 mapper 操作后关闭所以一级缓存基本不生效。二级缓存是 mapper namespace 级别的开启后同一个 Mapper 的查询结果会被缓存下来。关键问题在于一旦开启二级缓存联表查询如果被缓存在一个 Mapper 的 namespace 里另一边更新了关联表的数据这个缓存不会自动失效。教务系统的选课表、成绩表更新非常频繁开启二级缓存是弊大于利。下面的配置展示了怎么在 mybatis-config.xml 里显式关闭以及单条 SQL 如何绕过缓存。configuration settings setting namecacheEnabled valuefalse/ /settings /configurationselect idfindById resultTypecom.example.demo.vo.CourseVO flushCachetrue useCachefalse SELECT ... FROM course WHERE id #{id} /selectflushCachetrue会在执行这条 select 时清空二级缓存useCachefalse表示这次查询结果不进缓存。我通常在两种情况下用这两个属性一是刚更新完的数据立刻要查回来给前端展示二是报表统计查询本来就要实时数据。顺带说一句很多把 Mybatis 缓存面试题背得滚瓜烂熟的候选人真在项目里反而不清楚自己开了什么碰到教务这类写多读少的系统老老实实每次查数据库是最可靠的。4.3 XML 里单个数字字符比较的坑type 1 为什么永远不相等教务管理系统里经常要按状态过滤数据比如选课状态 1 表示已选2 表示退课3 表示已考。如果你在 Mapper XML 里这样写if testtype 1 AND status 1 /if你会得到一个非常隐蔽的 bug当传入的 type 是 String 类型的 1 时条件不生效查出来的数据永远为空或者不带筛选。原因在于 Mybatis 的 OGNL 表达式里1是字符类型不是字符串String 类型的 1 和 Character 类型的 1 用比较时不相等。if testtype 1 AND status 1 /if正确写法是把判断条件里的引号反过来外单内双让它成为字符串比较。或者更稳妥的做法是用.toString()testtype.toString() 1但也有人不喜欢在判断里调方法。写政务管理、教务管理系统时状态字段特别多我一般会统一规范如果状态值是数字字符串就统一用双引号外层写法并把这个规则写进项目里的编码规范说明防止后面的人再踩一遍。4.4 批量写操作用 foreach但要注意 Mysql URL 参数毕业设计里经常有“批量导入学生名单”“一键给全班录成绩”的需求这就是 Mybatis 批量写操作的典型场景也是热词里被反复检索的“使用 mybatis 进行批量写操作”。最直接的方式是 XML 里用 foreach 拼多条 INSERT 语句。insert idbatchInsertScore INSERT INTO course_selection (student_id, course_id, semester, score) VALUES foreach collectionlist itemitem separator, (#{item.studentId}, #{item.courseId}, #{item.semester}, #{item.score}) /foreach /insertforeach的collection属性接收方法传入的 list 参数separator,负责在两条记录之间补逗号生成的是一条多 VALUES 的 INSERT 语句。几百上千条数据这个写法完全够用Mysql 本身对包大小有max_allowed_packet限制默认 4M 或 64M当批量条数非常大时注意分批比如每 500 条一批。批量写操作还有一个容易被忽视的参数在 JDBC URL 上rewriteBatchedStatementstrue。如果你用的是 Mybatis 的ExecutorType.BATCH或者直接SqlSessionTemplate的批处理模式不加这个参数JDBC 驱动会一条一条发送 SQL性能提升非常有限加上之后驱动会把多条 INSERT 重写成多 VALUES 的形式一次网络往返提交。下面这条 URL 是在 3.1 的基础上追加的。jdbc:mysql://localhost:3306/edu?rewriteBatchedStatementstrueuseAffectedRowstrueserverTimezoneAsia/ShanghaiuseAffectedRowstrue对批量 UPDATE 有实际意义它让返回值为“实际变更的行数”而不是“匹配的行数”在判断“本次批量更新是否真的有数据变化”时很有用。5. 答辩与验收前值得打磨的 Mybatis 查询细节5.1 分页查询不用 PageHelper 也能讲清楚 limit 与 count教务管理系统的学生列表、选课名单都要分页。网上很多资料一上来就引 PageHelper但答辩时老师问“分页原理是什么”答不上来就露馅。我建议在课程设计里手写一个简单的分页查询既避免插件版本兼容问题也方便解释。手写分页的本质是两条 SQL一条 count 查总数一条 limit 查当前页。SELECT COUNT(*) FROM course_selection WHERE semester #{semester}; SELECT * FROM course_selection WHERE semester #{semester} ORDER BY id LIMIT #{offset}, #{pageSize};offset的计算方式是(currentPage - 1) * pageSize在 Service 层算好再传给 Mapper不要在 XML 里做乘法。这条 SQL 在数据量小的时候没问题但教务系统跑了几年后选课记录会有几十万条深分页LIMIT 100000, 20会越来越慢。优化方式是延迟关联先只查主键再用主键去查完整行。SELECT cs.* FROM course_selection cs INNER JOIN ( SELECT id FROM course_selection WHERE semester #{semester} ORDER BY id LIMIT #{offset}, #{pageSize} ) tmp ON cs.id tmp.id;这条 SQL 里子查询只扫主键索引取到 20 个 id 后再回表查完整数据比直接 LIMIT 大偏移量快得多。如果你在答辩里能主动说出这个优化哪怕系统数据量不大也会明显拉开和其他同学的差距。5.2 成绩统计的 GROUP BY 查询resultType 直接复用 Map教务管理系统里“每门课的平均分”“各分数段人数分布”这类统计查询是展示 Mybatis 动态 SQL 能力的最好场景。统计结果没有现成的实体类对应直接用resultTypejava.util.Map最方便。下面这条 SQL 统计每门课的选课人数和平均分。select idselectCourseStats resultTypejava.util.Map SELECT c.course_name AS courseName, COUNT(cs.id) AS selectCount, ROUND(AVG(cs.score), 2) AS avgScore FROM course c LEFT JOIN course_selection cs ON c.id cs.course_id AND cs.semester #{semester} GROUP BY c.id, c.course_name ORDER BY selectCount DESC /selectMap 结果的 key 是列别名所以courseName、selectCount、avgScore这三个 key 写死在 SQL 别名里Java 端取值时保持一致。GROUP BY里的c.id和c.course_name都写上是因为 Mysql 的ONLY_FULL_GROUP_BY模式要求 select 的非聚合列必须出现在 group by 中缺一个就会报错这也是很多人在本地 Mysql 8.x 跑统计 SQL 报错的原因。5.3 用 EXPLAIN 检查你的 Mapper 查询是否走索引这是我认为交付前性价比最高的一步。教务管理系统里的列表查询如果没有索引数据量一到几万条就会卡顿。把 Mapper XML 里那条慢查询的 SQL 拿出来前面加 EXPLAIN 在 Navicat 或命令行里执行一次看两个字段type和rows。type如果是 ALL说明全表扫描需要加索引如果是ref或const说明索引生效明显好转。rows是扫描的行数数字越小越好。mysql EXPLAIN SELECT * FROM course_selection WHERE student_id 1001 AND semester 2024-2025-1;返回的结果里typeref、rows1就是理想状态。如果typeALL那么应该在 student_id、semester 上建联合索引建完语句是ALTER TABLE course_selection ADD INDEX idx_stu_semester (student_id, semester);。这一条命令下去查询响应时间可能从几百毫秒降到个位数毫秒。做课程设计和毕业设计时这一步比加任何缓存都实在也是面试官最能直观感受到的工程意识。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →