☰
2025年还在用JSP+Servlet?手把手教你搭一套库存管理系统
2026/10/8 8:49:46 网站建设 项目流程

简介:一款基于JSP+Servlet+数据库的简易库存商品管理系统,适合正在学习Java Web开发的初学者,用来理解Servlet请求处理、JSP页面展示以及MySQL数据库读写的基本流程。系统完整覆盖典型库存管理需求:管理员可登录后台,查看所有商品的名称、规格、库存件数、单价;支持新增、修改、删除记录,删除前会弹出确认;点击新加商品可跳转至独立页面;当库存件数少于10件时数字显示为红色;提交前检查所有字段为必填项,件数和单价必须为数字;保存时若发现名称、规格、单价均相同的已有商品,则自动累加库存,否则新增一条记录。压缩包共38个文件,内含Java源文件、JSP页面、SQL脚本、class编译文件及CSS样式,另有项目配置和说明文档,整体只有148KB,部署和学习成本很低。随包提供user.sql与product.sql建表脚本和项目.doc,可快速初始化数据库并查看整体设计;读者参考源码即可掌握表单校验、数据库增删改查、请求跳转等关键写法。目前已有1488人学习/浏览,适合作为课程设计、毕业设计或Servlet入门练手项目。

1. 为什么 2025 年还有人用 JSP+Servlet 写库存系统

先说个反直觉的结论:如果你只想管好一两万条商品的进销存,不想引入 Spring Boot、MyBatis 那套依赖链和启动开销,JSP+Servlet+数据库这套组合依然能打。很多高校的毕业设计和中小型企业的内部工具,至今还在用这套技术栈维护和生产。标题里的“库存商品管理系统”,本质上就是一个典型的 CRUD 系统——商品增删改查、入库出库记录、库存数量汇总,外加一点简单的权限控制。

这套方案的价值在于:Servlet 负责接收 HTTP 请求和处理业务逻辑,JSP 负责把数据渲染成页面,数据库负责持久化。三者各管一段,边界极其清晰。对于新手来说,它比 Spring Boot 更容易理解“一个 HTTP 请求到底是怎么走完整个链路的”;对于熟手来说,它足够轻量,一个 Tomcat 加一个 MySQL 就能跑起来,连 Maven 都可以不用。

我见过不少人拿到这类项目源码后卡在第一步——不是看不懂代码,而是不知道从哪里启动。这篇文章就把这套库存管理系统的结构、核心代码、部署参数和常见坑一次性讲透,保证你照着做完能真正用起来,而不是停在“代码能编译通过”的假成功。

2. 从 JSP+Servlet 到数据库:这套架构到底怎么运作

2.1 为什么要用 JSP 做视图而不用纯 Servlet 输出 HTML

JSP 的本质是被 Tomcat 编译成 Servlet 再执行的,所以“JSP 和 Servlet 谁更好”本身就是个伪命题。但它们在分工上有个约定俗成的规矩:Servlet 不直接拼 HTML 字符串,JSP 里也不写业务逻辑。这样做的直接好处是,当页面样式需要调整时,你只需要改 .jsp 文件,不需要碰 Java 代码,更不需要重新编译整个项目——Tomcat 检测到 JSP 文件变化后会自动重编译。

在这类库存系统里,JSP 通常负责几个固定页面:商品列表页面、入库表单页面、出库表单页面、库存查询页面和登录页面。商品列表页面每隔几秒钟就要刷新一次库存数字,这时候 JSP 的脚本表达式就派上了用场。比如下面这段代码就是典型的商品列表核心片段:

<% List<Map<String, Object>> goodsList = (List<Map<String, Object>>) request.getAttribute("goodsList"); if (goodsList != null) { for (Map<String, Object> goods : goodsList) { %> <tr> <td><%= goods.get("goods_name") %></td> <td><%= goods.get("stock_quantity") %></td> <td> <a href="GoodsServlet?action=edit&id=<%= goods.get("id") %>">编辑</a> <a href="GoodsServlet?action=delete&id=<%= goods.get("id") %>" onclick="return confirm('确认删除该商品吗?');">删除</a> </td> </tr> <% } } %>

这段代码的逻辑很简单:Servlet 把查询结果塞进 request 作用域,JSP 用<%= %>表达式把数据输出到表格行里。注意这里我用的是Map<String, Object>,而不是实体类——对于这种简易系统,这种写法能省掉一个 JavaBean 类的定义,减少文件数量。但如果你要维护的商品字段很多,比如还要管批次、供应商、条码,那我建议你还是老老实实建一个Goods实体类,后面改起来会轻松很多。

参数说明:action=edit和action=delete是这个系统最关键的请求参数,Servlet 通过判断这个参数来决定调用哪个业务方法。如果你要加一个“商品导入”功能,就再定义一个action=import,然后在 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 怎么写才能不坑队友

DAO(Data Access Object)层在这套系统里就是一组数据库操作方法。简易系统通常不会引入连接池,直接用DriverManager.getConnection获取连接。但我要提醒你一句:如果你打算把这个系统部署给多个部门同时用,一定要换成Druid或HikariCP连接池,否则并发一上来,数据库连接数会被瞬间打满。

下面这段是典型的 JDBC 操作方式,用来根据关键字模糊查询商品:

public List<Map<String, Object>> searchGoods(String keyword) { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; List<Map<String, 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()) { Map<String, 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) ) ENGINE=InnoDB 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' ) ENGINE=InnoDB 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?useSSL=false&serverTimezone=Asia/Shanghai,用户名 root,密码空。下面是一个典型的配置文件:

driver=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/inventory?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username=root password=你的密码

逻辑说明:这段配置里最容易出问题的就是serverTimezone=Asia/Shanghai——MySQL 8.0 驱动强制要求指定时区,不写的话会报The server time zone value '�й���׼ʱ��' is unrecognized或者干脆是中文乱码的时区错误。useSSL=false是为了关掉 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 运行时验证:三张页面加一个数据库侧写

项目跑起来后,不要急着说“成功了”,用下面这套验证路径确保核心链路是通的:

  1. 登录:输入错误密码时会不会报错且停留在登录页,输入正确密码能不能跳转到商品列表。
  2. 新增商品:在表单里填一条完整商品信息,提交后列表页能不能立刻出现这条数据。
  3. 修改库存:把某件商品的库存数量改大或改小,看列表页数字有没有变化。
  4. 删除商品:删掉刚才新增的那条测试数据,列表页是否消失。
  5. 数据库验证:执行SELECT * FROM goods;看数据是不是和页面显示一致。

这五步全过了,才可以说这个系统的核心链路真的跑通了。很多项目跑不起来其实是卡在部署环节,代码本身没有 bug。

4. 库存系统的核心功能拆解:增删改查里的实现细节

4.1 新增商品:从表单提交到数据库插入的完整链路

新增商品通常是库存管理系统的第一大功能。表单在add_goods.jsp里,JSP 页面里有一个<form action="GoodsServlet?action=add" method="post">,字段包括商品名称、规格、单位、进货价、销售价、库存量和预警值。提交后请求打到GoodsServlet的doPost,然后根据action=add分发到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?action=list"); } 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 修改商品:回显与提交的两个坑位

编辑功能的流程是:先从列表页点“编辑”,携带action=edit&id=3跳转到GoodsServlet,然后在showEditPage方法里根据 id 查出这条商品的全部信息,set 到 request 作用域,forward 到edit_goods.jsp页面回显。这里 JSP 页面里要用<input type="text" name="goods_name" value="<%= goods.get("goods_name") %>">这种方式把数据库值填回输入框里。

坑一:value属性里如果商品名称包含双引号,回显时会破坏 HTML 结构,导致页面样式错乱甚至属性注入。可靠做法是用 EL 表达式加 fn:escapeXml 转义,或者在 Java 代码里调用StringEscapeUtils.escapeHtml4。但简易项目通常不管这个,商品名称是内部录入的,一般不会带特殊字符。这个风险你要知道,不一定要处理。

坑二:编辑提交和新增提交用的 action 是不同的——编辑是action=update,新增是action=add。很多新手写代码会把两个表单的 action 都写成同一个GoodsServlet?action=add,导致编辑数据变成了插入一条新数据。记住这个约定:action=update的请求承接方要执行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?action=list"); }

逻辑说明:这里一个常见的便利操作是——修改时只更新用户能看到的业务字段,不更新update_time和id。update_time交给数据库的ON UPDATE CURRENT_TIMESTAMP自动维护,id作为 WHERE 条件不要出现在 SET 子句里。如果把id也更新了,轻则自增值错乱,重则造成级联数据不一致。

4.3 删除商品:逻辑删除还是物理删除,这是个选择

删除在简易系统里一般就是物理删除——DELETE FROM goods WHERE id=?。这个操作在数据量小且没有历史追溯需求的场景下没问题。但如果你需要知道每个月卖出了哪些商品、在哪个时间段库存变化过,你就得引入出入库流水表,然后改成逻辑删除:在商品表加一个deleted字段,删除操作变成UPDATE goods SET deleted=1 WHERE id=?,查询时默认加WHERE deleted=0。

我的建议是,哪怕标题说是“简易”系统,你最好还是加一个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?action=list"); } 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 List<Map<String, Object>> queryGoods(String keyword, boolean onlyLowStock) { StringBuilder sql = new StringBuilder( "SELECT id, goods_name, spec, unit, stock_quantity, warning_value, supplier FROM goods WHERE 1=1"); List<Object> 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 1=1是一个经典技巧,作用是让后续的AND子句不需要判断是否为首个条件,简化动态 SQL 的拼装逻辑。数据库执行时1=1是常量条件,优化器会优化掉,性能没影响。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-8,Tomcat 读 JSP 文件时用默认 ISO-8859-1 解码,文件里的中文字节就被拆错了。第二,Servlet 里request.setCharacterEncoding("UTF-8")没写或者写的位置不对。第三,数据库连接 URL 没带characterEncoding=utf8,MySQL 驱动写入时用默认编码。

解决:三步全查一遍。JSP 文件第一行加上<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>;Servlet 的doPost第一行写request.setCharacterEncoding("UTF-8");JDBC URL 末尾拼上&characterEncoding=utf8。如果这三步都做了还是乱码,检查 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 后强制刷新浏览器Ctrl+F5。生产服务器上就老老实实重新打包 WAR 再部署,别指望线上热更新 JSP。

5.5 HTTP 404 错误:项目挂好了但访问不到

现象:Tomcat 启动正常,但浏览器访问http://localhost:8080/inventory/返回 404,或者在列表页面操作后跳到http://localhost:8080/GoodsServlet?action=list这种不带项目名的路径。

原因:两种情况。第一,项目没部署成功,检查 Tomcat 的webapps目录下有没有你的项目文件夹,或者 manager 应用里有没有显示这个应用。第二,JSP 页面里的 Servlet 路径写错了。比如你的项目上下文路径是/inventory,这里切不可用绝对路径/GoodsServlet,正确写法是<a href="${pageContext.request.contextPath}/GoodsServlet?action=list">这种带上下文路径的动态拼接。

解决:所有 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;charset=UTF-8"); response.setHeader("Content-Disposition", "attachment;filename=inventory_" + System.currentTimeMillis() + ".csv"); // 让浏览器用 UTF-8 带 BOM 的格式解析 CSV,否则 Excel 打开中文会乱码 response.getWriter().write("\uFEFF"); GoodsDAO dao = new GoodsDAO(); List<Map<String, Object>> list = dao.queryAllGoods(); response.getWriter().write("商品ID,商品名称,规格,单位,库存数量,预警值,进货价,销售价,供应商\n"); for (Map<String, 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 文件时,如果没有这个 BOM,Excel 会按系统本地编码(比如 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?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"); 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 执行次数和最慢查询,可以说是廉价版数据库监控工具。

这三件事做完,你的系统就从“能跑”变成了“像模像样”。我自己做这类教学项目时有一句嘴硬的话:“哪怕是个教学系统,也要像一个能上生产的东西来写。”这套思路帮我避免过不少低级失误——把基类写好、导出功能加上、连接池配上,以后接手新模块时你会回来感谢现在的自己。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询