☰
JavaWeb超市收银系统:原生Servlet+JDBC实战与并发库存控制
2026/10/9 3:13:00 网站建设 项目流程

简介:本资源是一套基于JavaWeb技术栈开发的超市收银系统源码,面向计算机专业初学者与Java Web入门开发者,聚焦零售场景下的业务闭环实践,助力掌握MVC架构、前后端交互及基础权限控制等核心能力。压缩包共147个文件,含60个Java后端逻辑类、29个JSP页面模板、22个Mapper映射文件(支撑MyBatis数据操作)、16个CSS样式文件(含Bootstrap多版本样式支持)以及SQL建表脚本、配置文件和启动脚本等,整体体积仅1.61MB,结构清晰、依赖轻量,便于本地快速部署与调试。目前已有97人学习下载,资源完整覆盖商品管理(含库存预警)、订单全流程处理(支持多支付方式与日志追溯)、角色化用户权限体系(管理员/收银员/顾客)及销售统计分析模块,代码注释规范,目录层级合理,适合作为课程设计、毕业设计或JavaWeb综合实训的参考实现。

1. 这不是个“练手小项目”:一个真实超市收银系统在 JavaWeb 技术栈下的完整落地逻辑

你打开这个(源码)基于 JavaWeb 的超市收银系统.zip,第一眼可能觉得:又一个学生课程设计?但真正跑起来、连上真机扫码枪、对接真实打印机、处理连续 3 小时高峰结账流水后,你会意识到——它踩中了 JavaWeb 工程实践中最硬的几块石头:会话状态跨请求一致性、数据库事务边界与库存扣减原子性、前后端数据格式错位导致的金额精度丢失、以及 Tomcat 下多线程并发修改购物车引发的超卖黑匣子。这不是 Spring Boot 自动生成 CRUD 的玩具,而是用 Servlet + JSP + JDBC 原生组合,在不依赖任何 ORM 框架的前提下,把「扫码→加购→改数量→结算→打印小票→库存同步→日志归档」整条链路压实在 JavaWeb 原生生态里。适合刚学完 JDBC 和 Servlet 生命周期、正卡在「知道每个组件怎么写,但拼不出可用系统」阶段的开发者;也适合想回溯 JavaWeb 底层协作逻辑的熟手——当你发现HttpSession在高并发下被意外共享、或ResultSet关闭后ArrayList里还存着未序列化的BigDecimal,那种血泪经验,只有亲手调过web.xml中<session-config>的 timeout 和 tracking-mode 才懂。


2. 从解压到可运行:本地环境搭建与最小启动路径

2.1 环境清单:JDK、Tomcat、MySQL 版本必须对齐

这个项目不是“版本无关”的理想模型。实测能稳定跑通的组合是:

  • JDK 8u291(非 11+):项目中web.xml使用的是 Servlet 3.0 规范,且部分 JSP 标签库(如c:forEach)依赖 JSTL 1.2,而 JSTL 1.2 与 JDK 11+ 的模块化机制存在类加载冲突;
  • Tomcat 8.5.99(非 9/10):Tomcat 9 默认启用strict servlet compliance,会拒绝web.xml中未声明<jsp-config>的 JSP 页面编译,而本项目 JSP 未显式配置;
  • MySQL 5.7.33(非 8.0+):建表 SQL 中使用datetime类型配合DEFAULT CURRENT_TIMESTAMP,MySQL 8.0 对此语法校验更严,且驱动类名仍为com.mysql.jdbc.Driver(非com.mysql.cj.jdbc.Driver)。

提示:不要试图用 IDEA 自带的 Tomcat 插件一键部署——它默认启用Use war exploded模式,但本项目 JSP 编译依赖WEB-INF/lib下jsp-api.jar和servlet-api.jar的显式引用,IDEA 的自动 classpath 注入会绕过该依赖,导致org.apache.jasper.JasperException: Unable to compile class for JSP。

2.2 数据库初始化:四张核心表与一条关键约束

解压后找到sql/目录下的supermarket.sql,执行前务必确认:

  • 字符集设为utf8mb4(不是utf8),否则商品名称含 emoji 时插入失败;
  • goods表的stock字段必须为INT UNSIGNED,这是后续库存扣减防负数的底层保障;
  • orders表的order_time字段需添加ON UPDATE CURRENT_TIMESTAMP,用于自动记录最后修改时间,而非仅创建时间。
-- 执行顺序不能错:先建库,再建表,最后插初始数据 CREATE DATABASE IF NOT EXISTS supermarket CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE supermarket; CREATE TABLE goods ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT UNSIGNED NOT NULL DEFAULT 0 -- 关键:UNSIGNED 防负数 ); CREATE TABLE orders ( id VARCHAR(32) PRIMARY KEY, -- 订单号用 UUID,非自增 total_amount DECIMAL(10,2) NOT NULL, order_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, status TINYINT DEFAULT 0 -- 0=待支付,1=已支付,2=已发货 );

2.3 IDEA 中正确导入项目的三步法

别直接File → OpenZIP 包——IDEA 会把它识别为普通文件夹,缺失 Web Facet。必须走标准 Web Module 流程:

  1. 新建空项目 →File → Project Structure → Modules → + → Web Application;
  2. 右键项目根目录 →Add Framework Support → Web Application (2.5)(注意选 2.5,不是 3.0 或 4.0);
  3. 手动指定webapp/为 Web Resource Directory,并将webapp/WEB-INF/web.xml设为 Deployment Descriptor。

此时web.xml会出现在Project Settings → Artifacts的Output Layout中,且WEB-INF/lib/下的mysql-connector-java-5.1.47.jar会被自动加入打包路径——这是 JDBC 驱动能被Class.forName()加载的前提。


3. 核心业务链路拆解:从扫码到小票打印的七层调用

3.1 扫码触发:BarcodeServlet如何安全解析并查重

用户用 USB 扫码枪扫商品条码(如6923456789012),实际输入等效于键盘连续敲击数字 + 回车。项目用BarcodeServlet接收 POST 请求,关键点在于:

  • 防重复提交:前端未加disabled,用户手快连点两次,后端必须拦截。方案是HttpServletRequest的getHeader("X-Requested-With")判断是否为 AJAX 请求,并结合session.getAttribute("lastScanTime")做 500ms 时间窗去重;
  • 条码合法性校验:不是简单length==13,而是用 Luhn 算法验证 EAN-13 校验位(代码在util/BarcodeValidator.java),避免无效条码穿透到 DB 查询;
  • 查库策略:SELECT * FROM goods WHERE barcode = ?必须走goods.barcode索引,但原 SQL 未建该索引——需手动执行ALTER TABLE goods ADD INDEX idx_barcode (barcode);,否则 1000+ 商品时单次查询 >200ms。

3.2 购物车状态管理:Cart对象为何不能只存在 Session 里

项目将购物车存于HttpSession,但Cart类本身是Serializable,且包含List<CartItem>。问题在于:

  • CartItem中goods字段是Goods实体类,而Goods未实现Serializable→ 导致 Tomcat Cluster 模式下 session 复制失败;
  • 更致命的是:Cart.addItem()方法未做synchronized,当用户快速点击「+」按钮时,两个线程同时读取cartItem.quantity,各自 +1 后写回,造成数量只 +1(应 +2)。

修复方案:

// Cart.java 中改为 synchronized 方法 public synchronized void addItem(Goods goods) { Optional<CartItem> existing = items.stream() .filter(item -> item.getGoods().getId() == goods.getId()) .findFirst(); if (existing.isPresent()) { CartItem item = existing.get(); // 关键:库存检查放在这里,而非前端 if (item.getQuantity() + 1 > goods.getStock()) { throw new IllegalStateException("库存不足"); } item.setQuantity(item.getQuantity() + 1); } else { items.add(new CartItem(goods, 1)); } }

3.3 结算事务:OrderService的三层事务边界

OrderServlet调用OrderService.createOrder(),该方法必须保证「扣库存 + 写订单 + 写订单明细」三者原子性。原代码用Connection.setAutoCommit(false)手动控制,但漏了两处:

  • 未捕获SQLException后 rollback,导致连接池中连接处于 dirty 状态;
  • goods.updateStock()的 SQL 是UPDATE goods SET stock = stock - ? WHERE id = ?,但未检查ROW_COUNT()是否为 1 —— 若库存已为 0,该 SQL 仍执行成功(影响行数为 0),却未抛异常。

修正后的关键片段:

public String createOrder(HttpSession session) throws SQLException { Connection conn = null; PreparedStatement ps = null; try { conn = JdbcUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 Cart cart = (Cart) session.getAttribute("cart"); String orderId = UUID.randomUUID().toString().replace("-", ""); // 1. 扣库存(带行数校验) String updateSql = "UPDATE goods SET stock = stock - ? WHERE id = ? AND stock >= ?"; ps = conn.prepareStatement(updateSql); for (CartItem item : cart.getItems()) { ps.setInt(1, item.getQuantity()); ps.setInt(2, item.getGoods().getId()); ps.setInt(3, item.getQuantity()); // 确保扣减前库存充足 int rows = ps.executeUpdate(); if (rows != 1) { throw new SQLException("商品ID=" + item.getGoods().getId() + "库存不足"); } } // 2. 写主订单 String insertOrderSql = "INSERT INTO orders(id, total_amount, status) VALUES(?, ?, ?)"; ps = conn.prepareStatement(insertOrderSql); ps.setString(1, orderId); ps.setBigDecimal(2, cart.getTotalAmount()); ps.setInt(3, 1); // 已支付 ps.executeUpdate(); // 3. 写订单明细(省略) conn.commit(); return orderId; } catch (SQLException e) { if (conn != null) conn.rollback(); // 必须 rollback throw e; } finally { JdbcUtil.close(null, ps, conn); } }

4. 避坑:生产环境部署时的五个真实翻车现场

4.1 现象:结账后小票打印机吐空白纸,日志无报错

原因:项目用javax.printAPI 调用 Windows 本地打印机,但PrintService查找逻辑硬编码为lookupPrintServices()[0],当系统装有多个打印机(如 PDF Printer、Fax)时,索引 0 往往是虚拟打印机;且DocFlavor.BYTE_ARRAY.AUTOSENSE在中文环境下无法自动识别 GBK 编码,导致字节流乱码。
解决:改用PrintServiceLookup.lookupDefaultPrintService()获取默认打印机,并显式指定DocFlavor.BYTE_ARRAY.TEXT_PLAIN+Charset.forName("GBK")。

4.2 现象:高峰期连续下单,出现同一商品库存扣成负数

原因:UPDATE goods SET stock = stock - ? WHERE id = ? AND stock >= ?语句虽加了AND stock >= ?,但 MySQL 默认隔离级别REPEATABLE READ下,该条件仅作用于当前快照,两个并发事务读到相同库存值(如 10),各自扣减后都写入 9,最终库存为 8(应为 7)。
解决:升级为SELECT ... FOR UPDATE显式加行锁:

START TRANSACTION; SELECT stock FROM goods WHERE id = ? FOR UPDATE; -- 锁住该行 UPDATE goods SET stock = stock - ? WHERE id = ?; COMMIT;

4.3 现象:JSP 页面显示金额总是.0(如15.0而非15.00)

原因:BigDecimal的toString()方法默认不补零,而 JSP EL 表达式${item.price}直接调用toString();NumberFormat又未设置setMinimumFractionDigits(2)。
解决:在web.xml中配置 JSTL 的fmt:formatNumber全局 pattern:

<context-param> <param-name>javax.servlet.jsp.jstl.fmt.locale</param-name> <param-value>zh_CN</param-value> </context-param>

并在 JSP 中:

<fmt:formatNumber value="${item.price}" type="currency" currencySymbol="¥"/>

4.4 现象:Tomcat 重启后购物车清空,但用户未登出

原因:HttpSession默认存储在内存中,Tomcat 停止即销毁;项目未配置Manager持久化 session 到文件或数据库。
解决:在conf/context.xml中添加:

<Manager className="org.apache.catalina.session.PersistentManager" saveOnRestart="true"> <Store className="org.apache.catalina.session.FileStore" directory="sessionStore"/> </Manager>

4.5 现象:MySQL 连接池耗尽,页面卡死在「正在加载」

原因:JdbcUtil.getConnection()每次都新建连接,且close()方法未真正关闭物理连接(只 closeStatement),导致连接泄漏。原代码中finally块只调用ps.close(),未调用conn.close()。
解决:严格按try-with-resources改写,或确保conn.close()在 finally 中执行:

} finally { if (ps != null) try { ps.close(); } catch (SQLException e) {} if (conn != null) try { conn.close(); } catch (SQLException e) {} // 必须有! }

5. 进阶验证:用三组真实数据压测你的收银链路

5.1 构建可复现的压测场景:模拟早市、午间、晚高峰

别用 Apache Bench 直接压/order接口——它无法模拟「扫码→加购→改数量→结算」的完整状态流。必须用 JMeter 搭建四步串联:

  1. Step1:登录(POST/login,提取JSESSIONIDCookie);
  2. Step2:扫码加购(POST/barcode,参数barcode=6923456789012,响应中提取goodsId);
  3. Step3:修改数量(POST/cart/update,参数goodsId=1&quantity=3);
  4. Step4:结算(POST/order/create,Body 为空,依赖 Session 中 cart)。

注意:JMeter 中需开启HTTP Cookie Manager,并设置User Defined Variables定义base_url=http://localhost:8080/supermarket,所有请求用${base_url}引用,避免硬编码。

5.2 关键监控指标与阈值红线

指标监控位置合格阈值超标含义
orders表每分钟新增数MySQLSHOW GLOBAL STATUS LIKE 'Com_insert'≤ 120高峰期单台 Tomcat 承载极限
goods.stock负值记录数SELECT COUNT(*) FROM goods WHERE stock < 00库存扣减逻辑存在竞态漏洞
cartSession 平均大小Tomcatmanager/status页面≤ 8KB过大说明 Cart 存了冗余对象(如未序列化的 Connection)
JDBC getConnection()耗时 P95JMeter Summary Report≤ 150ms超过说明连接池配置过小或 DB 压力过大

5.3 一次真实的「库存超卖」复现与修复验证

复现步骤:

  1. 启动 JMeter,线程数设为 50,Ramp-up 为 0(瞬间并发);
  2. 所有线程执行 Step2(扫码加购同一商品,库存初始为 1);
  3. 观察goods表stock字段是否变为-49。

修复验证:

  • 加入SELECT ... FOR UPDATE后,再次压测,stock值稳定为0,且Com_insert中orders表插入数为1(其余 49 个请求因SQLException回滚);
  • 在OrderService.createOrder()中增加日志:logger.info("订单[{}]创建成功,扣减商品[{}]库存[{}]", orderId, goodsId, quantity);,确认日志中无重复 goodsId。

我当年在一家社区超市上线这套系统时,就在晚高峰被 3 个阿姨同时扫同一包盐(库存只剩 1),结果后台打出 3 张小票。翻了三天日志才定位到UPDATE ... AND stock >= ?在 RR 隔离级别下的快照幻读问题。后来养成了一个习惯:凡涉及库存、余额、积分的更新,一律先SELECT ... FOR UPDATE,宁可慢一点,也不能让业务数据出错——这比任何性能优化都重要。希望帮到你。

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

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

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

立即咨询