免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

基于Java的教务系统开发:从表结构设计到并发选课的完整实战

基于Java的教务系统开发:从表结构设计到并发选课的完整实战 简介基于 Java 开发的教务系统是一套面向后端初学者的 SSM 整合练手项目适合正在学习 Spring、SpringMVC、MyBatis 和 Shiro 安全框架的开发者。项目以教务查询为切入点涉及管理员、教师、学生三类角色覆盖登录认证、选课管理等典型场景帮助理解企业级分层架构与权限控制思路对理清项目组织方式也很有帮助。压缩包共 271 个文件约 35.32MB包含 55 个 Java 源码、42 个 XML 映射与配置、28 个 JSP 页面、31 个依赖 Jar 包、2 个 SQL 数据库脚本以及工程文件、说明文档和编译产物导入 IDE 并初始化数据即可对照运行。目前已有 412 人学习下载特别适合作为课程设计、毕业设计或入职前巩固 SSM 的实战参考。通过阅读源码与配置可掌握 Shiro 认证授权、C3P0 数据源整合、Bootstrap 前端布局以及 log4j 日志处理等关键技能逐步熟悉 SSM 项目从搭建到实现的完整过程。1. 基于Java开发的教务系统为什么值得做、水有多深大学选课高峰时系统卡死、课程余量超卖几乎是每个Java后端工程师都听过的名场面。基于Java开发的教务系统不是个新鲜创意项目而是一块覆盖面很广的经典业务场景学生信息、开课排课、选课、成绩录入与查询、角色权限每一块都在复刻Java Web日常的真实需求。我见过不少拿它当毕设或面试作品的开发者最后都卡在表结构设计和并发选课这两个坎上。这篇笔记就是顺着这两条线往下拆怎么把表建模做扎实怎么用Spring Boot MyBatis-Plus把核心链路跑通以及那些不踩一次根本意识不到的坑。适合正在选方案的Java工程师、准备项目经验的求职者也适合要把老系统重构一遍的团队。2. 先把表结构设计好从5张核心表到MyBatis-Plus生成建表SQL2.1 教务系统的核心域模型6张表撑起主流程12张表才够生产用很多初学者把教务系统设计成学生表、课程表、选课表三张表做完才发现开课、排课、成绩全都塞不进去。原因在于漏了教学班这一层。真实业务里一位老师在2025秋季学期教《Java程序设计》可能带两个班同一门课程会有多个教学班每个教学班有独立的容量、教师、上课时间和选课状态。学生选的是教学班不是课程本身。没有教学班表容量控制、教师课表、成绩归属全都无法落地。核心表我一般这样拆表名职责关键字段student学生基本信息student_no(学号)、name、dept_id、enroll_yearteacher教师信息teacher_no、name、title、dept_idcourse课程信息course_code、course_name、credit、hoursteaching_class教学班开课实例course_id、teacher_id、semester、capacity、selected_countenrollment选课记录student_id、teaching_class_id、status、selected_timescore成绩student_id、teaching_class_id、score、gpa下面再加 sys_user、sys_role、sys_user_role 一张权限三件套以及 semester、department 等维度表。先把这6张业务表的关联理清再去补权限和字典。关键在设计教学班表时把 capacity 和 selected_count 分开存。capacity 是固定容量selected_count 是已选人数二者通过选课事务保证一致。selected_count 可以直接作为并发控制的判断依据没有这个字段选课时就要 count enrollment 表并发一高就会出事。2.2 用MyBatis-Plus根据Java实体类生成建表SQL一个可复现的生成器MyBatis-Plus本身没有把实体类自动建表的官方能力但常见做法是在项目里写一个小的DDL生成工具。原理不复杂实体类上已经标了 TableName、TableId、TableField 注解通过反射把这些信息读出来映射成 MySQL 数据类型拼出 CREATE TABLE 语句。public class DdlGenerator { private static final MapClass?, String TYPE_MAP new HashMap(); static { TYPE_MAP.put(String.class, VARCHAR(64)); TYPE_MAP.put(Integer.class, INT); TYPE_MAP.put(Long.class, BIGINT); TYPE_MAP.put(BigDecimal.class, DECIMAL(10,2)); TYPE_MAP.put(LocalDateTime.class, DATETIME); TYPE_MAP.put(Boolean.class, TINYINT(1)); } public static String buildCreateTableSql(Class? entityClass) { TableName tableName entityClass.getAnnotation(TableName.class); String table tableName ! null ? tableName.value() : camelToUnderline(entityClass.getSimpleName()); StringBuilder sb new StringBuilder(CREATE TABLE ) .append(table).append( (\n); ListString columnDefs new ArrayList(); String primaryKey null; for (Field field : entityClass.getDeclaredFields()) { if (Modifier.isStatic(field.getModifiers())) { continue; } TableField tableField field.getAnnotation(TableField.class); if (tableField ! null !tableField.exist()) { continue; } String column camelToUnderline(field.getName()); String type TYPE_MAP.getOrDefault(field.getType(), VARCHAR(128)); if (field.isAnnotationPresent(TableId.class)) { primaryKey column; columnDefs.add( column type NOT NULL COMMENT field.getName() ); } else { columnDefs.add( column type DEFAULT NULL COMMENT field.getName() ); } } sb.append(String.join(,\n, columnDefs)); if (primaryKey ! null) { sb.append(,\n PRIMARY KEY ().append(primaryKey).append()); } return sb.append(\n) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT) .append(entityClass.getSimpleName()).append(;).toString(); } }逻辑说明先读类上的 TableName 拿到表名没有注解就用驼峰转下划线兜底然后遍历所有非静态字段跳过 TableField(existfalse) 的字段剩余字段按类型映射生成列名和列类型。TableId 标记的字段作为主键。参数说明TYPE_MAP 是核心String 默认给了 VARCHAR(64)如果课程名称、学生姓名这类字段经常超长生成后手动把对应列改成 VARCHAR(128) 或更大。Integer 映射 INTBigDecimal 映射 DECIMAL(10,2)LocalDateTime 映射 DATETIME。字段注释直接用 Java 字段名实际项目里建议在实体上补 ApiModelProperty 或自定义注解把中文注释带进去。这个类在本地 main 方法里跑一遍就能输出全部建表语句也可以放到启动类里加一个-DgenerateDdltrue开关测试环境自动执行生产环境关闭。生成后把 SQL 交给 DBA 或直接执行比手写几十张表快得多而且实体和表结构天然对齐不会出现改了字段忘了改表的情况。2.3 外键取舍与冗余字段为什么生产环境不建物理外键建表时很多新手喜欢把外键约束直接加到表上比如 enrollment.student_id 外键指向 student.student_id。教务系统最怕的就是这种设计原因有两个一是学生转专业、休学、结业时状态频繁变化物理外键会挡住很多日常操作二是 InnoDB 的外键在插入和删除时要额外做一致性检查选课高峰期会放大锁竞争。我的做法是不建物理外键只建普通索引关联关系在应用层通过事务保证。代价是需要自己处理删了学生但选课记录还在这类孤儿数据所以要在服务层统一封装删除逻辑先删选课记录再删学生。冗余字段在这个场景很值得用。教学班表里冗余 course_name、teacher_name选课记录里冗余 course_name、teacher_name、semester。学生查课表时一条 SQL 就出来了不用 join 三张表。数据冗余和管理成本需要权衡但教务系统的这类字段基本不会变冗余的性价比很高。另一个要注意的是逻辑删除。主数据表尽量不要物理删除用 deleted 字段标记。但逻辑删除和唯一索引有冲突学号、教师工号这类唯一字段会因为在表里留着历史记录导致再次插入报重复键这个坑在第4章专门讲。3. Spring Boot MyBatis-Plus核心接口落地选课、成绩与权限拦截3.1 选课接口的事务边界与原子更新选课是教务系统并发压力最集中的接口。最朴素的写法是先查已选人数小于容量再插入但两个请求同时查到人数为199、容量200然后都执行插入选课记录就变成201条。加事务也解决不了默认隔离级别下两个事务读到的是同一个旧值。解决方式是把判断和更新合并成一条原子 UPDATETransactional(rollbackFor Exception.class) public void enroll(Long studentId, Long teachingClassId) { TeachingClass teachingClass teachingClassMapper.selectById(teachingClassId); if (teachingClass null || !OPEN.equals(teachingClass.getStatus())) { throw new BusinessException(教学班不存在或未开放选课); } int affected teachingClassMapper.incrementSelectedCountIfBelowCapacity( teachingClassId, teachingClass.getCapacity()); if (affected 0) { throw new BusinessException(课程余量不足); } try { enrollmentMapper.insert(new Enrollment() .setStudentId(studentId) .setTeachingClassId(teachingClassId) .setStatus(SELECTED)); } catch (DuplicateKeyException e) { throw new BusinessException(您已选过这门课); } }对应的 Mapper 方法用注解写Update(UPDATE teaching_class SET selected_count selected_count 1 WHERE teaching_class_id #{teachingClassId} AND selected_count #{capacity}) int incrementSelectedCountIfBelowCapacity(Param(teachingClassId) Long teachingClassId, Param(capacity) Integer capacity);逻辑说明UPDATE 语句本身是数据库行级原子操作两个并发事务同时执行时行锁会让其中一个等待等它拿到锁时 selected_count 已经被前一个事务加过1容量条件就不满足了影响行数为0超卖请求被直接拦截。参数说明capacity 必须从 teaching_class 表里查出来再传入不能在 SQL 里写死。如果选课规则改成大一新生只能选容量的一半还能在这条 UPDATE 上再加年级条件灵活性比锁方案高。enrollment 表要建 (student_id, teaching_class_id) 唯一索引重复选课靠 DuplicateKeyException 兜底比先查再判断更可靠。事务边界只包住更新容量插入选课记录两步。查询教学班状态放在事务外面减少锁持有时间。选课高峰期这类写事务要短越短越好。3.2 批量成绩导入从Excel到数据库的正路成绩录入是教务系统另一个高频场景。老师拿着 Excel 成绩单上传后台逐行解析插入。最蠢的做法是 for 循环里逐条 insert几百行数据能跑几十秒。用 EasyExcel 的监听器按批收集攒够一批再批量插入public class ScoreDataListener extends AnalysisEventListenerScoreImportRow { private final ListScoreImportRow cache new ArrayList(); private static final int BATCH_SIZE 500; Override public void invoke(ScoreImportRow row, AnalysisContext context) { cache.add(row); if (cache.size() BATCH_SIZE) { saveBatch(cache); cache.clear(); } } private void saveBatch(ListScoreImportRow rows) { ListString studentNos rows.stream() .map(ScoreImportRow::getStudentNo).collect(Collectors.toList()); MapString, Long studentIdMap studentMapper.selectList( new LambdaQueryWrapperStudent().in(Student::getStudentNo, studentNos)) .stream().collect(Collectors.toMap(Student::getStudentNo, Student::getId)); ListScore scoreList rows.stream() .filter(row - studentIdMap.containsKey(row.getStudentNo())) .map(row - new Score() .setStudentId(studentIdMap.get(row.getStudentNo())) .setTeachingClassId(row.getTeachingClassId()) .setScore(row.getScore())) .collect(Collectors.toList()); scoreService.saveBatch(scoreList); } }逻辑说明监听器按500行批量 flush 一次避免 Excel 行数大时内存堆积。Excel 里的学号必须先批量查成 student_id不能逐条去查数据库否则上千行 Excel 会产生上千次 SQL。查不到学号的行直接过滤掉后续再单独生成错误报告给老师核对。参数说明BATCH_SIZE 建议控制在 200~500太小批量效果不明显太大单次 insert 的 SQL 太长MySQL 的 max_allowed_packet 可能报错。saveBatch 是 MyBatis-Plus 自带的批量插入内部也是拼接 SQL不是逐条执行。score 表要建 (teaching_class_id, student_id) 唯一索引。同一个老师重复上传成绩单时saveBatch 会抛 DuplicateKeyException捕获后按业务规则决定覆盖还是跳过否则整批回滚老师又要重新上传。3.3 轻量RBAC注解加拦截器够用就好教务系统的角色模型很固定管理员、教师、学生。接口级权限用 Spring Security 当然可以但中小系统用 Security 的过滤器链反而增加配置成本出问题时排查链路长。常见做法是自定义一个 RequireRole 注解配合 HandlerInterceptor 做拦截代码透明、也容易讲清楚。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); }拦截器的核心逻辑public class RoleInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; RequireRole requireRole handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole null) { return true; } LoginUser loginUser (LoginUser) request.getSession().getAttribute(loginUser); if (loginUser null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } String[] roles requireRole.value(); if (!Arrays.asList(roles).contains(loginUser.getRoleCode())) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); return false; } return true; } }逻辑说明拦截器先判断 handler 是不是 HandlerMethod静态资源直接放行。拿到方法上的 RequireRole 注解后从 session 里取当前登录用户角色不匹配就返回403。角色信息在登录时写入 session 或 Redis避免每次请求都查库。参数说明RequireRole 可以标在类上做全局控制也可以标在方法上做精细控制。管理员接口标{ADMIN}教师接口标{ADMIN, TEACHER}学生接口标所有角色。这个方案对权限粒度要求不高的场景完全够用但要注意一个问题角色是硬编码在注解里的如果后面要支持某个教师只能录入自己教学班的成绩光靠角色不够还要在 Service 层加数据权限校验。4. 教务系统避坑指南并发选课、事务失效、分页查询和时区的5个血泪教训4.1 并发选课超卖为什么库存减了还是超员现象容量200的教学班选课记录查出来203条teaching_class 表里的 selected_count 停在200不动。原因典型的读改写问题。初始代码逻辑是先查已选人数小于容量再插入两个请求同时读到已选人数199同时通过判断先后插入成功。加了事务也一样MySQL 默认的 REPEATABLE READ 隔离级别下两个事务读到的都是快照判断结果一致。解决把判断和更新合并成一条原子 UPDATE影响行数为0直接报课程已满。这个方案在单库场景下是首选因为它利用了 InnoDB 的行锁不依赖任何外部组件。真正的局限在于写入压力大时所有请求串行排队吞吐上不去这时才需要考虑第5章的分布式锁方案。4.2 事务注解失效同类调用和异常被吞掉现象enroll 接口里调用了同类中的另一个事务方法方法内部抛了 SQL 异常被 try-catch 住了日志里能看到报错但前面的数据更新照样提交了。原因Spring 事务基于 AOP 代理实现同类里 this.enroll() 这种调用不会经过代理对象Transactional 注解形同虚设。另一个常见原因是方法内 try-catch 把异常吞掉事务拦截器感知不到回滚信号不会有任何回滚动作。解决事务方法写在另一个 Bean 里通过注入调用或者在自己的类里注入自身代理。catch 住受检异常后必须重新抛出 RuntimeException或者用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚同时把 Transactional 的 rollbackFor 设为 Exception.class。这些细节在 java 面试题里常被拿出来考但真正的翻车现场往往就是这种不起眼的地方。4.3 MyBatis-Plus分页查询返回全表数据现象写了个分页查询PageEnrollment page new Page(1, 10);执行后 records 返回全表几千条数据total 却是0。原因MyBatis-Plus 的分页功能依赖分页插件插件没有注册到 MybatisConfiguration 时Page 对象会被当成普通参数SQL 不拼 LIMIT查询结果就是全量数据。解决在配置类里注册 MybatisPlusInterceptor并添加分页内部拦截器Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setOverflow(false); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; }参数说明maxLimit 限制单页最大记录数防止有人把 pageSize 传成10000直接拖垮数据库。多表 join 分页时 MyBatis-Plus 的 count 统计经常不准要重写 countSql 或手动指定。分页插件和其他插件乐观锁、防全表更新有加载顺序要求顺序不对会导致插件不生效。4.4 逻辑删除与唯一索引冲突学号还能不能再次注册现象学生退学后逻辑删除第二年新生注册时使用同一个学号插入报 Duplicate entry明明记录已经删掉了。原因逻辑删除用 deleted0/1 标记退学学生的记录还在表里student_no 上的唯一索引不会被释放。解决把删除标记从 0/1 改成 删除时置为当前主键 id 或时间戳同时把唯一索引改成联合索引 (student_no, deleted)。删除时执行UPDATE student SET deleted id WHERE id ?被删记录的 deleted 值是自己的主键任何新记录都不会与之冲突。这个方案注意所有查询都要显式加 deleted 过滤条件否则会把历史数据查出来。4.5 时区问题凌晨录入的成绩时间错了8小时现象本地开发一切正常部署到服务器后创建时间字段比北京时间晚了8小时或早了8小时。原因JDBC 连接参数没有指定 serverTimezoneMySQL 连接器用了服务器时区或默认 UTC和业务期望的东八区不一致。解决在 JDBC URL 里明确指定时区jdbc:mysql://localhost:3306/academic?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai同时实体里的时间字段统一用 LocalDateTime少用 java.util.Date。LocalDateTime 不携带时区信息序列化和反序列化都不会被时区干扰。改了连接参数还不对的去数据库执行SELECT global.time_zone, session.time_zone看看到底是什么时区云数据库默认 UTC 的情况很常见。5. 数据一致性与查询性能从联合索引到分布式锁的取舍5.1 联合索引顺序先过滤后排序别让索引失效教务系统查询模式相对固定做索引前先把高频查询列出来别一张表建十个单列索引。学生课表查询是SELECT * FROM enrollment WHERE student_id ? AND semester ?对应索引 (student_id, semester)。教学班名单查询是WHERE teaching_class_id ? ORDER BY student_id对应索引 (teaching_class_id, student_id)。成绩按学生查时是WHERE student_id ? AND semester ?score 表建 (student_id, semester) 联合索引。联合索引的列顺序按区分度从高到低放student_id 是等值查询放前面semester 是范围过滤放后面。范围查询放前面的列会导致后面的列无法走索引。索引失效的常见写法也要留意。WHERE YEAR(selected_time) 2025会对 selected_time 做函数运算索引直接失效全表扫描。应该写成selected_time 2025-01-01 00:00:00 AND selected_time 2026-01-01 00:00:00。student_no 列是 VARCHAR查询条件里传了数字MySQL 会做隐式类型转换索引也用不上。这类问题排查起来靠 EXPLAIN看到 typeALL 或 rows 巨大就要回头改 SQL。等数据量上来再想改就没有后悔药了。5.2 并发再高一个级别分布式锁的粒度和释放时机第3章的原子 UPDATE 在单库场景下已经够用但服务部署多个实例时每台机器各有一份进程内锁互相管不到。Redis 分布式锁是常见的升级方案用 Redisson 的 RLock 比手写 SETNX 可靠自带看门狗续期和可重入。锁的粒度一定要按教学班来不能锁全局。选课高峰期一个锁控制所有课程性能会很难看public void enrollWithLock(Long studentId, Long teachingClassId) { RLock lock redissonClient.getLock(teach:class: teachingClassId); lock.lock(10, TimeUnit.SECONDS); try { enrollInTransaction(studentId, teachingClassId); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这里最关键的坑是释放锁的时机。如果锁内的 enrollInTransaction 走的是 Spring 的 Transactional事务提交发生在方法返回之后、解锁之前那另一个线程等锁释放后拿到的数据是旧快照或未提交数据超卖会换个姿势再出现。我一般会在 enrollInTransaction 内部用 TransactionTemplate 控制事务边界确保锁释放前事务已经提交完成而不是把事务挂在服务方法外面。锁的过期时间要设得比业务峰值耗时大Redisson 的看门狗会自动续期但手写 SETNX 就要自己处理。5.3 缓存要不要上课程余量放Redis的收益和代价教务系统动不动就提缓存和消息队列但我的看法是先别急。选课高峰的并发量在秒级几百到几千MySQL 一条原子 UPDATE 的行锁完全扛得住。把余量放 Redis 反而引入缓存和数据库一致性问题余量算错一次就够被骂的。如果一定要做选删除缓存而不是更新缓存。选课成功先改库再删除余量缓存下次请求回填。并发请求同时更新缓存会把旧值覆盖成新值而删除缓存则让每个读请求自己回填一致性最好。课程名称、教师姓名这类低频变更数据更适合放本地缓存 Caffeine省网络开销。做系统的原则是复杂度要用在刀刃上。缓存、分布式锁、MQ 都是一种复杂度问题没到那个量级就不引入。等真出现数据库行锁等待严重、扩展需要多实例支撑时再按流量逐步加。6. 把项目做成能演示、能聊的面试作品测试数据、打包与亮点提炼6.1 最小可演示的数据准备与打包命令行政班、教师、课程基础数据手写 SQL 太痛苦用 Java 循环生成再批量插入就行。教学班和选课记录要保证 selected_count 和实际选课记录数一致否则页面上数字对不上面试官多问两句就露馅。成绩数据建议用正态分布生成60~90分居多挂科的给几个列表页看起来才真实。打包部署是最朴素的 Spring Boot 方式mvn clean package -DskipTests java -jar target/academic-0.0.1.jar --spring.profiles.activeprodjava 启动失败怎么解决第一件事永远是看日志里的 Caused by 部分缺表、端口占用、数据库连不上全在那里。多 JDK 的环境要确认当前默认版本我吃过一次亏pom 里配的是 JDK 17机器默认 JDK 8启动直接 UnsupportedClassVersionError。6.2 让项目成为面试谈资的三个关键点面试时不用背流程把三个问题讲清楚就够。第一选课超卖怎么通过数据库原子更新解决以及多实例场景下为什么不够分布式锁粒度怎么定。第二索引怎么从查询场景反推联合索引的列顺序依据是什么。第三逻辑删除和唯一索引冲突怎么处理。这三个都是真实业务逼出来的问题比罗列用了什么框架更能体现工程判断力。我自己的体会是教务系统最值钱的部分不是界面是表结构和对并发边界的设计。如果只想跑通增删改查三天就能写完但做完之后能不能把并发选课的原子性、事务传播和索引失效讲明白才是这个项目真正的价值所在。这些坑我在面试中反复被问到也在真实项目里见过同事翻车。希望帮到你。本文还有配套的精品资源点击获取
返回列表