ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java+JSP+Servlet+MySQL选课系统复现指南:从表结构到事务避坑

Java+JSP+Servlet+MySQL选课系统复现指南:从表结构到事务避坑 简介基于JavaJSPServletMySQL的Web学生选课管理系统是一个适合高校学生及Java Web初学者的完整项目实例。系统覆盖登录认证与权限控制、课程信息维护、学生选课与冲突检测、选课记录查询、成绩录入及数据备份恢复等功能模块整体采用MVC设计模式清晰展现了Servlet控制层、JSP视图层与JDBC数据层的协作方式。压缩包内共408个文件包含43个Java源码与43个Class类文件、25个JSP页面、50个JavaScript脚本、15个CSS样式表、162张GIF操作演示图涵盖登录、选课、成绩管理等关键流程另附SQL数据库脚本、Eclipse工程配置和数据库连接池配置压缩后约6.59MB资源组织清晰便于按模块查阅运行。目前已有6906人学习下载该项目借助完整源码与演示素材可快速搭建运行环境深入理解从数据库设计到前后端交互的完整开发链路也适合作为毕业设计或课程设计的参考模板。1. JavaJSPServletMySQL这套选课系统到底值不值得花时间复现选课管理系统几乎是每个Java Web初学者绕不过去的课程设计题网上资源多到泛滥但真正能跑通、表结构合理、代码能看懂并改造成自己项目的版本并不多。这套基于JSPServletMySQL的Web学生选课管理系统属于典型的Java Web三层架构落地项目——没有Spring全家桶的复杂封装全靠Servlet控制请求流转、JSP渲染页面、原生JDBC操作MySQL反而能让你把Servlet生命周期、请求转发与重定向、数据库连接管理这些最底层的东西一次性看清。适合正在做课程设计、准备Java面试前想补Web基础、或者打算接手这类老项目的人使用。我拆完这套代码的感受是它不是一个惊艳的项目但作为一份能跑通、能改、能讲清楚原理的源码包比那些套了Spring Boot壳子却说不清底层的东西要有价值得多。2. 从MySQL表结构说起三张核心表和一条选课主线2.1 用户、课程、选课记录没有冗余的设计才能扛住并发校验这套系统的数据库设计遵循了经典的第三范式核心是三张表学生信息表student、课程表course、选课记录表sc。表结构不复杂但设计上有一个容易被新手忽略的关键点选课记录表通过联合主键确保同一学生不能重复选同一门课。CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 学号, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录用户名, password VARCHAR(50) NOT NULL COMMENT 登录密码, real_name VARCHAR(50) NOT NULL COMMENT 真实姓名, major VARCHAR(100) COMMENT 专业, grade INT COMMENT 年级 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 课程编号, course_name VARCHAR(100) NOT NULL COMMENT 课程名称, teacher VARCHAR(50) NOT NULL COMMENT 授课教师, credit DECIMAL(3,1) NOT NULL COMMENT 学分, max_students INT NOT NULL COMMENT 选课容量, selected_count INT DEFAULT 0 COMMENT 已选人数 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE sc ( student_id INT NOT NULL, course_id INT NOT NULL, select_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES student(id), FOREIGN KEY (course_id) REFERENCES course(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这一段SQL里有两个地方值得停下来说。第一sc表用PRIMARY KEY (student_id, course_id)联合主键这是防止重复选课的第一道防线——就算代码里忘了判断数据库层面也会直接报错。第二course表里维护了selected_count字段这个冗余字段在选课高峰期会带来并发问题但在这个项目规模下配合UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count max_students这种条件更新反而比每次都COUNT(*)再判断要高效得多。2.2 为什么不用MyBatis而用原生JDBC课程设计场景下的务实选择现在很多学生一上来就用Spring Boot MyBatis Plus但如果你选课系统这类经典题目我更建议先吃透原生JDBC。这套资源用的是DAO JDBC模式BaseDAO类统一管理Connection、PreparedStatement、ResultSet的创建和释放业务DAO继承它再写具体SQL。public class BaseDAO { private static final String DRIVER com.mysql.cj.jdbc.Driver; private static final String URL jdbc:mysql://localhost:3306/course_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { e.printStackTrace(); } } protected Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这里有一个非常具体的版本坑MySQL 8.x 的驱动类名必须是com.mysql.cj.jdbc.DriverMySQL 5.x 是com.mysql.jdbc.Driver。如果你本机装的是MySQL 8.0用旧驱动类名会直接ClassNotFoundException。另外serverTimezoneAsia/Shanghai这个参数在MySQL 8.x下必须显式声明否则会报Server returns invalid timezone。这套代码里还暴露了密码硬编码的问题实际项目应该放配置文件但课程设计场景能跑通优先。2.3 初始化数据脚本你需要的不是造数据而是能反复重置的测试环境项目自带一个init.sql包含建库、建表、插入管理员和学生账号。我建议你在这个脚本基础上加两样东西一是给course表插入至少十几门课程覆盖容量已满、待选、学分不同这三种状态二是故意创建一个test账号专门用来测试选课失败场景。INSERT INTO student (username, password, real_name, major, grade) VALUES (admin, admin123, 系统管理员, 教务处, 0), (stu001, 123456, 张三, 计算机科学与技术, 2022), (stu002, 123456, 李四, 软件工程, 2022); INSERT INTO course (course_name, teacher, credit, max_students, selected_count) VALUES (Java程序设计, 王老师, 3.5, 50, 0), (操作系统, 刘老师, 3.0, 40, 40), (数据库原理, 陈老师, 3.0, 60, 0);插入时注意selected_count 40这种写法是故意模拟一门已经选满的课程。新手在测试时最容易翻车的点是自己造的数据里没有满员课程导致选课冲突检测分支一直走不到报错时都不知道是自己代码错了还是数据没造对。3. 三层架构落地从实体类到Servlet再到JSP页面的完整链路3.1 实体类和DAO字段映射是第一步别在类型上栽跟头这套项目的实体类定义比较规矩Student、Course、SC三个POJO与数据库表字段一一对应。Course实体里特别注意credit用的是BigDecimal而不是Doublemax_students和selected_count用Integer——这种细节在银行和教务系统里很关键因为浮点数在比较时会有精度问题而选课人数本身就是整数没必要用Long。public class Course { private Integer id; private String courseName; private String teacher; private BigDecimal credit; private Integer maxStudents; private Integer selectedCount; // 无参构造器、有参构造器、getter/setter 省略 }DAO层接口和实现分开写接口定义方法签名实现类写SQL和执行逻辑。比如CourseDAOImpl里的findAvailableCourses方法核心SQL是查询selected_count max_students的课程public ListCourse findAvailableCourses() { String sql SELECT * FROM course WHERE selected_count max_students; ListCourse list new ArrayList(); try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Course c new Course(); c.setId(rs.getInt(id)); c.setCourseName(rs.getString(course_name)); c.setTeacher(rs.getString(teacher)); c.setCredit(rs.getBigDecimal(credit)); c.setMaxStudents(rs.getInt(max_students)); c.setSelectedCount(rs.getInt(selected_count)); list.add(c); } } catch (SQLException e) { e.printStackTrace(); } return list; }代码里用了try-with-resources这是JDK 7的语法Connection、PreparedStatement、ResultSet会自动关闭不需要在finally里手动释放。很多人从旧教程里抄来的代码还在用finally块做关闭这一点新版代码更干净也避免了连接泄漏。3.2 Servlet跳转方式forward和sendRedirect用错会出大问题Servlet在这一套架构里是纯控制器负责接收请求、调用Service或DAO、决定跳转方向。常见分层是JSP - Servlet - Service - DAO但多数课程设计直接是JSP - Servlet - DAO少一层Service。这套项目属于后者对于选课这种简单业务逻辑少一层反而容易看懂。protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action request.getParameter(action); if (list.equals(action)) { ListCourse courseList courseDAO.findAvailableCourses(); request.setAttribute(courseList, courseList); request.getRequestDispatcher(/course_list.jsp).forward(request, response); } else if (myCourses.equals(action)) { Integer studentId (Integer) request.getSession().getAttribute(studentId); ListCourse myCourses courseDAO.findCoursesByStudentId(studentId); request.setAttribute(myCourses, myCourses); request.getRequestDispatcher(/my_courses.jsp).forward(request, response); } }这里forward和sendRedirect的区别值得说透。forward是服务器内部转发地址栏不变可以用来带request属性到JSP页面sendRedirect是客户端重定向地址栏会变适合选课成功之后跳转防止刷新页面重复提交。一个典型错误是选课成功后用forward跳转回列表页用户一刷新就重复选课——sendRedirect才是正解因为刷新时请求的是新的URL不会再执行选课逻辑。3.3 JSP页面职责JSTL EL能让你少写一百行Java代码页面层这套项目用的是JSP JSTL EL没有在页面里写乱七八糟的% ... %脚本片段。课程列表页的核心逻辑是循环输出课程信息并判断是否还能选% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table border1 tr th课程编号/th th课程名称/th th教师/th th学分/th th已选/容量/th th操作/th /tr c:forEach items${courseList} varcourse tr td${course.id}/td td${course.courseName}/td td${course.teacher}/td td${course.credit}/td td${course.selectedCount}/${course.maxStudents}/td td c:choose c:when test${course.selectedCount course.maxStudents} a hrefselectCourse?actionselectcourseId${course.id}选课/a /c:when c:otherwise span stylecolor:gray;已满/span /c:otherwise /c:choose /td /tr /c:forEach /tableEL表达式里的${course.courseName}会自动调用POJO的getCourseName()方法这是EL的规范不用在页面里手动调getter。c:choose和c:when组合实现if-else比在页面里写Java脚本片段要干净得多。这里需要注意JSTL 1.2的uri是http://java.sun.com/jsp/jstl/core到了JSTL 2.0改成了jakarta.tags.core——如果你用的Tomcat版本和JSTL版本不匹配页面会报Unable to find taglib的错误。4. 核心功能拆解登录校验、选课事务和权限控制4.1 登录逻辑MySQL查询结果和Session状态必须同步处理登录功能是每个Web项目的入口这套系统的做法很常规表单提交username和password到LoginServletServlet调用StudentDAO.findByUsernameAndPassword查库查到了就放进Session查不到就回登录页带错误提示。protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); String password request.getParameter(password); Student student studentDAO.findByUsernameAndPassword(username, password); if (student ! null) { HttpSession session request.getSession(); session.setAttribute(student, student); session.setAttribute(studentId, student.getId()); response.sendRedirect(course?actionlist); } else { request.setAttribute(errorMsg, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } }登录成功后这里用的是sendRedirect而不是forward这个细节有必要强调。登录请求本身就是POST提交如果forward到列表页用户刷新时浏览器会重复提交表单可能产生重复登录的副作用比如Session被反复刷新。sendRedirect让浏览器发起全新的GET请求符合Post/Redirect/GetPRG模式。Session里同时存了student对象和studentId后面选课操作只需要studentId不需要把整个对象传来传去。4.2 选课事务三步操作要放在同一个Connection里才安全选课是这个项目最核心的功能涉及三个步骤检查课程是否还有余量、插入选课记录、更新课程已选人数。这三步必须在一个数据库事务里完成否则会出现超卖——两个人同时选最后一门课两个人都看到余量是1都插入成功但实际只有一条记录能选上。public boolean selectCourse(int studentId, int courseId) { String checkSql SELECT selected_count, max_students FROM course WHERE id ?; String insertSql INSERT INTO sc (student_id, course_id) VALUES (?, ?); String updateSql UPDATE course SET selected_count selected_count 1 WHERE id ?; try (Connection conn getConnection()) { conn.setAutoCommit(false); // 第一步查询当前余量 PreparedStatement ps1 conn.prepareStatement(checkSql); ps1.setInt(1, courseId); ResultSet rs ps1.executeQuery(); if (!rs.next()) { return false; } int selected rs.getInt(selected_count); int max rs.getInt(max_students); if (selected max) { return false; // 已满 } // 第二步插入选课记录 PreparedStatement ps2 conn.prepareStatement(insertSql); ps2.setInt(1, studentId); ps2.setInt(2, courseId); int insert ps2.executeUpdate(); if (insert ! 1) { conn.rollback(); return false; } // 第三步更新课程人数 PreparedStatement ps3 conn.prepareStatement(updateSql); ps3.setInt(1, courseId); ps3.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { e.printStackTrace(); try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } return false; } }事务的坑在于setAutoCommit(false)之后每一步都要留意失败回滚。上面这段代码里有一个经典陷阱UPDATE语句本身不会因为selected_count超过max_students而失败需要在第一步先读出来比较。但这里还有个并发窗口——两个请求同时读到selected_count max_students - 1都通过了判断都执行了插入和更新最终selected_count变成了max_students 1。本项目规模下用SELECT ... FOR UPDATE加锁是最简单的解法SELECT selected_count, max_students FROM course WHERE id ? FOR UPDATE加了FOR UPDATE之后两个并发事务会串行执行第二个事务等第一个提交后才能读到最新数据自然就走到了selected max分支选课失败。这是InnoDB行锁的基本使用方式面试时能把这一点讲清楚比背十道八股文都有用。4.3 权限控制页面级拦截靠Filter别指望每个Servlet里重复判断这套系统里有三种角色管理员、学生、未登录游客。防越权的标准姿势是写一个LoginFilter在web.xml里配置好URL映射拦截所有/pages/*和需要登录才能访问的Servlet。filter filter-nameLoginFilter/filter-name filter-classcom.course.filter.LoginFilter/filter-class /filter filter-mapping filter-nameLoginFilter/filter-name url-pattern/selectCourse/url-pattern url-pattern/course/url-pattern /filter-mappingFilter里检查Session是否有student对象没有就重定向到登录页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); if (session ! null session.getAttribute(student) ! null) { chain.doFilter(request, response); } else { resp.sendRedirect(req.getContextPath() /login.jsp); } }注意req.getSession(false)这个写法——传false表示如果当前没有Session就返回null而不是创建一个新Session。很多人写成getSession()会导致未登录用户也被强制创建一个Session多少有点浪费。管理员功能的控制同样靠Filter或Servlet里的角色判断这个项目里把管理员和学生的页面做了物理隔离管理端JSP放到/admin/目录下Filter只对这个目录做额外的角色校验。5. 避坑指南从配置到部署的五个高危雷区5.1 Tomcat版本和Java版本不匹配导致项目起不来现象Tomcat启动正常访问JSP页面报java.lang.NoClassDefFoundError: javax/servlet/ServletException或直接404。原因Tomcat 10把包名从javax.servlet改成了jakarta.servlet老项目里所有import javax.servlet.*全部失效。如果你下载的代码是2020年之前写的那时候的规范还是javax命名空间。解决要么换Tomcat 9兼容javax要么把代码里所有javax.servlet改成jakarta.servlet并引入对应的JSTL版本。我建议课程设计直接用Tomcat 9.0.x省事JDK 8也能跑。5.2 MySQL驱动JAR包版本对不上现象启动Tomcat后第一次操作数据库报ClassNotFoundException: com.mysql.cj.jdbc.Driver或者报Could not create connection to database server。原因你拷贝的驱动JAR包是5.1.x的旧版本里面只有com.mysql.jdbc.Driver类而代码里写的是8.x驱动类名。解决确认WEB-INF/lib下的驱动JAR版本。MySQL 5.7用mysql-connector-java-5.1.49.jarMySQL 8.0用mysql-connector-java-8.0.30.jar。文件名直接看版本别靠猜。同时pom.xml里如果引了Maven依赖检查artifactId是mysql-connector-java还是mysql-connector-j后者也要匹配版本。5.3 中文乱码URL参数和JSP页面编码不一致现象列表页中文正常但查询条件传中文时Servlet收到的是一串问号。原因JSP页面是UTF-8但Tomcat默认的POST表单解码是ISO-8859-1GET请求的URI解码默认也是ISO-8859-1。只设置了pageEncoding没设置请求解码。解决Servlet里统一加编码处理——POST方式在读取参数前设置请求编码request.setCharacterEncoding(UTF-8);GET方式修改Tomcat的server.xml连接器配置Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingUTF-8 /注意GET手动编解码也可以但改server.xml更一劳永逸。另外数据库连接串里已经有characterEncodingutf8这个别删否则写进库里的中文直接变乱码。5.4 数据库连接没有关闭导致连接数耗尽现象跑半小时后系统变慢MySQL报Too many connectionsTomcat日志出现Connection refused或Connection is not available。原因DAO代码里只关了ResultSet和PreparedStatement忘了关Connection或者关闭顺序出错。MySQL默认最大连接数是151连接泄漏时会快速打满。解决养成try-with-resources习惯因为Connection、Statement、ResultSet都实现了AutoCloseable。代码里如果还在用finally { conn.close(); }检查是否在null判断后才关闭。另外一个排查技巧MySQL执行SHOW PROCESSLIST;如果大量Sleep状态的连接堆积就是连接没有释放。5.5 刷新页面重复提交选课现象选课成功后刷新页面提示重复选课或出现同一条选课记录。原因选课的POST请求forward到列表页后浏览器地址栏还是那个POST提交的URL刷新时浏览器重发POST请求。解决选课成功后使用sendRedirect跳转到course?actionlist避免重复提交。同时sc表的联合主键已经做了兜底二次插入会违反主键约束抛SQLIntegrityConstraintViolationException代码里需要捕获这个异常并提示用户已选过这门课。6. 部署验证与调试习惯从IDE到Tomcat的最后一公里拆完这套源码包最后一步是把它跑起来。我建议你把部署过程当作一次完整的验收测试严格走下面这三条路径而不是启动成功就交差。第一条路径是验证管理员功能用管理员账号登录进入管理端尝试新增课程、修改课程容量、查看所有选课记录。退出时注意看Session是否被正常清除。第二条路径是验证学生选课全流程学生登录后看到课程列表选一门有余量的课然后在“我的课程”里确认存在再选一门已经满员的课确认系统给出提示而不是报500错误。第三条路径是验证异常场景不登录直接访问course?actionlist看Filter是否把你拦截回登录页用一个不存在的学号登录确认报“用户名或密码错误”。设备验证完之后可以用一个自动化脚本辅助检查数据库状态确保选课事务的原子性mysql -uroot -p123456 course_db -e SELECT c.course_name, c.selected_count, c.max_students, (SELECT COUNT(*) FROM sc WHERE sc.course_id c.id) AS actual_count FROM course c WHERE c.selected_count ! (SELECT COUNT(*) FROM sc WHERE sc.course_id c.id); 这条SQL用于检查selected_count字段和sc表实际记录数是否一致。如果查询有返回说明事务回滚逻辑有Bugselected_count被更新了但sc记录没插入。我第一次做课程设计时就没发现这个问题直到查这个对比才发现事务边界没拿捏好从那以后我每次做带事务的项目强制自己先写一遍这个一致性检查SQL再开始写业务代码这个习惯帮我少踩了无数个隐性问题希望帮到你。本文还有配套的精品资源点击获取
返回列表