☰
基于JavaWeb的学生选课系统毕设实战:数据库建模与并发控制解析
2026/9/28 15:51:40 网站建设 项目流程

简介:一套基于JavaWeb的学生选课系统完整毕业设计资料包,面向计算机相关专业正在准备毕设的学生,也适用于需要项目实战练习的Java开发人群。项目以Servlet、JSP、JDBC、DbUtils构建后台,结合EasyUI、jQuery、Ajax完成交互界面,数据库采用MySQL,系统涵盖学生、教师、系统管理员三种角色,包含学生信息、选课、考勤、请假、成绩等完整管理模块,功能完善且界面美观,操作体验流畅。资料包共13个文件,整体大小约7.67MB,包含项目源码zip压缩包、MySQL数据库脚本sql、项目文档pdf与md、系统运行截图png以及软件工具下载说明txt,其中截图可直观了解各功能模块的实际效果。源码经过严格调试可直接运行,数据库脚本可快速完成初始化,配套文档便于理解项目整体结构。已有6130人学习下载,系统操作简单、管理便捷,既适合作为毕业设计参考选题,也可作为JavaWeb项目开发实战练习的优质资料。

1. 基于JavaWeb的学生选课系统:这个毕设到底在做什么

做JavaWeb毕设的同学,十个里有八个绕不开“学生选课系统”这个题目。它业务完整、边界清楚,选课、退课、成绩管理三条线都能讲明白,又不像电商系统那样容易把项目做崩。也正因为它太常见,“把源码跑起来”和“把项目做完”完全是两码事——数据库怎么建模、并发选课怎么防超容量、IDEA里Tomcat怎么配置,每一步都有拦路石。这篇笔记就沿着这套基于JavaWeb的学生选课系统的源码,把表结构、事务处理、部署流程和答辩追问一次讲透,适合正在做毕设选题、或者拿了源码却不知道怎么落地的同学照着走。

2. 需求拆解与数据库建模:六张表撑起选课、退课与成绩管理

做这类管理系统,数据库设计永远不能省。选课系统的核心不是页面多好看,而是“学生选课、教师开课、管理员排课”这条业务链在表上能不能自洽。

2.1 三种角色的用例边界:管理员、教师、学生各做什么

学生端是最容易想当然的部分:登录后看到课程列表,点击选课,点击退课,期末查看成绩。这四件事背后都牵扯课程表的容量校验和选课表的唯一约束,不是简单的增删改查。

教师端要处理的是“开课信息”和“选课学生名单”。老师可以新增一门课程,也可以在下课之后录入成绩。教师的操作直接影响学生的可用课程池,所以教师表与课程表之间要保留外键关系,但不建议把教师和管理员混在一张表里,而是用角色字段区分,登录后按角色跳转到不同主页。

管理员负责的是基础数据维护:学生账号的导入、教师账号的分配、学期开关。选课系统里最容易被忽略的是“学期”这个概念,如果缺少学期字段,补考重修的学生会产生业务上的数据混乱。因此课程表和选课表都要带semester字段,哪怕默认只填一个值,也要把字段位置留出来。

2.2 核心表设计:课程表容量字段与选课表设计思路

课程表是整套系统的状态中枢。它除了课程名、学分、上课时间、授课教师之外,一定得有capacity和selected_count这两个字段。capacity是课程容量上限,selected_count是当前已选人数。为什么不实时用count()去统计选课表?因为选课操作频繁时,count()在MySQL里的开销会随选课记录增长越来越慢,而且在事务里可读性差。维护冗余计数虽然牺牲了范式,但在选课系统这种“读多写少但写有竞争”的场景里是常见的工程做法。

选课表要格外注意主键设计。很多同学习惯用(student_id, course_id)做复合主键,这在数据上没问题,但在后续成绩录入和退课记录追踪时会比较别扭,而且复合主键在MySQL的InnoDB里会直接影响聚簇索引结构。更稳的做法是选课表单独用自增id做主键,同时给学生选课表加一个UNIQUE(student_id, course_id, semester)唯一索引,唯一索引专门负责防重复选课。业务主键和数据主键分开,这是我踩过几次坑之后的固定习惯。

2.3 初始化SQL:从建库到测试数据一步到位

拿到源码之后第一步不是急着起Tomcat,而是先把数据库跑起来。下面是这套系统最基础的建表SQL,直接覆盖管理员、学生、教师、课程、选课、学期六张核心表。

CREATE DATABASE IF NOT EXISTS course_system DEFAULT CHARACTER SET utf8mb4; USE course_system; CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '学生主键', student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', password VARCHAR(64) NOT NULL COMMENT '密码,建议MD5后存储', name VARCHAR(50) NOT NULL, major VARCHAR(50) COMMENT '专业', grade VARCHAR(10) COMMENT '年级' ) ENGINE=InnoDB COMMENT '学生表'; CREATE TABLE teacher ( id INT PRIMARY KEY AUTO_INCREMENT, teacher_no VARCHAR(20) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, name VARCHAR(50) NOT NULL, title VARCHAR(20) COMMENT '职称' ) ENGINE=InnoDB COMMENT '教师表'; CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(20) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL ) ENGINE=InnoDB COMMENT '管理员表'; CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE COMMENT '课程编号', course_name VARCHAR(100) NOT NULL, credit TINYINT NOT NULL COMMENT '学分', teacher_id INT NOT NULL COMMENT '授课教师', capacity INT NOT NULL DEFAULT 0 COMMENT '容量上限', selected_count INT NOT NULL DEFAULT 0 COMMENT '已选人数', time_slot VARCHAR(50) NOT NULL COMMENT '上课时间段,如 周一3-4节', semester VARCHAR(20) NOT NULL COMMENT '开课学期', KEY idx_teacher (teacher_id), KEY idx_semester (semester), CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(id) ) ENGINE=InnoDB COMMENT '课程表'; CREATE TABLE student_course ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, score DECIMAL(5,2) DEFAULT NULL COMMENT '成绩,选课阶段为空', UNIQUE KEY uk_stu_course (student_id, course_id, semester), KEY idx_course (course_id) ) ENGINE=InnoDB COMMENT '选课表';

这段DDL里有几个参数值得说明。ENGINE=InnoDB是为了行级锁和事务支持,选课场景下MyISAM会因为表级锁把并发完全锁死。utf8mb4比utf8多支持一部分生僻字和特殊符号,用户名里的emoji在utf8下会直接报错。UNIQUE KEY uk_stu_course里加semester,是为了秋季和春季学期都选同一门课时不会被误判为重复。password字段预留64位,如果后面按MD5加盐处理,32位会不够用。

表建完之后要插入测试数据,这个环节别偷懒。至少准备三个学生、两个教师、五门课,其中一门课的capacity设为1或者已经满员,专门用来验证选课容量控制。没有造满员的测试数据,后面写选课事务时无法判断逻辑到底走没走通。

3. 技术选型与项目骨架:Servlet+JSP为什么是稳妥的毕设答案

不少同学一上来就问能不能换Spring Boot。能换,但建议先想清楚这个题目为什么固执地站在JavaWeb这边。

3.1 选型理由:课程大纲、答辩深度和源码可读性的平衡

很多学校的JavaWeb课程大纲就是围绕Servlet、JSP、Filter、Listener来排的。用Spring Boot做选课系统,框架替你做掉了大半的请求分发和数据封装,答辩时老师一句“你讲讲Spring Boot自动配置的原理”就能让场面冷下来。Servlet+JSP这套老路线反而实在:请求怎么进Servlet、Session怎么存用户、JDBC怎么拿连接,每一环都裸露在代码里,答得上就是答得上。

另一个现实问题是源码可读性。Spring Boot项目结构相对固定,但如果对starter、yml配置不熟,改一个端口都要翻半天。JavaWeb的源码通常就三层——Filter/Controller、Service、Dao,页面直接是JSP。对毕设来说,“代码量看起来足够、结构又不算复杂”是最有价值的状态。Spring Boot当然更好写,但更容易在答辩时陷入“框架替我做了,我不知道它做了什么”的困境。

3.2 项目目录结构:Servlet三层模型怎么摆

拿到源码后,先看目录结构。规范一点的JavaWeb项目会按下面的方式组织,这个结构在IDEA里直接就是一个标准的Web项目骨架。

src ├── main │ ├── java │ │ └── com.course │ │ ├── controller │ │ │ ├── LoginServlet.java │ │ │ ├── LogoutServlet.java │ │ │ ├── SelectCourseServlet.java │ │ │ └── CourseServlet.java │ │ ├── service │ │ │ └── CourseService.java │ │ ├── dao │ │ │ ├── StudentDao.java │ │ │ └── CourseDao.java │ │ ├── entity │ │ │ ├── Student.java │ │ │ └── Course.java │ │ └── util │ │ └── DruidUtil.java │ ├── resources │ │ └── druid.properties │ └── webapp │ ├── WEB-INF │ │ └── web.xml │ ├── jsp │ │ ├── login.jsp │ │ ├── student_home.jsp │ │ └── teacher_home.jsp │ └── index.jsp └── pom.xml

controller层只负责接收请求、调用service、携带结果跳转页面;service层处理事务和业务判断;dao层只做SQL交互。很多JavaWeb入门项目把业务代码全写在Servlet里,一个方法几十行,确实也能跑,但答辩时老师让说说系统结构,一句“全写在Servlet里”会让项目档次掉一半。分层不用过度设计,三层就够。JSP放在webapp目录下,如果希望代码更干净,就把JSP都放到WEB-INF里面,通过Servlet forward过去。

3.3 公共配置:Druid连接池参数与JDBC工具类

JavaWeb项目里最常用的连接池是Druid,它自带监控、参数丰富,性能也比手写连接池强得多。配置文件放在resources根目录下,名字和加载路径要对应。

driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/course_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username=root password=yourpass initialSize=5 minIdle=5 maxActive=20 maxWait=10000 testWhileIdle=true validationQuery=SELECT 1

这里最需要注意的是URL参数。serverTimezone必须指定,MySQL 8不写它会直接报时区错误;useSSL=false是为了关掉没有必要的SSL握手,本地开发能明显加快连接创建的耗时。initialSize和maxActive按毕设的量级配5和20就够,maxWait太长会让用户感觉卡死,10秒是合理上限。

配套的Jdbc工具类长这样:

public class DruidUtil { private static DataSource ds; static { Properties p = new Properties(); try (InputStream in = DruidUtil.class.getClassLoader() .getResourceAsStream("druid.properties")) { p.load(in); ds = DruidDataSourceFactory.createDataSource(p); } catch (IOException e) { throw new ExceptionInInitializerError("druid.properties 未找到"); } catch (Exception e) { throw new ExceptionInInitializerError("连接池初始化失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return ds.getConnection(); } }

getResourceAsStream读取的是classpath根路径,配合resources目录下的druid.properties使用即可。写成静态代码块是为了让连接池随类加载一次完成初始化,避免每次请求都重新建池。连接池不是越大越好,对选课系统这种几十个人同时用的场景,20个连接已经覆盖峰值。

4. 核心功能实现:登录拦截、选课事务与容量控制

功能实现是源码里最值得逐行读的部分。登录、选课、退课这三块写明白了,这个项目就站得住。

4.1 登录与Session管理:一个Filter拦截所有未登录请求

登录本身不复杂,复杂的是登录后的状态保持。Servlet规范里做这件事的标准方案是Session加Filter过滤器。登录成功后把用户对象塞进Session,然后写一个LoginFilter拦截所有需要登录的路径。

@WebFilter("/*") public class LoginFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); // 放行登录页、登录接口和静态资源,其余请求必须登录 String uri = req.getRequestURI(); if (uri.endsWith("/login") || uri.endsWith("/login.jsp") || uri.contains("/static/")) { chain.doFilter(request, response); return; } if (session == null || session.getAttribute("user") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp?target=" + uri); return; } chain.doFilter(request, response); } }

这里有一个细节:getSession(false)不会主动创建Session,如果直接getSession(),未登录请求也会新建一个空Session,Filter判断永远不成立。放行条件里不能只放行login.jsp,还要放行login这个Servlet路径,否则登录请求也会被拦截。重定向时带target参数属于锦上添花,登录成功后再跳回去,这个体验细节在答辩演示时很加分。

4.2 选课核心逻辑:SELECT ... FOR UPDATE 防止超选

选课是整个系统的核心,也是最容易在答辩时被追问的地方。先查容量再插入是最直观的写法,但两个人同时选最后一门课时,都读到已选人数=19,然后都插入成功,最后课程超员。解决这一个问题,比做完三个页面的加分都大。

下面是选课service的完整代码:

public boolean selectCourse(int studentId, int courseId) { String selectForUpdateSql = "SELECT capacity, selected_count FROM course WHERE id = ? FOR UPDATE"; String insertSql = "INSERT INTO student_course (student_id, course_id, semester) VALUES (?, ?, ?)"; String updateCountSql = "UPDATE course SET selected_count = selected_count + 1 WHERE id = ?"; Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DruidUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 锁住课程行,其他选课事务必须等这个事务提交 ps = conn.prepareStatement(selectForUpdateSql); ps.setInt(1, courseId); rs = ps.executeQuery(); if (!rs.next()) { conn.rollback(); return false; } int capacity = rs.getInt("capacity"); int selectedCount = rs.getInt("selected_count"); // 2. 容量判断在锁内做,防止读到过期数据 if (selectedCount >= capacity) { conn.rollback(); return false; } // 3. 插入选课记录,重复选课会触发唯一索引异常 ps = conn.prepareStatement(insertSql); ps.setInt(1, studentId); ps.setInt(2, courseId); ps.setString(3, "2024-2025-1"); ps.executeUpdate(); // 4. 更新冗余计数 ps = conn.prepareStatement(updateCountSql); ps.setInt(1, courseId); ps.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { try { if (conn != null) conn.rollback(); } catch (SQLException ignored) {} // 唯一索引冲突会被这里捕获,表现为“已选过这门课” return false; } finally { close(rs, ps, conn); } }

FOR UPDATE这行是防超选的关键。它把课程行锁住,事务提交前其他事务的相同查询只能等锁,于是容量判断和插入操作变成原子操作。这种方式叫悲观锁,适合选课这种写冲突概率高的场景。如果嫌锁粒度大,也可以用UPDATE course SET selected_count = selected_count + 1 WHERE id = ? AND selected_count < capacity,用affected rows判断是否超容量,这种方式不需要显式锁表,但需要把容量判断从业务层挪到SQL层。

record插入时会撞上student_course表的唯一索引uk_stu_course,这个异常不需要提前判断,索引兜底比代码判断更可靠。两个操作放在同一个事务里,commit或rollback保持数据一致。这种“锁行 + 冗余计数 + 唯一索引”的三层防御,就是答辩时可以拿出手的并发方案。

4.3 退课、补选与时序问题

退课逻辑相对简单,但同样要放在事务里:先删除student_course中的记录,再把course表的selected_count减一。顺序不要反过来。一旦删除成功但更新计数失败,只会出现“已选人数虚高”,不会出现“学生明明没选却占用名额”的严重错误。

除此之外,还有两个边界条件值得写:一是退课只允许在选课开放周期内进行,管理员关闭选课后学生端应隐藏退课按钮;二是当course表时间字段与student_course中已有课程的时间段相同时,要在选课前做冲突检测。比如课程A是“周一3-4节”,课程B也是“周一3-4节”,这两门课不能同时选。这个逻辑可以在Service里查询学生已选课程的时间段后逐条比对,也可以在插入前查一次课程表。

在IDEA里运行这个JavaWeb项目,常规操作路径是:配置Tomcat Server,把项目的war exploded包部署到Tomcat的Deployment里,Application context一般填/course;数据库先跑初始化SQL;然后在IDEA里启动Tomcat,浏览器访问http://localhost:8080/course/login.jsp。第一次跑通这条链路,后面改代码都不用重启整个Tomcat,热部署对JSP和Java类都生效。

5. 避坑指南:JavaWeb选课系统最常见的五个翻车点

毕设跑不起来,绝大多数问题不是逻辑难,而是环境配置里的隐性坑。

5.1 中文乱码:从页面到数据库全部变问号

现象:登录后学生姓名显示成“???”或者一片乱码,插入的中文课程名在Navicat里正常但页面上乱。原因多半不是单一环节引起的,而是JSP、Servlet、MySQL三处编码不一致。开发的机器上,IDEA文件编码默认可能是GBK,而JSP页面声明的是UTF-8,两边的字节流对不上。

解决:统一所有环节为UTF-8。JSP顶部写<%@ page contentType="text/html;charset=UTF-8" %>;Servlet在读取参数前执行request.setCharacterEncoding("UTF-8");MySQL连接URL带characterEncoding=utf8;数据库和表的字符集在建库时指定utf8mb4。排查时可以在关键位置各自打印一次,看是哪一段开始变乱码。

5.2 驱动程序类找不到

现象:启动Tomcat后访问登录页,报ClassNotFoundException: com.mysql.cj.jdbc.Driver,或者SMM项目中常见NoClassDefFoundError。

原因:IDEA里虽然把mysql-connector-java.jar加到了Project Structure的Libraries里,但这种方式只在编译期生效,运行期Tomcat用的是WEB-INF/lib下的jar包。如果jar没有随着Artifact一起打包进依赖里,运行时就找不到驱动。

解决:把驱动jar包放到src/main/webapp/WEB-INF/lib目录下,或者用Maven在pom.xml里引入依赖后重新build Artifact。注意MySQL 8以上版本的驱动类名是com.mysql.cj.jdbc.Driver,不是老资料里的com.mysql.jdbc.Driver,写错同样报ClassNotFound。

5.3 Druid连接池初始化失败

现象:Tomcat日志里抛出“Error querying database”或者连接超时,后面跟的提示很杂,有时是Communications link failure,有时是timezone。

原因:MySQL服务没启动、连接URL里的IP或端口写错、账号密码不对,这三个占八成。剩下两成是MySQL 8的时区问题,URL没带serverTimezone参数时总是提示连接被拒绝。连接池初始化在静态代码块里,一旦加载失败,整个应用启动就废了。

解决:先拿数据库工具连一下确认MySQL是可用的,再检查druid.properties属性名拼写。特别警惕properties文件里value前后的空格,properties文件解析不会自动trim,password= 123456和password=123456是两个值。连接不上的时候把Druid的log级别临时调成DEBUG,它会直接打印出连接失败的完整栈。

5.4 并发选课把容量选超

现象:两个学生同时点击选课,最后课程表capacity=1却有两行student_course记录,selected_count显示等于capacity但名单超员。

原因:代码里先SELECT查询已选人数,再INSERT选课记录,这两个操作之间没有事务隔离。A和B事务同时读到0人,先后执行INSERT,超员成立。如果用的是MyISAM引擎,连行锁都没有,问题必然发生。

解决:按第4章的写法,在事务中先SELECT ... FOR UPDATE,把课程行锁住再操作;或者把容量判断写进UPDATE条件里。改完之后观察selected_count,超出容量时插入必然失败。这个修复建议单独验证一次:开两个浏览器窗口同时点选课,看是否只有一个人成功。

5.5 404与Servlet类型的ClassCastException

现象:启动后访问/login一直404,或者Tomcat报错说Servlet不是Servlet类型。前者指向部署配置,后者往往和Tomcat版本选错有关。

原因:404多半是Application context配错,IDEA部署时context填的是/,访问路径就变成//login,干净的写法是保持context为/course,登录地址写http://localhost:8080/course/login。ClassCastException则常见于把基于javax.servlet的老项目直接部署到Tomcat 10以上版本,Tomcat 10把包名改成了jakarta.servlet,老代码的@WebServlet注解根本不会被识别。

解决:用Tomcat 9.0.x跑javax.servlet时代的JavaWeb项目。如果导师规定必须用新版Tomcat,就把代码里所有javax.servlet批量替换成jakarta.servlet,并把Servlet API依赖版本同步升级,这会引入额外工作量,非必要不建议。

6. 进阶:让选课系统在答辩现场“问不倒”

代码能跑只是及格线,答辩才是决定成绩的地方。准备答辩前,建议先把一条“一镜到底”的演示脚本练熟:管理员登录创建一门新课,切到学生账号登录选这门课,再切到教师账号录入成绩,最后回到学生端看到成绩。这条线走顺了,系统的完整性就能撑住前五分钟。

被问“系统有哪些不足”时,不要回答“没有不足”。三个比较体面的说法是:选课事务目前用的是悲观锁,高并发下锁等待偏多,可以换成乐观锁或异步削峰;课程冲突检测只在Service层做,没有下沉到数据库约束,分布式扩展时会有风险;密码存储用的是加盐哈希,但没做登录验证码和操作日志审计。每一条都说得出改进方向,比“我做得都很好”有说服力得多。

还有一个容易被忽视的动作:用日志佐证并发控制。答辩前把Druid的SQL日志打开,选课时让两个浏览器同时操作,截图或者现场展示其中一条FOR UPDATE语句在等待锁,然后再成功执行——这比嘴上说“我用了乐观锁”可信得多。最后是一个习惯建议:拿到任何一套源码,先把配置文件和SQL脚本通读一遍再启动,我见过太多人跳过这一步,结果在乱码和连接池上耗掉一整天。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询