
简介面向Java Web初学者或需要完成课程设计的在校生这套以JSPServlet数据库实现的简易库存商品管理系统精准匹配管理员登录、库存商品列表展示、增加修改删除、低库存红色预警、同商品库存自动累加等业务需求适合用来理解传统Java Web分层开发流程与MVC思想。压缩包共38个文件以Java源码、JSP页面、SQL脚本为主同时包含XML配置、CSS样式、项目描述文件与项目文档覆盖后端逻辑、前端展示和数据库初始化脚本体量仅148KB结构紧凑便于快速部署和二次开发。已有1488人学习下载资源沉淀了较为完整的项目实现过程。通过这套源码读者可以获得需求分析到代码落地的完整映射既能参照实现库存业务的增删改查与数据校验也能学习Servlet控制跳转、JSP动态渲染和JDBC访问数据库的配合方式配套SQL脚本可直接导入数据库适合作为实训作业或入门项目参考资料。1. 一个简易的库存商品管理系统用 JSPServlet数据库 写值得动手一次这个标题指向的项目表面看是 JavaWeb 课程里最常见的作业但它背后其实是一条完整的 Web 应用主线浏览器发请求Servlet 接收并分发DAO 通过 JDBC 操作数据库JSP 把结果渲染成表格页面。把这样一个简易的库存商品管理系统亲手写一遍你才会发现框架替你藏起来的那些细节——连接关闭、事务边界、中文乱码、页面刷新重复提交——全都是真实要趟的坑。适合刚学完 Java 基础、正在找课程设计选题的人也适合想回头把 Servlet/JSP 原理补扎实的开发者。它不追求复杂而是要把增删改查每一环走通。走通之后再上手 Spring Boot你就能看清楚它到底帮你接管了什么。2. 搭建 JSPServlet数据库 开发环境版本怎么选、目录怎么摆在动手写代码之前先把原理和选型讲清楚否则后面会出现大量“照着写却跑不起来”的玄学问题。Servlet 是运行在 Web 容器里的 Java 类JSP 本质上也会被 Tomcat 编译成对应的 Servlet 类来执行。你写的list.jsp第一次被访问时容器会在后台生成一个list_jsp.java再编译运行。所以 JSP 和 Servlet 不是两个独立的东西而是同一套 Web 机制的不同写法。理解了这层关系你才知道为什么页面里不该塞业务逻辑为什么请求要一层层往下传递。很多人在搜 servlet demo、基于 jsp 的毕业论文管理系统思路都是对的但卡在第一步环境不对。2.1 版本选型怎样组合踩坑最少站在 2025 年回头看最稳的组合就是 JDK 8 或 11、Tomcat 9、MySQL 5.7 或 8.0。为什么不用最新版有一个非常现实的坑Tomcat 10 把 Servlet 的包名从javax.servlet换成了jakarta.servlet网上绝大多数教程和现成代码都是老包名。如果你用了 Tomcat 10会发现import javax.servlet.http.HttpServlet;直接编译报错哪怕代码逻辑完全正确。这不是你不会写是环境不匹配。组件推荐版本理由JDK8 或 11教程生态最成熟Tomcat 9 完美支持Tomcat9.0.x用javax.*包名和老代码兼容MySQL5.7 或 8.08.0 注意驱动要配com.mysql.cj.jdbc.Drivermysql-connector-java5.1.4x 或 8.x驱动版本必须和 MySQL 版本匹配我一般会让刚起步的人直接用 Java 8 Tomcat 9 MySQL 8.0。MySQL 8 的驱动类名改了这个我们在后面代码里会处理。曾经有读者卡了一晚上原因就是他电脑上装的是 Tomcat 10却拿 Tomcat 8 的教程在敲代码这就是版本选型没做对带来的连锁问题。2.2 手工创建项目不依赖 Maven 的做法用 Maven 搭建虽然方便但对入门阶段的人来说它会引入大量你不知道从哪来的依赖出问题时更难定位。建议先在 IDE 里建一个 Dynamic Web Project不用 Maven依赖就一个WEB-INF/lib下的 MySQL 驱动 jar 包。项目的标准结构是这样的inventory/ ├── src/ │ └── com/ │ └── inventory/ │ ├── entity/ -- 商品实体类 │ ├── dao/ -- 数据库访问层 │ ├── servlet/ -- 控制器层 │ └── util/ -- 数据库工具类、过滤器 └── WebContent/ ├── WEB-INF/ │ ├── web.xml -- Web 配置 │ └── lib/ │ └── mysql-connector-java-8.0.33.jar ├── list.jsp -- 商品列表 ├── add.jsp -- 新增商品表单 └── edit.jsp -- 修改商品表单这个结构遵循的是最经典的三层思路entity只放字段和 getter/setterdao只做 SQL 操作servlet负责接收请求、调用 DAO、决定跳转到哪个 JSPjsp只负责展示。如果项目运行在 Servlet 3.0 以上版本Tomcat 9 满足web.xml里甚至可以不配 Servlet 映射直接用WebServlet注解这也是我们后面要采用的写法。2.3 把全局字符编码过滤器先配好中文乱码是这个项目里最劝退的问题之一。最省心的方案不是在每个 Servlet 里写request.setCharacterEncoding(UTF-8)而是写一个过滤器统一处理。在util包下建一个EncodingFilter然后在web.xml里注册filter filter-nameencodingFilter/filter-name filter-classcom.inventory.util.EncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping过滤器的逻辑非常简单在请求进入 Servlet 之前把请求体的编码设置为 UTF-8在响应返回之前把响应的编码也设置为 UTF-8。这样你后面所有的 Servlet 都不用再操心编码问题。有人问为什么不直接在 Tomcat 的server.xml里改URIEncodingUTF-8那也是可以的但那只解决 GET 请求的 URI 解码问题POST 请求体的编码还是得靠过滤器。两者结合是最稳的但如果你只做一个动作我建议做过滤器。3. 库存商品的建表与 DAO把 JDBC 封装成可复用的增删改查这一章是整套系统的核心。数据库表设计得好不好直接决定后面代码是越写越顺还是越写越拧巴。我先给出一张最小可用的商品表再带你封装一个 ProductDao把数据库的增删改查操作都收拢到这一个类里。很多人把 SQL 散落在各个 Servlet 里项目不到 1000 行就已经失控了这是常见误用。3.1 商品表结构设计字段别拍脑袋库存商品系统至少要管住三件事商品是什么、库存有多少、价格是多少。在此基础上我习惯加上创建时间和更新时间因为无论是排查数据问题还是做列表排序这两个字段几乎一定会用到。建表 SQL 如下CREATE DATABASE IF NOT EXISTS inventory DEFAULT CHARACTER SET utf8mb4; USE inventory; CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 商品名称, category VARCHAR(50) DEFAULT 未分类 COMMENT 商品分类, stock INT NOT NULL DEFAULT 0 COMMENT 当前库存, price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 销售单价, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;有几个点需要解释一下。价格字段用DECIMAL(10,2)绝对不能用 float 或 double否则算金额时会出现 0.1 0.2 不等于 0.3 的精度问题这在库存系统里是致命的。库存字段stock用INT就够不需要BIGINT除非你预估单商品库存会超过 21 亿。另外注意字符集用了utf8mb4它和utf8的区别在于能存 emoji 和生僻字而且排序规则更现代。数据库的默认编码在 MySQL 8 里已经是 utf8mb4但 MySQL 5.7 的老库经常还是 latin1所以建库时显式指定最保险。提示字段的COMMENT一定要写。这个系统可能过两个月你自己都记不清stock到底是可用库存还是包含预占的库存注释是给自己留的后路。3.2 DBUtil连接与释放的统一出口JDBC 操作的第一步永远是拿连接。入门阶段我用最简方案一个DBUtil类静态代码块加载驱动静态方法返回连接。后面如果你想升级到数据库连接池只需要改这一个类其他代码完全不用动。package com.inventory.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/inventory ?useSSLfalseserverTimezoneAsia/Shanghai characterEncodingutf8; private static final String USERNAME root; private static final String PASSWORD 你自己的密码; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL驱动加载失败检查WEB-INF/lib下是否有jar包); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USERNAME, PASSWORD); } }这段代码里最容易被忽略的是 URL 上的三个参数。useSSLfalse是因为本地开发环境一般没有配置 SSL 证书不加这个参数 MySQL 8 会警告甚至报错serverTimezoneAsia/Shanghai是解决驱动和数据库时区不一致的问题不加的话报错信息会提示 CST 和 UTC 时区识别不了characterEncodingutf8保证中文写入数据库时不是乱码。这三个参数是我配置 JDBC 的固定套路换数据库、换项目都适用。3.3 ProductDao增删改查的四个方法加一个补库存DAO 层的核心思想就是让 Servlet 不碰 SQL 字符串所有和数据库打交道的操作都封装在方法里。下面是一个典型的插入方法注意我用了PreparedStatement而不是拼字符串的Statement——这是防止 SQL 注入的底线。package com.inventory.dao; import com.inventory.entity.Product; import com.inventory.util.DBUtil; import java.math.BigDecimal; import java.sql.*; public class ProductDao { // 新增商品返回受影响行数 public int insert(Product p) { String sql INSERT INTO product(name, category, stock, price) VALUES(?, ?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, p.getName()); ps.setString(2, p.getCategory()); ps.setInt(3, p.getStock()); ps.setBigDecimal(4, p.getPrice()); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } // 扣减库存只允许在库存足够时扣减防止超卖 public int reduceStock(int id, int count) { String sql UPDATE product SET stock stock - ? WHERE id ? AND stock ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, count); ps.setInt(2, id); ps.setInt(3, count); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } }逻辑说明insert方法里用了?占位符PreparedStatement会把参数值做类型转义用户输入里就算带了 OR 11 --这种内容也只会被当成普通字符串不会被拼进 SQL。返回值是executeUpdate()的结果代表受影响的行数Servlet 拿到这个返回值就知道新增是否成功。reduceStock方法特别适合库存系统它在一条 SQL 里同时完成了“查库存是否足够”和“扣减”两个动作既减少了获取连接的开销又避免了下架超卖。如果库存不足更新的影响行数是 0你就不用再去查一次库存来判断了。搜索数据库并发锁和数据库死锁相关话题时会发现这类 UPDATE 语句是避免并发问题的经典手段。4. 用 JSP 把列表渲染出来EL 表达式、表单提交和跳转方式的分工数据库操作封装好了接下来要把商品数据展示给用户。JSP 这部分最常见的写坏方式是在页面里写一堆% %Java 代码然后 JSP 和 Servlet 的边界彻底糊掉。Servlet 负责把查询结果放进requestJSP 只负责循环展示这段代码是每一届 JavaWeb 学习者都会用到的标准写法。4.1 列表页用 JSTL 循环输出不要在页面上写 Java 循环先看 Servlet 里的一段查询逻辑它把查到的最新数据放进了请求域package com.inventory.servlet; import com.inventory.dao.ProductDao; import com.inventory.entity.Product; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; import java.util.List; WebServlet(/product) public class ProductServlet extends HttpServlet { private final ProductDao dao new ProductDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action req.getParameter(action); if (action null || list.equals(action)) { ListProduct list dao.findAll(); req.setAttribute(productList, list); req.getRequestDispatcher(list.jsp).forward(req, resp); } else if (delete.equals(action)) { int id Integer.parseInt(req.getParameter(id)); dao.delete(id); resp.sendRedirect(product?actionlist); } } }然后list.jsp的循环用 JSTL配合 EL 表达式取值页面里没有一个 Java 代码块% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle库存商品列表/title/head body table border1 tr thID/thth商品名/thth分类/thth库存/thth单价/thth操作/th /tr c:forEach varp items${productList} tr td${p.id}/td td${p.name}/td td${p.category}/td td${p.stock}/td td${p.price}/td td a hrefproduct?actioneditid${p.id}编辑/a a hrefproduct?actiondeleteid${p.id} onclickreturn confirm(确定删除该商品吗)删除/a /td /tr /c:forEach /table /body /html参数说明页面顶部第一行必须写contentTypetext/html;charsetUTF-8这是页面不出现乱码的第一道防线第二行引入 JSTL 核心标签库注意uri是固定的官方路径就是http://java.sun.com/jsp/jstl/core不是你自己起的名字。循环体里的${p.name}会调用Product类的getName()方法EL 表达式本质上是在访问 JavaBean 的属性。删除链接上我加了一个onclick确认弹窗这是库存管理里防止手滑误删的朴素有效手段如果你要在生产环境用更好的做法是改成 POST 请求再删除。4.2 新增和编辑表单POST 提交与后端必填校验表单页面本身很简单关键是 Servlet 在doPost里处理参数时要多做一步校验。很多入门代码对空参数不设防用户什么都没填就提交数据库里就插进一条空记录后期清理非常头疼。Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String name req.getParameter(name); String category req.getParameter(category); int stock 0; BigDecimal price BigDecimal.ZERO; boolean valid true; if (name null || name.trim().isEmpty()) { valid false; req.setAttribute(error, 商品名称不能为空); } try { stock Integer.parseInt(req.getParameter(stock)); price new BigDecimal(req.getParameter(price)); } catch (NumberFormatException e) { valid false; req.setAttribute(error, 库存和价格必须是数字); } if (!valid) { req.getRequestDispatcher(add.jsp).forward(req, resp); return; } Product p new Product(); p.setName(name.trim()); p.setCategory(category); p.setStock(stock); p.setPrice(price); int result dao.insert(p); if (result 0) { resp.sendRedirect(product?actionlist); } else { req.setAttribute(error, 新增失败请重试); req.getRequestDispatcher(add.jsp).forward(req, resp); } }这一段是最容易照抄也最容易栽跟头的地方。req.setCharacterEncoding(UTF-8)必须在读取任何参数之前执行如果你把这一行放在getParameter后面POST 请求的中文就已经按了 ISO-8859-1 解码之后再设置也救不回来。校验逻辑里我用try-catch包住了数字转换用户输入非数字内容时不会导致 500 页面而是带着错误提示回到表单页。这里的方式是先把 String 转成 int 和 BigDecimal再用普通对象承载等你用 ModelAndView 那一套框架时思路是一样的。4.3 Servlet 处理完转发还是重定向这是一个让很多初学者困惑的问题req.getRequestDispatcher(list.jsp).forward(req, resp)和resp.sendRedirect(product?actionlist)有什么区别简单说转发是服务端内部的一个动作浏览器地址栏不变一次请求完成整个流程重定向是服务端告诉浏览器“你去访问这个新地址”浏览器会重新发一次请求。库存系统里新增、修改、删除这类改变数据的操作完成后一定要用重定向。否则用户手动刷新列表页时浏览器会重新提交刚才的 POST 请求商品可能被插入了两次。这个场景叫做表单重复提交是数据库里出现一模一样重复记录的常见元凶。查询操作比如查看列表用转发没问题地址栏维持在product?actionlist也更直观。5. 库存系统最常见的 5 个翻车现场现象、原因、解决这一章把我见过的、以及自己在学生时代踩过的坑整理成排查手册。每一条都是先描述现场、再分析根因、最后给解决动作你可以对照自己的项目逐个核对。5.1 启动 Tomcat 报 ClassNotFoundException : com.mysql.cj.jdbc.Driver现象明明引入了 MySQL 驱动 JDBCTomcat 一启动就抛ClassNotFoundException最常见的是com.mysql.jdbc.Driver或com.mysql.cj.jdbc.Driver找不到。原因驱动 jar 没有真正放在WEB-INF/lib目录或者你用 IDE 引用了 jar但发布到 Tomcat 时没有勾选“Deploy this jar”。解决在项目面板里右键项目进入 Deployment Assembly确认有WEB-INF/lib这条条目或者干脆直接复制 jar 到本地 Tomcat 的webapps/你的项目/WEB-INF/lib下刷新重启。注意 MySQL 5.x 驱动和 8.x 驱动的类名不同8.x 必须写com.mysql.cj.jdbc.Driver。5.2 数据库里商品名显示一堆问号 ???现象列表页显示正常但用 Navicat 打开数据库表中文全部变成???。原因建表字符集是latin1连接 URL 没有指定characterEncodingutf8或者两者都错了。我之前排查过一个案例数据库已经是 utf8mb4但 JDBC URL 里漏掉了characterEncodingJava 端用 UTF-8 写进去连接层却按 GBK 解码落库就成了问号。解决建库用DEFAULT CHARACTER SET utf8mb4JDBC URL 加characterEncodingutf8过滤器保证请求编码是 UTF-8三步缺一不可。改完要清掉已经存在的脏数据再测试因为之前写入的问号不会自动恢复。5.3 Tomcat 10 编译报错javax.servlet 包不存在现象import javax.servlet.http.HttpServlet;这一行标红或者编译直接报“程序包 javax.servlet 不存在”。原因Tomcat 10 开始把 Servlet 的包名迁移到了jakarta.servlet这是 Java EE 移交 Eclipse 基金会后的改名影响面非常大。解决最省事的办法把 Tomcat 换成 9.0.x如果你必须用 Tomcat 10就把所有导入语句里的javax.servlet改成jakarta.servlet。我建议新手直接换 Tomcat 9因为教程、老代码、搜索引擎情绪里百分之九十的结果都基于javax。5.4 刷新列表页导致重复插入相同商品现象新增一个商品后地址栏停在新增成功的页面用户按 F5 刷新同一个商品在数据库里出现了两条甚至多条。原因新增操作是 POST 请求转发到列表页后这个 POST 请求仍然保持原样刷新等于重新执行一次 doPost 里的新增逻辑。解决按第 4 章讲的方式数据变更完成后用sendRedirect重定向到查询列表。区别在于重定向后的刷新会重新 GET 列表接口不会重复执行写入。这是防止重复提交的经典 PRG 模式在库存系统里尤其重要重复入库会导致库存虚高。5.5 并发扣库存时库存变成负数现象两个用户几乎同时购买同一商品数据库库存从 10 变成了 -1。原因两个请求都先查询 stock都看到是 10然后各自执行UPDATE product SET stock9最后一个提交的覆盖了前面的扣减或者没有条件判断直接减到了负数。解决把“判断库存够不够”和“扣减库存”合并到一条 UPDATE 语句里UPDATE product SET stock stock - ? WHERE id ? AND stock ?。这样数据库行锁会让第二个 UPDATE 等了第一个提交后再执行此时库存已经变成 9其中一条的stock ?条件不成立影响行数为 0你在 DAO 里捕获 0 值即可提示“库存不足”。这个场景是数据库并发锁和数据库死锁话题的入门案例如果你之后引入库存流水表还需要用事务把两个 SQL 包成原子操作。6. 让演示更完整的一个技巧分页查询和库存预警的快速实现项目做完基本增删改查肯定能过但如果你想在答辩或演示时拿出一点不一样的东西给列表加一个分页是最快见效的优化。它用到的是 MySQL 的LIMIT ? OFFSET ?语法代码改动集中在一个 DAO 方法里public ListProduct findPage(int pageNum, int pageSize) { String sql SELECT * FROM product ORDER BY id LIMIT ? OFFSET ?; int offset (pageNum - 1) * pageSize; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, pageSize); ps.setInt(2, offset); ResultSet rs ps.executeQuery(); // ... 封装成 ListProduct } catch (SQLException e) { e.printStackTrace(); return new ArrayList(); } }注意OFFSET的计算方法是页码从 1 开始第 2 页的偏移量就是(2-1)*1010。除了分页我再建议你加一个库存预警查询SELECT * FROM product WHERE stock 10然后在列表页顶部用醒目颜色展示“以下商品库存不足”。这两个功能加在一起演示时能让系统显得有真实业务价值而且实现成本非常低不涉及新的依赖。最后是我的一点经验。做这个简易的库存商品管理系统最常见的失败原因不是代码写不出来而是中途放弃。数据库驱动版本不对、JSP 页面乱码、Tomcat 版本不一致每一关都可能让你怀疑自己。我的教训是遇到报错先看异常堆栈的第三行再看是不是以前处理过的问题把这次的过程记在自己的排查笔记里。这套 JSPServlet数据库 的组合练一遍你再进 Spring Boot 时会有一种“工具变了但路我认识”的踏实感。希望帮到你。本文还有配套的精品资源点击获取