☰
JSP+Servlet+JavaBean进销存系统实战:三层架构与库存并发控制
2026/10/9 2:47:04 网站建设 项目流程

简介:这是一套面向计算机专业学生与Java Web初学者的超市进销存管理系统源码,采用JSP+Servlet+JavaBean经典三层架构,配合MySQL数据库实现商品、库存、订单等核心业务模块,适合用作毕业设计、课程设计或框架入门练手项目。压缩包共134个文件,约6.94MB,其中14个java源文件与14个class文件构成后台业务逻辑,9个jsp页面负责视图展示,16个js与15个css支撑前端交互与样式,另有6个jar依赖包及xml、properties等配置文件,目录结构清晰,便于按模块阅读与二次开发。资源中的源码均经过本地编译验证,按文档配置好环境即可运行,项目难度适中,内容经助教老师审定。目前已有68人学习下载,读者可借此掌握Servlet请求处理、JavaBean数据封装、数据库连接与增删改查等实战技能,并参考ExcelUtil等工具类理解数据导入导出思路,遇到问题也可私信作者获取解答。

1. JSP+Servlet+JavaBean 进销存系统:为什么老架构反而更适合练手

很多刚入行的兄弟一提到 JSP+Servlet+JavaBean 就皱眉,觉得这玩意儿是上个时代的产物,现在都 Spring Boot 满天飞了还学它干嘛。但如果你去翻招聘网站上那些中小型软件公司的 JD,或者接手过一些地方超市、连锁便利店的内部管理系统,会发现这套组合依然活得很好。原因很直接:部署简单、依赖少、服务器资源占用低,一台普通云主机跑几百个并发毫无压力。进销存管理系统这个场景更是典型——商品入库、库存盘点、销售出库、供应商管理,业务逻辑清晰,数据关系明确,用 JSP 做页面展示、Servlet 做流程控制、JavaBean 封装数据实体,三层结构各司其职,代码写出来干净利落。这篇文章不讲空泛概念,直接按一个可运行的超市进销存系统来拆,从建表到页面跳转,从参数传递到库存扣减,每一步都给出可抄的代码和踩过的坑。适合正在做课程设计的学生、需要快速交付内部工具的一线开发者,以及想理解 MVC 本质但被框架封装搞晕的人。

2. 三层架构落地:从建表到 JavaBean 的映射逻辑

2.1 数据库表设计与 JavaBean 字段对应关系

进销存系统的核心表就那么几张:商品表、供应商表、库存表、入库记录表、销售记录表。我一般会先画 ER 图再动手建表,但这里直接给最终确定的表结构,因为字段命名和类型选择直接决定了后面 JavaBean 写起来顺不顺手。

-- 商品表:存储商品基础信息 CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '商品名称', category VARCHAR(50) COMMENT '分类', unit VARCHAR(20) COMMENT '单位', purchase_price DECIMAL(10,2) COMMENT '进货价', sale_price DECIMAL(10,2) COMMENT '销售价', supplier_id INT COMMENT '供应商ID', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 库存表:与商品一对一,单独拆表便于加锁 CREATE TABLE inventory ( product_id INT PRIMARY KEY, quantity INT DEFAULT 0 COMMENT '当前库存数量', warn_threshold INT DEFAULT 10 COMMENT '预警阈值', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (product_id) REFERENCES product(id) ); -- 入库记录表 CREATE TABLE stock_in ( id INT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL, quantity INT NOT NULL, operator VARCHAR(50), in_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 销售记录表 CREATE TABLE sale_record ( id INT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL, quantity INT NOT NULL, total_price DECIMAL(10,2), sale_time DATETIME DEFAULT CURRENT_TIMESTAMP );

建表时有两个细节容易翻车:一是金额字段必须用 DECIMAL 而不是 FLOAT,否则累加计算会出现精度丢失,我见过有人用 DOUBLE 存销售总额,月底对账差了三分钱查了一下午;二是库存表单独拆出来,不要和商品表合并,因为库存更新频率远高于商品信息修改,拆开后锁粒度更小,并发扣减时不容易互相阻塞。

对应的 JavaBean 就按表结构一一映射,字段名保持驼峰命名,和数据库下划线命名通过 MyBatis 或手写 JDBC 做转换。这里以 Product 为例:

package com.supermarket.entity; import java.math.BigDecimal; import java.util.Date; public class Product { private Integer id; private String name; private String category; private String unit; private BigDecimal purchasePrice; // 金额用 BigDecimal private BigDecimal salePrice; private Integer supplierId; private Date createTime; // 省略 getter/setter,实际项目中必须生成 // 注意:BigDecimal 的 getter 返回类型必须是 BigDecimal }

JavaBean 的规范要求:私有字段、无参构造、getter/setter、实现 Serializable 接口。很多新手会漏掉无参构造,导致在 Servlet 里用反射封装请求参数时直接抛 InstantiationException。另外,日期字段建议统一用 java.util.Date,在 JSP 页面展示时用 fmt 标签格式化,不要试图在 Bean 里存字符串。

2.2 Servlet 作为控制器的请求分发与参数封装

Servlet 在进销存系统里承担的是调度中心角色。用户从 JSP 页面提交表单,请求先到 Servlet,Servlet 决定调用哪个 Service 方法、封装什么数据、跳转到哪个页面。我习惯用一个 BaseServlet 做统一分发,避免为每个功能写一个 Servlet 类导致 web.xml 臃肿不堪。

package com.supermarket.servlet; import javax.servlet.ServletException; import javax.servlet.http.*; import java.io.IOException; import java.lang.reflect.Method; public class BaseServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 设置编码,必须在获取参数之前 req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); // 通过 method 参数决定调用哪个方法 String methodName = req.getParameter("method"); if (methodName == null || methodName.trim().isEmpty()) { methodName = "index"; // 默认方法 } try { // 反射获取当前类的指定方法 Method method = this.getClass().getDeclaredMethod( methodName, HttpServletRequest.class, HttpServletResponse.class); method.setAccessible(true); // 返回值作为跳转路径 String result = (String) method.invoke(this, req, resp); if (result != null) { if (result.startsWith("redirect:")) { resp.sendRedirect(req.getContextPath() + result.substring("redirect:".length())); } else { req.getRequestDispatcher("/WEB-INF/jsp/" + result + ".jsp") .forward(req, resp); } } } catch (NoSuchMethodException e) { resp.sendError(404, "方法不存在: " + methodName); } catch (Exception e) { throw new ServletException(e); } } }

这个 BaseServlet 的关键点在于:用反射替代 if-else 链,新增功能只需要加一个方法,不用改分发逻辑。参数封装方面,我一般会写一个 BeanUtils 工具类,把 request.getParameterMap() 里的数据按字段名反射注入到 JavaBean 中。注意类型转换——字符串到 Integer、BigDecimal、Date 都要做异常处理,否则用户输入非法字符时页面直接 500。

public static <T> T populate(Class<T> clazz, HttpServletRequest req) { T bean = clazz.newInstance(); Map<String, String[]> map = req.getParameterMap(); for (Map.Entry<String, String[]> entry : map.entrySet()) { String fieldName = entry.getKey(); String value = entry.getValue()[0]; if (value == null || value.trim().isEmpty()) continue; try { Field field = clazz.getDeclaredField(fieldName); field.setAccessible(true); field.set(bean, convertType(field.getType(), value)); } catch (NoSuchFieldException e) { // 忽略请求中多余的参数 } } return bean; }

这段代码在实际项目里要加日志,记录哪些字段被忽略了,否则调试时发现某个参数没传进去会找半天。另外,convertType 方法里对 Date 类型的处理要支持多种格式,用户可能输入 yyyy-MM-dd 也可能输入 yyyy/MM/dd。

3. 库存扣减与并发控制:进销存系统最容易翻车的地方

3.1 超卖问题的三种解决方案对比

库存扣减是进销存系统的命门。假设两个收银员同时卖同一件商品,库存只剩 1 件,如果代码写的是「先查再减」,必然出现超卖。我见过最离谱的翻车现场是促销活动时库存显示 100 件,实际卖出 130 件,最后发不出货被投诉。

常见做法有三种:第一种是数据库行锁,在事务里用SELECT ... FOR UPDATE锁住库存行再更新;第二种是乐观锁,用版本号或库存数量做 CAS 更新;第三种是在应用层用 synchronized 或 ReentrantLock 加锁。三种方案各有适用场景,下面用表格对比。

方案实现方式优点缺点适用场景
数据库行锁SELECT FOR UPDATE强一致,实现简单锁等待时间长,高并发下连接池容易打满并发量低,库存操作不频繁
乐观锁UPDATE ... WHERE quantity >= n无锁等待,吞吐量高失败需重试,业务逻辑要处理并发中等,冲突概率低
应用层锁synchronized/Lock不依赖数据库只适用于单机部署单机小系统

我一般会选乐观锁,因为进销存系统的并发冲突其实没那么高,但一旦冲突必须保证不超卖。具体 SQL 写法:

UPDATE inventory SET quantity = quantity - #{num}, update_time = NOW() WHERE product_id = #{productId} AND quantity >= #{num};

执行后检查 affectedRows,如果返回 0 说明库存不足,抛业务异常回滚事务。这个方案不需要显式加锁,数据库自动保证原子性。注意 WHERE 条件里的quantity >= #{num}不能漏,否则库存会变成负数。

3.2 事务边界与回滚策略

入库和出库操作必须放在同一个事务里。比如销售出库,要同时做三件事:扣减库存、插入销售记录、更新商品销量统计。任何一步失败都要整体回滚,否则数据就对不上了。

public void sale(Integer productId, Integer quantity, BigDecimal totalPrice) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 扣减库存(乐观锁) String sql1 = "UPDATE inventory SET quantity = quantity - ? " + "WHERE product_id = ? AND quantity >= ?"; PreparedStatement ps1 = conn.prepareStatement(sql1); ps1.setInt(1, quantity); ps1.setInt(2, productId); ps1.setInt(3, quantity); int rows = ps1.executeUpdate(); if (rows == 0) { throw new RuntimeException("库存不足,扣减失败"); } // 2. 插入销售记录 String sql2 = "INSERT INTO sale_record(product_id, quantity, total_price) " + "VALUES(?, ?, ?)"; PreparedStatement ps2 = conn.prepareStatement(sql2); ps2.setInt(1, productId); ps2.setInt(2, quantity); ps2.setBigDecimal(3, totalPrice); ps2.executeUpdate(); conn.commit(); // 全部成功才提交 } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } throw new RuntimeException("销售出库失败", e); } finally { DBUtil.close(conn); } }

血泪经验:conn.setAutoCommit(false)之后,如果中间抛异常但没 rollback,连接归还到连接池时事务还挂着,下一个拿到这个连接的请求会莫名其妙看到未提交的数据。所以 finally 块里一定要确保连接被正确关闭或回滚。另外,事务方法不要嵌套调用,否则内层事务提交后外层回滚不了,数据就脏了。

4. JSP 页面渲染与表单交互的避坑指南

4.1 用 JSTL 替代脚本片段做数据展示

JSP 里写 Java 代码(<% %>)是万恶之源,页面稍微复杂一点就变成意大利面条。我接手过一个项目,一个 JSP 文件里嵌了三百多行 Java 代码,改一个字段名要翻半天。正确做法是用 JSTL 和 EL 表达式,页面只负责展示,逻辑全部交给 Servlet。

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <table border="1"> <tr> <th>商品名称</th> <th>库存数量</th> <th>进货价</th> <th>销售价</th> <th>操作</th> </tr> <c:forEach items="${productList}" var="p"> <tr> <td>${p.name}</td> <td> <c:choose> <c:when test="${p.quantity <= p.warnThreshold}"> <span style="color:red;">${p.quantity} (库存预警)</span> </c:when> <c:otherwise>${p.quantity}</c:otherwise> </c:choose> </td> <td><fmt:formatNumber value="${p.purchasePrice}" pattern="#,##0.00"/></td> <td><fmt:formatNumber value="${p.salePrice}" pattern="#,##0.00"/></td> <td> <a href="product?method=edit&id=${p.id}">编辑</a> <a href="product?method=delete&id=${p.id}" onclick="return confirm('确认删除?')">删除</a> </td> </tr> </c:forEach> </table>

注意 EL 表达式里的${p.quantity <= p.warnThreshold},JSTL 支持直接比较,不需要写<%= %>。金额格式化用 fmt 标签,避免在页面里手动拼字符串。删除操作加 confirm 确认框是基本素养,我见过有人误点删除把整个商品库清空的。

4.2 表单提交与中文乱码的根治方法

中文乱码是 JSP+Servlet 项目的经典问题,根源在于请求和响应的编码不一致。POST 请求乱码用req.setCharacterEncoding("UTF-8")解决,但必须在获取任何参数之前调用。GET 请求乱码更麻烦,因为参数在 URL 里,Tomcat 默认用 ISO-8859-1 解码。

根治方案分两步:第一,在 server.xml 的 Connector 标签里加URIEncoding="UTF-8";第二,如果没法改服务器配置,就在代码里手动转码:

String name = request.getParameter("name"); if (name != null) { name = new String(name.getBytes("ISO-8859-1"), "UTF-8"); }

但手动转码只适用于 GET,POST 用这招反而会乱。我一般会在过滤器里统一处理:

public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); } }

过滤器在 web.xml 里配置<url-pattern>/*</url-pattern>,所有请求都会经过。注意:setCharacterEncoding对 GET 请求无效,GET 乱码只能靠改服务器配置或手动转码。另外,响应头里的charset=UTF-8要写在getWriter()之前,否则不生效。

5. 常见问题排查:从 500 错误到数据对不上的排查路径

5.1 页面报 500 但控制台没日志怎么办

现象:点击提交后页面直接 500,Tomcat 控制台只输出一行Servlet.service() for servlet threw exception,没有堆栈。原因通常是异常被吞了,或者日志配置把输出重定向到了文件。解决:在 BaseServlet 的 catch 块里加e.printStackTrace(),确保异常栈能打到控制台。另外检查 web.xml 里有没有配置<error-page>,有些项目会把 500 重定向到一个友好页面,反而掩盖了真实错误。

5.2 数据库连接池耗尽:连接未关闭的排查

现象:系统运行一段时间后所有请求都卡住,日志显示Cannot get a connection, pool exhausted。原因:某个方法获取连接后没有在 finally 里关闭,或者事务回滚后连接状态异常。解决:用DBUtil.close(conn, ps, rs)统一关闭,确保 finally 块里调用。另外可以在连接池配置里加removeAbandoned="true"和removeAbandonedTimeout="60",自动回收超时未关闭的连接,但这只是兜底,根本问题还是要修代码。

5.3 库存数量对不上:并发扣减的隐蔽 Bug

现象:盘点时发现库存数量和出入库记录汇总对不上,差了几件。原因:扣减库存时用了「先查再减」的逻辑,两个请求同时查到库存 10,各自减 1 后写回 9,实际应该减到 8。解决:改用乐观锁UPDATE ... WHERE quantity >= n,或者用SELECT ... FOR UPDATE锁行。检查代码里所有涉及库存变更的地方,确保没有遗漏。

5.4 JSP 页面修改后不生效:缓存与编译路径问题

现象:改了 JSP 文件,刷新浏览器还是旧内容。原因:Tomcat 会缓存编译后的 JSP Servlet,或者浏览器缓存了静态资源。解决:在 Tomcat 的 context.xml 里加<Context reloadable="true">,开发阶段开启自动重载。浏览器端用 Ctrl+F5 强制刷新。另外,如果 JSP 放在 WEB-INF 下,只能通过 Servlet 转发访问,直接输 URL 会 404,这是正常的安全设计。

5.5 中文参数传到 Servlet 变成问号

现象:表单里输入「可口可乐」,Servlet 里request.getParameter("name")拿到的是「?????」。原因:请求编码不是 UTF-8,或者数据库连接 URL 没指定字符集。解决:检查三处——EncodingFilter 是否生效、数据库 URL 是否加了?useUnicode=true&characterEncoding=UTF-8、MySQL 表的字符集是否是 utf8mb4。三处都对了,中文就不会出问题。

6. 进阶技巧:用监听器做库存预警与系统初始化

系统上线后,运营最关心的是哪些商品快断货了。与其让用户手动点「库存查询」,不如在应用启动时加载预警阈值,在库存变更时自动检查并记录预警日志。用 ServletContextListener 可以在 Web 应用启动时执行初始化代码,比如从数据库加载所有商品的预警阈值到内存缓存。

public class InventoryWarnListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { // 应用启动时加载预警配置 Map<Integer, Integer> warnMap = new HashMap<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement( "SELECT product_id, warn_threshold FROM inventory"); ResultSet rs = ps.executeQuery()) { while (rs.next()) { warnMap.put(rs.getInt("product_id"), rs.getInt("warn_threshold")); } } catch (SQLException e) { e.printStackTrace(); } sce.getServletContext().setAttribute("warnMap", warnMap); } @Override public void contextDestroyed(ServletContextEvent sce) { // 清理资源,实际项目中可以关闭连接池 } }

这个监听器把预警阈值缓存在 ServletContext 里,所有 Servlet 都能读取。库存扣减后,拿当前库存和阈值比较,如果低于阈值就往预警表里插一条记录。注意:ServletContext 里的数据是全局共享的,多线程读写要加同步,或者用 ConcurrentHashMap。

另一个实用技巧是用 Filter 做权限校验。进销存系统里,普通收银员只能做出库,管理员才能改进货价。在 Filter 里检查 session 中的用户角色,不满足条件的请求直接重定向到登录页。这样不用在每个 Servlet 里写重复的权限判断代码。

public class AuthFilter implements Filter { private static final Set<String> ADMIN_ONLY = new HashSet<>(Arrays.asList( "product?method=delete", "product?method=editPrice", "supplier?method=save" )); public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); if (session == null || session.getAttribute("user") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } String uri = request.getRequestURI(); String query = request.getQueryString(); String fullPath = query == null ? uri : uri + "?" + query; User user = (User) session.getAttribute("user"); if ("admin".equals(user.getRole())) { chain.doFilter(req, resp); // 管理员放行 } else { // 普通用户检查是否访问了管理员接口 for (String adminPath : ADMIN_ONLY) { if (fullPath.contains(adminPath)) { response.sendError(403, "无权限访问"); return; } } chain.doFilter(req, resp); } } }

这个 Filter 的坑在于:getRequestURI()返回的是不含查询字符串的路径,所以要手动拼上getQueryString()。另外,ADMIN_ONLY 集合里的路径要和实际请求的 URL 格式匹配,建议用 Ant 风格路径或者正则,不要用 contains 做模糊匹配,否则容易误伤。

最后说一个我自己的习惯:每次改完库存相关的代码,一定会手动模拟并发测试。开两个浏览器窗口,同时提交同一个商品的出库请求,看最终库存是不是只减了一次。这个习惯帮我拦住了至少三次潜在的线上事故。进销存系统不怕功能少,就怕数据不准,库存对不上比页面丑严重一百倍。希望帮到你。

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

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

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

立即咨询