ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Servlet+JSP+MySQL宿舍管理系统开发实战:从环境搭建到避坑指南

Servlet+JSP+MySQL宿舍管理系统开发实战:从环境搭建到避坑指南 简介一份基于Servlet、JSP与MySQL的JavaWeb学生宿舍管理系统代码包适合正在学习JavaWeb开发的学生或需要完成课程设计、毕业设计者参考。系统覆盖登录验证、图形验证码、楼栋管理、宿舍分配、学生信息维护、宿管员管理等核心模块有助于理解经典三层架构与MVC思想。压缩包共295个文件主要包含Java源码、JSP页面、样式脚本、图片资源、数据库脚本及依赖库大小16.88MB目录清晰资源内附数据库脚本方便快速初始化环境。代码中的数据库操作类与各类控制器直观反映从数据访问到页面响应的完整调用关系能帮助读者快速搭建同类型管理系统。已有5921人浏览学习是掌握Servlet与JSP整合开发的实用参考项目包含完整的增删改查操作对理解系统开发流程很有帮助。1. Servlet JSP MySQL 做宿舍管理系统一个能跑通毕设也能写进简历的经典组合学生宿舍管理系统大概是 JavaWeb 方向被做烂了、但恰恰最值得亲手做一遍的项目。它业务边界清楚——学生管理、宿舍分配、报修登记、公告发布不涉及支付、秒杀那类高并发场景正好把 Servlet、JSP、MySQL 这三样东西的协作关系完整走一遍。你在这个项目里写的每一个请求处理、每一次 JDBC 查询、每一行 JSP 渲染到了 Spring Boot 项目里都是同一个套路换了层皮。对于刚学完 Java 基础、正要接触 javaWeb 的开发者或者准备拿课程设计交差的学生这个项目能把前端页面怎么提交数据→Servlet 怎么接→MySQL 怎么存→结果怎么回显这条链路彻底打通。本文按我实际搭过项目的顺序来讲环境配对、建表建模、Servlet JSP 分工、以及那些让你查一晚上也找不到原因的坑。2. 环境搭建JDK / Tomcat / MySQL / IDEA 的版本配对与最小可运行骨架2.1 JDK 与 Tomcat 版本兼容Java 17 配 Tomcat 9 还是 10先解决一个能卡住一半新手的版本问题。Servlet JSP 这套技术栈对运行环境极其敏感——Tomcat 10 开始把javax.servlet命名空间整体改成了jakarta.servlet这意味着你在网上搜到的绝大多数 Servlet 教程代码包括很多学校的实验手册直接复制到 Tomcat 10 上会编译报错程序包 javax.servlet 不存在。所以我的建议非常明确做这个项目无脑选 JDK 8 或 JDK 11 Tomcat 9.x MySQL 5.7 或 8.0。这三个版本互相兼容教程最多踩坑答案最好搜。如果你电脑里已经装了 JDK 17也能跑 Tomcat 9但 Maven 编译时要把source和target设成 11 或 8否则可能出现不支持发行版本的编译错误。MySQL 版本方面5.7 和 8.0 都能用但驱动选择有区别MySQL 5.7 用com.mysql.jdbc.DriverMySQL 8.0 必须用com.mysql.cj.jdbc.Driver并且连接串要带serverTimezone参数。我的建议是既然新装就装 8.0因为 5.7 官方已经停止维护了但要注意连接串配置别抄错这一块在第 3.2 节详细展开。Tomcat 9 下载后不需要安装解压即用。关键配置是conf/server.xml里的端口默认 8080如果你本机装了别的服务占用 8080把这里改成 8081 或 9090 都行。2.2 用 IDEA 创建 Maven 版 JavaWeb 项目web.xml 和目录结构一步到位虽然老教程里教你在 IDEA 里直接创建 Java Enterprise 项目并勾选 Web Application但我在实际工作中更推荐先建一个普通 Maven 项目再手动补上src/main/webapp目录和web.xml。理由有两个第一IDEA 自带的 Java Enterprise 模板会默认引入 Tomcat 10 的依赖直接把jakarta.servlet写进你项目里和你的老教程代码冲突第二手动建目录能让你真正理解 JavaWeb 项目的标准结构后面出问题不至于黑匣子。具体操作步骤# 1. 用 IDEA 新建 Maven 项目不勾选任何模板 # GroupId 填 com.dormArtifactId 填 dorm-system # 2. 在 src/main 下新建 webapp 目录 # 然后在 webapp/WEB-INF 下新建 web.xml这是 web.xml 的最小可用版本?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 display-namedorm-system/display-name welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list /web-app这段配置的作用是声明这是一个 Servlet 4.0 规范的 Web 应用并把访问根路径时默认打开的页面指向index.jsp。注意version4.0对应 Tomcat 9如果你写成 5.0Tomcat 9 会直接启动失败。IDEA 里配置 Tomcat 时Deployment 选项卡选 war explodedApplication context 填/dorm这样访问路径就是http://localhost:8080/dorm/。2.3 第一个 Servlet 跑通请求链路验证环境是否真正可用环境配好之后别急着写业务代码先写一个最小 Servlet 把整条链路打通。这一步能过滤掉 80% 的环境问题后续写业务时你就知道报错到底来自代码还是环境。先在pom.xml里加上 Servlet API 依赖dependencies !-- Servlet API编译时需要运行时不打包进 war -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- JSP API同样只需要编译期 -- dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency /dependencies然后写一个测试 Servletpackage com.dorm.servlet; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; WebServlet(/hello) public class HelloServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 注意setContentType 必须在 getWriter() 之前调用 resp.setContentType(text/html;charsetUTF-8); resp.getWriter().write(h1Servlet is running/h1); } }WebServlet(/hello)是 Servlet 3.0 之后提供的注解方式省去了在 web.xml 里逐条配置servlet和servlet-mapping的繁琐。路径必须以/开头否则启动时报WebServlet 路径为空的错误。scopeprovided/scope的意思是 Servlet API 由 Tomcat 在运行时提供不打包进你的 war 包——如果你把这个依赖的 scope 写成 compile最终部署时可能出现类冲突这是个很容易被忽略的细节。启动 Tomcat浏览器访问http://localhost:8080/dorm/hello看到页面上出现 Servlet is running说明编译、打包、部署、请求映射这条链路已经通了一半。接下来把 MySQL 接进来。3. 数据库设计与 JDBC 层宿舍楼栋、学生、报修三张表的建模与连接池3.1 三张核心表结构学生表为什么要冗余楼栋字段宿舍管理系统的业务模型不复杂但表结构设计得好不好直接决定你写 SQL 时要不要来回 join。我给出的方案是五张表管理员表、楼栋表、宿舍表、学生表、报修表。公告表可以后面再补核心先把前五张的关联理清。-- 楼栋表 CREATE TABLE t_building ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(20) NOT NULL COMMENT 楼栋名称如学宿1号楼, floor_count INT DEFAULT 6 COMMENT 楼层数, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 宿舍表 CREATE TABLE t_dorm ( id INT PRIMARY KEY AUTO_INCREMENT, building_id INT NOT NULL COMMENT 所属楼栋, room_no VARCHAR(10) NOT NULL COMMENT 房间号如 301, capacity INT DEFAULT 4 COMMENT 床位数量, occupied INT DEFAULT 0 COMMENT 已住人数, UNIQUE KEY uk_building_room (building_id, room_no), CONSTRAINT fk_dorm_building FOREIGN KEY (building_id) REFERENCES t_building(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 学生表 CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(30) NOT NULL, gender TINYINT DEFAULT 1 COMMENT 1男 0女, dorm_id INT COMMENT 所在宿舍IDNULL表示未分配, phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_student_dorm FOREIGN KEY (dorm_id) REFERENCES t_dorm(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 报修表 CREATE TABLE t_repair ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL COMMENT 报修人, dorm_id INT NOT NULL COMMENT 报修宿舍, description VARCHAR(500) NOT NULL COMMENT 问题描述, status TINYINT DEFAULT 0 COMMENT 0待处理 1处理中 2已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个设计上的关键决策。第一学生表里为什么不直接存楼栋房间号而是存dorm_id因为如果存冗余文本字段一旦宿舍调整你要 UPDATE 学生表的所有记录而存外键的话只需要一次UPDATE t_student SET dorm_id ? WHERE id ?楼栋和房间信息通过 JOIN 查出来。第二t_dorm表里同时维护capacity和occupied两个字段这是一个典型的空间换时间设计——查询哪个宿舍有空床位时直接WHERE occupied capacity就能拿到结果不用 COUNT 学生表。代价是分配宿舍时必须在一个事务里同时 UPDATE 两张表后面 4.3 节会讲这个事务怎么写。第三所有表名都加了t_前缀并且字段名用下划线命名这样做是为了避免和 MySQL 的保留字冲突——比如你给表起名叫student没问题但叫order就会在 INSERT 时报语法错误。另外注意字符集统一用utf8mb4而不是utf8原因很简单utf8在 MySQL 里是 utf8mb3 的别名存不了 emoji 和部分生僻字虽然宿舍管理系统里不太可能出现 emoji但统一用 utf8mb4 是习惯问题不至于后面扩展功能时踩编码的坑。3.2 MySQL 连接串与连接池参数characterEncoding 和 serverTimezone 一个不能少表建好之后下一步是让 Java 代码能连上 MySQL。这里有两个选择直接用 JDBC 的DriverManager.getConnection()每次请求连一次数据库或者用连接池。我强烈建议你直接上连接池——不是因为它高级而是因为用DriverManager每次连接都要经过 TCP 握手 MySQL 认证在高一点点的访问量下就会超时而且你还要自己管连接的关闭稍不注意就连接泄漏最终报Too many connections。我用的是 Druid 连接池国内项目用得最多配置项中文文档全。先把依赖加进 pom.xmldependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency然后在src/main/resources下新建druid.propertiesdriverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/dorm_db?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue usernameroot password你的密码 initialSize5 maxActive20 maxWait3000 minIdle2这里每个参数都值得说清楚。driverClassName用com.mysql.cj.jdbc.Driver这是 MySQL 8.0 驱动的新类名旧驱动com.mysql.jdbc.Driver在新版本驱动里虽然保留兼容但会打印弃用警告。useSSLfalse是因为本地开发用不到加密连接打开反而因为证书问题报错。serverTimezoneAsia/Shanghai是 MySQL 8.0 的硬性要求——不设置这个参数连接时大概率报The server time zone value й׼ʱ is unrecognized这种乱码时区错误。allowPublicKeyRetrievaltrue是 MySQL 8.0 用 caching_sha2_password 认证插件时的要求不配置可能报Public Key Retrieval is not allowed这个错位很隐蔽网上查半天都查不到。initialSize和maxActive是连接池的核心指标启动时建立 5 个连接最多 20 个超过 20 个请求就要排队等maxWait毫秒。宿舍管理系统这个量级5/20 绰绰有余。写一个工具类获取连接package com.dorm.util; import com.alibaba.druid.pool.DruidDataSourceFactory; import javax.sql.DataSource; import java.io.InputStream; import java.sql.Connection; import java.sql.SQLException; import java.util.Properties; public class DbUtil { private static DataSource dataSource; static { try { // 从 classpath 加载 druid.properties InputStream in DbUtil.class.getClassLoader() .getResourceAsStream(druid.properties); Properties props new Properties(); props.load(in); dataSource DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { // 初始化失败必须抛出异常否则后面 getConnection 会空指针 throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这段代码里有两个要点。一是DruidDataSourceFactory.createDataSource(props)直接读 Properties 就能创建好连接池不需要手动 new DruidDataSource 再 set 一堆属性。二是静态代码块里初始化失败要抛出ExceptionInInitializerError而不是只打印日志——否则连接池创建失败后后续每次调用getConnection()拿到的都是 null报错信息完全不指向根因。写完这个工具类可以用一个最简单的 main 方法测试连接public static void main(String[] args) throws SQLException { Connection conn DbUtil.getConnection(); System.out.println(conn ! null ? 数据库连接成功 : 连接失败); conn.close(); }这里conn.close()在连接池环境下并不是真正关闭连接而是把连接归还给连接池。这是连接池最常见的一个认知误区——你以为关掉了其实连接还在池子里待着下次 getConnection 还能复用。4. Servlet JSP 实现核心功能登录拦截、学生 CRUD 与宿舍分配4.1 登录与 Session 拦截Filter 里做未登录跳转业务代码的第一步永远是登录而登录功能最关键的不是验证用户名密码那个 SQL而是登录之后的权限控制。如果不做拦截用户直接访问http://localhost:8080/dorm/student/list就能绕过登录页看到所有数据——这在课程设计的答辩现场是致命的缺陷老师大概率会当场让你演示越权访问。登录逻辑本身很简单Servlet 里拿到表单提交的 username 和 password去数据库比对成功后把用户信息放进 Sessionpackage com.dorm.servlet; import com.dorm.dao.AdminDao; import com.dorm.entity.Admin; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; WebServlet(/admin/login) public class LoginServlet extends HttpServlet { private AdminDao adminDao new AdminDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // POST 请求中文乱码必须在第一次 getParameter 之前设置 req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); Admin admin adminDao.findByUsernameAndPassword(username, password); if (admin ! null) { // 登录成功写入 Session并跳转到首页 HttpSession session req.getSession(); session.setAttribute(loginAdmin, admin); resp.sendRedirect(req.getContextPath() /student/list); } else { // 登录失败回登录页并带出错误提示 req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); } } }注意这里两个关键细节。第一req.setCharacterEncoding(UTF-8)必须放在getParameter()之前才有效因为 Tomcat 在第一次读取参数时就已经按默认编码ISO-8859-1解析了之后再设置就来不及了。第二登录成功后我用sendRedirect而不是forward因为重定向会发起一次新的 GET 请求浏览器地址栏变成/student/list用户刷新页面不会出现确认重新提交表单的对话框如果用 forward地址栏还是/admin/login刷新就重复提交。而登录失败用 forward 是为了把error属性传给 JSP 页面显示。这两个方法的区别是 JavaWeb 面试爱问的点这里正好是实际场景。接下来是拦截器。Servlet 规范里做登录拦截的标准做法是 Filterpackage com.dorm.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; // 白名单登录页、登录接口、静态资源直接放行 String uri req.getRequestURI(); String ctx req.getContextPath(); if (uri.equals(ctx /) || uri.equals(ctx /login.jsp) || uri.equals(ctx /admin/login) || uri.startsWith(ctx /static/)) { chain.doFilter(request, response); return; } // 检查 Session 里是否有登录用户 HttpSession session req.getSession(false); if (session ! null session.getAttribute(loginAdmin) ! null) { chain.doFilter(request, response); } else { // 未登录重定向到登录页 resp.sendRedirect(ctx /login.jsp); } } }Filter 的WebFilter(/*)表示拦截所有请求所以必须有白名单逻辑否则登录页本身都被拦住形成死循环。这里有个容易踩的坑req.getSession(false)带了false参数表示如果没有 Session 就返回 null 而不是新建——如果不带参数每个未登录请求都会创建新的 Session白白浪费内存而且会让 Session 数量虚高。白名单判断里我没有用uri.contains(login.jsp)这种模糊匹配而是精确比较因为你永远不知道将来会不会加一个叫loginAction.jsp的文件不小心被放行。4.2 学生列表与分页Servlet 查数据、JSP 渲染的职责边界学生列表是宿舍管理系统的核心页面也是最能体现 Servlet JSP 分工的一个功能。JavaWeb 教学里有个经典概念叫 Model 2 架构——Servlet 只负责接收请求、调用业务、准备数据JSP 只负责展示数据数据库操作封装在 DAO 层。三者职责严格分离谁也不要越界。先看 DAO 层的分页查询方法package com.dorm.dao; import com.dorm.entity.Student; import com.dorm.util.DbUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class StudentDao { /** * 分页查询学生列表同时关联查询宿舍信息 */ public ListStudent findPage(String keyword, int offset, int limit) { ListStudent list new ArrayList(); String sql SELECT s.id, s.student_no, s.name, s.gender, s.phone, d.room_no, b.name AS building_name FROM t_student s LEFT JOIN t_dorm d ON s.dorm_id d.id LEFT JOIN t_building b ON d.building_id b.id WHERE s.name LIKE ? OR s.student_no LIKE ? ORDER BY s.id DESC LIMIT ?, ?; try (Connection conn DbUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { String likePattern % keyword %; ps.setString(1, likePattern); ps.setString(2, likePattern); ps.setInt(3, offset); ps.setInt(4, limit); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Student stu new Student(); stu.setId(rs.getInt(id)); stu.setStudentNo(rs.getString(student_no)); stu.setName(rs.getString(name)); stu.setGender(rs.getInt(gender)); stu.setPhone(rs.getString(phone)); // 把 JOIN 出来的宿舍信息也封装进学生对象 stu.setBuildingName(rs.getString(building_name)); stu.setRoomNo(rs.getString(room_no)); list.add(stu); } } } catch (SQLException e) { // 真实项目里这里应该用日志框架记录, 而不是 e.printStackTrace() throw new RuntimeException(查询学生列表失败, e); } } public int count(String keyword) { String sql SELECT COUNT(*) FROM t_student WHERE name LIKE ? OR student_no LIKE ?; // 实现略写法同上 return 0; } }注意我用了try-with-resources语法Connection、PreparedStatement、ResultSet 三个资源自动关闭顺序正好是逆序关闭。习惯上很多人只关 Connection这是错误的——PreparedStatement 不关闭会占用服务端游标ResultSet 不关闭在连接池场景下会把连接占住不放。左连接的用处是未分配宿舍的学生dorm_id为 NULL也能正常显示在列表里不会因为 JOIN 失败被过滤掉。Servlet 层拿到页面参数后调用 DAOWebServlet(/student/list) public class StudentListServlet extends HttpServlet { private StudentDao studentDao new StudentDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 解析参数当前页码、每页条数、搜索关键字 int pageNum 1; String pageStr req.getParameter(page); if (pageStr ! null !pageStr.isEmpty()) { pageNum Integer.parseInt(pageStr); } int pageSize 10; String keyword req.getParameter(keyword); if (keyword null) { keyword ; } // 2. 调用 DAO 查询数据 ListStudent list studentDao.findPage(keyword, (pageNum - 1) * pageSize, pageSize); int total studentDao.count(keyword); // 3. 把数据放到 request 域转发给 JSP req.setAttribute(list, list); req.setAttribute(total, total); req.setAttribute(pageNum, pageNum); req.setAttribute(pageSize, pageSize); req.setAttribute(keyword, keyword); req.getRequestDispatcher(/WEB-INF/jsp/studentList.jsp) .forward(req, resp); } }分页计算有个容易搞混的点数据库的LIMIT offset, limit里的 offset 从 0 开始而页面上的页码从 1 开始所以offset (pageNum - 1) * pageSize。第 1 页是 0 到 10第 2 页是 10 到 20这样算才对。另外我把 JSP 页面放在了WEB-INF目录下——这个目录下的文件浏览器不能直接访问 URL 访问到必须通过 Servlet forward 转发才能渲染既保护了页面不被直接打开也强制了必须先走 Servlet的架构约定。JSP 里用 EL 表达式加 JSTL 标签渲染不写 Java 脚本片段% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html body h2学生列表/h2 form methodget action${pageContext.request.contextPath}/student/list input typetext namekeyword value${keyword} placeholder输入姓名或学号 button typesubmit搜索/button /form table border1 tr th学号/thth姓名/thth性别/th th宿舍/thth联系电话/th /tr c:forEach items${list} varstu tr td${stu.studentNo}/td td${stu.name}/td td${stu.gender 1 ? 男 : 女}/td td${stu.buildingName} ${stu.roomNo}/td td${stu.phone}/td /tr /c:forEach /table c:if test${pageNum 1} a href${pageContext.request.contextPath}/student/list?page${pageNum - 1}keyword${keyword}上一页/a /c:if span第 ${pageNum} 页 / 共 ${total} 条/span c:if test${pageNum * pageSize total} a href${pageContext.request.contextPath}/student/list?page${pageNum 1}keyword${keyword}下一页/a /c:if /body /htmlJSP 里最忌讳的就是在% %里写 Java 代码那样会让页面完全不可维护。用 EL 加 JSTL 是标准做法${stu.name}自动调用stu.getName()方法${stu.gender 1 ? 男 : 女}是 EL 的三目运算${pageContext.request.contextPath}动态获取项目根路径这样无论部署时 context path 叫什么都不会写死。如果你启动后页面 EL 表达式不解析打印的还是${stu.name}原文检查 web.xml 里是不是配置了el-ignoredtrue/el-ignored或者 JSP 头部有没有少isELIgnoredfalse。4.3 宿舍分配与更新事务里先查容量再更新床位宿舍分配是这个系统里最需要严谨处理的功能——分配动作要同时更新学生表和宿舍表学生表写入dorm_id宿舍表把occupied字段加 1。这两个 UPDATE 只要有一个成功另一个失败数据就会不一致要么学生有宿舍但宿舍人数没变要么宿舍人数多了但没人住进去。先看直接写会出现什么经典问题。如果你用UPDATE t_student SET dorm_id ? WHERE id ?然后UPDATE t_dorm SET occupied occupied 1 WHERE id ?两条语句之间如果出异常第一条已经提交的事务不会自动回滚。这就是为什么必须要用事务把两条语句包起来public boolean assignDorm(int studentId, int dormId) { Connection conn null; try { conn DbUtil.getConnection(); // 关键关闭自动提交开启事务 conn.setAutoCommit(false); // 第一步查询目标宿舍容量判断是否已满 String checkSql SELECT capacity, occupied FROM t_dorm WHERE id ? FOR UPDATE; try (PreparedStatement ps conn.prepareStatement(checkSql)) { ps.setInt(1, dormId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { int capacity rs.getInt(capacity); int occupied rs.getInt(occupied); if (occupied capacity) { throw new RuntimeException(宿舍已满); } } else { throw new RuntimeException(宿舍不存在); } } } // 第二步更新学生表的宿舍ID String updateStudentSql UPDATE t_student SET dorm_id ? WHERE id ?; try (PreparedStatement ps conn.prepareStatement(updateStudentSql)) { ps.setInt(1, dormId); ps.setInt(2, studentId); ps.executeUpdate(); } // 第三步宿舍已住人数加一 String updateDormSql UPDATE t_dorm SET occupied occupied 1 WHERE id ?; try (PreparedStatement ps conn.prepareStatement(updateDormSql)) { ps.setInt(1, dormId); ps.executeUpdate(); } // 全部成功才提交 conn.commit(); return true; } catch (Exception e) { // 异常则回滚保证两个更新同时生效或同时失效 try { if (conn ! null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } throw new RuntimeException(分配宿舍失败, e); } finally { // 恢复自动提交归还连接 try { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } catch (SQLException e) { e.printStackTrace(); } } }这段代码有两个容易被忽略的细节。第一FOR UPDATE这是 SELECT 语句加的悲观锁查询宿舍容量时把这一行锁住防止两个管理员同时给同一个宿舍分配学生都查到occupied 3 capacity 4然后同时执行 1最终占用 5 个人。加了FOR UPDATE后第二个事务的 SELECT 会等待第一个事务提交后才执行此时它查到的occupied已经是 4会正确地抛出宿舍已满。第二finally块里要把setAutoCommit(true)恢复再close()——因为连接池会复用连接如果不重置 autoCommit 状态下一次从池子里拿到这个连接时依然处于非自动提交状态你执行的普通 SQL 不 commit 就永远不生效。这是连接池场景下最容易出现的玄学 bug现象是有时候数据写进去了有时候没写进去。事务这东西在 Spring 里一个Transactional注解就解决了但在纯 Servlet 项目里你必须手动管理走这一遍能真正理解为什么 Spring 的声明式事务那么值钱——它把这几行模板代码给消灭了。5. 避坑排查乱码、驱动、404、时区——运行期最常见的五个坑5.1 响应中文乱码setContentType 的位置比内容更重要现象页面上的中文全部变成???或者一堆乱码但数据库里的中文是正常的。原因浏览器默认按 ISO-8859-1 解码响应内容而你在 Servlet 里写入的是 UTF-8 字节流两边编码不一致。还有一种情况是你的代码先调了getWriter()再设置setContentType——字符编码在第一次获取 Writer 时已经确定之后再设置就晚了。解决resp.setContentType(text/html;charsetUTF-8)必须放在getWriter()之前并且每次写响应的 Servlet 都要设置。更彻底的办法是用 Filter 统一处理WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); chain.doFilter(req, resp); } }这个 Filter 解决所有 Servlet 的请求和响应乱码问题比在每个 Servlet 里重复设置可靠一个数量级。注意 Filter 的执行顺序多个 Filter 按类名的字典序执行如果你的编码 Filter 类名排在别后面可能依然绕不开乱码。5.2 MySQL 连接报 Communications link failure时区与 SSL 参数现象程序启动后第一次getConnection()报Communications link failure或者The last packet sent successfully to the server was 0 milliseconds ago。原因八成是连接串参数问题。MySQL 8.0 默认使用caching_sha2_password认证插件连接时需要从服务端获取 RSA 公钥如果连接串没加allowPublicKeyRetrievaltrue就会报Public Key Retrieval is not allowed。另外时区参数serverTimezone不设置MySQL 驱动会报The server time zone value is unrecognized在中文操作系统的 MySQL 上时区显示为乱码。解决把第 3.2 节那个连接串原封不动抄过去urljdbc:mysql://localhost:3306/dorm_db?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue如果你用的 MySQL 是 5.7allowPublicKeyRetrieval这个参数可以去掉driverClassName换成com.mysql.jdbc.Driver。但我还是建议统一用 8.0 的驱动类名因为新版驱动兼容连接 5.7 服务端。5.3 JSP 里取不到 Session 值请求域和会话域没分清现象登录成功后在 Servlet 里session.setAttribute(loginAdmin, admin)跳转到 JSP 后${loginAdmin.username}输出为空。原因用了sendRedirect之后浏览器发起新请求这时如果是通过 request 域传的值req.setAttribute就全丢了——request 域的生命周期只有一次请求。但 Session 域的值应该能拿到。真正拿不到的原因可能是你在 JSP 里写的 EL 表达式拼错了比如 Session 里存的是对象不是 Map你应该写${sessionScope.loginAdmin.username}或者检查loginAdmin对象有没有getUsername()方法——EL 通过内省机制调用 getter没有 getter 就拿不到值。解决先在 JSP 里直接输出${sessionScope}看整个 Session 里有啥确认 key 是否正确。然后在 Servlet 里resp.getWriter().println(session.getAttribute(loginAdmin))打印一下对象是否非空。从前往后逐段排查而不是盯着 EL 表达式猜。5.4 部署后访问 404IDEA Artifact 与 WebServlet 路径的双重检查现象Tomcat 启动没报错但访问http://localhost:8080/dorm/student/list返回 404。原因查两个地方。第一IDEA 的 Deployment 里 Artifact 是dorm-system:war explodedApplication context 填的/dorm这两者不匹配就会 404。第二WebServlet(/student/list)注解里如果写成了student/list少了开头斜杠Tomcat 启动时会直接报错。解决在 IDEA 底部的 Server 输出窗口看启动日志里有没有Mapped Servlet相关的映射记录以及是否出现了WebServlet注解解析失败的异常。另外把 Deployment 页面的 Application context 改成/然后用http://localhost:8080/student/list访问可以排除路径前缀的问题。5.5 Tomcat 端口占用与 JVM 内存不足项目没崩但服务起不来现象启动 Tomcat 时 IDEA 报Port 8080 was already in use或者重启多次后报java.lang.OutOfMemoryError: PermGen space老版本 JDK或MetaspaceJDK 8。原因端口占用通常是后台残留了 Tomcat 进程或者别的程序用了 8080。内存溢出是反复热部署导致 class 加载器没有被回收老版本 JDK 的 PermGen 空间被占满。解决IDEA 里打开 Run Configuration在 VM options 加上-Xms256m -Xmx512m -XX:MaxMetaspaceSize256m热部署次数多了之后停掉 Tomcat 重启一次。端口占用先查进程号再杀进程# Windows 下查看 8080 端口被谁占用 netstat -ano | findstr 8080 # 结果最后一列是 PID然后 taskkill /F /PID PID # Linux / macOS 下的操作 lsof -i :8080 kill -9 PID这类问题和代码无关纯粹是开发环境的运行态问题但也最磨人——因为你想不到服务起不来和代码半毛钱关系没有。我的习惯是每写一个功能模组就完整重启一次 Tomcat 而不是依赖热部署虽然慢几秒但能给排查省下大量时间。6. 进阶验证Filter 权限控制 Druid 连接池 事务回滚的三合一改造到这一步你的系统已经能跑、能分配宿舍、能查列表了。但距离能写进简历的项目还差半步这半步是工程质量层面的改造。我建议你按下面三件事依次做完做完之后对照验收清单逐个验证。第一件事是给管理员表加上角色字段把LoginFilter从只判断是否登录升级成判断角色权限。比如普通管理员只能看学生列表和报修工单超级管理员才能执行宿舍分配和删除学生。实现方式是在Admin实体类加一个role字段Filter 里从 Session 取出loginAdmin后判断roleString role ((Admin) session.getAttribute(loginAdmin)).getRole(); String uri req.getRequestURI(); if (uri.contains(/student/delete) !super.equals(role)) { resp.sendRedirect(req.getContextPath() /error/forbidden.jsp); return; }这比在每一个 Servlet 里重复写权限判断干净得多——权限规则集中在一个类里维护将来加新接口只需要改 Filter。第二件事是用 Druid 的监控页验证连接池是否真的在工作。在 web.xml 里配置 Druid 的 StatViewServletservlet servlet-nameDruidStatView/servlet-name servlet-classcom.alibaba.druid.support.http.StatViewServlet/servlet-class /servlet servlet-mapping servlet-nameDruidStatView/servlet-name url-pattern/druid/*/url-pattern /servlet-mapping启动后访问http://localhost:8080/dorm/druid/你能看到活跃连接数、执行次数最多的 SQL、慢查询记录。用这个页面验证两件事连接数是否稳定在 initialSize 附近没有泄漏有没有慢 SQL 超过 1 秒有的话说明索引没建对。第三件事是一个事务回滚的实战验证。手动删掉 4.3 节分配宿舍代码中的conn.commit()那一行然后重新分配一个学生到宿舍。你会发现学生表更新了、宿舍 occupied 也更新了但数据库里两边数据都没变——因为连接池归还连接时连接没有被提交连接池默认帮你回滚了未提交的事务。这个实验的意义是在提交和不提交之间建立起直接感受事务的提交时机决定数据的最终可见性。作为收尾我建议给这个项目配一份带数据的 SQL 初始化脚本包括建库、建表、插入几条楼栋和宿舍的测试数据。每次换个环境跑 demo重建数据库只需要执行一个脚本省去手工录入数据的痛苦。我在带新人做毕业设计时看到最多的翻车场景就是中途调整了表结构但忘记同步初始化脚本结果换台电脑项目直接跑不起来。最后说一个我的个人习惯给自己定一条规矩——凡是UPDATE或DELETE语句执行前必须先写SELECT确认影响范围。宿舍分配、学生删除这些敏感操作尤其如此。这个习惯救过我很多次也推荐你从这个小项目开始养成。希望帮到你。本文还有配套的精品资源点击获取
返回列表