资讯动态

JSP+Servlet+JavaBean进销存系统实战:三层架构与库存并发控制

发布时间:2026/10/9 2:47:08 来源:尧图企业网站定制
简介这是一套面向计算机专业学生与Java Web初学者的超市进销存管理系统源码采用JSPServletJavaBean经典三层架构配合MySQL数据库实现商品、库存、订单等核心业务模块适合用作毕业设计、课程设计或框架入门练手项目。压缩包共134个文件约6.94MB其中14个java源文件与14个class文件构成后台业务逻辑9个jsp页面负责视图展示16个js与15个css支撑前端交互与样式另有6个jar依赖包及xml、properties等配置文件目录结构清晰便于按模块阅读与二次开发。资源中的源码均经过本地编译验证按文档配置好环境即可运行项目难度适中内容经助教老师审定。目前已有68人学习下载读者可借此掌握Servlet请求处理、JavaBean数据封装、数据库连接与增删改查等实战技能并参考ExcelUtil等工具类理解数据导入导出思路遇到问题也可私信作者获取解答。1. JSPServletJavaBean 进销存系统为什么老架构反而更适合练手很多刚入行的兄弟一提到 JSPServletJavaBean 就皱眉觉得这玩意儿是上个时代的产物现在都 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 页面提交表单请求先到 ServletServlet 决定调用哪个 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;charsetUTF-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(ClassT clazz, HttpServletRequest req) { T bean clazz.newInstance(); MapString, String[] map req.getParameterMap(); for (Map.EntryString, 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 prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % table border1 tr th商品名称/th th库存数量/th th进货价/th th销售价/th th操作/th /tr c:forEach items${productList} varp tr td${p.name}/td td c:choose c:when test${p.quantity p.warnThreshold} span stylecolor:red;${p.quantity} (库存预警)/span /c:when c:otherwise${p.quantity}/c:otherwise /c:choose /td tdfmt:formatNumber value${p.purchasePrice} pattern#,##0.00//td tdfmt:formatNumber value${p.salePrice} pattern#,##0.00//td td a hrefproduct?methodeditid${p.id}编辑/a a hrefproduct?methoddeleteid${p.id} onclickreturn confirm(确认删除)删除/a /td /tr /c:forEach /table注意 EL 表达式里的${p.quantity p.warnThreshold}JSTL 支持直接比较不需要写% %。金额格式化用 fmt 标签避免在页面里手动拼字符串。删除操作加 confirm 确认框是基本素养我见过有人误点删除把整个商品库清空的。4.2 表单提交与中文乱码的根治方法中文乱码是 JSPServlet 项目的经典问题根源在于请求和响应的编码不一致。POST 请求乱码用req.setCharacterEncoding(UTF-8)解决但必须在获取任何参数之前调用。GET 请求乱码更麻烦因为参数在 URL 里Tomcat 默认用 ISO-8859-1 解码。根治方案分两步第一在 server.xml 的 Connector 标签里加URIEncodingUTF-8第二如果没法改服务器配置就在代码里手动转码String name request.getParameter(name); if (name ! null) { name new String(name.getBytes(ISO-8859-1), UTF-8); }但手动转码只适用于 GETPOST 用这招反而会乱。我一般会在过滤器里统一处理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;charsetUTF-8); chain.doFilter(request, response); } }过滤器在 web.xml 里配置url-pattern/*/url-pattern所有请求都会经过。注意setCharacterEncoding对 GET 请求无效GET 乱码只能靠改服务器配置或手动转码。另外响应头里的charsetUTF-8要写在getWriter()之前否则不生效。5. 常见问题排查从 500 错误到数据对不上的排查路径5.1 页面报 500 但控制台没日志怎么办现象点击提交后页面直接 500Tomcat 控制台只输出一行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 块里调用。另外可以在连接池配置里加removeAbandonedtrue和removeAbandonedTimeout60自动回收超时未关闭的连接但这只是兜底根本问题还是要修代码。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 reloadabletrue开发阶段开启自动重载。浏览器端用 CtrlF5 强制刷新。另外如果 JSP 放在 WEB-INF 下只能通过 Servlet 转发访问直接输 URL 会 404这是正常的安全设计。5.5 中文参数传到 Servlet 变成问号现象表单里输入「可口可乐」Servlet 里request.getParameter(name)拿到的是「?????」。原因请求编码不是 UTF-8或者数据库连接 URL 没指定字符集。解决检查三处——EncodingFilter 是否生效、数据库 URL 是否加了?useUnicodetruecharacterEncodingUTF-8、MySQL 表的字符集是否是 utf8mb4。三处都对了中文就不会出问题。6. 进阶技巧用监听器做库存预警与系统初始化系统上线后运营最关心的是哪些商品快断货了。与其让用户手动点「库存查询」不如在应用启动时加载预警阈值在库存变更时自动检查并记录预警日志。用 ServletContextListener 可以在 Web 应用启动时执行初始化代码比如从数据库加载所有商品的预警阈值到内存缓存。public class InventoryWarnListener implements ServletContextListener { Override public void contextInitialized(ServletContextEvent sce) { // 应用启动时加载预警配置 MapInteger, 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 SetString ADMIN_ONLY new HashSet(Arrays.asList( product?methoddelete, product?methodeditPrice, supplier?methodsave )); 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 做模糊匹配否则容易误伤。最后说一个我自己的习惯每次改完库存相关的代码一定会手动模拟并发测试。开两个浏览器窗口同时提交同一个商品的出库请求看最终库存是不是只减了一次。这个习惯帮我拦住了至少三次潜在的线上事故。进销存系统不怕功能少就怕数据不准库存对不上比页面丑严重一百倍。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑