
简介一套基于JSP、Servlet与MySQL的实验室设备管理系统完整源码项目适合JavaWeb课程设计、毕业设计及相关岗位入门学习者。系统覆盖设备信息登记、关键字查询、借用申请、分类管理、管理员与普通用户权限控制等典型业务采用MVC分层架构由JSP负责视图展示Servlet承担控制与请求分发MySQL负责持久化存储。开发过程中兼顾SQL注入防护、登录会话校验、多表关联查询等安全与数据操作细节。压缩包共182个文件大小约6.21MB包含34个JSP页面、32个Java源文件及对应class字节码、17个CSS样式、12个JS脚本、15个Jar依赖库及2个SQL初始化脚本等源码目录按功能模块组织便于导入Eclipse或IDEA后配置数据库运行。已有455人学习使用。通过该项目读者不仅可获得一套可直接演示的完整工程还能学习到从建库建表、JDBC操作到Servlet业务控制、JSP页面交互的完整链路适合课程答辩、源码解读及二次功能扩展。资源内控制器、DAO与工具类划分明确注释清晰适合按模块逐层研读。1. 这套 jspservletmysql 的实验室设备管理系统到底要解决什么如果你在大学课程设计或毕业设计里拿到这道题大概率是想要一个能完整跑通的 JavaWeb 项目管理员能录入设备、按分类筛选老师或学生能登录系统、查看空闲设备并发起借用归还后系统留一条流水记录。把“基于 jspservletmysql 的实验室设备管理系统”拆开看它就是一个最典型的 Servlet Demo MySQL 增删改查在 Spring Boot 满天飞的今天JSPServlet 这套老组合反而是很多学校考查 Web 基础的首选。它解决的问题很明确用最小的依赖把浏览器请求、业务处理和数据库存储这三段串起来。适合刚学完 Java 基础、还没接触过框架或者想在下一次面试前把 Web 容器原理补扎实的开发者。我建议不要只把这个项目当成“交作业的.zip”而是把它当成一个可以复现的完整案例。读的过程中你会反复看到三个相互纠缠的问题请求参数怎么拿到、页面往哪里跳、数据怎么在 MySQL 里保持一致。这篇文章按“选型与架构 → 数据库设计 → 核心代码落地 → 踩坑排查 → 进阶验证”的顺序来展开最终你不仅能跑起来还能知道自己为什么这样写。2. 技术选型与架构为什么课设和毕业设计至今绕不开 JSPServlet2.1 为什么是“jspservletmysql”而不是 Spring Boot很多读者会有疑问既然 Spring Boot 一个注解就能把 Web 项目跑起来学校为什么还在布置 Servlet JSP 的题目。常见原因有两个。第一Servlet 是 Java Web 规范的基础Spring MVC 的 DispatcherServlet 本质上也还是一个 Servlet只是帮你封装了参数绑定、视图解析和生命周期没写过 Servlet 的人去调 Spring MVC 的注解出了问题往往不知道怎么定位。第二JSPServlet 不依赖外部框架部署到 Tomcat 就能跑对课程设计这种“一人一台笔记本、没有公司级基础设施”的场景最友好。这套组合还有一个现实价值很多 2010 年前后交付的信息系统至今还在用 JSP 渲染页面维护这类老系统时你看不懂 JSP 里的 Java 脚本片段和 web.xml 映射基本没法动手改。所以“过时”是相对的它能让你理解老系统也能让你更清楚框架替你做了什么。Spring Boot 里写一个GetMapping(/device/list)很轻松但底层 request → servlet → service → dao 的路径没有变。2.2 请求从浏览器到数据库再回显一条链路上的分工一个典型流程是这样走的浏览器在设备列表页点击“借出”按钮表单以 POST 请求提交到某个 URL 映射对应的 ServletServlet 从 HttpServletRequest 里取出 deviceId、userId调用 DAO 层方法操作 MySQL操作完成后再用重定向或转发把请求带回到列表页。JSP 页面负责把结果渲染成 HTMLServlet 负责接参数和处理结果MySQL 负责持久化。三者缺一不可各管一段。这里有一个新手最容易犯的方向性错误把数据库操作直接写在 JSP 里。JSP 的本质是视图模板在页面里写% new DeviceDao().xxx() %虽然能跑通但一旦页面样式调整或需求变更你会在 HTML 和 Java 代码的混合体里翻车。标准做法是三层结构Servlet 充当控制器和入口DAO 类专门封装 SQL 操作实体类对应数据表JSP 只做展示。后面第四章的代码都会按这个层次来放。2.3 一个最小可运行 JavaWeb 项目的目录长什么样直接用 IDEA 新建一个普通 Web 项目不需要 Maven 也能跑。项目最外层是 Web 根目录里面一般长这样src/ com/example/lab/ model/ # 实体类 User.java, Device.java dao/ # UserDao.java, DeviceDao.java servlet/ # LoginServlet.java, DeviceListServlet.java, BorrowServlet.java webapp/ # IDEA 里有时叫 web WEB-INF/ web.xml # Servlet 映射、Filter 配置 lib/ # mysql-connector-java.jar, jstl.jar, standard.jar login.jsp device/list.jsp css/, js/, images/WEB-INF/lib是重点Tomcat 启动时会加载这个目录下的所有 jarMySQL 驱动和 JSTL 标签库都得放在这里。如果你用 Maven 管理依赖则用pom.xml声明但很多学校机房没有内网 Maven 仓库直接放 jar 反而更省事。IDEA 里要检查 Project Structure → Artifacts确认 “WEB-INF/lib” 里能看到你加入的 mysql-connector-java否则本地能启动、打出的 war 包缺驱动。2.4 Tomcat、JDK、MySQL 与驱动包版本怎么搭版本不匹配是这类项目启动失败的第一大原因。我一般按这套组合配环境兼容性高组件推荐版本说明JDKJDK 8Tomcat 8.5/9 和大量课设代码在 JDK8 下最稳避免高版本模块化问题Tomcat8.5 或 9.0支持 Servlet 3.1/4.0web.xml 头部也兼容MySQL5.7 或 8.05.7 驱动类名是 com.mysql.jdbc.Driver8.0 要换新驱动名并加参数MySQL 驱动mysql-connector-java 5.1.49配 5.78.0 推荐 8.0.33 及以上版本JSTLjstl.jar standard.jar老项目常用 1.2 版本按学校课件搭配MySQL 5.7 对初学者更友好字符集配置简单驱动出错率低如果你装的是 MySQL 8.0.46 这类新版本一定记得在 JDBC URL 里加allowPublicKeyRetrievaltrue和serverTimezoneAsia/Shanghai否则会在连接阶段报错这个坑第五章专门讲。3. 数据模型先行设备管理系统的表结构与 JDBC 连接层3.1 设备借还的用例先列出来再决定建几张表动手写 CREATE TABLE 之前建议先把用例列到一张纸上。实验室设备管理系统通常有这几类用户管理员维护设备台账教师/学生查看设备并发起借用归还后管理员确认。围绕设备的状态变化最少需要四张表用户表、设备分类表、设备表、借用记录表。分类和用户单独建表而不是把名字直接写在设备字段里是为了避免同一个分类名在设备表里出现多个版本比如“示波器”和“示波器 ”这种肉眼不可见的差异。借用记录表承担的是流水账职责。设备被谁借走、什么时候借、什么时候还、是否超期都从这张表查。不要在设备表里存借用人姓名和借用时间那样只能记录“当前谁在用”查不出历史。设备表里只保留一个status字段作为最新状态配合借用记录表的status字段形成“当前状态”和“历史流水”的组合这是这套系统数据模型的核心。3.2 四张表的字段与索引约定字段设计要贴合课设工作量不必过度设计但几个关键约束不能省。用户表的账号要加唯一索引设备表的设备编号建议唯一借用记录表给 device_id 和 user_id 建普通索引因为查询会频繁按设备或按人过滤。状态字段用tinyint比字符串更省空间0/1/2 的语义写在注释里。关于外键我通常不用物理外键而是保留逻辑关联。原因是课设阶段数据经常要手动清理物理外键会在删设备或删用户时引发级联限制调试时很烦。只要你保证业务代码里先判断设备存在再插入借用记录逻辑外键足够。如果验收老师硬性要求外键再加也不迟但业务层的事务不能依赖数据库外键兜底。time字段统一用datetime创建时间给DEFAULT CURRENT_TIMESTAMP这样插入时少写一个参数。MySQL 5.7 和 8.0 对这个语法的支持一致不用担心版本差异。3.3 建库建表 SQL 脚本与初始化数据下面是一份可以直接执行的 MySQL 脚本兼容 MySQL 5.7 与 8.0CREATE DATABASE IF NOT EXISTS lab_device DEFAULT CHARACTER SET utf8mb4; USE lab_device; -- 用户表role 1 普通用户2 管理员 CREATE TABLE IF NOT EXISTS user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录账号, password VARCHAR(64) NOT NULL COMMENT 密码课设演示用 MD5, real_name VARCHAR(50) DEFAULT NULL COMMENT 姓名, role TINYINT NOT NULL DEFAULT 1 COMMENT 1用户 2管理员, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE IF NOT EXISTS device_category ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL, remark VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT设备分类表; CREATE TABLE IF NOT EXISTS device ( id INT NOT NULL AUTO_INCREMENT, category_id INT NOT NULL COMMENT 关联 device_category.id, device_no VARCHAR(50) NOT NULL COMMENT 设备编号, name VARCHAR(100) NOT NULL COMMENT 设备名称, model VARCHAR(100) DEFAULT NULL COMMENT 型号, status TINYINT NOT NULL DEFAULT 0 COMMENT 0空闲 1已借 2维修, location VARCHAR(100) DEFAULT NULL COMMENT 存放位置, buy_date DATE DEFAULT NULL, remark VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_device_no (device_no), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT设备表; CREATE TABLE IF NOT EXISTS borrow_record ( id INT NOT NULL AUTO_INCREMENT, device_id INT NOT NULL, user_id INT NOT NULL, borrow_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 借用时间, due_time DATETIME NOT NULL COMMENT 应还时间, return_time DATETIME DEFAULT NULL COMMENT 实际归还时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0借用中 1已归还, PRIMARY KEY (id), KEY idx_device (device_id), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT借用记录表;这段脚本的关键点有三个。第一所有表统一utf8mb4MySQL 8.0 默认字符集本来就是 utf8mb45.7 下建库时必须显式指定否则后续中文录入变问号的概率极高。第二user表的唯一键是为了防止重复账号设备表的唯一键防止同一编号录两次。第三借用记录里due_time是必填字段前端表单传过来的时候别让它为空。初始化数据时至少插入一个管理员账号、一个普通用户、三条设备记录页面才有东西可以展示密码建议直接预置 MD5 值比如MD5(123456)。3.4 用 DBUtil 和 DAO 把 JDBC 连接收敛到一个类里很多课设代码把Class.forName和DriverManager.getConnection写在每一个 DAO 方法里这种方式虽然能跑但连接管理完全失控。我更建议先写一个 DBUtil 工具类把驱动的加载、URL 参数、连接的创建都收敛到一处package com.example.lab.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { // 注意MySQL 8.0 驱动类名5.7 用 com.mysql.jdbc.Driver private static final String DRIVER com.mysql.cj.jdbc.Driver; private static final String URL jdbc:mysql://localhost:3306/lab_device ?useUnicodetruecharacterEncodingUTF-8 useSSLfalseserverTimezoneAsia/Shanghai allowPublicKeyRetrievaltrue; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL驱动加载失败请检查WEB-INF/lib下的驱动jar); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, java.sql.Statement st, java.sql.ResultSet rs) { if (rs ! null) try { rs.close(); } catch (SQLException ignored) {} if (st ! null) try { st.close(); } catch (SQLException ignored) {} if (conn ! null) try { conn.close(); } catch (SQLException ignored) {} } }参数的优先级从高到低是这样排的characterEncodingUTF-8保证驱动在拿到字符串时按 UTF-8 转码如果你的数据库连接这里漏了后面加多少页面过滤器都救不回来serverTimezoneAsia/Shanghai解决 MySQL 8.0 的时区报错allowPublicKeyRetrievaltrue解决 8.0 默认认证插件下首次连接拿不到公钥的问题。如果你固定使用 MySQL 5.7驱动类名要改回com.mysql.jdbc.Driver其他参数保留也不报错。DAO 层的写法统一为方法内获取连接创建 PreparedStatement执行后 finally 关连接。不要用 Statement 做字符串拼接 SQL原因不光是安全问题拼接时参数里若有单引号SQL 直接语法报错排查起来非常被动。后面第四章的代码都基于这个 DAO 模式展开。4. 借还链路落地登录、列表分页、借出与条件更新的核心代码4.1 web.xml 里先配好字符编码过滤器与 Servlet 映射在写任何 Servlet 之前先把 web.xml 里的全局配置搭好。这个文件是传统 JSPServlet 项目的大脑Servlet 的 URL 映射、Filter 的执行顺序都在这里。一个最小的 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 !-- 全局字符编码过滤器解决 POST 提交中文乱码 -- filter filter-nameencodingFilter/filter-name filter-classcom.example.lab.filter.EncodingFilter/filter-class /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping servlet servlet-nameloginServlet/servlet-name servlet-classcom.example.lab.servlet.LoginServlet/servlet-class /servlet servlet-mapping servlet-nameloginServlet/servlet-name url-pattern/login/url-pattern /servlet-mapping /web-app编码过滤器的实现要放在 chain.doFilter 之前设置编码否则请求参数在容器解析时已经按默认 ISO-8859-1 读完后面再 set 就晚了package com.example.lab.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); chain.doFilter(req, resp); } }这里把 Filter 配置成/*对所有路径生效。如果你在 Servlet 里手动设置过response.setContentType(text/html;charsetutf-8)Filter 和 Servlet 两层并不冲突。需要注意web.xml 中 servlet-mapping 的 url-pattern 一定带斜杠/login同时表单的 action 必须也以/login开头但前面要拼上contextPath这个对应关系是新手 404 的高发区5.3 会专门排查。4.2 登录逻辑验证账号密码后把用户放进 Session登录是整个系统的入口逻辑不复杂但有一点必须做对登录成功后不能转发forward到首页而要重定向redirect。重定向会让浏览器重新发起一次 GET 请求地址栏变成首页 URL用户刷新页面不会重复提交登录表单。写转发的话按 F5 会重复执行登录验证虽然结果一样但那是一次没有必要的数据库查询而且在表单页和服务端之间来回跳动体验很奇怪。package com.example.lab.servlet; import com.example.lab.dao.UserDao; import com.example.lab.model.User; import javax.servlet.ServletException; import javax.servlet.http.*; import java.io.IOException; public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); // 基本校验为空时回到登录页并提示 if (username null || username.trim().isEmpty() || password null || password.trim().isEmpty()) { req.setAttribute(msg, 账号和密码不能为空); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } UserDao dao new UserDao(); User user dao.findByUsernameAndPassword(username.trim(), password); if (user null) { req.setAttribute(msg, 账号或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } HttpSession session req.getSession(); session.setAttribute(loginUser, user); // 重定向到设备列表避免刷新重复提交 resp.sendRedirect(req.getContextPath() /device/list); } }UserDao 里的查询用 PreparedStatement 的?占位。需要提醒的是req.getParameter拿到的所有值都是 String做空判断时用trim().isEmpty()而不是直接 因为浏览器提交的空字符串和 Java 字符串字面量不是同一个引用。另外如果密码在数据库里存的是 MD5查询时要先把用户输入的密码用DigestUtils.md5Hex(password)转换再传入 SQL。4.3 设备列表要分页DAO 里用 LIMIT ?,? 而不是全量查询设备列表如果只有几十条数据全量查询确实看不出问题。但实验室设备上百条以后一次全查会让页面加载变慢也显得设计不完整。所以列表页要有分页参数page 表示当前页码pageSize 表示每页条数。DeviceDao 的分页方法可以这样实现public ListDevice findByPage(String keyword, int page, int pageSize) { String sql SELECT id, category_id, device_no, name, model, status, location, buy_date, remark FROM device WHERE name LIKE ? OR device_no LIKE ? ORDER BY id LIMIT ?, ?; ListDevice list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { String like % (keyword null ? : keyword.trim()) %; ps.setString(1, like); ps.setString(2, like); ps.setInt(3, (page - 1) * pageSize); // 偏移量 ps.setInt(4, pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Device d new Device(); d.setId(rs.getInt(id)); d.setDeviceNo(rs.getString(device_no)); d.setName(rs.getString(name)); d.setStatus(rs.getInt(status)); d.setLocation(rs.getString(location)); list.add(d); } } } catch (SQLException e) { throw new RuntimeException(设备分页查询失败, e); } return list; }这里最容易写错的是 LIMIT 的两个参数顺序第一个是偏移量第二个是每页条数。第一页时(page - 1) * pageSize正好是 0第二页是 pageSize。PreparedStatement 的下标从 1 开始所以第 3 个?对应偏移量第 4 个对应 pageSize。分页总数需要单独再写一个count方法SELECT COUNT(*) FROM device WHERE name LIKE ?然后通过(total pageSize - 1) / pageSize计算总页数这个值放进页面用来渲染上一页下一页按钮。4.4 借出设备先 UPDATE 后 INSERT防止同一台设备被重复借走设备借出是整个系统里最值得认真写的动作。常见错误写法是先把设备查出来看 status再执行 UPDATE 改成已借出最后 INSERT 借用记录。这个流程在单用户下没问题但两个浏览器同时点借用时两个请求都可能查到 status0然后都执行 UPDATE造成同一台设备被借给两个人。解决办法是让 SQL 语句本身具备“条件更新”的原子性直接写UPDATE device SET status1 WHERE id? AND status0受影响行数为 1 说明这次更新抢到了设备行数为 0 说明设备已经被别人借走。package com.example.lab.servlet; import com.example.lab.dao.BorrowDao; import com.example.lab.model.User; import javax.servlet.ServletException; import javax.servlet.http.*; import java.io.IOException; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; public class BorrowServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 未登录用户不能借用 User loginUser (User) req.getSession().getAttribute(loginUser); if (loginUser null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } int deviceId; try { deviceId Integer.parseInt(req.getParameter(deviceId)); } catch (NumberFormatException e) { resp.sendError(400, deviceId 参数不合法); return; } Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 条件更新这一行就是防止并发重复借出的关键 String updateSql UPDATE device SET status1 WHERE id? AND status0; try (PreparedStatement ps conn.prepareStatement(updateSql)) { ps.setInt(1, deviceId); int rows ps.executeUpdate(); if (rows 0) { conn.rollback(); req.setAttribute(msg, 设备已被借走或不存在); req.getRequestDispatcher(/device/list).forward(req, resp); return; } } String insertSql INSERT INTO borrow_record(device_id, user_id, due_time) VALUES(?,?,?); try (PreparedStatement ps conn.prepareStatement(insertSql)) { ps.setInt(1, deviceId); ps.setInt(2, loginUser.getId()); // dueTime 由前端传入这里只做演示 ps.setString(3, req.getParameter(dueTime)); ps.executeUpdate(); } conn.commit(); resp.sendRedirect(req.getContextPath() /device/list); } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } throw new ServletException(借出失败数据库异常, e); } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) {} } } } }这里的核心是UPDATE ... WHERE id? AND status0这一句。MySQL 的 UPDATE 会在行级加锁同一时刻只有第一个请求能把 status 从 0 改成 1第二个请求的 WHERE 条件不再满足affected rows 为 0。先 UPDATE 后 INSERT 的顺序也重要先占住设备再写流水如果 INSERT 失败整体回滚设备状态恢复成空闲。事务的setAutoCommit(false)必须放在第一个 SQL 之前finally 里把 autoCommit 恢复成 true 是为了连接归还连接池后不污染下一个请求。完成借出后同样用sendRedirect回到列表而不是 forward 到列表 JSP。这里还有个细节如果你在设备列表页通过 JS 弹窗确认借用别忘了deviceId是整数前端传入时做parseInt校验否则攻击者传入一段字符串会让NumberFormatException直接打到页面上。4.5 列表页用 ELJSTL 渲染避免 JSP 里写 Java 脚本JSP 页面里最克制不住的就是直接用% for (...) { %循环输出表格行。这种写法短期能出效果但遇到两处以上嵌套html 的标签闭合会变得非常难维护。用 JSTL 的 c:forEach 可以让你只关心一条行模板数据从 request 或 session 属性里取% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html body h3设备列表/h3 table border1 tr th设备编号/thth名称/thth状态/thth操作/th /tr c:forEach items${page.list} vard tr td${d.deviceNo}/td td${d.name}/td td c:choose c:when test${d.status 0}空闲/c:when c:when test${d.status 1}已借出/c:when c:otherwise维修中/c:otherwise /c:choose /td td c:if test${d.status 0} form action${pageContext.request.contextPath}/device/borrow methodpost input typehidden namedeviceId value${d.id} input typehidden namedueTime value2025-06-30 20:00:00 button typesubmit借用/button /form /c:if /td /tr /c:forEach /table /body /html注意${d.deviceNo}的取值规则EL 表达式会调用 Device 类的getDeviceNo()方法所以实体类必须有标准的 getter/setter。${pageContext.request.contextPath}是 JSP 内置对象动态输出项目的部署根路径比如/lab_device。如果你的表单 action 直接写成actiondevice/borrow代码运行在/lab_device/device/list.jsp这个 URL 下时浏览器会拼接成/lab_device/device/device/borrow这也是 404 的经典翻车现场。c:forEach 里的 items 绑定的page.list来自 DeviceListServlet 往 request 里放的属性因此列表查询和分页信息最好用一个 PageBean 对象承载里面放 list、page、totalPage、keyword 四个字段JSP 里才能优雅地渲染翻页链接。5. 避坑排查从 MySQL 连接失败到设备被重复借出的 5 个实际问题5.1 MySQL 8.0 连不上的“Public Key Retrieval is not allowed”现象代码跑到DBUtil.getConnection()时抛异常控制台出现Public Key Retrieval is not allowed有的版本还伴随Unable to load authentication plugin caching_sha2_password。原因MySQL 8.0 默认的认证插件是 caching_sha2_password客户端第一次连接时需要通过 RSA 公钥交换密码如果 JDBC URL 没开启allowPublicKeyRetrievaltrue驱动直接拒绝获取公钥。而很多老课设代码还在用 5.7 时代的com.mysql.jdbc.Driver类名新版驱动已经把这个类移除了。解决JDBC URL 末尾追加allowPublicKeyRetrievaltrue驱动类名改成com.mysql.cj.jdbc.Driver。另外把数据库用户的认证插件改回 mysql_native_password 也能规避ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 密码;但这会让依赖 caching_sha2_password 的新特性无法使用我更推荐前者。顺便说一句MySQL 8.0 还需要在 URL 里加serverTimezoneAsia/Shanghai否则会报时区无法识别的错误。5.2 中文乱码JDBC URL、页面编码、表单提交编码要一致现象登录后页面显示的管理员姓名变成一串问号或者从表单提交的中文到数据库里存进去就是??。原因乱码通常不是单一问题。至少三个环节都可能出问题页面本身的 charset 不是 UTF-8表单 POST 请求体按容器默认的 ISO-8859-1 解析JDBC URL 没带characterEncodingUTF-8。数据库表是 utf8mb4、页面是 UTF-8、连接缺参数三个里只要有一个掉链子中文就会坏。解决全局统一三处。第一JSP 页面顶部的pageEncoding和contentType都写 UTF-8。第二用 4.1 的 EncodingFilter 在所有请求上setCharacterEncoding(UTF-8)。第三JDBC URL 里带useUnicodetruecharacterEncodingUTF-8。这三条都做了基本不会再遇到中文问题。检查时有个技巧先用 MySQL 命令行直接 INSERT 一条中文数据如果命令行能看到说明库和表没问题问题就在应用连接层。5.3 404 与“Servlet 找不到”先从 Network 请求 URL 开始排查现象启动 Tomcat 没报错配置的/login也存在但浏览器访问http://localhost:8080/login返回 404或访问 /device/list 报 The requested resource is not available。原因这类问题大多不是 Servlet 代码写错而是 URL 映射和浏览器实际请求的路径不一致。web.xml 里 url-pattern 必须是/login或/login/*少了斜杠或者写成loginTomcat 不会给这个 Servlet 注册任何可访问路径。还有一种情况是项目部署上下文路径不是根路径比如 IDEA 里 application context 配成/lab_device那你访问时必须带/lab_device/login。解决先按 F12 打开 Network 看请求 URL 和状态码确认浏览器实际请求的是不是contextPath urlPattern的组合。排查顺序是context pathIDEA 部署配置→ servlet-mapping 的 url-pattern → 表单 action 拼接结果。最后用${pageContext.request.contextPath}拼接所有内部跳转链接绝对路径可以根治这一类问题。另外如果同时用了 WebServlet 注解又在 web.xml 里配置了同一个 ServletTomcat 会直接报 Servlet 映射冲突这种翻车现象通常表现为启动时报Duplicate servlet mapping。5.4 设备状态被覆盖缺少查询条件导致重复借出现象两个用户同时点开同一台空闲设备的详情页几乎同时点借用最后设备只对应一条借用记录但其他页面看到设备状态还是空闲或者出现两条记录都显示借用中。原因代码先 SELECT 查询设备 status再执行 UPDATE 修改 status两步之间没有加任何条件。两个请求同时读到 status0后一个请求更新时没有判断当前状态直接把设备覆盖成已借出前一个请求插入的记录也成功结果设备被同一时间借给两个人。解决把所有“先查后改”改成“条件修改”。借出动作的 UPDATE 必须带AND status0靠受影响行数判断是否抢占成功。归还动作同理UPDATE device SET status0 WHERE id? AND status1。这就是 4.4 那段代码的核心价值也是课程设计答辩时老师最爱追问的点你的系统支持多少人同时借同一台设备能答出“条件更新 事务回滚”基本就是满分回答。5.5 Tomcat 启动报缺驱动类 / 编译版本不对现象代码在 IDEA 内置 Tomcat 里运行一切正常打成 war 包放到独立 Tomcat 后访问设备列表页报java.lang.ClassNotFoundException: com.mysql.jdbc.Driver或者启动直接报UnsupportedClassVersionError。原因独立 Tomcat 不会自动帮你带上 IDE 里的依赖。mysql-connector-java.jar 必须放在WEB-INF/lib下并随 war 一起发布如果只放在 IDEA 的 External Libraries 里打包时不会进入 lib 目录。UnsupportedClassVersionError 则是本地用 JDK 17 编译的 class 文件放到 JDK 8 的 Tomcat 环境里运行字节码版本不匹配。解决检查 Project Structure → Artifacts展开 Output Layout 里有没有WEB-INF/lib目录手动把 mysql 驱动、jstl、standard 三个 jar 加进去。编译级别统一IDEA 的 Project SDK 和 Project language level 都设成 8Tomcat 运行环境的 JAVA_HOME 也指到 JDK 8。判断是不是这个问题看报错日志里的major.minor version数字即可52.0 对应 Java 861.0 对应 Java 17。6. 从课设到能用的设备管理连接池、防注入与一段低成本的验证路径6.1 把 DriverManager 换成 Druid 连接池配置与代码改动很小当你有两个以上用户同时操作时每次请求都DriverManager.getConnection新建数据库连接在高并发下会频繁握手认证MySQL 连接数一满页面直接白屏。选课设阶段不一定要上连接池但如果你想让这个系统在答辩演示时抗住全班几十个浏览器同时访问最划算的改动是引入 Druid。它比你自己手写连接管理省心得多。引入连接池后DBUtil 的初始化代码可以改成这样import com.alibaba.druid.pool.DruidDataSource; public class DBUtil { private static DruidDataSource dataSource; private DBUtil() {} public static synchronized DruidDataSource getDataSource() { if (dataSource null) { dataSource new DruidDataSource(); dataSource.setDriverClassName(com.mysql.cj.jdbc.Driver); dataSource.setUrl(jdbc:mysql://localhost:3306/lab_device?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue); dataSource.setUsername(root); dataSource.setPassword(123456); dataSource.setInitialSize(5); dataSource.setMaxActive(20); dataSource.setMaxWait(3000); } return dataSource; } public static Connection getConnection() throws SQLException { return getDataSource().getConnection(); } }改动只集中在 DBUtil 这个类其他 DAO 代码完全不用变。initialSize5是启动时预创建的连接数maxActive20是最大连接数课设级机器这个配置足够。这里注意getConnection()实际上是从连接池借一条连接用完的conn.close()并不会真正关闭底层连接而是归还给连接池所以 finally 里的代码照旧保留即可。如果你不想引入第三方库退一步至少要把数据库密码和连接地址放到 properties 配置文件里而不是硬编码进类。代码审查时老师看到硬编码 root 密码大概率会追问一次提前准备一个读配置文件的小工具类能少一个扣分点。6.2 上线前不花时间的验证路径与我的习惯这个系统要做到“能演示且不丢人”有几条路径必须按顺序点一遍。第一用错误密码登录页面要返回友好提示而不是 500。第二先用管理员加一台设备然后以普通用户登录列表里能查到这台设备并成功借出。第三借出成功后再次回到列表设备状态要变成已借出不能再显示借用按钮。第四步是关键验证开两个浏览器窗口用两个账号同时点借同一台空闲设备预期是其中一个请求失败并提示“已被借走”。这个操作能同时验证 4.4 的条件更新和事务是否生效。如果你发现两个请求都成功说明 UPDATE 语句里漏了AND status0这一条不改答辩演示时下课就是事故现场。第五把一个明显包含单引号的字符串拼到 deviceNo 或 keyword 参数里看系统是报 SQL 语法错误还是安全拦截所有 SQL 都走 PreparedStatement 时单引号会被转义掉这是防 SQL 注入的底线。我第一次做这类管理系统时把借出逻辑直接写在了 JSP 页面里列个表单提交后刷新一次页面就重复借出一台设备。后来才明白资源的占用必须落在数据库的行级状态上而不是页面状态上。从那以后我写任何带状态流转的系统都先把“状态变更的那条 UPDATE 用条件字段锁住”理解成一种职业习惯。这套 jspservletmysql 的设备管理系统虽然技术栈很旧但它把 Web 项目的骨架和数据库并发的基本功都压实了希望帮到你。本文还有配套的精品资源点击获取