
简介一款基于JSPServlet数据库的简易库存商品管理系统适合正在学习Java Web开发的初学者用来理解Servlet请求处理、JSP页面展示以及MySQL数据库读写的基本流程。系统完整覆盖典型库存管理需求管理员可登录后台查看所有商品的名称、规格、库存件数、单价支持新增、修改、删除记录删除前会弹出确认点击新加商品可跳转至独立页面当库存件数少于10件时数字显示为红色提交前检查所有字段为必填项件数和单价必须为数字保存时若发现名称、规格、单价均相同的已有商品则自动累加库存否则新增一条记录。压缩包共38个文件内含Java源文件、JSP页面、SQL脚本、class编译文件及CSS样式另有项目配置和说明文档整体只有148KB部署和学习成本很低。随包提供user.sql与product.sql建表脚本和项目.doc可快速初始化数据库并查看整体设计读者参考源码即可掌握表单校验、数据库增删改查、请求跳转等关键写法。目前已有1488人学习/浏览适合作为课程设计、毕业设计或Servlet入门练手项目。1. 为什么 2025 年还有人用 JSPServlet 写库存系统先说个反直觉的结论如果你只想管好一两万条商品的进销存不想引入 Spring Boot、MyBatis 那套依赖链和启动开销JSPServlet数据库这套组合依然能打。很多高校的毕业设计和中小型企业的内部工具至今还在用这套技术栈维护和生产。标题里的“库存商品管理系统”本质上就是一个典型的 CRUD 系统——商品增删改查、入库出库记录、库存数量汇总外加一点简单的权限控制。这套方案的价值在于Servlet 负责接收 HTTP 请求和处理业务逻辑JSP 负责把数据渲染成页面数据库负责持久化。三者各管一段边界极其清晰。对于新手来说它比 Spring Boot 更容易理解“一个 HTTP 请求到底是怎么走完整个链路的”对于熟手来说它足够轻量一个 Tomcat 加一个 MySQL 就能跑起来连 Maven 都可以不用。我见过不少人拿到这类项目源码后卡在第一步——不是看不懂代码而是不知道从哪里启动。这篇文章就把这套库存管理系统的结构、核心代码、部署参数和常见坑一次性讲透保证你照着做完能真正用起来而不是停在“代码能编译通过”的假成功。2. 从 JSPServlet 到数据库这套架构到底怎么运作2.1 为什么要用 JSP 做视图而不用纯 Servlet 输出 HTMLJSP 的本质是被 Tomcat 编译成 Servlet 再执行的所以“JSP 和 Servlet 谁更好”本身就是个伪命题。但它们在分工上有个约定俗成的规矩Servlet 不直接拼 HTML 字符串JSP 里也不写业务逻辑。这样做的直接好处是当页面样式需要调整时你只需要改 .jsp 文件不需要碰 Java 代码更不需要重新编译整个项目——Tomcat 检测到 JSP 文件变化后会自动重编译。在这类库存系统里JSP 通常负责几个固定页面商品列表页面、入库表单页面、出库表单页面、库存查询页面和登录页面。商品列表页面每隔几秒钟就要刷新一次库存数字这时候 JSP 的脚本表达式就派上了用场。比如下面这段代码就是典型的商品列表核心片段% ListMapString, Object goodsList (ListMapString, Object) request.getAttribute(goodsList); if (goodsList ! null) { for (MapString, Object goods : goodsList) { % tr td% goods.get(goods_name) %/td td% goods.get(stock_quantity) %/td td a hrefGoodsServlet?actioneditid% goods.get(id) %编辑/a a hrefGoodsServlet?actiondeleteid% goods.get(id) % onclickreturn confirm(确认删除该商品吗);删除/a /td /tr % } } %这段代码的逻辑很简单Servlet 把查询结果塞进 request 作用域JSP 用% %表达式把数据输出到表格行里。注意这里我用的是MapString, Object而不是实体类——对于这种简易系统这种写法能省掉一个 JavaBean 类的定义减少文件数量。但如果你要维护的商品字段很多比如还要管批次、供应商、条码那我建议你还是老老实实建一个Goods实体类后面改起来会轻松很多。参数说明actionedit和actiondelete是这个系统最关键的请求参数Servlet 通过判断这个参数来决定调用哪个业务方法。如果你要加一个“商品导入”功能就再定义一个actionimport然后在 Servlet 里多写一个 case 分支就够了。2.2 Servlet 的请求分发与业务控制一个 Servlet 管全部操作很多简易库存系统只用一个 Servlet 类就能完成所有请求分发这种做法在小型项目里非常实用。常见的设计是GoodsServlet放在com.example.servlet包里它继承HttpServlet重写doGet和doPost两个方法。由于表单提交既有 GET 也有 POST我会在doPost里调用doGet这样两个入口统一走同一套分发逻辑。protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String action request.getParameter(action); if (action null || action.equals(list)) { listGoods(request, response); } else if (action.equals(add)) { addGoods(request, response); } else if (action.equals(edit)) { showEditPage(request, response); } else if (action.equals(update)) { updateGoods(request, response); } else if (action.equals(delete)) { deleteGoods(request, response); } else if (action.equals(search)) { searchGoods(request, response); } }这个分发的核心思想就是一个入口多个业务方法。每个业务方法只做一件事比如addGoods从请求中取出商品名称、规格、进货价、库存量交给 DAO 层插入数据库然后重定向回列表页。重定向一定要用response.sendRedirect不要用request.getRequestDispatcher().forward——如果你用了 forward用户刷新页面时浏览器会再次提交同一个表单造成重复插入。逻辑说明这里有一个容易被忽略的小地方——setCharacterEncoding(UTF-8)必须写在读取任何请求参数之前。如果写在request.getParameter之后Tomcat 8.5 以下版本读到的中文就会乱码。Tomcat 8.0 默认的 POST 编码还是 ISO-8859-1不写这一行商品名称里的中文字符基本必乱。2.3 DAO 层和数据库连接JDBC 怎么写才能不坑队友DAOData Access Object层在这套系统里就是一组数据库操作方法。简易系统通常不会引入连接池直接用DriverManager.getConnection获取连接。但我要提醒你一句如果你打算把这个系统部署给多个部门同时用一定要换成Druid或HikariCP连接池否则并发一上来数据库连接数会被瞬间打满。下面这段是典型的 JDBC 操作方式用来根据关键字模糊查询商品public ListMapString, Object searchGoods(String keyword) { Connection conn null; PreparedStatement ps null; ResultSet rs null; ListMapString, Object list new ArrayList(); String sql SELECT id, goods_name, spec, stock_quantity, warning_value FROM goods WHERE goods_name LIKE ? OR spec LIKE ?; try { conn DBUtil.getConnection(); ps conn.prepareStatement(sql); ps.setString(1, % keyword %); ps.setString(2, % keyword %); rs ps.executeQuery(); while (rs.next()) { MapString, Object row new HashMap(); row.put(id, rs.getInt(id)); row.put(goods_name, rs.getString(goods_name)); row.put(spec, rs.getString(spec)); row.put(stock_quantity, rs.getInt(stock_quantity)); row.put(warning_value, rs.getInt(warning_value)); list.add(row); } } catch (SQLException e) { e.printStackTrace(); // 实际项目中改用 log4j 或 slf4j 记录 } finally { DBUtil.close(conn, ps, rs); } return list; }参数说明LIKE ?配合setString(1, % keyword %)是防止 SQL 注入的底线写法。绝对不要用字符串拼接 SQL 的方式——我见过有人图省事写成SELECT * FROM goods WHERE name keyword 这个漏洞随便一个参数就能让整个表被删掉。warning_value是库存预警值如果查询出来后库存数量小于这个值列表页面就会用红色字体标出来。逻辑说明finally块里的资源关闭顺序必须从里到外先关 ResultSet再关 PreparedStatement最后关 Connection。如果你把 Connection 关了但 ResultSet 还开着在 MySQL 8.0 及以上版本会直接报错。另外DBUtil.getConnection()建议每次都新开连接不要用静态变量存一个全局 Connection——连接不是线程安全的并发请求下会互相踩。2.4 数据库建表和初始化数据从 root 用户到业务数据整套系统跑起来之前数据库必须先就位。常见的库名是inventory表结构也就是商品表加用户表出入库记录往往合并进商品表的一个流水表。下面是一个最精简的建表脚本覆盖了库存系统的全部核心字段CREATE DATABASE IF NOT EXISTS inventory DEFAULT CHARSET utf8mb4; USE inventory; CREATE TABLE goods ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 商品ID, goods_name VARCHAR(100) NOT NULL COMMENT 商品名称, spec VARCHAR(100) DEFAULT COMMENT 规格型号, unit VARCHAR(20) DEFAULT 个 COMMENT 计量单位, stock_quantity INT DEFAULT 0 COMMENT 当前库存数量, warning_value INT DEFAULT 10 COMMENT 库存预警值, purchase_price DECIMAL(10,2) DEFAULT 0 COMMENT 进货价, sale_price DECIMAL(10,2) DEFAULT 0 COMMENT 销售价, supplier VARCHAR(100) DEFAULT COMMENT 供应商, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, remark VARCHAR(255) DEFAULT COMMENT 备注, INDEX idx_goods_name (goods_name) ) ENGINEInnoDB COMMENT商品信息表; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(64) NOT NULL COMMENT 建议存MD5/SHA256摘要, real_name VARCHAR(50) DEFAULT , role VARCHAR(20) DEFAULT admin ) ENGINEInnoDB COMMENT系统用户表;参数说明DEFAULT CHARSET utf8mb4是 MySQL 5.7/8.0 的标配比utf8多支持 Emoji 和一些特殊字符微信公众号昵称这种带表情的文本也能存进去。ON UPDATE CURRENT_TIMESTAMP会让修改任意字段时自动刷新update_time省去在 Java 代码里手动更新的麻烦。DECIMAL(10,2)千万别用FLOAT——浮点数算钱会有精度丢失比如进货价 9.99 存 float 可能变成 9.989999771118164。建完表还要给一个初始管理员账号。密码建议先存 SHA-256 摘要别存明文。哪怕这个系统只是内网用存明文密码也是一种职业素养问题。插入语句大概是INSERT INTO user (username, password) VALUES (admin, SHA2(123456, 256));。3. 把项目跑起来的完整流程三个环节缺一不可3.1 环境准备JDK、Tomcat、MySQL 的版本配比这个项目跑起来需要三个基础环境JDK 8 或更高版本、Tomcat 8.5 或 9.x、MySQL 5.7 或 8.0。版本这里有个搭配建议JDK 8 配 Tomcat 8.5/9.0 非常稳定JDK 11 或 17 配 Tomcat 9.x 也没问题唯一要避免的是 JDK 17 配 Tomcat 8.5 的旧版本——Tomcat 8.5 老版本里依赖的某些反射 API 在新 JDK 里被禁用了启动时会报模块访问错误。安装完环境后先做一个最基础的验证启动 Tomcat 后浏览器访问http://localhost:8080如果能出现默认首页说明 Tomcat 本身没问题再用命令行mysql -u root -p能进 MySQL说明数据库服务没问题。这两个前提不满足项目永远跑不起来排查顺序一定是先环境后代码。如果你是 Windows 用户Tomcat 的startup.bat双击启动后窗口可能会一闪而过。这时候要打开命令行到 Tomcat 的bin目录下运行catalina.bat run才能看到完整报错日志。这个日志是启动阶段最重要的排错依据——org.apache.catalina.startup.Catalina.start后面跟着的异常会直接告诉你端口被占用还是类加载失败。3.2 导入项目和数据库拿到压缩包后的前三步假设你已经拿到了标题里那个压缩包解压后通常会看到这样的目录结构src文件夹放 Java 源码web或WebContent文件夹放 JSP 页面、WEB-INF 和配置文件根目录还有项目说明文档。如果你的压缩包是用 Eclipse 导出的可能还会带.classpath和.project文件这时候直接用 Eclipse 的 File → Import → Existing Projects into Workspace 导入最省事。导入项目后第一步是找到DBUtil.java或db.properties文件把数据库连接信息改成你的本地配置。绝大多数这类项目的默认配置是jdbc:mysql://localhost:3306/inventory?useSSLfalseserverTimezoneAsia/Shanghai用户名 root密码空。下面是一个典型的配置文件drivercom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/inventory?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 usernameroot password你的密码逻辑说明这段配置里最容易出问题的就是serverTimezoneAsia/Shanghai——MySQL 8.0 驱动强制要求指定时区不写的话会报The server time zone value йʱ is unrecognized或者干脆是中文乱码的时区错误。useSSLfalse是为了关掉 SSL 握手的额外开销本地开发完全不需要。第二步是在 MySQL 里执行第 2.4 节那段建表脚本可以把 SQL 语句保存成init.sql然后在命令行里执行mysql -u root -p init.sql。执行成功后用SHOW TABLES;验证一下应该能看到goods和user两张表。第三步是把 Tomcat 的 JDBC 驱动配置到位。把mysql-connector-java-8.0.x.jar复制到 Tomcat 的lib目录下。如果这个 jar 没放对位置Tomcat 启动项目时会报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。注意Tomcat 的lib目录只加载全局依赖放在项目的 WEB-INF/lib 下会隔离在当前 Web 应用里两种方式都能用但全局放置更省事。3.3 配置 Tomcat 的部署方式从三层目录结构到一键启动Tomcat 部署 Web 应用有两种常规方式一是直接把项目文件夹拷到 Tomcat 的webapps目录下二是在conf/Catalina/localhost下写一个 XML 描述文件。Eclipse 里调试一般用后一种方式而且 IDE 会自动生成配置不需要手动操作。我教你一个不用 IDE 也能快速部署的土办法在项目根目录执行jar -cvf inventory.war .把整个项目打成 WAR 包然后把这个 WAR 放到 Tomcat 的webapps目录。Tomcat 启动时会自动解压并部署。这种方式在服务器上最干净不依赖 IDE 的环境变量配置。如果你的项目和数据库都在同一台机器上部署成功后访问地址就是http://localhost:8080/inventory/。进入这个地址后系统会跳转到login.jsp用初始化数据里插入的 admin 账号登录就能看到商品列表页面了。3.4 运行时验证三张页面加一个数据库侧写项目跑起来后不要急着说“成功了”用下面这套验证路径确保核心链路是通的登录输入错误密码时会不会报错且停留在登录页输入正确密码能不能跳转到商品列表。新增商品在表单里填一条完整商品信息提交后列表页能不能立刻出现这条数据。修改库存把某件商品的库存数量改大或改小看列表页数字有没有变化。删除商品删掉刚才新增的那条测试数据列表页是否消失。数据库验证执行SELECT * FROM goods;看数据是不是和页面显示一致。这五步全过了才可以说这个系统的核心链路真的跑通了。很多项目跑不起来其实是卡在部署环节代码本身没有 bug。4. 库存系统的核心功能拆解增删改查里的实现细节4.1 新增商品从表单提交到数据库插入的完整链路新增商品通常是库存管理系统的第一大功能。表单在add_goods.jsp里JSP 页面里有一个form actionGoodsServlet?actionadd methodpost字段包括商品名称、规格、单位、进货价、销售价、库存量和预警值。提交后请求打到GoodsServlet的doPost然后根据actionadd分发到addGoods方法。private void addGoods(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String goodsName request.getParameter(goods_name); String spec request.getParameter(spec); String unit request.getParameter(unit); String stockQuantity request.getParameter(stock_quantity); String warningValue request.getParameter(warning_value); String purchasePrice request.getParameter(purchase_price); String salePrice request.getParameter(sale_price); String supplier request.getParameter(supplier); String remark request.getParameter(remark); // 必要参数空值校验 if (goodsName null || goodsName.trim().isEmpty()) { request.setAttribute(error, 商品名称不能为空); request.getRequestDispatcher(add_goods.jsp).forward(request, response); return; } // 参数校验通过后调用 DAO GoodsDAO dao new GoodsDAO(); boolean success dao.insertGoods(goodsName, spec, unit, Integer.parseInt(stockQuantity), Integer.parseInt(warningValue), new BigDecimal(purchasePrice), new BigDecimal(salePrice), supplier, remark); if (success) { response.sendRedirect(GoodsServlet?actionlist); } else { request.setAttribute(error, 新增失败请重试); request.getRequestDispatcher(add_goods.jsp).forward(request, response); } }逻辑说明注意Integer.parseInt(stockQuantity)这个位置——如果用户在表单里填了一个非数字字符比如“十二”这里会抛NumberFormatException。简易系统的做法通常是不管它让它抛异常然后被 Tomcat 捕获显示 500 页面。但稍微负责一点的系统应该在 JSP 端或 Servlet 端做一下正则校验stockQuantity.matches(\\d)不通过就返回错误提示。参数说明request.getParameter拿到的全是字符串你需要手动转成int和BigDecimal。BigDecimal的构造器推荐用字符串构造——new BigDecimal(9.9)不会丢精度但new BigDecimal(9.9)会得到一个近似双精度值精确值其实是 9.899999999999999466。4.2 修改商品回显与提交的两个坑位编辑功能的流程是先从列表页点“编辑”携带actioneditid3跳转到GoodsServlet然后在showEditPage方法里根据 id 查出这条商品的全部信息set 到 request 作用域forward 到edit_goods.jsp页面回显。这里 JSP 页面里要用input typetext namegoods_name value% goods.get(goods_name) %这种方式把数据库值填回输入框里。坑一value属性里如果商品名称包含双引号回显时会破坏 HTML 结构导致页面样式错乱甚至属性注入。可靠做法是用 EL 表达式加 fn:escapeXml 转义或者在 Java 代码里调用StringEscapeUtils.escapeHtml4。但简易项目通常不管这个商品名称是内部录入的一般不会带特殊字符。这个风险你要知道不一定要处理。坑二编辑提交和新增提交用的 action 是不同的——编辑是actionupdate新增是actionadd。很多新手写代码会把两个表单的 action 都写成同一个GoodsServlet?actionadd导致编辑数据变成了插入一条新数据。记住这个约定actionupdate的请求承接方要执行UPDATE goods SET ... WHERE id?。private void updateGoods(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int id Integer.parseInt(request.getParameter(id)); String goodsName request.getParameter(goods_name); int stockQuantity Integer.parseInt(request.getParameter(stock_quantity)); // ... 其他字段 GoodsDAO dao new GoodsDAO(); String sql UPDATE goods SET goods_name?, spec?, stock_quantity?, warning_value?, purchase_price?, sale_price?, supplier?, remark? WHERE id?; boolean success dao.updateGoodsById(sql, id, goodsName, /* ... */); response.sendRedirect(GoodsServlet?actionlist); }逻辑说明这里一个常见的便利操作是——修改时只更新用户能看到的业务字段不更新update_time和id。update_time交给数据库的ON UPDATE CURRENT_TIMESTAMP自动维护id作为 WHERE 条件不要出现在 SET 子句里。如果把id也更新了轻则自增值错乱重则造成级联数据不一致。4.3 删除商品逻辑删除还是物理删除这是个选择删除在简易系统里一般就是物理删除——DELETE FROM goods WHERE id?。这个操作在数据量小且没有历史追溯需求的场景下没问题。但如果你需要知道每个月卖出了哪些商品、在哪个时间段库存变化过你就得引入出入库流水表然后改成逻辑删除在商品表加一个deleted字段删除操作变成UPDATE goods SET deleted1 WHERE id?查询时默认加WHERE deleted0。我的建议是哪怕标题说是“简易”系统你最好还是加一个operation_log表记录关键操作。这个表不需要太复杂五个字段就够id、goods_id、operation_type入库/出库/修改/删除、quantity、operation_time。加了这个表你这个系统就从“玩具”变成了“工具”——至少老板问你“昨天的库存是不是有问题”时你能有据可查。删除操作的代码异常简单String sql DELETE FROM goods WHERE id?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); int affected ps.executeUpdate(); if (affected 0) { response.sendRedirect(GoodsServlet?actionlist); } else { request.setAttribute(error, 删除失败商品可能不存在); request.getRequestDispatcher(error.jsp).forward(request, response); } }逻辑说明Java 7 的 try-with-resources 语法可以自动关闭资源省掉手写 finally 的繁琐。但要注意这种方式关闭的是Connection和PreparedStatement如果有ResultSet需要读数据必须在 try 块内部读完不能在返回后继续访问——因为连接已经关了。4.4 库存查询与低库存预警一个 SQL 解决所有问题库存查询功能本质上就是一个带条件的SELECT。常见的业务场景是三个查所有商品、按商品名称模糊搜索、查低库存商品。这三种场景用一条动态拼接的 SQL 就能搞定。public ListMapString, Object queryGoods(String keyword, boolean onlyLowStock) { StringBuilder sql new StringBuilder( SELECT id, goods_name, spec, unit, stock_quantity, warning_value, supplier FROM goods WHERE 11); ListObject params new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { sql.append( AND (goods_name LIKE ? OR spec LIKE ?)); params.add(% keyword.trim() %); params.add(% keyword.trim() %); } if (onlyLowStock) { sql.append( AND stock_quantity warning_value); } sql.append( ORDER BY update_time DESC); // 执行查询循环赋值给 Map }逻辑说明这里用WHERE 11是一个经典技巧作用是让后续的AND子句不需要判断是否为首个条件简化动态 SQL 的拼装逻辑。数据库执行时11是常量条件优化器会优化掉性能没影响。stock_quantity warning_value这个条件就是低库存预警的判断核心——如果你的系统里没有 warning_value 字段也可以用stock_quantity 10这样的硬编码但这在业务上是很偷懒的做法不同商品的合理库存差异很大用字段控制才是负责任的方式。参数说明数据库里stock_quantity字段类型是INT你和warning_value比较时不需要类型转换。但如果你查询条件是stock_quantity 10这里的 10 会被 MySQL 自动转成整数没问题。如果你传的是10字符串MySQL 也允许但建议你在 Java 侧用Integer.parseInt转好再传避免类型不匹配的隐性风险。5. 部署上线避坑清单最常见的五个翻车现场5.1 页面中文乱码改了 JSP 编码还是乱问题出在哪现象JSP 页面上显示的商品名称是乱码数据库里也有乱码甚至有的页面源码里中文正常但浏览器显示时变成问号。原因这个问题的来源通常是在三个环节中的某一个断了。第一JSP 页面的pageEncoding没设置成UTF-8Tomcat 读 JSP 文件时用默认 ISO-8859-1 解码文件里的中文字节就被拆错了。第二Servlet 里request.setCharacterEncoding(UTF-8)没写或者写的位置不对。第三数据库连接 URL 没带characterEncodingutf8MySQL 驱动写入时用默认编码。解决三步全查一遍。JSP 文件第一行加上% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8%Servlet 的doPost第一行写request.setCharacterEncoding(UTF-8)JDBC URL 末尾拼上characterEncodingutf8。如果这三步都做了还是乱码检查 MySQL 字符集执行SHOW VARIABLES LIKE character_set%;确保character_set_database是 utf8mb4 而不是 latin1。MySQL 的库表字符集在数据已经插入乱码后需要重新建表才有效这就是为什么我一开始就强调建表脚本要指定字符集。5.2 ClassNotFoundException: com.mysql.cj.jdbc.Driver现象项目部署到服务器后启动访问登录页就报 500日志里写着java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因这个异常几乎都是同一个原因——MySQL 驱动 jar 包没放进 Tomcat 的 lib 目录或者放进了项目的 WEB-INF/classes 但 Tomcat 的类加载顺序有问题。另一个情况是你的驱动 jar 是旧版 5.x包名是com.mysql.jdbc.Driver但你代码里写的是新驱动包名com.mysql.cj.jdbc.Driver。解决去 Maven 中央仓库下载mysql-connector-java-8.0.33.jar或者更高版本放到$CATALINA_HOME/lib目录下重启 Tomcat。如果你的代码写的是旧版驱动包名和 8.x jar 搭配也能用但会有一条警告。最好统一成 8.x 的新包名。还有一个细节如果你改了 WEB-INF/lib 下的 jar必须重启 Tomcat 才会生效热部署不会重新加载 jar 文件。5.3 数据库连接超时空闲连接被 MySQL 服务器主动关闭现象系统早上还能正常用中午去吃了个饭下午回来点任何页面都报Connection refused或Communications link failure但重启 Tomcat 后又正常了。原因MySQL 的wait_timeout默认值是 8 小时你的连接池或 DriverManager 建立的长连接超过了这个时间还空闲着MySQL 就主动关了连接。而代码里的连接对象还在内存里当你拿着这个已经死掉的连接去查询时就会通信失败。解决这个问题的根治方案是换连接池Druid 或 HikariCP连接池自带testWhileIdle和timeBetweenEvictionRunsMillis参数会在执行查询前验证连接是否有效。如果你不想引入连接池简单方案是在DBUtil.getConnection()里每次都创建一个新连接用完了立刻关闭不留长连接。不过这样工作量小代价是每次数据库操作都多一些 TCP 握手。5.4 修改 JSP 后不生效浏览器和 Tomcat 都在缓存现象改了几行 JSP 代码刷新浏览器还是旧页面清缓存也没用。另一台电脑上访问却是新页面。原因Tomcat 在开发模式下默认对 JSP 文件开启自动重编译但这个重编译机制的触发条件是 JSP 文件的lastModified时间戳变了。如果你是用文件复制方式部署的复制可能保留了原始时间戳Tomcat 就误认为文件没修改。此外浏览器可能缓存了整个 HTML 页面。解决在web.xml里配置 JSP 属性jsp-config或者干脆把 Tomcat 的 Context 的reloadable设置成 true——在开发阶段这是最直接的。更暴力的做法是重启 Tomcat 后强制刷新浏览器CtrlF5。生产服务器上就老老实实重新打包 WAR 再部署别指望线上热更新 JSP。5.5 HTTP 404 错误项目挂好了但访问不到现象Tomcat 启动正常但浏览器访问http://localhost:8080/inventory/返回 404或者在列表页面操作后跳到http://localhost:8080/GoodsServlet?actionlist这种不带项目名的路径。原因两种情况。第一项目没部署成功检查 Tomcat 的webapps目录下有没有你的项目文件夹或者 manager 应用里有没有显示这个应用。第二JSP 页面里的 Servlet 路径写错了。比如你的项目上下文路径是/inventory这里切不可用绝对路径/GoodsServlet正确写法是a href${pageContext.request.contextPath}/GoodsServlet?actionlist这种带上下文路径的动态拼接。解决所有 JSP 页面中的链接和表单 action 统一使用pageContext.request.contextPath前缀。这个 EL 表达式的意思是“当前项目的根路径”无论项目叫 inventory 还是 stock部署到哪个上下文都能自动适配。把 JSP 里写死的/GoodsServlet全部替换成${pageContext.request.contextPath}/GoodsServlet这个坑就彻底没了。6. 从能跑变好用三个立刻能做的优化技巧6.1 建一个统一的 BaseServlet 基类消灭重复代码你现在这个系统里的 Servlet 可能是GoodsServlet和UserServlet各自写了一套分发逻辑。如果后面加了“供应商管理”“出库管理”之类的模块代码重复会越来越严重。这时可以建一个BaseServlet基类把doGet和doPost统一接收然后通过反射自动调方法。public abstract class BaseServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action req.getParameter(action); if (action null || action.isEmpty()) { action list; } try { Method method this.getClass().getDeclaredMethod(action, HttpServletRequest.class, HttpServletResponse.class); method.invoke(this, req, resp); } catch (NoSuchMethodException e) { resp.sendError(404, Action not found: action); } catch (Exception e) { throw new ServletException(e); } } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doGet(req, resp); } }这段代码的核心价值是新加一个功能只需要在子类里写一个同名方法比如delete、exportExcel不需要再改分发逻辑。代价是反射调用比直接调用多一点点时间但这个损耗对库存系统的并发量来说完全可以忽略。你要注意方法名不能叫doGet或doPost反射会直接跳到这两个方法造成死循环。6.2 给列表页加一个导出 CSV 的功能解决“要数据”的刚需库存系统上线后业务部门最常提的需求就是“把这个表导出来发给我”。与其用 POI 去做复杂的 Excel 导出不如用一个 CSV 导出功能代码量小且兼容所有版本的 Excel。CSV 的本质就是逗号分隔的纯文本用 Servlet 就能实现。protected void exportCsv(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType(text/csv;charsetUTF-8); response.setHeader(Content-Disposition, attachment;filenameinventory_ System.currentTimeMillis() .csv); // 让浏览器用 UTF-8 带 BOM 的格式解析 CSV否则 Excel 打开中文会乱码 response.getWriter().write(\uFEFF); GoodsDAO dao new GoodsDAO(); ListMapString, Object list dao.queryAllGoods(); response.getWriter().write(商品ID,商品名称,规格,单位,库存数量,预警值,进货价,销售价,供应商\n); for (MapString, Object g : list) { String line String.format(%d,%s,%s,%s,%d,%d,%.2f,%.2f,%s\n, g.get(id), g.get(goods_name), g.get(spec), g.get(unit), g.get(stock_quantity), g.get(warning_value), g.get(purchase_price), g.get(sale_price), g.get(supplier)); response.getWriter().write(line); } response.getWriter().flush(); }逻辑说明\uFEFF是字节序标记BOM在 Excel 里打开 UTF-8 编码的 CSV 文件时如果没有这个 BOMExcel 会按系统本地编码比如 GBK解析中文就会乱码。写这个字符是让 Excel 强制识别为 UTF-8。这个技巧在很多商业数据导出工具里都用算是一个标准了。注意不要用response.getWriter().write拼接大文件——如果商品有几万条考虑用BufferedWriter分批次写或者直接用 Java IO 的流写。简易系统商品数量通常在几千条直接拼接问题不大。6.3 把 DBUtil 升级成连接池配置顺便解决 5.3 的超时问题我已经说过 DriverManager 裸连接不适合部署。升级成 Druid 连接池的做法非常简单你只需要改两点把 DBUtil 的getConnection方法换成从 DruidDataSource 获取以及把驱动、URL、用户名、密码写成配置文件。public class DBUtil { private static DruidDataSource dataSource; static { dataSource new DruidDataSource(); dataSource.setDriverClassName(com.mysql.cj.jdbc.Driver); dataSource.setUrl(jdbc:mysql://localhost:3306/inventory?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8); dataSource.setUsername(root); dataSource.setPassword(123456); dataSource.setInitialSize(5); // 初始连接数 dataSource.setMinIdle(5); // 最小空闲连接 dataSource.setMaxActive(20); // 最大活跃连接 dataSource.setMaxWait(60000); // 获取连接最大等待时间 ms dataSource.setValidationQuery(SELECT 1); dataSource.setTestWhileIdle(true); dataSource.setTimeBetweenEvictionRunsMillis(60000); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { // 关闭 rs、ps、conn注意顺序 } }参数说明setValidationQuery(SELECT 1)是一个轻量级查询连接池在把连接交给代码之前用它验证连接是否可用比 MySQL 的 ping 命令更直接。testWhileIdle(true)配合timeBetweenEvictionRunsMillis(60000)表示每 60 秒对空闲连接做一次有效性检查超过 8 小时的旧连接在空闲扫描时会被逐出连接池这样就根治了 5.3 节的连接过期问题。Druid 连接池的另一个好处是它自带监控页面不过默认关闭。如果你想让老板看到一个“系统健康度”仪表盘可以在web.xml里加上 Druid 的 StatViewServlet路径映射为/druid/*。这个页面里能看到当前连接数、SQL 执行次数和最慢查询可以说是廉价版数据库监控工具。这三件事做完你的系统就从“能跑”变成了“像模像样”。我自己做这类教学项目时有一句嘴硬的话“哪怕是个教学系统也要像一个能上生产的东西来写。”这套思路帮我避免过不少低级失误——把基类写好、导出功能加上、连接池配上以后接手新模块时你会回来感谢现在的自己。希望帮到你。本文还有配套的精品资源点击获取