简介:这是一套基于JavaWeb的学生选课系统完整源码与SQL数据库资源,面向计算机、通信、人工智能、自动化等相关专业的在校学生、教师及从业者,可用于期末课程设计、课程大作业或毕业设计参考。项目为个人毕设成果,答辩评审分达95分,代码经过调试测试,可稳定运行,既适合初学者学习JavaWeb开发流程,也便于基础较好者在此基础上修改扩展功能。资源包共198个文件,约10.41MB,以35个Java源文件、29个JSP页面、23个JAR依赖包、23个JavaScript脚本、15个XML配置及12个CSS样式文件为主,另含SQL数据库脚本、Gradle构建文件与图片资源,覆盖后端逻辑、前端交互与数据持久化各层。目前已有437人学习下载,读者可借此掌握选课系统的模块划分、数据库表结构设计、前后端数据交互与项目部署思路,是一份结构完整、可直接运行的学习范本。
1. 从一份课程作业压缩包说起:JavaWeb 学生选课系统到底包含什么
很多计算机专业的学生在课程设计阶段都会拿到类似「基于JavaWeb的学生选课系统源码+sql数据库(课程作业).zip」这样的资源包。解压之后通常能看到一个 Eclipse 或 IDEA 的 Web 工程目录,外加一个.sql数据库脚本文件。这个标题背后对应的是一套典型的 JavaWeb 三层架构应用:前端用 JSP 或 HTML+Servlet 渲染页面,中间层用 Servlet 或 Spring 框架处理业务逻辑,底层用 JDBC 或 MyBatis 操作 MySQL 数据库。选课系统的核心业务不复杂,但麻雀虽小五脏俱全——用户登录鉴权、课程列表展示、选课退课、容量控制、已选课程查询,这几条线基本覆盖了 JavaWeb 开发中最常见的增删改查场景。如果你正在做课程作业、准备答辩、或者想找一个完整的 JavaWeb 项目练手,这套源码的参考价值在于它把「数据库设计 → 后端接口 → 前端页面」的完整链路串起来了,而不是只给你一个孤立的代码片段。适合的人群很明确:刚学完 Servlet 和 JSP、需要一个小型完整项目来巩固的在校生,以及需要快速搭出选课功能原型的开发者。
2. 选课系统的数据库设计与 SQL 脚本落地
2.1 四张核心表的设计思路与建表语句
选课系统的数据库设计围绕「学生」「课程」「选课记录」三个实体展开,通常需要四张表:学生表、课程表、选课关系表,外加一张管理员或用户角色表。学生表和课程表各自存储基本信息,选课关系表负责维护学生与课程之间的多对多映射。这里有一个设计决策需要提前想清楚:选课关系表是只存student_id和course_id,还是额外存选课时间、成绩、状态等字段。我的建议是至少加上选课时间和状态字段,因为后续做退课、成绩录入、选课时间窗口控制时都会用到。
-- 创建数据库,字符集用 utf8mb4 避免中文乱码 CREATE DATABASE IF NOT EXISTS course_selection DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE course_selection; -- 学生表:学号作为唯一标识,密码存明文仅用于课程作业演示 CREATE TABLE student ( student_id VARCHAR(20) PRIMARY KEY COMMENT '学号', student_name VARCHAR(50) NOT NULL COMMENT '姓名', password VARCHAR(64) NOT NULL COMMENT '登录密码', major VARCHAR(100) DEFAULT NULL COMMENT '专业', grade VARCHAR(10) DEFAULT NULL COMMENT '年级' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课程表:capacity 控制选课人数上限,selected_count 做冗余计数 CREATE TABLE course ( course_id VARCHAR(20) PRIMARY KEY COMMENT '课程编号', course_name VARCHAR(100) NOT NULL COMMENT '课程名称', teacher VARCHAR(50) DEFAULT NULL COMMENT '授课教师', credit DECIMAL(3,1) DEFAULT 2.0 COMMENT '学分', capacity INT DEFAULT 50 COMMENT '容量上限', selected_count INT DEFAULT 0 COMMENT '已选人数', schedule VARCHAR(100) DEFAULT NULL COMMENT '上课时间地点' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选课记录表:联合主键防止同一学生重复选同一门课 CREATE TABLE selection ( student_id VARCHAR(20) NOT NULL, course_id VARCHAR(20) NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间', status TINYINT DEFAULT 1 COMMENT '1=已选 0=已退', PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES student(student_id), FOREIGN KEY (course_id) REFERENCES course(course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段 SQL 里有几个参数值得展开说。utf8mb4而不是utf8,是因为 MySQL 的utf8实际上是阉割版,存不了 emoji 和部分生僻字,课程作业里学生姓名偶尔会有生僻字,用utf8mb4省心。selection表用(student_id, course_id)做联合主键,这是最直接的防重复选课手段——数据库层面直接拒绝重复插入,比在 Java 代码里先查再插要可靠得多。course表里的selected_count是一个冗余字段,理论上可以通过SELECT COUNT(*) FROM selection WHERE course_id=? AND status=1算出来,但每次选课都去 count 一遍在并发场景下性能差且容易出竞态,所以用冗余计数配合事务来维护。
2.2 初始化数据与 SQL 脚本导入的完整流程
建完表之后需要插入一些测试数据,否则系统跑起来是空的没法验证。初始化数据要覆盖几种典型场景:正常学生、已选满的课程、可选课程、已退课的记录。
-- 插入测试学生 INSERT INTO student VALUES ('2021001', '张三', '123456', '计算机科学与技术', '2021'), ('2021002', '李四', '123456', '软件工程', '2021'), ('2021003', '王五', '123456', '网络工程', '2021'); -- 插入测试课程,注意有一门课已选满 INSERT INTO course VALUES ('CS101', 'Java程序设计', '陈老师', 3.0, 50, 0, '周一1-2节 教A101'), ('CS102', '数据结构', '刘老师', 4.0, 45, 0, '周二3-4节 教B202'), ('CS103', '操作系统', '赵老师', 3.5, 40, 0, '周三5-6节 教A303'), ('CS104', '计算机网络', '孙老师', 3.0, 2, 2, '周四1-2节 教C101'); -- 插入选课记录,CS104 已被两人选满 INSERT INTO selection (student_id, course_id, status) VALUES ('2021001', 'CS104', 1), ('2021002', 'CS104', 1);导入流程上,如果你用的是 MySQL 命令行,直接source /path/to/init.sql即可;如果用 Navicat 或 DataGrip,打开 SQL 文件全选执行。这里有个常见的翻车点:SQL 文件里如果先写了CREATE DATABASE但后面没有USE语句,执行建表时会报「No database selected」。另一个坑是外键约束导致插入顺序必须正确——必须先插student和course,再插selection,否则外键检查会直接拒绝。如果导入时报Cannot add or update a child row,八成是插入顺序反了或者引用了不存在的 ID。
3. JavaWeb 后端:从 JDBC 工具类到选课核心逻辑
3.1 数据库连接池配置与 JDBC 工具类封装
JavaWeb 项目里最忌讳的就是在每个 Servlet 里都写一遍DriverManager.getConnection()。正确的做法是抽一个工具类,配合连接池来管理连接。课程作业级别的项目用 Druid 或者 HikariCP 都行,如果不想引入额外依赖,自己写一个简单的静态连接池也可以,但生产环境绝对不要那么干。
// DBUtil.java - 基于 HikariCP 的连接池工具类 public class DBUtil { private static final HikariDataSource dataSource; static { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/course_selection?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4"); config.setUsername("root"); config.setPassword("your_password"); config.setMaximumPoolSize(10); // 课程作业 10 个连接足够 config.setMinimumIdle(2); // 保持最少 2 个空闲连接 config.setConnectionTimeout(3000); // 获取连接超时 3 秒 config.setIdleTimeout(600000); // 空闲连接 10 分钟后回收 dataSource = new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } // 关闭资源的标准写法,注意关闭顺序:ResultSet -> PreparedStatement -> Connection public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (ps != null) ps.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }maximumPoolSize设成 10 是因为课程作业的并发量极低,设太大反而浪费资源。connectionTimeout设 3000 毫秒是为了在数据库没启动时快速失败,而不是让请求线程一直挂着。JDBC URL 里的serverTimezone=Asia/Shanghai是必须的,MySQL 8.x 驱动如果不指定时区,启动时会直接抛The server time zone value 'xxx' is unrecognized异常,这是新手最常遇到的翻车点之一。characterEncoding=utf8mb4确保 Java 端和数据库端的字符集一致,避免中文课程名变成问号。
3.2 选课接口的事务控制与并发防超选
选课这个操作看起来简单,实际上涉及三个步骤:检查课程是否已满、检查学生是否已选过、插入选课记录并更新课程已选人数。这三步必须在一个事务里完成,否则并发场景下会出现超选——两个学生同时看到还剩 1 个名额,同时插入,结果选了 2 个人。
// SelectionService.java - 选课核心逻辑 public String selectCourse(String studentId, String courseId) { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 第一步:用 SELECT ... FOR UPDATE 锁定课程行,防止并发超选 String checkSql = "SELECT capacity, selected_count FROM course WHERE course_id = ? FOR UPDATE"; ps = conn.prepareStatement(checkSql); ps.setString(1, courseId); rs = ps.executeQuery(); if (!rs.next()) { conn.rollback(); return "课程不存在"; } int capacity = rs.getInt("capacity"); int selectedCount = rs.getInt("selected_count"); if (selectedCount >= capacity) { conn.rollback(); return "课程已选满"; } rs.close(); ps.close(); // 第二步:检查是否已经选过(联合主键也会兜底,但提前检查给用户友好提示) String dupSql = "SELECT 1 FROM selection WHERE student_id = ? AND course_id = ? AND status = 1"; ps = conn.prepareStatement(dupSql); ps.setString(1, studentId); ps.setString(2, courseId); rs = ps.executeQuery(); if (rs.next()) { conn.rollback(); return "你已经选过这门课"; } rs.close(); ps.close(); // 第三步:插入选课记录 + 更新已选人数 String insertSql = "INSERT INTO selection (student_id, course_id, status) VALUES (?, ?, 1)"; ps = conn.prepareStatement(insertSql); ps.setString(1, studentId); ps.setString(2, courseId); ps.executeUpdate(); ps.close(); String updateSql = "UPDATE course SET selected_count = selected_count + 1 WHERE course_id = ?"; ps = conn.prepareStatement(updateSql); ps.setString(1, courseId); ps.executeUpdate(); conn.commit(); // 三步全部成功才提交 return "选课成功"; } catch (SQLException e) { try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return "系统异常,选课失败"; } finally { DBUtil.close(conn, ps, rs); } }这段代码的关键在于SELECT ... FOR UPDATE。它会在事务内对查询到的课程行加排他锁,其他事务想对同一行加锁时必须等待,直到当前事务提交或回滚。这样就保证了「检查容量」和「更新计数」之间不会有其他事务插进来。如果你把FOR UPDATE去掉,在并发测试下大概率会看到selected_count超过capacity的情况。另一个细节是conn.setAutoCommit(false)之后,所有 SQL 都在同一个事务里,任何一步失败都要rollback(),否则会出现选了课但人数没加、或者人数加了但选课记录没插入的数据不一致。退课逻辑是选课的逆操作,把status置为 0 并selected_count - 1,同样需要事务包裹。
4. 前端页面与 IDEA 运行配置:让系统真正跑起来
4.1 JSP 页面渲染课程列表与选课交互
前端部分如果用 JSP,核心页面通常有三个:登录页、课程列表页、已选课程页。课程列表页需要从数据库查出所有课程并渲染成表格,每行带一个「选课」按钮。这里用 JSTL 标签库来循环渲染,避免在 JSP 里写 Java 代码块。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head><title>课程列表</title></head> <body> <h2>可选课程</h2> <table border="1" cellpadding="8"> <tr> <th>课程编号</th><th>课程名称</th><th>教师</th> <th>学分</th><th>容量</th><th>已选</th><th>操作</th> </tr> <c:forEach items="${courseList}" var="course"> <tr> <td>${course.courseId}</td> <td>${course.courseName}</td> <td>${course.teacher}</td> <td>${course.credit}</td> <td>${course.capacity}</td> <td>${course.selectedCount}</td> <td> <c:choose> <c:when test="${course.selectedCount >= course.capacity}"> <span style="color:red;">已满</span> </c:when> <c:otherwise> <a href="SelectServlet?courseId=${course.courseId}">选课</a> </c:otherwise> </c:choose> </td> </tr> </c:forEach> </table> </body> </html><c:choose>标签在这里做前端层面的容量判断,已满的课程直接显示红色「已满」而不是给一个可点击的选课链接。但要注意,前端判断只是提升用户体验,后端SelectionService里的容量检查才是真正的防线——用户完全可以在浏览器地址栏直接敲SelectServlet?courseId=CS104绕过前端。所以永远不要信任前端传来的任何状态,后端必须重新校验。${course.selectedCount}这种 EL 表达式依赖 JavaBean 的 getter 方法,你的Course实体类里必须有getSelectedCount()方法,否则页面会报PropertyNotFoundException。
4.2 IDEA 运行 JavaWeb 项目的完整配置步骤
很多人拿到源码后在 IDEA 里跑不起来,八成是 Tomcat 配置或者项目结构的问题。下面是从零跑通一个 JavaWeb 项目的标准流程。
第一步,确认项目结构。IDEA 里的 Web 项目需要正确标记web目录为 Web 资源目录。打开File → Project Structure → Modules,检查是否有 Web Facet,Web Resource Directory是否指向你的web或webapp目录,Deployment Descriptor是否指向web.xml。如果这里没配好,Tomcat 启动后会直接 404。
第二步,配置 Tomcat Run Configuration。点击Run → Edit Configurations → + → Tomcat Server → Local,在Server标签页选择本地 Tomcat 安装目录,在Deployment标签页点击+ → Artifact,选择xxx:war exploded。用war exploded而不是war,这样修改 JSP 后不用重新打包就能生效。
第三步,检查依赖。在Project Structure → Libraries里确认mysql-connector-java、servlet-api、jstl这三个包都加进去了。servlet-api的 scope 要设成provided,因为 Tomcat 自带了这个包,打进去反而会冲突。mysql-connector-java的版本要和你本地 MySQL 版本匹配,MySQL 8.x 必须用 8.x 的驱动,用 5.x 的驱动连 8.x 数据库会报Unknown system variable 'query_cache_size'。
第四步,启动并验证。Tomcat 启动后控制台如果出现Server startup in xxx ms说明启动成功。访问http://localhost:8080/你的项目名/看是否能打开登录页。如果报 500 错误,先看 IDEA 控制台的异常堆栈,最常见的是ClassNotFoundException: com.mysql.cj.jdbc.Driver(驱动没加)或者Access denied for user(数据库密码不对)。
注意:如果你的项目用的是
javax.servlet包,Tomcat 版本不要超过 9.x。Tomcat 10 开始把包名改成了jakarta.servlet,老项目跑在 Tomcat 10 上会大面积报ClassNotFoundException。
5. 避坑与排查:选课系统最常见的五个翻车现场
5.1 中文乱码从数据库一路乱到浏览器
现象:课程名在数据库里看是正常的,但页面上显示成????或者æ•°æ®。原因通常出在三个环节中的某一个:数据库连接字符集、JSP 页面编码、或者 Tomcat 的 URI 编码。解决方法是逐层排查。数据库连接 URL 加characterEncoding=utf8mb4;JSP 页面头部加<%@ page contentType="text/html;charset=UTF-8" %>;如果用的是 GET 请求传中文参数,还需要在 Tomcat 的server.xml里给 Connector 加URIEncoding="UTF-8"。POST 请求则在 Servlet 里第一行写request.setCharacterEncoding("UTF-8")。三个地方都对齐了,乱码基本就消失了。
5.2 选课成功但已选人数没变
现象:学生选了课,selection表里有记录,但course表的selected_count没加。原因通常是插入选课记录和更新计数不在同一个事务里,或者更新语句执行失败但异常被吞了。排查时先看SelectionService里两步操作是否在setAutoCommit(false)之后、commit()之前。另一个可能是UPDATE语句的WHERE条件写错了,比如把course_id写成了id,SQL 不报错但影响行数为 0。建议在executeUpdate()之后打印返回值,如果是 0 就说明没更新到任何行。
5.3 并发选课导致超选
现象:课程容量设了 2,但选课记录有 3 条。原因是没有用SELECT ... FOR UPDATE锁行,两个线程同时读到selected_count=1,都判断没满,都执行了插入和加一。解决办法就是在事务内用FOR UPDATE锁定课程行。如果不想用悲观锁,也可以用乐观锁:UPDATE course SET selected_count = selected_count + 1 WHERE course_id = ? AND selected_count < capacity,然后检查影响行数是否为 1,为 0 就说明被抢完了,回滚事务返回「已满」。
5.4 外键约束导致删课失败
现象:管理员想删除一门课程,报Cannot delete or update a parent row: a foreign key constraint fails。原因是selection表里有学生选过这门课,外键挡住了删除。解决方式有两种:一是先删selection里对应的记录再删课程,二是把外键的ON DELETE行为改成CASCADE,删课程时自动删选课记录。课程作业里建议用第一种,手动控制更安全,避免误删。
5.5 Tomcat 启动报端口占用
现象:启动 Tomcat 时报Address already in use: bind。原因是 8080 端口被其他程序占了,常见的是之前没关干净的 Tomcat 进程或者某个开发工具。Windows 下用netstat -ano | findstr 8080找到占用进程的 PID,然后taskkill /PID xxx /F杀掉。Mac 或 Linux 用lsof -i :8080找到进程再kill -9。如果不想杀进程,也可以在 IDEA 的 Tomcat 配置里把端口改成 8081 或其他空闲端口。
6. 从课程作业到能拿出手的项目:三个进阶改造方向
课程作业级别的选课系统跑通之后,如果你想让它从一个「交完就忘」的作业变成一个能写进简历的项目,有三个方向值得投入时间。第一个方向是把 JDBC 换成 MyBatis 或 MyBatis-Plus。现在企业里直接用 JDBC 写 DAO 的项目已经很少了,MyBatis 的@Mapper接口加 XML 映射文件是更主流的做法。改造时先把DBUtil换成 MyBatis 的SqlSessionFactory,然后把每个 DAO 方法改成 Mapper 接口方法。这个过程能让你真正理解 ORM 框架解决了什么问题——不是「不用写 SQL」那么简单,而是结果集映射、参数绑定、缓存管理这一整套东西。
第二个方向是加一层简单的权限控制。课程作业里的登录通常只区分「学生」和「管理员」,但实现方式往往只是在 Session 里存一个角色字符串,然后在 JSP 里用<c:if>判断。更规范的做法是用 Filter 做统一的权限拦截,把「哪些 URL 需要什么角色」配置在web.xml或注解里。这样新增角色或调整权限时不用改每个页面。
第三个方向是补上接口文档和单元测试。给每个 Servlet 或 Controller 写 JUnit 测试,用 H2 内存数据库替代 MySQL 做测试环境,这样每次改代码都能快速验证核心逻辑没被改坏。接口文档用 Swagger 或 Knife4j 自动生成,答辩时打开文档页面比对着代码讲要直观得多。
我自己做这类项目最大的教训是:不要一上来就想着把功能做全。先把「登录 → 查课 → 选课 → 退课」这条主链路跑通,数据库建好、事务写对、页面能正常显示,这四步做扎实了,后面加功能都是顺水推舟。反过来,如果主链路里事务没控制好、字符集没统一,后面每加一个功能都会踩一遍同样的坑。希望帮到你。
本文还有配套的精品资源,点击获取