简介这是一份基于 ServletJSPMySQL 构建的 JavaWeb 学生成绩管理系统升级版资源面向正在学习 JavaWeb 开发、需要完成课程设计或毕业设计的开发者。相比原项目新增 ECharts 统计图模块可直观展示班级成绩分布、分数段对比与变化趋势便于教师快速掌握整体教学情况。资源共 344 个文件压缩包约 3.59MB包括 86 个 Java 源文件及对应 class 文件、37 个 JSP 页面、62 个 JS 脚本、28 个 CSS 样式同时附带 SQL 数据库脚本、JAR 依赖、Eclipse 工程配置与部分静态资源目录结构清楚便于定位前后端代码。已有 6723 人浏览学习。下载后可直接导入开发工具运行获得登录认证、学生/教师/课程信息维护、成绩增删改查、按条件统计与 ECharts 可视化报表等完整功能代码有助于理解 ServletJSP 的分层开发模式并按需改造复用。1. 这个组合看着复古却是最稳妥的 Java Web 入门路径它到底在做什么单看“Servlet JSP MySQL”这套组合2025 年确实有点复古气息但把它拆开看它描述的其实是一个典型到不能再典型的 Java Web 应用浏览器请求先打到 ServletServlet 调 ServiceService 调 DAODAO 用 JDBC 读写 MySQL页面用 JSP 渲染最后再用 ECharts 把成绩数据画成柱状图、饼图。全程没有 Spring 全家桶没有前后端分离没有微服务。这个系统能解决什么问题对要做课程设计、毕业设计的新人或者只是想在公司内部搭一个轻量成绩管理工具的开发者来说它足够简单、足够直观调试时你能亲眼看到一次请求走过的每一个环节而这种透明感恰恰是框架帮不了你的。我带 A 同学做某个实训项目时就发现很多直接上手框架的人出了错连错误日志该看哪一段都不知道。所以这篇笔记把这个系统能做的事、怎么做、坑在哪一次讲清楚。2. 技术选型与工程骨架Servlet、JSP、MySQL 各管哪一块目录怎么铺2.1 为什么不直接用 Spring Boot而是回到 ServletJSP很多人看到这个标题的第一反应是有 Spring Boot 不用干嘛倒退这个质疑合理但得分场景。如果你要的是高并发、复杂微服务、前后端分离的大工程那 ServletJSP 确实不合适但做学生成绩管理这类 CRUD 密集、页面逻辑简单的系统传统三件套反而有三个不可替代的优势第一没有任何黑匣子请求从进入 Tomcat 到返回 HTML每一步都能在代码里跟到第二环境要求低Tomcat JDK MySQL 就能跑不需要额外引入一堆依赖第三很多高校的课程设计和实训考核题目就明说要用 Servlet你没法绕开。我一般会这样判断如果项目里只有几张表、几个页面、十几个接口而且后续不会有大并发和复杂权限直接拿 ServletJSP 是最省事的。等真需要 Spring Boot 的时候你也不会因为跳过 Servlet 而看不懂请求是怎么流转的。反过来说如果你连 Servlet 都没写过就直接学 Spring Boot遇到问题你会在“这个注解到底怎么起作用的”这种最基础的问题上卡很久。2.2 数据库设计4 张表足够别把系统设计成 ERP学生成绩管理系统听起来很大落到数据库上其实四张表就够管理员表、学生表、课程表、成绩表。很多新手喜欢一张成绩表里把学生姓名、班级、课程名全冗余进去图省事直接查但后续维护会非常难受改个学生姓名要 UPDATE 一堆行。我建议按常规第三范式拆开成绩表只存学号、课程号和分数。建表 SQL 如下注释里标注了关键设计意图CREATE DATABASE IF NOT EXISTS grade_db DEFAULT CHARACTER SET utf8mb4; USE grade_db; -- 管理员表登录用密码存的是 MD5 或加盐哈希不要存明文 CREATE TABLE admin_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password CHAR(32) NOT NULL, real_name VARCHAR(32) ); -- 学生表班级是常用统计维度单独建索引 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(16) NOT NULL UNIQUE, student_name VARCHAR(32) NOT NULL, class_name VARCHAR(32) NOT NULL, gender TINYINT DEFAULT 1, enroll_year YEAR, INDEX idx_class (class_name) ); -- 课程表 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(32) NOT NULL, credit DECIMAL(3,1) DEFAULT 2.0 ); -- 成绩表学号 课程号唯一避免同一个人同一门课录两次 CREATE TABLE score ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(16) NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2), exam_time DATE, UNIQUE KEY uk_stu_course (student_no, course_id), INDEX idx_course (course_id) );注意成绩表用了 UNIQUE 约束这是有意为之。没有这个约束同一个学生同一门课录两次成绩统计图的数据就会翻倍你还在那查半天 SQL 哪里写错了。另外我建议只保留逻辑关联不建物理外键 FOREIGN KEY理由很实在删除成绩、批量导入时物理外键会带来一堆约束报错对新手不友好学号、课程号的有效性在业务代码里做判断就够了。2.3 目录结构与依赖清单照着这张图就能把工程搭出来用 Maven 搭工程的话目录结构长这样我习惯把实体、DAO、Service、Servlet 分层放清楚src/main/java com.example.entity // AdminUser, Student, Course, Score com.example.dao // StudentDao, ScoreDao, CourseDao, AdminDao com.example.service // StudentService, ScoreService com.example.servlet // LoginServlet, StudentServlet, ScoreServlet, StatServlet com.example.util // DBUtil, MD5Util src/main/webapp login.jsp index.jsp student.jsp score.jsp chart.jsp static/echarts.min.js // 从官网下载后放本地避免教室断网打不开 WEB-INF/web.xml pom.xmlServlet 版本选择 3.1 或以上可以完全用 WebServlet 注解注册web.xml 只留根配置能少写很多 XML。JSP 页面建议配合 JSTL 和 EL 表达式因为纯 Java 脚本片段% %在 JSP 里写多了后面维护的人会想骂人。下面这个表格是 Maven 依赖清单每项标了作用依赖坐标作用打包范围javax.servlet-api 4.x提供 Servlet、Filter、Session 相关 APIprovidedTomcat 自带javax.servlet.jsp-api 2.xJSP 编译期支持providedjstl 1.2JSP 标准标签库用于循环、判断随 war 发布mysql-connector-java 8.xJDBC 驱动随 war 发布gson 2.xJava 对象转 JSON供 ECharts 接口使用随 war 发布这里有一个新手经常翻车的点mysql-connector-java 如果只在 IDEA 里 Build Path 加了 jar打 war 包时没带进去部署到独立 Tomcat 就跑不起来报 ClassNotFoundException。Maven 工程里依赖写了且 scope 是默认 compile就不会有这个问题这也是我建议用 Maven 而不是直接在 IDEA 里手动塞 jar 的原因。3. 从登录到成绩查改一次请求走完 View、Servlet、DAO 全链路3.1 登录、Session 与过滤器先解决谁能进系统的问题登录逻辑本身不难难的是登录之后所有页面都得校验“这个人还在不在”总不能在每一个 Servlet 里复制粘贴判断代码。常规做法是用一个过滤器统一拦截。先看登录的 LoginServletWebServlet(/login) public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password MD5Util.md5(req.getParameter(password)); AdminDao dao new AdminDao(); AdminUser admin dao.findByUsernameAndPassword(username, password); if (admin ! null) { // 登录成功把用户信息放入 Session后面的过滤器就靠它判断 HttpSession session req.getSession(); session.setAttribute(loginUser, admin.getRealName()); session.setMaxInactiveInterval(30 * 60); // 30 分钟无操作自动失效 resp.sendRedirect(req.getContextPath() /index.jsp); } else { req.setAttribute(msg, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); } } }成功之后必须 sendRedirect不能用 forward。原因在避坑章节会细说这里先提一句forward 会导致刷新页面时重新提交表单登录倒是无所谓但成绩录入会出大问题。密码再提醒一次存库前做 MD5 或加盐处理别存明文。再看 LoginFilter拦截所有请求除了登录接口和静态资源Session 里没有登录用户就踢回去WebFilter(/*) public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); String ctx request.getContextPath(); // 放行登录接口、静态资源其余请求必须已登录 if (uri.endsWith(/login) || uri.endsWith(/login.jsp) || uri.startsWith(ctx /static/)) { chain.doFilter(req, resp); return; } Object user request.getSession().getAttribute(loginUser); if (user null) { response.sendRedirect(ctx /login.jsp); } else { chain.doFilter(req, resp); } } }这段代码里最容易被忽略的是ctx /static/的判断。页面用了本地 ECharts 文件如果你没放行 static 目录浏览器请求 echarts.min.js 会被过滤器拦一圈控制台报 302 然后你找半天不知道哪里断了。3.2 DAO 层PreparedStatement 是底线Statement 拼接是事故高发区DAO 层干的事只有一个把 Java 对象和数据库行互相转换。这里最常见的问题是新手用 Statement 拼 SQL一旦用户输入里带引号轻则语法报错重则 SQL 注入。以下面的 StudentDao 为例我统一用 PreparedStatementpublic class StudentDao { public ListStudent findByClass(String className) { ListStudent list new ArrayList(); String sql SELECT id, student_no, student_name, class_name, gender, enroll_year FROM student WHERE class_name ? ORDER BY student_no; // 使用 try-with-resources自动关闭连接避免连接泄漏 try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, className); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Student s new Student(); s.setId(rs.getInt(id)); s.setStudentNo(rs.getString(student_no)); s.setStudentName(rs.getString(student_name)); s.setClassName(rs.getString(class_name)); list.add(s); } } } catch (SQLException e) { throw new RuntimeException(查询学生失败, e); } return list; } }代码里的逻辑很简单但有两个习惯值得养成第一SQL 里的?占位符和 setString 的顺序必须一一对应一旦字段多了就容易看走眼我习惯把 SQL 拆行写方便对号第二Java 7 之后 try-with-resources 能自动关连接比在 finally 里手动写三个嵌套 if 省心得多也能杜绝“忘记关闭连接导致 MySQL 连接数打满”的线上事故。DBUtil 类里封装的是最传统的 Class.forName DriverManager 获取连接教学阶段够用等并发上来了把它内部换成 HikariCP 或 Druid 连接池DAO 的调用方不用改动。3.3 成绩录入与条件查询业务的核心在这张表上成绩录入的表单一般长这样选择学生、选择课程、填分数。对应的 ScoreDao 里有两个核心方法插入成绩和条件查询。插入时要注意 UNIQUE 约束重复插入会抛异常需要捕获后给用户一个友好提示而不是让页面上出现一大段 SQL 异常堆栈。条件查询的 SQL 是动态拼的学生姓名、班级、课程都是可选条件public ListScoreVO queryScores(String studentName, String className, Integer courseId) { StringBuilder sql new StringBuilder( SELECT s.student_no, s.student_name, s.class_name, c.course_name, sc.score, sc.exam_time FROM score sc JOIN student s ON s.student_no sc.student_no JOIN course c ON c.id sc.course_id WHERE 11 ); // 用?条件 参数数组防止 SQL 注入 ListObject params new ArrayList(); if (studentName ! null !studentName.trim().isEmpty()) { sql.append( AND s.student_name LIKE ?); params.add(% studentName.trim() %); } if (className ! null !className.trim().isEmpty()) { sql.append( AND s.class_name ?); params.add(className.trim()); } if (courseId ! null courseId 0) { sql.append( AND c.id ?); params.add(courseId); } sql.append( ORDER BY sc.exam_time DESC, s.student_no); // 参数数组化后交给 PreparedStatement try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql.toString())) { for (int i 0; i params.size(); i) { ps.setObject(i 1, params.get(i)); } // 省略 ResultSet 转换逻辑 } catch (SQLException e) { throw new RuntimeException(查询成绩失败, e); } return list; }这里最值得说的是WHERE 11这个写法。很多人会觉得它很奇怪但动态条件拼接的场景里它避免了第一条件用 WHERE、后续条件用 AND 的分支判断代码更干净。前提是你已经在 DAO 层用 PreparedStatement 把住了注入问题不然这种写法会放大注入风险。另外注意 JOIN 两张表要用 student_no 和 course_id 关联而不是在 score 表里冗余存储学生姓名——这是前面表结构设计埋的伏笔查询时多写一个 JOIN换来的是数据一致性。页面上用 JSTL 循环渲染成绩表格核心片段就这一段% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table classtable table-bordered thead trth学号/thth姓名/thth班级/thth课程/thth分数/thth考试日期/th/tr /thead tbody c:forEach items${scoreList} varvo tr td${vo.studentNo}/td td${vo.studentName}/td td${vo.className}/td td${vo.courseName}/td td${vo.score}/td td${vo.examTime}/td /tr /c:forEach /tbody /table如果你发现 el 表达式输出的是javabean对象的属性名而不是值比如显示${vo.studentNo}这几个字那不是代码逻辑错了而是页面没引入 JSTL 标准库或者 servlet-api 和 jsp-api 的 scope 配错导致编译异常。这个坑在避坑章节里单独列一条。还有一个容易被忽略的点JSP 页面里能不放 Java 代码就坚决不放数据尽量在 Servlet 里组装成 VO 对象让页面只负责展示这样后面接 ECharts 时也清爽。4. 用 ECharts 画成绩统计图后端出 JSON、前端画图、下拉框联动4.1 先定统计口径什么图最能说明问题统计图不是越多越好而是每一张图都得回答一个具体问题。成绩管理系统里我认为三张图最实用也最容易被答辩老师认可图表回答的问题统计口径柱状图哪个班平均分高按班级分组AVG(score)饼图分数段分布是否正常按优秀/良好/及格/不及格四段统计人数折线图各科难度对比按课程分组展示平均分和及格率两个指标这三张图的统计口径要在 Servlet 里写 SQL 时想清楚尤其是分数段的划分是 90/80/60/低于60还是按实际考试难度调整我一般会在 Servlet 里把分段做成常量方便以后改而不是把分段规则写死在 SQL 里。教条一点说统计口径属于业务规则应该集中在 Java 代码的常量里管理。4.2 统计接口一个返回 JSON 的 Servlet而不是 JSPECharts 需要的数据格式是 JSON所以统计后台不用再走 JSP 渲染那套了。常见做法是写一个 StatServlet查询完数据后用 Gson 转成 JSON 字符串通过 response 直接写出去。下面实现“各班级平均分对比”的接口WebServlet(/stat/classAvg) public class StatServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(application/json;charsetUTF-8); String sql SELECT s.class_name AS name, AVG(sc.score) AS avgScore FROM score sc JOIN student s ON s.student_no sc.student_no GROUP BY s.class_name ORDER BY avgScore DESC; ListMapString, Object data new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { MapString, Object row new HashMap(); row.put(name, rs.getString(name)); row.put(avgScore, rs.getDouble(avgScore)); data.add(row); } } catch (SQLException e) { throw new RuntimeException(e); } // Gson 负责把 ListMap 转成 [{name:计科2301,avgScore:82.5}, ...] resp.getWriter().write(new Gson().toJson(data)); } }有几个细节值得较真。第一resp.setContentType(application/json;charsetUTF-8)必须写在获取 Writer 之前而且 charset 必须带否则浏览器拿到 JSON 里中文就是乱码。第二用 Gson 而不是手拼 JSON 字符串是因为 VALUES 里如果出现引号、换行等特殊字符手拼很容易把 JSON 拼坏你还在前端控制台查半天数据为什么是 undefined。第三我特意用了ListMapString, Object而不是新建一个 StatVO 类因为统计结果字段不固定动态 Map 在简单图表场景下代码更短如果以后字段多了再考虑抽类。4.3 前端图表渲染JSP 里嵌 ECharts 的最小可运行代码chart.jsp 页面里先放一个 div 作为图表容器然后在 script 里 init 并配置 option。ECharts 的渲染过程很简单核心就是init一个 DOM 节点再setOption塞数据。下面是能直接跑的完整片段% page contentTypetext/html;charsetUTF-8 languagejava % !DOCTYPE html html head meta charsetUTF-8 title班级平均分统计/title !-- 本地静态文件避免演示时依赖外网 CDN -- script src${pageContext.request.contextPath}/static/echarts.min.js/script /head body !-- 注意容器必须有宽度和高度否则 echarts 渲染出来是空白 -- div idclassAvgChart stylewidth:100%;height:380px;/div script var chart echarts.init(document.getElementById(classAvgChart)); // 用 fetch 请求统计接口拿到 JSON 后设置柱状图数据 fetch(${pageContext.request.contextPath}/stat/classAvg) .then(res res.json()) .then(data { var xNames data.map(item item.name); var xValues data.map(item item.avgScore); chart.setOption({ title: { text: 各班平均分对比 }, tooltip: { trigger: axis }, xAxis: { data: xNames }, yAxis: { type: value }, series: [{ name: 平均分, type: bar, data: xValues }] }); }); /script /body /html这段代码里有三个最常见的翻车点。第一div 没给高度ECharts 在计算尺寸时得到 0画出来就是空白这是图表不显示的罪魁祸首之一。第二echarts.init的调用时机必须在 DOM 元素存在之后如果你把 script 放在 head 里document.getElementById 会拿到 null报 init 相关错误常规做法是把脚本放在页面底部。第三fetch 返回的 res.json() 本身是异步的很多新手在 then 外面就尝试使用 data拿到的自然是 undefined。如果你对 Promise 不熟用 jQuery 的 $.ajax 也是一样的道理回调里拿到数据再 setOption。4.4 让图表跟着条件走下拉框联动重新拉数据静态统计图只能看个热闹真要答辩时被打出高分的是能交互的图。我这里说的交互不是炫酷动画而是页面上放班级、课程两个下拉框用户选中后图表自动重新请求数据并更新。实现方式不复杂前端监听下拉框 change 事件带上参数请求不同的接口var classSelect document.getElementById(classSelect); classSelect.addEventListener(change, function () { var selectedClass classSelect.value; fetch(${pageContext.request.contextPath}/stat/classAvg?className encodeURIComponent(selectedClass)) .then(res res.json()) .then(data { chart.setOption({ xAxis: { data: data.map(item item.name) }, series: [{ data: data.map(item item.avgScore) }] }); }); });对应后端 Servlet 里的逻辑就是在原来固定 SQL 上追加一个 WHERE 条件这个跟成绩查询那节一个套路。需要强调的是前端参数传进来后 PHP 风格的字符串拼接是不行的你仍然要回到 PreparedStatement 的?占位方式否则统计接口同样会成为注入入口。另一个小技巧是setOption 里的 xAxis 和 series.data 都会整体覆盖旧值不需要调用 clear 再重绘这就是 ECharts 的“增量更新”特性它能自动做动画过渡比自己在外面销毁重建图表稳健得多。5. 避坑指南乱码、驱动、连接泄漏、图表空白这四关怎么过5.1 页面和 JSON 中文全变乱码现象JSP 页面中文显示正常但浏览器请求 Servlet 返回的 JSON 中文变成一串问号或者表单提交后 DAO 里收到的中文变成乱码。原因一层层拆开看要么是 MySQL 连接 URL 没指定编码要么是 JSP 页面 pageEncoding 和 contentType 不一致要么是 Servlet 里 response 写 JSON 前没设 charset。最常见的其实是第二种Tomcat 8 之前 GET 请求默认用 ISO-8859-1 解码Tomcat 8 之后默认改成了 UTF-8如果你用的是高版本 Tomcat 但连接串或页面编码没统一问题依旧。解决三处必须全部统一为 UTF-8。第一MySQL 连接 URL 加characterEncodingutf8第二所有 JSP 页面头部写% page languagejava contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %第三Servlet 里写 JSON 之前在 doGet/doPost 开头先 setCharacterEncoding。如果是 POST 请求还要在 Filter 里对每个请求调用request.setCharacterEncoding(UTF-8)。这三处都做齐了乱码基本消失剩下的就是清理数据库里已经乱掉的历史数据。5.2 mysql-connector 版本和驱动类对不上现象本地 IDEA 跑得好好的打 war 包部署到另一台机器启动报ClassNotFoundException: com.mysql.jdbc.Driver或者报Communications link failure。原因第一MySQL 5 和 MySQL 8 的驱动类名不一样MySQL 8 用的是com.mysql.cj.jdbc.Driver老教程里写的com.mysql.jdbc.Driver在 8.x 驱动下已经移除了第二依赖 jar 进了编译路径但没进 war 包的 WEB-INF/lib第三MySQL 8 驱动默认会去校验 SSL 和时区没带参数就会连接失败。解决代码里 DBUtil 的类名改成com.mysql.cj.jdbc.Driver连接 URL 写成完整格式jdbc:mysql://localhost:3306/grade_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8部署到独立 Tomcat 前用mvn clean package打 war 包解压后检查WEB-INF/lib里是否真的有 mysql-connector-java 对应的 jar这比调试日志来得直观。5.3 ECharts 容器画出来一片空白现象页面加载完成div 位置是空的F12 看控制台没有 JS 报错接口也返回了正确数据。原因这个坑我踩过不止一次。最常见的不是数据问题而是容器高度为 0。ECharts 初始化时读取 container 的 clientWidth 和 clientHeight如果 div 只设了宽度没设高度图表高度就是 0渲染出来空白但也不报错非常隐蔽。第二个常见原因是 init 时 DOM 还没被解析出来script 放在了 head 里。解决div 上直接写内联样式stylewidth:100%;height:380px;script 挪到页面底部。如果这两个都改了还空白就在浏览器地址栏直接访问/stat/classAvg那个接口看返回的 JSON 是不是合法。记住一句话先确认数据再怀疑 JS。ECharts 的视频教程里那些动画效果都是数据没问题之后才谈的。5.4 MySQL 连接数被打满页面卡成 PPT现象系统用了一阵子突然所有页面都报Too many connections重启 Tomcat 后恢复过一阵又炸。原因代码里获取了 Connection 但没关闭。最典型的是 ResultSet 查完没关或者关闭顺序不对导致连接根本没还回连接池。如果你用 DriverManager 每次新建连接情况更严重因为 MySQL 默认 max_connections 只有一百多。解决我上面 StudentDao 示例里用了 try-with-resources它会自动按逆序关闭 ResultSet、Statement、Connection。如果你维护的是老代码至少要做到在 finally 块里关闭而且顺序必须是 ResultSet - Statement - Connection。一个更快定位的办法是连接 MySQL 后执行SHOW PROCESSLIST;看到大量 Sleep 状态的连接基本就是连接泄漏然后按住 Ctrl 去排查每个 DAO 方法的关闭逻辑。5.5 表单提交后刷新页面成绩重复插入现象录入成绩后点刷新按钮浏览器弹出“确认重新提交表单”对话框点确定后同一条成绩被插入了两次。原因这是 HTTP POST 语义导致的经典问题。Servlet 处理完插入后用的转发是request.getRequestDispatcher(...).forward(...)浏览器地址栏没变F5 刷新时会把上一次的 POST 请求原样重发。这个问题在浏览器上看着像网络问题其实是服务端跳转方式不对。解决插入操作完成后一律用重定向也就是第 3 章 LoginServlet 里写的sendRedirect专业名称叫 Post/Redirect/Get 模式。改成重定向后刷新变成 GET 请求不会重复提交。如果想要更稳可以在表单加一个隐藏 token 做重复提交校验但那个属于后话了先把重定向的习惯养好。6. 进阶用法批量录入加事务、排名用窗口函数、部署前检查清单系统跑通后接下来要思考的是怎么从“能跑”变成“能交”。这里我不谈花哨功能只讲三个最能体现工程意识的操作。先看批量录入成绩。一般来说考试后成绩是 Excel 一批一批导进来的循环里调 DAO 的 insert 方法没问题但中间任何一条失败前面的会保留、后面的会中断数据就不一致。用事务包一层就能解决Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); // 关闭自动提交后续所有 insert 属于同一事务 for (Score s : scoreList) { scoreDaoBatch.insert(conn, s); // 注意方法需要接收 Connection 参数 } conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.close(); }注意事务版本的 DAO 方法要把 Connection 传进去而不是在 DAO 内部自己再获取一条新连接。这个细节很多人会忽略导致事务完全失效——各连各的rollback 也没用。批量插入还有一个实际性能技巧用 JDBC 的 addBatch/executeBatch 代替逐条 executeUpdate几百条成绩可以快一个数量级。再看排名统计。想在成绩列表显示“总分排名”用 Java 排序得先查全表再慢慢排写起来冗长。MySQL 8 支持窗口函数一行 SQL 就能解决SELECT student_name, class_name, AVG(score) AS avg_score, RANK() OVER (PARTITION BY class_name ORDER BY AVG(score) DESC) AS class_rank FROM score sc JOIN student s ON s.student_no sc.student_no GROUP BY sc.student_no, student_name, class_name;这里我只是举了“班级内按平均分排名”的例子。如果版本不支持窗口函数就得用 GROUP BY 变量模拟或者干脆在 Java 层算性能要求不高时也可以接受但能用窗口函数就别自己造轮子。这个点放在论文里也是一个不错的“创新点”来源。最后一个很不显眼但杀伤力极大的问题部署环境不一致。本地用 JDK 17 编译服务器装了个老版 JDK 8运行直接报UnsupportedClassVersionError。我有个习惯部署前把三件事核对一遍Maven 编译 target 版本有没有跟服务器 JDK 一致、mysql-connector-jar 有没有真的打进 war 包、数据库连接串里的时区和 SSL 参数是不是完整。这三件事每件都花不了两分钟但任何一个错了演示现场就是灾难。说起来有一次我在某教室帮 A 同学做系统演示页面一直报连接超时查到最后发现是服务器防火墙把 3306 端口拦了。那之后我就养成了一个习惯任何部署系统先 telnet 一下 IP 端口通了再考虑业务问题。业务问题可以从日志里查环境问题有时候真的会变成“玄学”与其现场花半小时猜不如动手前先把网络、版本这些硬条件列一遍。希望帮到你。本文还有配套的精品资源点击获取