恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
JSP+Servlet+MySQL手写教务系统:从建表到部署避坑全攻略
首页
资讯中心
/
JSP+Servlet+MySQL手写教务系统:从建表到部署避坑全攻略
JSP+Servlet+MySQL手写教务系统:从建表到部署避坑全攻略
发布时间:2026/10/9 3:58:08
简介面向高校教务管理场景的毕业设计项目采用JSP、Servlet与MySQL数据库构建提供完整可运行源码适合计算机专业学生进行课程设计、毕业设计或Java Web项目实战练习。系统覆盖学生信息维护、课程管理、成绩录入等核心业务从前端JSP交互页面到后端Servlet控制器再到JavaBean业务封装与MySQL数据访问整体分层清晰功能涵盖用户登录、增删改查、文件上传等典型模块可作为理解MVC开发模式的参考范例。资源包共535个文件其中gif动态演示素材占比最高另有jpg界面图、java源码与class文件、jsp页面、jar依赖库、SQL建表脚本及web.xml等配置文件压缩包约9.87MB并附带说明文档辅助梳理项目结构gif图片与界面截图可直观展示页面效果与操作流程有助于快速还原运行环境。目前已有1132人学习下载适合需要快速搭建可运行教务系统并对照源码二次开发的读者无论是用于毕业答辩演示还是作为项目模板都具有较高参考价值。1. 教务管理系统JSP Servlet MySQL 三件套为什么值得亲手搭一遍教务管理系统是 JavaWeb 毕业设计里最经典的选题之一JSP Servlet MySQL 这三件套就能覆盖登录、角色分权、课程管理、选课、成绩维护这些核心业务。网上下载的源码多半是打包成 .rar 的完整工程很多人卡住的不是业务代码而是环境Tomcat 版本对不上、MySQL 8 连不上、JSP 中文乱码、上传图片重启就丢。这篇文章把这类系统从模块拆分、数据表设计、Servlet 业务链路、JSP 渲染到联调避坑完整讲一遍读者可以照着搭出一套能交差、能二次开发的教务系统。适合正在做毕业设计的学生、刚学完 Servlet 想练完整 JavaWeb 项目的新手以及要接手老项目的开发。这套选型不是为了追新而是让你看清一次请求从 JSP 到 Servlet 再到 JDBC 的完整链路以后切 Spring Boot 时才知道底层到底发生了什么。2. 先把教务系统拆成模块角色矩阵、数据表设计和建库脚本2.1 登录后的角色矩阵学生、教师、管理员各自要做什么这类系统最忌讳一上来就写代码。先定角色再定页面和 URL最后才写 Servlet。一个标准教务管理系统至少有三个角色学生、教师、管理员每个角色登录后看到的功能完全不同。角色登录后首页核心操作对应 URL 前缀学生个人信息展示 可选课程列表选课、退课、查成绩/student/教师授课任务列表录成绩、看选课名单/teacher/管理员数据统计面板维护学生、教师、课程、公告/admin/这个矩阵表决定了后面的路由和权限设计。比如学生访问/teacher/score.jsp就不应该成功这不是靠页面层隐藏按钮实现的而是靠 Servlet 路径和 Filter 控制。我一般会把三个角色的登录请求统一交给一个LoginServlet在参数里带typestudent或typeteacher登录成功后把用户对象和角色塞进 Session后续每个角色专属目录下再放一层角色过滤器。还要注意一个细节很多毕业设计把学生、教师、管理员各建一张表登录时三张表分别查。这个设计能多展示 CRUD 功夫但真实系统更常用统一用户表加role字段。做毕业设计我建议按三张表来做因为答辩时“三套权限体系”听上去比“一个字段管权限”更完整而且代码量也够。对新手来说三张表查询逻辑更直白不容易在 Session 判断里绕晕。2.2 六张主表把核心数据串起来学生、课程、选课、成绩怎么关联无论功能多复杂核心表就是学生、教师、课程、选课关系、院系、管理员这几张。教务系统的难点在选课关系表一个学生选多门课一门课被多个学生选所以必须用中间表t_select_course存关系成绩字段也放在这张表里而不是放在课程表或者学生表里。CREATE DATABASE IF NOT EXISTS edu_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE edu_system; CREATE TABLE t_department ( dept_id INT PRIMARY KEY AUTO_INCREMENT, dept_name VARCHAR(50) NOT NULL UNIQUE ); CREATE TABLE t_student ( stu_id INT PRIMARY KEY AUTO_INCREMENT, stu_no VARCHAR(20) NOT NULL UNIQUE, login_pwd VARCHAR(64) NOT NULL, stu_name VARCHAR(30) NOT NULL, gender TINYINT DEFAULT 1, dept_id INT NOT NULL, class_name VARCHAR(30), phone VARCHAR(20), email VARCHAR(50), photo VARCHAR(255), status TINYINT DEFAULT 0, CONSTRAINT fk_stu_dept FOREIGN KEY (dept_id) REFERENCES t_department (dept_id) ); CREATE TABLE t_teacher ( tea_id INT PRIMARY KEY AUTO_INCREMENT, tea_no VARCHAR(20) NOT NULL UNIQUE, login_pwd VARCHAR(64) NOT NULL, tea_name VARCHAR(30) NOT NULL, title VARCHAR(20), dept_id INT NOT NULL, CONSTRAINT fk_tea_dept FOREIGN KEY (dept_id) REFERENCES t_department (dept_id) ); CREATE TABLE t_admin ( admin_id INT PRIMARY KEY AUTO_INCREMENT, admin_name VARCHAR(30) NOT NULL UNIQUE, login_pwd VARCHAR(64) NOT NULL ); CREATE TABLE t_course ( course_id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(50) NOT NULL, credit DECIMAL(3,1) NOT NULL DEFAULT 2.0, total_hours INT NOT NULL DEFAULT 32, dept_id INT NOT NULL, teacher_id INT NOT NULL, capacity INT NOT NULL DEFAULT 60, selected_count INT NOT NULL DEFAULT 0, week_time VARCHAR(100), location VARCHAR(50), CONSTRAINT fk_course_dept FOREIGN KEY (dept_id) REFERENCES t_department (dept_id), CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES t_teacher (tea_id), INDEX idx_course_name (course_name) ); CREATE TABLE t_select_course ( id INT PRIMARY KEY AUTO_INCREMENT, stu_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,1) DEFAULT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_course (stu_id, course_id) );这段 SQL 里有几个地方是毕业设计最容易丢分的点。第一选课表必须加UNIQUE KEY uk_stu_course (stu_id, course_id)否则同一个学生可以重复选同一门课后期做事务和统计都会出问题。第二t_course里的selected_count是冗余字段它的作用是让选课事务里能用“余额判断”避免超选但代价是每次选课都要同步更新它。第三外键约束建议保留虽然有些开发嫌外键影响写入性能但毕业设计要展示的是数据库设计能力外键和索引都是评分点。如果还想加工作量可以补一张t_notice公告表和t_operation_log日志表页面就有滚动公告栏可以做了。成绩查询的 SQL 建议直接用ORDER BY score DESC, stu_id ASC这种排序规则写在 SQL 里由后端控制JSP 只负责展示避免页面端再做一次不稳定的人工排序。2.3 建库脚本的字符集与索引utf8mb4 和唯一索引别漏很多下载的源码带 .sql 文件但导入后中文乱码十有八九是库的字符集不是 utf8mb4。老教程爱用utf8这个字符集在 MySQL 5.7 里还凑合但碰到生僻字、emoji 昵称就会变成问号。现在建库默认用utf8mb4排序规则用utf8mb4_general_ci兼容性和排序都够用。如果是跟着 mysql 安装教程刚装好的 MySQL导入脚本前先确认SHOW VARIABLES LIKE character_set_database;的结果。索引方面除了外键自带索引外有两处要手动加。一是学生考号和课程号这种用于登录查询的字段建表时已经用UNIQUE覆盖了唯一索引二是t_course.course_name课程名经常被LIKE模糊查询加普通索引能让教务系统里的课程搜索快不少。MySQL 创建索引最简单的方式就是建表语句里的INDEX idx_course_name (course_name)也可以用ALTER TABLE t_course ADD INDEX idx_course_name (course_name);后补。建表时密码字段长度我习惯给到 64因为登录逻辑里会做 MD5 或 SHA-256 后再比对明文密码留 20 个字符反而不够。业务里如果要做成绩汇总这类报表可以在初始化脚本里用 MySQL 存储过程生成统计结果但我一般只在脚本里放视图比如按课程统计平均分、选课人数视图的维护成本比存储过程低答辩时也好解释。3. 用 Servlet 串起核心业务登录校验、选课事务、成绩维护3.1 路由注册的两条路web.xml 与 WebServlet 怎么选Servlet 路由的写法有两种老工程在web.xml里配servlet和servlet-mapping新工程直接在类上加WebServlet(/login)。两种可以混用但一个类不要同时用两种注册方式否则 Tomcat 启动时可能报重复映射。一个最小 Servlet demo 其实只有三件事从请求里取参数、调用 DAO 查数据、用 forward 或 sendRedirect 决定去哪里。servlet servlet-nameLoginServlet/servlet-name servlet-classcom.edu.servlet.LoginServlet/servlet-class /servlet servlet-mapping servlet-nameLoginServlet/servlet-name url-pattern/login/url-pattern /servlet-mapping用注解等价于这两段配置代码更少而且 Servlet 3.0 之后的 Tomcat 都支持。但毕业设计里我建议保留web.xml因为 Filter、Listener、初始参数都在同一个文件里答辩时翻文件讲“请求是怎么被处理的”更方便。两种写法的共同点是url-pattern只写路径不写.jsp后缀真正返回给用户的页面是request.getRequestDispatcher(/student/index.jsp).forward(...)。3.2 LoginFilter 拦住未登录请求Session 校验的最小实现登录接口校验通过后紧接着要解决的是“不登录直接访问内页”的漏洞。只靠 JSP 页面里的判断不可靠正确做法是写一个过滤器统一拦截。WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); // 白名单登录页、登录接口、静态资源直接放行 if (uri.endsWith(/login.jsp) || uri.endsWith(/login) || uri.startsWith(request.getContextPath() /assets/)) { chain.doFilter(req, resp); return; } HttpSession session request.getSession(false); Object loginUser session ! null ? session.getAttribute(loginUser) : null; if (loginUser null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }这段代码的核心是request.getSession(false)注意一定要传false。传false表示没有 Session 时返回 null而不是自动创建一个新 Session否则攻击者每次访问都会被分配一个空 Session过滤器判断永远有问题。白名单里把登录页和静态资源放行其余所有页面和接口都必须有loginUser属性才能继续往下走。登录拦截只解决了“有没有登录”没解决“角色对不对”。角色控制我一般再加上一两个过滤器WebFilter(/student/*)检查 Session 里的角色是不是 studentWebFilter(/admin/*)检查是不是 admin。这样 URL 前缀本身就是权限边界Controller 里不用到处判断角色页面也只谈展示。3.3 选课事务MySQL 里的 commit、rollback 和行锁怎么配合教务系统里最容易暴露编程功底的是选课业务。选一门课要同时做两件事往t_select_course插入一条记录把t_course.selected_count加 1。如果这两步之间抛异常就会出现“选了课但人数没加”或者“人数加了但选课记录没有”的脏数据。解决办法就是把两步包在同一个事务里。public boolean selectCourse(int stuId, int courseId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交进入事务 // 1. 查余量并锁住课程行防止超选 String checkSql SELECT capacity, selected_count FROM t_course WHERE course_id ? FOR UPDATE; try (PreparedStatement ps conn.prepareStatement(checkSql)) { ps.setInt(1, courseId); try (ResultSet rs ps.executeQuery()) { if (!rs.next()) { return false; // 课程不存在 } int capacity rs.getInt(capacity); int selected rs.getInt(selected_count); if (selected capacity) { return false; // 已满直接返回 } } } // 2. 更新课程余量 String updateSql UPDATE t_course SET selected_count selected_count 1 WHERE course_id ?; try (PreparedStatement ps conn.prepareStatement(updateSql)) { ps.setInt(1, courseId); ps.executeUpdate(); } // 3. 插入选课记录 String insertSql INSERT INTO t_select_course (stu_id, course_id) VALUES (?, ?); try (PreparedStatement ps conn.prepareStatement(insertSql)) { ps.setInt(1, stuId); ps.setInt(2, courseId); ps.executeUpdate(); } conn.commit(); // 全部成功才提交 return true; } catch (SQLException e) { rollbackQuietly(conn); // 只要一步失败就整体回滚 log(选课事务失败 student%s course%s, stuId, courseId, e); return false; } finally { resetAutoCommitAndClose(conn); } }代码里的FOR UPDATE是 MySQL InnoDB 的锁定读它会在事务提交前锁住t_course里对应的那一行另一个学生同时选这门课时会被阻塞直到第一个事务提交。这一步是防超选的关键只靠代码里的selected_count capacity判断是不够的两个并发请求同时读到余量 1 时不加锁就会出现两单都成功。这里就涉及 MySQL 锁的分类FOR UPDATE拿到的是行锁如果查询条件走不上索引InnoDB 会退化成表锁所以course_id这种条件一定要有索引辅助。MySQL 事务处理默认是自动提交模式setAutoCommit(false)之后后面的UPDATE和INSERT才在你手里否则每一条语句自己就提交了根本没法回滚。4. JSP 页面把数据摆上桌EL/JSTL、头像上传到分页查询4.1 用 JSTL 和 EL 渲染课程列表JSP 里别写 JavaJSP 页面里最刺眼的代码是% for (...) { %这种脚本片段答辩时老师看到一般不会给高分。整洁的做法是用 EL 表达式拿数据用 JSTL 核心标签做循环和分支。做课程列表展示时Servlet 把查出来的ListCourse存到 request 属性courseList里然后 forward 到 JSP。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html head title可选课程/title /head body h3可选课程列表/h3 table border1 tr th课程号/th th课程名/th th学分/th th容量/th th操作/th /tr c:forEach items${courseList} varc tr td${c.courseNo}/td td a href${pageContext.request.contextPath}/course/detail?id${c.courseId} ${c.courseName} /a /td td${c.credit}/td td${c.capacity - c.selectedCount}/td td c:if test${c.selectedCount c.capacity} a href${pageContext.request.contextPath}/course/select?id${c.courseId}选课/a /c:if c:if test${c.selectedCount c.capacity} span已满/span /c:if /td /tr /c:forEach /table /body /html这段代码没有任何 Java 脚本数据来源全靠${}EL 表达式判断容量是否已满也交给c:if。${pageContext.request.contextPath}是 JSP 获取项目根路径的标准写法所有a href、form action、img src都应该以它开头。很多 JSP 页面图片不显示的常见原因就是写成了相对路径files/xxx.png浏览器会拿浏览器地址栏里的目录去拼结果拼出一个 404。关联数据时注意 EL 只能访问属性里的 getterCourse 类里要有 getCourseNo、getCourseName 这些方法否则 EL 拿不到值。4.2 个人信息展示页的头像上传图片路径和坐标定位的坑先给 JSP 里的个人信息展示页面定位问题。最常见的现象是上传完头像刷新就 404或者别人机器上看不到图片根源不是业务代码而是上传路径写了相对路径。上传头像属于文件写入操作路径必须写成磁盘绝对路径不能写成项目目录下的相对路径因为 Tomcat 的部署目录每次发布都可能被清理。图片如何对坐标定位这个问题困扰新手的大多是background-position和img的混用以及 img 的src路径拼错。只要img的src确实指向文件用 CSS 的object-fit: cover就能把大头照裁成正方形不需要真的做坐标裁剪那是前端复杂业务才需要的东西。form action${pageContext.request.contextPath}/upload/avatar methodpost enctypemultipart/form-data input typefile nameavatar acceptimage/*/ button typesubmit上传头像/button /form c:if test${not empty loginUser.photo} img src${pageContext.request.contextPath}/files/${loginUser.photo} width120 height120 styleobject-fit: cover;/ /c:if注意enctypemultipart/form-data普通表单默认提交格式是 URL 编码Servlet 里用request.getParameter(avatar)拿不到文件内容。上传用的 Servlet 要加注解才能解析文件字段WebServlet(/upload/avatar) MultipartConfig(maxFileSize 1024 * 1024 * 2, maxRequestSize 1024 * 1024 * 5) public class UploadServlet extends HttpServlet { private static final String UPLOAD_DIR D:/edu_upload/; Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Part part req.getPart(avatar); String original part.getSubmittedFileName(); String ext original ! null original.contains(.) ? original.substring(original.lastIndexOf(.)) : .jpg; String fileName UUID.randomUUID().toString().replace(-, ) ext; part.write(UPLOAD_DIR fileName); // 更新 t_student.photo 字段略 resp.sendRedirect(req.getContextPath() /student/profile.jsp); } }MultipartConfig里的maxFileSize限制单个文件大小maxRequestSize限制整个请求大小这是防大文件拖垮 Tomcat 的最简单手段。文件名一定要用 UUID 重命名不能直接存客户原始文件名否则两个用户都传avatar.jpg会互相覆盖还容易把..\这类路径字符带进服务器。文件写到项目目录之外还需要一个/files/*的 Servlet 或 Spring 静态映射来读文件否则浏览器无法直接访问磁盘路径。4.3 分页与条件查询LIMIT 参数化后还要注意排序课程列表动辄几百条不分页会让 JSP 渲染变慢。分页查询最容易翻车的两个点一是用拼接字符串拼 SQL 导致注入二是只ORDER BY course_name造成重复排序或翻页时数据跳动。public ListCourse searchCourses(String keyword, int page, int pageSize) { int offset (page - 1) * pageSize; String sql SELECT course_id, course_no, course_name, credit, capacity, selected_count FROM t_course WHERE course_name LIKE ? ORDER BY course_id ASC LIMIT ?, ?; ListCourse list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); ps.setInt(2, offset); ps.setInt(3, pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Course c new Course(); c.setCourseId(rs.getInt(course_id)); c.setCourseNo(rs.getString(course_no)); c.setCourseName(rs.getString(course_name)); c.setCredit(rs.getBigDecimal(credit)); c.setCapacity(rs.getInt(capacity)); c.setSelectedCount(rs.getInt(selected_count)); list.add(c); } } } catch (SQLException e) { log(课程分页查询失败, e); } return list; }LIMIT ?的两个参数必须是 intoffset是(page - 1) * pageSize第 1 页的 offset 是 0第 2 页是 10。这里不要拼写LIMIT 0,10预编译参数占位符让 MySQL 走 prepare 协议更安全。MySQL 排序在分页场景下的讲究是ORDER BY字段必须有唯一性保证比如course_id ASC只在score DESC这种业务排序需要的时候再加第二排序字段否则两页之间可能漏数据或重复数据。还需要一个SELECT COUNT(*)拿到总记录数来算总页数再结合当前页生成上一页/下一页链接这部分逻辑放在 DAO 里返回一个PageBeanJSP 只循环页码数字。5. MySQL 与 Tomcat 联调避坑五个常见问题逐个排查5.1 现象JDBC 连接 MySQL 报 Communications link failure连接失败是整套系统第一个拦路虎。报错可能写Communications link failure也可能写Public Key Retrieval is not allowed还可能直接超时。原因是多方面的MySQL 8 默认的认证插件是caching_sha2_password而老工程里的 mysql-connector-java 5.1.47 根本不认识这个插件或者连接串里没加serverTimezoneMySQL 8 对时区要求严格还有的是 3306 端口被占用MySQL 服务根本没起来。String url jdbc:mysql://localhost:3306/edu_system ?useUnicodetruecharacterEncodingutf8 useSSLfalse serverTimezoneAsia/Shanghai allowPublicKeyRetrievaltrue; String user root; String password 你的密码; Class.forName(com.mysql.cj.jdbc.Driver);如果本机还没装数据库先按 mysql 5.7.44 安装教程装一个非 8.0 版本配合com.mysql.jdbc.Driver旧驱动最省事如果已经装了 MySQL 8驱动类名必须改成com.mysql.cj.jdbc.Driver连接串带上allowPublicKeyRetrievaltrue。排查顺序是先netstat -ano | findstr 3306确认端口再用命令行客户端mysql -uroot -p确认能登录最后才看 Java 代码。用 docker 安装 mysql 的朋友要记得把 3306 端口映射到宿主机否则 Java 程序里的 localhost 根本访问不到容器内的 MySQL报错和连不上一模一样。5.2 现象登录页和表格里的中文全变成问号代码里全是中文浏览器显示全是???或者方块这是 JSPServlet 经典三乱码。根源有三个地方同时决定编码JSP 文件本身的编码、request.setCharacterEncoding有没有执行、数据库表和 JDBC 连接的字符集是不是 utf8mb4。少一个环节都会翻车。WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); chain.doFilter(req, resp); } }这个过滤器要注册在业务过滤器之前因为req.getParameter()第一次调用时就把请求体的编码定死了之后设置 setCharacterEncoding 无效。JSP 顶部要写% page contentTypetext/html;charsetUTF-8 languagejava %页面里的meta charsetUTF-8也不能省。GET 请求的参数编码不走请求体走 Tomcat 的 URI 编码所以还要在server.xml的 Connector 上改URIEncodingUTF-8。最后的兜底是 JDBC URL 里的characterEncodingutf8以及建库时用 utf8mb4。三条链路都对齐中文才能真正显示。5.3 现象Tomcat 10 下启动报 NoClassDefFoundError: javax/servlet用最新 Tomcat 10 跑老项目启动日志里出现NoClassDefFoundError: javax/servlet/http/HttpServletRequest或者ClassNotFoundException这是 Tomcat 10 把javax.servlet包整体迁移成了jakarta.servlet。不是代码问题也不是 jar 包缺失是整个 API 命名空间换了。解决方案有两个最省事的是把 Tomcat 换回 9.x老代码里的javax.servlet直接能用如果一定要用 Tomcat 10就要把代码里的javax.servlet全部替换成jakarta.servletMaven 依赖也换成jakarta.servlet-api。毕业设计下载的源码基本都是老规范我建议直接在本地用 Tomcat 9别在这个问题上浪费时间。还有一个隐蔽点IDEA 里如果同时存在多个 Tomcat 版本Project Structure 的 Facets 里确认选的是 Tomcat 9 Libraries而不是 maven 里引错的 servlet-api否则启动时看起来没问题运行到涉 Servlet 代码就报错。这类环境问题在黑匣子里最难查所以开头就固定版本最靠谱。5.4 现象上传的头像重启 Tomcat 后 404头像当时上传成功图片能看到重启一次 Tomcat 就变成 404 了。原因很简单上传的绝对路径写到了 IDEA 的 target 目录或者 Tomcat 的 webapps 部署目录里每次重新构建、清理、重启目录内容就变了。常见写法是把文件写到request.getServletContext().getRealPath(/upload)下面这个路径对应的是当前部署目录脆弱得很。解决方案是 4.2 里已经提过的思路定义独立的磁盘目录比如D:/edu_upload/然后单独写一个/files/*的 Servlet 映射去读取这个目录。不要把目录放在项目内部。如果你确实要去给别人演示还得保证对方机器上有同一个UPLOAD_DIR这也是为什么源码包里一般都会带README说明上传目录的位置和创建方式。图片如何对坐标定位这类问题根源仍然是对物理路径和 URL 路径的混淆URL 里看到的是/files/xxx.jpg并不代表它和项目目录有对应关系只是后端把请求映射到了磁盘文件。5.5 现象页面请求变慢MySQL 报 Too many connections系统跑几分钟后所有页面开始卡顿MySQL 报Too many connections。这个问题基本不是 MySQL 配置不够而是代码里连接没有关闭。典型代码长这样Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery();然后没有 finally 块也没有关闭任何资源。JDBC 的Connection不是用完就自动释放的ResultSet和Statement不关的话连接池或 MySQL 服务端的连接数会被耗尽。另一个怪癖是有人把Connection写成 DAO 的成员变量这个更危险DAO 被 Servlet 反复创建后所有实例共享同一个连接一个线程关闭另一个线程就用不了。解决方式是所有 JDBC 操作都用 try-with-resources或者至少 finally 里按ResultSet - Statement - Connection的顺序逆序关闭。我之前写的分页查询代码用的就是 try-with-resourcesPreparedStatement和ResultSet在方法跑完自动 close省心很多。DBUtil.closeConnection(conn)里要判断非空再关如果以后换成连接池close 方法只是把连接还给池不是真断开写代码时别纠结“为什么关了还能拿连接”这回事。6. 跑通后做四件事回滚验证、Session 时效、排序与索引、可移植部署6.1 用并发点两次选课验证事务回滚代码写完了验证事务要故意制造失败。最简单的方式是先在t_course里把某门课的capacity改成 1然后开两个浏览器窗口用两个学生账号同时点选这门课。正常结果是一门成功、一门提示已满且selected_count最终是 1而不是 2。如果两个都成功说明FOR UPDATE没生效去查course_id是不是主键、事务有没有真的关闭自动提交。故意让第二步失败的方法也简单临时把t_select_course的UNIQUE KEY触发一下让同一个学生重复选课看数据库会不会报错、业务有没有回滚。6.2 换一个没登录的浏览器直接访问管理页用隐身窗口直接输入/student/index.jsp如果跳回登录页说明 LoginFilter 起作用了如果页面能打开说明过滤器白名单写得太宽。再试/admin/manage.jsp用学生登录后访问管理员页面如果没被拦就要补角色过滤器。Session 时效也要测登录后把session.setMaxInactiveInterval调到 30 秒30 秒后再点一次跳转看看是否自动踢回登录页。这个检查点能堵住大部分权限漏洞也是答辩老师最爱演示的场景。6.3 用 EXPLAIN 看排序和索引是否真的生效课程搜索慢或选课慢别全靠加索引玄学。把 DAO 里那条查询 SQL 原样拿到 MySQL 命令行前面加EXPLAIN看 type 列和 possible_keys 列。登录查询走的是stu_no唯一索引type 应该是const课程名模糊查询如果用了idx_course_nametype 显示range说明索引被用到了。如果显示ALL就是全表扫描说明 SQL 写法没让索引生效比如对索引字段做了函数运算或者LIKE写成了%keyword%导致前缀未知。分页的ORDER BY course_id ASC如果走主键Extra 里有Using index就正常。6.4 把部署路径和初始化 SQL 写进 README最后一件容易被忽略的事是把环境依赖写清楚JDK 版本、Tomcat 9 还是 10、MySQL 5.7 还是 8.0、连接串里的账号密码、上传目录建在哪。这个 README 不是给老师看的是给两周后的自己看的也是给别人演示时的后悔药。每次换电脑重新部署都会发现少写一条配置已经成了我的固定习惯。这套 JSPServletMySQL 的技术栈虽然旧但它把请求、会话、事务、部署这些概念全摊在明面上你在这里踩过的每个坑都会变成后面用 Spring Boot 时少写的一行 debug 日志。希望帮到你。本文还有配套的精品资源点击获取