Java+JSP+MySQL毕业设计选题系统开发:从建表到部署全流程
简介JavaJspMysql实现的Web毕业设计选题系统面向计算机相关专业毕业生和Java Web开发者解决高校课题选题管理中管理员、教师、学生三方协作不便、流程分散的问题适合课程设计、毕业设计参考或二次开发。压缩包共221个文件、5.57MB包含98个jsp页面、12个java源文件、24个class文件、7个xml配置及sql数据库脚本等前端页面、后端逻辑与部署配置齐全目录结构清晰。已有2416人学习。系统内置管理员、教师、学生三个角色管理员可管理学生、教师与课题信息教师能添加并查看课题学生可浏览选题并选择经多次测试运行无误导入MySQL即可快速启动有助于理解选题系统的权限设计与JSPServletMySQL分层实现。1. 毕业设计选题系统为什么值得自己动手写一套每年 11 月到次年 4 月高校教务处的老师都会被同一件事折磨几百个学生抢几十个课题电话不停、Excel 来回传、选题撞车后还得人工协调。而对学生来说刷不到心仪的题目、不知道还剩几个名额、改选还要找辅导员签字——这些痛点背后其实就是一个典型的 JavaJspMysql 三层结构的 Web 系统能解决的问题。这套毕业设计选题系统本质上是把「教师出题、学生选题、管理员审核调剂」这条业务链路搬到浏览器里让名额实时可见、流程线上化。我用 Java、JSP、MySQL 这套组合做过至少三个版本的选题系统。选它不是因为技术新而是因为它在学校机房、学生笔记本、廉价的 Tomcat 服务器上都能低成本跑起来。JSP 做视图层能让不懂前后端分离的学生也能快速上手改页面Java 做业务层扛得住上百人同时抢题的压力MySQL 存选题关系数据量完全够用。这篇文章把从建表到部署的完整路径和踩过的坑都摊开讲读者跟着做就能交付一个能演示、能答辩、能实际跑的业务系统。新手可以按步骤复现熟手可以直接看我给的参数和边界经验少走弯路。2. 把选题系统拆成三层Java 负责逻辑JSP 负责展示MySQL 负责记账2.1 选题系统的核心业务流转先搞清楚要做什么毕业设计选题系统的业务场景并不复杂核心参与者就三种管理员、教师、学生。管理员的日常工作是把教师提交的课题导入系统、设置选题开放时间窗口、处理学生的调剂申请教师的操作是申报课题、录入题目要求和最多可选人数学生的操作是在开放期内浏览课题、选择第一第二志愿、确认最终选题结果。整条流程的关键在于「名额实时扣减」和「志愿冲突处理」。这里我给出一个经过多个版本验证过的业务规则学生在选题开放期内提交一个第一志愿和一个第二志愿第一志愿若还有名额则直接选中名额减一第一志愿已满则落到第二志愿两个志愿都满则进入待调剂池由管理员手动分配。这个规则简单易实现答辩时也能把逻辑讲清楚不会像多人拼团、动态轮换那么难解释。用 JavaJspMysql 实现这个业务就需要三个模块各司其职。MySQL 只负责数据落盘和事务。JSP 只负责渲染页面和收集表单参数不写任何业务代码。Java 这边用 Servlet 做控制器接收请求调用 Service 层完成选题逻辑再通过 DAO 层访问数据库。如果你用的是 JSP Model 2 模式就是标准的 MVC 结构——JSP 当 ViewServlet 当 ControllerServiceDAO 当 Model。这套结构虽然老派但胜在清晰也是多数毕设项目的标准做法。2.2 JSP 的存活理由为什么不用前后端分离三个现实原因热词里大量出现 sping boot 集成 web socket、v 语言 web 开发框架这类新东西但我在这里坚持用 JSP 是有现实考量的。第一毕业设计答辩现场通常只给一台能跑 Tomcat 的电脑不保证有 Node.js 环境更不保证能联网装依赖。JSP 是 Java EE 规范的一部分Tomcat 天然支持部署就是一个 WAR 包扔进去不涉及前端构建。第二对于没有系统学过 Vue 或 React 的学生JSP JSTL EL 表达式的学习成本低得多一个c:forEach就能把后端传来的 List 渲染成表格不需要理解响应式数据绑定和虚拟 DOM。第三学校机房的老机器跑前后端分离项目启动两个服务加上 MySQL内存经常不够用单 Tomcat 部署省事也稳定。但 JSP 也有明确的边界就是不适合写复杂业务逻辑。我见过不少学生把 Java 代码直接嵌在% %里一个页面三百多行数据库连接在里面写死这样的代码答辩时一问就露馅。正确的做法是 JSP 页面中只保留 JSTL 标签和 EL 表达式如果需要处理的逻辑就转发给 Servlet 处理。所以这套方案成立的前提是遵守 JSP 的角色定位把它当视图模板而不是脚本引擎。2.3 MySQL 的数据表设计五张表把业务关系锁死选题系统的数据库设计是整个项目的地基。我常用的表结构是五个表加一个视图用户表包含学生和教师、课题表、选题记录表、通知公告表以及用于批量导入的临时表。设计要点是用户表里用 role 字段区分身份而不是拆成 student 表和 teacher 表。课题表通过 teacher_id 外键关联用户表。选题记录表是这个系统的核心它记录了哪个学生选了哪个课题、第一志愿还是第二志愿、状态是什么。一定要给学生的第一志愿和第二志愿加唯一约束防止学生重复提交。下面是我常用的建表 SQL 片段可以直接抄作业。需要注意字符集统一用 utf8mb4排序规则选 utf8mb4_general_ci否则导入 Excel 里的中文课题名称时会出现乱码或索引失效的问题CREATE DATABASE IF NOT EXISTS topic_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE topic_system; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录账号, password VARCHAR(64) NOT NULL COMMENT 密码建议MD5或SHA-256存储, role TINYINT NOT NULL DEFAULT 2 COMMENT 0管理员,1教师,2学生, real_name VARCHAR(50) NOT NULL COMMENT 真实姓名, class_name VARCHAR(50) DEFAULT NULL COMMENT 学生班级, teacher_title VARCHAR(30) DEFAULT NULL COMMENT 教师职称, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT用户表-含学生和教师; CREATE TABLE t_topic ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 课题ID, teacher_id INT NOT NULL COMMENT 出题教师ID, title VARCHAR(200) NOT NULL COMMENT 课题名称, description TEXT COMMENT 课题要求与说明, max_students INT NOT NULL DEFAULT 5 COMMENT 最大可选人数, selected_count INT NOT NULL DEFAULT 0 COMMENT 当前已选人数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1接受选题,0关闭, CONSTRAINT fk_topic_teacher FOREIGN KEY (teacher_id) REFERENCES t_user(id) ) ENGINEInnoDB COMMENT课题表; CREATE TABLE t_selection ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL COMMENT 学生ID, topic_id INT NOT NULL COMMENT 课题ID, choice_rank TINYINT NOT NULL COMMENT 志愿序号 1第一志愿 2第二志愿, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待确认,1已选中,2已拒绝, select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_choice (student_id, choice_rank), CONSTRAINT fk_sel_student FOREIGN KEY (student_id) REFERENCES t_user(id), CONSTRAINT fk_sel_topic FOREIGN KEY (topic_id) REFERENCES t_topic(id) ) ENGINEInnoDB COMMENT选题记录表;这里要注意几个参数的含义。scrumptiousmax_students和selected_count这两个字段决定了课题是否还能选每完成一次选课selected_count就要加一同时要开启事务来保证selected_count不会超卖。t_selection表的status字段保存的是业务状态的中间值真正需要给学生看到已经选中的记录需要通过一个视图把t_selection和t_user、t_topic连起来。库存扣减和事务一致性的具体实现放到下一章写代码时详细说。2.4 环境选型Tomcat 版本与 JDK 版本怎么配省得启动就黑匣子报错毕业设计里最常见的环境问题是 Java 编译版本和 Tomcat 运行版本不一致。比如用 JDK 17 编译的 class 文件扔到 Tomcat 9 里启动时直接报 UnsupportedClassVersionError这一下就能卡住半天。我给出的稳妥组合是 JDK 8也就是 1.8 Tomcat 8.5 或 Tomcat 9 MySQL 5.7 或 8.0。这个组合兼容性最好网络上能搜到的资料也最多踩坑了容易找到答案。如果你是坚定的新版本党JDK 11 配 Tomcat 9.0 是可行的但不要直接用 Tomcat 10因为 Tomcat 10 把javax.servlet换成了jakarta.servlet很多老教材的代码直接跑不起来这个问题在热词里频繁出现这里先提醒一下。IDE 的选择我一般推荐 IntelliJ IDEA社区版就够用。新建项目的时候选 Java Enterprise勾选 Web Application就能得到一个标准的 Web 项目结构。如果你是按照 Servlet 3.0 以上规范开发可以完全用注解WebServlet(/login)代替 XML 配置省事很多。在 IDEA 里配置 Tomcat 时Application server 要选到 Tomcat 的安装根目录Deployment 里把 artifact 后缀设为ROOT这样访问路径就是http://localhost:8080/不用记长路径。3. 从零搭建 JavaJspMysql 选题系统数据库到登录一条命令一个坑3.1 准备工程结构MAVEN 还是传统 WEB-INF/lib选型理由与最小目录动手写代码前先决定工程构建方式。如果只是校内部署没有特别多的第三方依赖我建议直接用传统 Web 工程加 WEB-INF/lib 目录的方式。把 MySQL 驱动 JAR 包放在 WEB-INF/lib 下Tomcat 启动时自动加载简单直观。但有一个明显的坑如果传文件时漏了 lib 目录里的 JAR发布到服务器后就会出现 ClassNotFound 错误而且本地明明能跑。所以我更推荐用 Maven 管理依赖pom.xml 里声明依赖打包时用mvn clean package直接产出 WAR 包依赖自动打进WEB-INF/lib。看看 Maven 项目里的关键依赖以下 pom.xml 中的内容就是标准配置dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.28/version /dependency /dependencies注意javax.servlet-api的 scope 必须是 provided意思是打包的时候不把这个 JAR 打进去因为 Tomcat 自己就有 Servlet API。如果你把它打成 compile 或者默认的 scopeWAR 包的 lib 目录里会出现 servlet-api.jar这时 Tomcat 加载类的时候可能因为类加载器冲突抛出奇怪的异常。MySQL Connector/J 8.0.x 需要 JDK 8如果你的 Tomcat 用的 JDK 版本低于 8就必须换用 5.1.49。工程目录方面标准的 Maven Web 结构是src/main/java放 Java 代码src/main/webapp放 JSP 页面和静态资源。JSP 页面建议按目录分组webapp/login.jsp、webapp/student/select.jsp、webapp/teacher/publish.jsp、webapp/admin/examine.jsp。这样维护起来一眼就能找到对应的页面。3.2 数据库连接池配置不直接用 DriverManager 的三个理由与配置方法很多教材在 DAO 层直接写DriverManager.getConnection(url, user, password)一个数据库操作就开一个连接用完关闭。这个写法在小流量的练习里没问题但毕业设计选题的场景是几十人同时操作如果每次请求都新建物理连接数据库会很快出现 too many connections 错误。正确做法是用连接池。常见的连接池有 DBCP、C3P0、Druid。我推荐 Druid因为阿里开源、监控页面做得好答辩时可展示的部分也多GitHub 上的文档比较全配置起来也不复杂。在src/main/resources下创建druid.properties文件内容如下driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/topic_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue usernameroot passwordyour_password initialSize5 maxActive20 maxWait30000 minIdle5 validationQuerySELECT 1 testWhileIdletrue timeBetweenEvictionRunsMillis30000然后在 Java 代码里写个工具类获取连接import com.alibaba.druid.pool.DruidDataSourceFactory; import javax.sql.DataSource; import java.io.InputStream; import java.sql.Connection; import java.util.Properties; public class DBUtil { private static DataSource dataSource; static { try (InputStream is DBUtil.class.getClassLoader().getResourceAsStream(druid.properties)) { Properties props new Properties(); props.load(is); dataSource DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(数据库连接池初始化失败: e.getMessage()); } } public static Connection getConnection() throws Exception { return dataSource.getConnection(); } }这个参数的几个关键点要注意。allowPublicKeyRetrieval这一项是 MySQL 8.0 使用 caching_sha2_password 认证时需要的不加这个参数连接会建立失败报 Public Key Retrieval is not allowed。serverTimezone一定要设否则 JDBC 驱动与 MySQL 协商时间戳时默认按 UTC 处理如果你的课程表里存了时间字段显示出来的时间会差 8 个小时。validationQuery在 MySQL 5.7 中必须配成SELECT 1Druid 用它做连接存活检查这是大数据量并发时防止连接假死的重要手段。3.3 登录功能落地Session 处理与 MD5 加密存储附完整代码登录功能是每个管理系统都有的模块也是了解整个系统代码结构的入口。登录逻辑不复杂但要在代码里把用户角色区分清楚因为后续的导航菜单、功能权限都依托于登录成功后写入 Session 的 role 字段。登录过程是用户在login.jsp输入用户名和密码表单提交到LoginServletServlet 调用UserService.login(username, password)方法Service 层把密码做 MD5 后到数据库查询查到了就把用户对象和 role 放入 Session然后根据角色重定向到不同的首页。这里先说明密码存储的方式。密码在数据库里存的是加盐 MD5即对「密码 固定盐值」做 MD5 摘要。虽然 MD5 不算强加密但对毕业设计系统来说已经够用。如果你有更高的安全要求可以在代码里改用 SHA-256 或 BCrypt原理完全一样。UserService的核心代码如下public User login(String username, String password) throws Exception { String md5Pwd MD5Util.md5(password SALT); try (Connection conn DBUtil.getConnection()) { String sql SELECT id, username, role, real_name, class_name, teacher_title FROM t_user WHERE username? AND password?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, md5Pwd); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User u new User(); u.setId(rs.getInt(id)); u.setUsername(rs.getString(username)); u.setRole(rs.getInt(role)); u.setRealName(rs.getString(real_name)); u.setClassName(rs.getString(class_name)); u.setTeacherTitle(rs.getString(teacher_title)); return u; } return null; } } } }然后LoginServlet负责接收请求并做页面跳转WebServlet(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); if (username null || password null || username.trim().isEmpty() || password.trim().isEmpty()) { req.setAttribute(error, 用户名和密码不能为空); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } UserService service new UserService(); try { User user service.login(username.trim(), password); if (user null) { req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } HttpSession session req.getSession(); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(30 * 60); if (user.getRole() 0) { resp.sendRedirect(req.getContextPath() /admin/dashboard.jsp); } else if (user.getRole() 1) { resp.sendRedirect(req.getContextPath() /teacher/dashboard.jsp); } else { resp.sendRedirect(req.getContextPath() /student/dashboard.jsp); } } catch (Exception e) { e.printStackTrace(); req.setAttribute(error, 系统异常请稍后重试); req.getRequestDispatcher(/login.jsp).forward(req, resp); } } }这段代码里有两个容易被新手忽略的点。第一个是session.getSession()要放在认证成功之后不要一开始就无脑创建 Session否则大量未登录请求会撑爆服务器内存。第二个是resp.sendRedirect和req.getRequestDispatcher().forward的语义不同登录成功后用重定向可以防止刷新页面时重复提交表单失败时用服务器内部转发可以保留页面上填的用户名。重定向的路径要用req.getContextPath()拼接否则项目非 ROOT 部署时会出现路径 404 的问题。Session 失效时间的设置也值得注意。默认 Tomcat 的 Session 超时是 30 分钟但选题操作时学生可能会在详情页停留很久然后提交的时候发现 Session 失效被踢回登录页这会造成极差的交互体验。我一般会在web.xml里显式加一行session-configsession-timeout60/session-timeout/session-config把超时放宽到 60 分钟。这里用 60 分钟有一个还考虑到的情况即使 Session 失效数据库中的选题记录已经提交成功并扣减了名额所以不存在丢数据的风险顶多需要重新登录。4. 核心选题流程的实现事务保名额不超卖JSP 页面用 JSTL 渲染防空指针4.1 事务边界设计先锁行再判断再扣库存一个时序都不能错讲到选题动作这是整个系统里最容易出错的地方。设想这样的场景某个课题最多可选 5 人当前selected_count已经是 4只剩最后一个名额。张三和李四同时点了选题按钮两条请求并发到达服务器。如果不加控制两个请求都会读到selected_count 4然后各自执行selected_count 1 5最终库存变成 5但实际选了 6 个人超卖了一个名额。解决这个问题的方法是事务加行级锁。处理流程是先开启事务接着用SELECT ... FOR UPDATE锁定课题表的那一行记录然后判断selected_count max_students满足条件就插入选题记录并更新selected_count最后提交事务。以下是推荐直接抄用的代码public boolean selectTopic(int studentId, int topicId, int choiceRank) throws Exception { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); String lockSql SELECT selected_count, max_students FROM t_topic WHERE id ? FOR UPDATE; int selectedCount 0; int maxStudents 0; try (PreparedStatement ps conn.prepareStatement(lockSql)) { ps.setInt(1, topicId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { selectedCount rs.getInt(selected_count); maxStudents rs.getInt(max_students); } else { conn.rollback(); return false; } } } if (selectedCount maxStudents) { conn.rollback(); return false; } String insertSql INSERT INTO t_selection (student_id, topic_id, choice_rank, status) VALUES (?, ?, ?, 0); try (PreparedStatement ps conn.prepareStatement(insertSql)) { ps.setInt(1, studentId); ps.setInt(2, topicId); ps.setInt(3, choiceRank); ps.executeUpdate(); } String updateSql UPDATE t_topic SET selected_count selected_count 1 WHERE id ?; try (PreparedStatement ps conn.prepareStatement(updateSql)) { ps.setInt(1, topicId); ps.executeUpdate(); } conn.commit(); return true; } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (Exception ignore) {} } throw e; } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (Exception ignore) {} } } }这里最关键的是FOR UPDATE子句。当多个事务同时执行这条 SQL 时第二个事务会阻塞在锁等待上直到第一个事务提交或回滚释放行锁。InnoDB 引擎默认是行级锁但要注意WHERE id ?的字段必须是主键或唯一索引否则 InnoDB 会升级为表级锁在高并发下反而更慢。事务的隔离级别在这个场景下不需要额外设置MySQL 默认的 REPEATABLE READ 已经足够。因为在第一条 FOR UPDATE 语句中就已经把行锁住了后面的 SELECT 和 UPDATE 都在事务内部执行不会出现幻读。整个事务中要注意连接必须从连接池获取并且使用完毕后要归还连接不能直接 close 物理连接——Druid 连接池的 close 方法实际上是归还连接到池中。4.2 Servlet 中处理选题请求转发与重定向的选择及参数校验选题请求从学生的操作页面发出表单提交的是 topic_id 和 choice_rank。但需要注意choice_rank 不应该由前端传过来正确的做法是后端根据请求中是否已经有第一志愿来判断这次提交的是第一志愿还是第二志愿。因为前端传参是可以篡改的学生完全可以通过修改请求参数把自己第二志愿改成第一志愿。下面是处理选题请求的 Servlet 核心代码WebServlet(/student/selectTopic) public class SelectTopicServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(); User loginUser (User) session.getAttribute(loginUser); if (loginUser null || loginUser.getRole() ! 2) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } int topicId Integer.parseInt(req.getParameter(topicId)); // 判断学生是否已经选过第一志愿 SelectionService service new SelectionService(); int rank 1; if (service.hasSelection(loginUser.getId(), 1)) { rank 2; } boolean ok service.selectTopic(loginUser.getId(), topicId, rank); if (ok) { resp.sendRedirect(req.getContextPath() /student/dashboard.jsp?msgsuccess); } else { resp.sendRedirect(req.getContextPath() /student/select.jsp?msgfull); } } }写这段代码时要格外注意 HTTP 请求中的参数校验。Integer.parseInt(req.getParameter(topicId))在用户传入非数字字符串时会抛出 NumberFormatException这会导致整个请求返回 500。常见做法是写一个工具方法做安全转换private Integer parseIntSafe(String str) { if (str null || str.trim().isEmpty()) { return null; } try { return Integer.parseInt(str.trim()); } catch (NumberFormatException e) { return null; } }在hasSelection方法内部用一条带EXISTS的 SQL 判断即可例如SELECT EXISTS(SELECT 1 FROM t_selection WHERE student_id ? AND choice_rank 1 AND status ! 2)。注意要把已被拒绝的志愿排除掉否则学生第一志愿被管理员拒绝后系统会误以为已经有了第一志愿导致无法再提交新的第一志愿。4.3 JSP 页面渲染技巧JSTL 判断空集合避免页面一整片报错JSP 页面中遇到最大的坑是后端传来的 List 是 null或者里面某些字段是 null直接遍历时整个页面报 500 错误。学生浏览课题的select.jsp页面我现在的写法是通过 Servlet 把课题列表放入 request然后页面用 JSTL 遍历展示。这里 JavaWeb 头歌实训答案、JSP 入门相关的场景里经常出现的错误值得提前预防。先看一个安全的 JSP 片段% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle可选课题列表/title/head body h3当前开放选择的课题/h3 c:choose c:when test${empty topicList} p暂无可选课题请等待教师发布课题。/p /c:when c:otherwise table border1 cellpadding8 cellspacing0 tr th课题名称/th th发布教师/th th名额/th th已选人数/th th操作/th /tr c:forEach vartopic items${topicList} tr td${topic.title}/td td${topic.teacherName}/td td${topic.maxStudents}/td td${topic.selectedCount}/td td c:if test${topic.selectedCount topic.maxStudents} form action${pageContext.request.contextPath}/student/selectTopic methodpost input typehidden nametopicId value${topic.id}/ button typesubmit选择该课题/button /form /c:if c:if test${topic.selectedCount topic.maxStudents} span stylecolor:red名额已满/span /c:if /td /tr /c:forEach /table /c:otherwise /c:choose /body /html这段代码里的关键点是${empty topicList}这个写法它同时处理了 null 和空 List 两种情况不会在取长度时触发空指针。JSTL 的 c:if 代替 Java 脚本里的 if 判断页面干净可维护。这里有一个很多人疑惑的问题EL 表达式访问对象的属性时为什么${topic.teacherName}能取出来因为后端 Topic 对象里提供了getTeacherName()方法EL 会根据 getter 方法名推断属性名。所以写 Java Bean 时一定要遵循驼峰命名规范别在 getter 方法上搞花样。对于一个ListTopic中 Topic 对象不全有 teacherName 的情况后端已经用 JOIN 查询把教师姓名查出来了JSP 里不要嵌套调用数据库。这条原则要刻在脑子里JSP 页面永远不要在% %里写 Java 代码去查数据库。如果发现 JSP 页面里出现了%符号这条代码设计已经失败了需要重构。4.4 管理员的调剂与确认功能状态机驱动的流转逻辑当学生两个志愿都落空时记录会被进入待调剂池。管理员在后台看这些记录时需要看到学生信息、两个志愿的课题名和状态然后手动推荐一个还有名额的课题给学生。推荐动作在数据层面就是更新选题记录的 topic_id 和 choice_rank且把 status 置为 1。而确认动作是把 status 从 0 改成 1。这里其实是一个简单的状态机0 待确认 - 1 已选中0 待确认 - 2 已拒绝。任何一步都要记录操作时间。status字段的类型我用 TINYINT 而不是枚举字符串原因是在数据库层面用整数比字符串省空间且索引效率更高。Java 代码里用一个常量类来维护这些状态值避免魔法数字满天飞public class SelectionStatus { public static final int PENDING 0; public static final int ACCEPTED 1; public static final int REJECTED 2; }在业务逻辑里管理员把某个学生从待调剂池分配到新课题时必须要校验新课题的剩余名额。这里要复用前面selectTopic方法中的事务逻辑不能简单执行一个 UPDATE。从代码复用的角度可以把「检查并扣减名额」封装成TopicService.occupySlot(topicId, conn)方法这样在选题和调剂两处都调用。调剂操作改的是t_selection表的topic_id字段但注意如果学生之前已经占用过另一个课题的名额转调剂后要把原来课题的selected_count减回去否则名额就泄漏了。这个坑我在实际项目里踩过后来加了一组事务操作才修复。5. 避坑指南JSP 项目从开发到部署的 5 个经典现场5.1 本地能跑服务器上 404 或 500现象项目在 IDEA 里启动一切正常部署到服务器或别的主机上访问首页就是 404或者点击某个功能就报 500。原因排查分两路。404 大概率是Context Path的问题就是你访问的路径前缀和部署的应用名不一致。IDEA 里部署时如果 artifact 名称是test_war_exploded那访问路径就是http://localhost:8080/test_/login.jsp而服务器上 WAR 包名是topic_system.war访问路径就变成了http://localhost:8080/topic_system/login.jsp。第二个 500 的原因多半是数据库连接配置不对服务器上的 MySQL 用户权限、host 字段没有授权或者是缺少 MySQL 驱动 JAR。解决路径问题统一用req.getContextPath()生成绝对路径不要写死/projectName/xxx.jsp。JSP 里的链接用${pageContext.request.contextPath}拼前缀。连接问题在部署完先用一个简单的 JSP 页面测试数据库连接打印异常信息再定位。还有一个蠢但好用的排查手段把服务器的 Tomcat 日志catalina.out完整的结尾贴到搜索框里Stack Overflow 上九成问题都有解。5.2 中文乱码页面问号、数据库问号、请求参数问号三处不同原因现象页面上显示的中文课题名称变成???或者乱码表单提交到后台的中文变成乱码。原因这是三个层面的问题。页面显示乱码通常是 JSP 文件编码和浏览器解析编码不一致。请求参数乱码是 Tomcat 8 以下默认用 ISO-8859-1 解码请求体。数据库存储乱码是建表时字符集没设成 utf8mb4。解决JSP 文件顶部写% page contentTypetext/html;charsetUTF-8 languagejava %同时保证 JSP 文件本身用 UTF-8 编码保存。请求参数处理Servlet 里在读取任何参数前执行req.setCharacterEncoding(UTF-8)或者给 Tomcat 的server.xml里的 Connector 加URIEncodingUTF-8属性。数据库层面建表时在 CREATE TABLE 语句结尾加上DEFAULT CHARSETutf8mb4已经建错的表可以用ALTER TABLE t_topic CONVERT TO CHARACTER SET utf8mb4;修复。另外连接字符串里必须带characterEncodingutf8参数JDBC 驱动才会按 UTF-8 编码发送数据。5.3 并发选题导致超卖单测通过但多人同时点就出事现象自己测试选题选到第 6 个人依然提示成功但课题的max_students明明设置的是 5。原因这就是前面讲过的并发问题。两个人的请求同时读到selected_count 4然后都执行了加一操作各自提交事务最终库存变成了 6。原因是代码里没有加行锁或者加了FOR UPDATE但事务提交前就释放了连接。解决严格遵循前面给出的事务三重奏conn.setAutoCommit(false)-SELECT ... FOR UPDATE- 判断后INSERT和UPDATE-conn.commit()。可以用 JMeter 或者简单的多线程 Java 程序模拟 20 个并发请求验证。如果你不想用 FOR UPDATE也可以用UPDATE t_topic SET selected_count selected_count 1 WHERE id ? AND selected_count max_students这条原子语句再检查executeUpdate()返回的影响行数等于 1 才说明抢到了名额。两种方案我都在生产环境验证过FOR UPDATE 更适合后续还要读取其他数据的场景原子 UPDATE 更快但逻辑上不太好理解。5.4 导出课题汇总的 Excel 遇到 Java POI 图表现象管理员要把选题结果导出成 Excel但有领导提了一句希望表里能带个选题占比的图表。原因Java 生态做 Excel 文件的事实标准就是 Apache POI。HSSF 对应 xls 老格式XSSF 对应 xlsx 新格式。要做图表POI 也支持可以调用XSSFChart画柱状图或饼图但需要添加poi-ooxml依赖并原谅他的复杂 API。解决如果只是导出带数据的报表不需要 POI 的图表功能只导出表格用 Sheet 和 Row 接口就够了。实际做选题汇总表我一般用 XSSFWorkbook写一个工具类把查询结果集 List 直接写入 Sheet末尾加一个合计行。POI 生成图表的 API 确实太绕了在毕设场景里不建议死磕用 JFreeChart 生成一张图片放进 Excel 或者网页输出的方案反而更快。你在热词里看到的「Java POI Word 能生成图表吗」问的就是这件事——POI 能做但付出的成本远高于收益如果不是甲方硬性要求直接用前端图表库展示更划算。5.5 传统 JSP 项目打包 WAR 后的部署细节MySQL 权限与防火墙端口现象WAR 包扔到服务器 Tomcat启动后访问不到日志提示连接数据库失败Access denied for user rootlocalhost。原因MySQL 安装时默认只允许 root 从本机 localhost 连接但你的应用在 Tomcat 里跑Tomcat 可能配置为通过网络连接或者用的 JDBC URL 里写了 IP。此时 MySQL 的授权表里没有这条规则。解决建议为应用单独建一个账号不要直接用 root。在 MySQL 命令行执行CREATE USER topic_applocalhost IDENTIFIED BY StrongPwd123; GRANT ALL PRIVILEGES ON topic_system.* TO topic_applocalhost; FLUSH PRIVILEGES;这里 host 字段设为localhost就够了Tomcat 和 MySQL 在同一台机器时通过 socket 连接。应用发布到云服务器时还要检查安全组是否放行了 8080 端口以及 Linux 的防火墙状态firewall-cmd --permanent --add-port8080/tcp firewall-cmd --reload systemctl status mysqld # 确认 MySQL 服务在运行这一节的内容直接对应热搜词里的 mysql 安装、linux 安装 mysql 等如果你在 Linux 上从零部署建议顺序是先装 MySQL 并初始化数据库再上传 WAR 包到 Tomcat 的 webapps 目录最后启动服务看日志。不要先启动 Tomcat因为 WAR 包解压时如果发现连不上数据库会有大量异常刷屏干扰判断。6. 用 JMeter 压测选题接口验证超卖修复效果并找到性能上界6.1 构造一个 50 并发选同一课题的场景验证事务是否生效前面的代码有没有真的把并发问题解决掉需要用工具实测证明而不是靠肉眼看代码。JMeter 是常用的压测工具在它的安装目录的 bin 下启动jmeter.batWindows或jmeterLinux。创建一个测试计划添加线程组线程数设为 50Ramp-Up Period 设为 1 秒表示 1 秒内发起 50 个线程。添加 HTTP 请求默认值填服务器地址和端口。然后添加一个 HTTP 请求采样器路径填/student/selectTopic方法 POST参数填topicId1这 50 个并发请求模拟 50 个不同学生同时点同一个课题。断言也很关键需要根据响应判断请求是否成功。如果选题成功后页面重定向到dashboard.jsp?msgsuccess可以在 JMeter 中添加「响应断言」断言文本填success如果匹配就是成功。跑完压测后再查数据库里的t_selection表统计topic_id1的记录数应该严格等于max_students的值多一条就说明锁没生效。我在本机用这套方式验证过前面的事务代码50 并发中成功写入选课记录的条数恰好等于max_students的值剩下的学生落入了调剂池或提示名额已满。这个结果说明行锁边界是对的。压测数据可以作为答辩素材你能清楚的说出「系统在 1 秒 50 并发下数据一致性和之前相比没有出错」比任何口头描述都更有说服力。6.2 找出性能瓶颈连接池参数调整与 SQL 索引检查压测之后不要只满足于没有超卖还要看吞吐量和响应时间。JMeter 的聚合报告里有 Average、Min、Max 和 Error 四项数据。如果发现平均响应时间超过 500 毫秒或者吞吐量很低优先检查两件事连接池的maxActive是否够用以及 SQL 是否走了索引。用EXPLAIN SELECT ... FROM t_selection WHERE student_id ? AND choice_rank ?查看执行计划如果 type 列是 ALL就说明全表扫描了建议给student_id和choice_rank加联合索引ALTER TABLE t_selection ADD INDEX idx_student_rank (student_id, choice_rank);连接池的参数调整要和数据库的最大连接数配合。如果 Druid 的maxActive配了 50但 MySQL 的max_connections只有 100那你本地压测时如果还有其他服务占用连接很可能出现连接获取超时。推荐的联动配置是DruidmaxActive20MySQLmax_connections200。这样做留有余量也避免连接池配得过大反而拖垮数据库。压测后把initialSize调整为 10相当于系统启动后预先创建 10 个连接能减少第一次请求时的建连耗时。6.3 上线前最后半小时数据备份与可回滚部署脚本正式部署前我会额外做三件小事。第一把数据库导出一个 SQL 文件作为后悔药命令为mysqldump -u root -p topic_system backup_20250101.sql。第二把老的 WAR 包改名为topic_system_old.war而不是直接删除这样新版本出问题可以秒回滚。第三写一个启动脚本对 Tomcat 做健康检查轮询http://localhost:8080/login.jsp返回 200 就算启动完成然后自动发送一条提醒。回滚的操作非常简单停 Tomcat删掉新解压的目录把旧 WAR 包名字改回来再启动。全过程不需要动数据库因为数据是业务过程产生的回滚的只是程序逻辑数据都还在数据库里。最后给一个保存 Session 的小建议如果学校机房有多种浏览器混用注意要开启 Tomcat 的 Session 持久化。在conf/context.xml里取消Manager pathnameSESSIONS.ser /的注释这样 Tomcat 重启后 Session 不丢学生们不用重新登录一遍。这个细节可能不会被注意但实际用下来对体验提升非常明显。这套 JavaJspMysql 的选题系统做完之后最大的收获是让我明白了「老技术」和「过时技术」是两回事。JSP 的脚本时代早已翻篇但它作为视图层配合 Servlet 做 MVC到今天依然能在低配置环境下解决真实问题。数据库事务、连接池调优、并发控制这些基本功换到 Spring Boot 或者微服务架构下一样是核心能力。希望这里的建表逻辑、事务代码和排错顺序能帮到你在你做毕设或课设的时候少一点翻车多一点真正能讲清楚的东西。本文还有配套的精品资源点击获取